Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:02:58 +00:00
co-authored by Claude Fable 5.1
parent 9ca4cee341
commit c7aea8d419
295 changed files with 90146 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules/
data/
*.sqlite
*.sqlite-*
*.log
*.tmp
.DS_Store
/dist/
/modules/webmaker/data/
/modules/arimail/data/
/test/.tmp/
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
# 도입 소스 기록(IMPORTED_SOURCES)
생성: 2026-10-26T10:58:20.537Z — `node scripts/import-sources.mjs` 가 만든다. 파일별 '수정' 은 원본 커밋과 현재 파일의 해시 비교 결과이며, 수정 이유는 VERSION_HISTORY.md 의 "도입 소스" 절과 각 파일 머리 주석의 `[Ariweb Suite 변경]` 표시로 설명한다.
## webmaker
- 원본 저장소: https://source.ibit.kr/OZ-Group/WebHosting_AI_agent
- 고정 커밋: `3b10859884ef2fd0f2af11934ab6c16370fd64b8`
- 대상 경로: `modules/webmaker/`
- 파일 수: 176 (수정 0)
- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.
| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |
|---|---|---|---|
| README.md | modules/webmaker/README.md | 그대로 | a7aa1eb7a6fdae8d |
| deploy/deploy.sh | modules/webmaker/deploy/deploy.sh | 그대로 | 6f016f8dcf56a3b2 |
| deploy/front-proxy-webmaker.conf | modules/webmaker/deploy/front-proxy-webmaker.conf | 그대로 | 0ec1641b554a3b50 |
| deploy/install-arimail.sh | modules/webmaker/deploy/install-arimail.sh | 그대로 | d8626ef07df49738 |
| deploy/install-gitea.sh | modules/webmaker/deploy/install-gitea.sh | 그대로 | 199631e74ea4f62c |
| deploy/install-services.sh | modules/webmaker/deploy/install-services.sh | 그대로 | f3b507512b501fd8 |
| deploy/install.sh | modules/webmaker/deploy/install.sh | 그대로 | 58ba0c250620b638 |
| deploy/nginx-webhosting.conf | modules/webmaker/deploy/nginx-webhosting.conf | 그대로 | 0058f1dafd622bc5 |
| deploy/nginx-wmstats.conf | modules/webmaker/deploy/nginx-wmstats.conf | 그대로 | cf4069704f65ef73 |
| deploy/remote-install.sh | modules/webmaker/deploy/remote-install.sh | 그대로 | 66457327d0ef1b3c |
| deploy/remote-update.sh | modules/webmaker/deploy/remote-update.sh | 그대로 | fb0cf663fc687542 |
| deploy/webhosting-agent.service | modules/webmaker/deploy/webhosting-agent.service | 그대로 | 77259a206ca5530b |
| deploy/webmaker-setup.service | modules/webmaker/deploy/webmaker-setup.service | 그대로 | 9bbcbb9239d24ef9 |
| docs/ARIMAIL.md | modules/webmaker/docs/ARIMAIL.md | 그대로 | 1e326aa2252621cf |
| docs/CALENDAR.md | modules/webmaker/docs/CALENDAR.md | 그대로 | 1d12cd1720897254 |
| docs/CONFIG.md | modules/webmaker/docs/CONFIG.md | 그대로 | 13c26d45b5002b48 |
| docs/DOMAIN-DNS-PROVIDERS.md | modules/webmaker/docs/DOMAIN-DNS-PROVIDERS.md | 그대로 | c5ee35c850c7c728 |
| docs/DOMAIN-SETUP.md | modules/webmaker/docs/DOMAIN-SETUP.md | 그대로 | 83f0e447cd82e3dd |
| docs/FIRST-INSTALL.md | modules/webmaker/docs/FIRST-INSTALL.md | 그대로 | 642ce20eb96cabb9 |
| docs/GITEA-LOCAL.md | modules/webmaker/docs/GITEA-LOCAL.md | 그대로 | 4d7462b52ef34783 |
| docs/SSL-WILDCARD.md | modules/webmaker/docs/SSL-WILDCARD.md | 그대로 | d93102519cb39333 |
| hooks/askpass.sh | modules/webmaker/hooks/askpass.sh | 그대로 | c86b42091d07748d |
| hooks/guard.js | modules/webmaker/hooks/guard.js | 그대로 | 4804e4cd337db53c |
| hooks/guard.sh | modules/webmaker/hooks/guard.sh | 그대로 | 29df99b89bc31912 |
| hooks/settings.json | modules/webmaker/hooks/settings.json | 그대로 | 30ebe47478166d24 |
| lib/audit.js | modules/webmaker/lib/audit.js | 그대로 | 5a0b6a90e3281d2f |
| lib/auth.js | modules/webmaker/lib/auth.js | 그대로 | 84e3fcda15e46b49 |
| lib/awsv4.js | modules/webmaker/lib/awsv4.js | 그대로 | 8a4bdab7d701c4ec |
| lib/billing.js | modules/webmaker/lib/billing.js | 그대로 | 4e3f88fd1f8ebae1 |
| lib/brief.js | modules/webmaker/lib/brief.js | 그대로 | 9f37eb2e30c3e710 |
| lib/calendar.js | modules/webmaker/lib/calendar.js | 그대로 | c5ee8d9c2777daac |
| lib/claude.js | modules/webmaker/lib/claude.js | 그대로 | dcdebeeeb1d8ea99 |
| lib/config.js | modules/webmaker/lib/config.js | 그대로 | aa577cf668b504e7 |
| lib/connectors/arimail.js | modules/webmaker/lib/connectors/arimail.js | 그대로 | f4dea3dc448c155e |
| lib/connectors/gitea.js | modules/webmaker/lib/connectors/gitea.js | 그대로 | e736d764b2cdac6a |
| lib/connectors/index.js | modules/webmaker/lib/connectors/index.js | 그대로 | 1dc6f6a8290b7a7f |
| lib/costcal.js | modules/webmaker/lib/costcal.js | 그대로 | 22b008dccc3318db |
| lib/crawler.js | modules/webmaker/lib/crawler.js | 그대로 | 392e0fd1e9c88a26 |
| lib/credits.js | modules/webmaker/lib/credits.js | 그대로 | 59caba266ed2bcda |
| lib/deploy.js | modules/webmaker/lib/deploy.js | 그대로 | ddc85c8850a243a6 |
| lib/dns/gcloud.js | modules/webmaker/lib/dns/gcloud.js | 그대로 | e006e6ff4cde77c5 |
| lib/dns/openprovider.js | modules/webmaker/lib/dns/openprovider.js | 그대로 | ae8009bedd485e50 |
| lib/dns/porkbun.js | modules/webmaker/lib/dns/porkbun.js | 그대로 | 9f779244aeddc392 |
| lib/dns/route53.js | modules/webmaker/lib/dns/route53.js | 그대로 | b409db36696b9d5e |
| lib/dnszone.js | modules/webmaker/lib/dnszone.js | 그대로 | 62f74f0772fdc4d4 |
| lib/domains.js | modules/webmaker/lib/domains.js | 그대로 | cee7a767487f9a41 |
| lib/engine-codex.js | modules/webmaker/lib/engine-codex.js | 그대로 | a074e8a4b9867d6d |
| lib/engine-openai.js | modules/webmaker/lib/engine-openai.js | 그대로 | 93e4701e165d032a |
| lib/feedback.js | modules/webmaker/lib/feedback.js | 그대로 | c5860a676dd6118a |
| lib/forms.js | modules/webmaker/lib/forms.js | 그대로 | e2f1b1bb21e5bbdd |
| lib/fx.js | modules/webmaker/lib/fx.js | 그대로 | 7514ae9840fa6307 |
| lib/gitstore.js | modules/webmaker/lib/gitstore.js | 그대로 | d4650fab7b85f9b6 |
| lib/google.js | modules/webmaker/lib/google.js | 그대로 | bfdaa70652f846cc |
| lib/i18n.js | modules/webmaker/lib/i18n.js | 그대로 | 944c12aa16d00f45 |
| lib/jobpids.js | modules/webmaker/lib/jobpids.js | 그대로 | f5df0824a0a457e9 |
| lib/jsonstore.js | modules/webmaker/lib/jsonstore.js | 그대로 | 20a9ee6f5acb22e7 |
| lib/logview.js | modules/webmaker/lib/logview.js | 그대로 | 0262fdf9042301f5 |
| lib/lunar.js | modules/webmaker/lib/lunar.js | 그대로 | fc858ad1b9166b8c |
| lib/mailer.js | modules/webmaker/lib/mailer.js | 그대로 | 7f10112c31ca2d7b |
| lib/mailservers.js | modules/webmaker/lib/mailservers.js | 그대로 | 8608b0f16a0ea9b1 |
| lib/mailsys.js | modules/webmaker/lib/mailsys.js | 그대로 | 8f2e419e859f62ea |
| lib/mailusage.js | modules/webmaker/lib/mailusage.js | 그대로 | a3b608fc766ebe06 |
| lib/maintpage.js | modules/webmaker/lib/maintpage.js | 그대로 | f57501de5a9349d8 |
| lib/modules.js | modules/webmaker/lib/modules.js | 그대로 | b0f8b6df731d180e |
| lib/multilang.js | modules/webmaker/lib/multilang.js | 그대로 | 30eaec7a02d17836 |
| lib/netguard.js | modules/webmaker/lib/netguard.js | 그대로 | 7f3f4a1a908b2371 |
| lib/openprovider.js | modules/webmaker/lib/openprovider.js | 그대로 | aa0ff8df9e04b482 |
| lib/passpolicy.js | modules/webmaker/lib/passpolicy.js | 그대로 | 96c30ce7d9f87797 |
| lib/planner.js | modules/webmaker/lib/planner.js | 그대로 | 55fe3f3597bd9278 |
| lib/prepaid.js | modules/webmaker/lib/prepaid.js | 그대로 | f1078822b25d5325 |
| lib/prompt.js | modules/webmaker/lib/prompt.js | 그대로 | 3279a47d1bb1e078 |
| lib/providers.js | modules/webmaker/lib/providers.js | 그대로 | bf01ac2ac60c40d1 |
| lib/ratelimit.js | modules/webmaker/lib/ratelimit.js | 그대로 | 5abfa063c0f78873 |
| lib/recovery.js | modules/webmaker/lib/recovery.js | 그대로 | 42272b277598a7fb |
| lib/refs.js | modules/webmaker/lib/refs.js | 그대로 | 1c530a8241d363dc |
| lib/registrar.js | modules/webmaker/lib/registrar.js | 그대로 | de6c606dcc87d665 |
| lib/registrars/openprovider.js | modules/webmaker/lib/registrars/openprovider.js | 그대로 | 04697456df4cc68e |
| lib/registrars/porkbun.js | modules/webmaker/lib/registrars/porkbun.js | 그대로 | 6bb857595564dcf6 |
| lib/registrars/route53.js | modules/webmaker/lib/registrars/route53.js | 그대로 | b60f17fefad37a4f |
| lib/remote.js | modules/webmaker/lib/remote.js | 그대로 | 965366d84a6ee859 |
| lib/report.js | modules/webmaker/lib/report.js | 그대로 | 3c14814e9a8f424f |
| lib/run.js | modules/webmaker/lib/run.js | 그대로 | aad85b1616605fc3 |
| lib/seo.js | modules/webmaker/lib/seo.js | 그대로 | fabcb8b18469ec10 |
| lib/settings.js | modules/webmaker/lib/settings.js | 그대로 | 98abbde31bbef9ce |
| lib/setupapi.js | modules/webmaker/lib/setupapi.js | 그대로 | 3fd30ebd9efa47a6 |
| lib/setupauth.js | modules/webmaker/lib/setupauth.js | 그대로 | 8e6e1981448bc343 |
| lib/setupstate.js | modules/webmaker/lib/setupstate.js | 그대로 | 5de040b7a5a88f6c |
| lib/sitemsg.js | modules/webmaker/lib/sitemsg.js | 그대로 | 09bcc4cb52e93a57 |
| lib/ssl.js | modules/webmaker/lib/ssl.js | 그대로 | f7a5bb79900d8bdd |
| lib/stats.js | modules/webmaker/lib/stats.js | 그대로 | fc2de57d4be0960a |
| lib/store.js | modules/webmaker/lib/store.js | 그대로 | 8132dcde60fc5789 |
| lib/templates.js | modules/webmaker/lib/templates.js | 그대로 | bb44e537d0b28b16 |
| lib/uploads.js | modules/webmaker/lib/uploads.js | 그대로 | 5441ea45ed434575 |
| lib/vault.js | modules/webmaker/lib/vault.js | 그대로 | 0730f701278d33a1 |
| lib/xlsx.js | modules/webmaker/lib/xlsx.js | 그대로 | dc353dd3b5fd6e10 |
| locales/ko.json | modules/webmaker/locales/ko.json | 그대로 | 20abf531ef8320f6 |
| locales/site/ar.json | modules/webmaker/locales/site/ar.json | 그대로 | 8644e815887e9bb1 |
| locales/site/de.json | modules/webmaker/locales/site/de.json | 그대로 | 60a7b71fd70ae371 |
| locales/site/en.json | modules/webmaker/locales/site/en.json | 그대로 | 0939d2e2eedf1af9 |
| locales/site/es.json | modules/webmaker/locales/site/es.json | 그대로 | 8ba29c21cf2b90fb |
| locales/site/fa.json | modules/webmaker/locales/site/fa.json | 그대로 | 4c2b4917b1639f87 |
| locales/site/fr.json | modules/webmaker/locales/site/fr.json | 그대로 | 55e2e55db288f856 |
| locales/site/he.json | modules/webmaker/locales/site/he.json | 그대로 | ccaf97cc3f1ea1da |
| locales/site/id.json | modules/webmaker/locales/site/id.json | 그대로 | 9dc0512cc5d63255 |
| locales/site/ja.json | modules/webmaker/locales/site/ja.json | 그대로 | e6ecb37062ec5a8b |
| locales/site/ko.json | modules/webmaker/locales/site/ko.json | 그대로 | ea80e5efdb464a7e |
| locales/site/mn.json | modules/webmaker/locales/site/mn.json | 그대로 | bf5ceec52790a369 |
| locales/site/pt.json | modules/webmaker/locales/site/pt.json | 그대로 | 6922cd08078716c5 |
| locales/site/ru.json | modules/webmaker/locales/site/ru.json | 그대로 | 9d850324dda56354 |
| locales/site/th.json | modules/webmaker/locales/site/th.json | 그대로 | 4f22ec5351f5c41d |
| locales/site/tr.json | modules/webmaker/locales/site/tr.json | 그대로 | 597f9c5b952934ab |
| locales/site/vi.json | modules/webmaker/locales/site/vi.json | 그대로 | 886013f8dfbd56fc |
| locales/site/zh-CN.json | modules/webmaker/locales/site/zh-CN.json | 그대로 | 237e520720cb0166 |
| locales/site/zh-TW.json | modules/webmaker/locales/site/zh-TW.json | 그대로 | a7982e11198a617d |
| modules/README.md | modules/webmaker/modules/README.md | 그대로 | f5d15ad9e5fb2d26 |
| modules/blog/widget.html | modules/webmaker/modules/blog/widget.html | 그대로 | 6a2e80e57038f69a |
| modules/board/widget.html | modules/webmaker/modules/board/widget.html | 그대로 | ac119ae767ce5aa5 |
| modules/booking/widget.html | modules/webmaker/modules/booking/widget.html | 그대로 | 40b6e4cefcf0e901 |
| modules/calendar/widget.html | modules/webmaker/modules/calendar/widget.html | 그대로 | 999a4f5558854ddf |
| modules/faq/widget.html | modules/webmaker/modules/faq/widget.html | 그대로 | 791e54b1dfcb8448 |
| modules/gallery/widget.html | modules/webmaker/modules/gallery/widget.html | 그대로 | 70869995fd8fd4f7 |
| modules/inquiry/widget.html | modules/webmaker/modules/inquiry/widget.html | 그대로 | b56c4807dd11a007 |
| modules/library/widget.html | modules/webmaker/modules/library/widget.html | 그대로 | 1f5ff8f6874a741a |
| modules/map/widget.html | modules/webmaker/modules/map/widget.html | 그대로 | 0f68e9b3c48e4678 |
| modules/members/widget.html | modules/webmaker/modules/members/widget.html | 그대로 | 3a0c8409adda26db |
| modules/reviews/widget.html | modules/webmaker/modules/reviews/widget.html | 그대로 | de7ad783f7cad79a |
| modules/survey/widget.html | modules/webmaker/modules/survey/widget.html | 그대로 | 62b2a3c2ee267b66 |
| package.json | modules/webmaker/package.json | 그대로 | 624dc8ee663ac2ba |
| public/404.html | modules/webmaker/public/404.html | 그대로 | 22b18c443cbb8f50 |
| public/admin.css | modules/webmaker/public/admin.css | 그대로 | b9b4a4cbaa9dc3ec |
| public/admin.html | modules/webmaker/public/admin.html | 그대로 | 6853f78ac57ef360 |
| public/admin.js | modules/webmaker/public/admin.js | 그대로 | fa5725fbe6356bbc |
| public/app.js | modules/webmaker/public/app.js | 그대로 | 3baf113fbacdae72 |
| public/credits-ui.js | modules/webmaker/public/credits-ui.js | 그대로 | 44e43d5179d77578 |
| public/domains-ui.js | modules/webmaker/public/domains-ui.js | 그대로 | ea77c164db309303 |
| public/flow-ui.js | modules/webmaker/public/flow-ui.js | 그대로 | e9a92ab29b71840e |
| public/i18n.js | modules/webmaker/public/i18n.js | 그대로 | c6c4039435b77583 |
| public/import.js | modules/webmaker/public/import.js | 그대로 | 3f17de67ac16b9c4 |
| public/index.html | modules/webmaker/public/index.html | 그대로 | bc33e85029da63b7 |
| public/login.html | modules/webmaker/public/login.html | 그대로 | dd870dfc417adc65 |
| public/manage-ui.js | modules/webmaker/public/manage-ui.js | 그대로 | c57d9d54dea3ab8d |
| public/modules-ui.js | modules/webmaker/public/modules-ui.js | 그대로 | 5f4f54a0abe692c4 |
| public/numfmt.js | modules/webmaker/public/numfmt.js | 그대로 | a11ef5768e547929 |
| public/optimize.js | modules/webmaker/public/optimize.js | 그대로 | 1dc20eb65f0db62f |
| public/pwrule.js | modules/webmaker/public/pwrule.js | 그대로 | 9a1ea70939fa58ce |
| public/setup.html | modules/webmaker/public/setup.html | 그대로 | 5c91302251e5cb7b |
| public/setup.js | modules/webmaker/public/setup.js | 그대로 | 9027313fdabe3be0 |
| public/site-admin.html | modules/webmaker/public/site-admin.html | 그대로 | d598563c7d617bbb |
| public/site-admin.js | modules/webmaker/public/site-admin.js | 그대로 | 762da69b23f1db85 |
| public/site-info.js | modules/webmaker/public/site-info.js | 그대로 | 99060343aaca0ab3 |
| public/style.css | modules/webmaker/public/style.css | 그대로 | 041d7f216677b02d |
| public/templates-ui.js | modules/webmaker/public/templates-ui.js | 그대로 | 6adc66d8e2204b1f |
| public/webmail.html | modules/webmaker/public/webmail.html | 그대로 | 981fee72b5c72bed |
| public/webmail.js | modules/webmaker/public/webmail.js | 그대로 | 6cb9a50f39c8d025 |
| public/wizard.js | modules/webmaker/public/wizard.js | 그대로 | 2ba97adfbb4f7cc1 |
| public/wm-modules.js | modules/webmaker/public/wm-modules.js | 그대로 | efc7526ba17c338d |
| public/wm-stats.js | modules/webmaker/public/wm-stats.js | 그대로 | 7e72375620ac125d |
| scripts/acme-dns-hook.js | modules/webmaker/scripts/acme-dns-hook.js | 그대로 | 4401e102dde4c3ec |
| scripts/admin.js | modules/webmaker/scripts/admin.js | 그대로 | 02adaf25dabad0ec |
| scripts/backup-data.js | modules/webmaker/scripts/backup-data.js | 그대로 | 94b331c3e9555664 |
| scripts/collect-backlog.js | modules/webmaker/scripts/collect-backlog.js | 그대로 | d5e4a0c3ca9bf7f0 |
| scripts/config.js | modules/webmaker/scripts/config.js | 그대로 | d4b90ec541922219 |
| scripts/i18n-extract.js | modules/webmaker/scripts/i18n-extract.js | 그대로 | 7b0a2c7d76155b06 |
| scripts/migrate-gitstore.sh | modules/webmaker/scripts/migrate-gitstore.sh | 그대로 | 6b2fda206e3acb80 |
| scripts/site-msg-catalog.py | modules/webmaker/scripts/site-msg-catalog.py | 그대로 | 1f9b1ea8adbc174c |
| scripts/ssl-wildcard.js | modules/webmaker/scripts/ssl-wildcard.js | 그대로 | 68cbebdb59a5f6d9 |
| scripts/unit-check.js | modules/webmaker/scripts/unit-check.js | 그대로 | ba15624f696892b2 |
| server.js | modules/webmaker/server.js | 그대로 | 4585fd7fb07d3b4b |
| setup-server.js | modules/webmaker/setup-server.js | 그대로 | 6545aab3185765a8 |
| templates/contact-widget.html | modules/webmaker/templates/contact-widget.html | 그대로 | 8063f64226c13914 |
| test/dnszone-import.test.js | modules/webmaker/test/dnszone-import.test.js | 그대로 | 33e1209656a7bf3f |
| test/domains-process.test.js | modules/webmaker/test/domains-process.test.js | 그대로 | 57ff395fcf97fbab |
| test/lib.test.js | modules/webmaker/test/lib.test.js | 그대로 | d13ab84b5f8f8b0d |
| test/mailservers.test.js | modules/webmaker/test/mailservers.test.js | 그대로 | dfb80acca9782be0 |
| test/registrar-route53.test.js | modules/webmaker/test/registrar-route53.test.js | 그대로 | 84e9eba32ad5c7ef |
| test/server.test.js | modules/webmaker/test/server.test.js | 그대로 | b931228730a8bd70 |
## arimail
- 원본 저장소: https://source.ibit.kr/OZ-Group/AriMail
- 고정 커밋: `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`
- 대상 경로: `modules/arimail/`
- 파일 수: 88 (수정 0)
- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.
| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |
|---|---|---|---|
| README.md | modules/arimail/README.md | 그대로 | 942037563d2ec90a |
| deploy/deploy.sh | modules/arimail/deploy/deploy.sh | 그대로 | cd7e1a5f26435839 |
| deploy/domain-nginx.sh | modules/arimail/deploy/domain-nginx.sh | 그대로 | c8322d284a055a68 |
| deploy/gateway.env.in | modules/arimail/deploy/gateway.env.in | 그대로 | 3255005cd674a261 |
| deploy/haraka-apply.sh | modules/arimail/deploy/haraka-apply.sh | 그대로 | 4a5700051eef5a3e |
| deploy/letsencrypt-deploy-hook.sh.in | modules/arimail/deploy/letsencrypt-deploy-hook.sh.in | 그대로 | 6d22293ac934c49f |
| deploy/lib.sh | modules/arimail/deploy/lib.sh | 그대로 | 4726339b779386e2 |
| deploy/new-site.sh | modules/arimail/deploy/new-site.sh | 그대로 | 77048380fc977d44 |
| deploy/nginx-apply.sh | modules/arimail/deploy/nginx-apply.sh | 그대로 | 9709a481130916b8 |
| deploy/nginx/mail-path.conf.in | modules/arimail/deploy/nginx/mail-path.conf.in | 그대로 | 893c6a395cfa79fb |
| deploy/nginx/mail.cloudflare.conf.in | modules/arimail/deploy/nginx/mail.cloudflare.conf.in | 그대로 | a6bd91d9ec547aa4 |
| deploy/nginx/mail.conf.in | modules/arimail/deploy/nginx/mail.conf.in | 그대로 | 55b039d39b838ae9 |
| deploy/nginx/mail.http-only.conf.in | modules/arimail/deploy/nginx/mail.http-only.conf.in | 그대로 | 3f327fdaa7c2ce9d |
| deploy/setup.sh | modules/arimail/deploy/setup.sh | 그대로 | 74fb725daf0b4481 |
| deploy/site-mail-path.sh | modules/arimail/deploy/site-mail-path.sh | 그대로 | 50b30b646233902f |
| deploy/systemd/mail-gateway.service.in | modules/arimail/deploy/systemd/mail-gateway.service.in | 그대로 | 1a129db88217c13a |
| deploy/systemd/mail-haraka.service.in | modules/arimail/deploy/systemd/mail-haraka.service.in | 그대로 | 9854efc3e9e6e877 |
| deploy/systemd/mail-tls.service.in | modules/arimail/deploy/systemd/mail-tls.service.in | 그대로 | 445add8875f3d99d |
| deploy/systemd/mail-tls.timer.in | modules/arimail/deploy/systemd/mail-tls.timer.in | 그대로 | 9eebee444b7d22a8 |
| deploy/tls-when-ready.sh | modules/arimail/deploy/tls-when-ready.sh | 그대로 | 8add122491a9c3d5 |
| deploy/webmaker-access.sh | modules/arimail/deploy/webmaker-access.sh | 그대로 | 4390f154d7b48dc4 |
| docs/SETUP.md | modules/arimail/docs/SETUP.md | 그대로 | 1362935eee8bcfb9 |
| docs/USAGE-API.md | modules/arimail/docs/USAGE-API.md | 그대로 | 622b2346d12972cd |
| haraka/README.md | modules/arimail/haraka/README.md | 그대로 | d6ce6e90a1466334 |
| haraka/package.json | modules/arimail/haraka/package.json | 그대로 | b29222d4d10a4246 |
| haraka/plugins/arimail.js | modules/arimail/haraka/plugins/arimail.js | 그대로 | c33939420a33d72e |
| haraka/run.mjs | modules/arimail/haraka/run.mjs | 그대로 | c48fa74615815a7e |
| lang/ko.json | modules/arimail/lang/ko.json | 그대로 | 5e6a8354e25662ff |
| migrations/0001_init.sql | modules/arimail/migrations/0001_init.sql | 그대로 | f6b1b10b05cdb85a |
| migrations/0002_imap.sql | modules/arimail/migrations/0002_imap.sql | 그대로 | 3e234e2410a7e152 |
| migrations/0003_accounts.sql | modules/arimail/migrations/0003_accounts.sql | 그대로 | 00e8cf661fe62d5e |
| migrations/0004_no_retention.sql | modules/arimail/migrations/0004_no_retention.sql | 그대로 | 6e45e554cb1af82d |
| migrations/0005_imap_uid_reassign.sql | modules/arimail/migrations/0005_imap_uid_reassign.sql | 그대로 | 9d3d33c65e05b33d |
| migrations/0006_session_scope.sql | modules/arimail/migrations/0006_session_scope.sql | 그대로 | 9277cff3a82c282b |
| migrations/0007_auto_actions.sql | modules/arimail/migrations/0007_auto_actions.sql | 그대로 | 88c3c02ebf1f38fe |
| migrations/0008_large_files.sql | modules/arimail/migrations/0008_large_files.sql | 그대로 | a2d11409da0006b4 |
| package.json | modules/arimail/package.json | 그대로 | f986ee70b4841c73 |
| server/api.mjs | modules/arimail/server/api.mjs | 그대로 | b51ef607e4c0e65b |
| server/auth.mjs | modules/arimail/server/auth.mjs | 그대로 | e2d3c1906a46f0e1 |
| server/automation.mjs | modules/arimail/server/automation.mjs | 그대로 | 1f9a370fad319815 |
| server/config.mjs | modules/arimail/server/config.mjs | 그대로 | 9f5cc526198f988d |
| server/db-migrate.mjs | modules/arimail/server/db-migrate.mjs | 그대로 | cbc5540c4a43e134 |
| server/db.mjs | modules/arimail/server/db.mjs | 그대로 | 35f5d6c1fc4c8ff2 |
| server/dns/common.mjs | modules/arimail/server/dns/common.mjs | 그대로 | c6ccc6ceeafa0bcf |
| server/dns/google.mjs | modules/arimail/server/dns/google.mjs | 그대로 | c825d4f04f7921c9 |
| server/dns/index.mjs | modules/arimail/server/dns/index.mjs | 그대로 | e3605c56e29aeb58 |
| server/dns/route53.mjs | modules/arimail/server/dns/route53.mjs | 그대로 | e31e330aa00af85f |
| server/domains.mjs | modules/arimail/server/domains.mjs | 그대로 | ee558ae6f234b27f |
| server/files.mjs | modules/arimail/server/files.mjs | 그대로 | 0425698a1fe8652b |
| server/imap-mime.mjs | modules/arimail/server/imap-mime.mjs | 그대로 | ad5a4c60908a7847 |
| server/imap.mjs | modules/arimail/server/imap.mjs | 그대로 | 10ec4d7c53048df2 |
| server/index.mjs | modules/arimail/server/index.mjs | 그대로 | 3866bb3952efd9a8 |
| server/limits.mjs | modules/arimail/server/limits.mjs | 그대로 | 016c3aecef9a6da7 |
| server/main.mjs | modules/arimail/server/main.mjs | 그대로 | 90ad71bdc1bca7c0 |
| server/mime.mjs | modules/arimail/server/mime.mjs | 그대로 | 485abda8645820fe |
| server/pop3.mjs | modules/arimail/server/pop3.mjs | 그대로 | 377dcf0370e5cf9e |
| server/providers/index.mjs | modules/arimail/server/providers/index.mjs | 그대로 | 5774cb75b5b1c9d4 |
| server/providers/resend.mjs | modules/arimail/server/providers/resend.mjs | 그대로 | 2b964265e2d0eb8d |
| server/providers/smtp-client.mjs | modules/arimail/server/providers/smtp-client.mjs | 그대로 | 308ef3b4dfc0b9e0 |
| server/providers/smtp.mjs | modules/arimail/server/providers/smtp.mjs | 그대로 | e7b475fc79c65834 |
| server/provision.mjs | modules/arimail/server/provision.mjs | 그대로 | d4c17584d1ef605d |
| server/resend.mjs | modules/arimail/server/resend.mjs | 그대로 | a6076fdcffd14f57 |
| server/sanitize.mjs | modules/arimail/server/sanitize.mjs | 그대로 | d14fd6ffd01e75e0 |
| server/setup.mjs | modules/arimail/server/setup.mjs | 그대로 | ece096ed19cbb679 |
| server/smtp.mjs | modules/arimail/server/smtp.mjs | 그대로 | 61e2e7187c636297 |
| server/store.mjs | modules/arimail/server/store.mjs | 그대로 | a5741af42349a2dd |
| server/usage.mjs | modules/arimail/server/usage.mjs | 그대로 | 29c97a4b5f627741 |
| server/user-cli.mjs | modules/arimail/server/user-cli.mjs | 그대로 | ba64bccf7f3892dc |
| server/util.mjs | modules/arimail/server/util.mjs | 그대로 | 6daf63506b35fb6c |
| server/webhook.mjs | modules/arimail/server/webhook.mjs | 그대로 | b3246cf3b830c4d8 |
| server/webmaker-snapshot.mjs | modules/arimail/server/webmaker-snapshot.mjs | 그대로 | ef6962db91b5b09a |
| server/webmaker.mjs | modules/arimail/server/webmaker.mjs | 그대로 | 6cde505d6949137e |
| test/_helpers.mjs | modules/arimail/test/_helpers.mjs | 그대로 | 3c8cf13bfc4fee0a |
| test/config.test.mjs | modules/arimail/test/config.test.mjs | 그대로 | 740db8ae5b3d105f |
| test/domains.test.mjs | modules/arimail/test/domains.test.mjs | 그대로 | 81c77eadfdfba0ea |
| test/gateway.test.mjs | modules/arimail/test/gateway.test.mjs | 그대로 | fc5ef627262d950f |
| test/imap.test.mjs | modules/arimail/test/imap.test.mjs | 그대로 | d8387e1386302866 |
| test/provision.test.mjs | modules/arimail/test/provision.test.mjs | 그대로 | 58a66582f5d35655 |
| test/smtp.test.mjs | modules/arimail/test/smtp.test.mjs | 그대로 | f92287ef6e7df130 |
| test/usage.test.mjs | modules/arimail/test/usage.test.mjs | 그대로 | 659271c6b54935b2 |
| test/webmaker.test.mjs | modules/arimail/test/webmaker.test.mjs | 그대로 | 2a6818a40b54cfee |
| web/app.js | modules/arimail/web/app.js | 그대로 | cbfb4a748cc100f4 |
| web/favicon.svg | modules/arimail/web/favicon.svg | 그대로 | 0c44f177404b4090 |
| web/index.html | modules/arimail/web/index.html | 그대로 | f7935713b9d37b8d |
| web/setup.css | modules/arimail/web/setup.css | 그대로 | ebf376321f3f9074 |
| web/setup.html | modules/arimail/web/setup.html | 그대로 | b07043a0c5b952ad |
| web/setup.js | modules/arimail/web/setup.js | 그대로 | 24f72af0d740a559 |
| web/style.css | modules/arimail/web/style.css | 그대로 | b63d6c779186c363 |
+121
View File
@@ -0,0 +1,121 @@
# Ariweb Suite v0.1.1 — 아키텍처와 개발 규약
이 문서는 Ariweb Suite 의 모든 구성요소가 따르는 공통 규약이다. 하위 시스템을 나누어 개발할 때 이 문서가 인터페이스 계약이다.
개발 지시 원문은 `Ariweb-Suitle_Dev-Order_v0.1.1.md` 이며, 절 번호(예: §13.3)는 그 문서를 가리킨다.
## 1. 구성요소와 프로세스
| 프로세스 | 진입점 | 포트(127.0.0.1) | 역할 |
|---|---|---|---|
| `ariweb-hal` | `hal/index.mjs` | 8900 | HAL 제어 평면: 릴리스 등록부, 서버·Node Agent 등록부, 내구성 작업 엔진, 모듈 배포·갱신·되돌리기, 운영자 콘솔(`hal.ariweb.net/ops`), 공개 상태 페이지(`/status`), 긴급 복구, 플랫폼 재해복구 |
| `ariweb-agent` | `agent/index.mjs` | (없음, HAL 에 long-poll) | 각 관리 서버의 Node Agent: 일회용 등록 토큰으로 HAL 에 등록, heartbeat·지표(CPU·메모리·디스크·inode·I/O·네트워크·시계 차이), HAL 이 내린 작업(모듈 설치·갱신·되돌리기·건강 확인·사이트 배포·백업·복원·이전) 실행. 외부 고객 서버에서는 **External Pull Agent** 모드(사이트 배포만) |
| `ariweb-portal` | `portal/index.mjs` | 8910 | 고객 포털(`my.ariweb.net`): 회원·사용자 그룹·상품·주문·사이트·배포·도메인·메일·백업·복원·이전·소유권·사용량·알림·감사. Webmaker·AriMail 로 가는 통합 진입점 |
| `webmaker` | `modules/webmaker/server.js` | 3080 | 도입 모듈(WebHosting_AI_agent 1.7.3). 사이트 제작·AI·도메인·DNS·TLS·Gitea·배포. 포털 SSO 로 로그인 없이 진입 |
| `webmaker-setup` | `modules/webmaker/setup-server.js` | 8795 | Webmaker 초기 설정 페이지(기존 그대로) |
| `arimail` | `modules/arimail/server/index.mjs` | 8790 + 25/110/143/465/587/993/995 | 도입 모듈(AriMail 1.0.11). 웹메일·IMAP·POP3·SMTP·사용량 API |
| nginx | — | 80/443 | 모든 공개 진입. vhost 는 HAL/Agent 가 템플릿으로 생성 |
세 신규 프로세스(`hal`·`agent`·`portal`)는 같은 저장소의 `lib/` 를 공유하고 외부 npm 의존성이 없다(Node 22.13+, `node:sqlite`).
도입 모듈(`modules/*`)은 원본 저장소의 구조를 그대로 두고, 변경은 `IMPORTED_SOURCES.md` 에 파일 단위로 기록한다.
## 2. 저장소 레이아웃
```
lib/ 공통 라이브러리(ESM). db·http·session·status·jobs·quota·notify·audit·kst·redact·i18n
hal/ HAL 서버(api, ops 콘솔 UI, 릴리스·서버·작업·복구)
agent/ Node Agent(등록·heartbeat·작업 실행기·모듈 설치기·External Pull 모드)
portal/ 고객 포털(api, 화면, 도메인 로직) — portal/<영역>/*.mjs
modules/webmaker/ 도입: WebHosting_AI_agent @3b10859 (CommonJS)
modules/arimail/ 도입: AriMail @a07f69f (ESM)
catalog/ 출시 검증용 버전 고정 상품 Catalog(JSON) — 운영자가 HAL/포털 화면에서 관리하는 ProductVersion 의 시드
deploy/ 설치 스크립트·systemd 유닛·nginx 템플릿·부트스트랩(HAL 최초 설치)
scripts/ import-sources(도입 소스 복사·manifest), release(릴리스 artifact 생성·검사)
test/ 회귀 테스트(node:test 없이 lib/test.mjs 의 작은 러너). `npm test` 가 전부 실행
docs/ 이 문서, 운영 문서, 검증 기록
VERSION_HISTORY.md, IMPORTED_SOURCES.md, README.md
```
## 3. 공통 라이브러리(lib/) 계약
- `lib/db.mjs` — `openDb(file, migrations[])`: `node:sqlite` 를 열고(WAL, foreign_keys) `migrations` 배열(`{id, sql}`)을 순서대로 적용한다. 반환 `{ db, get(sql, ...p), all(sql, ...p), run(sql, ...p), tx(fn) }`. 모든 저장은 SQLite 이며 JSON 열은 `json_*` 접미사.
- `lib/http.mjs` — `createApp()` → `{ route(method, pattern, handler), static(prefix, dir), listen(port, host) }`. 핸들러 시그니처 `(ctx) => body|Response`. `ctx = { req, res, method, path, params, query, body(json), cookies, ip, user, set(status, headers), json(obj, status), html(str), redirect(url), error(status, code, message) }`. 오류는 `{ ok:false, error:'<code>', message }` JSON. 모든 응답 `Cache-Control: no-store`(정적 제외). 신뢰 프록시(nginx) 의 `X-Real-IP` 만 믿는다.
- `lib/session.mjs` — 서명 쿠키가 아니라 **DB 세션**(기기·IP·UA·발급·만료·마지막 사용, 개별/전체 종료 §3.2). `createSession(db, {userId, kind:'customer'|'operator', ...})`, `resolve(db, token)`, `revoke`, `revokeAll`. CSRF 는 세션에 묶인 HMAC 토큰(`X-CSRF` 헤더).
- `lib/status.mjs` — §2.2 사용자용 작업 상태 어휘와 전이 규칙. `STATUS = { NOT_READY:'준비 전', NEEDS_SETUP:'설정 필요', AWAITING_APPROVAL:'승인 대기', AWAITING_PAYMENT:'결제 대기', QUEUED:'처리 대기', RUNNING:'진행 중', AWAITING_EXTERNAL:'외부 작업 대기', VERIFYING:'검증 중', PAUSED:'일시 중지', DONE:'완료', FAILED:'실패', REVERTING:'되돌리는 중', REVERTED:'되돌림 완료', NEEDS_USER:'사용자 확인 필요' }`. 코드(영문 키)로 저장하고 화면에서 한국어로 바꾼다. 서비스 상태(`SERVICE_STATE`)·작업 상태(`STATUS`)·외부 건강 상태(`HEALTH`)는 **별도 필드**로 둔다(§2.2 "합치지 않는다").
- `lib/jobs.mjs` — 내구성 작업 엔진. 작업은 `jobs` 표에 `{id, kind, subject(json), steps(json: [{id,title,state,startedAt,endedAt,detail}]), status, attempt, maxAttempts, nextRunAt, lockedBy, lockedAt, result(json), error, idempotencyKey, createdBy, groupId}` 로 저장된다. 실행기는 `registerKind(kind, { run(job, ctx), revert?(job, ctx), steps:[...] })` 로 종류를 등록하고, `ctx.step(id, fn)` 으로 단계를 기록한다(단계마다 저장 → 프로세스 재시작 뒤 이어서 실행 §29). 같은 `idempotencyKey` 의 작업은 하나만 만든다(§36). 일시 오류는 `retryable` 표시로 지수 백오프 재시도(횟수·다음 시각 표시 §2.2). `ctx.waitExternal(reason)` 은 `AWAITING_EXTERNAL` 로 둔다.
- `lib/quota.mjs` — §13 보장량·예약. 자원 종류 `RESOURCE = sites | web_disk | web_out | mail_storage | mail_msgs | mailboxes | mail_services | source | backup`. 표 `entitlements`(그룹별 구매 총량, 상품정책 버전 참조), `allocations`(서비스별 배정량), `reservations`(진행 중 작업 예약: `{id, groupId, resource, amount, state: available|reserved|in_use|returning|returned, jobId, expiresAt}`). `reserve()` 는 단일 트랜잭션에서 `구매총량 - (배정 + 예약)` 을 검사해 **동시 요청 중 하나만 성공**(§13.3, 검증 27). `commit()`→in_use, `release()`→returned. `warnLevel(used, limit)` → 0|70|80|90|95|100.
- `lib/kst.mjs` — KST 월 경계(`monthStart(at)`, `nextMonthStart(at)`), 고정 시각 입력 가능(테스트).
- `lib/redact.mjs` — 비밀로 보이는 키·값 가리기. 감사 기록·알림·작업 결과·로그 **모두** 이 함수를 거친다(§37).
- `lib/notify.mjs` — 알림 센터(§24.1): 분류 `category`, 사건 묶음 `incidentKey`(같은 원인은 하나로 갱신), 필수 알림(`mandatory`), 외부 이메일 발송 훅(`mailer`, AriMail/Resend 는 포털 설정). 링크는 조치 화면으로.
- `lib/audit.mjs` — 감사 기록(§24.2): `{at, actorType: user|operator|system, actorId, groupId, subjectType, subjectId, action, outcome, detail(redacted), immutable}`. 권한·소유권·배포·해지·복원·이전·결제 승인은 `immutable=1`(삭제 불가).
- `lib/i18n.mjs` — 한국어 문구 사전(`lang/ko.json`). 화면 문구는 코드에 박지 않고 여기서 읽는다.
- `lib/test.mjs` — 작은 테스트 러너: `test(name, fn)`, `eq`, `ok`, `rejects`, 임시 DB 생성.
## 4. 데이터 모델(포털 SQLite, `portal/schema.mjs`)
핵심 표(열은 요지만, 상세는 스키마 파일):
- `users`(id, email, email_verified_at, display_name, pass_hash(scrypt), pass_salt, mfa_json, failed_logins, lock_until, created_at) · `email_tokens`(verify/reset, 만료·사용 완료) · `sessions`
- `groups`(id, name, kind: personal|team, billing_contact, tech_contact, owner_user_id) · `memberships`(group_id, user_id, role: owner|admin|member|billing|viewer, alias `user-01`, status) · `invites`(email, role, token_hash, expires_at, accepted_at)
- `site_permissions`(site_id, user_id, perms_json: view|edit|deploy|domain_mail|billing|admin)
- `product_versions`(id, product_id, version, name, audience, billing_cycle, price, currency, tax_rate, limits_json, options_json, warn_levels_json [70,80,90,95], approval: manual, effective_from, published) — 코드에 가격을 고정하지 않는다(§6.1, §27.2)
- `orders`(id, group_id, product_version_id, items_json, amount, tax, total, currency, status: awaiting_payment|awaiting_approval|approved|rejected|expired|cancelled, payment_ref, due_at, approved_by, idempotency_key) · `entitlements`(group_id, resource, amount, source_order_id, starts_at, ends_at)
- `sites`(id, group_id, name, purpose, webmaker_project_id, lifecycle: draft|building|deploying|live|paused|cancel_scheduled|readonly_retention|pending_delete|deleted, deploy_mode: managed|external|null, primary_domain, site_count_state: none|reserved|in_use|returning, server_id, current_version, health, settings_json)
- `deployments`(id, site_id, version_ref(저장본), mode, target(server/external agent), status(job), verify_state: verified|needs_domain|public, result_json)
- `domains`(id, group_id, name, purpose: web|mail|both, site_id, mail_service_id, ownership_state, dns_state, cert_state, mail_dns_state, last_checked_at, is_primary)
- `mail_services`(id, group_id, kind: site_bound|standalone, site_id, domain, arimail_server_id, status, allocations…) · `mailboxes`(mail_service_id, address, status, quota_bytes, …) — 실제 계정·메일 데이터는 AriMail 이 가지며 포털은 상태·배정만 둔다
- `backup_options`(site_id, product_version_id, schedule_json, retention_json, status, first_backup_due_at) · `backups`(id, site_id, created_at, scope_json, verify_state, expires_at, location) · `restores`(id, site_id, backup_id, status, cost, approval…)
- `relocations`(id, subject_type, subject_id, from_server, to_server, kind: auto_static|approved|customer_paid, status, boundaries_json)
- `ownership_transfers`(id, from_group, to_group, scope_json, status: requested|accepted|reviewing|approved|completed|rejected|expired|disputed, expires_at)
- `jobs`, `reservations`, `allocations`, `notifications`, `audit_log`, `usage_samples`(site/mail 별 측정값, measured_at)
## 5. HAL ↔ Node Agent 프로토콜(`docs/HAL-AGENT.md` 에 상세)
- 등록: 운영자가 HAL 에서 서버(역할·위치) 생성 → **일회용 등록 토큰**(10분, 1회) 발급 → 서버에서 `ariweb-agent register --hal https://hal.ariweb.net --token …` → Agent 가 키쌍 생성, 공개키 등록, HAL 이 Agent 자격(agent_id + 비밀, 응답에 1회) 발급 → 운영자 승인 후 배치 가능.
- 인증: 모든 Agent 요청은 `Authorization: Bearer <agent secret>` + 요청 서명(HMAC: method|path|ts|body-sha256). 시계 차이 ±5분.
- heartbeat: `POST /agent/v1/heartbeat` 30초마다 `{version, modules:{name:{version,active,health}}, metrics:{cpu,mem,disk,inode,io,net,clockSkewMs}, jobs:{running}}`.
- 작업: Agent 가 `GET /agent/v1/jobs/next?wait=25` 로 long-poll → HAL 이 `agent_jobs` 에서 배정 → Agent 실행 → `POST /agent/v1/jobs/<id>/progress|result`. Agent 재시작 시 미완료 작업은 재개 가능한 단계부터 다시 하거나 `NEEDS_USER` 로 보고한다.
- artifact: `GET /agent/v1/releases/<ver>/artifact` (tar.gz, sha256 검증). 모듈 설치 레이아웃 `/srv/ariweb/<module>/releases/<ver>` + `current` 심링크, 유닛 `ariweb-<module>.service`(도입 모듈은 기존 유닛 이름 `webmaker`·`arimail` 호환).
## 6. 포털 ↔ HAL
포털은 관리형 작업(사이트 배포·백업·복원·이전·사용량 수집)을 HAL API(`/portal/v1/*`, 서비스 키 + HMAC)로 요청하고 작업 ID 를 받는다. 포털 `jobs` 표의 작업이 HAL 작업을 참조(`hal_job_id`)하며 상태는 HAL 이 webhook(`/internal/hal-events`)으로 밀고, 포털도 주기 조회로 맞춘다(둘 중 빠른 쪽). HAL 이 응답하지 않으면 포털은 `정보 확인 지연` 을 표시하고 신규 증가를 허용하지 않는다(§13.4, §26.2).
## 7. 포털 ↔ Webmaker / AriMail
- **Webmaker SSO**(§0.2): 포털이 사용자별 Webmaker 계정(`aw-<groupId 앞 8>-<userId 앞 8>`)을 만들어 두고, "제작 화면 열기" 때 포털 서명 티켓(`/sso/webmaker?ticket=…`)으로 Webmaker 의 `/api/auth/sso` (신규, 포털 공유 비밀로 검증)에서 세션 쿠키를 받아 들어간다. Webmaker 의 `users.json` 비밀번호는 무작위이며 사용자는 모른다(별도 로그인 없음). 현재 사용자 그룹 이름을 Webmaker 상단에 표시한다(`X-Ariweb-Group` 배너).
- **Webmaker Project ↔ Site**: 포털 `sites.webmaker_project_id` ↔ Webmaker `data/sites/<id>.json`. Webmaker 프로젝트 삭제 API 에 포털 영향 확인 훅(`beforeDelete` → 포털 `/internal/webmaker/project-impact`)을 둔다.
- **AriMail**: 관리자 API(계정 CRUD)·사용량 API(`/api/usage`)·전체 설정 API(도메인 추가)를 포털 `portal/mail/arimail.mjs` 어댑터가 호출한다. 비밀 키는 포털 vault(`lib/vault.mjs`, AES-256-GCM, 데이터 디렉터리의 `vault.key`)에만 둔다.
## 8. 보안 규칙(§37)
1. 비밀값(비밀번호·토큰·API 키·개인키·복구 코드·메일 원문)은 소스·문서·로그·테스트·커밋·보고서에 쓰지 않는다. 시험용 값도 `example`·무작위 생성만.
2. 모든 기록 경로(감사·알림·작업 결과·오류 메시지)는 `redact()` 를 거친다.
3. 비밀값은 저장 후 다시 읽을 수 없고 재발급만 제공한다(일회용 표시 뒤 해시만 저장).
4. 그룹 경계: 모든 조회 SQL 은 `group_id` 조건을 포함한다. 검색·자동완성도 같다.
5. 운영자 콘솔은 별도 계정·별도 세션(`kind='operator'`)·제한 경로(`/ops`)·허용 네트워크(사설망/VPN CIDR 또는 기기 귀속 단기 접근권) 없이는 로그인 화면도 열리지 않는다(§25).
6. 고위험 작업은 최근 재인증(10분) + 3단계 확인(영향 범위 → 대상 식별정보 재입력 → 최종 실행·되돌리기 가능 여부)을 거친다.
## 9. 코딩 규약
- ESM(`.mjs`), 외부 의존성 없음, Node 22.13+.
- 모든 파일 머리에 모듈 목적 주석, 모든 함수·클래스에 목적·매개변수·반환·비자명한 논리 이유를 **한국어**로 적는다.
- 화면 문구는 `lang/ko.json`(포털·HAL) 에 둔다. 상태는 코드로 저장하고 표시 때 번역한다.
- 각 영역은 `portal/<영역>/{model,api,ui}.mjs` 로 나눈다. `api.mjs` 는 `register(app, deps)` 를 내보낸다.
- 테스트는 `test/<영역>.test.mjs`, `npm test` 로 전부 실행. 외부 네트워크·실서버에 닿는 테스트는 `ARIWEB_LIVE=1` 일 때만.
- 날짜·시각은 ISO 8601 UTC 로 저장하고 화면에서 KST 로 표시한다. 월간 사용량 경계는 KST.
## 10. 배포 레이아웃(관리 서버)
```
/srv/ariweb/
hal/ releases/<ver>/ current -> … data/(hal.sqlite, vault.key, artifacts/)
portal/ releases/<ver>/ current data/(portal.sqlite, vault.key)
agent/ releases/<ver>/ current data/(agent.json: id·공개키·HAL 주소; 비밀은 0600)
webmaker/ (도입 모듈) releases/<ver>/ current 데이터는 기존 /data/webmaker 유지
arimail/ (도입 모듈) releases/<ver>/ current 데이터는 기존 경로 유지
backups/ 관리형 사이트 백업 저장소(운영 서버와 다른 서버가 원칙)
systemd: ariweb-hal.service · ariweb-portal.service · ariweb-agent.service · webmaker.service · arimail(또는 기존 유닛명).service
nginx: /etc/nginx/sites-enabled/ariweb-hal.conf (hal.ariweb.net) · ariweb-portal.conf (my.ariweb.net) · wh-<siteId>.conf (사이트)
```
HAL 최초 설치(부트스트랩, §32.2)만 `deploy/bootstrap-hal.sh` 로 수행한다. 그 뒤 모든 모듈은 HAL 작업으로만 설치·갱신한다.
+7
View File
@@ -0,0 +1,7 @@
{
"app": { "name": "Ariweb Suite", "portal": "Ariweb 고객 포털", "ops": "Ariweb 운영자 콘솔", "hal": "HAL" },
"nav": { "dashboard": "대시보드", "sites": "사이트", "mail": "메일", "domains": "도메인", "backup": "백업", "billing": "상품 및 결제", "usage": "사용량", "group": "사용자 그룹 설정", "notifications": "알림", "help": "도움말", "account": "내 계정", "logout": "로그아웃" },
"state": { "loading": "불러오는 중", "empty": "비어 있음", "stale": "정보 확인 지연", "partial": "부분 실패", "failed": "전체 실패", "forbidden": "권한 없음", "notProvided": "제공되지 않음" },
"common": { "save": "저장", "cancel": "취소", "confirm": "확인", "retry": "다시 시도", "back": "뒤로", "next": "다음", "close": "닫기", "delete": "삭제", "edit": "수정", "create": "만들기", "refresh": "새로 고침", "lastUpdated": "마지막 갱신", "updatedAt": "갱신 시각", "actions": "작업", "status": "상태", "name": "이름", "more": "더 보기", "none": "없음", "yes": "예", "no": "아니요", "required": "필수", "optional": "선택", "help": "도움말", "copy": "복사", "copied": "복사했습니다" },
"error": { "generic": "일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요", "forbidden": "이 작업을 할 권한이 없습니다. 필요한 역할: {role}", "notFound": "요청한 항목을 찾을 수 없습니다", "csrf": "보안 토큰이 맞지 않습니다. 화면을 새로 고친 뒤 다시 시도하세요", "unauthenticated": "로그인이 필요합니다", "groupRequired": "사용자 그룹을 먼저 선택하세요" }
}
+45
View File
@@ -0,0 +1,45 @@
/**
* lib/audit.mjs — 감사 기록(§24.2).
*
* 누가(actor)·언제·어떤 그룹과 대상(subject)에서·무엇을(action)·자동/사용자 작업인지·결과(outcome)를 남긴다.
* 비밀값은 redact() 로 거른다. 권한·소유권·배포·해지·복원·이전·결제 승인은 immutable=1 로 두어 삭제 API 가 거부한다.
*/
import { newId } from './crypto.mjs';
import { redact } from './redact.mjs';
import { nowIso, toJson, parseJson } from './db.mjs';
export const MIGRATIONS = [{ id: 'lib_audit_0001', sql: `
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY, at TEXT NOT NULL, actor_type TEXT NOT NULL, actor_id TEXT, actor_label TEXT, group_id TEXT,
subject_type TEXT, subject_id TEXT, action TEXT NOT NULL, outcome TEXT NOT NULL, automatic INTEGER NOT NULL DEFAULT 0,
immutable INTEGER NOT NULL DEFAULT 0, detail_json TEXT, ip TEXT
);
CREATE INDEX IF NOT EXISTS audit_group ON audit_log(group_id, at);
CREATE INDEX IF NOT EXISTS audit_subject ON audit_log(subject_type, subject_id, at);` }];
/** 삭제할 수 없는 기록으로 남기는 행동 접두어 */
const IMMUTABLE_PREFIX = /^(permission|ownership|deploy|cancel|terminate|restore|relocate|order\.approve|order\.reject|payment|refund|member\.role|site_count|emergency|recovery)/;
/**
* 기록 남기기.
* @param {object} store lib/db
* @param {object} e { actorType: 'user'|'operator'|'system'|'agent', actorId, actorLabel, groupId, subjectType, subjectId, action, outcome: 'ok'|'failed'|'cancelled'|'denied', automatic, detail, ip }
*/
export function audit(store, e) {
const id = newId('a_');
store.run('INSERT INTO audit_log(id, at, actor_type, actor_id, actor_label, group_id, subject_type, subject_id, action, outcome, automatic, immutable, detail_json, ip) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
id, nowIso(), e.actorType || 'system', e.actorId || null, e.actorLabel || null, e.groupId || null, e.subjectType || null, e.subjectId || null, e.action, e.outcome || 'ok',
e.automatic ? 1 : 0, e.immutable || IMMUTABLE_PREFIX.test(e.action) ? 1 : 0, toJson(redact(e.detail || null)), e.ip || null);
return id;
}
/** 조회(그룹·대상·행동·기간 필터) */
export function listAudit(store, { groupId, subjectType, subjectId, action, since, until, limit = 200 } = {}) {
const w = []; const p = [];
if (groupId) { w.push('group_id=?'); p.push(groupId); }
if (subjectType) { w.push('subject_type=?'); p.push(subjectType); }
if (subjectId) { w.push('subject_id=?'); p.push(subjectId); }
if (action) { w.push('action LIKE ?'); p.push(action + '%'); }
if (since) { w.push('at>=?'); p.push(since); }
if (until) { w.push('at<=?'); p.push(until); }
return store.all(`SELECT * FROM audit_log ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY at DESC LIMIT ?`, ...p, Math.min(limit, 1000)).map((r) => ({ ...r, detail: parseJson(r.detail_json, null) }));
}
+29
View File
@@ -0,0 +1,29 @@
/**
* lib/crypto.mjs — 비밀번호 해시·토큰·HMAC 공통.
*
* - 비밀번호: scrypt(N=2^15) + 16바이트 salt. Webmaker 와 같은 계열의 알고리즘이라 SSO 계정 동기화 때 혼동이 없다.
* - 토큰: 32바이트 무작위 base64url. 저장은 항상 sha256 해시만(원문은 발급 응답에 한 번).
* - HMAC: Agent 요청 서명·SSO 티켓 서명. 비교는 항상 timingSafeEqual.
*/
import { scryptSync, randomBytes, createHash, createHmac, timingSafeEqual } from 'node:crypto';
/** 비밀번호 해시. @returns {{salt:string, hash:string}} */
export function hashPassword(password) { const salt = randomBytes(16).toString('hex'); return { salt, hash: scryptSync(String(password), salt, 64, { N: 2 ** 14, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }).toString('hex') }; }
/** 비밀번호 검증(상수 시간). */
export function verifyPassword(password, salt, hash) {
if (!salt || !hash) return false;
const a = scryptSync(String(password), salt, 64, { N: 2 ** 14, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }); const b = Buffer.from(hash, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}
/** 무작위 토큰(원문). bytes 기본 32. */
export const randomToken = (bytes = 32) => randomBytes(bytes).toString('base64url');
/** 짧은 식별자(ULID 대용): 시각 정렬 가능한 16진 26자 — 앞 10자는 ms 시각. */
export function newId(prefix = '') { const t = Date.now().toString(16).padStart(12, '0'); return prefix + t + randomBytes(7).toString('hex'); }
/** 토큰 저장용 해시 */
export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex');
/** HMAC-SHA256 (base64url) */
export const hmac = (secret, data) => createHmac('sha256', secret).update(String(data)).digest('base64url');
/** 문자열 상수 시간 비교 */
export function safeEqual(a, b) { const x = Buffer.from(String(a || '')); const y = Buffer.from(String(b || '')); return x.length === y.length && x.length > 0 && timingSafeEqual(x, y); }
/** 6자리 숫자 코드(이메일 확인·MFA 복구 등) */
export const numericCode = (digits = 6) => String(parseInt(randomBytes(4).toString('hex'), 16) % 10 ** digits).padStart(digits, '0');
+74
View File
@@ -0,0 +1,74 @@
/**
* lib/db.mjs — SQLite 저장소 공통 계층 (node:sqlite, 외부 의존성 없음).
*
* 목적
* HAL·포털·Agent 가 모두 같은 방식으로 DB 를 열고 마이그레이션을 적용하게 한다.
* - WAL 모드: 읽기와 쓰기가 서로 막지 않아 화면 조회 중 작업 엔진이 기록해도 된다.
* - foreign_keys ON: 그룹·사이트·작업 사이의 참조 무결성을 DB 가 지킨다.
* - busy_timeout: 짧은 잠금 경쟁(두 요청이 동시에 예약을 시도)은 기다렸다가 처리한다(§13.3 동시 요청).
*
* 사용
* const store = openDb('/srv/ariweb/portal/data/portal.sqlite', [{ id: '0001_init', sql: '...' }]);
* store.get('SELECT * FROM users WHERE id=?', id) → 한 행 또는 undefined
* store.all(...) → 행 배열
* store.run(...) → { changes, lastInsertRowid }
* store.tx(() => { ... }) → BEGIN IMMEDIATE … COMMIT (예외 시 ROLLBACK). 중첩되면 바깥 트랜잭션에 합류한다.
*
* 마이그레이션
* `_migrations` 표에 적용한 id 를 남기고, 아직 없는 것만 순서대로 적용한다. 각 모듈(lib/session·jobs·quota·notify·audit)은
* 자기 표의 마이그레이션을 `MIGRATIONS` 로 내보내고, 각 프로세스의 schema 파일이 그것을 모아 openDb 에 넘긴다.
*/
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
/**
* DB 를 열고 마이그레이션을 적용한다.
* @param {string} file 파일 경로. ':memory:' 면 메모리 DB(테스트).
* @param {Array<{id:string, sql:string}>} migrations 적용할 마이그레이션(순서대로)
* @returns {{db: DatabaseSync, get: Function, all: Function, run: Function, exec: Function, tx: Function, close: Function, file: string}}
*/
export function openDb(file, migrations = []) {
if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true });
const db = new DatabaseSync(file);
// 운영 안정성 설정. WAL 은 파일 DB 에서만 의미가 있다.
db.exec('PRAGMA foreign_keys = ON');
if (file !== ':memory:') { db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA synchronous = NORMAL'); }
db.exec('PRAGMA busy_timeout = 5000');
db.exec('CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)');
const applied = new Set(db.prepare('SELECT id FROM _migrations').all().map((r) => r.id));
for (const m of migrations) {
if (applied.has(m.id)) continue;
// 한 마이그레이션은 한 트랜잭션: 중간에 실패하면 전부 되돌려 반쪽 스키마가 남지 않게 한다.
db.exec('BEGIN');
try { db.exec(m.sql); db.prepare('INSERT INTO _migrations(id, applied_at) VALUES(?, ?)').run(m.id, new Date().toISOString()); db.exec('COMMIT'); }
catch (e) { db.exec('ROLLBACK'); throw new Error(`마이그레이션 실패 (${m.id}): ${e.message}`); }
}
let depth = 0; // 중첩 tx 깊이 — 안쪽 tx 는 BEGIN 을 다시 하지 않는다
const store = {
db, file,
get: (sql, ...p) => db.prepare(sql).get(...p),
all: (sql, ...p) => db.prepare(sql).all(...p),
run: (sql, ...p) => db.prepare(sql).run(...p),
exec: (sql) => db.exec(sql),
/**
* 즉시 잠금 트랜잭션. 쓰기 경쟁(예약·주문 중복)을 DB 수준에서 직렬화한다.
* 동기 함수만 받는다(node:sqlite 는 동기 API 라 await 사이에 잠금을 쥐고 있으면 안 된다).
*/
tx(fn) {
if (depth > 0) return fn(store);
db.exec('BEGIN IMMEDIATE'); depth++;
try { const r = fn(store); db.exec('COMMIT'); depth--; return r; }
catch (e) { depth--; try { db.exec('ROLLBACK'); } catch (_) {} throw e; }
},
close: () => db.close(),
};
return store;
}
/** JSON 열 읽기 도우미: 깨진 값은 fallback(화면이 깨지지 않게). */
export function parseJson(text, fallback = null) { if (text == null || text === '') return fallback; try { return JSON.parse(text); } catch (_) { return fallback; } }
/** JSON 열 쓰기 도우미 */
export const toJson = (v) => (v === undefined ? null : JSON.stringify(v));
/** 현재 시각(ISO, UTC) */
export const nowIso = () => new Date().toISOString();
+157
View File
@@ -0,0 +1,157 @@
/**
* lib/http.mjs — 의존성 없는 작은 HTTP 앱 프레임워크(라우터·JSON·쿠키·정적 파일·오류 형식).
*
* 왜 직접 만드나: 형제 제품(Webmaker·AriMail)과 같이 외부 npm 의존성 없이 운영하기 위해서다. 필요한 기능은
* 경로 매개변수(`/api/sites/:id`), JSON 본문, 쿠키, 신뢰 프록시 IP, 정적 파일, 일관된 오류 응답뿐이다.
*
* 핸들러: async (ctx) => 반환값
* - 객체를 돌려주면 JSON(200). `ctx.json(obj, status)`·`ctx.html(str)`·`ctx.redirect(url)` 로 다른 응답.
* - 예외: `ctx.error(status, code, message)` 가 만든 HttpError → { ok:false, error, message, detail? }. 그 밖의 예외는 500 + 기록.
* 미들웨어: app.use(async (ctx) => { ... }) — 모든 라우트 전에 실행(세션 해석 등). 반환값이 있으면 그것으로 응답(차단).
* 보안 기본값: 모든 동적 응답 `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `X-Frame-Options: SAMEORIGIN`.
* 접속 IP: nginx 가 붙인 `X-Real-IP` 를 소켓 주소가 루프백일 때만 믿는다(클라이언트가 넣은 헤더를 믿지 않음).
*/
import http from 'node:http';
import { readFileSync, statSync, createReadStream, existsSync } from 'node:fs';
import { join, normalize, extname } from 'node:path';
export class HttpError extends Error {
/** @param {number} status @param {string} code 기계용 오류 코드 @param {string} message 사용자용 설명 @param {object} [detail] 화면이 쓸 추가 정보(비밀 없음) */
constructor(status, code, message, detail) { super(message || code); this.status = status; this.code = code; this.detail = detail; }
}
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8', '.woff2': 'font/woff2' };
const MAX_BODY = 2 * 1024 * 1024; // JSON 본문 상한(2MB). 파일 업로드는 별도 스트리밍 라우트가 맡는다.
/** 쿠키 헤더 파싱 */
export function parseCookies(header) { const out = {}; for (const part of String(header || '').split(';')) { const i = part.indexOf('='); if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); } return out; }
/** Set-Cookie 문자열 만들기 */
export function cookie(name, value, { maxAge = 0, path = '/', secure = true, sameSite = 'Lax', httpOnly = true } = {}) {
return `${name}=${encodeURIComponent(value)}; Path=${path}; ${httpOnly ? 'HttpOnly; ' : ''}SameSite=${sameSite}${secure ? '; Secure' : ''}${maxAge ? `; Max-Age=${maxAge}` : '; Max-Age=0'}`;
}
/** 경로 패턴(`/a/:id/b`)을 정규식으로 */
function compile(pattern) {
const keys = []; const re = new RegExp('^' + pattern.replace(/\/+$/, '').replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '(.*)').replace(/:([a-zA-Z_]+)/g, (_, k) => { keys.push(k); return '([^/]+)'; }) + '/?$');
return { re, keys };
}
/**
* 앱 만들기.
* @param {{ log?: Function, trustProxy?: boolean, onError?: Function }} opts
*/
export function createApp(opts = {}) {
const routes = []; const middlewares = []; const statics = [];
const log = opts.log || ((...a) => console.log(...a));
const app = {
/** 라우트 등록. method 는 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'|'*' */
route(method, pattern, handler) { routes.push({ method: method.toUpperCase(), ...compile(pattern), pattern, handler }); return app; },
get: (p, h) => app.route('GET', p, h), post: (p, h) => app.route('POST', p, h), put: (p, h) => app.route('PUT', p, h), patch: (p, h) => app.route('PATCH', p, h), delete: (p, h) => app.route('DELETE', p, h),
use(fn) { middlewares.push(fn); return app; },
/** 정적 파일: prefix 아래 요청을 dir 에서 낸다(경로 탈출 차단). */
static(prefix, dir) { statics.push({ prefix: prefix.replace(/\/$/, ''), dir }); return app; },
/** 요청 하나 처리(테스트에서도 직접 부른다). */
async handle(req, res) {
const started = Date.now();
const url = new URL(req.url, 'http://localhost');
const ctx = makeCtx(req, res, url, opts);
try {
for (const s of statics) {
if (ctx.method === 'GET' && (url.pathname === s.prefix || url.pathname.startsWith(s.prefix + '/'))) {
const rel = normalize(url.pathname.slice(s.prefix.length) || '/').replace(/^(\.\.(\/|\\|$))+/, '');
const file = join(s.dir, rel);
if (!file.startsWith(s.dir)) throw new HttpError(404, 'not_found', '페이지를 찾을 수 없습니다');
if (existsSync(file) && statSync(file).isFile()) return sendFile(res, file);
}
}
for (const m of middlewares) { const r = await m(ctx); if (r !== undefined) return finish(ctx, r); if (ctx.ended) return; }
for (const r of routes) {
if (r.method !== '*' && r.method !== ctx.method) continue;
const m = r.re.exec(url.pathname); if (!m) continue;
r.keys.forEach((k, i) => { ctx.params[k] = decodeURIComponent(m[i + 1]); });
const out = await r.handler(ctx);
return finish(ctx, out);
}
throw new HttpError(404, 'not_found', '페이지를 찾을 수 없습니다');
} catch (e) { return sendError(ctx, e, log, opts); }
finally { if (opts.access) log(`${ctx.method} ${url.pathname} ${res.statusCode} ${Date.now() - started}ms`); }
},
/** 서버 시작 */
listen(port, host = '127.0.0.1') { const server = http.createServer((req, res) => app.handle(req, res)); server.keepAliveTimeout = 65000; return new Promise((resolve) => server.listen(port, host, () => resolve(server))); },
routes,
};
return app;
}
/** 요청 컨텍스트 */
function makeCtx(req, res, url, opts) {
const sock = req.socket?.remoteAddress || '';
const loop = sock === '127.0.0.1' || sock === '::1' || sock === '::ffff:127.0.0.1';
const ip = (opts.trustProxy !== false && loop && req.headers['x-real-ip']) ? String(req.headers['x-real-ip']) : sock.replace(/^::ffff:/, '');
let bodyCache;
const ctx = {
req, res, method: req.method.toUpperCase(), path: url.pathname, url, params: {}, query: Object.fromEntries(url.searchParams), cookies: parseCookies(req.headers.cookie), ip, headers: req.headers,
user: null, session: null, ended: false, status: 200, outHeaders: {},
secure: req.headers['x-forwarded-proto'] === 'https' || !!req.socket?.encrypted,
/** JSON 본문(한 번만 읽고 캐시). 비어 있으면 {} */
async body() {
if (bodyCache !== undefined) return bodyCache;
const chunks = []; let size = 0;
for await (const c of req) { size += c.length; if (size > MAX_BODY) throw new HttpError(413, 'body_too_large', '요청 본문이 너무 큽니다'); chunks.push(c); }
const text = Buffer.concat(chunks).toString('utf8');
if (!text) return (bodyCache = {});
const ct = String(req.headers['content-type'] || '');
if (ct.includes('application/x-www-form-urlencoded')) return (bodyCache = Object.fromEntries(new URLSearchParams(text)));
try { return (bodyCache = JSON.parse(text)); } catch (_) { throw new HttpError(400, 'bad_json', '요청 본문이 올바른 JSON 이 아닙니다'); }
},
set(status, headers = {}) { ctx.status = status; Object.assign(ctx.outHeaders, headers); },
setCookie(str) { const prev = ctx.outHeaders['Set-Cookie']; ctx.outHeaders['Set-Cookie'] = prev ? [].concat(prev, str) : str; },
json(obj, status = 200) { ctx.status = status; return { __json: obj }; },
html(str, status = 200) { ctx.status = status; return { __html: str }; },
text(str, status = 200) { ctx.status = status; return { __text: str }; },
redirect(url, status = 302) { ctx.status = status; ctx.outHeaders.Location = url; return { __text: '' }; },
error(status, code, message, detail) { return new HttpError(status, code, message, detail); },
};
return ctx;
}
function baseHeaders(ctx) { return { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-Frame-Options': 'SAMEORIGIN', ...ctx.outHeaders }; }
function finish(ctx, out) {
if (ctx.ended) return; ctx.ended = true;
const h = baseHeaders(ctx);
if (out && out.__html !== undefined) { ctx.res.writeHead(ctx.status, { 'Content-Type': 'text/html; charset=utf-8', ...h }); return ctx.res.end(out.__html); }
if (out && out.__text !== undefined) { ctx.res.writeHead(ctx.status, { 'Content-Type': 'text/plain; charset=utf-8', ...h }); return ctx.res.end(out.__text); }
const body = out && out.__json !== undefined ? out.__json : out;
ctx.res.writeHead(ctx.status, { 'Content-Type': 'application/json; charset=utf-8', ...h });
ctx.res.end(JSON.stringify(body === undefined ? { ok: true } : body));
}
function sendError(ctx, e, log, opts) {
if (ctx.ended) return;
const status = e instanceof HttpError ? e.status : (e && e.status) || 500;
const code = e instanceof HttpError ? e.code : (e && e.code) || 'server_error';
if (status >= 500) { log(`[http] ${ctx.method} ${ctx.path} 500: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); if (opts.onError) try { opts.onError(e, ctx); } catch (_) {} }
ctx.ended = true;
ctx.res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...baseHeaders(ctx) });
ctx.res.end(JSON.stringify({ ok: false, error: code, message: status >= 500 ? '일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요' : (e.message || code), ...(e.detail ? { detail: e.detail } : {}) }));
}
function sendFile(res, file) {
const type = MIME[extname(file).toLowerCase()] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' });
createReadStream(file).pipe(res);
}
/** 간단한 HTML 이스케이프 */
export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
/** 테스트용: 앱에 가짜 요청을 보내고 { status, headers, json, text } 를 돌려준다(소켓 없이). */
export async function invoke(app, method, path, { body, headers = {}, cookies = {}, ip = '127.0.0.1' } = {}) {
const { PassThrough } = await import('node:stream');
const req = new PassThrough(); req.method = method; req.url = path;
req.headers = { 'content-type': 'application/json', ...headers };
if (Object.keys(cookies).length) req.headers.cookie = Object.entries(cookies).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; ');
req.socket = { remoteAddress: ip };
if (body !== undefined) req.end(typeof body === 'string' ? body : JSON.stringify(body)); else req.end();
const out = { status: 0, headers: {}, chunks: [] };
const res = { writeHead(s, h) { out.status = s; out.headers = h || {}; }, end(d) { if (d) out.chunks.push(Buffer.from(d)); out.done = true; resolve(); }, write(d) { out.chunks.push(Buffer.from(d)); }, statusCode: 200, on() {}, once() {}, emit() {} };
let resolve; const p = new Promise((r) => { resolve = r; });
await app.handle(req, res); await p;
const text = Buffer.concat(out.chunks).toString('utf8');
let json = null; try { json = JSON.parse(text); } catch (_) {}
const setCookie = [].concat(out.headers['Set-Cookie'] || []);
return { status: out.status, headers: out.headers, text, json, cookies: Object.fromEntries(setCookie.map((c) => { const [kv] = c.split(';'); const i = kv.indexOf('='); return [kv.slice(0, i), decodeURIComponent(kv.slice(i + 1))]; })) };
}
+27
View File
@@ -0,0 +1,27 @@
/**
* lib/i18n.mjs — 화면 문구 사전. 문구를 코드에 박지 않고 lang/ko.json 에서 읽는다(§9 규약).
* t('auth.login.failed') → 문자열. {name} 자리표시자는 vars 로 치환. 없는 키는 키 그대로 돌려줘 누락이 화면에 드러난다.
*/
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
let dict = null; let loadedAt = 0;
/** lang/ko/*.json 을 모두 읽어 깊게 합친다(영역마다 파일을 나눠 충돌 없이 관리). 운영 중 수정은 5초 안에 반영. */
function load() {
if (dict && Date.now() - loadedAt < 5000) return dict;
const dir = join(ROOT, 'lang', 'ko'); const merged = {};
const deepMerge = (a, b) => { for (const [k, v] of Object.entries(b)) { if (v && typeof v === 'object' && !Array.isArray(v) && a[k] && typeof a[k] === 'object') deepMerge(a[k], v); else a[k] = v; } return a; };
try { for (const f of readdirSync(dir).filter((x) => x.endsWith('.json')).sort()) { try { deepMerge(merged, JSON.parse(readFileSync(join(dir, f), 'utf8'))); } catch (e) { console.error(`[i18n] ${f} 읽기 실패: ${e.message}`); } } } catch (_) {}
dict = merged; loadedAt = Date.now(); return dict;
}
/** 점(.)으로 중첩 키를 찾는다 */
function lookup(obj, key) { return key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), obj); }
export function t(key, vars = {}) {
const v = lookup(load(), key);
const s = typeof v === 'string' ? v : key;
return s.replace(/\{(\w+)\}/g, (_, k) => (vars[k] !== undefined ? String(vars[k]) : `{${k}}`));
}
/** 사전 전체(화면 JS 에 내려보낼 때) */
export function all() { return load(); }
+212
View File
@@ -0,0 +1,212 @@
/**
* lib/jobs.mjs — 내구성 작업 엔진(§2.2·§29·§36).
*
* 생각의 틀
* 배포·복원·이전·백업·주문 처리처럼 "오래 걸리고 중간에 프로세스가 죽을 수 있는" 일은 모두 작업(job)이다.
* 작업은 DB 행이며 단계(step)마다 결과를 저장한다. 프로세스가 재시작되면 끝난 단계는 건너뛰고 다음 단계부터 이어서 실행한다.
* 완료되지 않은 작업을 실패로 숨기지 않고, 재시도 횟수·다음 시각·외부 대기 사유·사용자 확인 필요를 상태로 드러낸다.
*
* 핵심 규약
* - register(kind, def): def = { title, steps:[{id,title}], run(job, ctx), revert?(job, ctx), maxAttempts?, retryable?(err) }
* - run() 은 **재진입 가능**해야 한다: 같은 작업으로 여러 번 불릴 수 있으므로 부수 효과가 있는 일은 ctx.step(id, fn) 안에서 한다.
* ctx.step 은 이미 done 인 단계면 저장된 결과를 돌려주고 fn 을 실행하지 않는다.
* - 같은 idempotencyKey 로 enqueue 하면 새 작업을 만들지 않고 기존 작업을 돌려준다(버튼 연타·새로 고침 §36).
* - 일시 오류(err.retryable=true 또는 def.retryable(err)) 는 지수 백오프로 재시도하고 attempt·next_run_at 을 보인다.
* - ctx.waitExternal(reason, {resumeAt}) → AWAITING_EXTERNAL. ctx.needsUser(message, actions) → NEEDS_USER. resume(id) 로 다시 QUEUED.
* - 되돌리기: revert(id) 는 def.revert 를 실행하며 REVERTING → REVERTED(실패하면 FAILED + error). 되돌리기 전 데이터 영향은 호출자가 안내한다.
* - 부모/자식: parentId 로 대상별 작업(여러 서버 모듈 갱신 §29)을 묶고 childrenSummary 로 대상별 성공·실패·대기를 보인다. 일부 실패를 전체 성공으로 보이지 않는다.
*/
import { newId } from './crypto.mjs';
import { redact, safeError } from './redact.mjs';
import { nowIso, toJson, parseJson } from './db.mjs';
import { JOB_STATUS as S, TERMINAL, CANCELLABLE } from './status.mjs';
export const MIGRATIONS = [{ id: 'lib_jobs_0001', sql: `
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY, kind TEXT NOT NULL, status TEXT NOT NULL, label TEXT, subject_json TEXT, steps_json TEXT, attempt INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 1,
next_run_at TEXT, locked_by TEXT, locked_at TEXT, result_json TEXT, error TEXT, error_code TEXT, wait_reason TEXT, user_message TEXT, actions_json TEXT, eta_at TEXT, progress INTEGER NOT NULL DEFAULT 0,
idempotency_key TEXT UNIQUE, created_by TEXT, actor_type TEXT, group_id TEXT, subject_type TEXT, subject_id TEXT, parent_id TEXT, external_ref TEXT, cancel_requested INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, started_at TEXT, ended_at TEXT, log_json TEXT
);
CREATE INDEX IF NOT EXISTS jobs_status ON jobs(status, next_run_at);
CREATE INDEX IF NOT EXISTS jobs_group ON jobs(group_id, created_at);
CREATE INDEX IF NOT EXISTS jobs_subject ON jobs(subject_type, subject_id, created_at);
CREATE INDEX IF NOT EXISTS jobs_parent ON jobs(parent_id);` }];
/** run() 안에서 흐름을 바꾸는 신호(예외로 전달) */
class Signal extends Error { constructor(type, payload) { super(type); this.signal = type; this.payload = payload || {}; } }
export class JobError extends Error { constructor(code, message, { retryable = false, detail } = {}) { super(message || code); this.code = code; this.retryable = retryable; this.detail = detail; } }
const BACKOFF_BASE_MS = 30 * 1000; const BACKOFF_MAX_MS = 60 * 60 * 1000; const STALE_LOCK_MS = 10 * 60 * 1000;
/**
* 작업 엔진 만들기.
* @param {object} store lib/db
* @param {{workerId?:string, pollMs?:number, log?:Function}} opts
*/
export function createJobEngine(store, opts = {}) {
const kinds = new Map(); const listeners = new Set();
const workerId = opts.workerId || `${process.pid}@${(await0() || 'host')}`; const log = opts.log || (() => {});
let timer = null; let running = 0; let stopped = true; const concurrency = opts.concurrency || 4;
function row2job(r) { if (!r) return null; return { ...r, subject: parseJson(r.subject_json, {}), steps: parseJson(r.steps_json, []), result: parseJson(r.result_json, null), actions: parseJson(r.actions_json, null), log: parseJson(r.log_json, []) }; }
function emit(job) { for (const fn of listeners) { try { fn(job); } catch (e) { log('[jobs] listener error', e.message); } } }
function save(id, patch) {
const sets = []; const vals = [];
for (const [k, v] of Object.entries(patch)) { sets.push(`${k}=?`); vals.push(v && typeof v === 'object' ? toJson(v) : v); }
sets.push('updated_at=?'); vals.push(nowIso());
store.run(`UPDATE jobs SET ${sets.join(', ')} WHERE id=?`, ...vals, id);
const j = engine.get(id); emit(j); return j;
}
const engine = {
workerId,
register(kind, def) { kinds.set(kind, { maxAttempts: 1, steps: [], ...def }); return engine; },
kinds: () => [...kinds.keys()],
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
/**
* 작업 넣기. idempotencyKey 가 같은 작업이 있으면 그것을 돌려준다(created=false).
* @returns {{job:object, created:boolean}}
*/
enqueue({ kind, subject = {}, idempotencyKey = null, createdBy = null, actorType = 'user', groupId = null, subjectType = null, subjectId = null, label = null, parentId = null, runAt = null, maxAttempts = null, initialStatus = null }) {
const def = kinds.get(kind); if (!def) throw new JobError('unknown_job_kind', `알 수 없는 작업 종류: ${kind}`);
return store.tx(() => {
if (idempotencyKey) { const ex = store.get('SELECT * FROM jobs WHERE idempotency_key=?', idempotencyKey); if (ex) return { job: row2job(ex), created: false }; }
const id = newId('j_'); const now = nowIso();
const steps = (def.steps || []).map((s) => ({ id: s.id, title: s.title, state: 'pending' }));
store.run('INSERT INTO jobs(id, kind, status, label, subject_json, steps_json, attempt, max_attempts, next_run_at, idempotency_key, created_by, actor_type, group_id, subject_type, subject_id, parent_id, created_at, updated_at, log_json) VALUES(?,?,?,?,?,?,0,?,?,?,?,?,?,?,?,?,?,?,?)',
id, kind, initialStatus || S.QUEUED, label || def.title || kind, toJson(subject), toJson(steps), maxAttempts || def.maxAttempts || 1, runAt || now, idempotencyKey, createdBy, actorType, groupId, subjectType, subjectId, parentId, now, now, '[]');
const job = engine.get(id); emit(job); return { job, created: true };
});
},
get: (id) => row2job(store.get('SELECT * FROM jobs WHERE id=?', id)),
list({ groupId, status, kind, subjectType, subjectId, parentId, limit = 100 } = {}) {
const w = []; const p = [];
if (groupId) { w.push('group_id=?'); p.push(groupId); } if (status) { w.push(Array.isArray(status) ? `status IN (${status.map(() => '?').join(',')})` : 'status=?'); p.push(...[].concat(status)); }
if (kind) { w.push('kind=?'); p.push(kind); } if (subjectType) { w.push('subject_type=?'); p.push(subjectType); } if (subjectId) { w.push('subject_id=?'); p.push(subjectId); } if (parentId) { w.push('parent_id=?'); p.push(parentId); }
return store.all(`SELECT * FROM jobs ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY created_at DESC LIMIT ?`, ...p, Math.min(limit, 1000)).map(row2job);
},
/** 자식 작업 요약(대상별 성공·실패·대기). 일부 실패는 'partial' */
childrenSummary(parentId) {
const kids = engine.list({ parentId, limit: 1000 }); const by = {}; for (const k of kids) by[k.status] = (by[k.status] || 0) + 1;
const failed = kids.filter((k) => ['failed', 'needs_user'].includes(k.status)).length; const done = kids.filter((k) => k.status === 'done').length;
return { total: kids.length, byStatus: by, overall: kids.length === 0 ? 'none' : done === kids.length ? 'done' : failed && done ? 'partial' : failed === kids.length ? 'failed' : 'running', children: kids.map((k) => ({ id: k.id, label: k.label, status: k.status, subject: k.subject, error: k.error, attempt: k.attempt, next_run_at: k.next_run_at })) };
},
/** 외부 대기·사용자 확인·일시 중지 상태의 작업을 다시 큐에 넣는다 */
resume(id, by = null) { const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다'); if (![S.AWAITING_EXTERNAL, S.NEEDS_USER, S.PAUSED, S.AWAITING_APPROVAL, S.AWAITING_PAYMENT, S.NOT_READY, S.NEEDS_SETUP].includes(j.status)) throw new JobError('job_not_resumable', `지금 상태(${j.status})에서는 이어서 할 수 없습니다`); return save(id, { status: S.QUEUED, next_run_at: nowIso(), wait_reason: null, user_message: null, actions_json: null, log_json: appendLog(j, `재개 (${by || 'system'})`) }); },
/** 실패한 작업 재시도(사용자·운영자 명시) */
retry(id, by = null) { const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다'); if (![S.FAILED, S.NEEDS_USER].includes(j.status)) throw new JobError('job_not_retryable', `지금 상태(${j.status})에서는 재시도할 수 없습니다`); return save(id, { status: S.QUEUED, next_run_at: nowIso(), error: null, error_code: null, max_attempts: j.attempt + 1, log_json: appendLog(j, `재시도 요청 (${by || 'system'})`) }); },
pause(id, by = null) { const j = engine.get(id); if (!j || ![S.QUEUED, S.AWAITING_EXTERNAL].includes(j.status)) throw new JobError('job_not_pausable', '지금 상태에서는 일시 중지할 수 없습니다'); return save(id, { status: S.PAUSED, log_json: appendLog(j, `일시 중지 (${by || 'system'})`) }); },
/** 취소: 대기 상태면 즉시, 실행 중이면 취소 요청 표시(ctx.checkCancel 에서 멈춤) */
cancel(id, by = null) {
const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다');
if (CANCELLABLE.has(j.status)) return save(id, { status: S.CANCELLED, ended_at: nowIso(), log_json: appendLog(j, `취소 (${by || 'system'})`) });
if (j.status === S.RUNNING) return save(id, { cancel_requested: 1, log_json: appendLog(j, `취소 요청 (${by || 'system'})`) });
throw new JobError('job_not_cancellable', `지금 상태(${j.status})에서는 취소할 수 없습니다`);
},
/** 되돌리기 */
async revert(id, by = null) {
const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다');
const def = kinds.get(j.kind); if (!def || !def.revert) throw new JobError('job_not_revertible', '이 작업은 되돌리기를 지원하지 않습니다');
if (![S.DONE, S.FAILED, S.NEEDS_USER].includes(j.status)) throw new JobError('job_not_revertible', `지금 상태(${j.status})에서는 되돌릴 수 없습니다`);
save(id, { status: S.REVERTING, log_json: appendLog(j, `되돌리기 시작 (${by || 'system'})`) });
const ctx = makeCtx(engine.get(id), { reverting: true });
try { await def.revert(engine.get(id), ctx); return save(id, { status: S.REVERTED, ended_at: nowIso(), steps_json: ctx.steps(), log_json: ctx.logs() }); }
catch (e) { return save(id, { status: S.FAILED, error: safeError(e), error_code: e.code || 'revert_failed', steps_json: ctx.steps(), log_json: ctx.logs(), ended_at: nowIso() }); }
},
/** 외부(HAL 등)에서 진행 상태를 밀어 넣을 때 */
update(id, patch) { return save(id, patch); },
/** 프로세스 시작 시: 이전 실행에서 RUNNING 으로 남은 작업(잠금 소유자 불문, 단일 프로세스 운영)을 QUEUED 로 되돌려 이어서 실행한다 */
recover() {
const stale = store.all('SELECT * FROM jobs WHERE status=?', S.RUNNING);
for (const r of stale) { const j = row2job(r); save(j.id, { status: S.QUEUED, locked_by: null, locked_at: null, next_run_at: nowIso(), log_json: appendLog(j, '프로세스 재시작 — 끝난 단계 다음부터 이어서 실행') }); }
return stale.length;
},
/** 실행 가능한 작업 하나를 집어 실행(테스트에서도 직접 부름). 돌아온 값: 실행한 작업 또는 null */
async tick() {
if (running >= concurrency) return null;
const now = nowIso();
const cand = store.get('SELECT id FROM jobs WHERE status=? AND (next_run_at IS NULL OR next_run_at<=?) ORDER BY next_run_at ASC, created_at ASC LIMIT 1', S.QUEUED, now);
if (!cand) return null;
const got = store.run('UPDATE jobs SET status=?, locked_by=?, locked_at=?, started_at=COALESCE(started_at, ?), attempt=attempt+1, updated_at=? WHERE id=? AND status=?', S.RUNNING, workerId, now, now, now, cand.id, S.QUEUED).changes;
if (!got) return null;
running++;
try { return await execute(cand.id); } finally { running--; }
},
/** 모든 큐 작업을 비울 때까지 tick(테스트용) */
async drain(max = 100) { let n = 0; while (n < max) { const r = await engine.tick(); if (!r) break; n++; } return n; },
start() { stopped = false; engine.recover(); const loop = async () => { if (stopped) return; try { let n = 0; while (n < concurrency && (await engine.tick())) n++; } catch (e) { log('[jobs] tick error', safeError(e)); } timer = setTimeout(loop, opts.pollMs || 1000); }; loop(); return engine; },
stop() { stopped = true; if (timer) clearTimeout(timer); },
};
function appendLog(job, line) { const l = (job.log || []).slice(-200); l.push({ at: nowIso(), line: String(line).slice(0, 500) }); return l; }
function makeCtx(job, { reverting = false } = {}) {
const steps = (job.steps || []).map((s) => ({ ...s })); const logs = (job.log || []).slice(-200);
const ctx = {
job, reverting, workerId,
/** 단계 실행. 이미 done 이면 저장된 결과를 돌려준다. 결과는 JSON 직렬화 가능해야 한다. */
async step(id, fn, title) {
let s = steps.find((x) => x.id === id); if (!s) { s = { id, title: title || id, state: 'pending' }; steps.push(s); }
if (s.state === 'done' && !reverting) return s.result;
s.state = 'running'; s.startedAt = nowIso(); delete s.error; ctx.flush();
try { const result = await fn(); s.state = 'done'; s.endedAt = nowIso(); s.result = result === undefined ? null : redact(result); ctx.flush(); return result; }
catch (e) { if (e instanceof Signal) { s.state = e.signal === 'wait' ? 'waiting' : e.signal === 'needs_user' ? 'needs_user' : 'pending'; s.detail = e.payload.reason || e.payload.message || ''; ctx.flush(); throw e; } s.state = 'failed'; s.endedAt = nowIso(); s.error = safeError(e); ctx.flush(); throw e; }
},
log(line) { logs.push({ at: nowIso(), line: String(line).slice(0, 500) }); ctx.flush(); },
progress(pct) { store.run('UPDATE jobs SET progress=?, updated_at=? WHERE id=?', Math.max(0, Math.min(100, Math.round(pct))), nowIso(), job.id); },
eta(at) { store.run('UPDATE jobs SET eta_at=?, updated_at=? WHERE id=?', at || null, nowIso(), job.id); },
/** 외부 작업(사용자 DNS 변경·운영자 승인 등)을 기다린다. run() 은 여기서 끝나고 resume() 뒤 다시 불린다. */
waitExternal(reason, { resumeAt = null } = {}) { throw new Signal('wait', { reason, resumeAt }); },
/** 사용자 확인이 필요해 멈춘다. actions: [{id,label,description}] */
needsUser(message, actions = null) { throw new Signal('needs_user', { message, actions }); },
/** 일시 오류로 나중에 다시(지수 백오프 대신 지정 시각) */
retryLater(ms, reason) { throw new Signal('retry', { ms, reason }); },
/** 취소 요청이 들어왔으면 멈춘다 */
checkCancel() { const r = store.get('SELECT cancel_requested FROM jobs WHERE id=?', job.id); if (r && r.cancel_requested) throw new Signal('cancel', {}); },
steps: () => steps, logs: () => logs,
flush() { store.run('UPDATE jobs SET steps_json=?, log_json=?, updated_at=? WHERE id=?', toJson(steps), toJson(logs), nowIso(), job.id); },
};
return ctx;
}
async function execute(id) {
const job = engine.get(id); const def = kinds.get(job.kind);
if (!def) return save(id, { status: S.FAILED, error: `알 수 없는 작업 종류: ${job.kind}`, error_code: 'unknown_job_kind', ended_at: nowIso() });
const ctx = makeCtx(job);
try {
const result = await def.run(job, ctx);
return save(id, { status: S.DONE, result_json: redact(result === undefined ? null : result), progress: 100, steps_json: ctx.steps(), log_json: ctx.logs(), ended_at: nowIso(), locked_by: null, error: null, error_code: null });
} catch (e) {
if (e instanceof Signal) {
// 단계 밖에서 대기·확인 신호가 나면 다음 미완료 단계에 그 상태를 표시해 화면이 "현재 단계" 를 보일 수 있게 한다
if (['wait', 'needs_user'].includes(e.signal)) { const st = ctx.steps(); if (!st.some((x) => ['waiting', 'needs_user'].includes(x.state))) { const nx = st.find((x) => x.state === 'pending'); if (nx) { nx.state = e.signal === 'wait' ? 'waiting' : 'needs_user'; nx.detail = e.payload.reason || e.payload.message || ''; } } }
if (e.signal === 'wait') return save(id, { status: S.AWAITING_EXTERNAL, wait_reason: e.payload.reason || null, eta_at: e.payload.resumeAt || null, next_run_at: e.payload.resumeAt || null, steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null });
if (e.signal === 'needs_user') return save(id, { status: S.NEEDS_USER, user_message: e.payload.message || null, actions_json: e.payload.actions || null, steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null });
if (e.signal === 'cancel') return save(id, { status: S.CANCELLED, ended_at: nowIso(), steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null, cancel_requested: 0 });
if (e.signal === 'retry') { const at = new Date(Date.now() + (e.payload.ms || BACKOFF_BASE_MS)).toISOString(); return save(id, { status: S.QUEUED, next_run_at: at, wait_reason: e.payload.reason || null, max_attempts: Math.max(job.max_attempts, job.attempt + 1), steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null }); }
}
const retryable = !!(e && e.retryable) || (def.retryable ? !!def.retryable(e) : false);
const fresh = engine.get(id);
if (retryable && fresh.attempt < fresh.max_attempts) {
const backoff = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (fresh.attempt - 1));
return save(id, { status: S.QUEUED, next_run_at: new Date(Date.now() + backoff).toISOString(), error: safeError(e), error_code: e.code || 'transient', steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null });
}
log(`[jobs] ${job.kind} ${id} 실패: ${safeError(e)}`);
return save(id, { status: S.FAILED, error: safeError(e), error_code: e.code || 'failed', steps_json: ctx.steps(), log_json: ctx.logs(), ended_at: nowIso(), locked_by: null });
}
}
return engine;
}
function await0() { try { return (process.env.HOSTNAME || '').slice(0, 20); } catch (_) { return ''; } }
/** 화면용 요약: 현재 단계·완료 단계·다음 단계·재시도 정보(§2.2) */
export function describe(job) {
if (!job) return null;
const steps = job.steps || []; const doneSteps = steps.filter((s) => s.state === 'done'); const current = steps.find((s) => ['running', 'waiting', 'needs_user', 'failed'].includes(s.state)) || steps.find((s) => s.state === 'pending') || null;
const idx = current ? steps.indexOf(current) : steps.length; const next = steps[idx + 1] || null;
return {
id: job.id, kind: job.kind, label: job.label, status: job.status, progress: job.progress, attempt: job.attempt, maxAttempts: job.max_attempts, nextRunAt: job.next_run_at, etaAt: job.eta_at || null,
etaUnknownReason: job.eta_at ? null : '외부 반영 시간에 따라 달라져 산정할 수 없습니다', updatedAt: job.updated_at, startedAt: job.started_at, endedAt: job.ended_at,
currentStep: current ? { id: current.id, title: current.title, state: current.state, detail: current.detail || current.error || null } : null,
completedSteps: doneSteps.map((s) => ({ id: s.id, title: s.title })), nextStep: next ? { id: next.id, title: next.title } : null, steps,
waitReason: job.wait_reason, userMessage: job.user_message, actions: job.actions, error: job.error, errorCode: job.error_code,
canCancel: CANCELLABLE.has(job.status) || job.status === 'running', canRetry: ['failed', 'needs_user'].includes(job.status), canResume: ['awaiting_external', 'needs_user', 'paused'].includes(job.status), terminal: TERMINAL.has(job.status),
stale: job.status === 'running' && Date.now() - Date.parse(job.updated_at) > 10 * 60 * 1000,
};
}
+25
View File
@@ -0,0 +1,25 @@
/**
* lib/kst.mjs — 한국 표준시(KST, UTC+9) 월 경계 계산.
*
* 월간 사용량(웹 아웃바운드·메일 통수)은 "KST 월" 기준으로 집계·초기화한다(§13.3·§13.4·§17.2).
* UTC 로 저장된 시각을 KST 월로 나누려면 9시간을 더해 날짜를 구하고, 월 시작을 다시 UTC 로 돌린다.
* 테스트가 고정 시각으로 검증할 수 있게 모든 함수는 `at` 을 인자로 받는다(AriMail usage.test 의 월 경계 오류 재발 방지).
*/
const KST_OFFSET_MS = 9 * 3600 * 1000;
/** at 이 속한 KST 월의 시작(UTC ISO). 예: 2026-10-31T16:00:00Z(=11-01 01:00 KST) → 2026-10-31T15:00:00.000Z */
export function monthStart(at = new Date()) {
const k = new Date(new Date(at).getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), 1) - KST_OFFSET_MS).toISOString();
}
/** 다음 KST 월 시작(UTC ISO) = 월간 사용량 초기화 시각 */
export function nextMonthStart(at = new Date()) {
const k = new Date(new Date(at).getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth() + 1, 1) - KST_OFFSET_MS).toISOString();
}
/** KST 기준 YYYY-MM-DD */
export function kstDate(at = new Date()) { return new Date(new Date(at).getTime() + KST_OFFSET_MS).toISOString().slice(0, 10); }
/** KST 기준 YYYY-MM */
export function kstMonth(at = new Date()) { return kstDate(at).slice(0, 7); }
/** 화면 표시용 "YYYY-MM-DD HH:mm (KST)" */
export function fmtKst(at) { if (!at) return ''; const k = new Date(new Date(at).getTime() + KST_OFFSET_MS); return k.toISOString().slice(0, 16).replace('T', ' ') + ' KST'; }
+59
View File
@@ -0,0 +1,59 @@
/**
* lib/notify.mjs — 알림 센터(§24.1).
*
* 분류(category): billing | site | domain | mail | usage | backup | relocation | security | incident | membership | ops
* 중요도(severity): info | warning | critical. mandatory=1 은 필수 알림(서비스 중단·보안·결제·데이터 삭제 예정) → 외부 이메일도 보낸다.
* 같은 원인의 반복 알림은 incident_key 로 하나의 사건으로 묶어 count·last_at·title/body 를 갱신한다(§24.1).
* link 는 사용자가 조치할 정확한 화면(§2.3). group_id 가 없으면 운영자 알림.
* 외부 이메일은 setMailer(fn) 로 끼운 발송기(포털이 AriMail/Resend 로 보냄)가 담당하며, 실패해도 알림 자체는 남는다.
*/
import { newId } from './crypto.mjs';
import { redact } from './redact.mjs';
import { nowIso, toJson, parseJson } from './db.mjs';
export const MIGRATIONS = [{ id: 'lib_notify_0001', sql: `
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY, group_id TEXT, user_id TEXT, category TEXT NOT NULL, severity TEXT NOT NULL DEFAULT 'info', mandatory INTEGER NOT NULL DEFAULT 0,
incident_key TEXT, title TEXT NOT NULL, body TEXT, link TEXT, subject_type TEXT, subject_id TEXT, action_required TEXT,
state TEXT NOT NULL DEFAULT 'open', count INTEGER NOT NULL DEFAULT 1, first_at TEXT NOT NULL, last_at TEXT NOT NULL, read_at TEXT, resolved_at TEXT, email_sent_at TEXT, email_error TEXT, detail_json TEXT
);
CREATE INDEX IF NOT EXISTS notif_group ON notifications(group_id, state, last_at);
CREATE INDEX IF NOT EXISTS notif_incident ON notifications(incident_key, state);` }];
let mailer = null;
/** 외부 이메일 발송기 등록: async ({groupId, userId, title, body, link}) => void */
export function setMailer(fn) { mailer = fn; }
/**
* 알림 만들기(또는 같은 사건 갱신).
* @returns {{id:string, merged:boolean}}
*/
export function notify(store, n) {
const now = nowIso(); const detail = toJson(redact(n.detail || null));
if (n.incidentKey) {
const open = store.get('SELECT id, count FROM notifications WHERE incident_key=? AND state=\'open\'', n.incidentKey);
if (open) { store.run('UPDATE notifications SET count=count+1, last_at=?, title=?, body=?, link=COALESCE(?, link), severity=?, action_required=?, detail_json=?, read_at=NULL WHERE id=?', now, n.title, n.body || null, n.link || null, n.severity || 'info', n.actionRequired || null, detail, open.id); return { id: open.id, merged: true }; }
}
const id = newId('n_');
store.run('INSERT INTO notifications(id, group_id, user_id, category, severity, mandatory, incident_key, title, body, link, subject_type, subject_id, action_required, state, count, first_at, last_at, detail_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,\'open\',1,?,?,?)',
id, n.groupId || null, n.userId || null, n.category, n.severity || 'info', n.mandatory ? 1 : 0, n.incidentKey || null, n.title, n.body || null, n.link || null, n.subjectType || null, n.subjectId || null, n.actionRequired || null, now, now, detail);
// 필수 알림·critical 은 외부 이메일(§24.1). 발송 실패는 email_error 에 남긴다(알림은 유지).
if ((n.mandatory || n.severity === 'critical' || n.email) && mailer) {
Promise.resolve().then(() => mailer({ groupId: n.groupId, userId: n.userId, title: n.title, body: n.body, link: n.link }))
.then(() => store.run('UPDATE notifications SET email_sent_at=? WHERE id=?', nowIso(), id))
.catch((e) => { try { store.run('UPDATE notifications SET email_error=? WHERE id=?', String(e.message || e).slice(0, 300), id); } catch (_) {} });
}
return { id, merged: false };
}
/** 사건 종료(같은 incident_key 의 열린 알림을 resolved 로) */
export function resolveIncident(store, incidentKey, note = '') { return store.run('UPDATE notifications SET state=\'resolved\', resolved_at=?, body=CASE WHEN ?<>\'\' THEN ? ELSE body END WHERE incident_key=? AND state=\'open\'', nowIso(), note, note, incidentKey).changes; }
/** 목록(그룹 기준, 최신순). unreadOnly / category 필터 */
export function listNotifications(store, { groupId, userId, category, unreadOnly = false, limit = 100 } = {}) {
const w = []; const p = [];
if (groupId !== undefined) { w.push(groupId === null ? 'group_id IS NULL' : '(group_id=? OR (group_id IS NULL AND user_id=?))'); if (groupId !== null) p.push(groupId, userId || ''); }
if (category) { w.push('category=?'); p.push(category); }
if (unreadOnly) w.push('read_at IS NULL');
return store.all(`SELECT * FROM notifications ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY last_at DESC LIMIT ?`, ...p, Math.min(limit, 500)).map((r) => ({ ...r, detail: parseJson(r.detail_json, null) }));
}
export function markRead(store, id, groupId) { return store.run('UPDATE notifications SET read_at=? WHERE id=? AND (group_id=? OR group_id IS NULL) AND read_at IS NULL', nowIso(), id, groupId || null).changes; }
export function unreadCount(store, groupId) { return store.get('SELECT COUNT(*) AS n FROM notifications WHERE group_id=? AND read_at IS NULL AND state=\'open\'', groupId)?.n || 0; }
+150
View File
@@ -0,0 +1,150 @@
/**
* lib/quota.mjs — 상품 권리(entitlement)·서비스별 배정(allocation)·진행 중 예약(reservation) (§13·§14·§6.4).
*
* 세 층
* entitlement 그룹이 구매해 활성화된 총량(상품정책 버전 참조). 결제·승인 전에는 만들지 않는다(§6.2).
* allocation 사이트·메일 서비스에 배정된 양(사용 중). 실제 사용량(usage)보다 작게 줄일 수 없다.
* reservation 진행 중 작업(최초 공개 배포·메일 생성 등)이 잡아 둔 양. 성공하면 commit → allocation, 실패·취소·되돌리기면 release → 반환.
* 불변식: Σ(allocation) + Σ(reserved) ≤ Σ(active entitlement). reserve/commit/setAllocation 은 BEGIN IMMEDIATE 트랜잭션 안에서 검사해
* 마지막 남은 단위를 두 작업이 동시에 요청하면 하나만 성공한다(§13.3, 검증 27).
* 자원 종류(RESOURCES): sites(개) web_disk(바이트) web_out(바이트/월) mail_storage(바이트) mail_msgs(통/월) mailboxes(개) mail_services(개) source(개) backup(개)
*/
import { newId } from './crypto.mjs';
import { nowIso } from './db.mjs';
export const RESOURCES = Object.freeze({
sites: { unit: 'count', label: '운영 사이트 수' }, web_disk: { unit: 'bytes', label: '웹 디스크' }, web_out: { unit: 'bytes_month', label: '월간 웹 아웃바운드' },
mail_storage: { unit: 'bytes', label: '메일 저장 공간' }, mail_msgs: { unit: 'count_month', label: '월간 메일 통수(송신+수신)' }, mailboxes: { unit: 'count', label: '메일함 수' },
mail_services: { unit: 'count', label: '독립 메일 서비스' }, source: { unit: 'count', label: 'Source 옵션' }, backup: { unit: 'count', label: '사이트 백업 옵션' },
});
export const DEFAULT_WARN_LEVELS = [70, 80, 90, 95];
export const MIGRATIONS = [{ id: 'lib_quota_0001', sql: `
CREATE TABLE IF NOT EXISTS quota_entitlements (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, amount INTEGER NOT NULL, source_type TEXT, source_id TEXT, product_version_id TEXT,
starts_at TEXT NOT NULL, ends_at TEXT, note TEXT, created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS qe_group ON quota_entitlements(group_id, resource);
CREATE TABLE IF NOT EXISTS quota_allocations (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, amount INTEGER NOT NULL,
state TEXT NOT NULL DEFAULT 'in_use', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, return_due_at TEXT,
UNIQUE(group_id, resource, subject_type, subject_id)
);
CREATE TABLE IF NOT EXISTS quota_reservations (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, amount INTEGER NOT NULL, state TEXT NOT NULL DEFAULT 'reserved',
subject_type TEXT, subject_id TEXT, job_id TEXT, reason TEXT, created_at TEXT NOT NULL, expires_at TEXT, committed_at TEXT, released_at TEXT, return_due_at TEXT
);
CREATE INDEX IF NOT EXISTS qr_group ON quota_reservations(group_id, resource, state);
CREATE TABLE IF NOT EXISTS quota_usage (
group_id TEXT NOT NULL, resource TEXT NOT NULL, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, used INTEGER NOT NULL, period_start TEXT, measured_at TEXT NOT NULL, source TEXT,
PRIMARY KEY(group_id, resource, subject_type, subject_id)
);` }];
export class QuotaError extends Error { constructor(code, message, detail) { super(message); this.code = code; this.status = 409; this.detail = detail; } }
/** 권리 부여(주문 승인 후). ends_at 이 없으면 무기한(갱신 때 갱신). */
export function grantEntitlement(store, { groupId, resource, amount, sourceType = 'order', sourceId = null, productVersionId = null, startsAt = null, endsAt = null, note = null }) {
if (!RESOURCES[resource]) throw new QuotaError('unknown_resource', `알 수 없는 자원: ${resource}`);
const id = newId('qe_'); store.run('INSERT INTO quota_entitlements(id, group_id, resource, amount, source_type, source_id, product_version_id, starts_at, ends_at, note, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', id, groupId, resource, Math.round(amount), sourceType, sourceId, productVersionId, startsAt || nowIso(), endsAt, note, nowIso()); return id;
}
/** 권리 종료(해지·축소 적용) */
export function endEntitlement(store, id, at = nowIso()) { return store.run('UPDATE quota_entitlements SET ends_at=? WHERE id=? AND (ends_at IS NULL OR ends_at>?)', at, id, at).changes; }
const sumSql = { purchased: 'SELECT COALESCE(SUM(amount),0) AS n FROM quota_entitlements WHERE group_id=? AND resource=? AND starts_at<=? AND (ends_at IS NULL OR ends_at>?)', allocated: 'SELECT COALESCE(SUM(amount),0) AS n FROM quota_allocations WHERE group_id=? AND resource=? AND state IN (\'in_use\',\'returning\')', reserved: 'SELECT COALESCE(SUM(amount),0) AS n FROM quota_reservations WHERE group_id=? AND resource=? AND state=\'reserved\' AND (expires_at IS NULL OR expires_at>?)' };
/** 자원 하나의 수치 */
export function figures(store, groupId, resource, at = nowIso()) {
const purchased = store.get(sumSql.purchased, groupId, resource, at, at).n; const allocated = store.get(sumSql.allocated, groupId, resource).n; const reserved = store.get(sumSql.reserved, groupId, resource, at).n;
const used = store.get('SELECT COALESCE(SUM(used),0) AS n FROM quota_usage WHERE group_id=? AND resource=?', groupId, resource).n;
return { resource, unit: RESOURCES[resource].unit, label: RESOURCES[resource].label, purchased, allocated, reserved, unallocated: Math.max(0, purchased - allocated - reserved), used };
}
/** 그룹 전체 요약(§13.2) */
export function summary(store, groupId, at = nowIso()) { return Object.keys(RESOURCES).map((r) => figures(store, groupId, r, at)); }
/**
* 예약(§13.3). 트랜잭션 안에서 가용량을 검사한다. 부족하면 QuotaError('quota_insufficient', shortage).
* 같은 subject·resource 의 활성 예약이 있으면 그것을 돌려준다(중복 예약 금지).
*/
export function reserve(store, { groupId, resource, amount, subjectType = null, subjectId = null, jobId = null, reason = null, ttlMs = 24 * 3600 * 1000 }) {
if (!RESOURCES[resource]) throw new QuotaError('unknown_resource', `알 수 없는 자원: ${resource}`);
amount = Math.round(amount); if (amount <= 0) throw new QuotaError('bad_amount', '예약량은 0보다 커야 합니다');
return store.tx(() => {
if (subjectId) { const ex = store.get('SELECT * FROM quota_reservations WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=? AND state=\'reserved\'', groupId, resource, subjectType, subjectId); if (ex) return { ...ex, existing: true }; }
const f = figures(store, groupId, resource);
if (amount > f.unallocated) throw new QuotaError('quota_insufficient', `${f.label}이(가) 부족합니다 (필요 ${amount}, 사용 가능 ${f.unallocated})`, { resource, requested: amount, available: f.unallocated, shortage: amount - f.unallocated, purchased: f.purchased, allocated: f.allocated, reserved: f.reserved });
const id = newId('qr_'); const now = nowIso();
store.run('INSERT INTO quota_reservations(id, group_id, resource, amount, state, subject_type, subject_id, job_id, reason, created_at, expires_at) VALUES(?,?,?,?,\'reserved\',?,?,?,?,?,?)', id, groupId, resource, amount, subjectType, subjectId, jobId, reason, now, ttlMs ? new Date(Date.now() + ttlMs).toISOString() : null);
return store.get('SELECT * FROM quota_reservations WHERE id=?', id);
});
}
/** 예약 확정 → 배정(in_use). 배정이 이미 있으면 양을 더한다. */
export function commit(store, reservationId) {
return store.tx(() => {
const r = store.get('SELECT * FROM quota_reservations WHERE id=?', reservationId); if (!r) throw new QuotaError('reservation_not_found', '예약이 없습니다');
if (r.state === 'in_use') return r;
if (r.state !== 'reserved') throw new QuotaError('reservation_not_active', `예약 상태(${r.state})에서는 확정할 수 없습니다`);
const now = nowIso();
store.run('UPDATE quota_reservations SET state=\'in_use\', committed_at=? WHERE id=?', now, r.id);
const a = store.get('SELECT * FROM quota_allocations WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', r.group_id, r.resource, r.subject_type || 'none', r.subject_id || r.id);
if (a) store.run('UPDATE quota_allocations SET amount=amount+?, state=\'in_use\', updated_at=? WHERE id=?', r.amount, now, a.id);
else store.run('INSERT INTO quota_allocations(id, group_id, resource, subject_type, subject_id, amount, state, created_at, updated_at) VALUES(?,?,?,?,?,?,\'in_use\',?,?)', newId('qa_'), r.group_id, r.resource, r.subject_type || 'none', r.subject_id || r.id, r.amount, now, now);
return store.get('SELECT * FROM quota_reservations WHERE id=?', r.id);
});
}
/** 예약 반환(실패·취소·되돌리기). returning=true 면 정리 전 '반환 중' 으로 두고 returnDueAt 을 표시한다(§11.2 "반환 예정 시각"). */
export function release(store, reservationId, { returning = false, returnDueAt = null } = {}) {
const r = store.get('SELECT * FROM quota_reservations WHERE id=?', reservationId); if (!r) return null;
if (r.state === 'returned') return r;
if (returning) store.run('UPDATE quota_reservations SET state=\'returning\', return_due_at=? WHERE id=?', returnDueAt, r.id);
else store.run('UPDATE quota_reservations SET state=\'returned\', released_at=? WHERE id=?', nowIso(), r.id);
return store.get('SELECT * FROM quota_reservations WHERE id=?', r.id);
}
/** 만료된 예약 자동 반환(주기 작업). 반환된 예약 목록을 돌려줘 호출자가 알림을 만든다. */
export function expireReservations(store, at = nowIso()) {
const rows = store.all('SELECT * FROM quota_reservations WHERE state=\'reserved\' AND expires_at IS NOT NULL AND expires_at<=?', at);
for (const r of rows) store.run('UPDATE quota_reservations SET state=\'returned\', released_at=? WHERE id=?', at, r.id);
return rows;
}
/** 대상의 배정 읽기 */
export function allocationOf(store, groupId, resource, subjectType, subjectId) { return store.get('SELECT * FROM quota_allocations WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', groupId, resource, subjectType, subjectId) || null; }
/**
* 배정량 변경(§13.3). 늘릴 때는 미배정량 안에서, 줄일 때는 실제 사용량(usedNow)과 이번 달 누적(monthUsed, 월간 자원)보다 작게 할 수 없다.
* @returns {{allocation:object, before:number, after:number}}
*/
export function setAllocation(store, { groupId, resource, subjectType, subjectId, amount, usedNow = 0, monthUsed = 0 }) {
if (!RESOURCES[resource]) throw new QuotaError('unknown_resource', `알 수 없는 자원: ${resource}`);
amount = Math.round(amount); if (amount < 0) throw new QuotaError('bad_amount', '배정량은 0 이상이어야 합니다');
return store.tx(() => {
const a = allocationOf(store, groupId, resource, subjectType, subjectId); const before = a ? a.amount : 0; const f = figures(store, groupId, resource);
const floor = RESOURCES[resource].unit.endsWith('_month') ? Math.max(usedNow, monthUsed) : usedNow;
if (amount < floor) throw new QuotaError('allocation_below_usage', `현재 사용량(${floor})보다 작게 줄일 수 없습니다. 먼저 ${floor - amount} 만큼 정리하세요`, { resource, requested: amount, floor, cleanupNeeded: floor - amount });
const delta = amount - before;
if (delta > f.unallocated) throw new QuotaError('quota_insufficient', `${f.label} 미배정량(${f.unallocated})을 넘습니다`, { resource, requested: delta, available: f.unallocated, shortage: delta - f.unallocated });
const now = nowIso();
if (a) store.run('UPDATE quota_allocations SET amount=?, state=\'in_use\', updated_at=? WHERE id=?', amount, now, a.id);
else store.run('INSERT INTO quota_allocations(id, group_id, resource, subject_type, subject_id, amount, state, created_at, updated_at) VALUES(?,?,?,?,?,?,\'in_use\',?,?)', newId('qa_'), groupId, resource, subjectType, subjectId, amount, now, now);
return { allocation: allocationOf(store, groupId, resource, subjectType, subjectId), before, after: amount };
});
}
/** 배정 반환 시작(반환 중) / 완료(삭제). §14 사이트 수 반환은 모든 조건이 충족된 뒤 finish 를 부른다. */
export function beginReturn(store, allocationId, returnDueAt = null) { return store.run('UPDATE quota_allocations SET state=\'returning\', return_due_at=?, updated_at=? WHERE id=? AND state=\'in_use\'', returnDueAt, nowIso(), allocationId).changes; }
export function finishReturn(store, allocationId) { return store.run('DELETE FROM quota_allocations WHERE id=?', allocationId).changes; }
export function cancelReturn(store, allocationId) { return store.run('UPDATE quota_allocations SET state=\'in_use\', return_due_at=NULL, updated_at=? WHERE id=? AND state=\'returning\'', nowIso(), allocationId).changes; }
/** 실제 사용량 기록(계측 결과). 월간 자원은 period_start(KST 월 시작) 를 함께 둔다. */
export function recordUsage(store, { groupId, resource, subjectType, subjectId, used, periodStart = null, source = null, measuredAt = nowIso() }) {
store.run('INSERT INTO quota_usage(group_id, resource, subject_type, subject_id, used, period_start, measured_at, source) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(group_id, resource, subject_type, subject_id) DO UPDATE SET used=excluded.used, period_start=excluded.period_start, measured_at=excluded.measured_at, source=excluded.source', groupId, resource, subjectType, subjectId, Math.round(used), periodStart, measuredAt, source);
}
export function usageOf(store, groupId, resource, subjectType, subjectId) { return store.get('SELECT * FROM quota_usage WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', groupId, resource, subjectType, subjectId) || null; }
/** 서비스별 배정·사용 목록(화면: 사이트별 배정량·사용량) */
export function listAllocations(store, groupId, resource = null) {
return store.all(`SELECT a.*, u.used, u.measured_at FROM quota_allocations a LEFT JOIN quota_usage u ON u.group_id=a.group_id AND u.resource=a.resource AND u.subject_type=a.subject_type AND u.subject_id=a.subject_id WHERE a.group_id=? ${resource ? 'AND a.resource=?' : ''} ORDER BY a.resource, a.created_at`, ...(resource ? [groupId, resource] : [groupId]));
}
export function listReservations(store, groupId, { active = true } = {}) { return store.all(`SELECT * FROM quota_reservations WHERE group_id=? ${active ? 'AND state IN (\'reserved\',\'returning\')' : ''} ORDER BY created_at DESC`, groupId); }
/**
* 경고 구간(§13.4). 넘은 가장 높은 구간을 돌려준다(0 = 정상, 100 = 제한).
* @param {number} used @param {number} limit 0 이면 제한 없음 → 0
*/
export function warnLevel(used, limit, levels = DEFAULT_WARN_LEVELS) {
if (!limit || limit <= 0) return 0; const pct = (used / limit) * 100; if (pct >= 100) return 100;
let lvl = 0; for (const l of levels) if (pct >= l) lvl = l; return lvl;
}
export const percent = (used, limit) => (limit > 0 ? Math.min(999, Math.round((used / limit) * 1000) / 10) : 0);
+45
View File
@@ -0,0 +1,45 @@
/**
* lib/redact.mjs — 비밀값 가리기(§37).
*
* 감사 기록·알림·작업 결과·오류 메시지·로그에 들어가는 모든 객체는 이 함수를 거친다.
* - 키 이름이 비밀로 보이면(password, secret, token, key, credential, cookie, authorization, recovery, otp, privkey …) 값을 '[redacted]' 로 바꾼다.
* - 문자열 값이 비밀 형태(sk-…, re_…, au_…, JWT, 40자 이상 base64url/hex, PEM 블록)면 가린다.
* - 깊이 6 까지 재귀. 배열·객체·문자열 모두.
*/
const KEY_RE = /pass(word|wd)?|secret|token|api[-_]?key|private[-_]?key|privkey|credential|cookie|authorization|recovery|otp|totp|session|vault|dkim|\bkey\b/i;
const VALUE_RES = [
/^(sk|rk|re|au|aw|ag|ghp|gho|xox[a-z])[-_][A-Za-z0-9_-]{16,}$/, // 서비스 키 접두어 형식
/^eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}$/, // JWT
/-----BEGIN [A-Z ]*PRIVATE KEY-----/, // PEM 개인키
/^[A-Za-z0-9_-]{40,}$/, // 긴 무작위 토큰
/^[a-f0-9]{48,}$/i, // 긴 hex 비밀
];
const KEEP_KEYS = /^(id|_id|site_id|group_id|user_id|job_id|idempotency_?key|incident_?key|public_?key_?fingerprint|key_?name|keyName|kind|resource)$/i;
/** 값 하나가 비밀 형태인지 */
export function looksSecret(v) { if (typeof v !== 'string' || v.length < 12) return false; return VALUE_RES.some((re) => re.test(v)); }
/**
* 객체/배열/문자열에서 비밀값을 가린다. 원본은 바꾸지 않는다.
* @param {*} v
* @param {number} depth 내부 재귀 깊이
*/
export function redact(v, depth = 0) {
if (depth > 6) return '[depth]';
if (v == null) return v;
if (typeof v === 'string') return looksSecret(v) ? '[redacted]' : v;
if (Array.isArray(v)) return v.map((x) => redact(x, depth + 1));
if (typeof v === 'object') {
const out = {};
for (const [k, val] of Object.entries(v)) {
if (!KEEP_KEYS.test(k) && KEY_RE.test(k)) { out[k] = val == null || val === '' ? val : '[redacted]'; continue; }
out[k] = redact(val, depth + 1);
}
return out;
}
return v;
}
/** 오류 객체를 기록용 안전 문자열로 */
export function safeError(e) { const m = e && e.message ? String(e.message) : String(e || ''); return redact(m.replace(/(Bearer\s+)[A-Za-z0-9._-]+/g, '$1[redacted]')); }
/** 비밀값의 앞뒤만 보이는 힌트(예: au_12…9f) — 화면에 "등록됨" 을 보여 주되 값은 못 알아보게 */
export function hint(secret) { const s = String(secret || ''); if (s.length < 10) return s ? '••••' : ''; return `${s.slice(0, 4)}…${s.slice(-2)}`; }
+64
View File
@@ -0,0 +1,64 @@
/**
* lib/session.mjs — DB 세션(기기 목록·개별/전체 종료 §3.2)과 CSRF.
*
* 쿠키에는 무작위 토큰만 두고 서버는 sha256 해시로 찾는다(DB 가 유출돼도 세션을 위조할 수 없다).
* kind: 'customer'(고객 포털) | 'operator'(운영자 콘솔) | 'setup'. 종류가 다른 세션으로는 상대편 화면을 열 수 없다(§25).
* CSRF: 세션마다 무작위 csrf 값을 두고 변경 요청은 `X-CSRF` 헤더로 보낸다(상수 시간 비교).
* 재인증(§25 고위험 작업): reauth_at 을 두고 requireRecentAuth(maxAgeMs) 로 검사한다.
*/
import { randomToken, sha256, safeEqual, newId } from './crypto.mjs';
import { nowIso, parseJson, toJson } from './db.mjs';
export const MIGRATIONS = [{ id: 'lib_sessions_0001', sql: `
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL, kind TEXT NOT NULL,
csrf TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, reauth_at TEXT,
ip TEXT, ua TEXT, label TEXT, revoked_at TEXT, data_json TEXT
);
CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id, kind);` }];
const COOKIE = { customer: 'aw_session', operator: 'aw_ops', setup: 'aw_setup' };
export const cookieName = (kind) => COOKIE[kind] || 'aw_session';
/** 세션 만들기. 반환 { id, token(원문, 쿠키용), csrf, expiresAt } */
export function createSession(store, { userId, kind = 'customer', ip = '', ua = '', days = 30, data = null, reauth = true }) {
const token = randomToken(32); const id = newId('s_'); const now = nowIso();
const expiresAt = new Date(Date.now() + days * 86400000).toISOString(); const csrf = randomToken(24);
store.run('INSERT INTO sessions(id, token_hash, user_id, kind, csrf, created_at, expires_at, last_seen_at, reauth_at, ip, ua, label, data_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)',
id, sha256(token), userId, kind, csrf, now, expiresAt, now, reauth ? now : null, ip, String(ua || '').slice(0, 200), deviceLabel(ua), toJson(data));
return { id, token, csrf, expiresAt };
}
/** 토큰(쿠키 값)으로 세션을 찾는다. 만료·종료된 세션은 null. 찾으면 last_seen 을 갱신(1분에 한 번만). */
export function resolveSession(store, token, kind) {
if (!token) return null;
const s = store.get('SELECT * FROM sessions WHERE token_hash=? AND kind=?', sha256(token), kind);
if (!s || s.revoked_at || Date.parse(s.expires_at) < Date.now()) return null;
if (Date.now() - Date.parse(s.last_seen_at) > 60000) store.run('UPDATE sessions SET last_seen_at=? WHERE id=?', nowIso(), s.id);
return { ...s, data: parseJson(s.data_json, {}) };
}
/** 세션 데이터(현재 그룹 등) 갱신 */
export function updateSessionData(store, id, patch) { const s = store.get('SELECT data_json FROM sessions WHERE id=?', id); const d = { ...parseJson(s?.data_json, {}), ...patch }; store.run('UPDATE sessions SET data_json=? WHERE id=?', toJson(d), id); return d; }
/** 재인증 기록(고위험 작업 전 비밀번호 재확인) */
export function markReauth(store, id) { store.run('UPDATE sessions SET reauth_at=? WHERE id=?', nowIso(), id); }
/** 최근 재인증 여부 */
export function recentlyAuthed(session, maxAgeMs = 10 * 60 * 1000) { return !!(session && session.reauth_at && Date.now() - Date.parse(session.reauth_at) < maxAgeMs); }
/** 개별 세션 종료 */
export function revokeSession(store, id, userId) { return store.run('UPDATE sessions SET revoked_at=? WHERE id=? AND user_id=? AND revoked_at IS NULL', nowIso(), id, userId).changes; }
/** 다른 모든 세션 종료(현재 세션 제외 가능). kind 를 주면 그 종류만 */
export function revokeOtherSessions(store, userId, keepId = null, kind = null) {
return store.run(`UPDATE sessions SET revoked_at=? WHERE user_id=? AND revoked_at IS NULL ${keepId ? 'AND id<>?' : ''} ${kind ? 'AND kind=?' : ''}`, ...[nowIso(), userId, ...(keepId ? [keepId] : []), ...(kind ? [kind] : [])]).changes;
}
/** 사용자의 활성 세션 목록(기기 화면용, 토큰 없음) */
export function listSessions(store, userId, kind) {
return store.all('SELECT id, created_at, last_seen_at, expires_at, ip, label FROM sessions WHERE user_id=? AND kind=? AND revoked_at IS NULL AND expires_at>? ORDER BY last_seen_at DESC', userId, kind, nowIso());
}
/** CSRF 검사 */
export function csrfOk(session, token) { return !!(session && token && safeEqual(session.csrf, token)); }
/** UA 에서 기기 표시 이름 (대략) */
function deviceLabel(ua) {
const s = String(ua || ''); const os = /Windows/.test(s) ? 'Windows' : /Android/.test(s) ? 'Android' : /iPhone|iPad/.test(s) ? 'iOS' : /Mac OS/.test(s) ? 'macOS' : /Linux/.test(s) ? 'Linux' : '기기';
const br = /Edg\//.test(s) ? 'Edge' : /Chrome\//.test(s) ? 'Chrome' : /Safari\//.test(s) ? 'Safari' : /Firefox\//.test(s) ? 'Firefox' : '브라우저';
return `${os} · ${br}`;
}
/** 만료·종료된 세션 정리(주기 작업) */
export function purgeSessions(store, olderThanDays = 7) { const cut = new Date(Date.now() - olderThanDays * 86400000).toISOString(); return store.run('DELETE FROM sessions WHERE expires_at<? OR (revoked_at IS NOT NULL AND revoked_at<?)', cut, cut).changes; }
+53
View File
@@ -0,0 +1,53 @@
/**
* lib/status.mjs — 사용자용 상태 어휘(§2.2)와 전이 규칙.
*
* 세 종류의 상태를 분리한다(§2.2 "하나의 상태로 합치지 않는다").
* JOB_STATUS 작업(배포·복원·이전·주문…)의 진행 상태 — 작업 엔진(lib/jobs)이 쓴다
* SERVICE_STATE 서비스(사이트·메일)의 생명주기 상태(§23.3)
* HEALTH 외부 확인 건강 상태(§31)
* 코드(영문)로 저장하고 화면은 label() 로 한국어를 얻는다.
*/
export const JOB_STATUS = Object.freeze({
NOT_READY: 'not_ready', NEEDS_SETUP: 'needs_setup', AWAITING_APPROVAL: 'awaiting_approval', AWAITING_PAYMENT: 'awaiting_payment',
QUEUED: 'queued', RUNNING: 'running', AWAITING_EXTERNAL: 'awaiting_external', VERIFYING: 'verifying', PAUSED: 'paused',
DONE: 'done', FAILED: 'failed', REVERTING: 'reverting', REVERTED: 'reverted', NEEDS_USER: 'needs_user', CANCELLED: 'cancelled',
});
export const JOB_LABEL = Object.freeze({
not_ready: '준비 전', needs_setup: '설정 필요', awaiting_approval: '승인 대기', awaiting_payment: '결제 대기', queued: '처리 대기', running: '진행 중',
awaiting_external: '외부 작업 대기', verifying: '검증 중', paused: '일시 중지', done: '완료', failed: '실패', reverting: '되돌리는 중', reverted: '되돌림 완료',
needs_user: '사용자 확인 필요', cancelled: '취소됨',
});
/** 끝난 상태(더 바뀌지 않음) */
export const TERMINAL = new Set(['done', 'failed', 'reverted', 'cancelled']);
/** 사용자가 취소할 수 있는 상태 */
export const CANCELLABLE = new Set(['not_ready', 'needs_setup', 'awaiting_approval', 'awaiting_payment', 'queued', 'awaiting_external', 'needs_user', 'paused']);
export const SERVICE_STATE = Object.freeze({
DRAFT: 'draft', BUILDING: 'building', DEPLOYING: 'deploying', LIVE: 'live', NEEDS_DOMAIN: 'needs_domain', PAUSED: 'paused',
CANCEL_SCHEDULED: 'cancel_scheduled', READONLY_RETENTION: 'readonly_retention', PENDING_DELETE: 'pending_delete', DELETED: 'deleted', DELETE_HOLD: 'delete_hold',
});
export const SERVICE_LABEL = Object.freeze({
draft: '초안', building: '제작 중', deploying: '배포 중', live: '운영 중', needs_domain: '도메인 설정 필요', paused: '일시 중지', cancel_scheduled: '해지 예약',
readonly_retention: '읽기 전용 보존', pending_delete: '삭제 대기', deleted: '삭제 완료', delete_hold: '삭제 보류',
});
export const HEALTH = Object.freeze({ OK: 'ok', DEGRADED: 'degraded', PARTIAL: 'partial_outage', DOWN: 'down', UNKNOWN: 'unknown', STALE: 'stale', NOT_PROVIDED: 'not_provided' });
export const HEALTH_LABEL = Object.freeze({ ok: '정상', degraded: '성능 저하', partial_outage: '일부 장애', down: '중단', unknown: '확인 전', stale: '정보 확인 지연', not_provided: '제공되지 않음' });
/** 배포 완료 세분(§11.2) */
export const DEPLOY_VERIFY = Object.freeze({ VERIFIED: 'verified', NEEDS_DOMAIN: 'needs_domain', PUBLIC: 'public' });
export const DEPLOY_VERIFY_LABEL = Object.freeze({ verified: '배포 검증 완료', needs_domain: '도메인 설정 필요', public: '공개 운영 중' });
/** 할당 상태(§13.3) */
export const ALLOC_LABEL = Object.freeze({ available: '사용 가능', reserved: '예약됨', in_use: '사용 중', returning: '반환 중', returned: '반환됨' });
/** 코드 → 한국어. 알 수 없는 코드는 그대로(화면에서 눈에 띄게) */
export function label(kind, code) {
const map = { job: JOB_LABEL, service: SERVICE_LABEL, health: HEALTH_LABEL, deploy: DEPLOY_VERIFY_LABEL, alloc: ALLOC_LABEL }[kind] || {};
return map[code] || code || '';
}
/**
* 상태 정보가 오래됐는지(§2.2 `정보 확인 지연`). 마지막 갱신으로부터 staleAfterMs 가 지났으면 true.
* @param {string} updatedAt ISO
* @param {number} staleAfterMs 기본 5분
*/
export function isStale(updatedAt, staleAfterMs = 5 * 60 * 1000, now = Date.now()) { if (!updatedAt) return true; return now - Date.parse(updatedAt) > staleAfterMs; }
+26
View File
@@ -0,0 +1,26 @@
/**
* lib/test.mjs — 작은 테스트 러너(의존성 없음). 형제 제품(AriMail)과 같은 방식: 파일을 node 로 직접 실행하면 순서대로 돈다.
* import { test, eq, ok, rejects, tmpDir, done } from '../lib/test.mjs';
* await test('이름', async () => { ... }); 실패하면 즉시 요약을 내고 종료 코드 1.
* done(); 마지막에 요약 출력.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
let passed = 0; let failed = 0; const dirs = [];
export async function test(name, fn) {
const t0 = Date.now();
try { await fn(); passed++; console.log(` ✔ ${name} (${Date.now() - t0}ms)`); }
catch (e) { failed++; console.error(` ✘ ${name}\n ${e && e.stack ? e.stack.split('\n').slice(0, 6).join('\n ') : e}`); process.exitCode = 1; }
}
export function eq(a, b, msg = '') { const sa = JSON.stringify(a); const sb = JSON.stringify(b); if (sa !== sb) throw new Error(`${msg ? msg + ': ' : ''}기대 ${sb} 실제 ${sa}`); }
export function ok(v, msg = '') { if (!v) throw new Error(`${msg || '참이어야 합니다'} (실제 ${JSON.stringify(v)})`); }
export async function rejects(fn, codeOrRe, msg = '') {
try { await fn(); } catch (e) { if (codeOrRe == null) return e; const s = `${e.code || ''} ${e.message || ''}`; if (codeOrRe instanceof RegExp ? codeOrRe.test(s) : (e.code === codeOrRe || String(e.message).includes(codeOrRe))) return e; throw new Error(`${msg || '다른 오류'}: 기대 ${codeOrRe} 실제 ${s}`); }
throw new Error(`${msg || '예외가 나야 합니다'}`);
}
/** 테스트용 임시 디렉터리(종료 시 삭제) */
export function tmpDir(prefix = 'aw-test-') { const d = mkdtempSync(join(tmpdir(), prefix)); dirs.push(d); return d; }
export function done() { for (const d of dirs) { try { rmSync(d, { recursive: true, force: true }); } catch (_) {} } console.log(`${failed ? '✘' : '✔'} 통과 ${passed} · 실패 ${failed}`); if (failed) process.exit(1); }
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+41
View File
@@ -0,0 +1,41 @@
/**
* lib/vault.mjs — 비밀값 보관소(AES-256-GCM).
*
* 서비스 키·API 키·Agent 비밀·SSO 공유 비밀처럼 "저장 후 다시 볼 수 없고 재발급만 되는" 값을 둔다(§37).
* - 암호화 키: 데이터 디렉터리의 `vault.key`(32바이트, 0600). 없으면 처음 열 때 만든다. 환경 변수 ARIWEB_VAULT_KEY(hex) 가 있으면 그것을 쓴다(재해복구 때 같은 키로 복원).
* - 저장 파일: `vault.json` — { items: { name: { iv, tag, data, hint, updatedAt, kind } } }. 원자적 쓰기(tmp → rename).
* - API: set(name, secret, {kind}) / get(name) / has(name) / remove(name) / list() (이름·힌트·시각만, 값 없음)
*/
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from 'node:fs';
import { join } from 'node:path';
import { hint } from './redact.mjs';
export function openVault(dir) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
const keyFile = join(dir, 'vault.key'); const file = join(dir, 'vault.json');
let key;
if (process.env.ARIWEB_VAULT_KEY) key = Buffer.from(process.env.ARIWEB_VAULT_KEY, 'hex');
else if (existsSync(keyFile)) key = Buffer.from(readFileSync(keyFile, 'utf8').trim(), 'hex');
else { key = randomBytes(32); writeFileSync(keyFile, key.toString('hex'), { mode: 0o600 }); }
if (key.length !== 32) throw new Error('vault 키 길이가 올바르지 않습니다(32바이트 hex 필요)');
const load = () => { try { return JSON.parse(readFileSync(file, 'utf8')); } catch (_) { return { items: {} }; } };
const save = (d) => { const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(d, null, 1), { mode: 0o600 }); renameSync(tmp, file); try { chmodSync(file, 0o600); } catch (_) {} };
return {
dir, file,
set(name, secret, { kind = 'secret' } = {}) {
const iv = randomBytes(12); const c = createCipheriv('aes-256-gcm', key, iv);
const data = Buffer.concat([c.update(String(secret), 'utf8'), c.final()]);
const d = load(); d.items[name] = { kind, iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), data: data.toString('base64'), hint: hint(secret), updatedAt: new Date().toISOString() }; save(d);
return { name, hint: d.items[name].hint };
},
get(name) {
const it = load().items[name]; if (!it) return null;
const dc = createDecipheriv('aes-256-gcm', key, Buffer.from(it.iv, 'base64')); dc.setAuthTag(Buffer.from(it.tag, 'base64'));
return Buffer.concat([dc.update(Buffer.from(it.data, 'base64')), dc.final()]).toString('utf8');
},
has: (name) => !!load().items[name],
remove(name) { const d = load(); const had = !!d.items[name]; delete d.items[name]; save(d); return had; },
list() { return Object.entries(load().items).map(([name, it]) => ({ name, kind: it.kind, hint: it.hint, updatedAt: it.updatedAt })); },
};
}
+68
View File
@@ -0,0 +1,68 @@
# AriMail — mail.arihakdang.com / mail.heart-m.com
**AriMail**(1.0.0 까지의 이름은 mail-gateway, 저장소 https://source.ibit.kr/OZ-Group/AriMail)은 웹 메일함과 IMAP·POP3·SMTP 서버를 갖춘 메일 게이트웨이입니다.
수신·발신은 **Resend** 또는 **내장 Haraka**(직접 수신 MX / 직접 발송) 또는 외부 SMTP 중계 중에서 설정으로 고릅니다. www.arihakdang.com 관리자 메일함(`server/mailbox.mjs`)을 참고해
별도 사이트로 분리했고, 다음을 더합니다.
- **설정 파일과 초기 설정 페이지**(1.0.0~): Resend 키·중계 서버·Haraka·사이트 정보 같은 운영 설정은 `data/config.json` 에 두고, 배포 뒤 전용 포트(8791, 토큰)의 초기 설정 페이지나 `/admin → 메일 서비스 설정` 에서 채웁니다(재시작 없음). 환경 파일에는 배포에 필요한 값만 남습니다.
- **발송 모듈화**(1.0.0~): `server/providers/`(resend · smtp 중계 · 내장 Haraka)와 `sendMail()` 한 지점. 새 서비스는 모듈 하나를 더해 끼웁니다.
- **자동 설정·DNS 모듈**(1.0.5~): 설정 화면의 "자동 설정" 이 Resend 도메인 등록·검증·웹훅(비밀키 자동 저장), DNS 레코드(Google Cloud DNS / AWS Route 53 자동 적용, 그 밖에는 안내), 내장 Haraka 준비(DKIM·PTR·설치)를 진단·실행하고 상황별 안내 끝에 "메일 사용 준비 완료" 를 알립니다.
- **내장 Haraka**(1.0.0~): `haraka/`(run.mjs 관리 프로세스 + arimail 플러그인)와 `deploy/haraka-apply.sh`. 수신(25 → 게이트웨이 저장)과 발신(로컬 제출 포트 → MX 직접 배달, DKIM 서명)을 설정으로 켭니다.
- **이중 수신**: Resend 웹훅(`email.received`) + 1시간마다 수신 목록 동기화(웹 메일함을 열어 두면 1분마다 빠른 확인). 같은 메일은 한 번만 저장.
- **원본 보관**: Resend 가 서명 URL 로 주는 원본 `.eml` 을 그대로 저장(없으면 본문·헤더·첨부로 재구성). 첨부 바이트도 함께 보관.
- **웹 메일함**: 3단(폴더·목록·읽기 창) 레이아웃, 중요 표시·읽지 않음·첨부 필터, 일괄 삭제/이동/읽음, 답장·전체답장·전달, 원본 다운로드, 첨부.
- **HTML 편집기**: 글꼴·크기·색·강조·정렬·목록·링크·인용·구분선·본문 이미지(cid 인라인 첨부로 발송)·HTML 소스 편집, 임시 저장 자동화.
- **관리자 페이지** `/admin`(0.8.4~): 별도 로그인(같은 관리자 계정)을 거치는 고정 주소. 게이트웨이 사용자 계정(로그인 ID·이름·비밀번호·역할·상태·POP/IMAP 허용)을 추가·수정·삭제하고 서버 상태를 봅니다. 메일함 주소는 `로그인ID@도메인` 으로 자동 지정. 웹·POP·IMAP·SMTP 동일 인증.
- **IMAP**: `mail.arihakdang.com:993`(TLS)/`143`(STARTTLS). INBOX/Sent/Drafts/Trash 가 웹 메일함과 같은 저장소. 읽음·플래그·이동·삭제 공유, IDLE·MOVE·UIDPLUS·APPEND.
- **POP3**: `mail.arihakdang.com:995`(TLS)/`110`(STLS, 관리자 설정에 따라 일반 접속 로그인도 허용 — Gmail 의 POP 가져오기용). 계정은 www 관리자 계정 그대로. 한 번 내려준 메일은 다시 주지 않음.
- **SMTP 제출**(0.8.1~): `mail.arihakdang.com:465`(TLS)/`587`(STARTTLS). 웹 메일과 같은 아이디·비밀번호로 인증하고, 실제 발송은 서버 내부의 Resend API 키로 합니다(키는 사용자에게 노출되지 않음). 보낸 메일은 보낸 메일함에 바로 저장.
- **기한 없는 보관**(0.8.0~): 메일은 휴지통에서 영구 삭제할 때만 원본·첨부와 함께 지우고, 지운 메일은 재수신하지 않음.
- **설정 화면**: IMAP·SMTP·POP 메일 프로그램 설정 안내, 화면 기본값, 계정별 POP/IMAP 접속 허용 스위치. 로그인 화면의 (?) 접속 안내. 화면 상단에 버전 표시.
- **메일 전달·자동 응답**(0.8.16~): 설정 화면에서 받은 메일을 지정 주소(최대 5개)로 전부 전달하고, 지정한 메시지로 자동 회신(기한 없음 또는 날짜·시간 단위 기간)합니다.
되풀이 방지(전달 표시 헤더·`Auto-Submitted`), 같은 상대 24시간 1회, 자동 발송 메일 제외, 처리 기록은 메일 상세에 표시.
- **독립 설치**(0.8.18~): `WWW_DB_FILE` 을 비우면 www 사이트 없이 게이트웨이 계정만으로 동작합니다(첫 관리자 `admin`/`adminpasswd` 자동 생성(0.8.24~), `server/user-cli.mjs` 로 계정 추가·비밀번호 재설정, `SITE_TITLE`/`SITE_BRAND` 로 화면 이름 변경).
- **비밀번호 정책**(0.8.24~): 관리자 페이지에서 최소 글자 수(6~64, 기본 6)와 대·소문자 혼용·숫자·특수문자 요구를 켜고 끕니다. 예전의 10자 고정 규칙을 대체.
- **비밀번호 변경 보호·도메인 기반 서비스 이름·자동 새로고침**(0.8.25~): 자기 비밀번호 변경은 현재 비밀번호 확인 + 두 번 입력 + 보기 단추. 왼쪽 위 이름은 메일 도메인에서(`heart-m.com` → "HEART-M 메일"). 배포 뒤 열어 둔 화면은 `X-App-Version` 을 보고 스스로 새로 고침.
배포 파일은 `deploy/sites/<이름>.env` 사이트 설정 + `.in` 템플릿이며, 현재 mail.arihakdang.com(www 연동)과 mail.heart-m.com(독립) 두 사이트가 있습니다.
의존성 없음(Node 22.13+ 의 `node:sqlite`, `node:tls`). 문구는 `lang/ko.json` 한 곳에 둡니다.
```bash
npm test # 인증·웹훅·동기화·보관·발송·POP3·IMAP·SMTP·설정/발송 모듈/Haraka 수신/초기 설정 회귀 테스트
DB_FILE=./data/mail-gateway.sqlite MAILBOX=admin@example.test node server/index.mjs # 운영 설정은 ./data/config.json 과 초기 설정 페이지(8791)
```
설치·운영은 [docs/SETUP.md](docs/SETUP.md), 배포 파일은 `deploy/`, 변경 이력은 [CHANGELOG.md](CHANGELOG.md) 를 보세요.
## 구조
| 파일 | 역할 |
|---|---|
| `server/index.mjs` | HTTP(127.0.0.1:8790) + POP3 + 주기 작업 |
| `server/api.mjs` | 로그인, 메일함 API, 설정 API, Resend 웹훅 |
| `server/store.mjs` | 수신 저장(웹훅/동기화), 원본, 시각 정규화, 세션 정리, 초안·발송 |
| `server/automation.mjs` | 수신 메일 자동 처리(0.8.16~): 메일 전달·자동 응답, 되풀이 방지 규칙, 처리 기록(auto_actions) |
| `server/smtp.mjs` | SMTP 제출 서버(EHLO/STARTTLS/AUTH PLAIN·LOGIN/MAIL/RCPT/DATA), 로그인 인증 후 내부 키로 Resend 발송·보낸 메일함 저장 |
| `server/pop3.mjs` | POP3 서버(USER/PASS, AUTH PLAIN, STLS, UIDL, TOP), 전달 기록 |
| `server/imap.mjs` | IMAP4rev1 서버(SELECT/FETCH/STORE/SEARCH/MOVE/COPY/EXPUNGE/APPEND/IDLE, STARTTLS) |
| `server/imap-mime.mjs` | 원본 MIME 해석: ENVELOPE, BODYSTRUCTURE, 섹션 추출, APPEND 요약 |
| `server/auth.mjs` | www 관리자 인증(PBKDF2, 별칭 admin) + 게이트웨이 계정(mail_users) 관리(회원 가입·승인 대기 포함), 세션, 계정별 설정(prefs: 외부 이미지·POP/IMAP·전달·자동 응답) |
| `server/files.mjs` | 0.9.3: 대용량 첨부파일 — 디스크 스트리밍 업로드, 발송 시 토큰 링크(/dl/<토큰>)와 본문 블록, 랜딩 페이지·횟수 차감 다운로드, 기한·횟수 만료 삭제·정리, 시스템 상한(LIMIT_LARGE_FILE_*)·관리자 설정(50% 기본) |
| `server/limits.mjs` | 0.9.0: 시스템 제한(환경 변수 LIMIT_*)·관리자 제한·계정별 메일함 크기, 사용량(MIME 기준)·발송/수신 제한 검사, POP/IMAP 서비스 스위치, 회원 가입 설정, 메일 현황 집계(일·주·월) |
| `server/user-cli.mjs` | 계정 명령줄 도구(0.8.18~): 독립 설치의 첫 관리자 생성, 비밀번호 재설정, 목록 |
| `server/config.mjs` | 1.0.0: 운영 설정 파일(config.json) — 스키마, 환경 변수 가져오기·우선순위, 저장 즉시 적용, 비밀값 가림, 초기 설정 토큰, DKIM 키·DNS 레코드 |
| `server/domains.mjs` | 1.0.7: 다중 도메인 — 도메인 등록부(data/domains.json)와 테넌트(도메인별 env: DB·설정·파일), Host·로그인 ID·주소로 도메인 고르기, 추가·중지·삭제, "메일 서비스 없음" 안내 화면 |
| `server/usage.mjs` | 1.0.9: 메일 사용량 정보 제공 API(`/api/usage`) — 도메인·계정별 디스크 사용량·메일 수·발송량(일/주/월·일별 추이), 비밀 키(Bearer)·허용 IP·시도 제한으로 보호된 읽기 전용([docs/USAGE-API.md](docs/USAGE-API.md)) |
| `server/webmaker.mjs`, `server/webmaker-snapshot.mjs` | 1.0.8: webmaker 인증 모드 — 웹메이커(WebHosting_AI_agent) 데이터 디렉터리를 읽기 전용 스냅샷으로 읽어(직접 또는 sudo 도우미) 같은 scrypt 로 비밀번호 검사, 도메인 ↔ 사이트 대응(사이트 사용자·소유자·웹메이커 관리자 → `아이디@도메인`), 관리자 페이지의 읽기 전용 행 |
| `server/setup.mjs` | 1.0.0: 초기 설정 페이지 API(/api/setup/*)와 전용 포트(8791, 토큰) 서버, Haraka 상태 읽기. 1.0.7: 전체 설정(도메인 목록·추가·중지, ?domain= 으로 도메인별 설정) |
| `server/dns/` | 1.0.5: DNS 모듈 — `index.mjs`(모듈 선택·필요한 레코드 계산·SPF 병합·적용·반영 확인), `google.mjs`(Cloud DNS, 서비스 계정 JWT), `route53.mjs`(Route 53, SigV4), `common.mjs`(공개 조회·PTR) |
| `server/provision.mjs` | 1.0.5: 자동 설정 계획 — 단계별 진단(buildPlan)과 실행(runPlan): Resend 도메인·검증·웹훅, DNS 적용, Haraka DKIM·설치·PTR, SMTP 중계 확인 |
| `server/providers/` | 1.0.0: 발송·수신 서비스 모듈 — `index.mjs`(sendMail 선택·상태), `resend.mjs`(Resend API, 옛 server/resend.mjs; 1.0.11: 발신 도메인 검증 상태 진단 `mail_domain_unverified`/`mail_domain_missing` — 레코드별 없음/값 다름, 자동 재검증), `smtp.mjs` + `smtp-client.mjs`(SMTP 중계·내장 Haraka 제출) |
| `haraka/` | 1.0.0: 내장 Haraka — `package.json`(Haraka + dkim 플러그인), `run.mjs`(설정 파일을 보고 Haraka 설정 생성·실행·상태 기록), `plugins/arimail.js`(게이트웨이 연결: relay 판정·RCPT 확인·원본 전달) |
| `web/setup.*` | 1.0.0: 메일 서비스 설정 화면(전용 포트와 /setup 공용). 1.0.7: 전용 포트에서는 전체 설정(도메인 카드 + 고른 도메인의 설정) |
| `server/mime.mjs` | RFC 5322 메시지 생성, POP 전송 정규화 |
| `server/sanitize.mjs` | 수신 HTML 정제(www 의 sanitize_mail.py 와 같은 허용 목록) |
| `web/` | 웹 메일 화면(로그인·회원 가입·접속 안내, 3단 메일함, HTML 편집기(8색 팔레트), 설정(비밀번호 변경·사용량·전달·자동 응답))과 관리자 페이지(/admin: 계정 관리·회원 가입/제한/서비스 설정, 서버 상태·메일 현황) |
| `deploy/` | 사이트 설정(`sites/*.env`, `new-site.sh` 로 생성), `nginx-apply.sh`(vhost 만 다시 적용, 0.9.5), `haraka-apply.sh`(내장 Haraka npm 설치·서비스 등록, 1.0.0), `domain-nginx.sh`(추가 도메인 호스트의 nginx vhost·인증서, 1.0.7), `webmaker-access.sh`(웹메이커 계정 읽기 도우미·sudoers, 1.0.8), `site-mail-path.sh`(사이트의 `/mail` 을 웹 메일로 — `nginx/mail-path.conf.in`, 1.0.8)과 템플릿(systemd·nginx·certbot 훅·환경 파일 `.in`), setup/deploy 스크립트(끝에 초기 설정 페이지 주소 출력), 인증서 자동 발급 타이머(`tls-when-ready.sh`). 설치 옵션 `LOGIN_MODE`(standalone 기본 / www / webmaker)와 시스템 제한 `LIMIT_*` |
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# 배포 — 서버에서 실행 계정으로 실행: sudo -u <APP_USER> env SOURCE_BUNDLE=<번들> <APP_DIR>/repo/deploy/deploy.sh
# 1. 소스 갱신(번들 또는 원격) 2. 테스트 3. 릴리스 복사 4. DB 백업·마이그레이션 5. 서비스 재시작(실패 시 이전 릴리스로 복귀)
# 사이트(서비스 이름·계정·경로·포트)는 <APP_DIR>/site.env(setup.sh 가 둠)에서 읽습니다. 없으면 arihakdang 설정으로 동작합니다(0.8.17 까지의 서버 호환).
set -euo pipefail
. "$(dirname "$0")/lib.sh"
APP="$(cd "$DEPLOY_DIR/../.." && pwd)" # <APP_DIR>/repo/deploy → <APP_DIR>
if [ -f "$APP/site.env" ]; then load_site "$APP/site.env"; else load_site "$DEPLOY_DIR/sites/arihakdang.env"; fi
APP="$APP_DIR"; REPO="$APP/repo"; RELEASES="$APP/releases"
[ -x "$NODE_BIN" ] || NODE_BIN="$(command -v node)"
MAJOR="$($NODE_BIN --version | sed -E 's/^v([0-9]+).*/\1/')"; [ "$MAJOR" -ge 22 ] || { echo "Node 22.13+ 필요: $($NODE_BIN --version)" >&2; exit 1; }
export PATH="$(dirname "$NODE_BIN"):$PATH"
STAMP="$(date +%Y%m%d-%H%M%S)"; TARGET="$RELEASES/$STAMP"
exec 9>"$APP/deploy.lock"; flock -n 9 || { echo '다른 배포가 진행 중입니다' >&2; exit 1; }
echo "▶ 소스 갱신 ($SITE: $MAIL_HOST)"; cd "$REPO"
# SOURCE_BUNDLE=<git bundle 경로> 를 주면 원격 자격 증명 없이 번들에서 main 을 받습니다(작업 PC 에서 scp 로 전달).
# 번들의 main 이 서버가 아는 origin/main 과 이어지지 않아도(릴리스 역사를 다시 쓴 경우) 받도록 강제(+) 갱신합니다. 어차피 아래에서 origin/main 으로 reset 합니다.
if [ -n "${SOURCE_BUNDLE:-}" ]; then git bundle verify "$SOURCE_BUNDLE" >/dev/null 2>&1; git fetch --quiet --force "$SOURCE_BUNDLE" +main:refs/remotes/origin/main; else git fetch --quiet origin; fi
git reset --hard --quiet origin/main; echo " $(git log --oneline -1)"
echo "▶ 테스트"; for t in gateway imap smtp config provision domains webmaker usage; do "$NODE_BIN" "test/$t.test.mjs"; done
echo "▶ 릴리스 배치"; mkdir -p "$TARGET"
for path in server web lang migrations package.json haraka; do cp -r "$REPO/$path" "$TARGET/$path"; done
git rev-parse HEAD > "$TARGET/REVISION"
DB="$APP/data/mail-gateway.sqlite"; mkdir -p "$APP/data/backups"
if [ -f "$DB" ]; then python3 - "$DB" "$APP/data/backups/pre-$STAMP.sqlite" <<'PY'
import sqlite3,sys
s=sqlite3.connect('file:'+sys.argv[1]+'?mode=ro',uri=True);b=sqlite3.connect(sys.argv[2]);s.backup(b);b.close();s.close()
PY
fi
DB_FILE="$DB" "$NODE_BIN" "$TARGET/server/db-migrate.mjs"
# 첫 배포에는 current 가 없습니다. -e 는 존재하는 경로만 돌려주므로 자기 자신을 가리키는 링크를 만들지 않습니다.
PREVIOUS="$(readlink -e "$APP/current" 2>/dev/null || true)"
rollback() { trap - ERR; if [ -n "$PREVIOUS" ] && [ -d "$PREVIOUS" ] && [ "$PREVIOUS" != "$TARGET" ]; then ln -sfn "$PREVIOUS" "$APP/current.rollback"; mv -Tf "$APP/current.rollback" "$APP/current"; sudo systemctl restart "$APP_NAME"; fi; echo "배포 실패 — 이전 릴리스로 복귀" >&2; }
trap rollback ERR
ln -sfn "$TARGET" "$APP/current.tmp"; mv -Tf "$APP/current.tmp" "$APP/current"; echo " current → $TARGET"
# sync: 릴리스 파일이 디스크에 완전히 쓰인 뒤 재시작합니다(1.0.5 — 0.9.7 배포 때 첫 시작이 빈 package.json 을 읽은 기록).
sync
echo "▶ 서비스 재시작"; sudo systemctl restart "$APP_NAME"; sleep 2
systemctl is-active --quiet "$APP_NAME" || { rollback; exit 1; }
curl -fsS "http://127.0.0.1:$PORT/api/health" >/dev/null && echo " health OK"
trap - ERR
ls -1dt "$RELEASES"/*/ 2>/dev/null | tail -n +6 | xargs -r rm -rf
# 내장 Haraka(1.0.0): 관리 서비스가 등록되어 있으면 새 릴리스의 run.mjs 로 다시 띄웁니다(sudoers 에 없으면 건너뜀 — haraka-apply.sh 가 다음 실행에서 맞춤).
# 의존성 설치는 root 가 하는 haraka-apply.sh 의 몫이지만, 이미 설치된 디렉터리가 있고 package.json 이 바뀌었으면 여기서 실행 계정으로 갱신을 시도합니다(실패해도 배포는 성공).
if [ -f "$APP/haraka/package.json" ] && ! cmp -s "$REPO/haraka/package.json" "$APP/haraka/package.json"; then
NPM_BIN="$(dirname "$NODE_BIN")/npm"; [ -x "$NPM_BIN" ] || NPM_BIN="$(command -v npm || true)"
if [ -x "$NPM_BIN" ]; then cp "$REPO/haraka/package.json" "$APP/haraka/package.json"; (cd "$APP/haraka" && HOME="$APP/haraka" "$NPM_BIN" install --omit=dev --no-audit --no-fund --no-package-lock --loglevel=error) || echo " ⚠️ Haraka 의존성 갱신 실패(인터넷?) — sudo bash $REPO/deploy/haraka-apply.sh 로 다시 시도" >&2; fi
fi
if systemctl is-enabled --quiet "$APP_NAME-haraka" 2>/dev/null; then sudo -n systemctl restart "$APP_NAME-haraka" 2>/dev/null && echo " Haraka 관리 서비스 재시작" || echo " (Haraka 관리 서비스는 root 가 재시작해야 합니다: sudo systemctl restart $APP_NAME-haraka)"; fi
echo "✔ 배포 완료: $STAMP"
# 초기 설정 페이지(1.0.0): 서버가 시작하며 데이터 디렉터리에 접속 주소(토큰 포함)를 남깁니다. 설치 직후 운영 설정(Resend 키 등)을 여기서 채웁니다.
sleep 1; if [ -f "$APP/data/setup-url.txt" ]; then echo "▶ 초기 설정 페이지(토큰 포함 — 공유하지 마세요):"; sed 's/^/ /' "$APP/data/setup-url.txt"; echo " 또는 https://$MAIL_HOST/admin → 메일 서비스 설정"; fi
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# 추가 도메인의 웹 메일 호스트에 nginx vhost 와 Let's Encrypt 인증서를 준비합니다(1.0.7, 요구사항 1) — root 로 실행:
# sudo bash <APP_DIR>/repo/deploy/domain-nginx.sh mail.example.com [site.env 경로]
#
# 왜 필요한가
# 도메인 자체는 전체 설정 화면(기본 도메인의 8791 포트)에서 추가하면 게이트웨이가 바로 받기 시작하지만, 브라우저가 https://mail.example.com/ 으로
# 오려면 nginx 가 그 이름(server_name)과 인증서를 알아야 합니다. 이 스크립트는 기존 사이트의 nginx 템플릿(deploy/nginx/*.in)을 그 호스트 이름으로 채워
# 같은 게이트웨이 포트(PORT)로 넘기는 vhost 를 만들고 certbot 으로 인증서를 받습니다. 게이트웨이 설정·환경 파일·데이터는 건드리지 않습니다.
#
# 하는 일
# 1) 인증서가 없으면 80 전용(ACME 챌린지) vhost 로 시작하고, DNS(호스트 → 이 서버 IP)가 맞으면 certbot certonly --webroot 로 발급합니다.
# DNS 가 아직 안 바뀌었으면 안내만 하고 끝납니다(DNS 가 바뀐 뒤 다시 실행).
# 2) 인증서가 있으면 443 vhost(mail.conf.in)로 바꾸고 nginx 를 다시 읽습니다. 갱신 뒤 nginx 재적재용 certbot 훅도 둡니다.
# 참고: 메일 프로그램 포트(IMAP/POP/SMTP)의 인증서는 기본 도메인 것 하나이므로, 추가 도메인의 사용자도 메일 프로그램에는 기본 도메인의 호스트 이름으로 접속합니다
# (웹 메일의 "메일 프로그램 설정" 화면이 그 이름을 안내합니다).
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'root 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
TARGET_HOST="$(printf '%s' "${1:?웹 메일 호스트 이름(예: mail.example.com)}" | tr 'A-Z' 'a-z')"
printf '%s' "$TARGET_HOST" | grep -Eq '^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$' || { echo "호스트 이름 형식이 아닙니다: $TARGET_HOST" >&2; exit 1; }
SITE_FILE="${2:-}"
if [ -z "$SITE_FILE" ]; then APP_GUESS="$(cd "$DEPLOY_DIR/../.." && pwd)"; if [ -f "$APP_GUESS/site.env" ]; then SITE_FILE="$APP_GUESS/site.env"; else SITE_FILE="$DEPLOY_DIR/sites/${SITE:-arihakdang}.env"; fi; fi
load_site "$SITE_FILE"
[ "$TARGET_HOST" != "$MAIL_HOST" ] || { echo "$TARGET_HOST 는 기본 도메인의 호스트입니다(setup.sh / nginx-apply.sh 가 관리)." >&2; exit 1; }
# 템플릿의 @MAIL_HOST@ 자리를 이 호스트로 채웁니다(나머지 변수 — 포트·로그 이름·ACME 경로 — 는 사이트 설정 그대로).
MAIL_HOST="$TARGET_HOST"; export MAIL_HOST
TARGET="/etc/nginx/sites-available/$TARGET_HOST.conf"
apply() { render "$1" "$TARGET"; chmod 644 "$TARGET"; ln -sfn "$TARGET" "/etc/nginx/sites-enabled/$TARGET_HOST.conf"; nginx -t; systemctl reload nginx; }
echo "▶ 추가 도메인 호스트: $TARGET_HOST (사이트 $SITE, 게이트웨이 포트 $PORT)"
if [ ! -d "/etc/letsencrypt/live/$TARGET_HOST" ]; then
apply "$DEPLOY_DIR/nginx/mail.http-only.conf.in"
echo " 80 포트(ACME) 설정 적용. 인증서를 발급합니다…"
RESOLVED="$(getent ahostsv4 "$TARGET_HOST" 2>/dev/null | awk '{print $1}' | sort -u || true)"
MINE="$(ip -4 -o addr show scope global | awk '{print $4}' | cut -d/ -f1 | sort -u)"
MATCH=""; for a in $RESOLVED; do for b in $MINE; do [ "$a" = "$b" ] && MATCH="$a"; done; done
if [ -z "$MATCH" ]; then
echo " $TARGET_HOST 가 아직 이 서버를 가리키지 않습니다 (DNS: ${RESOLVED:-없음} / 서버: $MINE)."
echo " DNS 의 A 레코드를 이 서버로 바꾼 뒤 같은 명령을 다시 실행하세요. 그때까지 https 는 열리지 않습니다."
exit 0
fi
mkdir -p "$ACME_WEBROOT"
ACCOUNT_FLAG=""; ls /etc/letsencrypt/accounts/*/directory/*/regr.json >/dev/null 2>&1 || ACCOUNT_FLAG="--register-unsafely-without-email"
certbot certonly --webroot -w "$ACME_WEBROOT" -d "$TARGET_HOST" --non-interactive --agree-tos --keep-until-expiring $ACCOUNT_FLAG
fi
apply "$DEPLOY_DIR/nginx/mail.conf.in"
# 갱신 훅: 이 호스트의 인증서가 갱신되면 nginx 만 다시 읽습니다(메일 포트는 기본 도메인 인증서를 쓰므로 게이트웨이 재시작 불필요).
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
cat > "/etc/letsencrypt/renewal-hooks/deploy/$APP_NAME-$TARGET_HOST.sh" <<HOOK
#!/bin/sh
# AriMail 추가 도메인($TARGET_HOST) 인증서 갱신 뒤 nginx 재적재
case "\${RENEWED_LINEAGE:-}" in */$TARGET_HOST*) systemctl reload nginx || true ;; esac
HOOK
chmod 755 "/etc/letsencrypt/renewal-hooks/deploy/$APP_NAME-$TARGET_HOST.sh"
echo "✔ https://$TARGET_HOST/ 준비 완료. 전체 설정 화면에서 이 호스트를 가진 도메인을 추가(또는 호스트 목록에 넣기)했는지 확인하세요."
+65
View File
@@ -0,0 +1,65 @@
# @ETC_DIR@/gateway.env — 소유자만 읽게 하세요 (chmod 600, chown @APP_USER@). setup.sh 가 사이트 설정(deploy/sites/@SITE@.env)으로 만든 파일입니다.
#
# 1.0.0: 이 파일에는 "배포에 꼭 필요한 값" 만 남깁니다. 운영 설정(수·발신 방식, Resend API 키·웹훅 비밀키, SMTP 중계 서버, 내장 Haraka, 발신 표시 이름,
# 탭 제목, 통계 시간대, 동기화 주기)은 데이터 디렉터리의 설정 파일(@APP_DIR@/data/config.json)에 있고, 설치 뒤 초기 설정 페이지
# (배포 스크립트가 출력하는 http(s)://<서버>:8791/?token=… 또는 https://@MAIL_HOST@/admin → 메일 서비스 설정)에서 채우면 재시작 없이 적용됩니다.
# 예전 서버에서 이 파일에 남아 있는 RESEND_API_KEY 등은 1.0.0 첫 시작 때 설정 파일로 가져가며, 그 뒤로는 설정 파일 값이 우선합니다.
#
# 게이트웨이가 다루는 기본 메일함. 계정의 메일함은 항상 "로그인 ID@(기본 메일함의 도메인)" 입니다.
MAILBOX=@MAILBOX@
PUBLIC_HOST=@MAIL_HOST@
# 운영 설정 파일 위치(비우면 DB 파일 옆의 config.json). 초기 설정 페이지 전용 포트(0 이면 끔)와 접속 허용 주소.
CONFIG_FILE=
SETUP_PORT=8791
SETUP_HOST=0.0.0.0
# 내장 Haraka 설치 디렉터리(deploy/haraka-apply.sh 가 npm 으로 설치, 서비스 @APP_NAME@-haraka). 비우면 @APP_DIR@/haraka.
HARAKA_DIR=@APP_DIR@/haraka
# 로그인 방식(0.9.0 설치 옵션): standalone = www 사이트 없이 게이트웨이 계정만 사용(기본 설치 형태, 첫 관리자 admin/adminpasswd 자동 생성)
# www = www 사이트 DB(WWW_DB_FILE)로 관리자를 인증. standalone 이면 WWW_DB_FILE 은 무시됩니다.
LOGIN_MODE=@LOGIN_MODE@
# www 사이트 DB(관리자 계정 인증용, 읽기 전용). LOGIN_MODE=www 일 때만 씁니다.
WWW_DB_FILE=@WWW_DB_FILE@
# 웹메이커 연동(1.0.8, LOGIN_MODE=webmaker): 웹메이커 데이터 디렉터리(DATA_ROOT), sudo 로 읽을 계정(선택, deploy/webmaker-access.sh), 사이트 id(선택).
WEBMAKER_DIR=@WEBMAKER_DIR@
WEBMAKER_USER=@WEBMAKER_USER@
WEBMAKER_SITE=@WEBMAKER_SITE@
# 시스템 제한(0.9.0, 메일 시스템 전체의 상위 설정 — 관리자 페이지에서 바꿀 수 없고 관리자 설정은 이 값 이내로만 가능). 모두 0 이면 제한 없음.
# 크기는 바이트 또는 K/M/G 접미사(예: 500M, 2G)이며 MIME 변환 뒤의 크기(첨부는 base64 로 약 1.37배) 기준입니다.
# (systemd 환경 파일은 값 뒤의 주석을 지원하지 않으므로 주석은 따로 둡니다.)
# 허용 가능한 메일 계정 수
LIMIT_ACCOUNTS=@LIMIT_ACCOUNTS@
# 메일함 전체 최대 용량
LIMIT_TOTAL_BYTES=@LIMIT_TOTAL_BYTES@
# 개별 메일함 최대 용량
LIMIT_MAILBOX_BYTES=@LIMIT_MAILBOX_BYTES@
# 첨부 파일 한 개 최대 크기
LIMIT_ATTACHMENT_BYTES=@LIMIT_ATTACHMENT_BYTES@
# 한 메일의 받는 사람 수(참조·숨은 참조 포함, 내장 상한 50)
LIMIT_RECIPIENTS=@LIMIT_RECIPIENTS@
# 월간(1일~말일) 최대 수발신 통수
LIMIT_MONTHLY_MESSAGES=@LIMIT_MONTHLY_MESSAGES@
# 대용량 첨부파일 시스템 상한(0.9.3): 관리자 설정의 상한이며 무제한으로 둘 수 없습니다(비우거나 0 이면 기본값 1G / 30 / 20 적용).
# 파일 한 개 최대 크기(기본 1G)
LIMIT_LARGE_FILE_BYTES=@LIMIT_LARGE_FILE_BYTES@
# 보관 최대 기간(일, 기본 30)
LIMIT_LARGE_FILE_DAYS=@LIMIT_LARGE_FILE_DAYS@
# 다운로드 최대 횟수(기본 20)
LIMIT_LARGE_FILE_DOWNLOADS=@LIMIT_LARGE_FILE_DOWNLOADS@
# 대용량 파일을 둘 디렉터리(비우면 DB 파일 옆의 files/). 서비스가 쓸 수 있는 경로여야 합니다(systemd ReadWritePaths).
FILES_DIR=
# 게이트웨이 저장소(SQLite). 설정 파일·초기 설정 토큰·Haraka 상태 파일도 같은 디렉터리에 둡니다.
DB_FILE=@APP_DIR@/data/mail-gateway.sqlite
# POP3·IMAP·SMTP TLS: 인증서 사본 경로(certbot 갱신 훅이 여기에 복사). 없으면 995/993/465 를 열지 않고 평문 포트는 STARTTLS 없이 로그인 거부.
POP_TLS_CERT=@ETC_DIR@/tls/fullchain.pem
POP_TLS_KEY=@ETC_DIR@/tls/privkey.pem
POP_PORT=110
POPS_PORT=995
# IMAP: 993(TLS) / 143(STARTTLS). 인증서는 POP 와 같은 파일을 씁니다.
IMAP_PORT=143
IMAPS_PORT=993
# SMTP 제출(보내는 메일 서버): 587(STARTTLS) / 465(TLS). 로그인 비밀번호로 인증하고 위 RESEND_API_KEY 로 발송합니다.
SMTP_PORT=587
SMTPS_PORT=465
# 110/143 포트의 TLS 없는 로그인: 0.9.7 부터 관리자 페이지(POP / IMAP 서비스 → 일반 접속 로그인 허용, 기본 켬)에서 정합니다.
# 1 로 두면 관리자 설정과 무관하게 항상 허용(진단용). 0 이나 빈 값은 관리자 설정을 따릅니다(예전처럼 "항상 거부" 가 아님).
POP_ALLOW_PLAINTEXT=0
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# 내장 Haraka 준비(1.0.0) — root 로 실행: sudo bash <APP_DIR>/repo/deploy/haraka-apply.sh [site.env 경로]
#
# 하는 일(환경 파일·데이터는 건드리지 않습니다)
# 1) <APP_DIR>/haraka 디렉터리(실행 계정 소유)에 haraka/package.json 을 두고 npm 으로 Haraka 와 DKIM 플러그인을 설치합니다(인터넷 필요, 몇 분).
# 2) systemd 서비스 <APP_NAME>-haraka 를 등록·시작합니다. 이 서비스(haraka/run.mjs)는 설정 파일(config.json)의 수·발신 방식이 haraka 일 때만
# Haraka 를 띄우므로, 지금 Resend 만 쓰고 있어도 켜 두어도 아무 포트를 열지 않습니다(설정 화면에서 바꾸는 순간 동작).
# setup.sh 가 새 사이트에 자동으로 부르고, 기존 사이트는 한 번 직접 실행합니다. 다시 실행해도 안전합니다(설치된 버전이 같으면 npm 이 금방 끝남).
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'sudo 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
SITE_FILE="${1:-}"
# 사이트 파일: 인자 → <APP_DIR>/site.env(0.8.18 이후 setup.sh 가 둠) → deploy/sites/<SITE>.env(0.8.17 이전에 설치한 서버는 site.env 사본이 없음, deploy.sh 와 같은 폴백).
if [ -n "$SITE_FILE" ] && [ ! -f "$SITE_FILE" ]; then echo "사이트 설정 파일이 없어 저장소의 기본 파일을 씁니다: $SITE_FILE" >&2; SITE_FILE=""; fi
if [ -z "$SITE_FILE" ]; then APP_GUESS="$(cd "$DEPLOY_DIR/../.." && pwd)"; if [ -f "$APP_GUESS/site.env" ]; then SITE_FILE="$APP_GUESS/site.env"; else SITE_FILE="$DEPLOY_DIR/sites/${SITE:-arihakdang}.env"; fi; fi
load_site "$SITE_FILE"
echo "▶ Haraka 준비: $SITE ($APP_DIR/haraka, 서비스 $APP_NAME-haraka)"
[ -x "$NODE_BIN" ] || { echo "Node 실행 파일이 없습니다: $NODE_BIN" >&2; exit 1; }
NPM_BIN="$(dirname "$NODE_BIN")/npm"; [ -x "$NPM_BIN" ] || NPM_BIN="$(command -v npm || true)"
REPO_HARAKA="$APP_DIR/repo/haraka"; [ -f "$REPO_HARAKA/package.json" ] || REPO_HARAKA="$DEPLOY_DIR/../haraka"
mkdir -p "$APP_DIR/haraka"; chown "$APP_USER:$APP_USER" "$APP_DIR/haraka"
install -o "$APP_USER" -g "$APP_USER" -m 644 "$REPO_HARAKA/package.json" "$APP_DIR/haraka/package.json"
if [ -x "$NPM_BIN" ]; then
echo "▶ npm 설치 ($NPM_BIN) — 인터넷이 필요합니다"
# 실행 계정으로 설치해 node_modules 소유자를 맞춥니다. 실패해도(오프라인 등) 서비스는 등록하고 안내만 남깁니다.
if sudo -u "$APP_USER" env PATH="$(dirname "$NODE_BIN"):$PATH" HOME="$APP_DIR/haraka" "$NPM_BIN" install --omit=dev --no-audit --no-fund --no-package-lock --prefix "$APP_DIR/haraka" --loglevel=error; then
echo " 설치됨: Haraka $("$NODE_BIN" -e "console.log(require('$APP_DIR/haraka/node_modules/Haraka/package.json').version)")"
else echo " ⚠️ npm 설치 실패 — 인터넷 연결을 확인하고 다시 실행하세요: sudo bash $0 $SITE_FILE" >&2; fi
else echo " ⚠️ npm 을 찾지 못했습니다($NODE_BIN 옆에 없음). Node 배포판의 npm 을 두고 다시 실행하세요." >&2; fi
REPO_DEPLOY="$APP_DIR/repo/deploy"; [ -f "$REPO_DEPLOY/systemd/mail-haraka.service.in" ] || REPO_DEPLOY="$DEPLOY_DIR"
render "$REPO_DEPLOY/systemd/mail-haraka.service.in" "/etc/systemd/system/$APP_NAME-haraka.service"; chmod 644 "/etc/systemd/system/$APP_NAME-haraka.service"
# 실행 계정이 deploy.sh 에서 이 서비스를 재시작할 수 있게 sudoers 를 최신 규칙으로 다시 씁니다(setup.sh 와 같은 한 줄).
sudoers_line > "/etc/sudoers.d/$APP_NAME"; chmod 440 "/etc/sudoers.d/$APP_NAME"; visudo -c -q
systemctl daemon-reload
# 첫 배포 전(current 없음)에는 시작하지 않고 등록만 합니다(deploy.sh 가 끝나면 다음 부팅·수동 시작 때 뜸). current 가 있으면 바로 (재)시작.
systemctl enable -q "$APP_NAME-haraka"
if [ -e "$APP_DIR/current/haraka/run.mjs" ]; then systemctl restart "$APP_NAME-haraka"; sleep 1; systemctl is-active --quiet "$APP_NAME-haraka" && echo " 서비스 실행 중: $APP_NAME-haraka" || echo " ⚠️ 서비스가 뜨지 않았습니다: journalctl -u $APP_NAME-haraka -n 50" >&2
else echo " (아직 배포된 릴리스가 없어 서비스는 등록만 했습니다. deploy.sh 뒤 자동으로 시작합니다)"; fi
cat <<MSG
완료. 설정 화면(초기 설정 페이지 또는 /admin → 메일 서비스 설정)에서 수신/발신 방식을 "내장 Haraka" 로 고르면 몇 초 안에 Haraka 가 뜹니다.
- 수신(MX)으로 쓰려면 방화벽에서 25 포트를 열고 DNS 의 MX 를 이 서버로 바꾸세요. 예) sudo ufw allow 25/tcp
- 발신으로 쓰려면 설정 화면에서 DKIM 키를 만들고 안내된 SPF·DKIM TXT 레코드를 DNS 에 올리세요. 서버 IP 의 역방향 DNS(PTR)도 필요합니다.
- 로그: journalctl -u $APP_NAME-haraka -f
MSG
@@ -0,0 +1,12 @@
#!/bin/sh
# certbot 갱신 훅(@MAIL_HOST@): 인증서 사본을 서비스 계정 전용 경로에 두고 nginx·게이트웨이에 반영합니다.
# certbot 의 개인키는 root 만 읽으므로 게이트웨이(@APP_USER@)가 읽을 사본이 필요합니다.
set -e
LINEAGE="${RENEWED_LINEAGE:-/etc/letsencrypt/live/@MAIL_HOST@}"
case "$LINEAGE" in */@MAIL_HOST@*) ;; *) exit 0 ;; esac
mkdir -p @ETC_DIR@/tls
install -o @APP_USER@ -g @APP_USER@ -m 640 "$LINEAGE/fullchain.pem" @ETC_DIR@/tls/fullchain.pem
install -o @APP_USER@ -g @APP_USER@ -m 640 "$LINEAGE/privkey.pem" @ETC_DIR@/tls/privkey.pem
[ "${SKIP_RESTART:-0}" = 1 ] && exit 0
systemctl reload nginx || true
systemctl restart @APP_NAME@ || true
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# setup.sh / deploy.sh / tls-when-ready.sh 가 공유하는 함수(0.8.18~). 직접 실행하지 않고 source 합니다.
#
# load_site <파일> : 사이트 설정(deploy/sites/<이름>.env 또는 $APP_DIR/site.env)을 읽고 필수 값을 확인합니다.
# render <템플릿> <출력> : 템플릿의 @변수@ 자리를 사이트 설정 값으로 바꿔 씁니다(systemd·nginx·certbot 훅·환경 파일).
#
# 예전(0.8.17 까지)에는 모든 파일에 mail.arihakdang.com / arihakdang 계정 / /srv/arihakdang-mail 이 박혀 있어 다른 도메인에 설치할 수 없었습니다.
# 이제 사이트마다 변수 파일 하나로 서비스 이름·계정·경로·호스트·메일함을 정하고, 같은 템플릿으로 설정 파일을 만듭니다.
DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# 템플릿이 치환하는 변수 목록. 새 변수를 쓰려면 여기와 sites/*.env 에 함께 더합니다.
# 0.9.0: LOGIN_MODE(설치 옵션: standalone | www)와 시스템 제한 LIMIT_*(0 = 제한 없음, 크기는 500M/2G 같은 접미사 가능)을 더했습니다.
SITE_VARS=(SITE APP_NAME APP_USER APP_DIR ETC_DIR NODE_BIN PORT MAIL_HOST MAILBOX MAIL_FROM_NAME SITE_TITLE SITE_BRAND WWW_DB_FILE WEBMAKER_DIR WEBMAKER_USER WEBMAKER_SITE EXTRA_RW ACME_WEBROOT CF_CERT CF_KEY REPO_URL LOGIN_MODE LIMIT_ACCOUNTS LIMIT_TOTAL_BYTES LIMIT_MAILBOX_BYTES LIMIT_ATTACHMENT_BYTES LIMIT_RECIPIENTS LIMIT_MONTHLY_MESSAGES LIMIT_LARGE_FILE_BYTES LIMIT_LARGE_FILE_DAYS LIMIT_LARGE_FILE_DOWNLOADS)
load_site() {
local file="$1"
[ -f "$file" ] || { echo "사이트 설정 파일이 없습니다: $file" >&2; return 1; }
# shellcheck disable=SC1090
set -a; . "$file"; set +a
for v in SITE APP_NAME APP_USER APP_DIR ETC_DIR NODE_BIN PORT MAIL_HOST MAILBOX; do
[ -n "${!v:-}" ] || { echo "사이트 설정에 $v 가 없습니다: $file" >&2; return 1; }
done
: "${MAIL_FROM_NAME:=$SITE}" "${SITE_TITLE:=}" "${SITE_BRAND:=}" "${WWW_DB_FILE:=}" "${WEBMAKER_DIR:=}" "${WEBMAKER_USER:=}" "${WEBMAKER_SITE:=}" "${EXTRA_RW:=}" "${ACME_WEBROOT:=/var/www/letsencrypt}" "${CF_CERT:=}" "${CF_KEY:=}" "${REPO_URL:=https://source.ibit.kr/OZ-Group/AriMail.git}"
: "${LIMIT_ACCOUNTS:=0}" "${LIMIT_TOTAL_BYTES:=0}" "${LIMIT_MAILBOX_BYTES:=0}" "${LIMIT_ATTACHMENT_BYTES:=0}" "${LIMIT_RECIPIENTS:=0}" "${LIMIT_MONTHLY_MESSAGES:=0}"
# 대용량 첨부파일 시스템 상한(0.9.3): 무제한이 없으므로 기본값(1G / 30일 / 20회)을 둡니다.
: "${LIMIT_LARGE_FILE_BYTES:=1G}" "${LIMIT_LARGE_FILE_DAYS:=30}" "${LIMIT_LARGE_FILE_DOWNLOADS:=20}"
# 로그인 방식(0.9.0 설치 옵션). 비워 두면 WWW_DB_FILE 유무로 정합니다(0.8.x 사이트 파일 호환). standalone 이 기본 설치 형태입니다.
# standalone : www 사이트 없이 게이트웨이 계정만 씀(WWW_DB_FILE 은 비움) www : www 사이트 DB 로 관리자를 인증(WWW_DB_FILE 필수)
: "${LOGIN_MODE:=}"
if [ -z "$LOGIN_MODE" ]; then if [ -n "$WWW_DB_FILE" ]; then LOGIN_MODE=www; else LOGIN_MODE=standalone; fi; fi
# webmaker(1.0.8): 웹메이커 데이터 디렉터리(WEBMAKER_DIR)로 계정을 연동. WEBMAKER_USER(sudo 로 읽을 계정)·WEBMAKER_SITE(사이트 id)는 선택.
case "$LOGIN_MODE" in
standalone) WWW_DB_FILE=""; WEBMAKER_DIR="" ;;
www) WEBMAKER_DIR=""; [ -n "$WWW_DB_FILE" ] || { echo "LOGIN_MODE=www 이면 WWW_DB_FILE(www 사이트 DB 경로)이 필요합니다: $file" >&2; return 1; } ;;
webmaker) WWW_DB_FILE=""; [ -n "$WEBMAKER_DIR" ] || { echo "LOGIN_MODE=webmaker 이면 WEBMAKER_DIR(웹메이커 데이터 디렉터리)이 필요합니다: $file" >&2; return 1; } ;;
*) echo "LOGIN_MODE 는 standalone, www, webmaker 중 하나여야 합니다: $LOGIN_MODE ($file)" >&2; return 1 ;;
esac
export MAIL_FROM_NAME SITE_TITLE SITE_BRAND WWW_DB_FILE WEBMAKER_DIR WEBMAKER_USER WEBMAKER_SITE EXTRA_RW ACME_WEBROOT CF_CERT CF_KEY REPO_URL LOGIN_MODE LIMIT_ACCOUNTS LIMIT_TOTAL_BYTES LIMIT_MAILBOX_BYTES LIMIT_ATTACHMENT_BYTES LIMIT_RECIPIENTS LIMIT_MONTHLY_MESSAGES LIMIT_LARGE_FILE_BYTES LIMIT_LARGE_FILE_DAYS LIMIT_LARGE_FILE_DOWNLOADS
}
# sed 치환에서 특별한 뜻을 갖는 글자(구분자 | 와 &, \)를 피합니다.
_sed_escape() { local s="$1"; s="${s//\\/\\\\}"; s="${s//|/\\|}"; s="${s//&/\\&}"; printf '%s' "$s"; }
render() {
local template="$1" out="$2" expr=()
for v in "${SITE_VARS[@]}"; do expr+=(-e "s|@${v}@|$(_sed_escape "${!v:-}")|g"); done
sed "${expr[@]}" "$template" > "$out"
# 치환되지 않은 자리가 남으면 템플릿과 변수 목록이 어긋난 것이므로 바로 알립니다.
if grep -q '@[A-Z_]\{3,\}@' "$out"; then echo "템플릿에 알 수 없는 변수가 있습니다: $template → $(grep -o '@[A-Z_]\{3,\}@' "$out" | sort -u | tr '\n' ' ')" >&2; return 1; fi
}
# 실행 계정이 서비스를 재시작하고 nginx 를 다시 읽을 수 있게 하는 sudoers 규칙 한 줄.
# 1.0.0: 내장 Haraka 관리 서비스(<APP_NAME>-haraka)의 재시작도 허용합니다(deploy.sh 가 새 릴리스로 다시 띄움).
sudoers_line() { printf '%s ALL=(root) NOPASSWD: /usr/bin/systemctl restart %s, /usr/bin/systemctl restart %s-haraka, /usr/bin/systemctl reload nginx\n' "$APP_USER" "$APP_NAME" "$APP_NAME"; }
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# 새 사이트 설정 파일 만들기(0.9.0 설치 옵션) — 작업 PC 에서: bash deploy/new-site.sh <사이트이름> --host mail.example.com --mailbox admin@example.com [옵션]
#
# deploy/sites/<사이트이름>.env 를 만듭니다. setup.sh 는 이 파일로 디렉터리·환경 파일·systemd·nginx 를 준비합니다.
# 로그인 방식은 --login standalone(기본: www 사이트 없이 게이트웨이 계정만 사용) 또는 --login www --www-db <www DB 경로>(www 사이트 관리자로 인증) 중 고릅니다.
# 시스템 제한(--limit-*)은 설치 시 gateway.env 에 들어가는 상위 설정이며, 0(기본)은 제한 없음입니다. 크기는 500M, 2G 처럼 적습니다.
#
# 옵션: --user <실행 계정>(기본 사이트이름) --app-name <서비스 이름>(기본 <사이트이름>-mail) --app-dir <경로>(기본 /srv/<서비스 이름>) --etc-dir <경로>(기본 /etc/<서비스 이름>)
# --node <node 경로>(기본 /opt/<서비스 이름>-node/bin/node) --port <포트>(기본 8790) --from-name <발신 표시 이름> --title <탭 제목>
# --login standalone|www --www-db <경로> --extra-rw <경로> --repo <저장소 URL> --force(있는 파일 덮어쓰기)
# --limit-accounts N --limit-total 2G --limit-mailbox 500M --limit-attachment 20M --limit-recipients N --limit-monthly N
# --large-bytes 1G --large-days 30 --large-downloads 20 (대용량 첨부파일 시스템 상한, 0.9.3 — 무제한 불가, 기본 1G/30/20)
set -euo pipefail
DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SITE="${1:-}"; [ -n "$SITE" ] && [ "${SITE#-}" = "$SITE" ] || { sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 1; }
shift
[[ "$SITE" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "사이트 이름은 영문 소문자·숫자·하이픈이어야 합니다: $SITE" >&2; exit 1; }
MAIL_HOST=""; MAILBOX=""; APP_USER="$SITE"; APP_NAME="$SITE-mail"; APP_DIR=""; ETC_DIR=""; NODE_BIN=""; PORT=8790; MAIL_FROM_NAME=""; SITE_TITLE=""
LOGIN_MODE=standalone; WWW_DB_FILE=""; EXTRA_RW=""; REPO_URL="https://source.ibit.kr/OZ-Group/AriMail.git"; FORCE=0
LIMIT_ACCOUNTS=0; LIMIT_TOTAL_BYTES=0; LIMIT_MAILBOX_BYTES=0; LIMIT_ATTACHMENT_BYTES=0; LIMIT_RECIPIENTS=0; LIMIT_MONTHLY_MESSAGES=0
LIMIT_LARGE_FILE_BYTES=1G; LIMIT_LARGE_FILE_DAYS=30; LIMIT_LARGE_FILE_DOWNLOADS=20
while [ $# -gt 0 ]; do
case "$1" in
--host) MAIL_HOST="$2"; shift 2 ;; --mailbox) MAILBOX="$2"; shift 2 ;; --user) APP_USER="$2"; shift 2 ;; --app-name) APP_NAME="$2"; shift 2 ;;
--app-dir) APP_DIR="$2"; shift 2 ;; --etc-dir) ETC_DIR="$2"; shift 2 ;; --node) NODE_BIN="$2"; shift 2 ;; --port) PORT="$2"; shift 2 ;;
--from-name) MAIL_FROM_NAME="$2"; shift 2 ;; --title) SITE_TITLE="$2"; shift 2 ;; --login) LOGIN_MODE="$2"; shift 2 ;; --www-db) WWW_DB_FILE="$2"; shift 2 ;;
--extra-rw) EXTRA_RW="$2"; shift 2 ;; --repo) REPO_URL="$2"; shift 2 ;; --force) FORCE=1; shift ;;
--limit-accounts) LIMIT_ACCOUNTS="$2"; shift 2 ;; --limit-total) LIMIT_TOTAL_BYTES="$2"; shift 2 ;; --limit-mailbox) LIMIT_MAILBOX_BYTES="$2"; shift 2 ;;
--limit-attachment) LIMIT_ATTACHMENT_BYTES="$2"; shift 2 ;; --limit-recipients) LIMIT_RECIPIENTS="$2"; shift 2 ;; --limit-monthly) LIMIT_MONTHLY_MESSAGES="$2"; shift 2 ;;
--large-bytes) LIMIT_LARGE_FILE_BYTES="$2"; shift 2 ;; --large-days) LIMIT_LARGE_FILE_DAYS="$2"; shift 2 ;; --large-downloads) LIMIT_LARGE_FILE_DOWNLOADS="$2"; shift 2 ;;
*) echo "알 수 없는 옵션: $1" >&2; exit 1 ;;
esac
done
[ -n "$MAIL_HOST" ] && [ -n "$MAILBOX" ] || { echo "--host 와 --mailbox 는 꼭 필요합니다" >&2; exit 1; }
[[ "$MAILBOX" == *@*.* ]] || { echo "--mailbox 는 이메일 주소여야 합니다: $MAILBOX" >&2; exit 1; }
case "$LOGIN_MODE" in
standalone) WWW_DB_FILE="" ;;
www) [ -n "$WWW_DB_FILE" ] || { echo "--login www 에는 --www-db <www 사이트 DB 경로> 가 필요합니다" >&2; exit 1; }; [ -n "$EXTRA_RW" ] || EXTRA_RW="$(dirname "$WWW_DB_FILE")" ;;
*) echo "--login 은 standalone 또는 www 입니다" >&2; exit 1 ;;
esac
for v in LIMIT_ACCOUNTS LIMIT_RECIPIENTS LIMIT_MONTHLY_MESSAGES; do [[ "${!v}" =~ ^[0-9]+$ ]] || { echo "$v 는 0 이상의 정수여야 합니다: ${!v}" >&2; exit 1; }; done
for v in LIMIT_LARGE_FILE_DAYS LIMIT_LARGE_FILE_DOWNLOADS; do [[ "${!v}" =~ ^[1-9][0-9]*$ ]] || { echo "$v 는 1 이상의 정수여야 합니다(무제한 불가): ${!v}" >&2; exit 1; }; done
[[ "$LIMIT_LARGE_FILE_BYTES" =~ ^[1-9][0-9]*(\.[0-9]+)?[KkMmGgTt]?[Bb]?$ ]] || { echo "LIMIT_LARGE_FILE_BYTES 는 0 이 아닌 크기(예: 1G)여야 합니다: $LIMIT_LARGE_FILE_BYTES" >&2; exit 1; }
for v in LIMIT_TOTAL_BYTES LIMIT_MAILBOX_BYTES LIMIT_ATTACHMENT_BYTES; do [[ "${!v}" =~ ^[0-9]+(\.[0-9]+)?[KkMmGgTt]?[Bb]?$ ]] || { echo "$v 는 크기(예: 500M, 2G, 0)여야 합니다: ${!v}" >&2; exit 1; }; done
: "${APP_DIR:=/srv/$APP_NAME}" "${ETC_DIR:=/etc/$APP_NAME}" "${NODE_BIN:=/opt/$APP_NAME-node/bin/node}" "${MAIL_FROM_NAME:=$SITE}"
OUT="$DEPLOY_DIR/sites/$SITE.env"
[ "$FORCE" = 1 ] || [ ! -e "$OUT" ] || { echo "이미 있습니다: $OUT (덮어쓰려면 --force)" >&2; exit 1; }
cat > "$OUT" <<ENV
# 사이트 설정: $MAIL_HOST (deploy/new-site.sh 가 $(date +%Y-%m-%d) 에 만듦) — 로그인 방식 $LOGIN_MODE
# setup.sh / deploy.sh / tls-when-ready.sh 가 읽는 셸 변수입니다. 설치: sudo env SITE=$SITE SOURCE_BUNDLE=<번들> bash deploy/setup.sh
SITE=$SITE
APP_NAME=$APP_NAME
APP_USER=$APP_USER
APP_DIR=$APP_DIR
ETC_DIR=$ETC_DIR
NODE_BIN=$NODE_BIN
PORT=$PORT
MAIL_HOST=$MAIL_HOST
MAILBOX=$MAILBOX
MAIL_FROM_NAME="$MAIL_FROM_NAME"
SITE_TITLE="$SITE_TITLE"
SITE_BRAND=""
# 로그인 방식(0.9.0 설치 옵션): standalone = www 사이트 없이 게이트웨이 계정만 사용(기본) / www = www 사이트 DB 로 관리자 인증(WWW_DB_FILE 필수)
LOGIN_MODE=$LOGIN_MODE
WWW_DB_FILE=$WWW_DB_FILE
EXTRA_RW=$EXTRA_RW
ACME_WEBROOT=/var/www/letsencrypt
CF_CERT=
CF_KEY=
REPO_URL=$REPO_URL
# 시스템 제한(0.9.0): 메일 시스템 전체의 상위 설정. 0 = 제한 없음. 크기는 500M/2G 식(MIME 변환 뒤 크기 기준).
LIMIT_ACCOUNTS=$LIMIT_ACCOUNTS
LIMIT_TOTAL_BYTES=$LIMIT_TOTAL_BYTES
LIMIT_MAILBOX_BYTES=$LIMIT_MAILBOX_BYTES
LIMIT_ATTACHMENT_BYTES=$LIMIT_ATTACHMENT_BYTES
LIMIT_RECIPIENTS=$LIMIT_RECIPIENTS
LIMIT_MONTHLY_MESSAGES=$LIMIT_MONTHLY_MESSAGES
# 대용량 첨부파일 시스템 상한(0.9.3): 무제한 불가.
LIMIT_LARGE_FILE_BYTES=$LIMIT_LARGE_FILE_BYTES
LIMIT_LARGE_FILE_DAYS=$LIMIT_LARGE_FILE_DAYS
LIMIT_LARGE_FILE_DOWNLOADS=$LIMIT_LARGE_FILE_DOWNLOADS
ENV
echo "✔ 만들었습니다: $OUT"
echo "다음: 서버에서 sudo env SITE=$SITE SOURCE_BUNDLE=/tmp/arimail.bundle bash <저장소>/deploy/setup.sh"
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# nginx 설정만 다시 적용(0.9.5~) — root 로: sudo bash deploy/nginx-apply.sh <APP_DIR>/site.env
#
# setup.sh 전체를 다시 돌리지 않고(환경 파일·인증서 사본은 건드리지 않음) 저장소의 nginx 템플릿을 사이트 설정으로 채워
# 기존 vhost 파일을 바꿔 쓰고 nginx -t 로 검사한 뒤 reload 합니다. 템플릿은 setup.sh 와 같은 규칙으로 고릅니다
# (Let's Encrypt 인증서 → mail.conf.in, Cloudflare 오리진 인증서 → mail.cloudflare.conf.in, 둘 다 없으면 80 전용).
# vhost 파일 경로: sites-enabled 에서 이 호스트(server_name)를 쓰는 파일이 있으면 그 실제 파일(예전 이름 mail.arihakdang.conf 도 포함), 없으면 sites-available/<MAIL_HOST>.conf.
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'root 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
load_site "${1:?site.env 경로}"
REPO_DEPLOY="$(cd "$(dirname "$0")" && pwd)"
if [ -d "/etc/letsencrypt/live/$MAIL_HOST" ]; then TEMPLATE="$REPO_DEPLOY/nginx/mail.conf.in"
elif [ -n "$CF_CERT" ] && [ -f "$CF_CERT" ]; then TEMPLATE="$REPO_DEPLOY/nginx/mail.cloudflare.conf.in"
else TEMPLATE="$REPO_DEPLOY/nginx/mail.http-only.conf.in"; fi
TARGET=""
for f in /etc/nginx/sites-enabled/*; do [ -f "$f" ] || continue; if grep -qE "^\s*server_name\s+$MAIL_HOST;" "$f"; then TARGET="$(readlink -f "$f")"; break; fi; done
[ -n "$TARGET" ] || { TARGET="/etc/nginx/sites-available/$MAIL_HOST.conf"; ln -sfn "$TARGET" "/etc/nginx/sites-enabled/$MAIL_HOST.conf"; }
BACKUP="$TARGET.bak-$(date +%Y%m%d-%H%M%S)"; [ -f "$TARGET" ] && cp "$TARGET" "$BACKUP"
render "$TEMPLATE" "$TARGET"; chmod 644 "$TARGET"
if nginx -t; then systemctl reload nginx; echo "✔ nginx 적용: $TARGET ($(basename "$TEMPLATE")), 이전 설정 $BACKUP"
else echo "nginx 검사 실패 — 이전 설정으로 되돌립니다" >&2; [ -f "$BACKUP" ] && cp "$BACKUP" "$TARGET"; exit 1; fi
@@ -0,0 +1,27 @@
# AriMail 경로 접두사(1.0.8, 요구사항 1): 이 사이트의 @MAIL_PATH@/ 아래를 게이트웨이(127.0.0.1:@PORT@)로 넘깁니다. deploy/site-mail-path.sh 가 만들어
# /etc/nginx/snippets/ 에 두고, 사이트의 server(443) 블록에 include 합니다. 서브 도메인 사이트(site.example.com)처럼 메일용 호스트 이름을 따로 두기 어려울 때
# https://site.example.com@MAIL_PATH@/ 가 그 도메인의 웹 메일이 됩니다(전체 설정 화면의 호스트 항목은 "site.example.com@MAIL_PATH@").
#
# - proxy_pass 끝의 "/" 가 @MAIL_PATH@/ 를 떼고 넘기므로 게이트웨이는 늘 / 기준 경로를 봅니다. 대신 X-Forwarded-Prefix 로 접두사를 알려 주면 화면(<base>)·링크가 그 아래로 만들어집니다.
# - ^~ : 사이트 설정의 정규식 location(예: 숨김 파일 차단)이 이 경로를 가로채지 못하게 합니다.
# - 대용량 첨부파일(@MAIL_PATH@/api/files)은 본문 크기 제한 없이 버퍼링 없이 흘려보냅니다(mail.conf.in 과 같은 이유).
location = @MAIL_PATH@ { return 301 @MAIL_PATH@/; }
location ^~ @MAIL_PATH@/ {
proxy_pass http://127.0.0.1:@PORT@/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Prefix @MAIL_PATH@;
proxy_set_header CF-Connecting-IP "";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
client_max_body_size 0;
proxy_request_buffering off;
proxy_buffering off;
proxy_no_cache 1;
proxy_cache_bypass 1;
# add_header 를 두지 않는 이유: location 안의 add_header 는 사이트 server 블록의 헤더(HSTS 등) 상속을 끊습니다. 캐시 금지·보안 헤더는 게이트웨이가 응답마다 붙입니다.
}
@@ -0,0 +1,76 @@
# @MAIL_HOST@ 가 Cloudflare 프록시(주황 구름) 뒤에 있을 때의 443 설정.
# Cloudflare 오리진 인증서(@CF_CERT@)를 써서 웹 화면과 웹훅을 바로 제공합니다.
# POP3·IMAP·SMTP 는 프록시를 지나지 못하므로, 메일 프로그램을 쓰려면 DNS 를 회색 구름으로 바꾸고
# Let's Encrypt 발급 후 setup.sh 로 mail.conf 로 교체합니다.
server {
listen 80; listen [::]:80;
server_name @MAIL_HOST@;
location /.well-known/acme-challenge/ { root @ACME_WEBROOT@; }
location / { return 301 https://@MAIL_HOST@$request_uri; }
}
server {
listen 443 ssl http2; listen [::]:443 ssl http2;
server_name @MAIL_HOST@;
ssl_certificate @CF_CERT@;
ssl_certificate_key @CF_KEY@;
ssl_protocols TLSv1.2 TLSv1.3;
server_tokens off; charset utf-8; client_max_body_size 32m;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Cache-Control "no-store, no-transform" always;
# 대용량 첨부파일 업로드(0.9.5): 본문 크기 제한을 두지 않고(크기는 Node 가 관리자·시스템 설정으로 거부) 버퍼링 없이 바로 흘려보냅니다.
# 예전에는 server 블록의 client_max_body_size 32m 에 걸려 32MB 가 넘는 파일이 nginx 의 413(HTML)으로 끊겼고 화면에는 일반 오류만 보였습니다.
# 버퍼링을 끄는 이유: 켜 두면 nginx 가 1GB 를 임시 파일에 다 받은 뒤 넘기므로 브라우저의 진행률은 100% 인데 서버 저장은 한참 뒤에 끝나고 디스크를 두 번 씁니다.
location = /api/files {
client_max_body_size 0;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:@PORT@;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Prefix "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
# 대용량 첨부파일 다운로드 링크: 큰 파일을 오래 흘려보내므로 응답 버퍼링을 끄고 시간 제한을 넉넉히 둡니다.
location /dl/ {
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:@PORT@;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Prefix "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
location / {
proxy_pass http://127.0.0.1:@PORT@;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Prefix "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_read_timeout 180s;
proxy_no_cache 1; proxy_cache_bypass 1;
}
access_log /var/log/nginx/@APP_NAME@.access.log;
error_log /var/log/nginx/@APP_NAME@.error.log;
}
+90
View File
@@ -0,0 +1,90 @@
# @MAIL_HOST@ — 웹 메일함과 Resend 웹훅. POP3·IMAP·SMTP 포트는 Node 프로세스가 직접 엽니다.
#
# DNS: @MAIL_HOST@ 는 이 서버를 직접 가리키는 A 레코드여야 합니다(Cloudflare 를 쓰면 프록시 끔, 회색 구름).
# 메일 프로그램 포트는 프록시를 지나지 못하고 공개 CA 인증서를 요구하므로 Let's Encrypt 인증서를 원본 서버에 둡니다.
#
# 인증서: certbot certonly --webroot -w @ACME_WEBROOT@ -d @MAIL_HOST@ (아래 경로가 만들어집니다)
server {
listen 80;
listen [::]:80;
server_name @MAIL_HOST@;
location /.well-known/acme-challenge/ { root @ACME_WEBROOT@; }
location / { return 301 https://@MAIL_HOST@$request_uri; }
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name @MAIL_HOST@;
ssl_certificate /etc/letsencrypt/live/@MAIL_HOST@/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/@MAIL_HOST@/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
server_tokens off;
charset utf-8;
client_max_body_size 32m;
# 로그인 전용 화면: 색인·캐시·프레임 금지. 응답 헤더는 Node 도 같이 붙이지만 nginx 레벨에서도 보장합니다.
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Cache-Control "no-store, no-transform" always;
# 대용량 첨부파일 업로드(0.9.5): 본문 크기 제한을 두지 않고(크기는 Node 가 관리자·시스템 설정으로 거부) 버퍼링 없이 바로 흘려보냅니다.
# 예전에는 server 블록의 client_max_body_size 32m 에 걸려 32MB 가 넘는 파일이 nginx 의 413(HTML)으로 끊겼고 화면에는 일반 오류만 보였습니다.
# 버퍼링을 끄는 이유: 켜 두면 nginx 가 1GB 를 임시 파일에 다 받은 뒤 넘기므로 브라우저의 진행률은 100% 인데 서버 저장은 한참 뒤에 끝나고 디스크를 두 번 씁니다.
location = /api/files {
client_max_body_size 0;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:@PORT@;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Prefix "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP "";
proxy_no_cache 1;
proxy_cache_bypass 1;
}
# 대용량 첨부파일 다운로드 링크: 큰 파일을 오래 흘려보내므로 응답 버퍼링을 끄고 시간 제한을 넉넉히 둡니다.
location /dl/ {
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:@PORT@;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Prefix "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP "";
proxy_no_cache 1;
proxy_cache_bypass 1;
}
location / {
proxy_pass http://127.0.0.1:@PORT@;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Prefix "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP "";
proxy_read_timeout 180s;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
access_log /var/log/nginx/@APP_NAME@.access.log;
error_log /var/log/nginx/@APP_NAME@.error.log;
}
@@ -0,0 +1,8 @@
# 인증서 발급 전 임시 설정: ACME 챌린지만 받고 나머지는 503. 발급 후 setup.sh(또는 tls-when-ready 타이머)가 443 설정으로 교체합니다.
server {
listen 80;
listen [::]:80;
server_name @MAIL_HOST@;
location /.well-known/acme-challenge/ { root @ACME_WEBROOT@; }
location / { return 503; }
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# 최초 1회 서버 준비(이후 인증서 발급 뒤에 다시 실행) — root 로 실행:
# sudo env SITE=heart-m SOURCE_BUNDLE=/tmp/arimail.bundle bash deploy/setup.sh
# SITE 는 deploy/sites/<SITE>.env 를 고릅니다(기본 arihakdang). 그 파일의 실행 계정(APP_USER)과 Node(NODE_BIN)는 미리 있어야 합니다.
# 새 사이트 파일은 deploy/new-site.sh 로 만듭니다(0.9.0). LOGIN_MODE(standalone | www)와 시스템 제한(LIMIT_*)은 그 파일에서 정하며,
# 환경 파일(gateway.env)은 처음 한 번만 만들므로 이후 바꾸려면 $ETC_DIR/gateway.env 를 직접 고치고 서비스를 재시작합니다.
# 하는 일: 디렉터리·사이트 설정 사본·환경 파일 → 저장소(번들 또는 원격) → systemd·sudoers → nginx(인증서 유무에 따라 80 전용/443) → certbot 훅 → 내장 Haraka(haraka-apply.sh, 1.0.0).
# 1.0.0: 운영 설정(Resend 키 등)은 환경 파일이 아니라 데이터 디렉터리의 config.json 에 있으며 배포 뒤 초기 설정 페이지에서 채웁니다(값이 없어도 배포는 성공).
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'sudo 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
SITE="${SITE:-arihakdang}"
load_site "$DEPLOY_DIR/sites/$SITE.env"
echo "▶ 사이트: $SITE ($MAIL_HOST, 계정 $APP_USER, $APP_DIR, 로그인 $LOGIN_MODE)"
id "$APP_USER" >/dev/null 2>&1 || { echo "실행 계정 $APP_USER 이 없습니다. 계정을 먼저 만드세요(www 연동 사이트라면 www 배포 문서를 따르세요)." >&2; exit 1; }
[ -x "$NODE_BIN" ] || { echo "Node 실행 파일이 없습니다: $NODE_BIN — nodejs.org 의 Linux 바이너리(22.13+)를 풀어 두세요(예: tar -xJf node-v22.x-linux-x64.tar.xz -C $(dirname "$(dirname "$NODE_BIN")") --strip-components=1)." >&2; exit 1; }
MAJOR="$("$NODE_BIN" --version | sed -E 's/^v([0-9]+).*/\1/')"; [ "$MAJOR" -ge 22 ] || { echo "Node 22.13+ 필요: $("$NODE_BIN" --version)" >&2; exit 1; }
mkdir -p "$APP_DIR"/{repo,releases,data} "$ETC_DIR" "$ACME_WEBROOT"
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
# 사이트 설정 사본: deploy.sh 와 tls-when-ready.sh 가 저장소 밖의 이 파일로 자기 사이트를 압니다.
install -o "$APP_USER" -g "$APP_USER" -m 644 "$DEPLOY_DIR/sites/$SITE.env" "$APP_DIR/site.env"
if [ ! -f "$ETC_DIR/gateway.env" ]; then
render "$DEPLOY_DIR/gateway.env.in" "$ETC_DIR/gateway.env"
chown "$APP_USER:$APP_USER" "$ETC_DIR/gateway.env"; chmod 600 "$ETC_DIR/gateway.env"
echo "→ $ETC_DIR/gateway.env 를 만들었습니다(배포에 필요한 값만). Resend 키 등 운영 설정은 배포 뒤 초기 설정 페이지에서 채웁니다."
fi
if [ ! -d "$APP_DIR/repo/.git" ]; then
if [ -n "${SOURCE_BUNDLE:-}" ]; then
echo '→ 번들에서 저장소를 만듭니다 (서버에 원격 자격 증명을 남기지 않음).'
sudo -u "$APP_USER" git clone -q -b main "$SOURCE_BUNDLE" "$APP_DIR/repo"
sudo -u "$APP_USER" git -C "$APP_DIR/repo" remote set-url origin "$REPO_URL"
else
echo "→ 저장소를 $APP_USER 계정으로 복제합니다 ($REPO_URL 자격 증명 필요)."
sudo -u "$APP_USER" git clone "$REPO_URL" "$APP_DIR/repo"
fi
fi
REPO_DEPLOY="$APP_DIR/repo/deploy"
render "$REPO_DEPLOY/systemd/mail-gateway.service.in" "/etc/systemd/system/$APP_NAME.service"; chmod 644 "/etc/systemd/system/$APP_NAME.service"
# 배포 스크립트(실행 계정)가 서비스 재시작과 nginx 재적재만 할 수 있게 합니다.
sudoers_line > "/etc/sudoers.d/$APP_NAME"; chmod 440 "/etc/sudoers.d/$APP_NAME"; visudo -c -q
# nginx: 인증서가 있으면 443, Cloudflare 오리진 인증서만 있으면 프록시용 443, 둘 다 없으면 80(ACME 챌린지)만 엽니다.
if [ -d "/etc/letsencrypt/live/$MAIL_HOST" ]; then
render "$REPO_DEPLOY/nginx/mail.conf.in" "/etc/nginx/sites-available/$MAIL_HOST.conf"
elif [ -n "$CF_CERT" ] && [ -f "$CF_CERT" ]; then
render "$REPO_DEPLOY/nginx/mail.cloudflare.conf.in" "/etc/nginx/sites-available/$MAIL_HOST.conf"
else
render "$REPO_DEPLOY/nginx/mail.http-only.conf.in" "/etc/nginx/sites-available/$MAIL_HOST.conf"
fi
chmod 644 "/etc/nginx/sites-available/$MAIL_HOST.conf"
ln -sfn "/etc/nginx/sites-available/$MAIL_HOST.conf" "/etc/nginx/sites-enabled/$MAIL_HOST.conf"
nginx -t && systemctl reload nginx
systemctl daemon-reload
systemctl enable -q "$APP_NAME"
# 메일 포트 TLS: certbot 의 개인키는 root 만 읽으므로, 실행 계정이 읽을 사본을 만들고 갱신 때마다 다시 복사합니다.
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
render "$REPO_DEPLOY/letsencrypt-deploy-hook.sh.in" "/etc/letsencrypt/renewal-hooks/deploy/$APP_NAME.sh"; chmod 755 "/etc/letsencrypt/renewal-hooks/deploy/$APP_NAME.sh"
if [ -d "/etc/letsencrypt/live/$MAIL_HOST" ]; then
RENEWED_LINEAGE="/etc/letsencrypt/live/$MAIL_HOST" SKIP_RESTART=1 sh "/etc/letsencrypt/renewal-hooks/deploy/$APP_NAME.sh"
sed -i -E "s#^POP_TLS_CERT=.*#POP_TLS_CERT=$ETC_DIR/tls/fullchain.pem#; s#^POP_TLS_KEY=.*#POP_TLS_KEY=$ETC_DIR/tls/privkey.pem#" "$ETC_DIR/gateway.env"
# 서비스가 이미 돌고 있으면 새 인증서로 다시 띄웁니다(처음 설치라면 아직 current 가 없어 deploy.sh 가 시작합니다).
if systemctl is-active --quiet "$APP_NAME"; then systemctl restart "$APP_NAME"; fi
else
# 인증서가 생길 때까지 15분마다 DNS 를 확인해 자동 발급하는 타이머를 켭니다(발급되면 스스로 꺼짐).
render "$REPO_DEPLOY/systemd/mail-tls.service.in" "/etc/systemd/system/$APP_NAME-tls.service"
render "$REPO_DEPLOY/systemd/mail-tls.timer.in" "/etc/systemd/system/$APP_NAME-tls.timer"
systemctl daemon-reload; systemctl enable -q --now "$APP_NAME-tls.timer"
cat <<MSG
→ TLS 인증서가 아직 없습니다. DNS($MAIL_HOST → 이 서버, Cloudflare 프록시 끔)가 바뀌면 $APP_NAME-tls.timer 가 15분 안에
certbot 으로 인증서를 받고 443 설정과 메일 포트 TLS(995/993/465)를 켭니다. 바로 하려면:
sudo bash $REPO_DEPLOY/tls-when-ready.sh $APP_DIR/site.env
MSG
fi
if [ -z "$WWW_DB_FILE" ]; then
cat <<MSG
→ 독립 실행(www 연동 없음)입니다. 첫 배포 때 서버가 초기 관리자 계정(${MAILBOX%@*} / adminpasswd)을 자동으로 만듭니다(0.8.24~).
https://$MAIL_HOST/admin 에 로그인해 바로 비밀번호를 바꾸세요. 추가 계정·비밀번호 재설정은 관리자 페이지 또는:
sudo -u $APP_USER env \$(grep -E '^(DB_FILE|MAILBOX)=' $ETC_DIR/gateway.env | xargs) $NODE_BIN $APP_DIR/current/server/user-cli.mjs passwd ${MAILBOX%@*}
MSG
fi
# 내장 Haraka(1.0.0): npm 설치와 관리 서비스 등록. 실패해도(오프라인) 설치는 계속되고 설정 화면이 안내합니다.
bash "$REPO_DEPLOY/haraka-apply.sh" "$APP_DIR/site.env" || echo "⚠️ Haraka 준비에 실패했습니다. 나중에 다시: sudo bash $REPO_DEPLOY/haraka-apply.sh $APP_DIR/site.env" >&2
echo "완료. 다음: sudo -u $APP_USER env SOURCE_BUNDLE=... $APP_DIR/repo/deploy/deploy.sh"
echo "배포가 끝나면 초기 설정 페이지 주소(포트 8791, 토큰 포함)가 출력됩니다. 방화벽을 쓰면 8791 포트를 열어 두세요(예: sudo ufw allow 8791/tcp)."
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# 사이트의 /mail 경로를 AriMail 웹 메일로 잇습니다(1.0.8, 요구사항 1) — root 로 실행:
# sudo bash <APP_DIR>/repo/deploy/site-mail-path.sh <nginx 설정 파일 | 사이트 호스트 이름> [--path /mail] [--site <site.env 경로>]
# 예) sudo bash deploy/site-mail-path.sh site.ariweb.net # sites-enabled 에서 server_name 에 그 이름이 있는 설정 파일을 찾음
# sudo bash deploy/site-mail-path.sh /etc/nginx/sites-enabled/wh-0aa8….conf
#
# 왜 필요한가
# 웹메이커가 만든 서브 도메인 사이트(site.ariweb.net)는 메일용 호스트 이름(mail.site.ariweb.net)을 따로 두기 어렵습니다(와일드카드 인증서가 한 단계만 덮음).
# 그래서 그 사이트 주소의 /mail 아래를 게이트웨이로 넘겨 https://site.ariweb.net/mail/ 이 그 도메인의 웹 메일이 되게 합니다. 게이트웨이는 Host 로 도메인(테넌트)을
# 고르고 X-Forwarded-Prefix 로 화면의 기준 경로를 압니다. 전체 설정 화면의 호스트 항목에는 "site.ariweb.net/mail" 처럼 경로까지 적습니다.
#
# 하는 일
# 1) deploy/nginx/mail-path.conf.in 을 포트·경로로 채워 /etc/nginx/snippets/<APP_NAME>-mail-path.conf 로 둡니다(여러 사이트가 같은 조각을 include).
# 2) 지정한 설정 파일의 443 server 블록마다 그 조각의 include 줄을 넣습니다(이미 있으면 그대로, 80 전용 블록은 건드리지 않음 — https 로의 301 이 유지되게).
# 바꾸기 전 사본(<파일>.bak-<시각>)을 남기고 nginx -t 가 실패하면 되돌립니다.
# 3) nginx 를 다시 읽습니다.
# 주의: 웹메이커는 사이트를 다시 발행할 때 그 vhost 파일(wh-<사이트ID>.conf)을 다시 쓰므로 include 줄이 사라질 수 있습니다. 그때는 이 명령을 다시 실행하거나,
# 웹메이커의 vhost 템플릿(lib/deploy.js vhostBlock)에 같은 include 를 넣어 두세요. 게이트웨이 설정·데이터는 건드리지 않습니다.
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'root 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
TARGET=""; MAIL_PATH=/mail; SITE_FILE=""
while [ $# -gt 0 ]; do
case "$1" in
--path) MAIL_PATH="$2"; shift 2 ;;
--site) SITE_FILE="$2"; shift 2 ;;
-h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) [ -z "$TARGET" ] || { echo "알 수 없는 인수: $1" >&2; exit 1; }; TARGET="$1"; shift ;;
esac
done
[ -n "$TARGET" ] || { echo "nginx 설정 파일이나 사이트 호스트 이름을 주세요(--help)" >&2; exit 1; }
printf '%s' "$MAIL_PATH" | grep -Eq '^/[A-Za-z0-9._~-]+(/[A-Za-z0-9._~-]+)*$' || { echo "경로 형식이 아닙니다: $MAIL_PATH (예: /mail)" >&2; exit 1; }
if [ -z "$SITE_FILE" ]; then APP_GUESS="$(cd "$DEPLOY_DIR/../.." && pwd)"; if [ -f "$APP_GUESS/site.env" ]; then SITE_FILE="$APP_GUESS/site.env"; else SITE_FILE="$DEPLOY_DIR/sites/${SITE:-arihakdang}.env"; fi; fi
load_site "$SITE_FILE"
# 설정 파일 찾기: 파일 경로면 그대로, 아니면 sites-enabled 에서 server_name 줄에 그 호스트가 있는 파일(하나여야 함).
if [ -f "$TARGET" ]; then CONF="$TARGET"; else
HOST_LC="$(printf '%s' "$TARGET" | tr 'A-Z' 'a-z')"
mapfile -t FOUND < <(grep -lE "^[[:space:]]*server_name[[:space:]]+([^;]*[[:space:]])?${HOST_LC//./\\.}([[:space:];]|$)" /etc/nginx/sites-enabled/*.conf /etc/nginx/sites-enabled/* /etc/nginx/conf.d/*.conf 2>/dev/null | sort -u || true)
[ "${#FOUND[@]}" -gt 0 ] || { echo "server_name 에 $HOST_LC 가 있는 nginx 설정을 찾지 못했습니다(사이트를 먼저 발행하세요)" >&2; exit 1; }
[ "${#FOUND[@]}" -eq 1 ] || { echo "설정 파일이 여럿입니다 — 파일 경로를 직접 주세요: ${FOUND[*]}" >&2; exit 1; }
CONF="${FOUND[0]}"
fi
SNIPPET="/etc/nginx/snippets/$APP_NAME-mail-path.conf"; INCLUDE=" include $SNIPPET;"
mkdir -p /etc/nginx/snippets
sed -e "s|@PORT@|$PORT|g" -e "s|@MAIL_PATH@|$MAIL_PATH|g" "$DEPLOY_DIR/nginx/mail-path.conf.in" > "$SNIPPET"; chmod 644 "$SNIPPET"
echo "▶ $CONF 의 443 블록에 $MAIL_PATH → 127.0.0.1:$PORT (조각 $SNIPPET)"
BAK="$CONF.bak-$(date +%Y%m%d-%H%M%S)"; cp -p "$CONF" "$BAK"
# server 블록 단위로 읽어 443 을 듣고 include 가 없는 블록의 닫는 괄호 앞에 include 를 넣습니다. 블록은 "server {" 로 열리고 첫 칸의 "}" 로 닫힙니다(웹메이커·AriMail 템플릿 모두 이 꼴).
awk -v inc="$INCLUDE" -v snippet="$SNIPPET" '
/^[[:space:]]*server[[:space:]]*\{[[:space:]]*$/ { inblock = 1; buf = ""; ssl = 0; has = 0 }
inblock {
buf = buf $0 "\n"
if ($0 ~ /^[[:space:]]*listen[^;]*[^0-9]443([^0-9]|$)/) ssl = 1
if (index($0, snippet)) has = 1
if ($0 ~ /^\}[[:space:]]*$/) { if (ssl && !has) { sub(/\}[[:space:]]*\n$/, inc "\n}\n", buf); added++ } printf "%s", buf; inblock = 0 }
next
}
{ print }
END { if (inblock) printf "%s", buf; if (!added) print "(이미 적용되어 있거나 443 블록이 없음)" > "/dev/stderr" }
' "$BAK" > "$CONF"
# 닫는 괄호를 못 찾아 블록이 잘리는 일이 없도록 줄 수를 비교합니다(include 줄만 늘어야 함). 사본은 성공해도 남겨 둡니다.
[ "$(wc -l < "$CONF")" -ge "$(wc -l < "$BAK")" ] || { cp -p "$BAK" "$CONF"; echo "✖ 설정 파일 구조를 해석하지 못해 되돌렸습니다(블록 닫는 괄호가 첫 칸에 있어야 함): $CONF" >&2; exit 1; }
if nginx -t; then systemctl reload nginx; echo "✔ https://<사이트 호스트>$MAIL_PATH/ 준비 완료(사본 $BAK). 전체 설정 화면의 도메인 호스트 항목에 \"<사이트 호스트>$MAIL_PATH\" 를 넣으세요."
else cp -p "$BAK" "$CONF"; echo "✖ nginx 설정 검사 실패 — 원래 파일로 되돌렸습니다($BAK 는 남김)" >&2; exit 1; fi
@@ -0,0 +1,39 @@
[Unit]
Description=@APP_NAME@ — AriMail gateway (@MAIL_HOST@: webmail, IMAP/POP3/SMTP, Resend or Haraka)
Documentation=https://source.ibit.kr/OZ-Group/AriMail
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# 사이트 설정(deploy/sites/@SITE@.env)의 실행 계정. www 사이트와 연동하면 www DB 를 같은 권한으로 읽기 위해 www 와 같은 계정을 씁니다.
User=@APP_USER@
Group=@APP_USER@
WorkingDirectory=@APP_DIR@/current
EnvironmentFile=@ETC_DIR@/gateway.env
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=@PORT@
ExecStart=@NODE_BIN@ server/index.mjs
Restart=always
RestartSec=3
# 110/143/465/587/993/995 포트를 일반 계정으로 열기 위한 권한
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
# 게이트웨이 DB·설정 파일(config.json, 1.0.0)·초기 설정 토큰과(www 연동 시) WAL 모드 www DB 를 읽기 위한 공유 메모리(-shm) 경로
ReadWritePaths=@APP_DIR@/data @EXTRA_RW@
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
StandardOutput=journal
StandardError=journal
SyslogIdentifier=@APP_NAME@
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,42 @@
[Unit]
Description=@APP_NAME@ — AriMail 내장 Haraka 관리(수신 25 / 로컬 제출 포트, 설정 파일에 따라 켜고 끔)
Documentation=https://source.ibit.kr/OZ-Group/AriMail
After=network-online.target @APP_NAME@.service
Wants=network-online.target
[Service]
Type=simple
User=@APP_USER@
Group=@APP_USER@
WorkingDirectory=@APP_DIR@/current
# 게이트웨이와 같은 환경 파일: DB_FILE(설정 파일 위치)·PORT(게이트웨이 HTTP)·MAILBOX(도메인)·PUBLIC_HOST·POP_TLS_* 를 읽습니다. 값이 없어도 뜹니다.
EnvironmentFile=@ETC_DIR@/gateway.env
Environment=NODE_ENV=production
Environment=PORT=@PORT@
Environment=HARAKA_DIR=@APP_DIR@/haraka
# haraka/run.mjs 는 config.json 을 5초마다 보고 Haraka(자식 프로세스)를 띄우거나 끕니다. 쓰지 않을 때는 아무 포트도 열지 않고 대기만 합니다.
ExecStart=@NODE_BIN@ @APP_DIR@/current/haraka/run.mjs
Restart=always
RestartSec=3
KillMode=mixed
TimeoutStopSec=15
# 25 포트를 일반 계정으로 열기 위한 권한(자식 프로세스 Haraka 도 상속)
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
# haraka/: 설정·플러그인·큐·node_modules, data/: 상태 파일(haraka-status.json)
ReadWritePaths=@APP_DIR@/haraka @APP_DIR@/data
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
StandardOutput=journal
StandardError=journal
SyslogIdentifier=@APP_NAME@-haraka
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,8 @@
[Unit]
Description=@APP_NAME@ — DNS 가 이 서버를 가리키면 Let's Encrypt 인증서를 받고 443·메일 포트 TLS 를 켭니다
After=network-online.target nginx.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/env bash @APP_DIR@/repo/deploy/tls-when-ready.sh @APP_DIR@/site.env
@@ -0,0 +1,10 @@
[Unit]
Description=@APP_NAME@ — 인증서가 생길 때까지 15분마다 DNS 를 확인해 TLS 를 켭니다(발급되면 스스로 멈춤)
[Timer]
OnBootSec=2min
OnUnitActiveSec=15min
Unit=@APP_NAME@-tls.service
[Install]
WantedBy=timers.target
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# 인증서 자동 발급(0.8.18~) — root 로 실행(보통 <APP_NAME>-tls.timer 가 15분마다 부름): bash deploy/tls-when-ready.sh <site.env>
#
# 새 서버에 설치할 때 DNS(MAIL_HOST → 이 서버)가 아직 바뀌지 않았으면 Let's Encrypt HTTP-01 검증이 실패하므로 setup.sh 는
# 80 포트만 여는 임시 nginx 설정으로 끝납니다. 이 스크립트는 MAIL_HOST 의 A 레코드가 이 서버의 IP 와 같아지는 순간을 기다렸다가
# certbot 으로 인증서를 받고 setup.sh 를 다시 실행해 443 설정·인증서 사본·메일 포트 TLS 를 켠 뒤 타이머를 멈춥니다.
# 이미 인증서가 있으면 아무 일도 하지 않고 타이머만 끕니다(갱신은 certbot 의 자체 타이머가 합니다).
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'root 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
load_site "${1:?site.env 경로}"
TIMER="$APP_NAME-tls.timer"
stop_timer() { systemctl disable --now "$TIMER" >/dev/null 2>&1 || true; }
if [ -d "/etc/letsencrypt/live/$MAIL_HOST" ]; then echo "이미 인증서가 있습니다: $MAIL_HOST"; stop_timer; exit 0; fi
# DNS 확인: 권위 DNS 가 아니라 이 서버의 해석기 기준입니다(certbot 검증 서버가 보는 값과 보통 같습니다).
RESOLVED="$(getent ahostsv4 "$MAIL_HOST" 2>/dev/null | awk '{print $1}' | sort -u || true)"
MINE="$(ip -4 -o addr show scope global | awk '{print $4}' | cut -d/ -f1 | sort -u)"
MATCH=""; for a in $RESOLVED; do for b in $MINE; do [ "$a" = "$b" ] && MATCH="$a"; done; done
if [ -z "$MATCH" ]; then echo "$MAIL_HOST 가 아직 이 서버를 가리키지 않습니다 (DNS: ${RESOLVED:-없음} / 서버: $MINE) — 다음에 다시 확인"; exit 0; fi
echo "DNS 확인됨($MAIL_HOST → $MATCH). 인증서를 발급합니다"
mkdir -p "$ACME_WEBROOT"
# 등록된 certbot 계정이 없으면 이메일 없이 등록합니다(만료 안내 메일을 받으려면 나중에 certbot register/update_account 로 바꾸세요).
ACCOUNT_FLAG=""; ls /etc/letsencrypt/accounts/*/directory/*/regr.json >/dev/null 2>&1 || ACCOUNT_FLAG="--register-unsafely-without-email"
certbot certonly --webroot -w "$ACME_WEBROOT" -d "$MAIL_HOST" --non-interactive --agree-tos --keep-until-expiring $ACCOUNT_FLAG
echo "발급 완료. setup.sh 를 다시 실행해 443 설정과 메일 포트 TLS 를 켭니다"
SITE="$SITE" bash "$APP_DIR/repo/deploy/setup.sh"
stop_timer
echo "✔ TLS 준비 완료: https://$MAIL_HOST/"
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# 웹메이커(webmaker) 계정 연동을 위한 읽기 도우미 설치(1.0.8, 요구사항 1) — root 로 실행:
# sudo bash <APP_DIR>/repo/deploy/webmaker-access.sh [<웹메이커 DATA_ROOT>] [--site <site.env 경로>] [--node <node 실행 파일>] [--sudo]
#
# 왜 필요한가
# webmaker 인증 모드는 웹메이커의 사용자 파일(DATA_ROOT/users.json, sites/, modules/, domains.json)을 읽기 전용으로 참조합니다. 그런데 웹메이커는 그 파일을
# 자기 서비스 계정(예: master)만 읽을 수 있게(0600/0700) 쓰므로 AriMail 실행 계정(예: arihakdang)이 직접 읽을 수 없습니다. 웹메이커가 파일을 다시 쓸 때마다
# 권한이 0600 으로 돌아가므로 ACL 로도 풀리지 않습니다. 그래서 "그 계정으로 스냅샷 하나를 읽어 JSON 으로 돌려주는" 도우미를 둡니다.
#
# 하는 일
# 1) server/webmaker-snapshot.mjs 의 사본을 root 소유로 /usr/local/lib/arimail/ 에 둡니다(실행 계정이 바꿀 수 없어야 다른 계정으로 도는 코드가 안전).
# 실행용 node 도 root 소유여야 하므로 /usr/bin/node(22 이상)를 먼저 찾고, 없으면 --node 로 지정한 root 소유 파일을 씁니다.
# 2) systemd 소켓 도우미(기본): /run/arimail-webmaker/<슬러그>.sock 에 접속하면 systemd 가 웹메이커 소유 계정으로 도우미를 띄워 JSON 을 소켓에 씁니다(Accept=yes).
# AriMail 서비스 유닛은 NoNewPrivileges=yes·CapabilityBoundingSet 로 잠겨 있어 sudo 가 동작하지 않으므로 이 방식이 필요합니다. 소켓은 AriMail 실행 계정의 그룹만 접속(0660).
# 3) --sudo: systemd 가 없는 서버용 — 감싸는 스크립트 /usr/local/lib/arimail/webmaker-snapshot 와 sudoers 규칙 한 줄(그 디렉터리 인수 하나만 허용)을 설치합니다.
# 4) 실행 계정으로 한 번 읽어 보고(계정 수 출력), 전체 설정 화면에서 적을 값(디렉터리·읽을 계정)을 안내합니다.
# 게이트웨이 설정·환경 파일·데이터와 웹메이커의 파일은 건드리지 않습니다. 다시 실행해도 안전합니다(같은 내용으로 덮어씀).
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'root 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
DATA_ROOT=""; SITE_FILE=""; NODE_OVERRIDE=""; MODE=socket
while [ $# -gt 0 ]; do
case "$1" in
--site) SITE_FILE="$2"; shift 2 ;;
--node) NODE_OVERRIDE="$2"; shift 2 ;;
--sudo) MODE=sudo; shift ;;
-h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) [ -z "$DATA_ROOT" ] || { echo "알 수 없는 인수: $1" >&2; exit 1; }; DATA_ROOT="$1"; shift ;;
esac
done
if [ -z "$SITE_FILE" ]; then APP_GUESS="$(cd "$DEPLOY_DIR/../.." && pwd)"; if [ -f "$APP_GUESS/site.env" ]; then SITE_FILE="$APP_GUESS/site.env"; else SITE_FILE="$DEPLOY_DIR/sites/${SITE:-arihakdang}.env"; fi; fi
load_site "$SITE_FILE"
# 데이터 디렉터리: 인수 → webmaker 서비스의 환경 변수(DATA_ROOT) → /data/webmaker
if [ -z "$DATA_ROOT" ]; then
DATA_ROOT="$(systemctl show -p Environment webmaker 2>/dev/null | tr ' ' '\n' | sed -n 's/^DATA_ROOT=//p' | head -1 || true)"
[ -n "$DATA_ROOT" ] || DATA_ROOT=/data/webmaker
fi
DATA_ROOT="${DATA_ROOT%/}"
# sudoers·유닛 파일에 그대로 적히므로 경로에 허용하는 글자를 제한합니다(공백·와일드카드·쉼표가 들어가면 규칙이 깨지거나 넓어짐).
printf '%s' "$DATA_ROOT" | grep -Eq '^/[A-Za-z0-9._/-]+$' || { echo "데이터 디렉터리는 영문·숫자·._- 로 된 절대 경로여야 합니다: $DATA_ROOT" >&2; exit 1; }
[ -f "$DATA_ROOT/users.json" ] || { echo "웹메이커 사용자 파일이 없습니다: $DATA_ROOT/users.json (DATA_ROOT 를 확인하세요)" >&2; exit 1; }
OWNER="$(stat -c %U "$DATA_ROOT/users.json")"
id "$APP_USER" >/dev/null 2>&1 || { echo "AriMail 실행 계정이 없습니다: $APP_USER (사이트 설정 $SITE_FILE)" >&2; exit 1; }
APP_GROUP="$(id -gn "$APP_USER")"
# 실행용 node: root 소유 + 22 이상. /usr/bin/node → --node 지정 → 사이트의 NODE_BIN(루트 소유일 때만).
pick_node() {
local cand; for cand in "$NODE_OVERRIDE" /usr/bin/node "$NODE_BIN"; do
[ -n "$cand" ] && [ -x "$cand" ] || continue
[ "$(stat -c %U "$cand")" = root ] || { echo " (건너뜀) root 소유가 아닌 node: $cand" >&2; continue; }
local major; major="$("$cand" -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
[ "$major" -ge 22 ] 2>/dev/null || { echo " (건너뜀) node 22 미만: $cand" >&2; continue; }
printf '%s' "$cand"; return 0
done; return 1
}
NODE_EXEC="$(pick_node)" || { echo "root 소유의 node 22 이상을 찾지 못했습니다. apt 로 설치하거나 --node <경로> 로 지정하세요(실행 계정이 바꿀 수 있는 파일은 쓰지 않습니다)." >&2; exit 1; }
LIB=/usr/local/lib/arimail
# 슬러그·소켓 경로: server/webmaker.mjs 의 webmakerSocketPath 와 같은 규칙(/data/webmaker → data-webmaker).
SLUG="$(printf '%s' "$DATA_ROOT" | sed 's#^/*##; s#[^A-Za-z0-9]#-#g')"; SOCK_DIR=/run/arimail-webmaker; SOCK="$SOCK_DIR/$SLUG.sock"
echo "▶ 웹메이커 읽기 도우미 설치($MODE): 데이터 $DATA_ROOT (소유 $OWNER) · AriMail 계정 $APP_USER($APP_GROUP) · node $NODE_EXEC"
install -d -o root -g root -m 755 "$LIB"
install -o root -g root -m 644 "$DEPLOY_DIR/../server/webmaker-snapshot.mjs" "$LIB/webmaker-snapshot.mjs"
[ "$MODE" = socket ] && ! command -v systemctl >/dev/null 2>&1 && { echo " systemd 가 없어 sudo 방식으로 설치합니다"; MODE=sudo; }
if [ "$MODE" = socket ]; then
UNIT="arimail-webmaker-$SLUG"
install -d -o root -g root -m 755 "$SOCK_DIR"
printf 'd %s 0755 root root -\n' "$SOCK_DIR" > /etc/tmpfiles.d/arimail-webmaker.conf
cat > "/etc/systemd/system/$UNIT.socket" <<UNIT1
[Unit]
Description=AriMail webmaker account snapshot socket ($DATA_ROOT)
# deploy/webmaker-access.sh 가 설치. AriMail(LOGIN_MODE=webmaker, 읽을 계정 $OWNER)이 접속하면 아래 서비스가 $OWNER 로 스냅샷 JSON 을 써 줍니다.
[Socket]
ListenStream=$SOCK
SocketUser=$OWNER
SocketGroup=$APP_GROUP
SocketMode=0660
Accept=yes
MaxConnections=16
[Install]
WantedBy=sockets.target
UNIT1
cat > "/etc/systemd/system/$UNIT@.service" <<UNIT2
[Unit]
Description=AriMail webmaker account snapshot ($DATA_ROOT, per connection)
CollectMode=inactive-or-failed
[Service]
User=$OWNER
ExecStart=$NODE_EXEC $LIB/webmaker-snapshot.mjs $DATA_ROOT
StandardInput=socket
StandardOutput=socket
StandardError=journal
NoNewPrivileges=yes
ProtectSystem=strict
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
UNIT2
chmod 644 "/etc/systemd/system/$UNIT.socket" "/etc/systemd/system/$UNIT@.service"
systemctl daemon-reload; systemctl enable --now "$UNIT.socket" >/dev/null 2>&1 || systemctl restart "$UNIT.socket"
systemctl restart "$UNIT.socket"
echo " 소켓: $SOCK ($UNIT.socket, 서비스 $UNIT@.service → $OWNER)"
# 실행 계정으로 실제 접속(계정 수만 출력)
READ_CMD="sudo -u $APP_USER $NODE_EXEC -e"
JS='const s=require("net").connect(process.argv[1]);let b="";s.setEncoding("utf8");s.on("data",c=>b+=c);s.on("end",()=>{const d=JSON.parse(b);console.log(`회원 ${Object.keys(d.users).length}명 · 사이트 ${Object.keys(d.sites).length}개 · 도메인 연결 ${Object.keys(d.domainSites).length}개`);});s.on("error",e=>{console.error(e.message);process.exit(1);});'
if OUT="$($READ_CMD "$JS" "$SOCK" 2>&1)"; then echo "✔ 확인: $APP_USER 계정이 소켓으로 스냅샷을 읽었습니다 — $OUT"
else echo "✖ 실행 계정으로 소켓 읽기에 실패했습니다: $OUT (journalctl -u '$UNIT@*' 확인)" >&2; exit 1; fi
else
cat > "$LIB/webmaker-snapshot" <<WRAP
#!/bin/sh
# AriMail 웹메이커 계정 스냅샷 도우미(deploy/webmaker-access.sh 가 설치). 첫 인수(데이터 디렉터리)만 넘기고 나머지는 무시합니다.
exec "$NODE_EXEC" "$LIB/webmaker-snapshot.mjs" "\$1"
WRAP
chmod 755 "$LIB/webmaker-snapshot"; chown root:root "$LIB/webmaker-snapshot"
# sudoers.d 는 이름에 점이 든 파일을 무시하므로 점을 빼고, 임시 파일을 visudo 로 검사한 뒤에 설치합니다(틀린 파일이 들어가면 서버의 sudo 전체가 막힘).
RULE="/etc/sudoers.d/$(printf '%s' "$APP_NAME" | tr -c 'A-Za-z0-9_-' '-')-webmaker"
TMP_RULE="$(mktemp)"; printf '%s ALL=(%s) NOPASSWD: %s %s\n' "$APP_USER" "$OWNER" "$LIB/webmaker-snapshot" "$DATA_ROOT" > "$TMP_RULE"
visudo -cf "$TMP_RULE" >/dev/null || { rm -f "$TMP_RULE"; echo "✖ sudoers 규칙 검사 실패(계정 이름·경로 확인)" >&2; exit 1; }
install -o root -g root -m 440 "$TMP_RULE" "$RULE"; rm -f "$TMP_RULE"
echo " sudoers: $RULE (주의: AriMail 서비스 유닛에 NoNewPrivileges=yes 가 있으면 sudo 가 동작하지 않습니다 — systemd 서버에서는 소켓 방식을 쓰세요)"
if OUT="$(sudo -u "$APP_USER" sudo -n -u "$OWNER" "$LIB/webmaker-snapshot" "$DATA_ROOT" 2>&1)"; then
N="$(printf '%s' "$OUT" | "$NODE_EXEC" -e 'let s="";process.stdin.on("data",(c)=>s+=c).on("end",()=>{const d=JSON.parse(s);console.log(`회원 ${Object.keys(d.users).length}명 · 사이트 ${Object.keys(d.sites).length}개 · 도메인 연결 ${Object.keys(d.domainSites).length}개`);});')"
echo "✔ 확인: $APP_USER 계정이 $OWNER 로 스냅샷을 읽었습니다 — $N"
else echo "✖ 실행 계정으로 읽기에 실패했습니다: $(printf '%s' "$OUT" | tail -1)" >&2; exit 1; fi
fi
cat <<EOF2
다음 단계: 전체 설정 화면(https://$MAIL_HOST:8791/?token=…)의 도메인 카드에서 도메인을 추가·수정하며
로그인 방식 = 웹메이커(webmaker) 계정과 연동, 웹메이커 데이터 디렉터리 = $DATA_ROOT, 파일을 읽을 계정 = $OWNER
로 저장하세요(기본 도메인이면 환경 파일 $ETC_DIR/gateway.env 에 LOGIN_MODE=webmaker, WEBMAKER_DIR=$DATA_ROOT, WEBMAKER_USER=$OWNER 를 적고 재시작).
EOF2
+453
View File
@@ -0,0 +1,453 @@
# AriMail(mail.arihakdang.com) 설치·운영
> 1.0.0 부터 프로젝트 이름은 **AriMail**, 저장소는 https://source.ibit.kr/OZ-Group/AriMail 입니다(내용은 같고 이름만 바뀜).
> 운영 설정(Resend 키 등)은 환경 파일이 아니라 **설정 파일 + 초기 설정 페이지**(10절)에서 다루고, 수·발신은 Resend 또는 **내장 Haraka**(12절)로 할 수 있습니다.
www.arihakdang.com 과 **같은 서버(187.53.133.173), 같은 실행 계정(`arihakdang`), 같은 Node(`/opt/arihakdang-node`)** 에서 돌아갑니다.
관리자 계정은 www 의 `users` 테이블을 읽기 전용으로 읽어 인증하므로 별도 계정·비밀번호가 없습니다.
사용자 이름 `admin` / `admin@arihakdang.com` 은 메일함 소유 관리자의 별칭입니다. 소유자는 `MAILBOX_OWNER_LOGIN`(www 로그인 ID 또는 이메일) →
이메일이 메일함 주소인 관리자 → 유일한 활성 관리자 순으로 정합니다. 관리자가 둘 이상이면 `MAILBOX_OWNER_LOGIN` 을 꼭 지정하세요.
```
Resend 수신(admin@arihakdang.com)
├─ 웹훅 email.received ──▶ POST https://mail.arihakdang.com/api/webhooks/resend (즉시)
└─ GET /emails/receiving ──▶ 1시간마다 동기화 (보충)
│ 원본(.eml)·첨부·정제 본문을 SQLite 에 기한 없이 보관
├─ 웹 메일함 https://mail.arihakdang.com/ (보기·답장·발송)
├─ IMAP mail.arihakdang.com:993 (TLS) / 143 (STARTTLS)
├─ POP3 mail.arihakdang.com:995 (TLS) / 110 (STLS 또는 일반 접속)
└─ SMTP 제출 mail.arihakdang.com:465 (TLS) / 587 (STARTTLS)
로그인 비밀번호로 인증 → 서버 내부 RESEND_API_KEY 로 Resend API 발송
```
## 1. DNS 와 인증서
1. Cloudflare DNS 에 `mail` A 레코드 → `187.53.133.173`, **프록시 끔(DNS only, 회색 구름)**.
POP3 포트(110/995)는 Cloudflare 프록시를 지나지 못하고, 메일 클라이언트는 공개 CA 인증서를 요구합니다.
그래서 이 호스트만 원본 IP 가 공개됩니다. www 의 Cloudflare 전용 차단(444)은 이 vhost 에 적용하지 않습니다.
2. 서버에서 인증서 발급:
```bash
sudo apt install -y certbot
sudo mkdir -p /var/www/letsencrypt
sudo certbot certonly --webroot -w /var/www/letsencrypt -d mail.arihakdang.com
```
발급 후 `sudo bash deploy/setup.sh` 를 다시 실행하면 갱신 훅(`deploy/letsencrypt-deploy-hook.sh.in` 에서 만든 `/etc/letsencrypt/renewal-hooks/deploy/arihakdang-mail.sh`)이 인증서 사본을
`/etc/arihakdang-mail/tls/` 에 서비스 계정 권한으로 만들고, nginx(443)와 POP3(995/STLS)가 같은 인증서를 씁니다.
certbot 이 갱신할 때마다 훅이 사본을 갱신하고 서비스를 재시작합니다.
(프로세스도 1시간마다 인증서 파일을 다시 읽으므로 재시작 없이도 새 인증서가 적용됩니다.)
3. 방화벽에 110, 995(POP3)와 143, 993(IMAP) TCP 를 엽니다. 80/443 은 www 와 공유합니다.
## 2. 서버 준비와 배포
```bash
sudo bash deploy/setup.sh # 디렉터리, 환경 파일(배포에 필요한 값만), systemd, nginx, 내장 Haraka 준비
sudo -u arihakdang /srv/arihakdang-mail/repo/deploy/deploy.sh # 끝에 "초기 설정 페이지" 주소(토큰 포함)가 출력됩니다
journalctl -u arihakdang-mail -n 50 --no-pager
```
1.0.0 부터 **환경 파일에 Resend 키를 적지 않습니다.** 배포가 끝나면 출력된 초기 설정 페이지 주소(`http(s)://<서버>:8791/?token=…`)를 열어
수·발신 방식과 키를 채우세요(10절). 값이 비어 있어도 배포는 실패하지 않고, 저장하면 재시작 없이 적용됩니다.
배포 스크립트는 `git reset --hard origin/main` → 테스트 → `releases/<시각>` 복사 → DB 백업·마이그레이션 →
`current` 링크 교체 → 재시작 순서이며, 재시작에 실패하면 이전 릴리스로 되돌립니다.
서버에는 `source.ibit.kr` 자격 증명을 두지 않습니다. 작업 PC 에서 번들을 만들어 올리고 번들로 배포합니다.
```bash
git bundle create /tmp/mail-gateway.bundle main && scp /tmp/mail-gateway.bundle hostinger:/tmp/
ssh hostinger 'sudo install -o arihakdang -g arihakdang -m 644 /tmp/mail-gateway.bundle /srv/arihakdang-mail/source.bundle && sudo -u arihakdang env SOURCE_BUNDLE=/srv/arihakdang-mail/source.bundle /srv/arihakdang-mail/repo/deploy/deploy.sh'
```
배포 스크립트의 재시작에는 sudoers 규칙이 필요합니다(`setup.sh` 가 `/etc/sudoers.d/arihakdang-mail` 로 만듭니다).
`deploy/` 의 systemd 유닛·nginx·certbot 훅·환경 파일은 0.8.18 부터 `.in` 템플릿이며, `setup.sh` 가 사이트 설정(`deploy/sites/<이름>.env`, 기본 `arihakdang`)으로
채워 설치합니다. 다른 도메인에 설치하는 방법은 8절, 설치 옵션(로그인 방식 `LOGIN_MODE`, 시스템 제한 `LIMIT_*`)은 9절을 보세요.
## 3. Resend 웹훅
1. Resend 대시보드 → **Webhooks → Add Endpoint**
- URL: `https://mail.arihakdang.com/api/webhooks/resend`
- Events: `email.received`
2. 만들어진 엔드포인트의 **Signing Secret(`whsec_…`)** 을 초기 설정 페이지(또는 `/admin` → 메일 서비스 설정)의 Resend 카드 "웹훅 서명 비밀키" 에 넣고 저장합니다(재시작 불필요, 1.0.0).
(0.9.x 까지는 `gateway.env` 의 `RESEND_WEBHOOK_SECRET` 이었습니다. 남아 있는 값은 1.0.0 첫 시작 때 설정 파일로 가져갑니다.)
3. 웹훅은 본문을 담지 않으므로 게이트웨이가 `GET /emails/receiving/{id}` 로 전문·헤더·첨부·원본(raw) 을 받아 저장합니다.
서명이 없거나 틀리면 401, 저장에 실패하면 500 을 돌려주어 Resend 가 재시도합니다.
4. 웹훅과 무관하게 **1시간마다** 수신·발송 목록을 확인해 빠진 메일을 보충합니다(관리자 페이지 → 서버 상태의 "지금 동기화"로 즉시 실행).
한 번에 받은 메일 500건·보낸 메일 200건(최신순)까지 거슬러 올라갑니다.
웹 메일함 목록을 열어 두면 1분마다 **빠른 확인**을 합니다. 최신 메일부터 보다가 이미 저장한 메일을 만나면 멈추므로 Resend 호출이 한두 번에 그칩니다.
## 4. 보관·중복 방지
- 받은 메일과 보낸 메일은 **기한 없이** 보관합니다(0.8.0 에서 30일 제한과 `RETENTION_DAYS` 설정을 없앴습니다).
메일은 휴지통에서 **영구 삭제**할 때만 원본·첨부와 함께 지워지고, Resend 목록에 남아 있어도 다시 가져오지 않습니다(tombstone).
- 저장 공간은 SQLite 파일(`/srv/arihakdang-mail/data/mail-gateway.sqlite`)이 계속 커지므로 디스크 사용량을 가끔 확인하세요.
왼쪽 패널 아래에 메일함별 사용량이 보입니다.
- POP3 로 **RETR(내려받기)** 하거나 클라이언트가 **DELE** 한 메일은 `pop_deliveries` 에 기록되어 같은 계정에 다시 내려주지 않습니다.
서버 저장본은 유지되어 웹에서 계속 볼 수 있고, 필요하면 메일 상세의 "POP 전달 기록 초기화"로 다시 내려받게 할 수 있습니다.
- 계정마다 따로 기록하므로 관리자가 두 명이면 각자 한 번씩 받습니다.
## 4a. 웹 메일함 사용법
- **왼쪽 패널**: 메일 쓰기 버튼, 받은 메일함(읽지 않음 수)·중요 표시·보낸 메일함·임시 보관함·휴지통, 보기 필터(읽지 않음·첨부), 설정(관리자에게는 관리자 페이지 링크), 저장 사용량(적용 중인 메일함 최대 크기가 있으면 막대와 비율).
0.9.0 부터 **누구나 자기 메일함만** 봅니다. 예전의 "관리자가 다른 계정의 메일함을 열어 보는" 기능(상단 메일함 선택)은 없앴고, 관리자 권한은 관리자 페이지의 계정·설정 관리에만 쓰입니다.
- **로그인 화면 → 회원 가입**(0.9.0~): 관리자가 켜 두었으면 접속 단추 아래에 "회원 가입" 이 보입니다. 로그인 ID(= 메일 주소의 앞부분)·이름·비밀번호(2회, 눈 단추)를 넣으면 계정이 생기며,
관리자 설정에 따라 바로 쓰거나 "관리자 승인 후 사용"(승인 전 로그인하면 "가입 승인을 기다리는 계정입니다") 입니다.
- **목록**: 체크박스로 여러 통을 골라 삭제·복원·읽음/읽지 않음·이동을 한 번에 처리합니다. ☆ 를 누르면 중요 표시(IMAP `\Flagged` 와 공유)입니다. 상단 검색은 보낸 사람·제목·본문을 찾습니다.
- **읽기 창**: 답장·전체 답장·전달·삭제·이동·중요·읽지 않음, 원본(.eml) 내려받기, 상세 정보(헤더·POP 전달 기록), 외부 이미지 표시(기본 차단).
- **로그인 화면**: 오른쪽 위 (?) 를 누르면 접속 안내 팝업(이 도메인의 메일 서비스, 부여받은 계정 또는 도메인을 포함한 이메일 주소로 로그인, 웹 접속 계정과 같은 아이디·비밀번호)이 뜹니다.
- **설정 → 내 비밀번호 변경**(0.9.0~): 현재 비밀번호를 넣고 새 비밀번호를 두 번 같게 입력합니다(눈 단추로 글자 확인). 바꾸면 세션이 모두 끝나 새 비밀번호로 다시 로그인합니다.
www 관리자 계정은 www 사이트에서 관리하므로 이 카드가 안내문으로만 보입니다.
- **설정 → 메일함 사용량·제한**(0.9.0~): 사용량(MIME 기준)과 적용 중인 메일함 최대 크기, 첨부 한 개 최대 크기, 한 메일의 받는 사람 수를 보여 줍니다.
- **설정**: 화면 기본값(외부 이미지), **메일 프로그램 접속 허용(POP3 / IMAP)** 스위치, IMAP·SMTP·POP 설정 안내. 끈 서비스는 그 계정의 메일 프로그램 로그인을 거부합니다(웹 메일·SMTP 는 영향 없음).
관리자가 서비스 자체를 껐으면 스위치가 비활성화되고 "관리자가 서비스를 껐습니다" 가 붙습니다.
- **설정 → 메일 전달**(0.8.16~): "받은 메일을 아래 주소로 전달" 을 체크하고 전달 주소(쉼표로 구분, 최대 5개)를 넣고 저장하면 이 메일함으로 받는 모든 메일을 그 주소로 보냅니다.
체크를 풀고 저장하면 전달하지 않습니다. 전달 중이면 체크 상자 옆에 "현재 메일은 다음 주소로 전달되고 있습니다" 가 굵게 보입니다.
원본은 받은 메일함에 그대로 남습니다. 전달 메일의 보내는 사람은 자기 메일함 주소(발송 서비스가 우리 도메인만 허용)이고 Reply-To 가 원래 발신자라 전달받은 쪽에서 답장하면 원래 보낸 사람에게 갑니다.
첨부·본문 이미지도 함께 가며(첨부 합계 25MB 까지), 전달 메일에는 `X-Ari-Auto-Forwarded` 헤더가 붙어 두 메일함이 서로를 전달 대상으로 두어도 한 번만 전달됩니다.
전달 주소에서 온 메일, 72시간이 지난 뒤 동기화가 뒤늦게 가져온 메일은 전달하지 않습니다. 전달 메일은 보낸 메일함에 쌓지 않고 메일 상세 정보의 "자동 처리" 줄에 기록만 남깁니다.
- **설정 → 자동 응답**(0.8.16~): 응답 제목(비우면 `Re: 원래 제목`)과 메시지를 쓰고, 기간을 **기한 제한 없음** 또는 **기간 지정**(시작·종료 날짜와 시간, 시작을 비우면 즉시)으로 정해 저장합니다.
기간 선택과 시작·종료 상자는 항상 보이며(기한 제한 없음이면 시각 상자는 비활성), 켜고 끄는 것은 "자동 응답 사용" 체크 상자와 저장 버튼입니다.
켜져 있으면 체크 상자 옆에 "메일이 수신되면 자동으로 아래 메일을 발신자에게 회신합니다." 가 굵게 보이고(지정 기간 밖이면 기간 안내가 덧붙음), 작동 중이면 왼쪽 패널 아래에도 "자동 응답 작동 중" 이 보입니다.
같은 상대에게는 24시간에 한 번만 보내고, 자동 발송 메일(`Auto-Submitted`, `Precedence: bulk/list`, 메일링 리스트, no-reply·mailer-daemon 발신)과 72시간 지난 메일에는 응답하지 않습니다.
자동 응답에는 `Auto-Submitted: auto-replied` 가 붙어 상대 서버의 자동 응답과 핑퐁이 생기지 않습니다. 보낸 자동 응답은 보낸 메일함에 남고 원래 메일에는 "답장함"(IMAP `\Answered`) 표시가 붙습니다.
- **메일 쓰기**: 참조 토글, 서식 도구(실행취소·글꼴·크기·굵게/기울임/밑줄/취소선·글자색·강조(기본 8색 팔레트 → 더 많은 색상은 브라우저 색상 선택창)·정렬·목록·들여쓰기·링크·인용·구분선·이미지·서식 지우기·HTML 편집),
파일 첨부(파일당 상한은 관리자 설정, 합계 20MB), 본문 이미지 삽입·붙여넣기·끌어놓기(발송 시 cid 인라인 첨부로 변환), 4초 간격 자동 임시 저장.
보내는 주소는 로그인 계정의 메일함 주소입니다.
- **참조 / 숨은 참조**(0.9.5): 받는 사람 줄 오른쪽의 "참조" 와 "숨은 참조" 를 각각 누르면 그 입력 줄이 열립니다. 숨은 참조도 초안에 저장되고 발송됩니다(받는 사람·참조에게는 보이지 않음).
- **대용량 첨부**(0.9.3~, 관리자가 켠 경우): "☁ 대용량 첨부" 로 올린 파일은 메일에 붙지 않고 서버에 보관되며 본문 끝에 **다운로드 링크** 블록이 붙어 나갑니다.
올리는 동안 칩 자리에 진행 막대·퍼센트·보낸 양/전체가 보이고 ✕ 로 취소할 수 있습니다(0.9.5). nginx 가 요청을 막으면(413) 그 사실을 따로 알립니다.
일반 첨부 상한을 넘는 파일을 첨부하거나 끌어 놓으면 자동으로 대용량으로 올라갑니다. 대용량 파일이 하나라도 있으면 **발송 조건(보관 일수·다운로드 횟수)** 줄이 보이며 둘 다 반드시
관리자 상한 이내로 정해야 합니다(제한 없는 발송 불가, 기본값은 상한). 받는 사람은 링크를 열면 랜딩 페이지(파일 이름·크기·남은 횟수·기한)를 보고 "다운로드" 단추를 눌러 받습니다
(단추를 누를 때만 횟수가 줄어 메일 보안 장치가 링크를 미리 열어도 소진되지 않음). 기한이 지나거나 횟수를 다 쓰면 파일이 서버에서 지워지고 링크는 "만료" 페이지가 됩니다.
보낸 메일함에서 메일을 열면 파일마다 남은 횟수·기한이 보이고 ✕ 로 링크를 끊을(파일 즉시 삭제) 수 있습니다.
## 4b. 관리자 페이지 (`https://mail.arihakdang.com/admin`)
관리자 페이지는 **`/admin` 주소로 고정**되어 있고(0.8.4~), 웹 메일과 **별도의 로그인**을 거칩니다. 로그인 정보는 같습니다
(www.arihakdang.com 관리자 계정 = 별칭 admin, 또는 역할이 관리자인 게이트웨이 계정). 세션 쿠키가 다르므로(`mg_admin`)
웹 메일에 로그인돼 있어도 관리자 페이지는 다시 로그인해야 하고, 관리자 세션으로는 메일 API 를 쓸 수 없습니다. 웹 메일의 설정 메뉴에는
관리자에게만 "관리자 페이지 ↗" 링크가 보입니다. 일반 계정으로 `/admin` 에 로그인하면 "관리자 역할 계정만" 안내와 함께 거부됩니다.
**계정 관리**
- 계정 항목: 로그인 ID(영문 소문자·숫자·`.`·`_`·`-`, 2~64자), 이름, 비밀번호(아래 비밀번호 정책을 따름), 역할(관리자/사용자), 상태(사용/정지), 메일 프로그램 접속(POP3 / IMAP 허용).
- **비밀번호 재설정**(0.9.0): 다른 계정의 비밀번호는 계정을 열어 **한 번만 입력**하고 눈 단추로 확인한 뒤 저장합니다(0.8.25 의 두 번 입력·현재 비밀번호 확인은 설정 화면의 "내 비밀번호 변경" 으로 옮겼습니다).
**자기 계정**을 열면 비밀번호 칸 대신 "도구 → 설정에서 바꾸세요" 안내만 보이며, API 로 시도해도 `self_password_settings` 로 거부됩니다.
- **메일함 최대 크기**(0.9.0): 계정마다 MB 로 지정합니다(0 = 관리자 기본값·시스템 값). 시스템 제한(설치 시 `LIMIT_MAILBOX_BYTES`)이 있으면 그 이내만 저장됩니다.
적용값은 "계정별 → 관리자 기본값 → 시스템" 중 가장 작은 0 아닌 값이며, 목록의 "저장 사용량" 열에 사용량 / 적용값이 보입니다.
- **승인 대기 계정**(0.9.0): 회원 가입으로 만들어진 계정은 상태가 "승인 대기" 로 보이고, 계정을 열면 **승인**(상태를 사용으로) / **거절**(계정 삭제) 단추가 있습니다.
- **비밀번호 정책**(0.8.24~): 계정 관리 화면 아래 "비밀번호 정책" 카드에서 최소 글자 수(6~64, 기본 6)와 영문 대·소문자 혼용 / 숫자 포함 / 특수문자 포함 요구를 각각 켜고 끕니다.
예전에는 10자 이상이 코드에 고정돼 있었습니다. 정책은 새로 정하는 비밀번호(계정 추가·수정, 명령줄 도구)에만 적용되고 기존 비밀번호에는 소급하지 않습니다.
규칙에 어긋나면 어느 항목이 어긋났는지("비밀번호가 정책에 맞지 않습니다: 8자 이상, 숫자 포함")를 알려 줍니다.
- **메일함 주소는 입력하지 않습니다.** 항상 `로그인 ID@arihakdang.com` 으로 자동 지정됩니다(예: `info` → `info@arihakdang.com`). 0.8.4 이전에 만든 계정은 등록된 주소를 유지합니다.
- 만든 계정은 웹 메일함·IMAP·POP·SMTP 에 **로그인 ID 또는 메일함 주소 + 비밀번호**로 접속하며 자기 메일함의 메일만 봅니다.
- 수신 메일은 받는 사람 주소가 등록된 메일함으로 들어옵니다(기본 메일함 admin@ + 활성 계정의 메일함).
- POP3 / IMAP 허용을 끄면 그 계정의 메일 프로그램 로그인을 거부합니다(`-ERR [SYS/PERM] POP access is disabled`, `NO [UNAVAILABLE] IMAP access is disabled`). 사용자도 웹 메일 설정에서 직접 켜고 끌 수 있습니다.
- 비밀번호를 바꾸거나 정지하면 해당 계정의 웹 세션이 즉시 종료됩니다. 계정을 삭제해도 메일함의 메일은 남습니다.
- www 관리자 계정(admin 행) 자체는 www 관리자 화면에서 관리하며 여기서는 POP/IMAP 허용만 바꿉니다.
**회원 가입 / 메일 제한 / POP·IMAP 서비스**(0.9.0, 계정 관리 화면 아래 카드)
- 회원 가입: "로그인 화면에서 회원 가입 받기" 와 가입 계정의 사용 시점(즉시 사용 / 관리자 승인 후 사용). 끄면 관리자가 만든 계정만 메일을 쓸 수 있습니다(`POST /api/signup` 은 404).
- 메일 제한: 계정별 기본 메일함 크기, 첨부 한 개 최대 크기(MIME 기준), 한 메일의 받는 사람 수. 각 값은 시스템 제한 이내만 저장되며(`limit_exceeds_system`), 0 은 시스템 값을 따릅니다.
그 아래 "시스템 설정" 에 설치 시 정한 `LIMIT_*` 값이 읽기 전용으로 보입니다(9절).
- POP3 / IMAP 서비스: 서비스 자체를 켜고 끕니다. 끄면 모든 계정의 POP(또는 IMAP) 로그인이 `-ERR [SYS/PERM] POP service is disabled by the administrator` / `NO [UNAVAILABLE] IMAP service is disabled by the administrator` 로 거부됩니다.
- 일반 접속 로그인 허용(0.9.7, 기본 켬): 110/143 포트에서 STARTTLS 없이 보낸 비밀번호로 로그인을 받을지. 끄면 CAPA 에서 `USER` 가 빠지고(IMAP 은 `LOGINDISABLED`) `-ERR [SYS/PERM] use STLS or port 995 before login` / `NO [PRIVACY] use STARTTLS or port 993 before login` 으로 거부합니다. 995/993·웹 메일·SMTP 에는 영향이 없고, 바꾼 값은 다음 접속부터 적용됩니다.
**대용량 첨부파일**(0.9.3, 카드)
- "대용량 첨부파일 기능 사용" 스위치와 파일 한 개 최대 크기(MB)·보관 최대 기간(일)·다운로드 최대 횟수. 세 값은 **비울 수 없고(무제한 불가)** 시스템 상한(`LIMIT_LARGE_FILE_*`, 9절) 이내여야 하며
기본값은 시스템 상한의 50%(1GB/30일/20회 → 512MB/15일/10회)입니다. 기본은 꺼짐. 카드에 현재 서버에 보관 중인 파일 수·용량이 보입니다(서버 상태의 현황에도 표시).
- **nginx**(0.9.5): 업로드 경로 `/api/files` 는 `client_max_body_size 0` + `proxy_request_buffering off`, 다운로드 `/dl/` 는 버퍼링 없이 1시간 시간 제한으로 프록시합니다(템플릿 `deploy/nginx/*.conf.in`).
이미 설치된 서버의 vhost 는 `sudo bash deploy/nginx-apply.sh <APP_DIR>/site.env`(site.env 가 없으면 `deploy/sites/<이름>.env`)로 다시 적용합니다 — 환경 파일·인증서는 건드리지 않고 vhost 파일만 바꿔 쓰고(이전 설정은 `.bak-<시각>`) `nginx -t` 뒤 reload 합니다.
0.9.3 까지는 server 블록의 `client_max_body_size 32m` 때문에 32MB 가 넘는 대용량 파일이 nginx 에서 413 으로 끊겼습니다.
- 파일은 `<APP_DIR>/data/files/<id>`(또는 `FILES_DIR`)에 있으며 만료·횟수 소진·링크 끊기·버려진 초안(하루 뒤)은 1시간마다 정리됩니다. 메일함 용량 제한(`LIMIT_MAILBOX_BYTES`)에는 들지 않습니다.
**서버 상태**: **메일 현황**(0.9.0: 일일·주간·월간 발신/수신/수발신 통수 — Asia/Seoul 기준, 월간은 1일~말일; 메일 계정·총 메일함 사용량·이달 통수를 시스템 제한과 견준 "8개 사용 중 / 최대 10개 (80%)";
사용자별 메일함 사용량과 적용 중인 최대 크기), 서비스 포트·TLS 인증서·POP/IMAP 스위치 상태, Resend 웹훅·동기화 기록·저장 통계·POP 전달 기록, "지금 동기화" 버튼. 사용량은 모두 MIME 기준(저장 원본 크기)입니다.
## 5. IMAP 사용법
IMAP 은 서버의 메일함을 그대로 보여 주는 방식입니다. 읽음·삭제·이동이 웹 메일함, 다른 기기와 함께 공유되므로
휴대폰과 PC 에서 같은 메일함을 쓰려면 POP 대신 IMAP 을 권장합니다.
| 항목 | 값 |
|---|---|
| 받는 메일 서버 | `mail.arihakdang.com`, 포트 993 (SSL/TLS) 또는 143 (STARTTLS) |
| 사용자 이름 / 비밀번호 | `admin` 또는 `admin@arihakdang.com` (www 관리자 로그인 ID·이메일도 가능) / www 관리자 로그인 비밀번호 |
| 메일함 | `INBOX`(받은), `Sent`(보낸), `Drafts`(초안), `Trash`(휴지통). 새 폴더는 만들 수 없습니다. |
| 보내는 메일 서버 | `mail.arihakdang.com`, 포트 465 (SSL/TLS) 또는 587 (STARTTLS). 받는 서버와 같은 계정이므로 "받는 서버 설정 사용"을 켜도 됩니다. |
설정 순서: 계정 추가 → 수동 설정(IMAP) → 받는 서버에 위 값 입력 → 보내는 서버에 같은 호스트·계정과 465/587 입력 →
보낸 메일 복사본은 `Sent`, 임시 저장은 `Drafts`, 삭제는 `Trash` 폴더를 쓰도록 지정(대부분 자동 인식).
동작 규칙:
- 클라이언트에서 읽으면 웹 메일함도 읽음으로 바뀝니다(`\Seen` ↔ read_at). 깃발(`\Flagged`), 답장함(`\Answered`)은 서버에 저장됩니다.
- 클라이언트의 삭제는 `Trash` 로 이동입니다. `Trash` 에서 다시 삭제(EXPUNGE)하면 영구 삭제되며 같은 메일을 다시 가져오지 않습니다.
- 클라이언트가 `Sent`/`Drafts` 에 올린 메일(APPEND)은 원본 그대로 저장되어 웹 메일함에도 보입니다.
- IDLE 을 지원하므로 새 메일이 오면 10초 안에 클라이언트에 알립니다.
- 서버의 메일은 기한 없이 보관되므로 IMAP 에서도 계속 보입니다. 지우려면 클라이언트에서 삭제 후 `Trash` 를 비우세요.
- 지원 명령: IMAP4rev1 전체 기본 명령, LITERAL+, IDLE, UIDPLUS, MOVE, SPECIAL-USE, NAMESPACE, ID, UNSELECT, ENABLE. CREATE/RENAME/DELETE 는 거부합니다.
확인:
```bash
openssl s_client -connect mail.arihakdang.com:993 -quiet # * OK [CAPABILITY ...] 가 보여야 함
```
## 6. 메일 클라이언트 설정
| 항목 | 값 |
|---|---|
| 받는 메일(IMAP, 권장) | `mail.arihakdang.com`, 포트 993 (SSL/TLS) 또는 143 (STARTTLS, 또는 일반 접속*) |
| 받는 메일(POP3) | `mail.arihakdang.com`, 포트 995 (SSL/TLS) 또는 110 (STARTTLS, 또는 일반 접속*) |
| 보내는 메일(SMTP) | `mail.arihakdang.com`, 포트 465 (SSL/TLS) 또는 587 (STARTTLS), 인증: 일반 비밀번호 |
| 사용자 이름 | `admin` 또는 `admin@arihakdang.com` (www 관리자 로그인 ID·이메일도 가능). 게이트웨이 계정은 그 로그인 ID |
| 비밀번호 | 웹 메일 로그인 비밀번호 (받는 서버·보내는 서버 모두 같음) |
| 보내는 사람 | 계정의 메일함 주소(예: `admin@arihakdang.com`). 다른 주소는 서버가 거부합니다(관리자는 게이트웨이의 모든 메일함 주소 가능). |
\* 일반 접속(암호화 없이 110/143 에 비밀번호를 보내는 방식)은 관리자 페이지 → 계정 관리 → "POP / IMAP 서비스" 카드의 **일반 접속 로그인 허용** 스위치(0.9.7, 기본 켬)를 따릅니다.
Gmail 의 "다른 계정에서 메일 확인(POP3)" 은 110 포트에서 STLS 를 쓰지 않고 바로 로그인하므로 이 스위치가 꺼져 있으면 Gmail 이 "사용자 이름과 비밀번호가 일치하지 않음" 으로 보여 줍니다.
가능하면 Gmail 에서 "보안 연결(SSL) 사용" 을 켜고 995 를 쓰세요. 환경 변수 `POP_ALLOW_PLAINTEXT=1` 은 관리자 설정과 무관하게 항상 허용(진단용)이고, 그 밖의 값은 관리자 설정을 따릅니다.
POP3 로 받아도 서버 저장본은 지우지 않으며("서버에 메시지 복사본 남기기" 옵션과 무관) 웹에서 영구 삭제할 때까지 남습니다.
SMTP 로 보낸 메일은 곧바로 웹의 보낸 메일함에 저장됩니다. 메일 프로그램이 IMAP `Sent` 에 사본을 올려도 Message-ID 로 한 통으로 합칩니다.
### SMTP 제출 서버 (0.8.1~)
0.8.0 까지는 메일 프로그램이 Resend SMTP 에 직접 접속하며 **Resend API 키를 SMTP 비밀번호로** 썼습니다.
키가 사용자 PC 와 메일 프로그램 설정에 퍼지는 문제를 없애려고, 게이트웨이가 SMTP 제출 서버(`server/smtp.mjs`)를 직접 엽니다.
- 포트 465(접속 즉시 TLS)·587(STARTTLS). 인증서는 POP·IMAP 과 같은 파일이고, 인증(AUTH PLAIN/LOGIN)은 TLS 위에서만 받습니다.
- 계정·비밀번호는 웹 메일·IMAP·POP 과 같습니다. 인증한 사용자는 자기 메일함 주소로만 보낼 수 있습니다(헤더 From 과 봉투 MAIL FROM 모두 검사).
- 받은 원본을 해석해 **서버의 `RESEND_API_KEY` 로 Resend API(`POST /emails`)** 를 호출합니다. 숨은 참조는 봉투(RCPT)에만 있는 주소로 판단합니다.
같은 원본을 다시 보내면 멱등 키로 한 번만 발송합니다.
- 한도: 한 통 30MB(원본 기준), 받는 사람 50명. 인증 실패는 IP 당 15분 20회.
- 인증하지 않은 접속은 MAIL 단계에서 거부하므로 오픈 릴레이가 아닙니다. 외부 메일을 받는 MX 도 아닙니다(수신은 Resend).
- 환경 변수: `SMTP_PORT`(기본 587), `SMTPS_PORT`(기본 465), `SMTP_DISABLED=1`(끄기), `SMTP_ALLOW_PLAINTEXT=1`(진단용 평문 인증 허용).
- 기존에 메일 프로그램에 넣어 둔 Resend API 키는 더는 필요 없습니다. Resend 대시보드에서 그 키를 폐기하세요(서버 환경 파일의 키와 다른 키였다면).
## 7. 문제 확인
```bash
journalctl -u arihakdang-mail -n 100 --no-pager # 동기화·웹훅·POP 로그
curl -s http://127.0.0.1:8790/api/health
openssl s_client -connect mail.arihakdang.com:995 -quiet # +OK ... POP3 ready 가 보여야 함
```
- 메일 화면 상단의 연결 상태가 `mail_auth_failed` 이면 `RESEND_API_KEY` 가 폐기·변경된 것입니다.
- 연결 상태가 `mail_domain_unverified`(1.0.11~)이면 Resend 가 발신 도메인을 검증하지 못한 것입니다. 관리자 › 서버 상태(또는 전체 설정 화면 › 자동 설정)에 **어느 DNS 레코드가 없거나 값이 다른지**
문장으로 보입니다(예: `검증 실패 — 값이 다른 레코드: TXT resend._domainkey.<도메인> (지금 p=… / 필요 p=…)`). 값이 다르면 다른 Resend 계정·예전 등록의 값이니 표의 값으로 바꾸세요.
바꾸면 1시간마다 도는 자동 재검증이 Resend 에 검증을 다시 요청합니다(`RESEND_AUTO_VERIFY=0` 으로 끔). `mail_domain_missing` 은 Resend 계정에 도메인이 없는 것 — 자동 설정 실행.
`mail_forbidden` 은 그 밖의 403(키 권한 등)에만 씁니다.
- 관리자 페이지 → 서버 상태의 웹훅 처리 내역이 비어 있으면 Resend 엔드포인트 URL·이벤트·Signing Secret 을 확인합니다.
- 995/993 이 열리지 않으면 인증서 경로(`POP_TLS_CERT/KEY`)와 `arihakdang` 계정의 읽기 권한을 확인합니다.
- POP 이 "되다말다" 하면 `journalctl` 의 `POP3` 줄을 봅니다(0.9.7~): `POP3 평문 로그인 거부`(110 에서 STLS 없이 로그인 → 일반 접속 로그인 허용 스위치 확인),
`POP3 접속 거부 … 다른 세션 … 사용 중`(같은 계정의 다른 메일 프로그램이 아직 연결돼 있음 → `-ERR [IN-USE]`, 그 세션이 끝나면 바로 됨),
`POP3 종료 … RETR n·DELE m … (quit|timeout|error:…)`(세션 요약 — `timeout` 은 10분 동안 명령이 없어 끊은 것, `error:` 는 상대가 연결을 끊은 것).
- 화면에 "처리하지 못했습니다 (코드)" 가 보이면 괄호 안 코드가 서버 오류 코드입니다(0.8.25~). 배포 직후라면 화면이 서버 버전(`X-App-Version`)을 보고 스스로 새로 고치므로 보통 한 번만 보입니다.
- 메일 전달·자동 응답이 나가지 않으면 메일 상세 정보의 "자동 처리" 줄을 봅니다. `전달하지 않음 — …` 은 규칙상 건너뛴 것(되풀이 방지·72시간 지난 메일·같은 상대 24시간 안 응답 등)이고,
`전달 실패 / 자동 응답 실패` 는 발송 서비스 오류입니다(`journalctl` 에 `메일 전달 실패` / `자동 응답 실패` 로 남음). 기록은 DB 의 `auto_actions` 표에 있습니다.
## 8. 다른 도메인에 독립 설치 (예: mail.heart-m.com)
0.8.18 부터 www 사이트 없이 **독립 실행(standalone)** 으로 다른 서버·도메인에 설치할 수 있습니다. 설치는 mail.heart-m.com(115.68.193.164, 실행 계정 `webmaster`)을 기준으로 적습니다.
1. **사이트 설정 파일** `deploy/sites/heart-m.env` — 서비스 이름(`heart-mail`), 실행 계정, 경로(`/srv/heart-mail`, `/etc/heart-mail`), Node 경로, 포트, 호스트, 기본 메일함(`admin@heart-m.com`),
탭 제목(`SITE_TITLE`, 비우면 서비스 이름과 같음), `LOGIN_MODE=standalone`(0.9.0 설치 옵션, 9절). 새 사이트 파일은 0.9.0 부터 `deploy/new-site.sh` 로 만듭니다:
```bash
bash deploy/new-site.sh cafe --host mail.cafe.heart-m.com --mailbox admin@cafe.heart-m.com --user webmaster \
--limit-accounts 20 --limit-mailbox 500M --limit-attachment 20M # 기본은 --login standalone, 제한 0 = 없음
bash deploy/new-site.sh shop --host mail.shop.test --mailbox admin@shop.test --login www --www-db /srv/shop/data/shop.sqlite # www 연동
```
왼쪽 위 서비스 이름은 0.8.25 부터 메일 도메인에서 자동으로 정합니다(heart-m.com → "HEART-M 메일", cafe.heart-m.com → "CAFE 메일"). `SITE_BRAND` 는 더 쓰지 않습니다(남아 있어도 무시).
2. **서버 준비(root)**: 실행 계정이 있어야 하고(`id webmaster`), Node 22.13+ 를 `NODE_BIN` 경로에 둡니다(패키지 저장소 없이 nodejs.org 바이너리를 푸는 방식).
```bash
curl -fsSLO https://nodejs.org/dist/v22.23.3/node-v22.23.3-linux-x64.tar.xz && sudo mkdir -p /opt/heart-mail-node \
&& sudo tar -xJf node-v22.23.3-linux-x64.tar.xz -C /opt/heart-mail-node --strip-components=1
git bundle create /tmp/mail-gateway.bundle main && scp /tmp/mail-gateway.bundle heart:/tmp/ # 작업 PC 에서
sudo install -o webmaster -g webmaster -m 644 /tmp/mail-gateway.bundle /srv/heart-mail/source.bundle # 서버에서(디렉터리는 setup.sh 가 만들지만 번들은 먼저 둬도 됨)
sudo env SITE=heart-m SOURCE_BUNDLE=/srv/heart-mail/source.bundle bash /path/to/repo/deploy/setup.sh
```
`setup.sh` 는 `/srv/heart-mail/{repo,releases,data}`, `/srv/heart-mail/site.env`, `/etc/heart-mail/gateway.env`, `heart-mail.service`, sudoers, nginx vhost(`mail.heart-m.com.conf`), certbot 훅을 만듭니다.
3. **환경 파일** `/etc/heart-mail/gateway.env` 에 `RESEND_API_KEY` 와 `RESEND_WEBHOOK_SECRET` 를 채웁니다(heart-m.com 이 Resend 에 수신·발신 도메인으로 등록되어 있어야 함).
Resend 웹훅 엔드포인트는 `https://mail.heart-m.com/api/webhooks/resend`(3절과 같음).
4. **배포**: `sudo -u webmaster env SOURCE_BUNDLE=/srv/heart-mail/source.bundle /srv/heart-mail/repo/deploy/deploy.sh` — 테스트·릴리스 복사·마이그레이션·재시작·health.
이후 갱신도 같은 명령입니다(번들만 새로 올림).
5. **첫 관리자 계정** (0.8.24~): 독립 실행이고 계정이 하나도 없으면 서버가 시작할 때 **`admin` / `adminpasswd`** 관리자 계정(기본 메일함 admin@heart-m.com 의 주인)을 자동으로 만들고
journal 에 안내를 남깁니다. `https://mail.heart-m.com/admin` 에 그 비밀번호로 로그인하면 "초기 비밀번호 사용 중" 경고가 보이며, 계정 관리에서 admin 계정을 열어 비밀번호를 바꾸면 사라집니다.
초기 비밀번호는 비밀번호 정책과 무관하게 글자 수 하한(6)만 적용됩니다(예전에는 10자 규칙 때문에 짧은 초기 비밀번호를 만들 수 없었음).
셸에서 비밀번호를 재설정하거나 계정을 추가하려면:
```bash
sudo -u webmaster env $(sudo grep -E '^(DB_FILE|MAILBOX)=' /etc/heart-mail/gateway.env | xargs) \
/opt/heart-mail-node/bin/node /srv/heart-mail/current/server/user-cli.mjs passwd admin # 또는 add <login> [--admin] / list
# 비밀번호는 단말에서 숨겨 입력(또는 PASSWORD=... 환경 변수 / --password-stdin). 규칙은 관리자 페이지의 비밀번호 정책을 따릅니다.
```
나머지 계정은 `https://mail.heart-m.com/admin` 에서 만듭니다.
6. **DNS 와 인증서**: `mail.heart-m.com` A 레코드를 115.68.193.164 로 바꿉니다(프록시 없이). DNS 가 바뀌기 전에는 nginx 가 80 포트(ACME 챌린지)만 열고 나머지는 503 이며,
`heart-mail-tls.timer` 가 15분마다 A 레코드를 확인해 이 서버를 가리키면 certbot(webroot)으로 발급 → `setup.sh` 재실행 → 443·995/993/465 TLS 를 켜고 타이머를 끕니다.
바로 하려면 `sudo bash /srv/heart-mail/repo/deploy/tls-when-ready.sh /srv/heart-mail/site.env`. 갱신은 certbot 타이머와 훅이 맡습니다.
7. **확인**: `curl -s http://127.0.0.1:8790/api/health`, `journalctl -u heart-mail -n 50 --no-pager`, `systemctl list-timers heart-mail-tls.timer`.
메일 프로그램 설정값은 6절과 같되 호스트가 `mail.heart-m.com`, 사용자 이름이 게이트웨이 계정의 로그인 ID 또는 `로그인ID@heart-m.com` 입니다.
## 9. 설치 옵션: 로그인 방식과 시스템 제한 (0.9.0~)
설치·배포 때 사이트 설정 파일(`deploy/sites/<이름>.env`, `new-site.sh` 가 만듦)에서 정합니다. `setup.sh` 가 **처음 한 번** `gateway.env` 를 만들 때 이 값을 넣으므로,
이미 설치된 서버에서 바꾸려면 `<ETC_DIR>/gateway.env` 를 직접 고치고 `systemctl restart <서비스>` 합니다(mail.heart-m.com 의 환경 파일은 0.9.0 배포에서 건드리지 않았으며,
값이 없으면 모두 "제한 없음" 과 `standalone` 으로 동작합니다).
**로그인 방식 `LOGIN_MODE`** — 독립 실행이 기본 설치 형태입니다.
- `standalone`(기본): www 사이트와 연동하지 않습니다. 첫 관리자 `admin / adminpasswd` 가 자동으로 생기고, 계정은 관리자 페이지(또는 회원 가입)로 만듭니다. `WWW_DB_FILE` 은 무시됩니다.
- `www`: www 사이트 DB(`WWW_DB_FILE`, 필수)로 관리자를 인증합니다(mail.arihakdang.com). 경로가 없으면 서비스가 시작하지 않습니다.
- 값이 없으면 0.8.x 처럼 `WWW_DB_FILE` 유무로 정합니다(기존 서버 호환).
**시스템 제한 `LIMIT_*`** — 메일 시스템 전체의 상위 설정입니다. 관리자 페이지에서는 볼 수만 있고, 관리자가 정하는 값(계정별 메일함 크기, 첨부 크기, 받는 사람 수)은 이 값 이내로만 저장됩니다. **모두 0 이면 제한 없음.**
| 변수 | 뜻 | 어긋나면 |
|---|---|---|
| `LIMIT_ACCOUNTS` | 허용 가능한 메일 계정 수(정지·승인 대기 포함) | 계정 추가·회원 가입이 `account_limit_reached` 로 거부 |
| `LIMIT_TOTAL_BYTES` | 모든 메일함을 합친 최대 용량 | 수신은 `storage_full` 로 건너뜀(공간이 생기면 다음 동기화가 가져옴), 발송은 거부 |
| `LIMIT_MAILBOX_BYTES` | 개별 메일함 최대 용량(관리자·계정별 값의 상한) | 그 메일함 수신 건너뜀(`quota_exceeded`), 웹·SMTP 발송 거부(`452 4.2.2`), IMAP APPEND `NO [OVERQUOTA]` |
| `LIMIT_ATTACHMENT_BYTES` | 첨부 파일 한 개 최대 크기(관리자 값의 상한, 내장 상한 25MB) | 웹 첨부·전달 복사 `attachment_too_large`, SMTP `552 5.3.4` |
| `LIMIT_RECIPIENTS` | 한 메일의 받는 사람 수(참조·숨은 참조 포함, 내장 상한 50) | 웹 발송 `too_many_recipients`, SMTP `452 4.5.3` |
| `LIMIT_MONTHLY_MESSAGES` | 월간(1일~말일, Asia/Seoul) 최대 수발신 통수(보낸 + 받은) | 발송 거부(`monthly_limit_exceeded`, SMTP `550 5.7.1`), 자동 전달·자동 응답 건너뜀(limit) |
**대용량 첨부파일 상한(0.9.3)** — 다른 `LIMIT_*` 와 달리 **무제한이 없습니다**. 비우거나 0 이면 기본값이 적용됩니다.
| 변수 | 뜻 | 기본값 |
|---|---|---|
| `LIMIT_LARGE_FILE_BYTES` | 대용량 파일 한 개 최대 크기(관리자 설정의 상한) | `1G` |
| `LIMIT_LARGE_FILE_DAYS` | 보관 최대 기간(일) | `30` |
| `LIMIT_LARGE_FILE_DOWNLOADS` | 다운로드 최대 횟수 | `20` |
| `FILES_DIR` | 파일 저장 디렉터리(비우면 DB 옆 `files/`) | |
크기 값은 바이트 또는 `K`/`M`/`G` 접미사(`500M`, `2G`)이며 **MIME 변환 뒤의 크기** 기준입니다. 메일은 저장·전송될 때 첨부가 base64 로 바뀌어 약 1.37배가 되므로
사용량(저장된 원본 `.eml` 크기)과 모든 제한을 그 크기로 셉니다. 첨부 파일을 올릴 때는 파일 크기 × 4/3 을 제한과 비교합니다.
메일 현황의 시간대는 `STATS_TIMEZONE`(기본 `Asia/Seoul`)으로 바꿀 수 있습니다.
## 10. 설정 파일과 초기 설정 페이지 (1.0.0)
설정은 두 층입니다.
| 층 | 어디에 | 무엇 | 바꾸는 방법 |
|---|---|---|---|
| 배포 설정 | `/etc/<서비스>/gateway.env` (systemd EnvironmentFile) | DB 경로, 포트, 기본 메일함·공개 호스트, 로그인 방식, 시스템 제한 `LIMIT_*`, TLS 사본 경로, `SETUP_PORT`, `HARAKA_DIR` | root 가 파일을 고치고 서비스 재시작 |
| 운영 설정 | `<APP_DIR>/data/config.json` (실행 계정만 읽음) | 수·발신 방식, Resend API 키·웹훅 비밀키, SMTP 중계 서버, 내장 Haraka(포트·DKIM), 발신 표시 이름, 탭 제목, 통계 시간대, 동기화 주기 | **초기 설정 페이지** 또는 `/admin` → **메일 서비스 설정**. 저장 즉시 적용(재시작 없음) |
- **왜 나눴나**: 운영 설정은 처음 배포할 때 알 수 없거나(키를 아직 만들지 않음) 배포 자체에는 필요하지 않습니다. 비어 있어도 배포는 성공해야 하고, 채울 때 root 작업과 재시작이 없어야 합니다.
- **기존 서버**: 설정 파일이 없으면 첫 시작 때 환경 파일에 있던 값(`RESEND_API_KEY` 등)을 그대로 읽어 설정 파일을 만듭니다. 환경 파일은 그대로 두어도 됩니다.
그 뒤로는 설정 파일 값이 우선합니다(설정 파일에서 지운 값은 환경 파일 값으로 되살아나지 않음). 설정 화면의 각 항목 아래에 출처(설정 파일 / 환경 파일 값 / 기본값)가 보입니다.
- **초기 설정 페이지**: 게이트웨이가 전용 포트(`SETUP_PORT`, 기본 **8791**, 모든 주소)에서 엽니다. 접속에는 데이터 디렉터리의 `setup-token`(배포 계정만 읽음)이 필요하며,
`deploy.sh` 가 끝에 전체 주소(`…:8791/?token=…`)를 출력하고 `data/setup-url.txt` 에도 남깁니다. 틀린 토큰은 IP 당 15분 10회만 받습니다.
메일 포트 인증서(`POP_TLS_CERT`)를 읽을 수 있으면 HTTPS 로, 없으면 HTTP 로 엽니다(새 서버는 인증서 전이라 HTTP — 토큰이 노출되므로 설정이 끝나면 토큰 파일을 바꾸거나 `SETUP_PORT=0` 으로 끌 수 있음).
방화벽을 쓰면 8791 을 열어야 합니다(`sudo ufw allow 8791/tcp`). 운영 중에는 `https://<호스트>/setup`(관리자 세션)으로 같은 화면을 엽니다.
- **화면 구성**: 현재 상태(수·발신 방식, 발신 서비스 연결, Haraka, 설정 파일) → ① 수·발신 방식 → ② Resend → ③ SMTP 중계 서버 → ④ 내장 Haraka(DKIM 키 생성, DNS 레코드, 서비스 상태·로그) → ⑤ 사이트 정보 → ⑥ 마무리(점검표, 완료 표시, systemd 아래면 재시작).
고른 방식에 필요한 카드만 보입니다. 비밀값은 가려서 보이며(`re_••••abcd`) 입력 칸을 비워 두면 그대로, "비우기" 를 누르면 지워집니다.
- **API**: `GET/PUT /api/setup/config`, `POST /api/setup/check {what:'outbound'|'haraka'}`, `POST /api/setup/dkim`, `POST /api/setup/restart`. 전용 포트는 `X-Setup-Token`(또는 `?token=`), 웹 서버는 관리자 세션 + `X-Mail-Scope: admin`.
## 11. 발송 모듈 (1.0.0)
발송은 `server/providers/index.mjs` 의 `sendMail(env, payload, key)` 한 곳을 거치며, 설정의 **발신 방식(outbound)** 이 모듈을 고릅니다.
| 발신 방식 | 모듈 | 설명 |
|---|---|---|
| `resend` | `providers/resend.mjs` | Resend REST API(예전과 같음). 도메인이 Resend 에 검증되어 있어야 함 |
| `haraka` | `providers/smtp.mjs` → 127.0.0.1:`HARAKA_RELAY_PORT`(2525) | 내장 Haraka 의 로컬 제출 포트로 넘기면 Haraka 가 받는 쪽 MX 에 직접 배달(12절) |
| `smtp` | `providers/smtp.mjs` → `SMTP_RELAY_HOST:PORT` | 외부 SMTP 중계 서버(587 STARTTLS / 465 TLS / 인증 선택). 보낸 사람 도메인을 그 서버가 허용해야 함 |
메시지 객체는 Resend 요청과 같은 모양(from, to, cc, bcc, reply_to, subject, text, html, headers, attachments[{filename, content(base64), content_type, content_id}])이라
웹 발송(store.mjs)·SMTP 제출 서버(smtp.mjs)·자동 전달/응답(automation.mjs)은 어느 모듈을 쓰는지 모릅니다. SMTP 쪽은 `mime.mjs` 의 `buildEml` 로 원본을 만들고(Bcc 는 봉투에만),
의존성 없는 SMTP 클라이언트(`providers/smtp-client.mjs`)로 보냅니다. 오류 코드는 공통(`mail_not_configured` / `mail_auth_failed` / `mail_rejected` / `mail_rate_limited` / `mail_provider_unavailable`)이라
화면 문구와 SMTP 제출 서버의 응답 변환이 그대로 동작합니다. 새 발송 서비스를 더하려면 `providers/<이름>.mjs` 에 같은 모양의 send 를 만들고 `index.mjs` 에 한 줄씩 더합니다.
수신 방식(inbound)은 `resend`(웹훅 + 동기화) 또는 `haraka`(12절)입니다. Resend 동기화는 Resend 를 쓰는 방향만 훑습니다(수신이 Haraka 면 받은 메일 목록은 보지 않음).
## 12. 내장 Haraka (1.0.0)
[Haraka](https://haraka.github.io/)(Node.js 메일 서버)를 함께 띄워 **Resend 없이** 메일을 받거나(MX) 보낼 수 있습니다. 조합은 자유입니다(예: 수신 Haraka + 발신 Resend).
- **설치**: 저장소의 `haraka/package.json`(Haraka 3.3.4 + `haraka-plugin-dkim`)을 `deploy/haraka-apply.sh` 가 `<APP_DIR>/haraka` 에 npm 으로 설치하고 systemd 서비스 `<APP_NAME>-haraka` 를 등록합니다.
새 사이트는 `setup.sh` 가 자동으로 부르고, 기존 사이트는 한 번 실행합니다: `sudo bash <APP_DIR>/repo/deploy/haraka-apply.sh`(인터넷 필요, 환경 파일·데이터는 건드리지 않음).
- **동작**: 관리 프로세스 `haraka/run.mjs` 가 항상 떠 있으면서 설정 파일을 5초마다 보고, 수신 또는 발신 방식이 `haraka` 이면 Haraka 설정(`<APP_DIR>/haraka/config/*`, 플러그인 `plugins/arimail.js`)을 만들어
Haraka 를 자식 프로세스로 띄우고, 아니면 끕니다. 상태는 `data/haraka-status.json` 에 써서 설정 화면·서버 상태에 보입니다. 로그: `journalctl -u <APP_NAME>-haraka -f`.
- **수신**: Haraka 가 `HARAKA_PORT`(25)로 받아 RCPT 단계에서 게이트웨이에 메일함이 있는지 확인하고(없는 사용자는 550), DATA 가 끝나면 원본을 `POST /api/inbound/haraka`(로컬, 토큰 인증)로 넘깁니다.
게이트웨이는 Resend 경로와 같은 규칙으로 저장합니다(중복·영구 삭제·용량·사본·자동 전달/응답). 메일함이 가득 차면 452 로 보낸 쪽이 나중에 다시 보냅니다.
DNS 의 **MX** 를 이 서버(`HARAKA_HOSTNAME`, 기본 공개 호스트 이름)로 바꾸고 방화벽에서 25 를 열어야 합니다.
- **발신**: 게이트웨이가 127.0.0.1:`HARAKA_RELAY_PORT`(2525, 로컬 전용)로 넘기면 Haraka 가 받는 쪽 MX 에 직접 배달하고 실패는 재시도·반송합니다.
설정 화면에서 **DKIM 키 만들기** 를 누르면 RSA 2048 키가 설정 파일에 저장되고 Haraka 가 서명합니다. 화면의 **SPF·DKIM(·DMARC) TXT 레코드**를 DNS 에 올리고, 서버 IP 의 역방향 DNS(PTR)가
메일 서버 이름을 가리키게 호스팅 업체에 요청하세요(없으면 스팸 분류가 잦습니다). STARTTLS 는 메일 포트 인증서(`POP_TLS_CERT`)를 그대로 씁니다.
- **환경 변수**: `HARAKA_DIR`(설치 위치), `HARAKA_LOGLEVEL`(기본 info). 그 밖의 값(포트·호스트 이름·DKIM)은 설정 파일에 있습니다.
## 13. 자동 설정과 DNS 모듈 (1.0.5)
설정 화면의 **자동 설정** 카드가 수·발신 방식과 도메인에 맞는 준비 상태를 진단하고(`GET /api/setup/plan`), 자동으로 할 수 있는 일은 **자동 설정 실행**(`POST /api/setup/plan/run`) 한 번으로 처리합니다(`server/provision.mjs`).
단계마다 상태(완료 / 자동 실행 가능 / 반영 대기 / 사용자 작업 / 오류 / 참고)와 안내 문구가 붙고, 사람이 해야 하는 단계는 입력 칸이나 단추를 함께 보여 줍니다.
| 구성 | 자동으로 하는 일 | 사용자가 하는 일(안내) |
|---|---|---|
| Resend 수·발신 | 도메인 등록(지역 `RESEND_REGION`)과 발송/수신 기능 맞춤, 요구 DNS 레코드(DKIM TXT·`send` CNAME·수신 MX) 적용(DNS 모듈이 있을 때), 검증 요청과 상태 추적(`pending`/`verified`/`failed`/`temporary_failure`), 수신 웹훅 생성과 **서명 비밀키 자동 저장** | API 키 입력, DNS 모듈이 없으면 표의 레코드를 직접 넣고 "반영 확인", 웹훅 비밀키를 잃었으면 입력 또는 "웹훅 새로 만들기" |
| 내장 Haraka | DKIM 키 생성, MX/SPF/DKIM 레코드 적용(기존 SPF 가 있으면 우리 메커니즘만 끼워 넣음), 실행 상태·PTR 확인 | `deploy/haraka-apply.sh` 실행(설치), 방화벽 25 개방, 호스팅 업체에 PTR 요청 |
| 외부 SMTP 중계 | 접속·인증 확인 | 접속 정보 입력 |
- **DNS 모듈**(`server/dns/`, 요구사항 1): `DNS_PROVIDER` 가 `google`(Google Cloud DNS, 서비스 계정 JSON) 또는 `route53`(AWS Route 53, 액세스 키)이면 존을 자동으로 찾아 레코드를 UPSERT 합니다(외부 의존성 없이 OAuth2 JWT / SigV4 서명 구현).
`none` 이면 자동 적용 없이 표의 레코드를 보여 주고 공개 리졸버(8.8.8.8 / 1.1.1.1)로 반영 여부만 확인합니다. DMARC 는 권장 레코드로 보여 주되 자동으로 넣지 않습니다(기존 정책을 덮어쓰지 않기 위해).
권한: Google 은 프로젝트의 "DNS 관리자", AWS 는 `route53:ListHostedZonesByName/GetHostedZone/ListResourceRecordSets/ChangeResourceRecordSets`.
- **Resend 모듈**(`server/providers/resend.mjs`, 요구사항 3): 도메인 목록·등록·기능 변경·검증, 웹훅 목록·생성·삭제를 담당합니다. 자동 설정은 이 모듈만 통해 Resend 를 바꿉니다.
- **검증 실패 진단과 자동 재검증**(1.0.11): Resend 도메인이 `failed` 면 레코드마다 공개 DNS 조회로 "없음(전파 대기)" 과 "값이 다름(바꿔야 함)" 을 구분해 단계 문구와 표의 "지금 값" 으로 보여 줍니다.
Resend 자신이 verified 라고 하는 레코드는 우리 조회 값이 달라도 정상으로 봅니다(예: 예전 수신 MX 값). 레코드가 모두 보이면 정기 동기화 주기(기본 1시간)마다 검증을 자동으로 다시 요청하므로
"지금 검증 요청" 을 누르지 않아도 됩니다. 발신 상태(`/api/mail/status`, 서버 상태 화면)도 `mail_domain_unverified`(원인 문장 `detail`, 레코드별 `records`) / `mail_domain_missing` 으로 구분합니다.
- **상황별 안내**: 인증 정보 없음·형식 오류·권한 없음·존 없음·연결 실패(DNS), 키 거부·권한·요청 제한(Resend), 미설치·서비스 꺼짐·실행 대기·PTR 불일치(Haraka) 등 각 단계의 `detail` 에 원인과 다음 할 일이 적힙니다. 모든 단계가 끝나면 "메일 사용 준비가 끝났습니다" 를 알립니다.
## 14. 다중 도메인과 전체 설정 화면 (1.0.7)
한 서버(한 프로세스)가 여러 도메인의 메일 서비스를 섬깁니다(`server/domains.mjs`). 도메인마다 계정·세션·메일·설정 파일·파일 저장소가 따로이고, 로그인과 계정 관리는 도메인 단위입니다.
| | 기본 도메인 | 추가 도메인 |
|---|---|---|
| 정해지는 곳 | 환경 파일(`MAILBOX`·`PUBLIC_HOST`·`LOGIN_MODE`·`WWW_DB_FILE`·`DB_FILE`) | 전체 설정 화면의 **도메인** 카드(재시작 없음) |
| 데이터 | `data/mail-gateway.sqlite`, `data/config.json`, `data/files/` (예전 그대로) | `data/domains/<도메인>/{mail.sqlite, config.json, files/}` |
| 웹 메일 주소 | `PUBLIC_HOST` (+ 등록부의 추가 호스트) | 등록한 호스트 이름(기본 `mail.<도메인>`) — nginx vhost 와 인증서는 `sudo bash deploy/domain-nginx.sh <호스트>` |
| 로그인 방식 | 환경 파일 | `local`(자체 계정, 첫 관리자 `admin` 자동 생성) 또는 `www`(연결된 웹 서비스의 SQLite `users` 표로 관리자 인증) |
| 메일 프로그램 | `PUBLIC_HOST`:993/995/465/587 | 같은 서버 이름(기본 도메인 호스트)에 `아이디@도메인` 으로 로그인 — 인증서가 하나이므로 |
- **등록부** `data/domains.json`: 1.0.7 로 올린 서버는 첫 시작에 기본 도메인만 담아 만들고(데이터 이동 없음) 예전과 똑같이 동작합니다. 추가 도메인의 설정 파일은 기본 도메인의 설정에서 API 키·DNS 인증 정보·SMTP 중계·Haraka 포트·시간대를 복사해 시작할 수 있습니다.
- **요청 → 도메인**: HTTP 는 Host(X-Forwarded-Host). 등록된 호스트 이름과 같거나 도메인의 하위 이름이면 그 도메인, IP·localhost 는 기본 도메인. 모르는 호스트에는 "메일 서비스가 존재하지 않습니다", 중지한 도메인에는 "메일 서비스가 활성화되지 않았습니다"(관리자에게 요청) 화면을 줍니다. 메일 프로그램은 로그인 ID 의 `@도메인`(없으면 TLS SNI 호스트, 그래도 없으면 기본 도메인).
- **전체 설정 화면**: 기본 도메인의 전용 포트(`https://<PUBLIC_HOST>:8791/?token=…`, 배포 출력에 주소). 맨 위 도메인 카드에서 추가·수정·중지/사용·삭제(등록 해제만, 디렉터리는 남김)하고, 행의 "선택" 으로 고른 도메인의 설정을 아래 카드에서 바꿉니다. 추가 도메인의 호스트 이름으로 이 포트에 오면 열리지 않습니다(기본 도메인 주소 안내). 관리자 페이지의 `/setup` 은 자기 도메인만.
- **서버에 하나뿐인 것**(기본 도메인 설정을 따름): 메일 포트·인증서, 내장 Haraka 의 수신/제출 포트·메일 서버 이름·게이트웨이 토큰. Haraka 는 수신 방식이 haraka 인 모든 도메인을 받고(받는 사람 주소로 나눔), 발신 haraka + DKIM 키가 있는 도메인마다 `config/dkim/<도메인>/` 키로 서명합니다.
- **www 연동 도메인**: 그 SQLite 파일을 서비스 계정이 읽을 수 있어야 하며 systemd 의 `ReadWritePaths`(사이트 설정 `EXTRA_RW`)에 그 디렉터리(WAL 의 -shm 파일 때문)가 있어야 합니다.
- 추가 도메인의 명령줄 계정 도구: `DB_FILE=<APP_DIR>/data/domains/<도메인>/mail.sqlite MAILBOX=admin@<도메인> node server/user-cli.mjs …`
- API: `GET/POST /api/setup/domains`, `PATCH/DELETE /api/setup/domains/<도메인>`(전용 포트 전용), 그 밖의 `/api/setup/*` 에 `?domain=<도메인>`. 오류: `domain_exists`, `host_taken`, `www_db_unreadable`, `primary_domain`, `domain_not_found`, `validation`(field).
## 15. webmaker 인증 모드와 사이트의 /mail 경로 (1.0.8)
웹메이커(WebHosting_AI_agent, `https://source.ibit.kr/OZ-Group/WebHosting_AI_agent`)의 사용자 계정으로 AriMail 에 로그인하게 하는 로그인 방식입니다(`server/webmaker.mjs`). 웹메이커의 **사용자 파일을 읽기 전용으로 참조**해 같은 방식(scrypt)으로 비밀번호를 검사하므로 계정을 따로 만들거나 메일 비밀번호를 따로 정하지 않습니다. 웹 메일·IMAP·POP·SMTP 모두 같은 비밀번호이며, 도메인마다 따로 켭니다.
| 웹메이커 쪽 | AriMail 계정 (도메인 D) | 역할 |
|---|---|---|
| 사이트 회원 중 로그인 ID 가 있는 계정(사이트 관리자 페이지 › 계정 추가, `modules/<사이트>.json` members.loginId) | `loginId@D` | 일반 |
| 사이트 소유자(`sites/<사이트>.json` ownerId → `users.json`) | `소유자ID@D` | 일반(웹메이커 관리자이기도 하면 관리자) |
| 웹메이커 관리자(`users.json` role=admin, 보통 `admin`) | `admin@D` | 관리자 |
| (도메인에 대응하는 사이트가 없을 때) 웹메이커의 모든 회원 | `아이디@D` | 관리자는 관리자, 나머지 일반 |
- 도메인 D 에 대응하는 사이트: 등록부의 `siteId` → 웹메이커 `domains.json` 의 서브 도메인 등록(name=D, siteId) → 사이트의 `domains` 목록 → 사이트의 메일 도메인(`webmail.domain`) 순으로 찾습니다. 같은 로그인 ID 가 겹치면 사이트 회원 → 소유자 → 플랫폼 관리자 순으로 먼저 찾은 쪽이 그 주소를 씁니다. 승인이 취소된 회원(approved=false)은 로그인할 수 없습니다.
- 예: 웹메이커에서 `site.ariweb.net` 사이트를 만들고 AriMail 에 도메인 `site.ariweb.net`(로그인 방식 webmaker)을 추가하면 `admin@site.ariweb.net`(웹메이커 admin 비밀번호)으로 바로 수·발신하고, 사이트 관리자 페이지에서 사용자 `user` 를 추가하면 `user@site.ariweb.net` 도 같은 비밀번호로 씁니다.
- 웹메이커에서 비밀번호를 바꾸거나 계정을 지우면 바로 반영됩니다(스냅샷 3초 캐시, 로그인 실패 시 한 번 다시 읽음). 웹메이커 계정의 이름·비밀번호·역할은 AriMail 에서 바꿀 수 없고(관리자 페이지에 읽기 전용 행), POP/IMAP 허용과 메일함 크기만 둡니다. 게이트웨이 계정(관리자 페이지에서 만든 것)은 함께 쓸 수 있되 웹메이커 계정과 같은 로그인 ID 는 만들 수 없습니다. webmaker 모드 도메인에는 초기 관리자 admin/adminpasswd 를 만들지 않습니다.
### 켜는 방법
1. **파일 읽기 권한**: 웹메이커 데이터(`DATA_ROOT`, 예 `/data/webmaker`)는 보통 웹메이커 서비스 계정(예: master)만 읽을 수 있고(0600/0700) 웹메이커가 파일을 다시 쓸 때마다 권한이 돌아옵니다. 같은 서버라면 root 로 한 번:
```bash
sudo bash /srv/<사이트>/repo/deploy/webmaker-access.sh /data/webmaker
```
root 소유 사본 `/usr/local/lib/arimail/webmaker-snapshot.mjs`(node 22+)와 **systemd 소켓 도우미**(`arimail-webmaker-<슬러그>.socket` + `@.service`: `/run/arimail-webmaker/data-webmaker.sock` 에 접속하면 웹메이커 소유 계정으로 스냅샷 JSON 을 써 줌, 소켓은 AriMail 계정 그룹만 0660)를 설치하고 실행 계정으로 읽어 봅니다. AriMail 서비스 유닛은 `NoNewPrivileges=yes` 라 sudo 가 동작하지 않으므로 소켓 방식이 기본이고, systemd 가 없는 서버는 `--sudo`(sudoers 규칙 한 줄, 그 디렉터리 인수만 허용)로 설치합니다. AriMail 실행 계정이 직접 읽을 수 있는 디렉터리면 이 단계는 필요 없습니다.
2. **도메인 설정**: 전체 설정 화면(§14)의 도메인 카드에서 추가·수정 → 로그인 방식 **웹메이커(webmaker) 계정과 연동**, 웹메이커 데이터 디렉터리 `/data/webmaker`, 파일을 읽을 계정 `master`(1단계를 했을 때 — 소켓 도우미가 있으면 소켓, 없으면 sudo 도우미를 씀), 사이트 ID 는 보통 비움. 등록부에는 `auth: { mode:'webmaker', webmakerDir, webmakerUser, siteId }` 로 저장됩니다. 기본 도메인은 환경 파일(`/etc/<app>/gateway.env`)에 `LOGIN_MODE=webmaker`, `WEBMAKER_DIR=/data/webmaker`, 선택 `WEBMAKER_USER=master`, `WEBMAKER_SITE=<32자리>` 를 적고 재시작합니다.
3. 확인: 관리자 페이지(웹메이커 admin 으로 로그인)의 계정 목록에 "웹메이커 계정" 행이 보입니다. 파일을 못 읽으면 도메인 목록과 계정 목록에 "읽기 실패" 와 사유가 보이고, 마지막으로 읽은 스냅샷이 있으면 그것으로 계속 동작합니다.
### 사이트 주소의 /mail 로 웹 메일 열기
서브 도메인 사이트(`site.ariweb.net`)는 메일용 호스트 이름(`mail.site.ariweb.net`)을 따로 두기 어렵습니다(와일드카드 인증서가 한 단계만 덮음). 그런 도메인은 사이트 주소의 `/mail` 아래를 웹 메일로 씁니다.
1. 도메인의 호스트 항목에 `site.ariweb.net/mail` 처럼 **호스트/경로** 를 적습니다(같은 호스트 이름을 경로만 달리해 두 번 적을 수는 없음).
2. 사이트의 nginx 설정에 조각을 넣습니다(root): `sudo bash deploy/site-mail-path.sh site.ariweb.net` (또는 설정 파일 경로). `deploy/nginx/mail-path.conf.in` 을 `/etc/nginx/snippets/<app>-mail-path.conf` 로 채우고 443 server 블록에 `include` 를 더합니다 — `location ^~ /mail/ { proxy_pass http://127.0.0.1:<포트>/; proxy_set_header X-Forwarded-Prefix /mail; … }`. 게이트웨이는 Host 로 도메인을, `X-Forwarded-Prefix`(없으면 등록부의 경로)로 화면의 기준 경로(`<html data-base="/mail/">`)를 압니다. 웹메이커는 사이트를 다시 발행할 때 vhost 파일(`wh-<사이트ID>.conf`)을 다시 쓰므로 그때 이 명령을 다시 실행하거나 웹메이커의 vhost 템플릿에 같은 include 를 넣어 둡니다. 사이트 폴더의 `mail/index.html`(웹메이커의 안내 파일)은 프록시가 먼저 받으므로 영향이 없습니다.
3. `https://site.ariweb.net/mail/` 이 웹 메일, `/mail/admin` 이 관리자 페이지, `/mail/setup` 이 설정 화면입니다. 세션 쿠키는 `Path=/mail` 로 한정됩니다.
4. **보안 참고**: 이 방식은 웹 메일이 사이트와 같은 출처(origin)를 씁니다. 사이트에 스크립트를 넣을 수 있는 사람(사이트 소유자)은 그 사이트를 방문한 로그인 사용자의 메일 API 를 같은 출처에서 부를 수 있으므로, 사이트 소유자를 믿을 수 없는 도메인은 별도 호스트 이름(`mail.<도메인>`, `domain-nginx.sh`)을 쓰세요. 메일 프로그램은 §14 와 같이 기본 도메인의 서버 이름에 `아이디@site.ariweb.net` 으로 접속합니다.
## 16. 메일 사용량 정보 제공 API (1.0.9)
웹메이커 같은 외부 관리 시스템이 도메인·계정별 디스크 사용량·메일 수·발송량을 읽는 읽기 전용 API 입니다. 전체 설정 화면(기본 도메인)의 **사용량 API** 카드에서 키를 만들면 켜지고(한 번만 표시), 접속 허용 IP 를 적어 웹메이커 서버만 부를 수 있게 합니다. 호출은 `GET /api/usage`(전체) · `GET /api/usage/<도메인>`(상세)에 `Authorization: Bearer <키>`. 자세한 응답 필드·보안 장치·웹메이커 쪽 예제는 [docs/USAGE-API.md](USAGE-API.md).
+91
View File
@@ -0,0 +1,91 @@
# 메일 사용량 정보 제공 API (1.0.9)
웹메이커(WebHosting_AI_agent) 같은 외부 관리 시스템이 AriMail 의 **디스크 사용량·메일 수·발송량**을 도메인·계정 단위로 읽어 통합 관리할 수 있게 하는 읽기 전용 API 입니다. 구현은 `server/usage.mjs`.
## 1. 켜기 — 키 만들기
1. 전체 설정 화면(기본 도메인의 전용 포트 `https://<PUBLIC_HOST>:8791/?token=…`, 또는 관리자 페이지 › 메일 서비스 설정 `/setup`)의 **사용량 API** 카드에서 **키 만들기**를 누릅니다.
2. 키(`au_…`, 32바이트 난수)는 **그때 한 번만** 보입니다. 웹메이커의 설정에 복사해 두세요. 설정 화면에는 이후 앞뒤 일부만 보입니다.
3. (권장) 같은 카드의 **접속 허용 IP** 에 웹메이커 서버의 IP 또는 CIDR 을 적고 저장합니다(쉼표 구분, 예 `203.0.113.10, 10.0.0.0/8`). 비우면 키만 맞으면 어디서나 허용합니다.
4. 키를 바꾸려면 **키 다시 만들기**(기존 키는 즉시 무효), 끄려면 **키 삭제**. 키는 기본 도메인의 설정 파일(`data/config.json` 의 `USAGE_API_KEY`)에만 저장되고 서버 하나에 하나입니다(모든 도메인을 한 키로 봄).
API 로도 할 수 있습니다(전용 포트의 토큰 또는 관리자 세션): `POST /api/setup/usage-key {"action":"generate"}` → `{ ok, key, endpoint }`, `{"action":"revoke"}` → `{ ok, enabled:false }`. 허용 IP 는 `PUT /api/setup/config {"values":{"USAGE_API_ALLOW":"…"}}`.
## 2. 부르기
```
GET https://<기본 도메인 호스트>/api/usage 모든 도메인 요약
GET https://<기본 도메인 호스트>/api/usage/<도메인> 도메인 하나 상세(계정 목록·일별 추이 포함)
헤더: Authorization: Bearer <키> (또는 X-Usage-Key: <키>)
쿼리: days=30 일별 추이·계정별 기간 합계의 기간(1~366, 기본 30)
accounts=1 계정 목록 포함(요약은 기본 0, 상세는 기본 1)
series=1 일별 추이 포함(요약은 기본 0, 상세는 기본 1)
```
어느 등록 호스트로 불러도 같습니다(예 `https://webmail.ariweb.net/api/usage`). 경로 접두사 아래의 도메인(`site.example.com/mail`)은 `https://site.example.com/mail/api/usage`.
```bash
curl -s -H 'Authorization: Bearer au_…' 'https://mail.arihakdang.com/api/usage?days=30' | jq .totals
curl -s -H 'Authorization: Bearer au_…' 'https://mail.arihakdang.com/api/usage/ariweb.net?days=7'
```
## 3. 응답
전체 요약(`/api/usage`):
```json
{ "ok": true, "version": "1.0.9", "generatedAt": "…", "primary": "arihakdang.com",
"totals": { "domains": 2, "accounts": 5, "storageBytes": 12345678, "largeFileBytes": 0, "messages": 420, "sentMonth": 31, "receivedMonth": 88 },
"domains": [ { …도메인 보고서(아래, accountList·daily 없음)… } ] }
```
도메인 보고서(`/api/usage/<도메인>` 또는 `domains[]` 의 항목):
| 필드 | 뜻 |
|---|---|
| `domain`, `primary`, `enabled`, `hosts`, `authMode`(local/www/webmaker), `mailbox`, `inbound`, `outbound`, `timezone` | 도메인 정보. `enabled:false` 는 중지한 도메인 |
| `accounts` | `{ local, external, total, limit, percent, externalSource }` — 게이트웨이 계정 수, 외부(웹메이커) 계정 수, 합계, 시스템 제한(0 = 없음) |
| `storage` | `{ bytes, limit, percent, largeFiles:{count, bytes, sent} }` — 모든 메일함의 사용량(MIME 기준 원본 크기 합), 시스템 제한, 대용량 첨부파일(별도 저장) |
| `messages` | `{ total, inbound, outbound, drafts, failed }` — 저장된 메일 수(휴지통 포함, 영구 삭제 제외) |
| `traffic` | `{ day, week, month }` 각각 `{ sent, received, total, sentBytes, receivedBytes, start, end }` — 통계 시간대(`STATS_TIMEZONE`, 기본 Asia/Seoul) 기준 오늘·이번 주(월~일)·이달. `monthlyLimit`, `monthlyPercent` 는 월간 통수 제한 대비 |
| `period` | `{ days, start, end }` — 아래 `daily`·계정별 기간 합계의 범위(`daily` 또는 `accountList` 를 포함할 때만 있음) |
| `daily[]` | `{ date:"YYYY-MM-DD", sent, received, sentBytes, receivedBytes }` 오래된 날부터 `days` 개 |
| `accountList[]` | `{ login, name, mailbox, role, source, status, messages, bytes, limit, percent, lastLoginAt, sent, received, sentBytes, receivedBytes }` — 계정(메일함)마다. `source` 는 local(게이트웨이 계정) / www(연결된 웹 서비스 관리자) / webmaker(웹메이커 계정) / orphan(계정은 지웠지만 메일이 남은 메일함). `sent`·`received`·`*Bytes` 는 `period` 안의 합계, `bytes`·`limit` 은 현재 사용량과 적용 중인 최대 크기(0 = 없음) |
| `limits` | `{ system, admin }` — 설치 시 시스템 제한(`LIMIT_*`)과 관리자 페이지의 제한 값(안내용) |
"발신" 은 발송이 끝난 메일(`state='sent'`, 발송 시각 기준), "수신" 은 받은 시각 기준입니다. 바이트는 저장된 원본(.eml) 크기라 MIME 변환으로 늘어난 실제 크기입니다. 메일 본문·주소록·비밀번호·API 키는 어떤 응답에도 들어가지 않습니다.
오류: `usage_api_disabled`(404, 키를 만들지 않음) · `unauthenticated`(401) · `forbidden_ip`(403) · `rate_limited`(429) · `domain_not_found`(404) · `method_not_allowed`(405). 모든 응답은 `Cache-Control: no-store`. 전체 요약에서 어느 도메인의 조회가 실패하면 그 항목만 `{ domain, error:'server_error' }` 로 표시되고 합계에서 빠집니다. `accounts.externalSource.error` 에는 웹메이커 파일을 읽지 못한 사유(파일 경로 포함)가 그대로 들어갑니다.
## 4. 보안 대책 — 웹메이커의 해당 기능에서만 접근
| 장치 | 내용 |
|---|---|
| 비밀 키 | 32바이트 난수(`au_…`). 전체 설정 화면에서만 만들고 한 번만 표시, 비교는 상수 시간. 키가 없으면 API 자체가 404 |
| 접속 IP 제한 | `USAGE_API_ALLOW`(IP/CIDR 목록)에 웹메이커 서버만 적어 두면 키가 유출돼도 다른 곳에서는 403. 접속 IP 는 클라이언트가 넣을 수 있는 헤더가 아니라 nginx 가 보증하는 값 — `X-Real-IP`(1.0.9 템플릿) 또는 `X-Forwarded-For` 의 **마지막** 주소(nginx 가 덧붙인 실제 접속 주소) — 로 정합니다. Cloudflare 프록시 뒤라면 실제 접속 주소가 Cloudflare 이므로 환경 변수 `USAGE_TRUST_CF=1` 을 두어 `CF-Connecting-IP` 를 믿게 하거나(원본 서버 접근을 Cloudflare IP 로 제한한 경우만) IP 제한 대신 키만 쓰세요. 저장 때 항목 형식을 검사해 오타가 있으면 422 로 거부합니다 |
| 시도 제한 | 키가 틀린 요청 IP 마다 15분 10회(그 뒤 429), 성공 요청도 IP 마다 1분 120회 |
| 읽기 전용·최소 정보 | GET 만, 수(통·바이트)·제한·계정 이름만. 쓰기 동작 없음. 세션 쿠키와 무관(쿠키로는 못 부름) |
| 기록 | 성공 호출은 감사 기록(`audit_log`, actor `usage-api`), 거부(틀린 키·허용 밖 IP·빈도 초과)는 서버 로그에 IP·사유와 함께 남음 |
| 키를 만들 수 있는 사람 | 전체 설정 화면(전용 포트 토큰) 또는 기본 도메인 관리자 세션. 기본 도메인 관리자는 모든 도메인의 계정 목록·사용량을 볼 수 있는 키를 만들 수 있으므로 운영자만 두세요 |
| 전송 | HTTPS(nginx) 로만 노출. 키 교체·삭제는 즉시 적용(재시작 없음) |
웹메이커 쪽에는 키를 비밀 보관소(`lib/vault.js`, 예: 종류 `arimail-usage`)에 두고 서버 간 호출에만 쓰세요(브라우저에 내려보내지 않음).
## 5. 웹메이커에서 쓰는 예(Node, `lib/connectors/arimail.js` 에 더할 수 있는 모양)
```js
// 설정: mail.url(= AriMail 공개 주소), 보관소 'arimail-usage' 에 키
async function usage(domain = '', { days = 30 } = {}) {
const base = String(config.get('mail.url') || '').replace(/\/+$/, '');
const key = vault.get('arimail-usage'); if (!base || !key) throw new Error('AriMail 사용량 API 주소/키 없음');
const r = await fetch(`${base}/api/usage${domain ? '/' + encodeURIComponent(domain) : ''}?days=${days}`, { headers: { Authorization: 'Bearer ' + key }, signal: AbortSignal.timeout(20000) });
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(`AriMail 사용량 API ${r.status} ${d.error || ''}`);
return d; // 전체: d.totals / d.domains[] 도메인 하나: d.storage / d.traffic / d.accountList …
}
// 예: 사이트(site.ariweb.net)의 디스크 사용량과 이달 발송량
const u = await usage('site.ariweb.net', { days: 30 });
console.log(u.storage.bytes, u.traffic.month.sent, u.accountList.map((a) => [a.mailbox, a.bytes, a.sent]));
```
웹메이커의 관리자 콘솔 › 메일 시스템 화면에서 도메인마다 `storage.bytes`(디스크), `traffic.month.sent`(이달 발송), `accounts.total`(계정 수)을 보여 주고, 사용자(사이트 관리자) 화면에는 `accountList` 에서 그 사용자의 `mailbox` 행만 골라 보여 주면 됩니다. 1분에 120회 제한이 있으므로 화면마다 호출하지 말고 1~5분 캐시를 두는 편이 좋습니다.
+12
View File
@@ -0,0 +1,12 @@
# haraka/ — AriMail 내장 Haraka (1.0.0)
Haraka 를 게이트웨이와 함께 띄워 Resend 없이 메일을 받고(MX) 보낼 수 있게 하는 묶음입니다. 자세한 설명은 `docs/SETUP.md` 12절.
| 파일 | 역할 |
|---|---|
| `package.json` | Haraka 3.3.4 + `haraka-plugin-dkim`. `deploy/haraka-apply.sh` 가 서버의 `<APP_DIR>/haraka` 에 npm 으로 설치합니다(저장소에는 node_modules 를 두지 않음). |
| `run.mjs` | 관리 프로세스(systemd `<APP_NAME>-haraka`). 설정 파일(config.json)을 5초마다 보고 수·발신 방식이 haraka 이면 `config/*` 와 `plugins/arimail.js` 를 만들어 Haraka 를 자식으로 띄우고, 아니면 끕니다. 상태는 `data/haraka-status.json`. |
| `plugins/arimail.js` | Haraka 플러그인. 로컬 제출 포트에서 온 접속은 relaying(발신), 그 밖은 수신으로 보고 RCPT 를 게이트웨이에 확인한 뒤 원본을 `POST /api/inbound/haraka` 로 넘깁니다. |
개발 PC 에서 시험하려면: `npm install --prefix <어딘가>/haraka` 로 의존성을 두고 `HARAKA_DIR=<어딘가>/haraka PORT=8790 DB_FILE=<데이터>/mail-gateway.sqlite MAILBOX=admin@example.test node haraka/run.mjs`,
설정 파일의 `HARAKA_PORT`/`HARAKA_RELAY_PORT` 를 1024 이상으로 두면 root 권한 없이 뜹니다.
+9
View File
@@ -0,0 +1,9 @@
{
"name": "arimail-haraka",
"private": true,
"description": "AriMail 내장 Haraka 메일 서버(MTA) — 수신(25) 과 발신(로컬 제출 포트 → 받는 쪽 MX 직접 배달). 설치는 deploy/haraka-apply.sh 가 서버의 <APP_DIR>/haraka 에 npm 으로 합니다.",
"dependencies": {
"Haraka": "3.3.4",
"haraka-plugin-dkim": "1.3.1"
}
}
+111
View File
@@ -0,0 +1,111 @@
'use strict';
/**
* AriMail ↔ Haraka 연결 플러그인(1.0.0, 요구사항 1). haraka/run.mjs 가 Haraka 설치 디렉터리(<APP_DIR>/haraka/plugins/)에 복사하고
* config/plugins 에 'arimail' 로 올립니다. Haraka 플러그인은 CommonJS 라 이 파일만 require 문법을 씁니다.
*
* 하는 일(세 개의 훅)
* connect : 127.0.0.1 에서 "제출 포트(relay_port)" 로 들어온 접속은 게이트웨이가 보내는 메일이므로 connection.relaying = true 로 둡니다.
* relaying 이 켜진 접속은 Haraka 가 받는 쪽 MX 를 찾아 직접 배달하는 "발신" 경로(outbound 큐)로 처리합니다.
* 다른 주소·다른 포트에서 온 접속은 relaying 이 꺼진 "수신" 경로이며, 우리 도메인의 실제 계정으로 오는 메일만 받습니다(오픈 릴레이 방지).
* rcpt : 수신 접속의 받는 사람마다 (1) 우리 도메인인지, (2) 게이트웨이에 그 메일함이 있는지(GET /api/inbound/haraka/rcpt) 확인합니다.
* 없는 사용자는 RCPT 단계에서 바로 거부해(550) 받아 놓고 반송하는 역산란(backscatter)을 만들지 않습니다.
* queue : 수신한 메일 원본을 모아 게이트웨이의 POST /api/inbound/haraka 로 넘깁니다(헤더: 토큰, 트랜잭션 id, 봉투 보낸 사람/받는 사람).
* 게이트웨이가 저장하면 250(OK), 메일함이 가득 차 건너뛰면 452(DENYSOFT — 보낸 쪽이 나중에 다시 시도), 게이트웨이 오류면 451(DENYSOFT).
*
* 설정(config/arimail.ini, run.mjs 가 만듭니다)
* [main] gateway=http://127.0.0.1:8790 token=<HARAKA_TOKEN> relay_port=2525 domains=example.com[,other.com] timeout_ms=30000
*/
const http = require('node:http');
const { URL } = require('node:url'); // Haraka 플러그인 샌드박스에는 전역 URL 이 없습니다
const { Writable } = require('node:stream');
exports.register = function () {
this.load_arimail_ini();
this.register_hook('connect', 'mark_relay');
this.register_hook('rcpt', 'check_rcpt');
this.register_hook('queue', 'deliver');
};
/** arimail.ini 를 읽고, 파일이 바뀌면 다시 읽습니다(Haraka 의 config 감시 기능). */
exports.load_arimail_ini = function () {
this.cfg = this.config.get('arimail.ini', { booleans: [] }, () => this.load_arimail_ini());
const m = this.cfg.main || {};
this.gateway = String(m.gateway || 'http://127.0.0.1:8790').replace(/\/+$/, '');
this.token = String(m.token || '');
this.relayPort = Number(m.relay_port || 2525);
this.domains = String(m.domains || '').split(/[,\s]+/).map((d) => d.trim().toLowerCase()).filter(Boolean);
this.timeoutMs = Number(m.timeout_ms || 30000);
};
const isLoopback = (ip) => ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
// Haraka 3 의 주소 객체(address-rfc2821)는 address 가 속성(문자열)입니다. 예전 판은 메서드였으므로 둘 다 받습니다.
const addrOf = (a) => (a ? String(typeof a.address === 'function' ? a.address() : a.address || '') : '');
/** connect: 로컬 제출 포트로 온 접속은 발신(relaying). */
exports.mark_relay = function (next, connection) {
const port = Number(connection.local && connection.local.port);
if (isLoopback(connection.remote && connection.remote.ip) && port === this.relayPort) {
connection.relaying = true;
connection.loginfo(this, `relay from gateway (local port ${port})`);
}
next();
};
/** 게이트웨이 API 호출(JSON). 실패는 reject. */
exports.gateway_request = function (method, path, headers, body) {
return new Promise((resolve, reject) => {
const url = new URL(this.gateway + path);
const req = http.request({ hostname: url.hostname, port: url.port || 80, path: url.pathname + url.search, method, headers: { 'X-AriMail-Token': this.token, ...headers } }, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => { let data = null; try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { data = null; } resolve({ status: res.statusCode, data }); });
});
req.setTimeout(this.timeoutMs, () => req.destroy(new Error('gateway timeout')));
req.on('error', reject);
if (body) req.end(body); else req.end();
});
};
/** rcpt: 수신 접속의 받는 사람 확인. */
exports.check_rcpt = function (next, connection, params) {
if (connection.relaying) return next(); // 발신(게이트웨이 제출)은 어떤 받는 사람이든 허용
const rcpt = params[0];
const addr = addrOf(rcpt).toLowerCase(), domain = String(rcpt.host || addr.split('@')[1] || '').toLowerCase();
if (!this.domains.includes(domain)) return next(DENY, 'Relay access denied');
this.gateway_request('GET', '/api/inbound/haraka/rcpt?addr=' + encodeURIComponent(addr), {})
.then(({ status, data }) => {
if (status === 200 && data && data.local) return next(OK);
if (status === 200 && data && data.local === false) { connection.loginfo(this, `rcpt rejected (no such user): ${addr}`); return next(DENY, 'No such user here'); }
connection.logwarn(this, `gateway rcpt check failed: HTTP ${status}`); next(DENYSOFT, 'Mailbox lookup temporarily unavailable');
})
.catch((e) => { connection.logerror(this, `gateway rcpt check error: ${e.message}`); next(DENYSOFT, 'Mailbox lookup temporarily unavailable'); });
};
/** queue: 원본을 모아 게이트웨이로 넘깁니다. */
exports.deliver = function (next, connection) {
const txn = connection.transaction;
if (!txn) return next();
const chunks = [];
const sink = new Writable({ write(chunk, _enc, cb) { chunks.push(chunk); cb(); } });
sink.on('error', (e) => { connection.logerror(this, `message stream error: ${e.message}`); next(DENYSOFT, 'Temporary failure reading message'); });
sink.on('finish', () => {
const eml = Buffer.concat(chunks);
const rcpt = (txn.rcpt_to || []).map((r) => addrOf(r).toLowerCase());
const from = addrOf(txn.mail_from);
const headers = { 'Content-Type': 'message/rfc822', 'Content-Length': String(eml.length), 'X-AriMail-Id': String(txn.uuid || ''), 'X-AriMail-From': from, 'X-AriMail-Rcpt': JSON.stringify(rcpt) };
this.gateway_request('POST', '/api/inbound/haraka', headers, eml)
.then(({ status, data }) => {
if (status >= 200 && status < 300 && data && data.ok) {
if (data.skipped === 'quota_exceeded' || data.skipped === 'storage_full') { connection.lognotice(this, `mailbox full: ${rcpt.join(',')}`); return next(DENYSOFT, 'Mailbox full, try again later'); }
connection.loginfo(this, `delivered to gateway${data.imported ? ' id=' + data.id : ' (' + (data.skipped || 'skipped') + ')'}`);
return next(OK, 'Message accepted for delivery');
}
connection.logerror(this, `gateway rejected: HTTP ${status} ${data && data.error ? data.error : ''}`);
next(DENYSOFT, 'Delivery to mailbox temporarily failed');
})
.catch((e) => { connection.logerror(this, `gateway delivery error: ${e.message}`); next(DENYSOFT, 'Delivery to mailbox temporarily failed'); });
});
// haraka-message-stream 의 옵션 뜻(3.1+): dot_stuffed:true = 저장된 SMTP 점 스터핑을 풀어 "로컬 전달용" 원본으로 내보냄(false 는 SMTP 중계용으로 그대로 둠).
// 이름이 직관과 반대이므로 주의. 줄 끝은 CRLF 로 맞춥니다(게이트웨이 해석기 기준).
txn.message_stream.pipe(sink, { line_endings: '\r\n', dot_stuffed: true });
};
+198
View File
@@ -0,0 +1,198 @@
/**
* 내장 Haraka 관리 프로세스(1.0.0, 요구사항 1) — systemd 서비스 <APP_NAME>-haraka 가 실행합니다(deploy/systemd/mail-haraka.service.in).
*
* 왜 따로 두나
* 게이트웨이 프로세스는 systemd 의 NoNewPrivileges 아래에서 돌아 다른 서비스를 켜고 끌 수 없습니다(sudo 불가). 대신 이 관리 프로세스가
* 항상 떠 있으면서 설정 파일(config.json, config.mjs)을 5초마다 보고, 수신 또는 발신 방식이 haraka 이면 Haraka 설정을 만들어 Haraka 를 자식
* 프로세스로 띄우고, 아니면 끕니다. 그래서 운영자는 웹 화면에서 방식만 고르면 되고 서버에서 명령을 칠 일이 없습니다.
*
* 하는 일
* 1) 설정 읽기: CONFIG_FILE(없으면 DB 파일 옆 config.json). 값이 없으면 환경 변수(HARAKA_*)와 기본값을 씁니다.
* 2) Haraka 설정 만들기: <HARAKA_DIR>/config/{smtp.ini, plugins, me, arimail.ini, tls.ini, dkim.ini, dkim.private.key, outbound.ini} 와 plugins/arimail.js.
* 내용이 바뀐 파일만 다시 쓰고, 바뀌었으면 Haraka 를 다시 띄웁니다.
* 3) Haraka 실행: node <HARAKA_DIR>/node_modules/Haraka/bin/haraka -c <HARAKA_DIR>. 죽으면 5초→최대 60초 간격으로 다시 띄웁니다.
* 4) 상태 기록: 데이터 디렉터리의 haraka-status.json 에 실행 여부·pid·대기 포트·최근 로그를 30초마다(변화가 있으면 즉시) 씁니다.
* 게이트웨이(setup.mjs harakaStatus)와 설정 화면이 이 파일로 상태를 보여 줍니다.
*
* 환경 변수(gateway.env 를 그대로 받음): DB_FILE 또는 CONFIG_FILE, HARAKA_DIR(기본 데이터 디렉터리 옆 haraka/), PORT(게이트웨이 HTTP 포트), MAILBOX(메일 도메인),
* PUBLIC_HOST(기본 호스트 이름), POP_TLS_CERT/POP_TLS_KEY(있으면 Haraka STARTTLS 에 같은 인증서), HARAKA_LOGLEVEL(기본 info).
*
* 다중 도메인(1.0.7): 데이터 디렉터리의 도메인 등록부(domains.json)와 각 추가 도메인의 설정 파일(domains/<도메인>/config.json)도 읽습니다.
* - 수신 방식이 haraka 인 모든(켜진) 도메인을 arimail.ini 의 domains= 에 넣어 Haraka 가 그 도메인들의 메일을 받습니다(게이트웨이가 받는 사람 주소로 도메인을 나눔).
* - 발신 방식이 haraka 이고 DKIM 키가 있는 도메인마다 config/dkim/<도메인>/{private,selector} 를 만들어 haraka-plugin-dkim 이 보낸 사람 도메인에 맞는 키로 서명합니다.
* - 포트·호스트 이름·게이트웨이 토큰은 기본 도메인(환경 파일의 도메인) 설정을 씁니다. Haraka 는 서버에 하나입니다.
*/
import { spawn } from 'node:child_process';
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, copyFileSync, statSync, readdirSync, rmSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const DB_FILE = process.env.DB_FILE || join(process.cwd(), 'data', 'mail-gateway.sqlite');
const CONFIG_FILE = process.env.CONFIG_FILE || join(dirname(DB_FILE), 'config.json');
const DATA_DIR = dirname(CONFIG_FILE);
const HARAKA_DIR = process.env.HARAKA_DIR || join(DATA_DIR, '..', 'haraka');
const STATUS_FILE = join(DATA_DIR, 'haraka-status.json');
const HARAKA_BIN = join(HARAKA_DIR, 'node_modules', 'Haraka', 'bin', 'haraka');
const CHECK_MS = Number(process.env.HARAKA_CHECK_MS || 5000);
const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a);
let child = null, startedAt = null, restarts = 0, lastExit = null, backoff = 5000, restartTimer = null, stopping = false, expectedExit = false;
const lastLog = []; const remember = (line) => { for (const l of String(line).split(/\r?\n/)) if (l.trim()) { lastLog.push(l.slice(0, 300)); if (lastLog.length > 15) lastLog.shift(); } };
let lastStatusWrite = 0, lastStatusJson = '';
/** 설정 파일 + 환경 변수 → 이 프로세스가 쓰는 값. 파일을 못 읽으면 null(Haraka 를 띄우지 않음). */
function readSettings() {
let cfg = {};
try { cfg = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') log('설정 파일 읽기 실패:', e.message); return null; }
const pick = (key, fallback) => (cfg[key] !== undefined && cfg[key] !== '' && cfg[key] !== null ? cfg[key] : process.env[key] !== undefined && process.env[key] !== '' ? process.env[key] : fallback);
const mailbox = String(process.env.MAILBOX || 'admin@arihakdang.com');
const domain = mailbox.split('@')[1] || 'localhost';
const hostname = String(pick('HARAKA_HOSTNAME', process.env.PUBLIC_HOST || 'mail.' + domain)).trim();
// 1.0.7: 등록부의 추가 도메인(켜진 것만)과 그 설정 파일. 등록부가 없으면 기본 도메인 하나(1.0.5 까지와 같음).
const all = [{ domain, cfg }];
try {
const reg = JSON.parse(readFileSync(join(DATA_DIR, 'domains.json'), 'utf8'));
for (const [d, entry] of Object.entries(reg.domains || {})) {
if (d === reg.primary || d === domain || !entry || entry.enabled === false) continue;
let c = {}; try { c = JSON.parse(readFileSync(join(DATA_DIR, 'domains', d, 'config.json'), 'utf8')); } catch { c = {}; }
all.push({ domain: d, cfg: c });
}
} catch { /* 등록부 없음 = 단일 도메인 */ }
const inboundDomains = all.filter((x) => x.cfg.inbound === 'haraka').map((x) => x.domain);
// 서명 키: 발신 haraka + DKIM 키가 있는 도메인. 기본 도메인은 설정 파일에 없으면 환경 변수(HARAKA_DKIM_*)도 봅니다(예전 방식).
const signers = all.filter((x) => x.cfg.outbound === 'haraka').map((x) => ({ domain: x.domain, selector: String((x.domain === domain ? pick('HARAKA_DKIM_SELECTOR', 'arimail') : x.cfg.HARAKA_DKIM_SELECTOR) || 'arimail'), privateKey: String((x.domain === domain ? pick('HARAKA_DKIM_PRIVATE', '') : x.cfg.HARAKA_DKIM_PRIVATE) || '') })).filter((x) => x.privateKey);
const inbound = inboundDomains.length > 0, outbound = all.some((x) => x.cfg.outbound === 'haraka');
const tlsCert = process.env.POP_TLS_CERT, tlsKey = process.env.POP_TLS_KEY;
const tls = tlsCert && tlsKey && existsSync(tlsCert) && existsSync(tlsKey);
const primarySigner = signers.find((x) => x.domain === domain) || signers[0] || null;
return {
enabled: inbound || outbound, inbound, outbound, domain, domains: inboundDomains, signers, hostname,
port: Number(pick('HARAKA_PORT', 25)), relayPort: Number(pick('HARAKA_RELAY_PORT', 2525)),
token: String(pick('HARAKA_TOKEN', '')), gateway: `http://127.0.0.1:${Number(process.env.PORT || 8790)}`,
// dkimSelector/dkimPrivate: 단일 도메인 폴백([sign] 절). 도메인별 디렉터리(config/dkim/<도메인>)가 먼저 적용됩니다.
dkimSelector: primarySigner ? primarySigner.selector : String(pick('HARAKA_DKIM_SELECTOR', 'arimail')), dkimPrivate: primarySigner ? primarySigner.privateKey : '',
tls: tls ? { cert: tlsCert, key: tlsKey } : null, loglevel: String(process.env.HARAKA_LOGLEVEL || 'info'),
};
}
/** Haraka 설정 파일 내용. 키는 <HARAKA_DIR> 기준 상대 경로. */
function renderFiles(s) {
const files = {};
// 수신 포트는 모든 주소(IPv6 포함 [::0]), 제출 포트는 루프백만. 발신만 쓰면 25 를 열 필요가 없지만 수신·발신을 바꾸는 순간 재시작 없이 동작하도록 둘 다 엽니다.
const listen = [...(s.inbound ? [`[::0]:${s.port}`] : []), `127.0.0.1:${s.relayPort}`].join(',');
files['config/smtp.ini'] = `; AriMail 이 만든 파일 — 직접 고치지 마세요(haraka/run.mjs 가 설정 파일로 다시 만듭니다)\nlisten=${listen}\nnodes=1\ndaemonize=false\nspool_dir=${join(HARAKA_DIR, 'queue')}\n`;
files['config/me'] = s.hostname + '\n';
files['config/plugins'] = ['; AriMail 이 만든 파일', ...(s.tls ? ['tls'] : []), 'arimail', ...(s.outbound && s.signers.length ? ['dkim'] : [])].join('\n') + '\n';
// domains=: 수신 방식이 haraka 인 모든 도메인(1.0.7). 하나도 없으면(발신만) 기본 도메인만 적어 두되 수신 포트는 열지 않습니다.
files['config/arimail.ini'] = `[main]\ngateway=${s.gateway}\ntoken=${s.token}\nrelay_port=${s.relayPort}\ndomains=${(s.domains.length ? s.domains : [s.domain]).join(',')}\ntimeout_ms=30000\n`;
files['config/tls.ini'] = s.tls ? `key=${s.tls.key}\ncert=${s.tls.cert}\n[outbound]\n` : `; TLS 인증서 없음 — POP_TLS_CERT/POP_TLS_KEY 가 읽히면 STARTTLS 를 켭니다\n[outbound]\n`;
files['config/outbound.ini'] = `received_header=AriMail\nenable_tls=true\n`;
files['config/log.ini'] = `level=${s.loglevel}\ntimestamps=false\nformat=default\n`;
// 1.0.7: 단일 도메인 폴백([sign] 의 domain/selector + dkim.private.key)은 쓰지 않습니다 — 키가 없는 다른 도메인의 메일이 기본 도메인 키(d=기본 도메인)로 서명되어
// DMARC 정렬이 조용히 깨지기 때문입니다. 키는 도메인별 디렉터리(config/dkim/<도메인>/)에만 두고, 키 없는 도메인은 서명하지 않습니다.
files['config/dkim.ini'] = `[sign]\nenabled=${s.outbound && s.signers.length ? 'true' : 'false'}\nheaders=From, Sender, Reply-To, Subject, Date, Message-ID, To, Cc, MIME-Version, Content-Type, Content-Transfer-Encoding\n[canon]\nbody=relaxed\n[verify]\nenabled=true\n`;
// 도메인별 서명 키(1.0.7): haraka-plugin-dkim 은 보낸 사람 도메인의 config/dkim/<도메인>/ 디렉터리에서 키를 찾습니다.
for (const x of s.signers) { files[`config/dkim/${x.domain}/selector`] = x.selector + '\n'; files[`config/dkim/${x.domain}/private`] = x.privateKey.endsWith('\n') ? x.privateKey : x.privateKey + '\n'; }
files['plugins/arimail.js'] = readFileSync(join(HERE, 'plugins', 'arimail.js'), 'utf8');
return files;
}
/**
* Haraka 기본 설정 파일 채우기. `haraka -c <dir>` 는 <dir>/config 만 보므로 Haraka 패키지의 기본 설정(connection.ini·outbound.ini·host_list …)이 없으면
* 핵심 코드가 cfg.message 같은 값을 못 읽어 접속 때 예외를 냅니다(`haraka -i` 가 하는 일과 같음). 우리가 만드는 파일은 덮어쓰고, 그 밖의 기본 파일은 없을 때만 복사합니다.
*/
function seedDefaults(ours) {
const src = join(HARAKA_DIR, 'node_modules', 'Haraka', 'config'); let entries = [];
try { entries = readdirSync(src); } catch { return; }
for (const name of entries) {
const rel = 'config/' + name; if (ours[rel]) continue;
const dst = join(HARAKA_DIR, rel);
try { if (!existsSync(dst) && statSync(join(src, name)).isFile()) copyFileSync(join(src, name), dst); } catch (e) { log('기본 설정 복사 실패', name, e.message); }
}
}
/** 바뀐 파일만 씁니다. 하나라도 바뀌면 true. */
function writeFiles(files) {
let changed = false;
mkdirSync(join(HARAKA_DIR, 'config'), { recursive: true }); mkdirSync(join(HARAKA_DIR, 'plugins'), { recursive: true }); mkdirSync(join(HARAKA_DIR, 'queue'), { recursive: true });
seedDefaults(files);
for (const [rel, content] of Object.entries(files)) {
const file = join(HARAKA_DIR, rel); let current = null;
mkdirSync(dirname(file), { recursive: true }); // config/dkim/<도메인>/ 같은 하위 디렉터리(1.0.7)
try { current = readFileSync(file, 'utf8'); } catch { current = null; }
if (current === content) continue;
writeFileSync(file, content, { mode: rel.includes('private') || rel.includes('arimail.ini') ? 0o600 : 0o644 }); changed = true;
try { chmodSync(file, rel.includes('private') || rel.includes('arimail.ini') ? 0o600 : 0o644); } catch { /* 무시 */ }
}
// 더는 서명하지 않는 도메인의 키 디렉터리는 지웁니다(도메인 삭제·발신 방식 변경). config/dkim 은 이 프로그램이 전부 관리하는 디렉터리입니다.
const keep = new Set(Object.keys(files).map((rel) => rel.match(/^config\/dkim\/([^/]+)\//)?.[1]).filter(Boolean));
// 1.0.5 까지 쓰던 단일 도메인 키 파일은 지웁니다(폴백 서명 방지).
try { if (existsSync(join(HARAKA_DIR, 'config', 'dkim.private.key'))) { rmSync(join(HARAKA_DIR, 'config', 'dkim.private.key')); changed = true; } } catch { /* 무시 */ }
try { for (const name of readdirSync(join(HARAKA_DIR, 'config', 'dkim'))) { const dir = join(HARAKA_DIR, 'config', 'dkim', name); if (!keep.has(name) && statSync(dir).isDirectory()) { rmSync(dir, { recursive: true, force: true }); changed = true; } } } catch { /* 디렉터리 없음 */ }
return changed;
}
function installed() { return existsSync(HARAKA_BIN); }
function harakaVersion() { try { return JSON.parse(readFileSync(join(HARAKA_DIR, 'node_modules', 'Haraka', 'package.json'), 'utf8')).version; } catch { return null; } }
function startHaraka(s) {
if (child || stopping) return;
log(`Haraka 시작: ${HARAKA_BIN} -c ${HARAKA_DIR} (수신 ${s.inbound ? s.port : '꺼짐'} · 제출 127.0.0.1:${s.relayPort})`);
child = spawn(process.execPath, [HARAKA_BIN, '-c', HARAKA_DIR], { cwd: HARAKA_DIR, env: { ...process.env, HARAKA: HARAKA_DIR }, stdio: ['ignore', 'pipe', 'pipe'] });
startedAt = new Date().toISOString();
child.stdout.on('data', (c) => { process.stdout.write(c); remember(c.toString('utf8')); });
child.stderr.on('data', (c) => { process.stderr.write(c); remember(c.toString('utf8')); });
child.on('exit', (code, signal) => {
lastExit = { code: code ?? signal, at: new Date().toISOString() }; const uptime = Date.now() - Date.parse(startedAt);
child = null; startedAt = null; log(`Haraka 종료: ${code ?? signal}`);
if (stopping) return;
// 우리가 끈 것(설정 변경·사용 중지)은 장애가 아니므로 재시작 횟수·대기 시간을 늘리지 않고 다음 점검(tick)에서 필요하면 바로 다시 띄웁니다.
if (expectedExit) { expectedExit = false; backoff = 5000; writeStatus(readSettings(), true); return; }
// 스스로 죽었으면: 오래 잘 돌다 죽었으면 5초 뒤, 금방 죽었으면 간격을 늘려 다시 띄웁니다(설정 오류로 무한 재시작 폭주 방지).
backoff = uptime > 60000 ? 5000 : Math.min(60000, backoff * 2); restarts++;
restartTimer = setTimeout(() => { restartTimer = null; const s2 = readSettings(); if (s2?.enabled && installed()) startHaraka(s2); }, backoff);
writeStatus(readSettings(), true);
});
writeStatus(s, true);
}
function stopHaraka(reason) {
if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; }
if (!child) return;
log(`Haraka 중지(${reason})`);
expectedExit = true; const c = child; c.kill('SIGTERM'); setTimeout(() => { try { c.kill('SIGKILL'); } catch { /* 이미 끝남 */ } }, 8000).unref();
}
/** 상태 파일 쓰기. 내용이 같으면 30초마다 updatedAt 만 갱신(살아 있음 표시). */
function writeStatus(s, force = false) {
const status = {
enabled: !!s?.enabled, inbound: !!s?.inbound, outbound: !!s?.outbound, installed: installed(), version: harakaVersion(),
running: !!child, pid: child?.pid || null, startedAt, restarts, lastExit, lastLog,
listen: s ? [...(s.inbound ? [`0.0.0.0:${s.port}`] : []), `127.0.0.1:${s.relayPort}`] : [], hostname: s?.hostname || null, domain: s?.domain || null,
// 1.0.7: 수신하는 도메인 목록과 DKIM 서명 도메인 목록(설정 화면의 Haraka 상태에 보임)
domains: s?.domains || [], signers: (s?.signers || []).map((x) => x.domain),
dkim: !!(s?.signers?.length), tls: !!s?.tls, configDir: join(HARAKA_DIR, 'config'), updatedAt: new Date().toISOString(),
};
const json = JSON.stringify({ ...status, updatedAt: null });
if (!force && json === lastStatusJson && Date.now() - lastStatusWrite < 30000) return;
try { writeFileSync(STATUS_FILE + '.tmp', JSON.stringify(status, null, 1)); chmodSync(STATUS_FILE + '.tmp', 0o644); copyFileSync(STATUS_FILE + '.tmp', STATUS_FILE); } catch (e) { log('상태 파일 쓰기 실패:', e.message); }
lastStatusJson = json; lastStatusWrite = Date.now();
}
let lastFiles = null;
function tick() {
const s = readSettings();
if (!s || !s.enabled) { if (child) stopHaraka('설정에서 Haraka 를 쓰지 않음'); writeStatus(s); return; }
if (!installed()) { writeStatus(s); return; }
if (!s.token) { log('HARAKA_TOKEN 이 없어 기다립니다(게이트웨이가 처음 뜨면 만듭니다)'); writeStatus(s); return; }
const files = renderFiles(s);
const changed = writeFiles(files);
const key = JSON.stringify(files);
if (child && (changed || (lastFiles && lastFiles !== key))) { log('설정이 바뀌어 Haraka 를 다시 띄웁니다'); stopHaraka('설정 변경'); }
lastFiles = key;
if (!child && !restartTimer) startHaraka(s);
writeStatus(s);
}
log(`AriMail Haraka 관리 시작 — 설정 ${CONFIG_FILE}, Haraka ${HARAKA_DIR}${installed() ? ' (v' + harakaVersion() + ')' : ' (미설치: deploy/haraka-apply.sh 로 설치)'}`);
tick(); const timer = setInterval(tick, CHECK_MS);
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { stopping = true; clearInterval(timer); stopHaraka(sig); writeStatus(readSettings(), true); setTimeout(() => process.exit(0), child ? 2000 : 0); });
+666
View File
@@ -0,0 +1,666 @@
{
"title": "아리학당 메일 게이트웨이",
"signIn": "접속",
"loginId": "아이디 또는 이메일",
"loginHelpBtn": "접속 안내",
"loginHelpTitle": "{host} 접속 안내",
"loginHelpIntro": "{host} 은(는) {domain} 도메인의 메일 서비스입니다. 이 도메인으로 받은 메일을 보고 보내는 웹 메일함이며, 메일 프로그램(IMAP·POP·SMTP)에서도 같은 계정으로 쓸 수 있습니다.",
"loginHelp1": "아이디: 부여받은 계정 이름(예: {example}) 또는 도메인을 포함한 이메일 주소(예: {example}@{domain})를 입력합니다. 둘 다 같은 계정입니다.",
"loginHelp2": "비밀번호: 웹 접속에 쓰는 계정의 비밀번호를 그대로 입력합니다. 메일 전용 비밀번호는 따로 없습니다.",
"loginHelp3": "{www} 관리자는 www 사이트에 로그인할 때 쓰는 아이디(또는 이메일)와 비밀번호를 그대로 씁니다. admin 또는 admin@{domain} 으로도 접속할 수 있습니다.",
"loginHelp4": "관리자가 만들어 준 계정은 전달받은 아이디와 비밀번호를 씁니다. 비밀번호를 바꾸거나 잊었으면 관리자에게 문의하세요.",
"loginHelp5": "여기서 쓰는 아이디·비밀번호는 메일 프로그램의 받는 서버(IMAP·POP)와 보내는 서버(SMTP) 설정에도 그대로 씁니다. 설정 값은 로그인 후 설정 화면에서 볼 수 있습니다.",
"loginHelpFooter": "접속이 되지 않으면 아이디에 도메인까지 포함해 다시 시도하고, 그래도 안 되면 관리자에게 계정 상태를 확인해 달라고 하세요.",
"adminLoginTitle": "관리자 페이지 로그인",
"adminLoginHelp": "이 화면({host}/admin)은 관리자 페이지입니다. 관리자 역할 계정으로만 들어갈 수 있고, 웹 메일 로그인과는 별도로 다시 로그인합니다.",
"admin_only": "관리자 역할 계정만 관리자 페이지에 들어갈 수 있습니다.",
"admin_login_required": "계정 관리는 관리자 페이지({host}/admin)에서 로그인한 뒤 쓸 수 있습니다.",
"adminTitle": "관리자",
"password": "비밀번호",
"logout": "로그아웃",
"close": "닫기",
"mail": "메일",
"settings": "설정",
"mail_inbox": "받은 메일함",
"mail_sent": "보낸 메일함",
"mail_drafts": "초안 작성중",
"mail_trash": "휴지통",
"compose": "메일 쓰기",
"mailBody": "본문",
"mailSearch": "보낸 사람, 받는 사람, 제목, 본문 검색",
"search": "검색",
"refresh": "새로고침",
"previous": "이전",
"next": "다음",
"empty": "내역이 없습니다.",
"loading": "불러오는 중…",
"noSubject": "제목 없음",
"from": "보내는 사람",
"to": "받는 사람",
"cc": "참조",
"mailSubject": "메일 제목",
"save": "저장",
"saved": "저장했습니다.",
"error": "처리하지 못했습니다. 입력 내용을 확인해 주세요.",
"errorCode": "처리하지 못했습니다 ({code}). 화면을 새로 고친 뒤에도 같으면 관리자에게 이 코드를 알려 주세요.",
"delete": "삭제",
"deleteConfirm": "이 항목을 삭제하시겠습니까?",
"restore": "복원",
"deleteForever": "영구 삭제",
"moveTrash": "휴지통으로 이동",
"reply": "답장",
"replyAll": "전체 답장",
"forward": "전달",
"sendMail": "메일 보내기",
"retrySend": "다시 보내기",
"mailSent": "발송했습니다.",
"draftSaved": "초안을 저장했습니다. 아직 발송되지 않았습니다.",
"attachments": "첨부파일",
"addAttachment": "파일 첨부",
"attachment_too_large": "첨부 크기가 한도를 넘습니다(한 메일에 10개, 합계 20MB 까지).",
"attachmentUnavailable": "저장하지 못한 첨부(용량 초과)",
"original": "원본(.eml) 내려받기",
"originalStored": "Resend 원본 그대로 보관",
"originalBuilt": "저장한 본문·첨부로 재구성한 원본",
"originalNone": "원본 없음",
"showImages": "외부 이미지 표시",
"hideImages": "외부 이미지 숨기기",
"markUnread": "읽지 않음으로 표시",
"popDelivered": "POP 전달됨",
"popNotDelivered": "POP 미전달",
"popReset": "POP 전달 기록 초기화(다시 내려받기)",
"popResetConfirm": "이 메일을 POP 클라이언트에 다시 내려줄 수 있도록 전달 기록을 지웁니다. 계속할까요?",
"receivedAt": "수신 시각",
"sentAt": "발송 시각",
"source_webhook": "웹훅 수신",
"source_sync": "정기 동기화",
"source_web": "웹에서 작성",
"edit_bold": "굵게",
"edit_italic": "기울임",
"edit_underline": "밑줄",
"edit_strikeThrough": "취소선",
"edit_insertOrderedList": "번호 목록",
"edit_insertUnorderedList": "글머리 목록",
"edit_removeFormat": "서식 지우기",
"insertLink": "링크",
"unlink": "링크 삭제",
"quote": "인용",
"linkUrl": "연결할 주소를 입력하세요.",
"invalidUrl": "올바른 웹 주소를 입력하세요.",
"editorToolbar": "메일 서식 도구",
"editorHelp": "서식 있는 텍스트를 붙여넣을 수 있습니다. 스크립트와 외부 스타일은 제거됩니다.",
"mailServiceOk": "메일 서비스 연결 정상",
"mailServiceError": "메일 서비스 오류",
"syncing": "Resend 목록을 확인하는 중…",
"syncStarted": "동기화를 시작했습니다. 잠시 후 목록이 갱신됩니다.",
"invalid_credentials": "아이디 또는 비밀번호를 확인해 주세요.",
"rate_limited": "시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.",
"unauthenticated": "다시 접속해 주세요.",
"validation": "받는 사람, 제목, 본문을 확인해 주세요.",
"invalid_email": "받는 사람 주소 형식을 확인해 주세요.",
"not_found": "항목을 찾을 수 없습니다.",
"mail_provider_unavailable": "메일 서비스 요청에 실패했습니다. 잠시 후 다시 시도하세요.",
"mail_not_configured": "메일 서비스 연결 설정(RESEND_API_KEY)을 확인해 주세요.",
"mail_auth_failed": "Resend API 키 인증에 실패했습니다. 서버 환경 파일의 RESEND_API_KEY가 유효한 키인지 확인해 주세요.",
"mail_forbidden": "Resend가 요청을 거부했습니다(403). API 키의 권한(전체 접근)과 도메인 소유를 확인해 주세요.",
"mail_domain_unverified": "발신 도메인이 Resend에서 검증되지 않아 메일을 보낼 수 없습니다. 관리자 › 서버 상태에서 원인(DNS 레코드)을 확인해 주세요.",
"mail_domain_missing": "발신 도메인이 Resend에 등록되어 있지 않습니다. 관리자 › 메일 서비스 설정에서 자동 설정을 실행해 주세요.",
"mail_rate_limited": "Resend 요청 한도를 초과했습니다. 잠시 후 다시 시도해 주세요.",
"mail_rejected": "Resend가 메일 내용을 거부했습니다. 받는 사람 주소, 제목, 본문을 확인해 주세요.",
"mail_locked": "발송을 시작한 내용은 수정할 수 없습니다.",
"mail_sending": "발송 처리 중입니다. 잠시 후 새로고침하세요.",
"mail_trash_first": "휴지통으로 옮긴 뒤 영구 삭제할 수 있습니다.",
"confirmCloseUnsaved": "작성 중인 메일을 저장하지 못했습니다. 그래도 닫을까요? 닫으면 저장하지 못한 내용은 사라집니다.",
"mail_move_invalid": "이 메일은 그 폴더로 옮길 수 없습니다. 받은 메일은 받은 메일함, 보낸 메일은 보낸 메일함, 초안은 임시 보관함과 휴지통으로만 옮길 수 있습니다.",
"moveSkipped": "일부 메일은 상태에 맞지 않아 옮기지 않았습니다(받은 메일 → 받은 메일함, 보낸 메일 → 보낸 메일함, 초안 → 임시 보관함).",
"message_too_large": "본문이 너무 큽니다. 내용을 줄여 주세요.",
"webhook_not_configured": "웹훅 비밀키가 설정되지 않았습니다.",
"settingsIntro": "메일 프로그램(IMAP·POP·SMTP) 연결에 필요한 값과 화면 설정입니다. 메일 프로그램에는 이 사이트의 로그인 아이디와 비밀번호를 그대로 씁니다.",
"smtpSettings": "SMTP 설정 (보내는 메일 서버)",
"smtpIntro": "메일 프로그램(Outlook, Thunderbird, Apple Mail, 스마트폰 메일 앱 등)에서 이 계정으로 메일을 보내려면 아래 값을 보내는 메일 서버(SMTP)로 등록합니다. 받는 메일 서버(IMAP·POP)와 같은 서버·같은 계정을 씁니다.",
"smtpHost": "보내는 메일 서버(SMTP 호스트)",
"smtpPortSmtps": "포트 (SSL/TLS, 권장)",
"smtpPortStarttls": "포트 (STARTTLS)",
"smtpFrom": "보내는 사람 주소",
"smtpAuth": "인증 방식",
"smtpAuthValue": "일반 비밀번호(PLAIN/LOGIN). 보안 연결(SSL/TLS 또는 STARTTLS)을 반드시 켭니다.",
"smtpSteps": "설정 순서",
"smtpStep1": "메일 프로그램의 계정 설정에서 보내는 메일 서버(SMTP)를 엽니다.",
"smtpStep2": "서버 이름에 위 SMTP 호스트를 입력하고, 포트와 보안 연결을 465 + SSL/TLS(권장) 또는 587 + STARTTLS 로 맞춥니다.",
"smtpStep3": "인증을 켜고(일반 비밀번호), 사용자 이름과 비밀번호는 이 사이트에 로그인할 때와 같게 입력합니다. 받는 메일 서버와 같은 계정이므로 \"받는 서버와 같은 설정 사용\"을 켜 두어도 됩니다.",
"smtpStep4": "보내는 사람 주소는 위의 내 메일 주소로 둡니다. 다른 주소를 보내는 사람으로 쓰면 서버가 발송을 거부합니다.",
"smtpStep5": "테스트 메일을 보내 받는 사람에게 도착하는지, 이 사이트의 보낸 메일함에 저장되는지 확인합니다.",
"smtpNotes": "참고",
"smtpNote1": "이 사이트의 비밀번호를 바꾸면 메일 프로그램의 받는 서버·보내는 서버 비밀번호도 함께 바꿉니다.",
"smtpNote2": "보낸 메일은 이 사이트의 보낸 메일함에 자동으로 저장됩니다. 메일 프로그램이 보낸 메일함에 사본을 올려도 한 통으로 합쳐집니다.",
"smtpNote3": "한 통에 받는 사람(참조·숨은 참조 포함) 수와 첨부 크기는 관리자 설정을 따릅니다(설정 화면의 \"메일함 사용량·제한\" 참고). 메일 전체 크기는 30MB 까지입니다.",
"popSettings": "POP 설정 (메일 클라이언트로 받기)",
"popIntro": "받은 메일을 메일 클라이언트로 내려받는 설정입니다. 계정은 웹 메일 로그인 계정(아이디 또는 메일함 주소 + 비밀번호)과 같습니다.",
"popHost": "받는 메일 서버(POP3 호스트)",
"popPortTls": "포트 (SSL/TLS, 권장)",
"popUser": "사용자 이름",
"popUserValue": "로그인 ID 또는 메일함 주소(예: admin 또는 admin@{domain}). 웹 메일에 로그인하는 아이디·비밀번호와 같습니다.",
"popPasswordValue": "이 사이트에 로그인할 때 쓰는 비밀번호 그대로 (비밀번호를 바꾸면 바로 적용)",
"popTlsActive": "TLS 인증서 적용됨",
"popTlsMissing": "TLS 인증서 미설정 — 995 포트가 열리지 않았습니다",
"popPlainAllowed": "일반 접속(암호화 없음) 로그인 허용",
"popPlainRequired": "STARTTLS 이후에만 로그인 (일반 접속 거부)",
"popPortPlain": "포트 (일반 접속 / STARTTLS)",
"popNoteGmail": "Gmail 의 \"다른 계정에서 메일 확인(POP3)\" 은 110 포트에서 STARTTLS(STLS)를 쓰지 않습니다. \"보안 연결(SSL) 사용\" 을 켜고 포트 995 를 쓰는 것을 권장하며, 110 으로 쓰려면 관리자 설정의 \"일반 접속 로그인 허용\" 이 켜져 있어야 합니다(꺼져 있으면 Gmail 은 사용자 이름·비밀번호 오류로 보여 줍니다).",
"popDedupe": "중복 방지: 한 번 내려받은(RETR) 메일이나 클라이언트가 삭제(DELE)한 메일은 같은 계정에 다시 내려주지 않습니다. 서버 저장본은 지우지 않으며 웹에서 영구 삭제하기 전까지 계속 볼 수 있습니다. 필요하면 메일 상세에서 전달 기록을 초기화합니다.",
"popDeliveries": "POP 전달 기록",
"popDeliveriesValue": "{count}건 · 계정 {accounts}개 · 최근 {last}",
"receiveSettings": "수신 설정 (Resend 웹훅 + 1시간 동기화)",
"receiveIntro": "받은 메일은 두 경로로 저장합니다. Resend 웹훅(email.received)이 즉시 알리고, 1시간마다 Resend 수신 목록을 다시 확인해 놓친 메일을 보충합니다. 같은 메일은 한 번만 저장합니다.",
"webhookUrl": "웹훅 주소 (Resend → Webhooks → Add Endpoint)",
"webhookEvent": "구독 이벤트",
"webhookSecret": "서명 비밀키",
"webhookSecretOk": "설정됨 (RESEND_WEBHOOK_SECRET)",
"webhookSecretMissing": "미설정 — 웹훅 요청을 거부합니다. Resend 엔드포인트의 Signing Secret(whsec_…)을 서버 환경 파일에 넣고 재시작하세요.",
"webhookEvents": "웹훅 처리 내역",
"syncInterval": "동기화 주기",
"syncIntervalValue": "{minutes}분마다 · 시작 10초 후 1회",
"syncRuns": "최근 동기화",
"syncNow": "지금 동기화",
"retention": "보관 기간",
"retentionValue": "제한 없음. 메일은 원본·첨부와 함께 휴지통에서 영구 삭제할 때까지 보관합니다. 영구 삭제한 메일은 동기화가 다시 가져오지 않습니다.",
"storage": "저장 현황",
"storageValue": "받은 메일 {total}건 · 원본 그대로 {raw}건 · 재구성 {built}건 · 웹훅 {webhook}건 · 동기화 {sync}건",
"status_ok": "완료",
"status_partial": "일부 실패",
"status_failed": "실패",
"status_running": "진행 중",
"status_stored": "저장",
"status_skipped": "건너뜀(이미 있음)",
"status_ignored": "무시",
"status_duplicate": "중복",
"minutes": "분",
"yes": "예",
"no": "아니요",
"imapSettings": "IMAP 설정 (메일 클라이언트와 동기화)",
"imapIntro": "IMAP 은 메일을 내려받아 없애는 POP 과 달리 서버의 메일함을 그대로 보여 주고, 읽음·삭제·이동 상태를 여러 기기와 웹 메일함이 함께 공유합니다. 휴대폰과 PC 에서 같은 메일함을 쓰려면 IMAP 을 권장합니다.",
"imapHost": "받는 메일 서버(IMAP 호스트)",
"imapPortTls": "포트 (SSL/TLS, 권장)",
"imapMailboxes": "메일함",
"imapMailboxesValue": "INBOX(받은 메일함) · Sent(보낸 메일함) · Drafts(초안) · Trash(휴지통). 웹 메일함의 4개 폴더와 같은 저장소이며 새 폴더는 만들 수 없습니다.",
"imapSteps": "설정 순서",
"imapStep1": "메일 클라이언트에서 계정 추가 → 수동 설정(IMAP)을 고릅니다. 이메일 주소는 자기 메일함 주소(예: admin@{domain})입니다.",
"imapStep2": "받는 서버: 위 IMAP 호스트, 포트 993, 보안 SSL/TLS. 사용자 이름은 로그인 ID 또는 메일함 주소, 비밀번호는 웹 메일 로그인 비밀번호입니다.",
"imapStep3": "보내는 서버: 아래 SMTP 설정의 값(포트 465 SSL/TLS 또는 587 STARTTLS)을 입력하고, 받는 서버와 같은 사용자 이름·비밀번호를 씁니다.",
"imapStep4": "클라이언트가 보낸 메일 복사본을 Sent 에, 작성 중인 메일을 Drafts 에, 삭제한 메일을 Trash 에 저장하도록 폴더를 지정합니다(대부분 자동 인식).",
"imapStep5": "동기화 후 받은 메일함에서 메일을 읽으면 웹 메일함에도 읽음으로 표시되고, 웹에서 휴지통으로 옮긴 메일은 클라이언트에서도 Trash 로 이동합니다.",
"imapNotes": "참고",
"imapNote1": "클라이언트에서 삭제(Trash 로 이동) → 휴지통에서 다시 삭제하면 서버에서 영구 삭제되며 같은 메일을 다시 가져오지 않습니다.",
"imapNote2": "서버의 메일은 기한 없이 보관됩니다. 클라이언트에서 지운 메일은 휴지통으로 옮겨지고, 휴지통에서 지우면 웹 메일함에서도 영구 삭제됩니다.",
"imapNote3": "POP 와 IMAP 을 같은 계정에서 함께 쓸 수 있습니다. POP 전달 기록은 IMAP 에 영향을 주지 않습니다.",
"imapTlsActive": "TLS 인증서 적용됨",
"imapTlsMissing": "TLS 인증서 미설정 — 993 포트가 열리지 않았습니다",
"imapPortPlain": "포트 (일반 접속 / STARTTLS)",
"smtpTlsMissing": "TLS 인증서 미설정 — 465 포트가 열리지 않았고 587 은 STARTTLS 이후에만 로그인됩니다",
"appName": "아리학당 메일",
"brand": "Ari Hakdang Mail",
"searchPlaceholder": "메일 검색 (보낸 사람, 제목, 본문)",
"searchChip": "검색: \"{q}\"",
"searchClear": "검색 해제",
"folder_inbox": "받은 메일함",
"folder_starred": "중요 표시",
"folder_sent": "보낸 메일함",
"folder_drafts": "임시 보관함",
"folder_trash": "휴지통",
"filters": "보기",
"filterUnread": "읽지 않음만",
"filterAttachments": "첨부 있음",
"filterAll": "전체",
"toolsSection": "도구",
"nav_settings": "설정",
"nav_admin": "계정 관리",
"nav_adminPage": "관리자 페이지",
"nav_status": "서버 상태",
"nav_mailConfig": "메일 서비스 설정",
"mailConfigIntro": "수·발신 방식, Resend 키, SMTP 중계 서버, 내장 Haraka, 사이트 정보는 설정 파일(config.json)에 있고 전용 화면에서 재시작 없이 바꿉니다.",
"mailConfigOpen": "메일 서비스 설정 열기",
"statusMailSection": "메일 서비스 (1.0.0)",
"statusInbound": "수신 방식",
"statusOutbound": "발신 방식",
"statusOutboundState": "발신 서비스 연결",
"statusHaraka": "내장 Haraka",
"statusConfigFile": "설정 파일",
"mode_resend": "Resend",
"mode_haraka": "내장 Haraka",
"mode_smtp": "외부 SMTP 중계",
"harakaOff": "사용 안 함",
"harakaNotInstalled": "미설치",
"harakaStopped": "서비스 꺼짐",
"harakaRunning": "실행 중",
"harakaWaiting": "시작 대기",
"openMailbox": "메일함 열기",
"nav_mail": "메일",
"mailboxSelect": "메일함 선택",
"usageLabel": "저장 사용량",
"retentionShort": "보관 기간 제한 없음",
"selectAll": "전체 선택",
"selected": "{count}개 선택",
"actionDelete": "삭제",
"actionRestore": "복원",
"actionDeleteForever": "영구 삭제",
"actionMarkRead": "읽음",
"actionMarkUnread": "읽지 않음",
"actionStar": "중요",
"actionUnstar": "중요 해제",
"actionMove": "이동",
"moveTo": "이동 위치",
"emptyFolder": "이 폴더에 메일이 없습니다.",
"noSelection": "왼쪽 목록에서 메일을 선택하면 여기에 내용이 표시됩니다.",
"loadingMail": "메일을 불러오는 중…",
"today": "오늘",
"yesterday": "어제",
"me": "나",
"attachmentsCount": "첨부 {count}개",
"downloadAll": "모두 내려받기",
"replyPrefix": "Re:",
"forwardPrefix": "Fwd:",
"forwardedHeader": "---------- 전달된 메일 ----------",
"quoteHeader": "{date}, {from} 작성:",
"composeTitle": "새 메일",
"replyTitle": "답장",
"forwardTitle": "전달",
"editDraft": "임시 보관 메일 편집",
"bcc": "숨은 참조",
"bccFromMailClient": "메일 프로그램에서 저장한 초안의 숨은 참조입니다. 발송할 때 함께 보냅니다(웹에서는 고칠 수 없음).",
"showCc": "참조/숨은참조",
"fromLabel": "보내는 사람",
"send": "보내기",
"saveDraft": "임시 저장",
"discard": "삭제",
"closeCompose": "닫기",
"attachFiles": "파일 첨부",
"insertInlineImage": "본문에 이미지 넣기",
"sending": "보내는 중…",
"draftAutosaved": "임시 저장됨 {time}",
"tb_undo": "실행 취소",
"tb_redo": "다시 실행",
"tb_font": "글꼴",
"tb_size": "크기",
"tb_bold": "굵게",
"tb_italic": "기울임",
"tb_underline": "밑줄",
"tb_strike": "취소선",
"tb_color": "글자색",
"tb_highlight": "강조색",
"tb_moreColors": "더 많은 색상",
"tb_noHighlight": "강조 없음",
"color_black": "검정",
"color_red": "빨강",
"color_orange": "주황",
"color_yellow": "노랑",
"color_green": "초록",
"color_blue": "파랑",
"color_purple": "보라",
"color_gray": "회색",
"color_white": "흰색",
"tb_alignLeft": "왼쪽 정렬",
"tb_alignCenter": "가운데 정렬",
"tb_alignRight": "오른쪽 정렬",
"tb_ol": "번호 목록",
"tb_ul": "글머리 목록",
"tb_outdent": "내어쓰기",
"tb_indent": "들여쓰기",
"tb_link": "링크",
"tb_unlink": "링크 제거",
"tb_quote": "인용",
"tb_hr": "구분선",
"tb_image": "이미지",
"tb_clear": "서식 지우기",
"tb_html": "HTML 편집",
"size_small": "작게",
"size_normal": "보통",
"size_large": "크게",
"size_huge": "매우 크게",
"confirmDiscard": "작성 중인 메일을 삭제할까요?",
"confirmDeleteForever": "선택한 메일을 영구 삭제할까요? 되돌릴 수 없습니다.",
"confirmEmptyTrash": "휴지통을 비울까요?",
"emptyTrash": "휴지통 비우기",
"unreadBadge": "읽지 않음 {count}",
"popTag": "POP",
"flagged": "중요",
"answered": "답장함",
"adminUsers": "계정 관리",
"adminUsersIntro": "관리자 페이지입니다. 여기서 만든 계정은 웹 메일함·IMAP·POP·SMTP 에 자기 메일함 주소와 비밀번호로 접속합니다. 메일함 주소는 로그인 ID@{domain} 으로 자동 지정되며, 계정마다 POP·IMAP 접속 허용을 켜고 끌 수 있습니다.",
"adminUsersIntroWww": "www 관리자 계정 자체는 www 사이트에서 관리합니다.",
"addUser": "계정 추가",
"editUser": "계정 수정",
"userLogin": "로그인 ID",
"userName": "이름",
"userMailbox": "메일함 주소",
"userMailboxAuto": "메일함 주소는 로그인 ID@도메인으로 자동 지정됩니다(따로 입력하지 않음).",
"userServices": "메일 프로그램",
"editMyServices": "관리자 계정 — 메일 프로그램 접속",
"wwwAdminServicesHelp": "www 관리자 계정(admin)의 이름·비밀번호는 www 사이트에서 관리합니다. 여기서는 이 계정의 POP·IMAP 접속 허용만 바꿉니다.",
"userRole": "역할",
"userStatus": "상태",
"userPassword": "비밀번호",
"userPasswordEdit": "새 비밀번호 (비우면 유지)",
"userPasswordConfirm": "새 비밀번호 확인",
"userCurrentPassword": "현재 비밀번호",
"currentPasswordHelp": "자기 계정의 비밀번호를 바꿀 때는 본인 확인을 위해 현재 비밀번호를 함께 입력합니다. 새 비밀번호는 두 번 같게 입력하세요.",
"showPassword": "비밀번호 보기",
"hidePassword": "비밀번호 숨기기",
"password_mismatch": "새 비밀번호 두 입력이 서로 다릅니다.",
"current_password_invalid": "현재 비밀번호가 맞지 않습니다.",
"current_password_required": "현재 비밀번호를 입력하세요.",
"passwordChangedRelogin": "비밀번호를 바꿨습니다. 새 비밀번호로 다시 로그인하세요.",
"userCreatedAt": "만든 날짜",
"userLastLogin": "최근 로그인",
"role_admin": "관리자",
"role_user": "사용자",
"status_active": "사용",
"status_suspended": "정지",
"deleteUser": "계정 삭제",
"confirmDeleteUser": "이 계정을 삭제할까요? 메일함의 메일은 남습니다.",
"userSaved": "계정을 저장했습니다.",
"invalid_login": "로그인 ID 는 영문 소문자·숫자·점·밑줄·하이픈 2~64자여야 합니다(www 연동 사이트에서는 admin 을 쓸 수 없음).",
"invalid_mailbox": "메일함 주소는 이 도메인의 주소여야 합니다.",
"weak_password": "비밀번호가 정책에 맞지 않습니다.",
"weakPasswordDetail": "비밀번호가 정책에 맞지 않습니다: {rules}",
"passwordRule": "비밀번호는 {rules}이어야 합니다.",
"passwordRuleLength": "{n}자 이상",
"passwordRuleMixedCase": "영문 대문자와 소문자 모두 포함",
"passwordRuleDigit": "숫자 포함",
"passwordRuleSpecial": "특수문자(!@#$ 등) 포함",
"policySection": "비밀번호 정책",
"policyIntro": "새로 정하는 비밀번호에 적용하는 규칙입니다. 이미 있는 계정의 비밀번호에는 소급하지 않으며, 계정 추가·수정과 명령줄 도구 모두 같은 규칙을 씁니다.",
"policyMinLength": "최소 글자 수",
"policyMinLengthHelp": "6자 이상 64자 이하에서 정합니다(6 미만으로는 내릴 수 없음).",
"policyMixedCase": "영문 대문자와 소문자를 모두 포함해야 함",
"policyDigit": "숫자를 포함해야 함",
"policySpecial": "특수문자를 포함해야 함",
"policySaved": "비밀번호 정책을 저장했습니다.",
"policy_invalid": "최소 글자 수는 6 이상 64 이하의 정수여야 합니다.",
"initialPasswordWarn": "초기 비밀번호(adminpasswd)를 쓰고 있습니다. 계정 관리에서 관리자 계정을 열어 비밀번호를 바꾸세요.",
"initialPasswordShort": "초기 비밀번호 사용 중",
"login_taken": "이미 사용 중인 로그인 ID 입니다.",
"cannot_delete_self": "자기 계정은 삭제할 수 없습니다.",
"forbidden": "관리자만 사용할 수 있는 기능입니다.",
"userHelpMailbox": "메일함 주소는 로그인 ID 에 따라 자동으로 정해집니다(예: info → info@{domain}). 수신 메일은 받는 사람 주소와 같은 메일함으로 들어옵니다. 0.8.4 이전에 만든 계정은 등록된 메일함 주소를 그대로 유지합니다.",
"wwwAdminNote": "www 관리자 (별칭 admin)",
"menu": "메뉴",
"back": "목록으로",
"refreshed": "새로고침했습니다.",
"openOriginal": "원본 보기",
"details": "상세 정보",
"hideDetails": "상세 정보 숨기기",
"toMe": "나에게",
"showRemote": "외부 이미지 표시",
"hideRemote": "외부 이미지 숨기기",
"remoteBlocked": "외부 이미지는 개인정보 보호를 위해 차단되었습니다.",
"linkText": "링크 텍스트",
"imageUploadFailed": "이미지를 올리지 못했습니다.",
"htmlMode": "HTML 원본 편집 중 — 다시 누르면 편집기로 돌아갑니다.",
"composeFromHelp": "보내는 주소는 로그인한 계정의 메일함 주소입니다.",
"prefsSection": "화면 기본값",
"prefRemoteImages": "외부 이미지 기본 표시",
"prefRemoteImagesHelp": "켜면 메일을 열 때 외부 이미지를 바로 불러옵니다. 끄면 차단하고, 본문 오른쪽 위 작은 버튼으로 메일마다 표시/숨기기를 바꿀 수 있습니다. 외부 이미지는 발신자에게 열람 사실을 알릴 수 있습니다.",
"prefSaved": "기본값을 저장했습니다.",
"servicesSection": "메일 프로그램 접속",
"servicesIntro": "이 계정으로 메일 프로그램(Outlook, Thunderbird, 스마트폰 메일 앱 등)이 받는 서버에 접속하는 것을 허용할지 정합니다. 끄면 그 방식의 로그인이 거부되고, 웹 메일함과 보내는 서버(SMTP)에는 영향이 없습니다.",
"prefPop": "POP3 접속 허용",
"prefImap": "IMAP 접속 허용",
"servicesHelp": "둘 다 끄면 메일 프로그램으로 받을 수 없고 웹 메일함에서만 볼 수 있습니다. 바꾸면 바로 적용되며 이미 연결된 메일 프로그램은 다음 접속부터 거부됩니다.",
"statusIntro": "{host} · 버전 {version}. 발송 서비스 연동과 저장 상태를 보여 줍니다.",
"statusServices": "서비스 포트",
"statusTls": "TLS 인증서",
"statusTlsMissing": "미설정 — 993/995/465 포트가 열리지 않았습니다(587 은 STARTTLS 이후에만 인증)",
"statusPlainLogin": "일반 접속 로그인 (110 / 143)",
"statusMailboxes": "메일함별 저장",
"showRemoteShort": "이미지 표시",
"hideRemoteShort": "이미지 숨기기",
"dropHint": "여기에 놓으면 첨부됩니다",
"dropTip": "파일을 이 창에 끌어 놓아 첨부할 수 있습니다.",
"imageChoiceTitle": "이미지 {count}개를 어떻게 넣을까요?",
"choiceInline": "본문에 표시",
"choiceAttach": "첨부 파일로",
"choiceCancel": "취소",
"inlineAdded": "본문에 이미지를 넣었습니다.",
"attached": "첨부했습니다.",
"imgSize": "이미지 크기",
"imgFit": "창에 맞춤",
"imgOriginal": "원본 크기",
"imgWidthPx": "너비(px)",
"imgResizeTip": "이미지를 클릭하면 크기를 바꿀 수 있습니다. 모서리 손잡이를 끌어도 됩니다.",
"forwardSection": "메일 전달",
"forwardIntro": "이 메일함으로 받는 메일을 지정한 주소로 전부 전달합니다. 원본은 받은 메일함에 그대로 남습니다.",
"prefForward": "받은 메일을 아래 주소로 전달",
"forwardTo": "전달 주소",
"forwardHelp": "주소는 쉼표로 구분해 5개까지 넣을 수 있습니다. 전달 메일의 보내는 사람은 이 메일함 주소이고, 전달받은 쪽에서 답장하면 원래 보낸 사람에게 갑니다. 첨부와 본문 이미지도 함께 전달하며(첨부 합계 25MB 까지), 전달받은 메일이 되돌아와도 다시 전달하지 않습니다. 전달 주소에서 보낸 메일과 72시간이 지난 뒤 뒤늦게 가져온 메일은 전달하지 않습니다.",
"forward_invalid": "전달 주소를 확인해 주세요. 올바른 이메일 주소를 쉼표로 구분해 5개까지 넣을 수 있고, 자기 메일함 주소는 넣을 수 없습니다.",
"autoReplySection": "자동 응답",
"autoReplyIntro": "받은 메일에 정해 둔 메시지로 자동 회신합니다. 휴가·부재중 안내나 접수 확인에 씁니다.",
"prefAutoReply": "자동 응답 사용",
"autoReplySubject": "응답 제목",
"autoReplySubjectHelp": "비우면 \"Re: 원래 제목\" 으로 보냅니다",
"autoReplyMessage": "응답 메시지",
"autoReplyMessageHelp": "예: 휴가 중입니다. 10월 15일에 돌아와 답장드리겠습니다.",
"autoReplyPeriod": "기간",
"autoReplyNoLimit": "기한 제한 없음 (끌 때까지 계속)",
"autoReplyRange": "기간 지정 (날짜·시간)",
"autoReplyStart": "시작",
"autoReplyEnd": "종료",
"autoReplyPeriodHelp": "기간 지정을 고르면 시작·종료를 날짜와 시간으로 정합니다. 시작을 비우면 저장하는 즉시 시작하고, 종료 시각이 지나면 자동으로 멈춥니다. 기한 제한 없음이면 체크를 풀고 저장할 때까지 계속 보냅니다.",
"autoReplyHelp": "같은 상대에게는 24시간에 한 번만 보냅니다. 자동 발송 메일(다른 자동 응답·알림·메일링 리스트·no-reply 발신)과 72시간이 지난 뒤 뒤늦게 가져온 메일에는 응답하지 않습니다. 보낸 자동 응답은 보낸 메일함에 남고 원래 메일에는 \"답장함\" 표시가 붙습니다.",
"autoreply_message": "자동 응답 메시지를 입력해 주세요.",
"autoreply_period": "자동 응답 기간을 확인해 주세요. 기간을 지정하면 종료 시각이 필요하고, 종료는 시작보다 뒤여야 합니다.",
"sidebarForward": "메일 전달 중",
"sidebarAutoReply": "자동 응답 작동 중",
"autoActions": "자동 처리",
"auto_forward_sent": "전달됨 → {target}",
"auto_forward_failed": "전달 실패 → {target} ({detail})",
"auto_forward_skipped": "전달하지 않음 — {detail}",
"auto_forward_pending": "전달 처리 중",
"auto_reply_sent": "자동 응답 보냄 → {target}",
"auto_reply_failed": "자동 응답 실패 → {target} ({detail})",
"auto_reply_skipped": "자동 응답 안 함 — {detail}",
"auto_reply_pending": "자동 응답 처리 중",
"auto_pending": "처리 중",
"reason_loop": "전달받은 메일(되풀이 방지)",
"reason_self": "전달 주소에서 보낸 메일",
"reason_stale": "72시간 지난 메일",
"reason_recent": "같은 상대에게 24시간 안에 이미 응답함",
"reason_claimed": "이미 처리됨",
"reason_no_address": "회신할 주소 없음",
"reason_no_target": "전달 주소 없음",
"reason_auto-submitted": "자동 발송 메일(Auto-Submitted)",
"reason_bulk": "대량·자동 발송 메일",
"reason_list": "메일링 리스트 메일",
"reason_suppressed": "자동 응답 거부 요청 메일",
"reason_noreply": "회신받지 않는 발신 주소(no-reply)",
"forwardOnNote": "현재 메일은 다음 주소로 전달되고 있습니다",
"autoReplyOnNote": "메일이 수신되면 자동으로 아래 메일을 발신자에게 회신합니다.",
"autoReplyOutsideNote": "지정한 기간({start} ~ {end}) 밖이라 지금은 회신을 보내지 않습니다. 기간이 되면 자동으로 시작합니다.",
"autoReplyNow": "저장 시점",
"attachment_too_largeLimit": "첨부 한 개는 {mb}(MIME 변환 기준, 파일 크기의 약 1.37배)까지 올릴 수 있습니다. 한 메일에 10개, 합계 20MB 까지입니다.",
"signupLink": "계정이 없으신가요? 회원 가입",
"signupTitle": "회원 가입",
"signupIntro": "{domain} 메일 계정을 만듭니다. 로그인 ID 가 곧 메일 주소(ID@{domain})가 됩니다.",
"signupHelp": "로그인 ID 는 영문 소문자·숫자·점·밑줄·하이픈 2~64자입니다. 비밀번호는 두 번 같게 입력하고, 눈 단추로 입력한 글자를 확인할 수 있습니다.",
"signupSubmit": "가입",
"signupDone": "가입이 끝났습니다. 만든 아이디와 비밀번호로 로그인하세요.",
"signupPending": "가입 신청을 받았습니다. 관리자가 승인하면 로그인할 수 있습니다.",
"signup_disabled": "지금은 회원 가입을 받지 않습니다. 관리자에게 계정을 요청하세요.",
"pending_approval": "가입 승인을 기다리는 계정입니다. 관리자가 승인하면 로그인할 수 있습니다.",
"account_limit_reached": "더 이상 계정을 만들 수 없습니다(시스템의 계정 수 제한에 이름).",
"account_limit_reachedLimit": "더 이상 계정을 만들 수 없습니다. 이 메일 시스템은 계정을 최대 {limit}개까지 둘 수 있습니다.",
"passwordSection": "내 비밀번호 변경",
"passwordIntro": "웹 메일·IMAP·POP·SMTP 에 함께 쓰는 비밀번호를 바꿉니다. 본인 확인을 위해 현재 비밀번호를 먼저 입력하고 새 비밀번호를 두 번 같게 입력합니다.",
"newPassword": "새 비밀번호",
"changePassword": "비밀번호 변경",
"passwordChangeHelp": "바꾸면 이 계정의 웹 세션이 모두 끝나므로 새 비밀번호로 다시 로그인합니다. 메일 프로그램의 비밀번호도 함께 바꾸세요.",
"passwordWwwManaged": "www 관리자 계정의 비밀번호는 www 사이트에서 관리합니다.",
"www_managed": "www 관리자 계정의 비밀번호는 www 사이트에서 바꿉니다.",
"self_password_settings": "자기 계정의 비밀번호는 웹 메일의 도구 → 설정에서 현재 비밀번호를 확인한 뒤 바꿉니다.",
"selfPasswordNote": "내 비밀번호는 웹 메일의 도구 → 설정 → 내 비밀번호 변경에서 바꿉니다.",
"quotaSection": "메일함 사용량·제한",
"quotaUnlimited": "용량 제한 없음",
"quotaPercent": "{percent}% 사용",
"quotaLimit": "최대 크기",
"quotaNow": "현재 적용 {limit}",
"limitAttachment": "첨부 한 개 최대 크기",
"limitAttachmentValue": "{mb} (MIME 변환 기준)",
"limitRecipients": "한 메일의 받는 사람 수",
"limitRecipientsValue": "최대 {n}명 (받는 사람·참조·숨은 참조 합계)",
"mimeNote": "크기는 메일로 저장·전송될 때의 MIME 기준입니다. 첨부 파일은 base64 로 바뀌어 원래 크기의 약 1.37배가 됩니다.",
"serviceOffByAdmin": "관리자가 서비스를 껐습니다",
"serviceOffByAdminHelp": "관리자가 끈 서비스는 계정 설정과 무관하게 접속할 수 없습니다.",
"too_many_recipients": "받는 사람이 너무 많습니다.",
"too_many_recipientsLimit": "받는 사람(참조·숨은 참조 포함)은 한 메일에 최대 {limit}명입니다.",
"quota_exceeded": "메일함이 가득 찼습니다. 필요 없는 메일을 영구 삭제해 공간을 비우세요.",
"quota_exceededLimit": "메일함이 가득 찼습니다(최대 {mb}). 휴지통에서 영구 삭제해 공간을 비우세요.",
"storage_full": "메일 시스템의 저장 공간이 가득 찼습니다. 관리자에게 알려 주세요.",
"storage_fullLimit": "메일 시스템의 저장 공간(최대 {mb})이 가득 찼습니다. 관리자에게 알려 주세요.",
"monthly_limit_exceeded": "이달의 메일 수발신 한도에 이르러 지금은 보낼 수 없습니다.",
"monthly_limit_exceededLimit": "이달의 메일 수발신 한도({limit}통)에 이르러 지금은 보낼 수 없습니다. 다음 달 1일부터 다시 보낼 수 있습니다.",
"limit_invalid": "제한 값은 0 이상의 숫자여야 합니다(0 = 제한 없음).",
"limit_exceeds_system": "시스템 설정의 제한을 넘는 값은 저장할 수 없습니다.",
"limit_exceeds_systemLimit": "{field} 값이 시스템 제한({max})을 넘습니다. 그 이내로 입력하세요.",
"limit_mailboxBytes": "메일함 최대 크기",
"limit_attachmentBytes": "첨부 최대 크기",
"limit_recipients": "받는 사람 수",
"limit_accounts": "허용 계정 수",
"limit_totalBytes": "전체 저장 용량",
"limit_monthlyMessages": "월간 수발신 통수",
"reason_limit": "월간 수발신 한도에 이름",
"status_pending": "승인 대기",
"pendingNotice": "회원 가입으로 만들어진 계정입니다. 승인하면 바로 쓸 수 있고, 거절하면 계정이 삭제됩니다.",
"approveUser": "승인",
"rejectUser": "거절",
"confirmRejectUser": "이 가입 신청을 거절하고 계정을 삭제할까요?",
"userApproved": "계정을 승인했습니다.",
"userRejected": "가입 신청을 거절했습니다.",
"userQuotaMb": "메일함 최대 크기 (MB, 0 = 기본값)",
"systemCap": "시스템 제한: {max} 이내",
"systemNoCap": "시스템 제한 없음",
"noLimit": "제한 없음",
"countUnit": "{n}개",
"signupSection": "회원 가입",
"signupAdminIntro": "사용자가 로그인 화면에서 직접 계정을 만들 수 있게 할지 정합니다. 끄면 관리자가 만든 계정만 메일을 쓸 수 있습니다.",
"signupEnable": "로그인 화면에서 회원 가입 받기",
"signupApprovalLegend": "가입한 계정의 사용 시점",
"signupImmediate": "즉시 사용",
"signupApprove": "관리자 승인 후 사용",
"signupAdminHelp": "승인 대기 계정은 계정 목록에 \"승인 대기\" 로 보이며, 계정을 열어 승인하거나 거절합니다. 가입 계정은 항상 일반 사용자 역할이고 비밀번호 정책·계정 수 제한을 똑같이 따릅니다.",
"limitsSection": "메일 제한",
"limitsIntro": "계정별 기본 메일함 크기, 첨부 한 개의 최대 크기, 한 메일의 받는 사람 수를 정합니다. 시스템 설정(설치 시 지정)에 제한이 있으면 그 값 이내로만 저장됩니다.",
"limitMailboxMb": "계정별 메일함 최대 크기 (MB)",
"limitAttachmentMb": "첨부 한 개 최대 크기 (MB)",
"limitRecipientsN": "한 메일의 받는 사람 수",
"limitsHelp": "0 또는 빈 값은 시스템 값을 그대로 따릅니다. 메일함 크기는 계정마다 따로 정할 수도 있습니다(계정 수정 창). 받는 사람 수는 내장 상한 50명을 넘을 수 없습니다.",
"systemSection": "시스템 설정 (설치 시 지정)",
"systemIntro": "환경 파일(gateway.env)의 LIMIT_* 값입니다. 메일 시스템 전체의 상위 제한이라 여기서는 바꿀 수 없습니다. 0 은 제한 없음.",
"servicesAdminSection": "POP / IMAP 서비스",
"servicesAdminIntro": "메일 프로그램 접속 서비스를 통째로 켜고 끕니다. 끄면 모든 계정의 POP(또는 IMAP) 로그인이 거부됩니다.",
"servicePop": "POP3 서비스 사용",
"serviceImap": "IMAP 서비스 사용",
"servicePlainLogin": "일반 접속(암호화 없음) 로그인 허용 — POP3 110 / IMAP 143",
"servicePlainLoginHelp": "켜면 110/143 포트에서 STARTTLS 없이 보낸 비밀번호로도 로그인할 수 있습니다. Gmail 의 \"다른 계정에서 메일 확인\" 처럼 STARTTLS 를 지원하지 않는 클라이언트에 필요합니다. 비밀번호가 암호화되지 않고 지나가므로, 모든 사용자가 995/993(SSL) 또는 STARTTLS 를 쓸 수 있다면 끄는 것이 안전합니다. 995/993 과 웹 메일에는 영향이 없습니다.",
"servicesAdminHelp": "웹 메일과 SMTP(보내는 서버)에는 영향이 없습니다. 이미 연결된 메일 프로그램은 다음 접속부터 거부됩니다.",
"configSaved": "설정을 저장했습니다.",
"statsSection": "메일 현황",
"statsIntro": "기간은 {tz} 시간대 기준입니다. 일일은 오늘 0시부터, 주간은 이번 주 월요일부터, 월간은 이달 1일부터 말일까지입니다.",
"statPeriod": "기간",
"statDay": "일일",
"statWeek": "주간",
"statMonth": "월간",
"statSent": "발신",
"statReceived": "수신",
"statTotal": "수발신 합계",
"statLimits": "시스템 제한 대비 사용량",
"statAccounts": "메일 계정",
"statTotalUsage": "총 메일함 사용량",
"statMonthly": "이달 수발신 통수",
"statMailboxes": "사용자별 메일함 사용량",
"statMessages": "메일 수",
"usageOfMax": "{used} 사용 중 / 최대 {max} ({percent}%)",
"usageNoLimit": "{used} 사용 중 · 제한 없음",
"serviceOn": "사용",
"serviceOff": "중지",
"reason_forwarded": "자동 전달된 메일(되풀이 방지)",
"largeAttach": "대용량 첨부",
"largeAttachHelp": "파일을 메일에 붙이지 않고 서버에 두고 다운로드 링크를 보냅니다(파일당 {mb}까지).",
"largeAttached": "대용량 파일을 올렸습니다. 발송 조건(보관 일수·다운로드 횟수)을 확인하세요.",
"largeAutoRouted": "일반 첨부 한도를 넘는 파일은 대용량 첨부로 올렸습니다.",
"largeTerms": "대용량 첨부 발송 조건",
"largeDays": "보관",
"largeDownloads": "다운로드",
"largeTermsHelp": "일수 1~{days}, 횟수 1~{downloads}. 기한이 지나거나 횟수를 다 쓰면 파일이 서버에서 삭제됩니다(제한 없는 발송은 할 수 없음).",
"largeFileHeading": "대용량 첨부파일",
"largeFileTerms": "{until} 까지 · {downloads}회",
"largeFileNote": "링크는 기한이 지나거나 다운로드 횟수를 다 쓰면 만료되어 파일을 받을 수 없습니다.",
"largeStatus": "남은 {remaining}/{max}회 · {until} 까지",
"largeExpired": "만료",
"largeRevoke": "링크 끊기(파일 삭제)",
"confirmRevoke": "이 파일의 다운로드 링크를 끊고 서버에서 삭제할까요? 받는 사람은 더 이상 받을 수 없습니다.",
"largeLimitsValue": "파일당 {mb} · 보관 최대 {days}일 · 다운로드 최대 {downloads}회",
"large_files_disabled": "대용량 첨부파일 기능이 꺼져 있습니다. 관리자에게 문의하세요.",
"too_many_files": "대용량 첨부는 한 메일에 10개까지입니다.",
"file_too_large": "대용량 파일이 한도를 넘습니다.",
"file_too_largeLimit": "대용량 파일은 한 개에 {mb}까지 올릴 수 있습니다.",
"large_file_terms": "대용량 첨부의 발송 조건(보관 일수·다운로드 횟수)을 확인하세요.",
"large_file_termsLimit": "대용량 첨부의 {field}은(는) 1 이상 {limit} 이하여야 합니다.",
"limit_days": "보관 일수",
"limit_downloads": "다운로드 횟수",
"limit_maxBytes": "최대 크기",
"limit_maxDays": "보관 최대 기간",
"limit_maxDownloads": "다운로드 최대 횟수",
"largeSection": "대용량 첨부파일",
"largeAdminIntro": "큰 파일을 메일에 첨부하지 않고 서버에 보관한 뒤 다운로드 링크를 보내는 기능입니다. 보낸 사람이 정한 보관 기간이 지나거나 다운로드 횟수를 다 쓰면 서버에서 지워집니다.",
"largeEnable": "대용량 첨부파일 기능 사용",
"largeMaxMb": "파일 한 개 최대 크기 (MB)",
"largeMaxDays": "보관 최대 기간 (일)",
"largeMaxDownloads": "다운로드 최대 횟수",
"largeAdminHelp": "세 값은 비울 수 없고(무제한 불가) 시스템 설정 이내여야 합니다. 기본값은 시스템 설정의 50% 입니다. 사용자는 발송할 때 이 값 이내에서 보관 일수와 횟수를 정합니다.",
"largeUsage": "현재 서버에 보관 중: {count}개, {size}",
"systemCapStrict": "시스템 상한 {max} (반드시 그 이내의 값)",
"daysUnit": "{n}일",
"timesUnit": "{n}회",
"dl_title": "대용량 첨부파일 다운로드",
"dl_file": "파일",
"dl_size": "크기",
"dl_remaining": "남은 횟수",
"dl_until": "기한",
"dl_button": "다운로드",
"dl_note": "다운로드 단추를 누를 때마다 남은 횟수가 1 줄어듭니다. 기한이 지나거나 횟수를 다 쓰면 파일이 삭제됩니다.",
"dl_expiredTitle": "만료된 링크",
"dl_expired": "이 파일은 보관 기한이 지났거나 다운로드 횟수를 다 써서 더 이상 받을 수 없습니다. 보낸 사람에게 다시 요청하세요.",
"dl_missingTitle": "링크를 찾을 수 없습니다",
"dl_missing": "주소가 잘못됐거나 파일이 삭제되었습니다.",
"cannot_modify_self": "자기 계정의 역할을 내리거나 정지할 수 없습니다(다른 관리자 계정으로 하세요).",
"selfRoleNote": "자기 계정의 역할·상태는 바꿀 수 없습니다.",
"bccHelp": "숨은 참조 주소는 받는 사람·참조에게 보이지 않습니다.",
"cancelUpload": "업로드 취소",
"uploadFinishing": "서버 저장 중…",
"upload_too_large_proxy": "서버 앞단(nginx)이 요청 크기를 거부했습니다(HTTP 413). 관리자에게 프록시의 업로드 크기 제한을 확인해 달라고 하세요.",
"upload_failed": "파일을 올리지 못했습니다.",
"upload_network": "파일을 올리는 중 연결이 끊겼습니다. 다시 시도하세요.",
"upload_cancelled": "업로드를 취소했습니다.",
"upload_write_failed": "서버가 파일을 저장하지 못했습니다(디스크 오류). 관리자에게 알려 주세요.",
"domainMissingTitle": "메일 서비스가 존재하지 않습니다",
"domainMissing": "이 주소에는 등록된 메일 서비스가 없습니다.",
"domainDisabledTitle": "메일 서비스가 활성화되지 않았습니다",
"domainDisabled": "이 도메인의 메일 서비스는 지금 사용이 중지되어 있습니다.",
"domainAskAdmin": "서비스가 필요하면 관리자에게 이 도메인의 메일 서비스 등록(또는 활성화)을 요청하세요.",
"domain_not_found": "이 도메인에는 메일 서비스가 없습니다. 관리자에게 요청하세요.",
"domain_disabled": "이 도메인의 메일 서비스는 활성화되지 않았습니다. 관리자에게 요청하세요.",
"loginHelpWebmaker": "이 메일 서비스는 웹메이커(webmaker) 계정과 연동되어 있습니다. 웹메이커에 로그인할 때 쓰는 아이디와 비밀번호를 그대로 입력하세요(아이디 또는 아이디@{domain}). 웹메이커 관리자(admin)와 사이트 소유자는 관리자, 사이트에 추가한 사용자는 일반 계정입니다. 비밀번호는 웹메이커에서 바꿉니다.",
"webmakerAccount": "웹메이커 계정",
"kind_member": "사이트 사용자",
"kind_owner": "사이트 소유자",
"kind_admin": "웹메이커 관리자",
"kind_user": "웹메이커 회원",
"adminUsersIntroWebmaker": "웹메이커 계정 {n}개가 연동되어 있습니다(사이트: {site}). 이 계정들의 이름·비밀번호·역할은 웹메이커에서 관리하며, 여기서는 POP·IMAP 접속 허용과 메일함 크기만 바꿉니다.",
"webmakerNoSite": "도메인에 대응하는 사이트 없음 — 웹메이커의 모든 회원",
"webmakerReadFailed": "웹메이커 계정 파일을 읽지 못했습니다",
"editExternalUser": "웹메이커 계정 설정",
"webmakerAccountHelp": "{login} ({mailbox}) 은 웹메이커 계정입니다. 이름·비밀번호·역할은 웹메이커에서 관리하고, 여기서는 메일함 크기와 POP·IMAP 접속 허용만 바꿉니다.",
"passwordWebmakerManaged": "웹메이커 계정의 비밀번호는 웹메이커에서 바꿉니다. 바꾸면 웹 메일·IMAP·POP·SMTP 에도 바로 적용됩니다.",
"webmaker_managed": "웹메이커 계정은 웹메이커에서 관리합니다(여기서는 접속 허용·메일함 크기만).",
"webmaker_dir_unreadable": "웹메이커 데이터 디렉터리를 읽을 수 없습니다."
}
+81
View File
@@ -0,0 +1,81 @@
-- 메일 게이트웨이 저장소. www 사이트의 DB와 분리하며 인증만 www DB(users)를 읽습니다.
CREATE TABLE messages (
id TEXT PRIMARY KEY,
provider_id TEXT UNIQUE,
message_id TEXT,
direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound')),
folder TEXT NOT NULL CHECK(folder IN ('inbox','sent','drafts','trash')),
previous_folder TEXT,
sender TEXT NOT NULL DEFAULT '',
recipients_json TEXT NOT NULL DEFAULT '[]',
cc_json TEXT NOT NULL DEFAULT '[]',
bcc_json TEXT NOT NULL DEFAULT '[]',
reply_to_json TEXT NOT NULL DEFAULT '[]',
subject TEXT NOT NULL DEFAULT '',
html TEXT,
text TEXT NOT NULL DEFAULT '',
original_html TEXT,
headers_json TEXT NOT NULL DEFAULT '{}',
source TEXT NOT NULL DEFAULT 'sync' CHECK(source IN ('webhook','sync','web')),
state TEXT NOT NULL DEFAULT 'received' CHECK(state IN ('draft','sending','sent','received','failed')),
send_key TEXT,
send_started_at TEXT,
read_at TEXT,
received_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
sent_at TEXT,
raw_status TEXT NOT NULL DEFAULT 'none' CHECK(raw_status IN ('none','stored','built','unavailable')),
raw_size INTEGER NOT NULL DEFAULT 0,
expires_at TEXT
);
CREATE INDEX messages_folder_date ON messages(folder, received_at DESC);
CREATE INDEX messages_expires ON messages(expires_at);
CREATE INDEX messages_pop ON messages(direction, folder, received_at);
-- 원본(RFC 5322 .eml)은 그대로 보관합니다. POP 은 이 바이트를 그대로 전달합니다.
CREATE TABLE message_raw (
message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
eml BLOB NOT NULL,
stored_at TEXT NOT NULL
);
CREATE TABLE attachments (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
provider_id TEXT,
filename TEXT NOT NULL DEFAULT '',
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
content_id TEXT,
disposition TEXT NOT NULL DEFAULT 'attachment',
size INTEGER NOT NULL DEFAULT 0,
bytes BLOB,
created_at TEXT NOT NULL
);
CREATE INDEX attachments_message ON attachments(message_id);
-- 영구 삭제하거나 보관 기간이 지난 메일을 동기화가 다시 가져오지 않게 합니다.
CREATE TABLE tombstones (provider_id TEXT PRIMARY KEY, deleted_at TEXT NOT NULL, reason TEXT NOT NULL DEFAULT 'deleted');
CREATE TABLE sessions (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, login TEXT NOT NULL,
expires_at TEXT NOT NULL, created_at TEXT NOT NULL, ip TEXT, ua TEXT
);
-- POP 으로 한 번 전달한 메일은 같은 계정에 다시 내려주지 않습니다.
CREATE TABLE pop_deliveries (
user_id TEXT NOT NULL,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
delivered_at TEXT NOT NULL,
via TEXT NOT NULL DEFAULT 'retr' CHECK(via IN ('retr','dele')),
PRIMARY KEY(user_id, message_id)
);
CREATE TABLE webhook_events (
id TEXT PRIMARY KEY, type TEXT NOT NULL, provider_id TEXT,
received_at TEXT NOT NULL, status TEXT NOT NULL, detail TEXT
);
CREATE INDEX webhook_events_date ON webhook_events(received_at DESC);
CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT, trigger TEXT NOT NULL DEFAULT 'timer',
started_at TEXT NOT NULL, finished_at TEXT, imported INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'running', detail TEXT
);
CREATE TABLE audit_log (
id TEXT PRIMARY KEY, actor TEXT, action TEXT NOT NULL, target TEXT, detail TEXT, created_at TEXT NOT NULL
);
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL);
+16
View File
@@ -0,0 +1,16 @@
-- IMAP: 메일함별 UID(전역 단조 증가)와 영구 플래그.
CREATE TABLE imap_uids (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
folder TEXT NOT NULL,
UNIQUE(message_id, folder)
);
CREATE TABLE imap_flags (
message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
flags TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL
);
INSERT INTO settings(key,value,updated_at) VALUES('imap_uidvalidity', CAST(strftime('%s','now') AS TEXT), strftime('%Y-%m-%dT%H:%M:%SZ','now'));
INSERT INTO imap_uids(message_id, folder) SELECT id, folder FROM messages ORDER BY received_at, id;
-- 보낸 메일·초안은 읽음 상태로 둡니다. IMAP 의 \Seen 은 read_at 으로 표현합니다.
UPDATE messages SET read_at = COALESCE(read_at, sent_at, received_at) WHERE direction = 'outbound';
@@ -0,0 +1,19 @@
-- 게이트웨이 사용자 계정과 메일함 분리.
-- www 관리자 계정은 계속 www DB 로 인증하며(별칭 admin 포함), 여기 계정은 관리자가 만드는 추가 사용자입니다.
CREATE TABLE mail_users (
id TEXT PRIMARY KEY,
login TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT '',
mailbox TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')),
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','suspended')),
password_hash TEXT NOT NULL,
created_by TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login_at TEXT
);
CREATE INDEX mail_users_mailbox ON mail_users(mailbox);
-- 메시지가 속한 메일함 주소. 기존 메시지는 기본 메일함(admin@arihakdang.com)입니다.
ALTER TABLE messages ADD COLUMN mailbox TEXT NOT NULL DEFAULT 'admin@arihakdang.com';
CREATE INDEX messages_mailbox_folder ON messages(mailbox, folder, received_at DESC);
@@ -0,0 +1,20 @@
-- 0.8.0: 메일 보관 기간(30일) 제한 삭제와 시각 형식 통일.
--
-- 1) 보관 기간 제한 삭제
-- 예전 릴리스는 메일마다 expires_at(수신 후 30일)을 두고 매시간 지난 메일을 원본·첨부와 함께 지웠습니다.
-- 이제 메일은 사용자가 영구 삭제할 때까지 보관합니다. 열 자체는 지우지 않고 값만 비웁니다.
-- 열을 남기는 이유: 배포 실패로 이전 릴리스로 되돌아가도 그 코드가 넣는 INSERT 가 깨지지 않게 하기 위해서입니다.
-- 값이 NULL 이면 이전 릴리스의 정리 작업(expires_at IS NOT NULL 조건)도 이 메일들을 지우지 않습니다.
UPDATE messages SET expires_at = NULL WHERE expires_at IS NOT NULL;
-- 만료 정리에만 쓰던 색인은 필요 없습니다.
DROP INDEX IF EXISTS messages_expires;
-- 2) 시각 형식 통일
-- Resend 보낸 메일 목록은 "2026-09-28 06:20:26.514000+00" 형식(공백 구분, 분 없는 시간대)을 돌려주고,
-- 0.8.0 이전에는 이 값을 그대로 received_at·sent_at 에 저장했습니다. 다른 메일은 ISO 형식(…T…Z)이라
-- 같은 날짜에서 문자열 정렬이 시각과 무관하게 뒤섞였습니다. "+00" 뒤에 ":00" 을 붙여 SQLite 가 해석할 수 있게 한 뒤
-- ISO 8601 UTC(밀리초 3자리, Z)로 바꿉니다. 이미 ISO 형식인 값은 GLOB 조건에 맞지 않아 그대로 둡니다.
UPDATE messages SET received_at = strftime('%Y-%m-%dT%H:%M:%fZ', received_at || ':00')
WHERE received_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9]*[+-][0-9][0-9]';
UPDATE messages SET sent_at = strftime('%Y-%m-%dT%H:%M:%fZ', sent_at || ':00')
WHERE sent_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9]*[+-][0-9][0-9]';
@@ -0,0 +1,16 @@
-- 0.8.0: 메일이 이전에 있던 IMAP 메일함으로 돌아올 때 새 UID 를 받게 합니다.
--
-- 문제: imap_uids 는 (message_id, folder) 마다 UID 한 개를 두고 INSERT OR IGNORE 로 채웁니다.
-- 메일이 다른 폴더로 나갔다가(휴지통 이동·IMAP MOVE·웹 "이동") 돌아오면 예전 행이 남아 있어 옛 UID 를 다시 썼습니다.
-- RFC 3501 은 메일함에 새로 들어온 메일의 UID 가 그때까지의 모든 UID 보다 커야 한다고 정합니다. 옛 UID 로 돌아온
-- 메일은 "UIDNEXT 이후만" 받아 가는 클라이언트(Thunderbird·Outlook 등)에서 보이지 않았습니다.
-- 해결: 폴더가 바뀌는 순간 떠난 폴더의 UID 행을 지웁니다. 돌아오면 다음 동기화(SELECT·NOOP·IDLE)나 MOVE/APPEND 가
-- INSERT OR IGNORE 로 새(더 큰) UID 를 줍니다. 웹·IMAP 등 어떤 경로로 옮기든 같은 규칙이 적용되도록 트리거로 둡니다.
CREATE TRIGGER IF NOT EXISTS messages_folder_leaves_imap_uid
AFTER UPDATE OF folder ON messages
WHEN OLD.folder <> NEW.folder
BEGIN
DELETE FROM imap_uids WHERE message_id = OLD.id AND folder = OLD.folder;
END;
-- 이미 남아 있는 "지금은 그 폴더에 없는 메일"의 UID 행을 정리합니다. 현재 폴더의 UID 는 그대로 두므로 클라이언트 캐시는 유지됩니다.
DELETE FROM imap_uids WHERE folder <> (SELECT m.folder FROM messages m WHERE m.id = imap_uids.message_id);
@@ -0,0 +1,6 @@
-- 0.8.4: 관리자 페이지(/admin) 전용 세션.
-- 세션마다 범위(scope)를 둡니다. 'mail' 은 웹 메일(/), 'admin' 은 관리자 페이지(/admin)입니다.
-- 관리자 페이지는 별도의 로그인을 거치고 다른 이름의 쿠키(mg_admin)를 쓰므로, 웹 메일에 로그인돼 있어도
-- 관리자 페이지를 열려면 다시 로그인해야 하고, 관리자 세션 쿠키로는 메일 API 를 쓸 수 없습니다.
-- 기존 세션은 모두 웹 메일 세션입니다.
ALTER TABLE sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'mail';
@@ -0,0 +1,17 @@
-- 0.8.16: 수신 메일 자동 처리 기록(메일 전달·자동 응답).
-- 설정 화면의 "메일 전달"(지정 주소로 받은 메일 전부 전달)과 "자동 응답"(지정 기간 동안 정해진 메시지로 회신)이
-- 메일 한 통에 무엇을 했는지 남깁니다. 같은 메일에 같은 작업을 두 번 하지 않게 (message_id, kind) 를 기본 키로 둡니다
-- (웹훅과 동기화가 같은 메일을 거의 동시에 저장하려 할 때, 또는 원본 없이 저장했던 메일을 다시 채울 때 중복 발송 방지).
CREATE TABLE auto_actions (
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind IN ('forward','reply')),
mailbox TEXT NOT NULL, -- 작업을 설정한 메일함(받은 메일의 메일함)
target TEXT NOT NULL DEFAULT '', -- 전달: 전달한 주소 목록(쉼표 구분) / 자동 응답: 회신한 상대 주소
status TEXT NOT NULL CHECK(status IN ('pending','sent','skipped','failed')), -- pending: 처리 중(먼저 행을 넣어 중복 처리를 막음)
detail TEXT, -- 건너뛴 이유(loop·stale·recent·bulk …) 또는 오류 메시지, 보냈으면 발송 id
sent_message_id TEXT, -- 자동 응답을 보낸 메일함(sent)에 저장한 메시지 id
created_at TEXT NOT NULL,
PRIMARY KEY(message_id, kind)
);
-- 자동 응답 "같은 상대에게는 하루 한 번" 검사: 메일함·종류·상대 주소로 최근 기록을 찾습니다.
CREATE INDEX auto_actions_target ON auto_actions(mailbox, kind, target, created_at DESC);
@@ -0,0 +1,26 @@
-- 0.9.3: 대용량 첨부파일(다운로드 링크 발송).
-- 큰 파일은 메일에 첨부하지 않고 서버 디스크(FILES_DIR, 기본 <DB 디렉터리>/files/<id>)에 두고 메일에는 다운로드 링크만 넣습니다.
-- 파일 바이트는 DB 에 넣지 않습니다(1GB 급 BLOB 은 SQLite 와 백업에 부담). 이 표는 파일의 메타데이터와 링크 상태만 가집니다.
-- message_id : 파일이 딸린 초안/보낸 메일. 메일이 영구 삭제돼도 보낸 링크는 기한까지 살아 있어야 하므로 NULL 로 풀립니다(초안이면 정리 작업이 파일을 지움).
-- token : 다운로드 링크(/dl/<token>)의 비밀 값. 발송할 때 만들어지며 그 전(초안)에는 NULL.
-- state : draft(초안에 올려 둠) → sent(링크 발송됨) → expired(기한·횟수 만료 또는 보낸 사람이 링크를 끊음, 파일은 지워짐)
-- max_downloads: 허용 다운로드 횟수, downloads: 지금까지 받은 횟수(랜딩 페이지의 다운로드 단추를 누를 때마다 1), expires_at: 보관 기한(ISO)
-- 발송 전에 기한과 횟수를 반드시 정하므로 sent 상태의 행은 둘 다 NULL 이 아닙니다(기한·횟수 제한 없는 발송 불가).
CREATE TABLE large_files (
id TEXT PRIMARY KEY,
message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
mailbox TEXT NOT NULL,
filename TEXT NOT NULL DEFAULT 'file',
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
size INTEGER NOT NULL DEFAULT 0,
token TEXT UNIQUE,
state TEXT NOT NULL DEFAULT 'draft' CHECK(state IN ('draft','sent','expired')),
max_downloads INTEGER,
downloads INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
created_at TEXT NOT NULL,
sent_at TEXT,
last_download_at TEXT
);
CREATE INDEX large_files_message ON large_files(message_id);
CREATE INDEX large_files_state ON large_files(state, expires_at);
+19
View File
@@ -0,0 +1,19 @@
{
"name": "arimail",
"version": "1.0.11",
"private": true,
"description": "AriMail — 웹 메일함, IMAP·POP3·SMTP 서버, Resend 또는 내장 Haraka 로 수·발신하는 메일 게이트웨이",
"type": "module",
"scripts": {
"start": "node server/index.mjs",
"migrate": "node server/db-migrate.mjs",
"test": "node test/gateway.test.mjs && node test/imap.test.mjs && node test/smtp.test.mjs && node test/config.test.mjs && node test/provision.test.mjs && node test/domains.test.mjs && node test/webmaker.test.mjs && node test/usage.test.mjs"
},
"engines": {
"node": ">=22.13"
},
"repository": {
"type": "git",
"url": "https://source.ibit.kr/OZ-Group/AriMail"
}
}
+479
View File
@@ -0,0 +1,479 @@
/**
* HTTP API. 표준 Request/Response 로 작성해 www 사이트 핸들러와 같은 방식으로 실행합니다.
* 모든 메일 조회는 세션 사용자의 메일함으로 범위를 제한합니다. 0.9.0 부터는 관리자도 예외가 아닙니다
* (예전의 ?mailbox= 로 다른 메일함을 여는 기능은 요구사항에 따라 삭제했습니다. 관리자 권한은 /admin 의 계정·설정 관리에만 쓰입니다).
*
* 세션 범위(0.8.4~): 요청 헤더 X-Mail-Scope 가 'admin' 이면 관리자 페이지(/admin)의 요청입니다. 이 요청은 관리자 세션 쿠키(mg_admin)로
* 인증하고 /api/admin/* 와 /api/session 만 쓸 수 있습니다. 헤더가 없으면 웹 메일 요청이며 메일 세션 쿠키(mg_session)로 인증하고
* /api/admin/* 는 쓸 수 없습니다(관리자도 /admin 에서 따로 로그인해야 합니다).
*
* 0.9.0 에 더한 경로
* POST /api/signup 회원 가입(관리자가 켠 경우). 옵션에 따라 즉시 사용 또는 승인 대기.
* PATCH /api/password 로그인한 계정의 비밀번호 변경(현재 비밀번호 + 새 비밀번호 2회). 웹 메일 "도구 → 설정" 이 씁니다.
* GET/PATCH /api/admin/config 회원 가입 설정·메일 제한(관리자 층)·POP/IMAP 서비스 스위치. 시스템 제한 값도 함께 돌려줍니다.
* PATCH /api/admin/users/<id> quotaBytes(계정별 메일함 크기), status:'active'(승인) 추가. 자기 비밀번호는 여기서 바꿀 수 없음(설정 화면으로).
*/
import { json, bad, now, uid, clean, addresses } from './util.mjs';
import { verifyUser, createSession, sessionCookie, currentUser, destroySession, requireUser, audit, listUsers, createUser, updateUser, deleteUser, knownMailboxes, readPrefs, writePrefs, mailDomain, readPasswordPolicy, writePasswordPolicy, usesInitialPassword, checkCurrentPassword, pendingLogin, externalUsers, authMode } from './auth.mjs';
import { outboundStatus, inboundMode, outboundMode, usesHaraka } from './providers/index.mjs';
import { configFile } from './config.mjs';
import { setupApi, harakaStatus } from './setup.mjs';
import { ingestRaw as ingestRawMail } from './store.mjs';
import { safeEqual as timingEqual, address as bareAddress, clean as cleanStr, uid as newId, now as nowIso } from './util.mjs';
import { knownMailboxes as mailboxList } from './auth.mjs';
import { sanitizeHtml } from './sanitize.mjs';
import { verifySvix } from './webhook.mjs';
import { setFlag, mailboxOf, ingestReceived, startSync, isSyncing, saveDraft, sendDraft, addAttachment, removeAttachment, copyAttachments } from './store.mjs';
import { autoActionsOf, autoReplyActive } from './automation.mjs';
import { readLargeFileSettings, writeLargeFileSettings, systemLargeFiles, listLargeFiles, removeLargeFile, largeFileStats } from './files.mjs';
import { systemLimits, readAdminLimits, writeAdminLimits, readUserQuota, writeUserQuota, checkUserQuota, mailboxQuota, mailboxUsage, attachmentLimit, recipientLimit, readServices, writeServices, plainLoginAllowed, readSignup, writeSignup, mailStats, accountCount } from './limits.mjs';
const FOLDERS = ['inbox', 'sent', 'drafts', 'trash'];
/**
* 메일이 상태와 맞게 들어갈 수 있는 폴더 목록(웹의 "이동" 메뉴·API 공통 규칙).
* - 받은 메일: 받은 메일함·휴지통
* - 발송한 메일(state='sent'): 보낸 메일함·휴지통
* - 그 밖의 보내는 메일(초안·발송 실패): 임시 보관함·휴지통
* 왜: 예전에는 아무 폴더로나 옮길 수 있어, 초안을 보낸 메일함에 넣으면 보내지 않은 메일이 보낸 것처럼 보이고,
* 받은 메일을 임시 보관함에 넣으면 작성 창으로 열려 저장·발송이 mail_locked 로 실패했습니다.
*/
export const movableFolders = (m) => m.direction === 'inbound' ? ['inbox', 'trash'] : m.state === 'sent' ? ['sent', 'trash'] : ['drafts', 'trash'];
// prefix(1.0.8): 화면이 경로 접두사(/mail) 아래에서 돌 때 인라인 이미지 주소도 그 아래로 만듭니다(저장된 본문은 늘 /api/mail/… 로 두고 내보낼 때만 붙임).
const previewHtml = (html, id, attachments, prefix = '') => String(html || '').replace(/(<img\b[^>]*\bsrc=")cid:([^"]+)("[^>]*>)/g, (m, a, cid, b) => {
const match = attachments.find((x) => x.content_id && x.content_id === cid.replace(/&amp;/g, '&'));
return match ? `${a}${prefix}/api/mail/${encodeURIComponent(id)}/attachments/${encodeURIComponent(match.id)}?inline=1${b}` : `${a}${b}`;
// 초안·보낸 메일에 이미 /api/mail/… 로 저장된 인라인 이미지도 접두사 아래에서 열리게 같이 바꿉니다(없으면 사이트의 404 를 보게 됨).
}).replace(/(<img\b[^>]*\bsrc=")\/api\/mail\//g, (m, a) => prefix ? `${a}${prefix}/api/mail/` : m);
/**
* 초안 본문의 인라인 이미지 주소에서 경로 접두사를 뗍니다(1.0.8). 편집기는 화면 기준 경로(/mail/api/mail/…)로 넣는데, 저장·발송(cid: 변환)은 /api/mail/… 만 알아보므로
* 저장 전에 되돌립니다. 접두사가 없으면 그대로입니다.
*/
const stripPrefix = (html, prefix, env) => { let out = String(html || ''); for (const p of new Set([prefix, env?.PUBLIC_PATH].filter(Boolean))) out = out.split(`src="${p}/api/mail/`).join('src="/api/mail/'); return html ? out : html; };
async function applyAction(env, mail, body, user) {
if (body.folder === 'trash' || body.action === 'trash') { if (mail.state === 'sending') throw new Error('mail_sending'); await env.DB.prepare("UPDATE messages SET previous_folder=folder,folder='trash',updated_at=? WHERE id=? AND folder<>'trash'").bind(now(), mail.id).run(); }
else if (body.restore === true || body.action === 'restore') await env.DB.prepare("UPDATE messages SET folder=COALESCE(previous_folder,CASE direction WHEN 'inbound' THEN 'inbox' ELSE 'sent' END),previous_folder=NULL,updated_at=? WHERE id=? AND folder='trash'").bind(now(), mail.id).run();
else if (body.action === 'move' && FOLDERS.includes(body.target)) { if (mail.state === 'sending') throw new Error('mail_sending'); if (!movableFolders(mail).includes(body.target)) throw new Error('mail_move_invalid'); await env.DB.prepare('UPDATE messages SET previous_folder=folder,folder=?,updated_at=? WHERE id=?').bind(body.target, now(), mail.id).run(); }
else if (body.unread === true || body.action === 'unread') await env.DB.prepare('UPDATE messages SET read_at=NULL WHERE id=?').bind(mail.id).run();
else if (body.read === true || body.action === 'read') await env.DB.prepare('UPDATE messages SET read_at=COALESCE(read_at,?) WHERE id=?').bind(now(), mail.id).run();
else if (body.flagged !== undefined || body.action === 'flag' || body.action === 'unflag') await setFlag(env, mail.id, '\\Flagged', body.flagged !== undefined ? !!body.flagged : body.action === 'flag');
else if (body.popReset === true || body.action === 'popReset') { await env.DB.prepare('DELETE FROM pop_deliveries WHERE message_id=?').bind(mail.id).run(); await audit(env, user.login, 'pop_reset', mail.id); }
else if (body.action === 'delete') { if (mail.folder !== 'trash') throw new Error('mail_trash_first'); await purge(env, mail); await audit(env, user.login, 'mail_delete', mail.id); }
else return false;
return true;
}
async function purge(env, mail) {
const statements = [];
if (mail.provider_id) statements.push(env.DB.prepare("INSERT OR IGNORE INTO tombstones(provider_id,deleted_at,reason) VALUES(?,?,'deleted')").bind(mail.provider_id, now()));
statements.push(env.DB.prepare('DELETE FROM messages WHERE id=?').bind(mail.id));
await env.DB.batch(statements);
}
/** 메일함 폴더별 건수·읽지 않음 수(사이드바용). */
const folderCounts = async (env, mailbox) => (await env.DB.prepare("SELECT folder,COUNT(*) n,SUM(CASE WHEN read_at IS NULL AND direction='inbound' THEN 1 ELSE 0 END) unread FROM messages WHERE mailbox=? GROUP BY folder").bind(mailbox).all()).results;
export async function handle(env, request, params) {
const { path, ip } = params, prefix = params.prefix || '';
const method = request.method;
// scope: 이 요청이 어느 화면에서 왔는지. 관리자 페이지는 모든 API 호출에 X-Mail-Scope: admin 을 붙입니다.
const sessionScope = request.headers.get('x-mail-scope') === 'admin' ? 'admin' : 'mail';
try {
// ---------------------------------------------------------------- 공개
// 상태 확인. version 은 배포 후 실제로 새 코드가 돌고 있는지 확인하는 데 씁니다.
if (path[0] === 'health') return json({ ok: true, version: env.APP_VERSION || null });
if (path[0] === 'webhooks' && path[1] === 'resend' && method === 'POST') return await webhook(env, request);
// 내장 Haraka 수신 전달(1.0.0): 로컬 Haraka 플러그인이 토큰으로 인증해 부릅니다(수신 방식이 haraka 일 때만 열림).
if (path[0] === 'inbound' && path[1] === 'haraka') return await harakaInbound(env, request, path, method);
if (path[0] === 'login' && method === 'POST') {
const body = await request.json().catch(() => null); if (!body) return bad('bad_request');
// 로그인 제한(IP 당·계정 당 15분 10회)은 실패만 셉니다. 예전에는 성공도 세어 공유 IP(사무실) 에서 10명이 로그인하면 나머지가 막혔습니다.
// 로그인 ID 는 login 또는 email 필드로 옵니다. 제한 키·검사·기록 모두 같은 값을 씁니다(예전에는 email 필드 로그인이 빈 계정 키 'login:' 하나를 함께 써서 서로 잠겼습니다, 0.9.3 수정).
const loginId = clean(body.login ?? body.email, 200).toLowerCase();
const keys = ['login:' + ip, 'login:' + loginId];
if (keys.some((k) => env.RATE.blocked(k, { limit: 10 }))) return bad('rate_limited', 429);
const password = String(body.password ?? '');
const user = await verifyUser(env, loginId, password);
if (!user) {
// 회원 가입 뒤 승인을 기다리는 계정(비밀번호까지 맞을 때만)에는 "승인 대기" 를 알려 줍니다. 실패 횟수로 세지 않습니다.
if (await pendingLogin(env, loginId, password)) { await audit(env, null, 'login_pending', loginId, { ip }); return bad('pending_approval', 403); }
keys.forEach((k) => env.RATE.hit(k, { limit: 10, windowSec: 900 })); await audit(env, null, 'login_failed', loginId, { ip, scope: sessionScope }); return bad('invalid_credentials', 401);
}
// 관리자 페이지 로그인은 관리자 역할 계정만 통과합니다. 비밀번호는 맞아도 일반 계정이면 거절 사유를 따로 알려 줍니다.
if (sessionScope === 'admin' && user.role !== 'admin') { await audit(env, user.login, 'admin_login_denied', null, { ip }); return bad('admin_only', 403); }
const token = await createSession(env, user, request, { scope: sessionScope }); await audit(env, user.login, sessionScope === 'admin' ? 'admin_login' : 'login', null, { ip, source: user.source });
// 쿠키 Path(1.0.8): 경로 접두사 아래의 웹 메일이면 그 접두사(/mail)로 한정해 같은 사이트의 다른 경로(사이트 자체 백엔드)에는 세션 쿠키가 가지 않게 합니다.
return json({ ok: true }, 200, { 'Set-Cookie': sessionCookie(token, { scope: sessionScope, path: prefix }) });
}
if (path[0] === 'session' && method === 'GET') {
const user = await currentUser(env, request, { scope: sessionScope });
const prefs = user ? await readPrefs(env, user) : {};
// autoReplyActive: 자동 응답이 지금 동작 중인지(기간 판단은 서버 시각 기준). 화면은 이 값으로 사이드바에 "자동 응답 작동 중" 을 표시합니다.
// initialPassword(0.8.24~): 독립 실행의 초기 관리자가 아직 adminpasswd 를 쓰면 true. 화면이 비밀번호를 바꾸라는 경고를 띄웁니다.
// signup(0.9.0~): 회원 가입이 켜져 있는지(로그인 화면의 "회원 가입" 단추). mailboxes 는 항상 자기 메일함 하나입니다(메일함 전환 삭제).
// largeFiles(0.9.3): 대용량 첨부파일 설정(사용 여부·상한). 작성 창이 단추를 보일지와 발송 조건 입력 칸의 기본값·상한을 정하는 데 씁니다. attachmentBytes 는 일반 첨부 상한(이보다 큰 파일은 대용량으로 안내).
return json({ ok: true, user, mailbox: user?.mailbox || mailboxOf(env), mailboxes: user ? [user.mailbox] : [], prefs, autoReplyActive: user ? autoReplyActive(prefs.autoReply) : false, initialPassword: user?.source === 'local' ? await usesInitialPassword(env, user) : false, domain: mailDomain(env), host: (env.PUBLIC_HOST || 'mail.arihakdang.com') + (env.PUBLIC_PATH || ''), authMode: authMode(env), version: env.APP_VERSION || null, signup: (await readSignup(env)).enabled,
...(user && sessionScope === 'mail' ? { largeFiles: await readLargeFileSettings(env), attachmentBytes: await attachmentLimit(env) } : {}) });
}
if (path[0] === 'session' && method === 'DELETE') { await destroySession(env, request, { scope: sessionScope }); return json({ ok: true }, 200, { 'Set-Cookie': sessionCookie('', { clear: true, scope: sessionScope, path: prefix }) }); }
// return await: try 안에서 프라미스를 그냥 return 하면 거부가 아래 catch 에 잡히지 않습니다(createUser 의 weak_password 등이 500 으로 새어 나감).
if (path[0] === 'signup' && method === 'POST') return await signup(env, request, ip);
// ---------------------------------------------------------------- 로그인 사용자
const user = await requireUser(env, request, { scope: sessionScope }); if (user instanceof Response) return user;
// ---------------------------------------------------------------- 관리자 페이지(/admin) 전용
// 관리자 세션으로는 아래 경로만 쓸 수 있고, 반대로 웹 메일 세션으로는 /api/admin 을 쓸 수 없습니다(관리자 페이지에서 따로 로그인).
// 초기 설정 API(1.0.0): 관리자 세션으로도 같은 API 를 씁니다(/setup 화면을 관리자 페이지에서 열 때). 토큰 인증 쪽은 setup.mjs 의 전용 포트 서버가 처리합니다.
if (path[0] === 'setup') { if (sessionScope !== 'admin') return bad('admin_login_required', 403); return await setupApi(env, request, path.slice(1), { ip, user }); }
if (path[0] === 'admin') {
if (sessionScope !== 'admin') return bad('admin_login_required', 403);
if (path[1] === 'status' && method === 'GET') return await adminStatus(env);
if (path[1] === 'sync' && method === 'POST') return json({ ok: true, ...startSync(env, 'manual') });
// 비밀번호 정책(0.8.24~): GET 은 현재 정책, PATCH 는 { minLength?, mixedCase?, digit?, special? } 를 저장합니다. 기존 비밀번호에는 소급하지 않습니다.
if (path[1] === 'policy' && method === 'GET') return json({ ok: true, policy: await readPasswordPolicy(env) });
if (path[1] === 'policy' && method === 'PATCH') {
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request');
const policy = await writePasswordPolicy(env, body); await audit(env, user.login, 'policy_update', null, policy);
return json({ ok: true, policy });
}
// 관리자 설정(0.9.0~): 회원 가입·메일 제한·서비스 스위치. system 은 설치 시 정한 상위 제한(읽기 전용)입니다.
if (path[1] === 'config' && method === 'GET') return json({ ok: true, ...(await adminConfig(env)) });
if (path[1] === 'config' && method === 'PATCH') {
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request');
const changed = {};
if (body.signup && typeof body.signup === 'object') changed.signup = await writeSignup(env, body.signup);
if (body.limits && typeof body.limits === 'object') changed.limits = await writeAdminLimits(env, body.limits);
if (body.services && typeof body.services === 'object') changed.services = await writeServices(env, body.services);
if (body.largeFiles && typeof body.largeFiles === 'object') changed.largeFiles = await writeLargeFileSettings(env, body.largeFiles);
await audit(env, user.login, 'config_update', null, changed);
return json({ ok: true, ...(await adminConfig(env)) });
}
if (path[1] !== 'users') return bad('not_found', 404);
const target = path[2];
if (method === 'GET' && !target) {
// 계정마다 메일 프로그램 접속 허용 여부(prefs 의 pop/imap)와 메일함 크기·사용량(0.9.0)을 붙여 돌려줍니다.
const users = await listUsers(env);
for (const u of users) {
const p = await readPrefs(env, u); u.services = { pop: p.pop, imap: p.imap };
u.quotaBytes = await readUserQuota(env, u.id); u.limit = await mailboxQuota(env, u.mailbox); u.usageBytes = await mailboxUsage(env, u.mailbox);
}
// me: www 관리자 계정의 행. 역할이 관리자인 게이트웨이 계정으로 들어온 경우에는 그 계정이 users 목록에 이미 있으므로 me 를 주지 않습니다
// (예전에는 로그인한 누구든 "admin" 행으로 그려져, 게이트웨이 관리자의 설정이 www 관리자 행에 표시됐습니다). 웹메이커 관리자(1.0.8)는 아래 external 목록에 있으므로 역시 me 가 없습니다.
const mine = user.source === 'www' ? await readPrefs(env, user) : null;
// external(1.0.8): webmaker 연동 도메인의 웹메이커 계정(읽기 전용 — 이름·비밀번호·역할은 웹메이커가 관리). POP/IMAP 허용·메일함 크기·사용량만 붙여 줍니다.
const ext = await externalUsers(env);
const external = [];
for (const a of ext.accounts) { const p = await readPrefs(env, a); external.push({ ...a, services: { pop: p.pop, imap: p.imap }, quotaBytes: await readUserQuota(env, a.id), limit: await mailboxQuota(env, a.mailbox), usageBytes: await mailboxUsage(env, a.mailbox) }); }
// policy: 계정 추가·수정 창이 비밀번호 입력 칸의 최소 길이와 안내문을 정책대로 그리는 데 씁니다. system/limits: 메일함 크기 입력 칸의 상한 안내.
const sys = systemLimits(env);
return json({ ok: true, users, external, externalSource: authMode(env) === 'webmaker' ? { kind: 'webmaker', ok: ext.ok, error: ext.error || null, site: ext.site || null, readAt: ext.readAt || null } : null, defaultMailbox: mailboxOf(env), domain: mailDomain(env), authMode: authMode(env), policy: await readPasswordPolicy(env), system: sys, limits: await readAdminLimits(env), accounts: { used: await accountCount(env), limit: sys.accounts },
me: mine ? { id: user.id, login: user.login, name: user.name, source: user.source, services: { pop: mine.pop, imap: mine.imap }, usageBytes: await mailboxUsage(env, user.mailbox), limit: await mailboxQuota(env, user.mailbox) } : null });
}
const body = method === 'DELETE' ? {} : await request.json().catch(() => null); if (!body) return bad('bad_request');
if (method === 'POST' && !target) {
// 메일함 크기 값은 계정을 만들기 전에 검사합니다(틀린 값으로 계정만 생기고 저장은 실패하는 일 방지).
if (body.quotaBytes !== undefined) checkUserQuota(env, body.quotaBytes);
const id = await createUser(env, body, user.login);
if (body.services && typeof body.services === 'object') await writePrefs(env, { id }, body.services);
if (body.quotaBytes !== undefined) await writeUserQuota(env, id, body.quotaBytes);
await audit(env, user.login, 'user_create', id, { login: clean(body.login, 64) }); return json({ ok: true, id }, 201);
}
// 'me' 는 관리자 자신(www 계정)의 메일 프로그램 접속 설정만 바꿉니다. www 계정 자체는 www 사이트에서 관리합니다.
if (method === 'PATCH' && target === 'me') { if (user.source !== 'www') return bad('not_found', 404); const prefs = await writePrefs(env, user, body.services || {}); await audit(env, user.login, 'user_update', user.id, { services: true }); return json({ ok: true, services: { pop: prefs.pop, imap: prefs.imap } }); }
// 웹메이커 계정(1.0.8, id 'wm:…'): 이름·비밀번호·역할·상태는 웹메이커가 주인이라 받지 않고, 메일 프로그램 접속 허용(services)과 메일함 크기(quotaBytes)만 저장합니다. 삭제도 할 수 없습니다.
if (target && target.startsWith('wm:')) {
if (method !== 'PATCH') return bad('webmaker_managed', 403);
const ext = await externalUsers(env); const acc = ext.accounts.find((a) => a.id === target); if (!acc) return bad('not_found', 404);
if (body.quotaBytes !== undefined) { checkUserQuota(env, body.quotaBytes); await writeUserQuota(env, target, body.quotaBytes); }
const prefs = body.services && typeof body.services === 'object' ? await writePrefs(env, acc, body.services) : await readPrefs(env, acc);
await audit(env, user.login, 'user_update', target, { services: !!body.services, ...(body.quotaBytes !== undefined ? { quotaBytes: Number(body.quotaBytes) } : {}) });
return json({ ok: true, services: { pop: prefs.pop, imap: prefs.imap } });
}
if (method === 'PATCH' && target) {
// 0.9.0: 자기 계정의 비밀번호는 관리자 페이지에서 바꾸지 않습니다(웹 메일 "도구 → 설정" 에서 현재 비밀번호 확인을 거쳐 바꿈).
// 다른 계정의 비밀번호는 관리자가 한 번 입력으로 재설정합니다. 화면 보조 필드(passwordConfirm/currentPassword)는 무시합니다.
const { currentPassword, passwordConfirm, quotaBytes, ...fields } = body;
if (fields.password && target === user.id) return bad('self_password_settings', 422);
// 자기 계정의 역할을 내리거나 정지하면 관리자 페이지에서 바로 쫓겨나고(독립 실행이면 남은 관리자가 없어 영영 잠김) 되돌릴 길이 없으므로 거부합니다(0.9.3).
if (target === user.id && ((fields.role !== undefined && fields.role !== 'admin') || fields.status === 'suspended')) return bad('cannot_modify_self', 409);
// 메일함 크기는 먼저 검사만 하고, 계정 수정이 성공한 뒤에 저장합니다(예전에는 없는 계정·정책 위반 비밀번호로 실패해도 크기가 먼저 저장됐습니다, 0.9.3).
if (quotaBytes !== undefined) checkUserQuota(env, quotaBytes);
await updateUser(env, target, fields);
if (quotaBytes !== undefined) await writeUserQuota(env, target, quotaBytes);
await audit(env, user.login, 'user_update', target, { fields: Object.keys(fields).filter((k) => k !== 'password'), password: fields.password ? true : undefined, ...(quotaBytes !== undefined ? { quotaBytes: Number(quotaBytes) } : {}), ...(fields.status === 'active' ? { approve: true } : {}) });
return json({ ok: true });
}
if (method === 'DELETE' && target) { if (target === user.id) return bad('cannot_delete_self', 409); await deleteUser(env, target); await audit(env, user.login, 'user_delete', target); return json({ ok: true }); }
return bad('not_found', 404);
}
if (sessionScope === 'admin') return bad('forbidden', 403);
// ---------------------------------------------------------------- 웹 메일
if (path[0] === 'settings' && method === 'GET') return await settings(env, user);
if (path[0] === 'prefs' && method === 'GET') return json({ ok: true, prefs: await readPrefs(env, user) });
if (path[0] === 'prefs' && method === 'PATCH') {
// 화면 기본값(remoteImages)·메일 프로그램 접속 허용(pop/imap)·메일 전달(forward)·자동 응답(autoReply)을 사용자가 직접 바꿉니다.
// 아는 키만 저장하고, 전달 주소·자동 응답 기간 검사 실패는 writePrefs 가 던지는 코드(forward_invalid 등)를 422 로 돌려줍니다.
const body = await request.json().catch(() => null); if (!body) return bad('bad_request');
const next = await writePrefs(env, user, body);
await audit(env, user.login, 'prefs_update', null, { keys: Object.keys(body).slice(0, 10), ...(body.forward ? { forward: next.forward.enabled ? next.forward.to : 'off' } : {}), ...(body.autoReply ? { autoReply: next.autoReply.enabled ? (next.autoReply.noLimit ? 'unlimited' : `${next.autoReply.start || 'now'}~${next.autoReply.end}`) : 'off' } : {}) });
return json({ ok: true, prefs: next, autoReplyActive: autoReplyActive(next.autoReply) });
}
// 비밀번호 변경(0.9.0~): 웹 메일 설정 화면. 게이트웨이 계정만(www 관리자는 www 사이트에서 관리). 현재 비밀번호가 맞고 새 비밀번호 두 입력이 같아야 합니다.
// 바꾸면 이 계정의 세션이 모두 끝나므로(탈취된 세션 차단) 화면은 다시 로그인하게 안내합니다.
if (path[0] === 'password' && method === 'PATCH') {
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request');
// 외부 계정(www 관리자·웹메이커 계정)의 비밀번호는 그쪽 서비스에서 바꿉니다(1.0.8: webmaker_managed).
if (user.source !== 'local') return bad(user.source === 'webmaker' ? 'webmaker_managed' : 'www_managed', 403);
const password = String(body.password ?? ''); if (!password) return bad('bad_request');
if (body.passwordConfirm !== undefined && String(body.passwordConfirm) !== password) return bad('password_mismatch', 422);
// 현재 비밀번호 확인에도 로그인과 같은 제한(계정·IP 당 15분 10회)을 둡니다. 훔친 세션으로 비밀번호를 무제한 대입하지 못하게(0.9.3).
const pwKeys = ['pwchange:' + user.id, 'pwchange:' + ip];
if (pwKeys.some((k) => env.RATE.blocked(k, { limit: 10 }))) return bad('rate_limited', 429);
if (!(await checkCurrentPassword(env, user.id, body.currentPassword))) { pwKeys.forEach((k) => env.RATE.hit(k, { limit: 10, windowSec: 900 })); await audit(env, user.login, 'password_change_denied', user.id, { ip }); return bad('current_password_invalid', 422); }
await updateUser(env, user.id, { password }); await audit(env, user.login, 'password_change', user.id, { ip });
return json({ ok: true }, 200, { 'Set-Cookie': sessionCookie('', { clear: true, scope: 'mail', path: prefix }) });
}
if (path[0] !== 'mail') return bad('not_found', 404);
const [, id, action, sub] = path;
const url = new URL(request.url);
// 메일함은 언제나 로그인한 계정의 메일함입니다(0.9.0: 관리자의 다른 메일함 열람 삭제). ?mailbox= 가 와도 무시합니다.
const mailbox = user.mailbox, scope = user;
const canSee = (m) => m.mailbox === mailbox;
if (id === 'status' && method === 'GET') return json({ ok: true, mailbox, ...await outboundStatus(env, { force: url.searchParams.get('force') === '1' }) });
// 동기화 시작. 목록 화면의 1분 자동 새로 고침은 { quick: true } 를 보내 "빠른 확인"(이미 아는 메일에서 멈춤)으로,
// 설정 화면의 "지금 동기화" 버튼은 본문 없이 보내 전체 훑기로 동작합니다(store.syncMailbox 설명 참고).
if (id === 'sync' && method === 'POST') { const b = await request.json().catch(() => ({})); const quick = b?.quick === true; return json({ ok: true, ...startSync(env, quick ? 'poll' : 'manual', { quick }) }); }
// usageBytes/quotaBytes(0.9.0): 사용량은 MIME 기준(저장 원본 크기), quotaBytes 는 이 메일함에 적용 중인 최대 크기(0 = 제한 없음). 사이드바 사용량 막대가 씁니다.
if (id === 'counts' && method === 'GET') return json({ ok: true, counts: await folderCounts(env, mailbox), usageBytes: await mailboxUsage(env, mailbox), quotaBytes: await mailboxQuota(env, mailbox), mailbox, syncing: isSyncing(env) });
if (id === 'sanitize' && method === 'POST') { const b = await request.json().catch(() => ({})); return json({ ok: true, ...sanitizeHtml(b.html) }); }
if (id === 'bulk' && method === 'POST') {
const body = await request.json().catch(() => null); if (!body || !Array.isArray(body.ids)) return bad('bad_request');
let done = 0;
for (const mid of body.ids.slice(0, 200)) { const m = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(String(mid)).first(); if (!m || !canSee(m)) continue; try { if (await applyAction(env, m, body, user)) done++; } catch { /* 개별 실패는 건너뜁니다 */ } }
return json({ ok: true, done });
}
if (method === 'GET' && !id) {
const folder = url.searchParams.get('folder') || 'inbox', q = clean(url.searchParams.get('q'), 200), unreadOnly = url.searchParams.get('unread') === '1', flaggedOnly = url.searchParams.get('flagged') === '1', attachOnly = url.searchParams.get('attach') === '1';
if (!FOLDERS.includes(folder)) return bad('validation', 422);
const page = Math.max(0, Math.min(100000, Number(url.searchParams.get('page')) || 0)), size = Math.min(100, Math.max(10, Number(url.searchParams.get('size')) || 50));
// 검색어의 % _ \ 는 LIKE 와일드카드가 아니라 글자 그대로 찾습니다(0.9.0 수정, 예전에는 "_" 하나로 모든 메일이 나왔음).
const like = Array(4).fill('%' + q.replace(/[\\%_]/g, '\\$&') + '%');
const where = `mailbox=? AND folder=? AND (subject LIKE ? ESCAPE '\\' OR text LIKE ? ESCAPE '\\' OR sender LIKE ? ESCAPE '\\' OR recipients_json LIKE ? ESCAPE '\\')${unreadOnly ? ' AND read_at IS NULL' : ''}${flaggedOnly ? " AND EXISTS(SELECT 1 FROM imap_flags f WHERE f.message_id=messages.id AND f.flags LIKE '%\\\\Flagged%')" : ''}${attachOnly ? " AND EXISTS(SELECT 1 FROM attachments a WHERE a.message_id=messages.id AND (a.disposition='attachment' OR a.content_id IS NULL))" : ''}`;
// attachOnly: "첨부" 보기 필터. 예전에는 화면이 받아 온 한 페이지(50통) 안에서만 걸러, 페이지 수·합계는 전체 기준인데
// 목록은 비어 보이는 문제가 있었습니다. 서버 조건으로 옮겨 합계·페이지가 필터와 일치합니다.
const rows = await env.DB.prepare(`SELECT id,direction,folder,sender,recipients_json,subject,substr(text,1,160) AS preview,state,source,raw_status,read_at,received_at,created_at,updated_at,mailbox,
(SELECT COUNT(*) FROM attachments a WHERE a.message_id=messages.id AND (a.disposition='attachment' OR a.content_id IS NULL)) AS attachment_count,(SELECT COUNT(*) FROM pop_deliveries d WHERE d.message_id=messages.id) AS pop_delivered,
(SELECT flags FROM imap_flags f WHERE f.message_id=messages.id) AS flags
FROM messages WHERE ${where} ORDER BY received_at DESC LIMIT ? OFFSET ?`).bind(mailbox, folder, ...like, size, page * size).all();
const total = await env.DB.prepare(`SELECT COUNT(*) n FROM messages WHERE ${where}`).bind(mailbox, folder, ...like).first();
return json({ ok: true, messages: rows.results.map((m) => ({ ...m, flagged: String(m.flags || '').includes('\\Flagged'), flags: undefined })), total: total.n, page, size, counts: await folderCounts(env, mailbox), mailbox, usageBytes: await mailboxUsage(env, mailbox), quotaBytes: await mailboxQuota(env, mailbox), syncing: isSyncing(env) });
}
if (method === 'POST' && !id) { const body = await request.json().catch(() => null); if (!body) return bad('bad_request'); if (body.html) body.html = stripPrefix(body.html, prefix, env); return json({ ok: true, id: await saveDraft(env, body, undefined, scope) }, 201); }
if (!id) return bad('not_found', 404);
const mail = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first(); if (!mail || !canSee(mail)) return bad('not_found', 404);
if (method === 'GET') {
const attachments = (await env.DB.prepare('SELECT id,filename,content_type,content_id,disposition,size,(bytes IS NOT NULL) AS available FROM attachments WHERE message_id=? ORDER BY rowid').bind(id).all()).results;
if (action === 'original') {
const raw = await env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(id).first(); if (!raw) return bad('not_found', 404);
return new Response(Buffer.from(raw.eml), { headers: { 'Content-Type': 'message/rfc822', 'Content-Disposition': `attachment; filename="${id}.eml"`, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' } });
}
if (action === 'attachments' && !sub) return json({ ok: true, attachments });
if (action === 'attachments' && sub) {
const row = await env.DB.prepare('SELECT filename,content_type,bytes FROM attachments WHERE id=? AND message_id=?').bind(sub, id).first(); if (!row?.bytes) return bad('not_found', 404);
const inline = url.searchParams.get('inline') === '1' && /^image\//.test(row.content_type);
return new Response(Buffer.from(row.bytes), { headers: { 'Content-Type': inline ? row.content_type : 'application/octet-stream', 'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(row.filename)}`, 'Cache-Control': 'private, max-age=3600', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'" } });
}
if (action === 'files' && !sub) return json({ ok: true, files: await listLargeFiles(env, id) }); // 대용량 파일 목록(0.9.3)
if (action) return bad('not_found', 404);
if (!mail.read_at && mail.direction === 'inbound' && url.searchParams.get('peek') !== '1') await env.DB.prepare('UPDATE messages SET read_at=? WHERE id=?').bind(now(), id).run();
const popDeliveries = (await env.DB.prepare('SELECT user_id,delivered_at,via FROM pop_deliveries WHERE message_id=?').bind(id).all()).results;
const flags = JSON.parse((await env.DB.prepare('SELECT flags FROM imap_flags WHERE message_id=?').bind(id).first())?.flags || '[]');
// autoActions: 이 메일에 한 자동 처리(전달·자동 응답) 기록. 상세 정보 상자에 "전달됨 → 주소 / 자동 응답 보냄" 으로 보입니다.
// largeFiles(0.9.3): 보내는 메일(초안·보낸 메일)에 딸린 대용량 파일과 링크 상태(남은 횟수·기한·만료).
return json({ ok: true, message: { ...mail, original_html: undefined, previewHtml: previewHtml(mail.html, id, attachments, prefix), attachments, popDeliveries, autoActions: await autoActionsOf(env, id), flagged: flags.includes('\\Flagged'), answered: flags.includes('\\Answered'), largeFiles: mail.direction === 'outbound' ? await listLargeFiles(env, id) : [] } });
}
// 대용량 파일 떼기(0.9.3): 초안이면 파일 삭제, 보낸 메일이면 링크 끊기(파일 삭제·만료 처리). 업로드는 index.mjs 의 스트리밍 경로(POST /api/files)가 맡습니다.
if (method === 'DELETE' && action === 'files' && sub) { await removeLargeFile(env, sub, mailbox, id); await audit(env, user.login, 'large_file_remove', sub, { message: id }); return json({ ok: true }); }
if (method === 'POST' && action === 'send') {
// body.largeFiles({ days, downloads }, 0.9.3): 초안에 대용량 파일이 있을 때의 발송 조건. 없으면 sendDraft 가 large_file_terms 로 거부합니다.
const b = await request.json().catch(() => ({}));
await sendDraft(env, id, { largeFiles: b && typeof b === 'object' ? b.largeFiles : null }); await audit(env, user.login, 'mail_send', id); if (mail.headers_json && JSON.parse(mail.headers_json)['In-Reply-To']) { const parent = await env.DB.prepare('SELECT id FROM messages WHERE message_id=? AND mailbox=?').bind(JSON.parse(mail.headers_json)['In-Reply-To'], mailbox).first(); if (parent) await setFlag(env, parent.id, '\\Answered', true); } return json({ ok: true }); }
if (method === 'POST' && action === 'attachments') {
// JSON { from, ids }: 다른 메일(전달하는 원본)의 첨부를 이 초안으로 복사합니다(0.8.25~, 웹 "전달" 이 첨부를 함께 보내기 위해). 같은 메일함의 메일만 원본이 될 수 있습니다.
if ((request.headers.get('content-type') || '').includes('application/json')) {
const body = await request.json().catch(() => null); if (!body || typeof body.from !== 'string' || !Array.isArray(body.ids)) return bad('bad_request');
const origin = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(body.from).first(); if (!origin || !canSee(origin)) return bad('not_found', 404);
const ids = await copyAttachments(env, id, origin.id, body.ids.map(String).slice(0, 50));
return json({ ok: true, ids }, 201);
}
const form = await request.formData().catch(() => null); if (!form) return bad('bad_request');
const ids = []; const inline = url.searchParams.get('inline') === '1';
for (const file of form.getAll('files')) { if (typeof file === 'string') continue; ids.push(await addAttachment(env, id, { filename: file.name, contentType: file.type, bytes: Buffer.from(await file.arrayBuffer()), inline })); }
return json({ ok: true, ids, urls: ids.map((aid) => `${prefix}/api/mail/${encodeURIComponent(id)}/attachments/${encodeURIComponent(aid)}?inline=1`) }, 201);
}
if (method === 'DELETE' && action === 'attachments' && sub) { await removeAttachment(env, id, sub); return json({ ok: true }); }
if (method === 'PATCH' && !action) {
const body = await request.json().catch(() => null); if (!body) return bad('bad_request');
if (!(await applyAction(env, mail, body, user))) {
// 동작도 아니고 초안 내용도 없는 본문(예: {})은 거부합니다. 예전에는 saveDraft 로 흘러가 받는 사람·제목·본문이 빈 값으로 덮였습니다(0.8.16 수정).
if (!['to', 'cc', 'bcc', 'subject', 'html', 'text'].some((k) => body[k] !== undefined)) return bad('bad_request');
if (body.html) body.html = stripPrefix(body.html, prefix, env);
await saveDraft(env, body, id, scope);
}
return json({ ok: true, id });
}
if (method === 'DELETE' && !action) {
if (mail.folder !== 'trash') return bad('mail_trash_first', 409);
await purge(env, mail); await audit(env, user.login, 'mail_delete', id); return json({ ok: true });
}
return bad('not_found', 404);
} catch (e) {
const status = e.message === 'not_found' ? 404 : e.message === 'bad_request' ? 400 : ['mail_move_invalid', 'mail_sending', 'mail_locked', 'mail_trash_first', 'login_taken', 'cannot_delete_self', 'cannot_modify_self', 'account_limit_reached'].includes(e.message) ? 409 : e.message === 'forbidden' ? 403 : ['signup_disabled', 'large_files_disabled'].includes(e.message) ? 404 : e.message === 'file_too_large' ? 413 : 422;
// weak_password 에는 어긋난 항목(problems)과 정책(policy)을 붙여 화면이 "6자 이상, 숫자 포함" 같은 구체적 안내를 만들 수 있게 합니다.
// 제한 오류(too_many_recipients·attachment_too_large·quota_exceeded·storage_full·monthly_limit_exceeded·limit_exceeds_system·account_limit_reached)에는 적용된 상한(limit)과 항목(field)을 붙입니다.
return bad(e.message || 'server_error', status, { ...(e.providerMessage ? { detail: e.providerMessage } : {}), ...(e.providerStatus ? { providerStatus: e.providerStatus } : {}), ...(e.problems ? { problems: e.problems, policy: e.policy } : {}), ...(e.limit !== undefined ? { limit: e.limit } : {}), ...(e.field ? { field: e.field } : {}) });
}
}
/**
* 회원 가입(0.9.0~). 관리자가 켜 두었을 때만 받습니다(아니면 404 signup_disabled). IP 당 1시간 5회로 제한합니다.
* 승인 옵션이 켜져 있으면 승인 대기 계정으로 만들고(로그인 불가, 관리자 페이지에 "승인 대기" 로 보임), 아니면 바로 쓸 수 있는 일반 계정을 만듭니다.
* 역할은 언제나 '사용자' 이고 비밀번호 정책·계정 수 제한은 관리자가 만드는 계정과 같습니다.
*/
async function signup(env, request, ip) {
const cfg = await readSignup(env); if (!cfg.enabled) return bad('signup_disabled', 404);
if (env.RATE.blocked('signup:' + ip, { limit: 5 })) return bad('rate_limited', 429);
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request');
const password = String(body.password ?? '');
if (body.passwordConfirm !== undefined && String(body.passwordConfirm) !== password) return bad('password_mismatch', 422);
env.RATE.hit('signup:' + ip, { limit: 5, windowSec: 3600 });
const id = await createUser(env, { login: body.login, name: body.name, password, role: 'user' }, 'signup', { pending: cfg.approval });
await audit(env, null, 'signup', id, { login: clean(body.login, 64), ip, pending: cfg.approval });
return json({ ok: true, id, pending: cfg.approval }, 201);
}
/** 관리자 설정 응답: 회원 가입·관리자 제한·서비스 스위치 + 시스템 제한(읽기 전용)·계정 수. */
async function adminConfig(env) {
const sys = systemLimits(env);
// largeFiles / systemLargeFiles / largeFileUsage(0.9.3): 대용량 첨부파일의 관리자 설정, 시스템 상한(읽기 전용), 디스크 사용량.
return { signup: await readSignup(env), limits: await readAdminLimits(env), services: await readServices(env), system: sys, accounts: { used: await accountCount(env), limit: sys.accounts }, largeFiles: await readLargeFileSettings(env), systemLargeFiles: systemLargeFiles(env), largeFileUsage: await largeFileStats(env) };
}
/**
* 내장 Haraka → 게이트웨이 수신 전달(1.0.0). Haraka 플러그인(haraka/plugins/arimail.js)이 같은 서버 안에서 로컬 HTTP 로 부릅니다.
* GET /api/inbound/haraka/rcpt?addr=… RCPT 단계: 이 주소가 우리 메일함인지(아니면 Haraka 가 "No such user" 로 바로 거부해 역산란을 막습니다)
* POST /api/inbound/haraka DATA 가 끝난 메일 원본(본문) + X-AriMail-Id(트랜잭션 id) / X-AriMail-From / X-AriMail-Rcpt(JSON 배열)
* 인증: X-AriMail-Token 이 설정 파일의 HARAKA_TOKEN(자동 생성)과 같아야 하고, 수신 방식이 haraka 일 때만 열립니다(그 밖에는 404).
* 응답: 저장하면 200 { imported }, 건너뛰면 200 { skipped }(플러그인이 quota_exceeded/storage_full 은 452 로 되돌려 보낸 쪽이 나중에 다시 시도), 실패는 500(플러그인이 451).
* 기록: webhook_events 에 type 'haraka.received' 로 남겨 상태 화면의 수신 기록과 함께 보입니다.
*/
async function harakaInbound(env, request, path, method) {
// 1.0.7: Haraka 는 서버에 하나이므로 이 엔드포인트도 하나입니다. 토큰은 기본 도메인의 HARAKA_TOKEN(haraka/run.mjs 가 그 설정 파일을 읽음)으로 확인하고,
// 받는 사람 주소의 @ 뒤로 어느 도메인(테넌트)의 메일함인지 고릅니다. 수신 방식이 haraka 인 도메인이 하나도 없으면 예전처럼 404 입니다.
const tenants = env.TENANTS, primary = tenants?.primary?.env || env;
const receivers = tenants ? tenants.envs().filter((e) => inboundMode(e) === 'haraka') : (inboundMode(env) === 'haraka' ? [env] : []);
if (!receivers.length) return bad('not_found', 404);
const token = request.headers.get('x-arimail-token') || '';
if (!primary.HARAKA_TOKEN || !timingEqual(String(token), String(primary.HARAKA_TOKEN))) return bad('unauthenticated', 401);
/** 주소가 속한 도메인의 env(수신 방식이 haraka 인 것만). 테넌트가 없으면(시험 등) 이 env. */
const pick = (addr) => { if (!tenants) return receivers[0]; const t = tenants.byAddress(addr); return t && t.entry.enabled !== false && inboundMode(t.env) === 'haraka' ? t.env : null; };
if (path[2] === 'rcpt' && method === 'GET') { const addr = bareAddress(new URL(request.url).searchParams.get('addr') || ''); const target = pick(addr); return json({ ok: true, local: !!target && (await mailboxList(target)).includes(addr) }); }
if (!path[2] && method === 'POST') {
const raw = Buffer.from(await request.arrayBuffer()); if (!raw.length) return bad('bad_request');
let rcpt = []; try { rcpt = JSON.parse(request.headers.get('x-arimail-rcpt') || '[]'); } catch { rcpt = []; }
if (!Array.isArray(rcpt)) rcpt = [];
const id = cleanStr(request.headers.get('x-arimail-id'), 120) || newId(), stamp = nowIso(), from = request.headers.get('x-arimail-from') || '';
// 받는 사람을 도메인별로 묶어 각 도메인의 DB 에 저장합니다(한 트랜잭션이 두 도메인의 주소를 담을 수 있음). 도메인을 못 찾는 주소는 첫 수신 도메인으로(예전 동작).
const groups = new Map();
for (const a of rcpt) { const target = pick(bareAddress(a)) || receivers[0]; if (!groups.has(target)) groups.set(target, []); groups.get(target).push(a); }
if (!groups.size) groups.set(receivers[0], []);
const record = (target, status, detail) => target.DB.prepare('INSERT OR REPLACE INTO webhook_events(id,type,provider_id,received_at,status,detail) VALUES(?,?,?,?,?,?)').bind('haraka:' + id, 'haraka.received', 'haraka:' + id, stamp, status, detail || null).run();
const results = []; let failed = null;
for (const [target, list] of groups) {
try {
const r = await ingestRawMail(target, raw, { id, rcpt: list, from, known: await mailboxList(target) });
// 같은 트랜잭션이 다시 오면(Haraka 재시도) 이미 'stored' 로 남은 기록을 'skipped' 로 덮어쓰지 않습니다.
if (r.skipped !== 'exists') await record(target, r.imported ? 'stored' : 'skipped', r.skipped || null);
results.push(r);
} catch (e) { console.error('Haraka 수신 저장 실패', target.DOMAIN || '', id, e.message); await record(target, 'failed', String(e.message || 'ingest_failed').slice(0, 300)); failed = failed || e; }
}
const imported = results.reduce((n, r) => n + Number(r.imported || 0), 0);
if (failed && !imported) return bad(failed.message || 'ingest_failed', 500);
// 도메인이 하나면 예전과 같은 모양({ ok, imported, id, mailbox, … }), 여럿이면 합계와 도메인별 결과.
if (results.length === 1) return json({ ok: true, ...results[0] });
const skipped = imported ? undefined : results.find((r) => r.skipped)?.skipped;
return json({ ok: true, imported, ...(skipped ? { skipped } : {}), results });
}
return bad('not_found', 404);
}
async function webhook(env, request) {
const raw = Buffer.from(await request.arrayBuffer());
const headers = { 'svix-id': request.headers.get('svix-id'), 'svix-timestamp': request.headers.get('svix-timestamp'), 'svix-signature': request.headers.get('svix-signature') };
if (!env.RESEND_WEBHOOK_SECRET) { console.error('웹훅 비밀키 없음 — RESEND_WEBHOOK_SECRET 를 설정하세요'); return bad('webhook_not_configured', 503); }
const check = verifySvix(env.RESEND_WEBHOOK_SECRET, headers, raw);
if (!check.ok) return bad(check.error, 401);
let event; try { event = JSON.parse(raw.toString('utf8')); } catch { return bad('bad_request'); }
const eventId = headers['svix-id'] || uid(), stamp = now();
const type = String(event?.type || ''), data = event?.data || {};
// 같은 svix-id 는 한 번만 처리합니다. 단, 저장에 실패해 500 을 돌려준 이벤트('failed')는 Resend 가 같은 id 로
// 다시 보내므로 중복으로 보지 않고 다시 처리합니다(예전에는 재시도가 duplicate 로 끝나 메일이 저장되지 않았습니다).
if (await env.DB.prepare("SELECT id FROM webhook_events WHERE id=? AND status<>'failed'").bind(eventId).first()) return json({ ok: true, duplicate: true });
const record = (status, detail) => env.DB.prepare('INSERT OR REPLACE INTO webhook_events(id,type,provider_id,received_at,status,detail) VALUES(?,?,?,?,?,?)').bind(eventId, type, data.email_id || null, stamp, status, detail || null).run();
if (type !== 'email.received') { await record('ignored', 'type'); return json({ ok: true, ignored: type }); }
const known = await knownMailboxes(env);
if (!addresses([...(data.to || []), ...(data.cc || []), ...(data.bcc || []), ...(data.received_for || [])]).some((a) => known.includes(a))) { await record('ignored', 'other_mailbox'); return json({ ok: true, ignored: 'other_mailbox' }); }
try {
const result = await ingestReceived(env, data.email_id, { source: 'webhook', known, summary: { from: data.from, to: data.to, subject: data.subject, created_at: data.created_at, attachments: data.attachments } });
await record(result.imported ? 'stored' : 'skipped', result.skipped || result.raw);
return json({ ok: true, ...result });
} catch (e) {
// 5xx 를 돌려주면 Resend 가 재시도합니다. 실패 이유는 기록만 남깁니다.
await record('failed', (e.message + ' ' + (e.providerMessage || '')).slice(0, 300));
return bad(e.message || 'ingest_failed', 500);
}
}
/**
* 웹 메일 설정 화면용 정보: 이 계정으로 메일 프로그램을 설정할 때 필요한 값(IMAP·SMTP·POP 호스트·포트·TLS 상태)과 사용자 설정.
* 서버 운영 정보(웹훅·동기화·저장 통계)는 0.8.4 부터 관리자 페이지의 adminStatus 로 옮겼습니다.
* 0.9.0: services(관리자가 POP/IMAP 서비스 자체를 껐는지), limits(첨부 한 개 최대 MIME 크기·받는 사람 수), quota(메일함 최대 크기·사용량),
* canChangePassword(게이트웨이 계정이면 설정 화면에서 비밀번호를 바꿀 수 있음)를 더했습니다.
*/
async function settings(env, user) {
// host: 웹 메일 주소. serverHost(1.0.7): 메일 프로그램이 접속할 IMAP·POP·SMTP 서버 이름 — 서버에 하나뿐인 메일 포트·인증서는 기본 도메인의 호스트 이름이므로
// 추가 도메인의 사용자도 그 이름으로 접속합니다(자기 도메인 이름으로 접속하면 인증서 이름이 맞지 않음).
const host = env.PUBLIC_HOST || 'mail.arihakdang.com', serverHost = env.MAIL_SERVER_HOST || host;
const plainOk = await plainLoginAllowed(env);
return json({
ok: true, mailbox: user.mailbox, user: { login: user.login, name: user.name, role: user.role, source: user.source }, host, prefs: await readPrefs(env, user),
// 보관 정책: 0.8.0 부터 기한 없음. 화면은 이 값으로 "삭제할 때까지 보관" 문구를 보여 줍니다.
retention: 'unlimited',
services: await readServices(env), limits: { attachmentBytes: await attachmentLimit(env), recipients: await recipientLimit(env) },
// managedBy(1.0.8): 비밀번호를 어디서 바꾸는지(local 이면 여기, www / webmaker 면 그 서비스). 화면은 이 값으로 안내 문구를 고릅니다.
quota: { usageBytes: await mailboxUsage(env, user.mailbox), limit: await mailboxQuota(env, user.mailbox) }, canChangePassword: user.source === 'local', managedBy: user.source, policy: await readPasswordPolicy(env), largeFiles: await readLargeFileSettings(env),
// SMTP(보내는 메일 서버): 이 게이트웨이의 제출 서버(smtp.mjs). 계정·비밀번호는 IMAP·POP 과 같은 로그인 정보이고,
// 발송 서비스의 API 키는 서버 내부에만 있으므로 화면에 키 관련 정보(설정 여부 포함)를 내보내지 않습니다.
smtp: { host: serverHost, tls: !!env.POP_TLS_ACTIVE, ports: { smtps: Number(env.SMTPS_PORT || 465), starttls: Number(env.SMTP_PORT || 587) }, from: `${user.name || env.MAIL_FROM_NAME || 'Ari Hak Dang'} <${user.mailbox}>` },
// plaintext(0.9.7): 110/143 에서 TLS 없이 로그인할 수 있는지(관리자 설정 "평문 로그인 허용" 또는 POP_ALLOW_PLAINTEXT=1). 화면이 110 포트 줄에 표시합니다.
pop: { host: serverHost, tls: !!env.POP_TLS_ACTIVE, ports: { pop3s: Number(env.POPS_PORT || 995), starttls: Number(env.POP_PORT || 110) }, plaintext: plainOk },
imap: { host: serverHost, tls: !!env.POP_TLS_ACTIVE, ports: { imaps: Number(env.IMAPS_PORT || 993), starttls: Number(env.IMAP_PORT || 143) }, plaintext: plainOk, mailboxes: ['INBOX', 'Sent', 'Drafts', 'Trash'] },
});
}
/**
* 관리자 페이지 "서버 상태": 발송 서비스 웹훅·동기화 기록·저장 통계·POP 전달 기록·서비스 포트 상태. 관리자 세션으로만 볼 수 있습니다.
* 0.9.0: stats(메일 현황 — 일·주·월 발신/수신/수발신, 메일함별·전체 사용량, 시스템 제한 대비 사용 비율)와 services(POP/IMAP 스위치)를 더했습니다.
*/
async function adminStatus(env) {
const last = (await env.DB.prepare('SELECT * FROM sync_runs ORDER BY id DESC LIMIT 5').all()).results;
const webhooks = (await env.DB.prepare("SELECT status,COUNT(*) n,MAX(received_at) last FROM webhook_events GROUP BY status").all()).results;
const counts = await env.DB.prepare("SELECT COUNT(*) total,SUM(CASE WHEN raw_status='stored' THEN 1 ELSE 0 END) raw_stored,SUM(CASE WHEN raw_status='built' THEN 1 ELSE 0 END) raw_built,SUM(CASE WHEN source='webhook' THEN 1 ELSE 0 END) via_webhook,SUM(CASE WHEN source='sync' THEN 1 ELSE 0 END) via_sync,SUM(CASE WHEN provider_id LIKE 'haraka:%' THEN 1 ELSE 0 END) via_haraka FROM messages WHERE direction='inbound'").first();
const perMailbox = (await env.DB.prepare('SELECT mailbox,COUNT(*) n,COALESCE(SUM(raw_size),0) bytes FROM messages GROUP BY mailbox ORDER BY mailbox').all()).results;
const pop = await env.DB.prepare('SELECT COUNT(*) n,COUNT(DISTINCT user_id) accounts,MAX(delivered_at) last FROM pop_deliveries').first();
const host = env.PUBLIC_HOST || 'mail.arihakdang.com';
return json({
ok: true, host, version: env.APP_VERSION || null, mailbox: mailboxOf(env), tls: !!env.POP_TLS_ACTIVE, retention: 'unlimited',
ports: { pop3s: Number(env.POPS_PORT || 995), pop3: Number(env.POP_PORT || 110), imaps: Number(env.IMAPS_PORT || 993), imap: Number(env.IMAP_PORT || 143), smtps: Number(env.SMTPS_PORT || 465), submission: Number(env.SMTP_PORT || 587) },
services: await readServices(env), plainLogin: await plainLoginAllowed(env),
// 1.0.0: 수·발신 방식과 발신 서비스 상태(60초 캐시), Haraka 상태(haraka/run.mjs 가 쓰는 상태 파일), 설정 파일 경로.
mail: { inbound: inboundMode(env), outbound: outboundMode(env), status: await outboundStatus(env).catch((e) => ({ provider: e.message })), haraka: usesHaraka(env) ? harakaStatus(env) : null, configFile: configFile(env) },
webhook: { url: `https://${host}/api/webhooks/resend`, secretConfigured: !!env.RESEND_WEBHOOK_SECRET, events: webhooks },
sync: { intervalMinutes: Math.round(Number(env.SYNC_INTERVAL_MS || 3600000) / 60000), running: isSyncing(env), runs: last },
storage: counts, mailboxes: perMailbox, pop: { deliveries: pop },
stats: await mailStats(env, await knownMailboxes(env), await listUsers(env)), largeFiles: await largeFileStats(env),
});
}
+416
View File
@@ -0,0 +1,416 @@
/**
* 인증.
* - www.arihakdang.com 관리자 계정: www DB(users)를 읽기 전용으로 읽어 login_id/email + PBKDF2(www 와 같은 형식)로 검사합니다.
* 이 계정은 게이트웨이의 관리자(계정 관리 가능)이며 기본 메일함(MAILBOX)을 씁니다. 별칭 admin / admin@... 도 여기로 연결됩니다.
* - 게이트웨이 사용자 계정(mail_users): 관리자가 만들거나(0.9.0 부터는 회원 가입으로도) 생기는 계정. 자기 메일함 주소를 갖고 웹·POP·IMAP·SMTP 에 같은 비밀번호로 접속합니다.
* - 0.9.0 부터 관리자도 메일 기능에서는 자기 메일함만 봅니다(다른 메일함으로 전환하는 기능 삭제). 관리자 권한은 관리자 페이지(/admin)의 계정·설정 관리에만 쓰입니다.
* - webmaker 연동(1.0.8, LOGIN_MODE=webmaker): 웹메이커의 사용자 파일을 읽기 전용으로 참조해 같은 방식(scrypt)으로 검사합니다(server/webmaker.mjs).
* 웹메이커 계정이 먼저이고, 그다음 게이트웨이 계정(mail_users)을 봅니다. 웹·POP·IMAP·SMTP 모두 이 verifyUser 를 거치므로 어디서나 같은 비밀번호입니다.
*/
import { openDb } from './db.mjs';
import { json, now, plus, secret, sha256, safeEqual, clean, uid, isEmail, addresses } from './util.mjs';
import { systemLimits, accountCount, clearUserQuota } from './limits.mjs';
import { isWebmaker, verifyWebmaker, webmakerPrincipalById, webmakerByMailbox, webmakerMailboxes, webmakerAccounts, webmakerHasLogin } from './webmaker.mjs';
export const SESSION_COOKIE = 'mg_session';
// 관리자 페이지(/admin)는 웹 메일과 별도의 로그인·세션을 씁니다(0.8.4~). 쿠키 이름이 달라 두 로그인이 서로 영향을 주지 않습니다.
export const ADMIN_COOKIE = 'mg_admin';
// 세션 범위: 'mail' = 웹 메일(/), 'admin' = 관리자 페이지(/admin). 범위가 다른 세션 쿠키로는 상대편 API 를 쓸 수 없습니다.
export const SCOPES = ['mail', 'admin'];
const cookieName = (scope) => scope === 'admin' ? ADMIN_COOKIE : SESSION_COOKIE;
const SESSION_DAYS = 7;
/** 메일함 주소의 도메인 부분(예: arihakdang.com). 계정의 메일함은 항상 "로그인 ID@이 도메인" 입니다. */
export const mailDomain = (env) => mailboxOf(env).split('@')[1];
const mailboxOf = (env) => String(env.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase();
/**
* 독립 실행(standalone) 여부(0.8.18~). www 사이트 DB(WWW_DB_FILE / 테스트의 WWW_DB)가 없으면 독립 실행입니다.
* 독립 실행에서는 www 관리자가 없으므로 기본 메일함(MAILBOX)도 게이트웨이 계정(mail_users)이 갖습니다:
* - 로그인 ID 가 기본 메일함의 앞부분(예: admin)인 계정을 만들 수 있고, 그 계정의 메일함이 곧 기본 메일함입니다.
* - 첫 관리자 계정은 화면에서 만들 수 없으므로 server/user-cli.mjs 로 만듭니다.
*/
export const standalone = (env) => !env.WWW_DB && !env.WWW_DB_FILE;
/** 로그인 방식 이름(1.0.8): 'webmaker'(웹메이커 계정 연동) | 'www'(웹 서비스 DB 연동) | 'local'(자체 계정만). 화면·API 안내용. */
export const authMode = (env) => isWebmaker(env) ? 'webmaker' : standalone(env) ? 'local' : 'www';
async function pbkdf2(password, saltB64, iterations) {
const salt = Uint8Array.from(atob(saltB64), (c) => c.charCodeAt(0));
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, key, 256);
return btoa(String.fromCharCode(...new Uint8Array(bits)));
}
export async function verifyPassword(password, stored) {
if (!stored) return false;
const [scheme, iterations, salt, digest] = String(stored).split('$');
const n = Number(iterations);
if (scheme !== 'pbkdf2' || !salt || !digest || !Number.isInteger(n) || n < 1000 || n > 5000000) return false;
return safeEqual(await pbkdf2(password, salt, n), digest);
}
// 없는 계정의 로그인 시도에 쓰는 더미 해시(프로세스마다 한 번 만들어 재사용). 반복 횟수가 실제 계정과 같아 걸리는 시간이 같습니다.
let dummy = null; const dummyHash = async () => (dummy ??= await hashPassword(crypto.randomUUID()));
export async function hashPassword(password, iterations = 210000) {
const salt = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(16))));
return `pbkdf2$${iterations}$${salt}$${await pbkdf2(password, salt, iterations)}`;
}
// ------------------------------------------------------------------ www 관리자
// www DB 핸들은 파일 경로별로 하나씩 둡니다(1.0.7: 도메인마다 다른 웹 서비스 DB 와 연동할 수 있으므로 모듈 전역 하나로는 부족).
const wwwDbs = new Map(); // WWW_DB_FILE → DatabaseSync(읽기 전용)
function wwwHandle(env) {
if (env.WWW_DB) return env.WWW_DB;
if (!env.WWW_DB_FILE) throw new Error('www_db_not_configured');
let db = wwwDbs.get(env.WWW_DB_FILE);
if (!db) { db = openDb(env.WWW_DB_FILE, { readOnly: true }); wwwDbs.set(env.WWW_DB_FILE, db); }
return db;
}
function query(env, sql, params) {
try { return wwwHandle(env).prepare(sql).get(...params); }
catch {
// 배포 중 www DB 파일이 교체되면 핸들이 깨질 수 있어 한 번 닫고 다시 엽니다.
if (!env.WWW_DB && env.WWW_DB_FILE) { try { wwwDbs.get(env.WWW_DB_FILE)?.close(); } catch {} wwwDbs.delete(env.WWW_DB_FILE); }
return wwwHandle(env).prepare(sql).get(...params);
}
}
const COLS = 'SELECT id,email,login_id,name,role,status,password_hash FROM users';
/** 메일함 주소(admin@...)와 그 앞부분(admin)은 메일함 소유 관리자 계정의 별칭입니다.
* 소유자는 MAILBOX_OWNER_LOGIN → 이메일이 메일함 주소인 관리자 → 유일한 활성 관리자 순입니다. */
export function mailboxOwner(env) {
const mailbox = mailboxOf(env), configured = clean(env.MAILBOX_OWNER_LOGIN, 200).toLowerCase();
if (configured) return query(env, `${COLS} WHERE ${configured.includes('@') ? 'email' : 'login_id'} = ?`, [configured]) || null;
const byEmail = query(env, `${COLS} WHERE email = ?`, [mailbox]); if (byEmail) return byEmail;
const admins = wwwHandle(env).prepare(`${COLS} WHERE role = 'admin' AND status = 'active'`).all();
return admins.length === 1 ? admins[0] : null;
}
export function isMailboxAlias(env, login) { const mailbox = mailboxOf(env), key = clean(login, 200).toLowerCase(); return key === mailbox || key === mailbox.split('@')[0]; }
const wwwPrincipal = (row, env) => ({ id: 'www:' + row.id, source: 'www', login: row.login_id || row.email, email: row.email, name: row.name || '', role: 'admin', mailbox: mailboxOf(env) });
const localPrincipal = (row) => ({ id: row.id, source: 'local', login: row.login, email: row.mailbox, name: row.name || '', role: row.role, mailbox: row.mailbox });
/** 로그인 이름과 비밀번호로 사용자를 찾습니다. www 관리자(별칭 포함) → 게이트웨이 계정 순. 실패 사유는 나누지 않습니다. */
export async function verifyUser(env, login, password) {
const key = clean(login, 200).toLowerCase();
if (!key || !password) return null;
// webmaker 연동(1.0.8): 웹메이커 계정이 먼저입니다. 같은 로그인 ID 의 게이트웨이 계정이 있어도(createUser 가 막지만 연동 전에 만든 것) 웹메이커 쪽이 그 주소의 주인입니다.
// 웹메이커 계정이 아니거나 비밀번호가 틀리면 아래 게이트웨이 계정으로 넘어갑니다(관리자가 따로 만든 로컬 계정도 함께 쓸 수 있게).
if (isWebmaker(env)) { const wm = await verifyWebmaker(env, key, password); if (wm) return wm; }
let row = null;
try {
row = isMailboxAlias(env, key) ? mailboxOwner(env) : key.includes('@') ? query(env, `${COLS} WHERE email = ?`, [key]) : query(env, `${COLS} WHERE login_id = ?`, [key]);
} catch (e) { if (e.message !== 'www_db_not_configured') throw e; }
if (row && row.role === 'admin' && row.status === 'active' && (await verifyPassword(password, row.password_hash))) return wwwPrincipal(row, env);
const local = await env.DB.prepare('SELECT * FROM mail_users WHERE (login = ? OR mailbox = ?) AND status = ?').bind(key, key, 'active').first();
if (local && (await verifyPassword(password, local.password_hash))) { await env.DB.prepare('UPDATE mail_users SET last_login_at = ? WHERE id = ?').bind(now(), local.id).run(); return localPrincipal(local); }
// 시간 차이로 계정 존재 여부가 드러나지 않게 합니다. 예전에는 verifyPassword(password, null) 을 불렀는데 저장값이 없으면 해시 없이 바로 false 라
// 없는 계정은 0.2ms, 있는 계정은 100ms 넘게 걸려 로그인 ID 를 추측할 수 있었습니다(0.8.24). 이제 더미 해시로 실제 PBKDF2 를 돕니다.
if (!row && !local) await verifyPassword(password, await dummyHash());
return null;
}
/** @deprecated verifyUser 로 대체. 기존 호출부 호환. */
export const verifyAdmin = verifyUser;
export async function principalById(env, id) {
if (String(id).startsWith('wm:')) return isWebmaker(env) ? await webmakerPrincipalById(env, String(id)) : null;
if (String(id).startsWith('www:')) { const row = query(env, `${COLS} WHERE id = ?`, [String(id).slice(4)]); return row && row.role === 'admin' && row.status === 'active' ? wwwPrincipal(row, env) : null; }
const local = await env.DB.prepare("SELECT * FROM mail_users WHERE id = ? AND status = 'active'").bind(id).first();
return local ? localPrincipal(local) : null;
}
// ------------------------------------------------------------------ 비밀번호 정책 (0.8.24~)
// 예전에는 "10자 이상" 이 코드에 고정돼 있었습니다. 이제 관리자가 관리자 페이지에서 정하며 settings 표의 password_policy 키에 JSON 으로 저장합니다.
// minLength : 최소 글자 수. 6 ~ 64. 기본 6 (요구사항: 최소 6자 이상, 관리자가 조정)
// mixedCase : true 면 영문 대문자와 소문자를 모두 포함해야 함
// digit : true 면 숫자를 포함해야 함
// special : true 면 특수문자(영문·숫자·공백이 아닌 글자)를 포함해야 함
export const DEFAULT_PASSWORD_POLICY = { minLength: 6, mixedCase: false, digit: false, special: false };
export const PASSWORD_MIN_FLOOR = 6, PASSWORD_MIN_CEIL = 64;
/** 독립 실행의 초기 관리자 비밀번호. 정책과 무관하게 글자 수 하한(6)만 만족하면 되고, 로그인 뒤 바꾸라고 안내합니다. */
export const INITIAL_ADMIN_PASSWORD = 'adminpasswd';
const POLICY_KEY = 'password_policy';
/** 저장된 정책을 기본값과 합쳐 돌려줍니다. 손상된 값은 기본값으로 대체합니다. */
export async function readPasswordPolicy(env) {
const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind(POLICY_KEY).first();
let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; }
const p = { ...DEFAULT_PASSWORD_POLICY, ...(stored && typeof stored === 'object' ? stored : {}) };
p.minLength = Number.isInteger(p.minLength) && p.minLength >= PASSWORD_MIN_FLOOR && p.minLength <= PASSWORD_MIN_CEIL ? p.minLength : DEFAULT_PASSWORD_POLICY.minLength;
for (const k of ['mixedCase', 'digit', 'special']) p[k] = !!p[k];
return p;
}
/**
* 정책을 저장합니다(관리자 전용). minLength 는 6~64 의 정수여야 하며(아니면 policy_invalid), 나머지는 불리언으로 바꿉니다.
* 이미 있는 계정의 비밀번호에는 소급 적용하지 않습니다(새 비밀번호를 정할 때만 검사).
*/
export async function writePasswordPolicy(env, patch) {
const current = await readPasswordPolicy(env), next = { ...current };
if (patch.minLength !== undefined) { const n = Number(patch.minLength); if (!Number.isInteger(n) || n < PASSWORD_MIN_FLOOR || n > PASSWORD_MIN_CEIL) throw new Error('policy_invalid'); next.minLength = n; }
for (const k of ['mixedCase', 'digit', 'special']) if (patch[k] !== undefined) next[k] = !!patch[k];
await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind(POLICY_KEY, JSON.stringify(next), now()).run();
return next;
}
/** 비밀번호가 정책에 어긋나는 항목 목록(비어 있으면 통과): 'length' | 'mixedCase' | 'digit' | 'special'. 화면이 같은 규칙으로 안내문을 만듭니다. */
export function passwordProblems(password, policy) {
const pw = String(password ?? ''), problems = [];
if ([...pw].length < policy.minLength) problems.push('length');
if (policy.mixedCase && !(/[A-Z]/.test(pw) && /[a-z]/.test(pw))) problems.push('mixedCase');
if (policy.digit && !/[0-9]/.test(pw)) problems.push('digit');
if (policy.special && !/[^A-Za-z0-9\s]/.test(pw)) problems.push('special');
return problems;
}
/** 정책 검사. 어긋나면 weak_password 오류를 던지며, 오류 객체에 problems(어긋난 항목)와 policy 를 붙여 API 가 화면에 그대로 전달합니다. */
async function checkPassword(env, password) {
const policy = await readPasswordPolicy(env), problems = passwordProblems(password, policy);
if (problems.length) { const e = new Error('weak_password'); e.problems = problems; e.policy = policy; throw e; }
}
// 초기 비밀번호 표시: settings 의 initial_password:<계정 id> 키가 있으면 그 계정은 아직 초기 비밀번호를 씁니다. 비밀번호를 바꾸면 지웁니다.
const initialKey = (id) => 'initial_password:' + id;
export async function usesInitialPassword(env, user) { return !!(await env.DB.prepare('SELECT 1 AS x FROM settings WHERE key=?').bind(initialKey(user.id)).first()); }
/**
* 독립 실행의 초기 관리자 계정(0.8.24~). 서버가 시작할 때 부르며, 독립 실행이고 게이트웨이 계정이 하나도 없을 때만
* 기본 메일함의 앞부분(보통 admin)을 로그인 ID 로, 비밀번호 adminpasswd 로 관리자 계정을 만듭니다.
* 예전에는 첫 관리자를 user-cli 로만 만들 수 있었고 "10자 이상" 규칙 때문에 admin 같은 짧은 초기 비밀번호가 거부돼 설치가 막혔습니다.
* @param {object} [o] password: 초기 비밀번호(기본 adminpasswd; 1.0.7 도메인 추가 폼에서 지정)
* @returns {Promise<{login:string, password:string}|null>} 만들었으면 계정 정보(시작 로그에 안내용), 아니면 null
*/
export async function ensureInitialAdmin(env, { password = INITIAL_ADMIN_PASSWORD } = {}) {
// webmaker 연동(1.0.8)에서는 웹메이커의 관리자(admin)가 곧 이 도메인의 관리자이므로 adminpasswd 계정을 만들지 않습니다.
if (!standalone(env) || isWebmaker(env)) return null;
const count = await env.DB.prepare('SELECT COUNT(*) AS n FROM mail_users').first();
if (Number(count?.n || 0) > 0) return null;
const login = mailboxOf(env).split('@')[0];
// password(1.0.7): 전체 설정 화면에서 도메인을 추가할 때 지정한 초기 비밀번호. 기본값은 예전과 같은 adminpasswd.
const id = await createUser(env, { login, name: '관리자', password, role: 'admin' }, 'system', { initial: true });
await audit(env, 'system', 'user_create', id, { login, initial: true });
return { login, password };
}
// ------------------------------------------------------------------ 게이트웨이 계정 관리 (관리자 전용)
const LOGIN_RE = /^[a-z0-9][a-z0-9._-]{1,63}$/;
// 승인 대기(0.9.0~): 회원 가입으로 만들어졌지만 관리자 승인 전인 계정. mail_users.status 는 'suspended'(로그인 불가)로 두고
// settings 의 pending:<계정 id> 키로 "정지" 가 아니라 "승인 대기" 임을 표시합니다. 스키마의 status 제약(active/suspended)을 바꾸지 않으려는 선택이며,
// 로그인·세션·메일함 주인 판단은 모두 status='active' 만 보므로 대기 계정은 어디서도 들어올 수 없습니다. 승인 = status 를 active 로 바꾸고 이 키를 지움.
const pendingKey = (id) => 'pending:' + id;
/** 계정 목록. 승인 대기 계정은 status 를 'pending' 으로 바꿔 돌려줍니다(화면·API 용; DB 값은 suspended). */
export async function listUsers(env) {
const rows = (await env.DB.prepare(`SELECT u.id,u.login,u.name,u.mailbox,u.role,CASE WHEN p.key IS NULL THEN u.status ELSE 'pending' END AS status,u.created_by,u.created_at,u.updated_at,u.last_login_at
FROM mail_users u LEFT JOIN settings p ON p.key = 'pending:' || u.id ORDER BY u.created_at`).all()).results;
return rows;
}
/**
* 게이트웨이 계정을 만듭니다(관리자 페이지·회원 가입·명령줄 도구).
* 메일함 주소는 따로 받지 않고 항상 "로그인 ID@도메인"(예: info → info@arihakdang.com)으로 정합니다(0.8.4~).
* 예전에는 메일함 주소를 직접 입력해 여러 계정이 한 메일함을 함께 쓸 수 있었지만, 계정마다 자기 주소를 갖는 편이
* 받는 사람 주소와 로그인 ID 가 일치해 혼동이 없습니다. 기존 계정의 메일함 주소는 바꾸지 않습니다.
* 비밀번호는 관리자가 정한 정책(readPasswordPolicy)으로 검사합니다(0.8.24~, 예전에는 10자 이상 고정).
* 시스템 설정의 계정 수 제한(LIMIT_ACCOUNTS, 0.9.0~)에 이르렀으면 account_limit_reached 를 던집니다(정지·대기 계정도 셉니다).
* @param {object} body { login, name?, password, role?, status? } — body.mailbox 는 무시합니다.
* @param {string} actor 만든 주체(관리자 로그인 이름, 'signup', 'user-cli', 'system')(기록용)
* @param {object} [options]
* @param {boolean} [options.initial] 초기 관리자 계정: 정책 대신 글자 수 하한(6)만 보고, 초기 비밀번호 표시를 남깁니다.
* @param {boolean} [options.pending] 회원 가입 승인 대기 계정으로 만듭니다(관리자가 승인하기 전에는 로그인 불가).
* @returns {Promise<string>} 새 계정 id
*/
export async function createUser(env, body, actor, { initial = false, pending = false } = {}) {
const login = clean(body.login, 64).toLowerCase(), password = String(body.password ?? '');
// 기본 메일함의 별칭(admin)은 www 관리자 몫이라 막지만, 독립 실행에서는 그 메일함을 가질 게이트웨이 계정이 필요하므로 허용합니다.
if (!LOGIN_RE.test(login) || (isMailboxAlias(env, login) && !standalone(env))) throw new Error('invalid_login');
const mailbox = `${login}@${mailDomain(env)}`;
if (!isEmail(mailbox)) throw new Error('invalid_login');
if (initial) { if ([...password].length < PASSWORD_MIN_FLOOR) throw new Error('weak_password'); } else await checkPassword(env, password);
if (await env.DB.prepare('SELECT id FROM mail_users WHERE login = ?').bind(login).first()) throw new Error('login_taken');
// webmaker 연동(1.0.8): 웹메이커 계정과 같은 로그인 ID 는 만들 수 없습니다(그 주소의 주인은 웹메이커 계정이고 verifyUser 도 그쪽을 먼저 봄).
if (isWebmaker(env) && (await webmakerHasLogin(env, login))) throw new Error('login_taken');
const limit = systemLimits(env).accounts;
if (limit > 0 && (await accountCount(env)) >= limit) { const e = new Error('account_limit_reached'); e.limit = limit; throw e; }
const id = uid(), stamp = now();
await env.DB.prepare('INSERT INTO mail_users(id,login,name,mailbox,role,status,password_hash,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)')
.bind(id, login, clean(body.name, 80), mailbox, body.role === 'admin' ? 'admin' : 'user', pending || body.status === 'suspended' ? 'suspended' : 'active', await hashPassword(password), actor || null, stamp, stamp).run();
const mark = (key) => env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind(key, '1', stamp).run();
if (initial) await mark(initialKey(id));
if (pending) await mark(pendingKey(id));
return id;
}
/** 승인 대기 계정인지(settings 의 pending:<id> 키). */
export async function isPending(env, id) { return !!(await env.DB.prepare('SELECT 1 AS x FROM settings WHERE key=?').bind(pendingKey(id)).first()); }
/**
* 게이트웨이 계정을 수정합니다(관리자 전용). 이름·역할·상태·비밀번호만 바꿀 수 있고 메일함 주소는 로그인 ID 에 묶여 있어 바꾸지 않습니다.
* body.services({ pop, imap }) 가 있으면 그 계정의 메일 프로그램 접속 허용 여부(prefs)도 함께 저장합니다.
* body.status 를 'active' 로 두면 승인 대기 계정은 승인됩니다(대기 표시를 지움). 'pending' 은 받지 않습니다(대기는 가입 때만 생김).
*/
export async function updateUser(env, id, body) {
const row = await env.DB.prepare('SELECT * FROM mail_users WHERE id = ?').bind(id).first(); if (!row) throw new Error('not_found');
// 역할·상태는 아는 값만 받습니다. 예전에는 'pending' 같은 다른 값이 모두 'active' 로 바뀌어 승인 대기 계정이 뜻하지 않게 승인됐습니다(0.9.3).
if (body.role !== undefined && !['admin', 'user'].includes(body.role)) throw new Error('validation');
if (body.status !== undefined && !['active', 'suspended'].includes(body.status)) throw new Error('validation');
const next = { name: body.name !== undefined ? clean(body.name, 80) : row.name, mailbox: row.mailbox, role: body.role !== undefined ? body.role : row.role, status: body.status !== undefined ? body.status : row.status };
if (body.services && typeof body.services === 'object') await writePrefs(env, { id }, body.services);
let hash = row.password_hash;
if (body.password !== undefined && body.password !== '') { await checkPassword(env, String(body.password)); hash = await hashPassword(String(body.password)); }
await env.DB.prepare('UPDATE mail_users SET name=?,role=?,status=?,password_hash=?,updated_at=? WHERE id=?').bind(next.name, next.role, next.status, hash, now(), id).run();
if (hash !== row.password_hash || next.status === 'suspended') await env.DB.prepare('DELETE FROM sessions WHERE user_id = ?').bind(id).run();
// 비밀번호를 바꿨으면 "초기 비밀번호 사용 중" 표시를 지웁니다(initial_password:<id>).
if (hash !== row.password_hash) await env.DB.prepare('DELETE FROM settings WHERE key = ?').bind(initialKey(id)).run();
// 상태를 명시해 바꾸면 승인 대기 표시를 지웁니다: '사용' = 승인, '정지' = 대기가 아니라 정지 계정으로 둠(예전에는 정지로 바꿔도 "승인 대기" 로 남았습니다, 0.9.3).
if (body.status !== undefined) await env.DB.prepare('DELETE FROM settings WHERE key = ?').bind(pendingKey(id)).run();
}
/**
* 로그인에 실패한 뒤 "승인 대기 계정이라서" 인지 알아봅니다(0.9.0~). 로그인 ID 와 비밀번호가 모두 맞는 대기 계정일 때만 true 이므로
* 계정 존재 여부가 새어 나가지 않습니다. API 는 이 경우 invalid_credentials 대신 pending_approval(403)을 돌려줍니다.
*/
export async function pendingLogin(env, login, password) {
const key = clean(login, 200).toLowerCase(); if (!key || !password) return false;
const row = await env.DB.prepare("SELECT u.id,u.password_hash FROM mail_users u JOIN settings p ON p.key = 'pending:' || u.id WHERE (u.login = ? OR u.mailbox = ?) AND u.status = 'suspended'").bind(key, key).first();
return !!row && (await verifyPassword(password, row.password_hash));
}
/**
* 자기 비밀번호를 바꿀 때 현재 비밀번호가 맞는지 확인합니다(0.8.25~). 게이트웨이 계정만 대상입니다(www 관리자는 www 사이트에서 관리).
* 세션을 훔친 사람이 비밀번호를 바꿔 계정을 가로채지 못하게 하는 장치이므로, 틀리면 false 만 돌려주고 사유는 나누지 않습니다.
*/
export async function checkCurrentPassword(env, id, password) {
const row = await env.DB.prepare('SELECT password_hash FROM mail_users WHERE id = ?').bind(id).first();
return !!row && (await verifyPassword(String(password ?? ''), row.password_hash));
}
/** 계정을 지웁니다(회원 가입 거절에도 씀). 세션과 계정에 딸린 설정 표시(초기 비밀번호·승인 대기·메일함 크기)도 함께 지웁니다. 메일함의 메일은 남습니다. */
export async function deleteUser(env, id) {
const r = await env.DB.prepare('DELETE FROM mail_users WHERE id = ?').bind(id).run(); if (!r.meta.changes) throw new Error('not_found');
await env.DB.prepare('DELETE FROM sessions WHERE user_id = ?').bind(id).run();
await env.DB.prepare('DELETE FROM settings WHERE key IN (?,?,?)').bind(initialKey(id), pendingKey(id), 'prefs:' + id).run(); // prefs(전달·자동 응답 등)도 함께 지움(0.9.3)
await clearUserQuota(env, id);
}
/**
* 수신을 받아들일 메일함 주소 목록: 기본 메일함 + 모든 계정(정지 포함)의 메일함.
* 예전에는 활성 계정만 넣어, 계정을 정지하면 그 주소로 오는 메일이 'other_mailbox' 로 버려졌고(동기화 창을 벗어나면 복구 불가) 정지를 풀어도 돌아오지 않았습니다(0.8.24).
* 정지는 로그인을 막는 것이지 메일함을 없애는 것이 아니므로, 메일은 계속 저장하고 관리자는 그 메일함을 볼 수 있습니다.
*/
export async function knownMailboxes(env) {
const rows = (await env.DB.prepare("SELECT DISTINCT mailbox FROM mail_users").all()).results;
// webmaker 연동(1.0.8): 웹메이커 계정의 메일함(아이디@도메인)도 받는 주소입니다. 파일을 읽지 못하면 빈 목록(마지막 스냅샷이 있으면 그것).
const external = isWebmaker(env) ? await webmakerMailboxes(env) : [];
return [...new Set([mailboxOf(env), ...rows.map((r) => r.mailbox), ...external])];
}
/**
* 외부(웹메이커) 계정 목록(1.0.8) — 관리자 페이지에 읽기 전용 행으로 보여 주는 용도. 해시는 없습니다.
* @returns {Promise<{ok:boolean, error?:string, site?:object, accounts:object[]}>} webmaker 모드가 아니면 ok:true 에 빈 목록
*/
export async function externalUsers(env) { return isWebmaker(env) ? webmakerAccounts(env) : { ok: true, accounts: [] }; }
// ------------------------------------------------------------------ 사용자별 설정(prefs)
// settings 테이블에 prefs:<사용자 id> 로 저장합니다. www 관리자(id 'www:…')와 게이트웨이 계정 모두 같은 방식입니다.
// remoteImages: 메일을 열 때 외부 이미지를 바로 표시할지
// pop / imap: 이 계정으로 POP3 / IMAP 서버에 접속할 수 있는지(0.8.4~). 끄면 메일 프로그램 로그인을 거부합니다.
// forward: 메일 전달(0.8.16~). enabled 면 이 메일함으로 받은 메일을 전부 to(주소 목록, 최대 5개)로 전달합니다. 원본은 메일함에 남습니다.
// autoReply: 자동 응답(0.8.16~). enabled 면 받은 메일에 message 로 자동 회신합니다. noLimit 이면 기한 없이, 아니면 start~end(ISO 시각) 동안만.
// subject 가 비어 있으면 "Re: 원래 제목" 으로 보냅니다. 실제 판단은 automation.mjs 의 autoReplyActive() 가 합니다.
export const DEFAULT_PREFS = {
remoteImages: false, pop: true, imap: true,
forward: { enabled: false, to: '' },
autoReply: { enabled: false, noLimit: true, start: '', end: '', subject: '', message: '' },
};
// 전달 주소 수·자동 응답 글자 수 상한. 전달은 받은 메일마다 발송이 생기므로 주소를 적게 두고, 메시지는 화면 입력 상자 기준으로 넉넉히 둡니다.
export const FORWARD_MAX = 5, AUTOREPLY_MESSAGE_MAX = 5000, AUTOREPLY_SUBJECT_MAX = 200;
/** 사용자(id 만 있어도 됨)의 설정을 기본값과 합쳐 돌려줍니다. 중첩 설정(forward·autoReply)도 키마다 기본값을 채웁니다. */
export async function readPrefs(env, user) {
const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind('prefs:' + user.id).first();
let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; }
if (!stored || typeof stored !== 'object') stored = {};
return {
...DEFAULT_PREFS, ...stored,
forward: { ...DEFAULT_PREFS.forward, ...(stored.forward && typeof stored.forward === 'object' ? stored.forward : {}) },
autoReply: { ...DEFAULT_PREFS.autoReply, ...(stored.autoReply && typeof stored.autoReply === 'object' ? stored.autoReply : {}) },
};
}
/** ISO 시각이면 정규화한 ISO 문자열, 비어 있으면 '', 해석할 수 없으면 null. */
const isoOrEmpty = (v) => { if (v === undefined || v === null || v === '') return ''; const ms = Date.parse(String(v)); return Number.isFinite(ms) ? new Date(ms).toISOString() : null; };
/**
* 설정 중 아는 키만 골라 저장합니다. 불리언 설정은 불리언으로 바꾸고, forward·autoReply 는 검사한 뒤 정리된 값으로 저장합니다.
* 검사 실패는 예외로 알립니다(API 가 422 로 돌려줌):
* forward_invalid — 전달을 켰는데 주소가 없거나 형식이 틀리거나 5개를 넘거나 자기 메일함 주소를 넣음
* autoreply_message — 자동 응답을 켰는데 메시지가 비어 있음
* autoreply_period — 기한 제한을 두었는데 종료 시각이 없거나, 시각 형식이 틀리거나, 종료가 시작보다 앞섬
* 돌려주는 값은 저장 후의 전체 설정입니다.
* @param {object} user { id, mailbox? } — mailbox 가 있으면 전달 주소에 자기 메일함을 넣지 못하게 막습니다(전달 고리 방지).
*/
export async function writePrefs(env, user, patch) {
const current = await readPrefs(env, user);
const next = { ...current };
for (const key of ['remoteImages', 'pop', 'imap']) if (patch[key] !== undefined) next[key] = !!patch[key];
if (patch.forward && typeof patch.forward === 'object') {
const f = patch.forward, enabled = f.enabled !== undefined ? !!f.enabled : current.forward.enabled;
const to = f.to !== undefined ? addresses(clean(f.to, 1000)) : addresses(current.forward.to);
if (enabled && (!to.length || to.length > FORWARD_MAX || to.some((a) => !isEmail(a)) || (user.mailbox && to.includes(String(user.mailbox).toLowerCase())))) throw new Error('forward_invalid');
if (!enabled && to.some((a) => !isEmail(a))) throw new Error('forward_invalid');
next.forward = { enabled, to: to.join(', ') };
}
if (patch.autoReply && typeof patch.autoReply === 'object') {
const a = patch.autoReply, base = current.autoReply;
const enabled = a.enabled !== undefined ? !!a.enabled : base.enabled, noLimit = a.noLimit !== undefined ? !!a.noLimit : base.noLimit;
const start = a.start !== undefined ? isoOrEmpty(a.start) : base.start, end = a.end !== undefined ? isoOrEmpty(a.end) : base.end;
const subject = a.subject !== undefined ? clean(a.subject, AUTOREPLY_SUBJECT_MAX) : base.subject;
const message = a.message !== undefined ? String(a.message ?? '').replace(/\r\n?/g, '\n').trim().slice(0, AUTOREPLY_MESSAGE_MAX) : base.message;
if (start === null || end === null) throw new Error('autoreply_period');
if (enabled && !message) throw new Error('autoreply_message');
// 기한 제한이 있으면 종료 시각은 꼭 있어야 하고(시작은 비우면 "지금부터"), 종료가 시작보다 앞서면 기간이 성립하지 않습니다.
if (enabled && !noLimit && (!end || (start && Date.parse(end) <= Date.parse(start)))) throw new Error('autoreply_period');
next.autoReply = { enabled, noLimit, start, end, subject, message };
}
await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind('prefs:' + user.id, JSON.stringify(next), now()).run();
return next;
}
/**
* 메일함 주소로 그 메일함을 쓰는 사용자(설정의 주인)를 찾습니다. 받은 메일을 자동 처리(전달·자동 응답)할 때 메일함의 설정을 읽는 데 씁니다.
* 기본 메일함(MAILBOX)은 www 관리자(mailboxOwner), 그 밖의 주소는 활성 게이트웨이 계정입니다. 주인이 없으면 null.
* 독립 실행(standalone)에서는 기본 메일함도 게이트웨이 계정(로그인 ID 가 메일함 앞부분인 계정)이 주인입니다.
*/
export async function mailboxUser(env, mailbox) {
const box = String(mailbox || '').trim().toLowerCase();
if (!box) return null;
// webmaker 연동(1.0.8): 웹메이커 계정의 메일함이면 그 계정(설정 prefs 는 'wm:…' id 로 저장됨).
if (isWebmaker(env)) { const wm = await webmakerByMailbox(env, box); if (wm) return wm; }
// 기본 메일함은 www 관리자 몫입니다. 독립 실행(www DB 없음)이면 아래의 게이트웨이 계정 조회로 넘어갑니다.
if (box === mailboxOf(env) && !standalone(env)) { try { const row = mailboxOwner(env); return row ? wwwPrincipal(row, env) : null; } catch { return null; } }
const local = await env.DB.prepare("SELECT * FROM mail_users WHERE mailbox = ? AND status = 'active' ORDER BY created_at LIMIT 1").bind(box).first();
return local ? localPrincipal(local) : null;
}
/**
* 메일 프로그램(POP3/IMAP) 서버가 로그인 성공 직후 부르는 검사. 계정 설정에서 그 서비스를 꺼 두었으면 false.
* @param {'pop'|'imap'} service
*/
export async function serviceAllowed(env, user, service) { return (await readPrefs(env, user))[service] !== false; }
// ------------------------------------------------------------------ 세션
/**
* 세션을 만들고 쿠키에 넣을 토큰을 돌려줍니다. DB 에는 토큰의 SHA-256 만 저장합니다.
* @param {'mail'|'admin'} [scope] 세션 범위. 'admin' 은 관리자 페이지(/admin) 전용이며 관리자 역할 계정만 만들 수 있습니다.
*/
export async function createSession(env, user, request, { scope = 'mail' } = {}) {
if (!SCOPES.includes(scope)) throw new Error('bad_request');
if (scope === 'admin' && user.role !== 'admin') throw new Error('forbidden');
const token = secret(32);
await env.DB.prepare('INSERT INTO sessions (id,user_id,login,expires_at,created_at,ip,ua,scope) VALUES (?,?,?,?,?,?,?,?)')
.bind(await sha256(token), user.id, user.login, plus(SESSION_DAYS * 86400000), now(), request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || '', (request.headers.get('User-Agent') || '').slice(0, 300), scope).run();
return token;
}
/**
* Set-Cookie 값. scope 에 따라 쿠키 이름이 다릅니다(mg_session / mg_admin). clear 면 즉시 만료시킵니다.
* path(1.0.8): 경로 접두사(/mail) 아래의 웹 메일이면 Path 를 그 접두사로 한정합니다(같은 호스트의 사이트 쪽 요청에 세션 쿠키가 실리지 않게). 보통은 '/'.
*/
export function sessionCookie(token, { clear = false, scope = 'mail', path = '' } = {}) {
return [`${cookieName(scope)}=${clear ? '' : token}`, `Path=${path || '/'}`, 'HttpOnly', 'Secure', 'SameSite=Lax', clear ? 'Max-Age=0' : `Max-Age=${SESSION_DAYS * 86400}`].join('; ');
}
function readCookie(request, name) { for (const part of (request.headers.get('Cookie') || '').split(';')) { const [k, ...v] = part.trim().split('='); if (k === name) return v.join('='); } return ''; }
/**
* 요청의 세션 쿠키로 사용자를 찾습니다. 범위(scope)가 맞는 세션만 인정합니다.
* 관리자 범위에서는 지금도 관리자 역할인지 다시 확인합니다(세션이 살아 있는 동안 역할이 바뀌었을 수 있음).
*/
export async function currentUser(env, request, { scope = 'mail' } = {}) {
const token = readCookie(request, cookieName(scope)); if (!token) return null;
const hash = await sha256(token);
const row = await env.DB.prepare('SELECT * FROM sessions WHERE id = ? AND scope = ?').bind(hash, scope).first(); if (!row) return null;
let principal = null;
// 사용자 조회가 일시적으로 실패하면(배포 중 www DB 교체, SQLITE_BUSY) 세션을 지우지 않고 이번 요청만 비로그인으로 처리합니다.
// 예전에는 이런 예외에도 세션을 지워 관리자가 영문 없이 로그아웃됐습니다. 만료·계정 없음·역할 불일치일 때만 지웁니다.
if (row.expires_at >= now()) { try { principal = await principalById(env, row.user_id); } catch { return null; } }
if (principal && scope === 'admin' && principal.role !== 'admin') principal = null;
if (!principal) { await env.DB.prepare('DELETE FROM sessions WHERE id = ?').bind(hash).run(); return null; }
return { ...principal, expires_at: row.expires_at, scope };
}
export async function destroySession(env, request, { scope = 'mail' } = {}) { const token = readCookie(request, cookieName(scope)); if (token) await env.DB.prepare('DELETE FROM sessions WHERE id = ?').bind(await sha256(token)).run(); }
export async function requireUser(env, request, { scope = 'mail' } = {}) { const user = await currentUser(env, request, { scope }); return user || json({ ok: false, error: 'unauthenticated' }, 401); }
export async function audit(env, actor, action, target, detail) {
await env.DB.prepare('INSERT INTO audit_log (id,actor,action,target,detail,created_at) VALUES (?,?,?,?,?,?)').bind(uid(), actor || null, action, target || null, detail ? JSON.stringify(detail).slice(0, 2000) : null, now()).run();
}
+191
View File
@@ -0,0 +1,191 @@
/**
* 수신 메일 자동 처리(0.8.16~): 메일 전달과 자동 응답.
*
* 설정 화면(prefs, auth.mjs 의 DEFAULT_PREFS 참고)에서 사용자가 켜 두면, 받은 메일을 저장한 직후(store.ingestReceived)
* 이 모듈의 autoProcess() 가 불려 다음을 합니다.
* - 메일 전달(forward): 받은 메일을 지정한 주소(최대 5개)로 통째로(제목·본문·첨부·인라인 이미지) 보냅니다. 원본은 메일함에 그대로 남습니다.
* 보내는 사람은 이 메일함 주소(발송 서비스가 우리 도메인만 허용)이고, 답장이 원래 보낸 사람에게 가도록 Reply-To 를 원래 발신자로 둡니다.
* - 자동 응답(reply): 사용자가 정한 메시지로 보낸 사람에게 회신합니다. 기한 없음이거나 설정한 기간(시작~종료) 안일 때만 동작하며,
* 같은 상대에게는 24시간에 한 번만 보냅니다(한 사람이 여러 통을 보내도 한 번만 답함). 보낸 자동 응답은 보낸 메일함에 남깁니다.
*
* 되풀이(loop) 방지 — 자동 발송 메일은 받는 쪽의 자동 처리가 다시 반응하지 않게 표시를 달고, 받을 때는 그 표시를 보고 건너뜁니다.
* - 자동 응답에는 RFC 3834 의 Auto-Submitted: auto-replied (와 Microsoft 용 X-Auto-Response-Suppress) 를 붙입니다.
* - 전달 메일에는 X-Ari-Auto-Forwarded 헤더를 붙입니다. 두 메일함이 서로를 전달 대상으로 두어도 한 번만 전달되고 멈춥니다.
* - 받은 메일에 Auto-Submitted(no 제외)·Precedence bulk/list/junk·List-Id 가 있거나 발신자가 mailer-daemon/no-reply 류이면 자동 응답하지 않습니다.
* - 72시간보다 오래된 메일(동기화가 뒤늦게 가져온 옛 메일)에는 아무것도 하지 않습니다.
*
* 처리 기록은 auto_actions 테이블(message_id + kind 가 기본 키)에 남깁니다. 먼저 'pending' 행을 넣어 자리를 잡기 때문에
* 웹훅과 동기화가 같은 메일을 거의 동시에 처리해도 전달·응답이 두 번 나가지 않습니다. 결과는 메일 상세의 "자동 처리" 줄에 보입니다.
*/
import { sendMail } from './providers/index.mjs';
import { mailboxUser, readPrefs } from './auth.mjs';
import { now, uid, clean, addresses, address, isEmail } from './util.mjs';
import { sanitizeHtml, textToHtml } from './sanitize.mjs';
// store.mjs 와는 서로 들여옵니다(store 가 저장 직후 autoProcess 를 부름). ESM 은 함수 선언을 호출 시점에 찾으므로 순환이어도 문제없습니다.
import { buildStoredEml, storeRaw, setFlag } from './store.mjs';
import { monthlyBlocked } from './limits.mjs';
/** 이보다 오래된 수신 메일은 자동 처리하지 않습니다(72시간). 동기화가 옛 메일을 뒤늦게 가져올 때 뜬금없는 전달·응답이 나가는 것을 막습니다. */
export const STALE_MS = 72 * 3600 * 1000;
/** 같은 상대에게 자동 응답을 다시 보내기까지의 간격(24시간). */
export const REPLY_INTERVAL_MS = 24 * 3600 * 1000;
/** 전달 메일에 붙이는 표시 헤더. 받은 메일에 이 헤더가 있으면 다시 전달하지 않습니다(전달 고리 방지). 값은 전달한 메일함 주소입니다. */
export const FORWARD_MARKER = 'X-Ari-Auto-Forwarded';
/** 전달 한 통에 넣는 첨부 바이트 합계 상한. 발송 서비스 한도(40MB)와 base64 부풀림을 고려해 25MB 로 둡니다. 넘는 첨부는 빼고 기록에 남깁니다. */
const FORWARD_ATTACH_LIMIT = 25 * 1024 * 1024;
/**
* 자동 응답이 지금 동작하는 상태인지. 설정이 켜져 있고 메시지가 있어야 하며, 기한 없음(noLimit)이면 항상,
* 아니면 시작(start, 비우면 바로)~종료(end) 사이일 때만 true. 설정 화면의 "작동 중" 표시와 수신 처리가 같은 규칙을 씁니다.
* @param {object} a prefs.autoReply
* @param {number} [at] 판단 시각(ms). 기본은 지금. 테스트에서 기간 경계를 확인하는 데 씁니다.
*/
export function autoReplyActive(a, at = Date.now()) {
if (!a || !a.enabled || !String(a.message || '').trim()) return false;
if (a.noLimit) return true;
const start = a.start ? Date.parse(a.start) : -Infinity, end = a.end ? Date.parse(a.end) : NaN;
if (!Number.isFinite(end)) return false; // 기한 제한인데 종료 시각이 없으면(저장 검사를 거치지 않은 옛 값) 동작하지 않습니다.
return at >= start && at <= end;
}
/** headers_json 객체에서 이름을 대소문자 구분 없이 찾습니다. 값이 배열이면 첫 값. 없으면 ''. */
function header(headers, name) {
const key = Object.keys(headers || {}).find((k) => k.toLowerCase() === name.toLowerCase());
const v = key ? headers[key] : ''; return String(Array.isArray(v) ? v[0] : v ?? '').trim();
}
/** "홍길동 <hong@x>" 에서 표시 이름(홍길동)만. 이름이 없으면 주소의 @ 앞부분. */
function displayName(sender) {
const s = String(sender || '').trim(); const m = s.match(/^"?(.*?)"?\s*<[^<>]+>$/);
return (m && m[1] ? m[1] : s.split('@')[0]).replace(/[\r\n"<>]/g, '').trim();
}
/**
* 자동 발송된 메일(다른 서버의 자동 응답·알림·목록 메일 등)인지 판단해 이유를 돌려줍니다. 사람이 보낸 메일이면 null.
* 'auto-submitted' Auto-Submitted 가 no 가 아님(RFC 3834) / 'forwarded' 우리 전달 표시 / 'bulk' Precedence bulk·list·junk·auto_reply
* 'list' 메일링 리스트 헤더 / 'suppressed' X-Auto-Response-Suppress(Microsoft) / 'noreply' 발신 주소가 mailer-daemon·no-reply 류
* @param {object} mail messages 행
* @param {object} headers headers_json 을 푼 객체
*/
export function automatedReason(mail, headers) {
const auto = header(headers, 'Auto-Submitted').toLowerCase();
if (auto && auto !== 'no') return 'auto-submitted';
if (header(headers, FORWARD_MARKER)) return 'forwarded';
if (/^(bulk|list|junk|auto_reply)$/i.test(header(headers, 'Precedence'))) return 'bulk';
if (header(headers, 'List-Id') || header(headers, 'List-Unsubscribe')) return 'list';
if (/\b(all|autoreply|oof)\b/i.test(header(headers, 'X-Auto-Response-Suppress'))) return 'suppressed';
const from = address(mail.sender);
if (!isEmail(from) || /^(mailer-daemon|postmaster|no-?reply|do-?not-?reply|bounces?|notifications?)([+@.-]|$)/i.test(from)) return 'noreply';
return null;
}
/** auto_actions 에 처리 중(pending) 행을 넣어 자리를 잡습니다. 이미 행이 있으면(다른 경로가 먼저 처리) false. */
async function claim(env, mail, kind) {
const r = await env.DB.prepare("INSERT OR IGNORE INTO auto_actions(message_id,kind,mailbox,target,status,created_at) VALUES(?,?,?,'','pending',?)").bind(mail.id, kind, mail.mailbox, now()).run();
return r.meta.changes > 0;
}
async function finish(env, mail, kind, { status, target = '', detail = null, sentMessageId = null }) {
await env.DB.prepare('UPDATE auto_actions SET status=?,target=?,detail=?,sent_message_id=?,created_at=? WHERE message_id=? AND kind=?').bind(status, target, detail ? String(detail).slice(0, 300) : null, sentMessageId, now(), mail.id, kind).run();
return { kind, status, target, detail };
}
/**
* 받은 메일 한 통에 메일함 설정에 따라 전달·자동 응답을 수행합니다. store.ingestReceived 가 저장 직후에 부릅니다.
* 예외는 밖으로 던지지 않고 기록(failed)만 남깁니다 — 자동 처리 실패가 수신 저장 자체를 실패로 만들면 안 됩니다.
* @param {string} messageId 저장한 메일 id
* @returns {Promise<{forward?:object, reply?:object}|null>} 작업별 결과(테스트·로그용). 설정이 모두 꺼져 있으면 null.
*/
export async function autoProcess(env, messageId) {
const mail = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(messageId).first();
if (!mail || mail.direction !== 'inbound') return null;
const user = await mailboxUser(env, mail.mailbox); if (!user) return null;
const prefs = await readPrefs(env, user);
const wantForward = prefs.forward.enabled && addresses(prefs.forward.to).length > 0, wantReply = autoReplyActive(prefs.autoReply);
if (!wantForward && !wantReply) return null;
let headers = {}; try { headers = JSON.parse(mail.headers_json || '{}'); } catch { headers = {}; }
const stale = Date.now() - Date.parse(mail.received_at) > STALE_MS;
const out = {};
if (wantForward) out.forward = await runForward(env, mail, user, prefs.forward, headers, stale);
if (wantReply) out.reply = await runReply(env, mail, user, prefs.autoReply, headers, stale);
return out;
}
/**
* 메일 전달. 저장된 원본 HTML(정제 전, 인라인 이미지 cid 포함)·텍스트·첨부를 그대로 실어 지정 주소로 보냅니다.
* 건너뛰는 경우: 우리 전달 표시가 있는 메일(loop), 전달 대상이 보낸 메일(self: 다시 돌려보내지 않음), 72시간 지난 메일(stale).
*/
async function runForward(env, mail, user, forward, headers, stale) {
const kind = 'forward';
const targets = addresses(forward.to).filter((a) => isEmail(a) && a !== mail.mailbox);
if (!targets.length) return { kind, status: 'skipped', detail: 'no_target' };
if (!(await claim(env, mail, kind))) return { kind, status: 'skipped', detail: 'claimed' };
const sender = address(mail.sender);
if (stale) return finish(env, mail, kind, { status: 'skipped', detail: 'stale' });
if (header(headers, FORWARD_MARKER)) return finish(env, mail, kind, { status: 'skipped', detail: 'loop' });
if (targets.includes(sender)) return finish(env, mail, kind, { status: 'skipped', detail: 'self' });
// 월간 수발신 통수 제한(0.9.0, 시스템 설정)에 이르렀으면 전달도 보내지 않습니다(전달 한 통도 발신으로 셈).
if (await monthlyBlocked(env)) return finish(env, mail, kind, { status: 'skipped', detail: 'limit' });
try {
const rows = (await env.DB.prepare('SELECT id,filename,content_type,content_id,disposition,size,bytes FROM attachments WHERE message_id=? ORDER BY rowid').bind(mail.id).all()).results;
// 첨부는 합계 상한까지만 싣습니다. 상한을 넘겨 뺀 첨부 이름은 기록에 남겨 상세 화면에서 알 수 있게 합니다.
let total = 0; const files = [], dropped = [];
for (const a of rows) { if (!a.bytes) { dropped.push(a.filename); continue; } if (total + a.bytes.length > FORWARD_ATTACH_LIMIT) { dropped.push(a.filename); continue; } total += a.bytes.length; files.push(a); }
const html = mail.original_html || mail.html || '';
const payload = {
// 표시 이름에 괄호와 @ 가 들어가므로 따옴표로 묶습니다(RFC 5322: 괄호는 주석, @ 는 따옴표 없는 이름에 쓸 수 없음).
from: `"${displayName(mail.sender) || sender} (via ${mail.mailbox})" <${mail.mailbox}>`, to: targets,
subject: mail.subject || '(no subject)', text: mail.text || '', ...(html ? { html } : {}),
// 전달받은 사람이 "답장" 을 누르면 원래 보낸 사람에게 가도록 합니다(우리 메일함이 아니라).
...(isEmail(sender) ? { reply_to: [mail.sender] } : {}),
headers: { [FORWARD_MARKER]: mail.mailbox },
};
if (files.length) payload.attachments = files.map((a) => ({ filename: a.filename, content: Buffer.from(a.bytes).toString('base64'), content_type: a.content_type, ...(a.content_id ? { content_id: a.content_id } : {}) }));
// 멱등 키: 같은 메일을 다시 처리하게 되더라도 발송 서비스가 한 번만 보냅니다.
const result = await sendMail(env, payload, 'auto-forward-' + mail.id);
return finish(env, mail, kind, { status: 'sent', target: targets.join(', '), detail: (result.id || '') + (dropped.length ? ' dropped:' + dropped.join(',') : '') });
} catch (e) {
console.error('메일 전달 실패', mail.id, mail.mailbox, '→', targets.join(','), e.message, e.providerMessage || '');
return finish(env, mail, kind, { status: 'failed', target: targets.join(', '), detail: e.message + (e.providerMessage ? ' ' + e.providerMessage : '') });
}
}
/**
* 자동 응답. Reply-To(없으면 From)에게 설정한 제목·메시지로 회신하고 보낸 메일함에 저장합니다.
* 건너뛰는 경우: 자동 발송 메일(automatedReason), 72시간 지난 메일(stale), 같은 상대에게 24시간 안에 이미 보냄(recent), 회신 주소 없음(no_address).
*/
async function runReply(env, mail, user, autoReply, headers, stale) {
const kind = 'reply';
let replyTo = []; try { replyTo = JSON.parse(mail.reply_to_json || '[]'); } catch { replyTo = []; }
const target = addresses(replyTo)[0] || address(mail.sender);
if (!(await claim(env, mail, kind))) return { kind, status: 'skipped', detail: 'claimed' };
if (stale) return finish(env, mail, kind, { status: 'skipped', detail: 'stale' });
if (!isEmail(target)) return finish(env, mail, kind, { status: 'skipped', detail: 'no_address' });
const reason = automatedReason(mail, headers);
if (reason) return finish(env, mail, kind, { status: 'skipped', target, detail: reason });
if (target === mail.mailbox) return finish(env, mail, kind, { status: 'skipped', target, detail: 'self' });
const recent = await env.DB.prepare("SELECT created_at FROM auto_actions WHERE mailbox=? AND kind='reply' AND target=? AND status='sent' AND created_at>? AND message_id<>? LIMIT 1").bind(mail.mailbox, target, new Date(Date.now() - REPLY_INTERVAL_MS).toISOString(), mail.id).first();
if (recent) return finish(env, mail, kind, { status: 'skipped', target, detail: 'recent' });
if (await monthlyBlocked(env)) return finish(env, mail, kind, { status: 'skipped', target, detail: 'limit' });
try {
const original = String(mail.subject || '').trim();
const subject = clean(autoReply.subject, 200) || (/^re:/i.test(original) ? original : 'Re: ' + original).trim() || 'Re:';
const text = String(autoReply.message || '').trim(), content = sanitizeHtml(textToHtml(text));
const fromName = (user.name || env.MAIL_FROM_NAME || 'Ari Hak Dang').replace(/[\r\n"<>]/g, '').trim();
const threadHeaders = mail.message_id ? { 'In-Reply-To': mail.message_id, References: mail.message_id } : {};
const payload = { from: `${fromName} <${mail.mailbox}>`, to: [target], subject, text, html: content.html, headers: { ...threadHeaders, 'Auto-Submitted': 'auto-replied', 'X-Auto-Response-Suppress': 'All' } };
const result = await sendMail(env, payload, 'auto-reply-' + mail.id);
// 보낸 메일함에 남깁니다(사용자가 무엇이 나갔는지 볼 수 있게). 원본(.eml)은 저장한 부품으로 재구성합니다.
const id = uid(), stamp = now();
await env.DB.prepare(`INSERT INTO messages(id,provider_id,direction,folder,sender,recipients_json,subject,html,text,headers_json,source,state,received_at,created_at,updated_at,sent_at,read_at,mailbox) VALUES(?,?,'outbound','sent',?,?,?,?,?,?,'web','sent',?,?,?,?,?,?)`)
.bind(id, result.id || null, payload.from, JSON.stringify([target]), subject, content.html, text, JSON.stringify(payload.headers), stamp, stamp, stamp, stamp, stamp, mail.mailbox).run();
await storeRaw(env, id, await buildStoredEml(env, await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first()), 'built');
await setFlag(env, mail.id, '\\Answered', true); // 원래 메일에 "답장함" 표시(IMAP \Answered 와 공유)
return finish(env, mail, kind, { status: 'sent', target, detail: result.id || null, sentMessageId: id });
} catch (e) {
console.error('자동 응답 실패', mail.id, mail.mailbox, '→', target, e.message, e.providerMessage || '');
return finish(env, mail, kind, { status: 'failed', target, detail: e.message + (e.providerMessage ? ' ' + e.providerMessage : '') });
}
}
/** 메일 상세 화면용: 이 메일에 한 자동 처리 기록. */
export async function autoActionsOf(env, messageId) {
return (await env.DB.prepare('SELECT kind,target,status,detail,sent_message_id,created_at FROM auto_actions WHERE message_id=? ORDER BY kind').bind(messageId).all()).results;
}
+274
View File
@@ -0,0 +1,274 @@
/**
* 설정 파일(config.json) 모듈 — 1.0.0 에서 환경 파일(gateway.env)과 분리한 "운영 설정".
*
* 왜 분리했나
* 예전에는 Resend API 키·웹훅 비밀키·발신 표시 이름 같은 값이 모두 systemd 환경 파일(gateway.env)에 있었습니다.
* 그 값들은 처음 배포할 때는 알 수 없거나(키를 아직 만들지 않음) 배포 자체에는 필요하지 않은데, 비어 있으면 운영이 안 되고
* 채우려면 root 로 파일을 고치고 서비스를 재시작해야 했습니다. 이제 "배포에 꼭 필요한 값"(DB 경로·포트·메일함·로그인 방식·시스템 제한)만
* 환경 파일에 남기고, 나머지는 데이터 디렉터리의 config.json 에 두어 설치가 끝난 뒤 웹(초기 설정 페이지 / 관리자 페이지)에서 채웁니다.
*
* 우선순위와 호환(요구사항 5: 기존 운영 유지)
* - 설정 파일에 있는 값이 환경 변수보다 우선합니다. 설정 파일이 없으면(처음 1.0.0 으로 올린 서버) 환경 변수에 있던 값을 그대로
* 읽어 설정 파일을 만듭니다(가져오기). 그래서 두 운영 서버의 환경 파일을 바꾸지 않아도 동작이 그대로이고, 값은 설정 파일에 보관됩니다.
* - 가져오기 뒤에는 설정 파일이 기준입니다(파일에 빈 값으로 저장하면 환경 변수 값이 되살아나지 않음). 파일에 아예 없는 키만 환경 변수를 봅니다.
* - 코드의 나머지 부분은 예전처럼 env.RESEND_API_KEY 같은 환경 변수 이름으로 값을 읽습니다. applyConfig() 가 설정 파일 값을 env 객체에
* 덮어쓰므로 모듈들이 바뀌지 않았고, 저장 즉시(재시작 없이) 새 값이 쓰입니다.
*
* 파일 위치: CONFIG_FILE 환경 변수, 없으면 DB 파일 옆의 config.json(예: /srv/<사이트>/data/config.json). 소유자만 읽습니다(0600, 비밀키 포함).
* 초기 설정 페이지의 접속 토큰도 같은 디렉터리의 setup-token 파일입니다.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { generateKeyPairSync } from 'node:crypto';
import { secret } from './util.mjs';
/** 설정 파일 형식 버전. 구조가 바뀌면 올리고 loadConfig 에서 변환합니다. */
export const CONFIG_FORMAT = 1;
/**
* 설정 항목 정의.
* key : config.json 의 키이자(env 가 있으면) 코드가 읽는 환경 변수 이름
* env : 이 항목이 대신하는 환경 변수(가져오기와 applyConfig 대상). 없으면 설정 파일 전용 항목
* group : 화면의 묶음 — mail(수·발신 방식) resend smtp haraka site setup
* type : text | secret(화면에 가린 채 보임) | select | number | bool
* hidden : 화면 폼에 직접 보이지 않는 항목(DKIM 키·내부 토큰처럼 단추로 만들거나 자동 생성)
* default: 값이 없을 때의 기본값(applyConfig 는 기본값을 env 에 쓰지 않고, 읽는 쪽 코드의 기본값에 맡깁니다)
*/
export const CONFIG_SCHEMA = [
// 수·발신 방식(요구사항 1·2): inbound = 받는 길, outbound = 보내는 길. 조합은 자유입니다(예: 수신 Haraka + 발신 Resend).
{ key: 'inbound', group: 'mail', type: 'select', options: ['resend', 'haraka'], default: 'resend' },
{ key: 'outbound', group: 'mail', type: 'select', options: ['resend', 'haraka', 'smtp'], default: 'resend' },
// Resend
{ key: 'RESEND_API_KEY', env: 'RESEND_API_KEY', group: 'resend', type: 'secret' },
{ key: 'RESEND_WEBHOOK_SECRET', env: 'RESEND_WEBHOOK_SECRET', group: 'resend', type: 'secret' },
// Resend 도메인 자동 설정(1.0.5): 발송 지역(수신 MX 값도 지역에 따라 다름). RESEND_DOMAIN_ID / RESEND_WEBHOOK_ID 는 자동 설정이 찾거나 만든 id(화면에는 숨김).
{ key: 'RESEND_REGION', env: 'RESEND_REGION', group: 'resend', type: 'select', options: ['ap-northeast-1', 'us-east-1', 'eu-west-1', 'sa-east-1'], default: 'ap-northeast-1' },
{ key: 'RESEND_DOMAIN_ID', env: 'RESEND_DOMAIN_ID', group: 'resend', type: 'text', hidden: true },
{ key: 'RESEND_WEBHOOK_ID', env: 'RESEND_WEBHOOK_ID', group: 'resend', type: 'text', hidden: true },
// DNS 모듈(1.0.5, 요구사항 1): none 이면 레코드를 보여 주기만 하고, google / route53 이면 자동으로 적용합니다. 자격 증명은 비밀값.
{ key: 'DNS_PROVIDER', env: 'DNS_PROVIDER', group: 'dns', type: 'select', options: ['none', 'google', 'route53'], default: 'none' },
{ key: 'DNS_GOOGLE_CREDENTIALS', env: 'DNS_GOOGLE_CREDENTIALS', group: 'dns', type: 'secret', multiline: true },
{ key: 'DNS_GOOGLE_PROJECT', env: 'DNS_GOOGLE_PROJECT', group: 'dns', type: 'text' },
{ key: 'DNS_GOOGLE_ZONE', env: 'DNS_GOOGLE_ZONE', group: 'dns', type: 'text' },
{ key: 'DNS_AWS_ACCESS_KEY_ID', env: 'DNS_AWS_ACCESS_KEY_ID', group: 'dns', type: 'text' },
{ key: 'DNS_AWS_SECRET_ACCESS_KEY', env: 'DNS_AWS_SECRET_ACCESS_KEY', group: 'dns', type: 'secret' },
{ key: 'DNS_AWS_HOSTED_ZONE_ID', env: 'DNS_AWS_HOSTED_ZONE_ID', group: 'dns', type: 'text' },
// 외부 SMTP 중계 서버(outbound=smtp)
{ key: 'SMTP_RELAY_HOST', env: 'SMTP_RELAY_HOST', group: 'smtp', type: 'text' },
{ key: 'SMTP_RELAY_PORT', env: 'SMTP_RELAY_PORT', group: 'smtp', type: 'number', default: 587 },
{ key: 'SMTP_RELAY_SECURITY', env: 'SMTP_RELAY_SECURITY', group: 'smtp', type: 'select', options: ['starttls', 'tls', 'none'], default: 'starttls' },
{ key: 'SMTP_RELAY_USER', env: 'SMTP_RELAY_USER', group: 'smtp', type: 'text' },
{ key: 'SMTP_RELAY_PASS', env: 'SMTP_RELAY_PASS', group: 'smtp', type: 'secret' },
// 내장 Haraka(inbound=haraka 또는 outbound=haraka 일 때 haraka/run.mjs 가 이 값으로 Haraka 설정을 만듭니다)
{ key: 'HARAKA_PORT', env: 'HARAKA_PORT', group: 'haraka', type: 'number', default: 25 },
{ key: 'HARAKA_RELAY_PORT', env: 'HARAKA_RELAY_PORT', group: 'haraka', type: 'number', default: 2525 },
{ key: 'HARAKA_HOSTNAME', env: 'HARAKA_HOSTNAME', group: 'haraka', type: 'text' },
{ key: 'HARAKA_DKIM_SELECTOR', env: 'HARAKA_DKIM_SELECTOR', group: 'haraka', type: 'text', default: 'arimail' },
{ key: 'HARAKA_DKIM_PRIVATE', env: 'HARAKA_DKIM_PRIVATE', group: 'haraka', type: 'secret', hidden: true },
{ key: 'HARAKA_DKIM_PUBLIC', env: 'HARAKA_DKIM_PUBLIC', group: 'haraka', type: 'text', hidden: true },
// Haraka 플러그인 ↔ 게이트웨이 사이의 공유 비밀(자동 생성). 로컬 HTTP 로 넘어오는 수신 메일이 정말 Haraka 에서 온 것인지 확인합니다.
{ key: 'HARAKA_TOKEN', env: 'HARAKA_TOKEN', group: 'haraka', type: 'secret', hidden: true },
// 사이트 정보(배포에는 필요 없고 운영 중 바꿀 수 있는 값)
{ key: 'MAIL_FROM_NAME', env: 'MAIL_FROM_NAME', group: 'site', type: 'text' },
{ key: 'SITE_TITLE', env: 'SITE_TITLE', group: 'site', type: 'text' },
{ key: 'MAILBOX_OWNER_LOGIN', env: 'MAILBOX_OWNER_LOGIN', group: 'site', type: 'text' },
{ key: 'STATS_TIMEZONE', env: 'STATS_TIMEZONE', group: 'site', type: 'text', default: 'Asia/Seoul' },
{ key: 'SYNC_INTERVAL_MS', env: 'SYNC_INTERVAL_MS', group: 'site', type: 'number', default: 3600000 },
// 사용량 API(1.0.9, 요구사항 1): 외부 관리 시스템(웹메이커)이 /api/usage 를 부를 때 쓰는 비밀 키(기본 도메인 설정에만, 화면의 단추로 생성·교체·삭제)와 접속 허용 IP/CIDR 목록(비우면 제한 없음).
{ key: 'USAGE_API_KEY', env: 'USAGE_API_KEY', group: 'usage', type: 'secret', hidden: true },
{ key: 'USAGE_API_ALLOW', env: 'USAGE_API_ALLOW', group: 'usage', type: 'text' },
// 초기 설정을 마쳤다는 표시(초기 설정 페이지의 "완료" 단추). 화면 안내용이며 동작에는 영향이 없습니다.
{ key: 'SETUP_DONE', group: 'setup', type: 'bool', default: false },
];
const BY_KEY = new Map(CONFIG_SCHEMA.map((item) => [item.key, item]));
/** 설정 파일 경로: CONFIG_FILE, 없으면 DB 파일 옆의 config.json. */
export function configFile(env) {
return env.CONFIG_FILE || join(dirname(env.DB_FILE || join(process.cwd(), 'data', 'mail-gateway.sqlite')), 'config.json');
}
/** 설정 파일과 같은 디렉터리의 보조 파일(setup-token, haraka-status.json). */
export const configDir = (env) => dirname(configFile(env));
function readJsonFile(file) {
try { return JSON.parse(readFileSync(file, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
}
/** 임시 파일에 쓰고 이름을 바꿔 중간에 끊겨도 반쪽 파일이 남지 않게 합니다. 비밀키가 들어 있으므로 0600. */
function writeJsonFile(file, data) {
mkdirSync(dirname(file), { recursive: true });
const tmp = file + '.tmp';
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
try { chmodSync(tmp, 0o600); } catch { /* 권한을 못 바꾸는 파일 시스템이면 그대로 둡니다 */ }
renameSync(tmp, file);
}
/**
* 값 하나를 항목 형식에 맞게 다듬습니다. 잘못된 값은 Error('validation') 를 던지며 field 에 키를 넣습니다.
* number 는 0 이상의 정수, select 는 목록 안의 값, bool 은 true/false/'1'/'0'. 빈 문자열은 "지움" 으로 보고 '' 로 둡니다.
*/
export function coerceValue(item, value) {
const fail = () => { const e = new Error('validation'); e.field = item.key; throw e; };
if (value === null || value === undefined) return '';
if (item.type === 'bool') { if (typeof value === 'boolean') return value; if (['1', 'true', 'on'].includes(String(value))) return true; if (['', '0', 'false', 'off'].includes(String(value))) return false; fail(); }
const s = String(value).trim();
if (s === '') return '';
if (item.type === 'number') { if (!/^\d{1,12}$/.test(s)) fail(); return Number(s); }
if (item.type === 'select') { if (!item.options.includes(s)) fail(); return s; }
if (s.length > 20000 || /[\0]/.test(s)) fail();
return s;
}
/**
* 환경 변수에서 설정 파일을 처음 만듭니다(1.0.0 으로 올린 기존 서버). env 에 있는 항목은 모두 그대로 복사하고,
* 수·발신 방식은 예전 동작과 같은 resend 로 둡니다. 가져온 사실은 _meta 에 남겨 화면이 "환경 파일에서 가져옴" 을 보여 줄 수 있습니다.
*/
function importFromEnv(env) {
const cfg = { _meta: { format: CONFIG_FORMAT, createdAt: new Date().toISOString(), importedFromEnv: [] } };
for (const item of CONFIG_SCHEMA) {
if (!item.env || env[item.env] === undefined || env[item.env] === '') continue;
try { cfg[item.key] = coerceValue(item, env[item.env]); cfg._meta.importedFromEnv.push(item.key); } catch { /* 형식이 안 맞는 옛 값은 건너뜁니다 */ }
}
cfg.inbound = 'resend'; cfg.outbound = 'resend';
return cfg;
}
/**
* 설정 파일을 읽어(없으면 환경 변수에서 만들어) env 에 적용합니다. 서버 시작 때 한 번 부릅니다.
* 내부 토큰(HARAKA_TOKEN)이 없으면 만들어 저장합니다. 파일을 쓸 수 없는 환경(읽기 전용)이면 경고만 남기고 메모리에서만 동작합니다.
* @returns {object} 적용된 설정(env.CONFIG 와 같은 객체)
*/
export function loadConfig(env) {
const file = configFile(env);
let cfg = readJsonFile(file), created = false;
if (!cfg || typeof cfg !== 'object') { cfg = importFromEnv(env); created = true; }
if (!cfg._meta) cfg._meta = { format: CONFIG_FORMAT };
if (!cfg.HARAKA_TOKEN) { cfg.HARAKA_TOKEN = secret(32); created = true; }
if (created) { try { writeJsonFile(file, cfg); } catch (e) { console.warn(` ⚠️ 설정 파일을 쓸 수 없습니다(${file}): ${e.message} — 환경 변수 값으로 동작합니다`); } }
applyConfig(env, cfg);
env.CONFIG_CREATED = created;
return cfg;
}
/**
* 설정 값을 env 에 적용합니다. env 를 쓰는 다른 모듈(Resend 호출·발신 표시 이름·시간대 등)은 바뀌지 않았으므로
* 저장 즉시 새 값이 쓰입니다. 파일에 있는 키만 덮어쓰고(빈 값 포함), 파일에 없는 키는 환경 변수 값을 그대로 둡니다.
* MAIL_INBOUND / MAIL_OUTBOUND 는 providers/index.mjs 가 읽는 수·발신 방식입니다.
*/
export function applyConfig(env, cfg) {
for (const item of CONFIG_SCHEMA) {
if (!item.env || !(item.key in cfg)) continue;
const v = cfg[item.key];
env[item.env] = v === null || v === undefined ? '' : String(v);
}
env.MAIL_INBOUND = BY_KEY.get('inbound').options.includes(cfg.inbound) ? cfg.inbound : 'resend';
env.MAIL_OUTBOUND = BY_KEY.get('outbound').options.includes(cfg.outbound) ? cfg.outbound : 'resend';
env.CONFIG = cfg;
env.CONFIG_VERSION = (env.CONFIG_VERSION || 0) + 1;
}
/**
* 설정 일부를 바꿔 저장하고 바로 적용합니다(초기 설정 페이지·관리자 페이지의 저장 단추).
* @param {object} patch { 키: 값 } — 알 수 없는 키는 무시, hidden 항목은 allowHidden 일 때만(내부 동작용)
* @returns {object} publicConfig(env)
*/
export function saveConfig(env, patch, { allowHidden = false } = {}) {
const cfg = { ...(env.CONFIG || readJsonFile(configFile(env)) || {}) };
for (const [key, value] of Object.entries(patch || {})) {
const item = BY_KEY.get(key); if (!item || (item.hidden && !allowHidden)) continue;
cfg[key] = coerceValue(item, value);
}
cfg._meta = { ...(cfg._meta || {}), format: CONFIG_FORMAT, updatedAt: new Date().toISOString() };
writeJsonFile(configFile(env), cfg);
applyConfig(env, cfg);
if (typeof env.ON_CONFIG_CHANGE === 'function') { try { env.ON_CONFIG_CHANGE(cfg); } catch (e) { console.error('설정 적용 알림 실패', e.message); } }
return publicConfig(env);
}
/** 비밀값은 앞뒤 일부만 보여 줍니다(있는지·어느 키인지만 확인할 수 있게). 짝수 길이 보장 없이 짧은 값은 전부 가립니다. */
export function maskSecret(value) {
const s = String(value || ''); if (!s) return '';
if (s.length <= 8) return '••••••';
return s.slice(0, 3) + '••••' + s.slice(-4);
}
/**
* 화면에 주는 설정: 비밀값은 가리고, 각 키의 출처(config = 설정 파일, env = 환경 변수 폴백, default = 기본값)를 함께 줍니다.
* hidden 항목은 값 대신 있음/없음만 알립니다.
*/
export function publicConfig(env) {
const cfg = env.CONFIG || {}, values = {}, sources = {}, present = {};
for (const item of CONFIG_SCHEMA) {
const inFile = item.key in cfg && cfg[item.key] !== '' && cfg[item.key] !== undefined && cfg[item.key] !== null;
const raw = inFile ? cfg[item.key] : item.env && env[item.env] ? env[item.env] : item.default ?? '';
sources[item.key] = inFile ? 'config' : item.env && env[item.env] ? 'env' : 'default';
if (item.hidden) { present[item.key] = !!raw; continue; }
values[item.key] = item.type === 'secret' ? maskSecret(raw) : raw;
}
return {
values, sources, present, inbound: env.MAIL_INBOUND || 'resend', outbound: env.MAIL_OUTBOUND || 'resend',
file: configFile(env), importedFromEnv: cfg._meta?.importedFromEnv || [], updatedAt: cfg._meta?.updatedAt || cfg._meta?.createdAt || null,
schema: CONFIG_SCHEMA.filter((i) => !i.hidden).map(({ key, group, type, options, default: d, multiline }) => ({ key, group, type, ...(options ? { options } : {}), ...(d !== undefined ? { default: d } : {}), ...(multiline ? { multiline: true } : {}) })),
};
}
/**
* 초기 설정 페이지 접속 토큰. 설정 파일 옆 setup-token 에 두고 없으면 만듭니다(0600).
* 배포 스크립트가 이 파일을 읽어 접속 주소(…/?token=…)를 출력합니다.
*/
export function setupToken(env) {
const file = join(configDir(env), 'setup-token');
try { const t = readFileSync(file, 'utf8').trim(); if (t) return t; } catch { /* 없으면 만듭니다 */ }
const token = secret(24);
try { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, token + '\n', { mode: 0o600 }); } catch (e) { console.warn(' ⚠️ setup-token 을 쓸 수 없습니다:', e.message); }
return token;
}
/**
* Haraka 설치 디렉터리(node_modules·config·plugins·queue): HARAKA_DIR, 없으면 데이터 디렉터리 옆의 haraka/. haraka/run.mjs 와 같은 규칙.
* 1.0.7: 추가 도메인의 env 는 DATA_DIR(기본 도메인의 데이터 디렉터리)을 가지므로 그 옆을 봅니다 — Haraka 는 서버에 하나뿐이고 모든 도메인이 함께 씁니다.
*/
export const harakaDir = (env) => env.HARAKA_DIR || join(env.DATA_DIR || configDir(env), '..', 'haraka');
/**
* Haraka 상태: haraka/run.mjs(별도 systemd 서비스)가 데이터 디렉터리의 haraka-status.json 에 주기적으로 쓰는 값 + 설치 여부.
* 서비스가 아예 없으면(파일 없음) status 는 null 이고 화면은 "서비스 미설치(deploy/haraka-apply.sh)" 를 안내합니다. 1.0.5 에서 setup.mjs 에서 이곳으로 옮김(provision.mjs 도 씀).
*/
export function harakaStatus(env) {
const dir = harakaDir(env); let status = null;
// 상태 파일은 기본 도메인의 데이터 디렉터리(DATA_DIR)에 하나만 있습니다(1.0.7: 추가 도메인도 같은 파일을 봄).
try { status = JSON.parse(readFileSync(join(env.DATA_DIR || configDir(env), 'haraka-status.json'), 'utf8')); } catch { status = null; }
const stale = status && Date.now() - Date.parse(status.updatedAt || 0) > 90000; // 30초마다 쓰므로 90초 넘게 조용하면 서비스가 멈춘 것
const wanted = env.MAIL_INBOUND === 'haraka' || env.MAIL_OUTBOUND === 'haraka';
return { dir, installed: existsSync(join(dir, 'node_modules', 'Haraka', 'package.json')), status, stale: !!stale, wanted };
}
/** 메일 도메인(MAILBOX 의 @ 뒤)과 Haraka 호스트 이름(HARAKA_HOSTNAME → PUBLIC_HOST). */
export const mailDomain = (env) => String(env.MAILBOX || 'admin@arihakdang.com').split('@')[1] || '';
export const harakaHostname = (env) => String(env.HARAKA_HOSTNAME || env.PUBLIC_HOST || 'mail.' + mailDomain(env)).trim();
/**
* DKIM 키 쌍을 만들어 설정에 저장합니다(RSA 2048). 공개키는 DNS TXT 레코드에 넣고 비밀키는 Haraka 가 서명에 씁니다.
* 이미 키가 있으면 force 일 때만 바꿉니다(바꾸면 DNS 도 다시 올려야 하므로).
*/
export function generateDkim(env, { force = false } = {}) {
if (env.CONFIG?.HARAKA_DKIM_PRIVATE && !force) return dnsRecords(env);
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'der' }, privateKeyEncoding: { type: 'pkcs1', format: 'pem' } });
saveConfig(env, { HARAKA_DKIM_PRIVATE: privateKey, HARAKA_DKIM_PUBLIC: Buffer.from(publicKey).toString('base64') }, { allowHidden: true });
return dnsRecords(env);
}
/**
* Haraka 로 직접 수·발신할 때 도메인 DNS 에 넣어야 하는 레코드(화면 안내용).
* MX : 받는 메일이 이 서버로 오게(수신 Haraka)
* SPF : 이 서버가 보낸 메일을 받는 쪽이 신뢰하게(발신 Haraka). Resend 를 함께 쓰면 include 를 더합니다.
* DKIM : 서명 공개키(키를 만든 뒤)
* DMARC: 권장 정책(선택)
*/
export function dnsRecords(env) {
const domain = mailDomain(env), host = harakaHostname(env), selector = env.HARAKA_DKIM_SELECTOR || 'arimail', pub = env.HARAKA_DKIM_PUBLIC || '';
const includeResend = env.MAIL_OUTBOUND === 'resend' ? ' include:amazonses.com' : '';
const records = [
{ kind: 'MX', name: domain, value: `10 ${host}.`, for: 'inbound' },
{ kind: 'TXT', name: domain, value: `v=spf1 mx a:${host}${includeResend} ~all`, for: 'outbound' },
...(pub ? [{ kind: 'TXT', name: `${selector}._domainkey.${domain}`, value: `v=DKIM1; k=rsa; p=${pub}`, for: 'outbound' }] : []),
{ kind: 'TXT', name: `_dmarc.${domain}`, value: 'v=DMARC1; p=none; adkim=r; aspf=r', for: 'outbound', optional: true },
];
return { domain, host, selector, hasDkim: !!pub, records };
}
+9
View File
@@ -0,0 +1,9 @@
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { openDb, migrate } from './db.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const file = process.env.DB_FILE || join(ROOT, 'data', 'mail-gateway.sqlite');
const db = openDb(file);
console.log(` DB: ${file}`);
migrate(db, join(ROOT, 'migrations'));
db.close();
+62
View File
@@ -0,0 +1,62 @@
/**
* SQLite 어댑터. www 사이트(server/db.mjs)와 같은 모양(prepare/bind/first/all/run/batch)을 유지해
* 두 저장소의 코드를 서로 옮겨 쓸 수 있게 합니다. node:sqlite 는 Node 22.13+ 에서 플래그 없이 동작합니다.
*/
import { DatabaseSync } from 'node:sqlite';
import { readFileSync, readdirSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
class Stmt {
constructor(db, sql, params = []) { this.db = db; this.sql = sql; this.params = params; }
bind(...params) { return new Stmt(this.db, this.sql, this.params.length ? this.params : params); }
#prep() { return this.db.prepare(this.sql); }
async first() { const row = this.#prep().get(...this.params); return row === undefined ? null : row; }
async all() { return { results: this.#prep().all(...this.params) }; }
async run() { const r = this.#prep().run(...this.params); return { success: true, meta: { changes: Number(r.changes) } }; }
}
export function openDb(file, { readOnly = false } = {}) {
if (file !== ':memory:' && !readOnly) mkdirSync(dirname(file), { recursive: true });
const db = new DatabaseSync(file, readOnly ? { readOnly: true } : {});
if (!readOnly) {
db.exec('PRAGMA foreign_keys = ON;');
if (file !== ':memory:') { db.exec('PRAGMA journal_mode = WAL;'); db.exec('PRAGMA busy_timeout = 5000;'); }
} else if (file !== ':memory:') db.exec('PRAGMA busy_timeout = 5000;');
return db;
}
export function d1(db) {
let queue = Promise.resolve();
return {
prepare: (sql) => new Stmt(db, sql),
batch: (stmts) => {
const execute = async () => {
db.exec('BEGIN');
try { const out = []; for (const s of stmts) out.push(await s.run()); db.exec('COMMIT'); return out; }
catch (e) { db.exec('ROLLBACK'); throw e; }
};
const result = queue.then(execute, execute);
queue = result.catch(() => undefined);
return result;
},
_raw: db,
};
}
export function migrate(db, dir, { quiet = false } = {}) {
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)');
const done = new Set(db.prepare('SELECT name FROM schema_migrations').all().map((r) => r.name));
let applied = 0;
for (const f of readdirSync(dir).filter((x) => x.endsWith('.sql')).sort()) {
if (done.has(f)) continue;
db.exec('BEGIN');
try {
db.exec(readFileSync(join(dir, f), 'utf8'));
db.prepare('INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)').run(f, new Date().toISOString());
db.exec('COMMIT'); applied++;
if (!quiet) console.log(` 적용: ${f}`);
} catch (e) { db.exec('ROLLBACK'); throw new Error(`마이그레이션 실패 ${f}: ${e.message}`); }
}
if (!quiet && !applied) console.log(' 적용할 마이그레이션 없음');
return applied;
}
+52
View File
@@ -0,0 +1,52 @@
/**
* DNS 모듈 공통 도구(1.0.5): 이름 정규화, TXT 분할, 오류 객체, 공개 DNS 조회(반영 확인).
*/
import { Resolver } from 'node:dns/promises';
/** FQDN(끝에 점). 빈 값은 그대로. */
export const fqdn = (name) => { const s = String(name || '').trim().toLowerCase(); return !s ? s : s.endsWith('.') ? s : s + '.'; };
/** 끝 점 없는 소문자 이름(비교용). */
export const bare = (name) => fqdn(name).replace(/\.$/, '');
/** TXT 값을 255자 이하 조각으로 나눕니다(RFC 1035 문자열 한도). */
export const txtChunks = (value) => { const s = String(value ?? ''); const out = []; for (let i = 0; i < Math.max(1, s.length); i += 255) out.push(s.slice(i, i + 255)); return out; };
/** dns_* 코드를 가진 Error. detail 은 화면에 그대로 보입니다. */
export function dnsError(code, detail) { const e = new Error(code); e.detail = String(detail || '').slice(0, 300); return e; }
/**
* 공개 리졸버(Google 8.8.8.8, Cloudflare 1.1.1.1)로 레코드가 실제로 보이는지 확인합니다. 권위 서버가 아니라 "세상이 보는 값" 기준이라
* DNS 업체에 적용한 직후에는 아직 안 보일 수 있습니다(캐시·전파). 그 경우 state 'wait' 로 알립니다.
* @param {object} record { name, type, values[], priority? }
* @param {object} [o] resolver: 시험용 대체 객체({ resolveTxt, resolveMx, resolveCname, resolve4 })
* @returns {Promise<{ok:boolean, found:string[], error?:string}>}
*/
export async function lookup(record, { resolver = null, servers = ['8.8.8.8', '1.1.1.1'] } = {}) {
const r = resolver || (() => { const x = new Resolver({ timeout: 4000, tries: 2 }); x.setServers(servers); return x; })();
const name = bare(record.name);
try {
if (record.type === 'TXT') {
const found = (await r.resolveTxt(name)).map((chunks) => chunks.join(''));
const ok = record.values.every((v) => found.some((f) => f.replace(/\s+/g, ' ').trim() === String(v).replace(/\s+/g, ' ').trim()));
return { ok, found };
}
if (record.type === 'MX') {
const found = (await r.resolveMx(name)).map((m) => `${m.priority} ${bare(m.exchange)}`);
const want = record.values.map((v) => `${record.priority ?? 10} ${bare(v)}`);
const ok = want.every((w) => found.includes(w));
return { ok, found };
}
if (record.type === 'CNAME') { const found = (await r.resolveCname(name)).map(bare); return { ok: record.values.every((v) => found.includes(bare(v))), found }; }
if (record.type === 'A') { const found = await r.resolve4(name); return { ok: record.values.every((v) => found.includes(v)), found }; }
return { ok: false, found: [], error: 'unsupported_type' };
} catch (e) {
// ENOTFOUND/ENODATA = 아직 없음(전파 대기), 그 밖은 조회 실패
return { ok: false, found: [], error: e.code === 'ENOTFOUND' || e.code === 'ENODATA' ? 'not_found' : e.code || e.message };
}
}
/** 호스트 이름의 A 레코드와 그 IP 의 PTR(역방향)을 함께 봅니다(Haraka 발신 준비 점검용). */
export async function reverseCheck(hostname, { resolver = null, servers = ['8.8.8.8', '1.1.1.1'] } = {}) {
const r = resolver || (() => { const x = new Resolver({ timeout: 4000, tries: 2 }); x.setServers(servers); return x; })();
let ips = []; try { ips = await r.resolve4(bare(hostname)); } catch (e) { return { ok: false, ips: [], ptr: [], error: 'a_' + (e.code || 'failed') }; }
const ptr = []; for (const ip of ips) { try { ptr.push(...(await r.reverse(ip)).map(bare)); } catch { /* PTR 없음 */ } }
return { ok: ips.length > 0 && ptr.includes(bare(hostname)), ips, ptr };
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Google Cloud DNS 모듈(1.0.5, 요구사항 1). 외부 의존성 없이 서비스 계정(JSON 키)으로 인증해 Cloud DNS REST API(v1)를 부릅니다.
*
* 인증: 서비스 계정 JSON 의 client_email / private_key 로 RS256 JWT 를 만들어 OAuth2 토큰(https://oauth2.googleapis.com/token)과 교환합니다.
* 범위는 ndev.clouddns.readwrite. 토큰은 만료 1분 전까지 재사용합니다. 서비스 계정에는 프로젝트의 "DNS 관리자" 역할이 필요합니다.
* 존 찾기: 프로젝트의 managedZones 목록에서 dnsName 이 도메인(또는 그 상위 도메인)과 같은 공개 존을 고릅니다(DNS_GOOGLE_ZONE 으로 고정 가능).
* 적용: changes API 에 additions/deletions 를 한 번에 넘깁니다(같은 이름·형식의 기존 rrset 은 deletions 로 빼고 새 값으로 넣음 = UPSERT).
* TXT 값은 따옴표로 감싸고 255자 단위로 나눕니다(DKIM 공개키처럼 긴 값). MX 는 "우선순위 호스트." 형식, CNAME/MX 의 호스트는 끝에 점을 붙입니다.
*
* 설정(config.json): DNS_GOOGLE_CREDENTIALS(서비스 계정 JSON 전체, 비밀), DNS_GOOGLE_PROJECT(비우면 JSON 의 project_id), DNS_GOOGLE_ZONE(비우면 자동).
* 시험: env.DNS_FETCH 로 fetch 를 바꿔 끼울 수 있습니다.
*/
import { createSign } from 'node:crypto';
import { fqdn, txtChunks, dnsError } from './common.mjs';
const SCOPE = 'https://www.googleapis.com/auth/ndev.clouddns.readwrite';
const tokenCache = new WeakMap(); // env.DB → { token, exp }
/** 서비스 계정 JSON 해석. 비어 있거나 모양이 틀리면 dns_not_configured. */
export function credentials(env) {
const raw = String(env.DNS_GOOGLE_CREDENTIALS || '').trim();
if (!raw) throw dnsError('dns_not_configured', 'Google 서비스 계정 JSON 이 없습니다');
let json; try { json = JSON.parse(raw); } catch { throw dnsError('dns_bad_credentials', '서비스 계정 JSON 을 해석할 수 없습니다'); }
if (!json.client_email || !json.private_key) throw dnsError('dns_bad_credentials', '서비스 계정 JSON 에 client_email / private_key 가 없습니다');
const project = String(env.DNS_GOOGLE_PROJECT || json.project_id || '').trim();
if (!project) throw dnsError('dns_not_configured', '프로젝트 id 가 없습니다(DNS_GOOGLE_PROJECT)');
return { email: json.client_email, key: json.private_key, tokenUri: json.token_uri || 'https://oauth2.googleapis.com/token', project };
}
const b64url = (v) => Buffer.from(typeof v === 'string' ? v : JSON.stringify(v)).toString('base64url');
/** RS256 JWT 를 만들어 액세스 토큰을 받습니다(캐시). */
async function accessToken(env) {
const cached = tokenCache.get(env.DB || env); if (cached && cached.exp > Date.now() + 60000) return cached.token;
const c = credentials(env), now = Math.floor(Date.now() / 1000);
const unsigned = b64url({ alg: 'RS256', typ: 'JWT' }) + '.' + b64url({ iss: c.email, scope: SCOPE, aud: c.tokenUri, iat: now, exp: now + 3600 });
let signature; try { signature = createSign('RSA-SHA256').update(unsigned).sign(c.key, 'base64url'); } catch (e) { throw dnsError('dns_bad_credentials', '개인키로 서명하지 못했습니다: ' + e.message); }
const r = await (env.DNS_FETCH || fetch)(c.tokenUri, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: unsigned + '.' + signature }), signal: AbortSignal.timeout(20000) }).catch((e) => { throw dnsError('dns_unavailable', 'Google 인증 서버 연결 실패: ' + e.message); });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.access_token) throw dnsError(r.status === 400 || r.status === 401 ? 'dns_auth_failed' : 'dns_unavailable', `토큰 발급 실패 ${r.status} ${data.error_description || data.error || ''}`.trim());
tokenCache.set(env.DB || env, { token: data.access_token, exp: Date.now() + Number(data.expires_in || 3600) * 1000 });
return data.access_token;
}
/** Cloud DNS API 호출. 오류는 dns_* 코드로. */
async function api(env, path, method = 'GET', body) {
const token = await accessToken(env), c = credentials(env);
const r = await (env.DNS_FETCH || fetch)(`https://dns.googleapis.com/dns/v1/projects/${encodeURIComponent(c.project)}${path}`, { method, headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) }).catch((e) => { throw dnsError('dns_unavailable', 'Cloud DNS 연결 실패: ' + e.message); });
const data = await r.json().catch(() => ({}));
if (!r.ok) throw dnsError(r.status === 401 ? 'dns_auth_failed' : r.status === 403 ? 'dns_forbidden' : r.status === 404 ? 'dns_zone_not_found' : 'dns_unavailable', `Cloud DNS ${r.status} ${data.error?.message || ''}`.trim());
return data;
}
/**
* 도메인을 담당하는 존을 찾습니다. DNS_GOOGLE_ZONE 이 있으면 그 존을, 없으면 dnsName 이 도메인과 같거나 상위인 공개 존 중 가장 긴 것.
* @returns {Promise<{id:string, name:string, dnsName:string}>}
*/
export async function findZone(env, domain) {
const want = fqdn(domain);
if (env.DNS_GOOGLE_ZONE) { const z = await api(env, '/managedZones/' + encodeURIComponent(env.DNS_GOOGLE_ZONE)); return { id: z.name, name: z.name, dnsName: z.dnsName }; }
const list = (await api(env, '/managedZones')).managedZones || [];
const candidates = list.filter((z) => z.visibility !== 'private' && (want === z.dnsName || want.endsWith('.' + z.dnsName))).sort((a, b) => b.dnsName.length - a.dnsName.length);
if (!candidates.length) throw dnsError('dns_zone_not_found', `${domain} 을 담당하는 존이 프로젝트에 없습니다`);
return { id: candidates[0].name, name: candidates[0].name, dnsName: candidates[0].dnsName };
}
/** 레코드 값 → Cloud DNS rrdatas 문자열. */
export function rrdata(record) {
if (record.type === 'TXT') return record.values.map((v) => txtChunks(v).map((c) => `"${c.replace(/(["\\])/g, '\\$1')}"`).join(' '));
if (record.type === 'MX') return record.values.map((v) => `${record.priority ?? 10} ${fqdn(v)}`);
if (record.type === 'CNAME' || record.type === 'NS') return record.values.map((v) => fqdn(v));
return record.values.slice();
}
/** 존의 특정 이름·형식 rrset(없으면 null). */
async function existing(env, zone, name, type) {
const data = await api(env, `/managedZones/${encodeURIComponent(zone.id)}/rrsets?name=${encodeURIComponent(fqdn(name))}&type=${encodeURIComponent(type)}`);
return (data.rrsets || [])[0] || null;
}
/**
* 레코드들을 UPSERT 합니다(같은 이름·형식은 새 값으로 교체). 바뀐 것이 없으면 변경을 보내지 않습니다.
* @param {object[]} records { name, type, ttl, values[], priority? }
* @returns {Promise<{zone:string, changed:string[], unchanged:string[]}>}
*/
export async function upsert(env, zone, records) {
const additions = [], deletions = [], changed = [], unchanged = [];
for (const r of records) {
const want = { name: fqdn(r.name), type: r.type, ttl: Number(r.ttl || 300), rrdatas: rrdata(r) };
const cur = await existing(env, zone, r.name, r.type);
if (cur && JSON.stringify([...cur.rrdatas].sort()) === JSON.stringify([...want.rrdatas].sort())) { unchanged.push(`${r.type} ${r.name}`); continue; }
if (cur) deletions.push({ name: cur.name, type: cur.type, ttl: cur.ttl, rrdatas: cur.rrdatas });
additions.push(want); changed.push(`${r.type} ${r.name}`);
}
if (additions.length) await api(env, `/managedZones/${encodeURIComponent(zone.id)}/changes`, 'POST', { additions, deletions });
return { zone: zone.name, changed, unchanged };
}
/** 연결 확인: 토큰을 받고 존을 찾아 봅니다. */
export async function probe(env, domain) { const zone = await findZone(env, domain); return { ok: true, provider: 'google', zone: zone.name, dnsName: zone.dnsName, project: credentials(env).project }; }
+99
View File
@@ -0,0 +1,99 @@
/**
* DNS 모듈 선택과 "필요한 레코드" 계산(1.0.5, 요구사항 1·2).
*
* 역할
* - dnsProvider(env): 설정 DNS_PROVIDER(none | google | route53)에 따라 Google Cloud DNS / AWS Route 53 모듈을 고릅니다. none 이면 자동 적용 없이
* 레코드를 사용자에게 보여 주기만 합니다(요구사항 1: 인증 정보가 없으면 DNS 설정 정보를 제공).
* - requiredRecords(env, { resendDomain, existing }): 지금 설정(수·발신 방식, 도메인, Haraka 호스트 이름, DKIM, Resend 도메인의 요구 레코드)으로
* 도메인에 있어야 하는 레코드 목록을 만듭니다. 기존 SPF 가 있으면 우리 메커니즘만 끼워 넣어(mergeSpf) 다른 발송 서비스 설정을 깨지 않습니다.
* - applyRecords(env, records): 고른 모듈로 존을 찾고 UPSERT 합니다.
* - checkRecords(records): 공개 리졸버로 반영 여부를 확인합니다(common.mjs lookup).
*
* 레코드 모양: { name(FQDN, 끝 점 없음), type, ttl, values[], priority?, purpose(화면 설명), source('haraka'|'resend'), optional?(권장 사항 — 준비 완료 판정에서 제외, 자동 적용도 하지 않음) }
*/
import * as google from './google.mjs';
import * as route53 from './route53.mjs';
import { lookup, bare, dnsError } from './common.mjs';
import { inboundMode, outboundMode } from '../providers/index.mjs';
import { mailDomain, harakaHostname } from '../config.mjs';
import { resendDnsRecords } from '../providers/resend.mjs';
export const DNS_PROVIDERS = ['none', 'google', 'route53'];
const MODULES = { google, route53 };
/** 설정된 DNS 모듈 이름(모르는 값은 none). */
export const dnsProviderName = (env) => (DNS_PROVIDERS.includes(env.DNS_PROVIDER) ? env.DNS_PROVIDER : 'none');
/** 모듈 객체 또는 null(none). */
export const dnsProvider = (env) => MODULES[dnsProviderName(env)] || null;
/** 모듈이 쓸 인증 정보가 설정에 있는지(값의 유효성은 probe 로 확인). */
export function dnsConfigured(env) {
const p = dnsProviderName(env);
if (p === 'google') return !!String(env.DNS_GOOGLE_CREDENTIALS || '').trim();
if (p === 'route53') return !!(String(env.DNS_AWS_ACCESS_KEY_ID || '').trim() && String(env.DNS_AWS_SECRET_ACCESS_KEY || '').trim());
return false;
}
/**
* 기존 SPF 레코드에 우리 메커니즘을 끼워 넣습니다. 예: "v=spf1 include:_spf.google.com ~all" + ["mx","a:mail.x.com"] → "v=spf1 include:_spf.google.com mx a:mail.x.com ~all".
* 이미 있는 메커니즘은 다시 넣지 않고, 끝의 all 지시(~all/-all/?all/+all)는 그대로 둡니다. 기존 값이 없으면 새로 만듭니다(기본 ~all).
*/
export function mergeSpf(existing, mechanisms) {
const cur = String(existing || '').trim();
if (!/^v=spf1\b/i.test(cur)) return `v=spf1 ${mechanisms.join(' ')} ~all`;
const parts = cur.split(/\s+/); const all = parts.findLast((p) => /^[-~?+]?all$/i.test(p));
const body = parts.filter((p) => !/^v=spf1$/i.test(p) && !/^[-~?+]?all$/i.test(p));
for (const m of mechanisms) if (!body.some((p) => p.toLowerCase() === m.toLowerCase())) body.push(m);
return ['v=spf1', ...body, all || '~all'].join(' ');
}
/**
* 도메인에 있어야 하는 레코드 목록.
* @param {object} [o]
* @param {object|null} [o.resendDomain] Resend 도메인 상세(records 포함). 수신 방식이 resend 가 아니면 그 안의 MX 는 뺍니다(루트 MX 는 한 가지만).
* @param {string[]} [o.existingSpf] 루트 TXT 중 기존 SPF 값(있으면 병합)
*/
export function requiredRecords(env, { resendDomain = null, existingSpf = null } = {}) {
const domain = mailDomain(env), host = bare(harakaHostname(env)), inbound = inboundMode(env), outbound = outboundMode(env), out = [];
if (inbound === 'haraka') out.push({ name: domain, type: 'MX', ttl: 300, values: [host], priority: 10, purpose: '받는 메일을 이 서버(Haraka)로', source: 'haraka' });
if (outbound === 'haraka') {
out.push({ name: domain, type: 'TXT', ttl: 300, values: [mergeSpf(existingSpf, ['mx', `a:${host}`])], purpose: 'SPF — 이 서버가 보낸 메일을 신뢰', source: 'haraka' });
if (env.HARAKA_DKIM_PUBLIC) out.push({ name: `${env.HARAKA_DKIM_SELECTOR || 'arimail'}._domainkey.${domain}`, type: 'TXT', ttl: 300, values: [`v=DKIM1; k=rsa; p=${env.HARAKA_DKIM_PUBLIC}`], purpose: 'DKIM 서명 공개키', source: 'haraka' });
out.push({ name: `_dmarc.${domain}`, type: 'TXT', ttl: 300, values: ['v=DMARC1; p=none; adkim=r; aspf=r'], purpose: 'DMARC 정책(권장, 자동 적용하지 않음)', source: 'haraka', optional: true });
}
if (resendDomain) for (const r of resendDnsRecords(resendDomain)) { if (r.type === 'MX' && inbound !== 'resend') continue; out.push(r); }
return out;
}
/**
* 모든(선택 아닌) 레코드의 반영 여부. 각 레코드에 check { ok, found[], error?, byProvider?, problem? } 를 붙여 돌려줍니다.
* 1.0.11: Resend 가 자기 레코드를 verified 라고 하면 우리 조회 값이 달라도 ok 로 봅니다(byProvider). Resend 가 받아들인 값(예: 예전 수신 MX
* inbound-smtp.<지역>.amazonaws.com)을 "틀렸다" 고 표시해 사용자가 멀쩡한 레코드를 바꾸게 하는 일을 막습니다. 반대로 Resend 가 failed 라고 해도
* 우리 조회가 맞으면 ok — Resend 의 레코드 상태는 마지막 검증 시점의 것이라 DNS 를 고친 뒤에는 오래된 값일 수 있습니다(재검증이 갱신).
* problem: 'wrong'(다른 값이 보임) / 'missing'(없음) — 화면이 "값을 바꾸세요" 와 "반영을 기다리세요" 를 구분하는 근거.
*/
export async function checkRecords(records, { resolver = null } = {}) {
const out = [];
for (const r of records) {
const check = await lookup(r, { resolver });
const byProvider = !check.ok && String(r.status || '') === 'verified';
out.push({ ...r, check: { ...check, ok: check.ok || byProvider, ...(byProvider ? { byProvider: true } : {}), problem: check.ok || byProvider ? null : check.found && check.found.length ? 'wrong' : 'missing' } });
}
return out;
}
/**
* 고른 DNS 모듈로 레코드를 적용합니다(optional 레코드는 뺌). 모듈이 없으면 dns_not_configured.
* @returns {Promise<{provider:string, zone:string, changed:string[], unchanged:string[]}>}
*/
export async function applyRecords(env, records) {
const mod = dnsProvider(env); if (!mod) throw dnsError('dns_not_configured', 'DNS 모듈이 설정되지 않았습니다(DNS_PROVIDER)');
const zone = await mod.findZone(env, mailDomain(env));
const result = await mod.upsert(env, zone, records.filter((r) => !r.optional));
return { provider: dnsProviderName(env), ...result };
}
/** 연결 확인(설정 화면 단추): 인증과 존 찾기. */
export async function probeDns(env) {
const mod = dnsProvider(env); if (!mod) return { ok: false, provider: 'none', error: 'dns_not_configured' };
try { return await mod.probe(env, mailDomain(env)); } catch (e) { return { ok: false, provider: dnsProviderName(env), error: e.message, detail: e.detail }; }
}
+107
View File
@@ -0,0 +1,107 @@
/**
* AWS Route 53 모듈(1.0.5, 요구사항 1). 외부 의존성 없이 액세스 키로 SigV4 서명을 만들어 Route 53 REST API(2013-04-01, XML)를 부릅니다.
*
* 인증: DNS_AWS_ACCESS_KEY_ID / DNS_AWS_SECRET_ACCESS_KEY (IAM 사용자 또는 역할 키). 필요한 권한: route53:ListHostedZonesByName, GetHostedZone,
* ListResourceRecordSets, ChangeResourceRecordSets, GetChange. Route 53 은 글로벌 서비스라 서명 지역은 us-east-1 입니다.
* 존 찾기: ListHostedZonesByName 으로 도메인(또는 상위 도메인)과 이름이 같은 공개 호스팅 존을 고릅니다(DNS_AWS_HOSTED_ZONE_ID 로 고정 가능).
* 적용: ChangeResourceRecordSets 의 UPSERT 로 같은 이름·형식을 새 값으로 바꿉니다. TXT 는 따옴표 + 255자 분할, MX 는 "우선순위 호스트".
* XML 은 이 API 가 쓰는 단순한 모양만 다루므로 정규식으로 만들고 읽습니다(외부 XML 라이브러리 없음).
* 시험: env.DNS_FETCH 로 fetch 를 바꿔 끼울 수 있습니다.
*/
import { createHash, createHmac } from 'node:crypto';
import { fqdn, txtChunks, dnsError } from './common.mjs';
const HOST = 'route53.amazonaws.com', REGION = 'us-east-1', SERVICE = 'route53';
const sha256 = (v) => createHash('sha256').update(v).digest('hex');
const hmac = (key, v, enc) => createHmac('sha256', key).update(v).digest(enc);
const esc = (v) => String(v).replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[c]));
const unesc = (v) => String(v).replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
export function credentials(env) {
const id = String(env.DNS_AWS_ACCESS_KEY_ID || '').trim(), secret = String(env.DNS_AWS_SECRET_ACCESS_KEY || '').trim();
if (!id || !secret) throw dnsError('dns_not_configured', 'AWS 액세스 키가 없습니다');
return { id, secret };
}
/**
* SigV4 서명 요청. 쿼리 문자열은 미리 정규화(키 정렬·인코딩)해서 넘깁니다.
* @returns {Promise<{status:number, text:string}>}
*/
async function request(env, method, path, query = '', body = '') {
const c = credentials(env), now = new Date(), amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''), date = amzDate.slice(0, 8);
const payloadHash = sha256(body);
const headers = { host: HOST, 'x-amz-date': amzDate, 'x-amz-content-sha256': payloadHash, ...(body ? { 'content-type': 'application/xml' } : {}) };
const signedHeaders = Object.keys(headers).sort().join(';');
const canonical = [method, path.split('/').map((seg) => encodeURIComponent(decodeURIComponent(seg))).join('/'), query, ...Object.keys(headers).sort().map((k) => `${k}:${String(headers[k]).trim()}`), '', signedHeaders, payloadHash].join('\n');
const scope = `${date}/${REGION}/${SERVICE}/aws4_request`;
const toSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonical)].join('\n');
const kSigning = hmac(hmac(hmac(hmac('AWS4' + c.secret, date), REGION), SERVICE), 'aws4_request');
const signature = hmac(kSigning, toSign, 'hex');
const authorization = `AWS4-HMAC-SHA256 Credential=${c.id}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
const r = await (env.DNS_FETCH || fetch)(`https://${HOST}${path}${query ? '?' + query : ''}`, { method, headers: { ...headers, Authorization: authorization }, body: body || undefined, signal: AbortSignal.timeout(30000) }).catch((e) => { throw dnsError('dns_unavailable', 'Route 53 연결 실패: ' + e.message); });
const text = await r.text().catch(() => '');
if (!r.ok) {
const code = (text.match(/<Code>([^<]+)<\/Code>/) || [])[1] || String(r.status), message = unesc((text.match(/<Message>([^<]*)<\/Message>/) || [])[1] || '');
throw dnsError(/InvalidClientTokenId|SignatureDoesNotMatch|InvalidSignature|AuthFailure|UnrecognizedClient/.test(code) ? 'dns_auth_failed' : /AccessDenied|NotAuthorized/.test(code) ? 'dns_forbidden' : /NoSuchHostedZone/.test(code) ? 'dns_zone_not_found' : 'dns_unavailable', `Route 53 ${code} ${message}`.trim());
}
return { status: r.status, text };
}
/** 호스팅 존 찾기(DNS_AWS_HOSTED_ZONE_ID 가 있으면 그 존). 사설 존은 뺍니다. */
export async function findZone(env, domain) {
if (env.DNS_AWS_HOSTED_ZONE_ID) {
const id = String(env.DNS_AWS_HOSTED_ZONE_ID).replace(/^\/hostedzone\//, '');
const { text } = await request(env, 'GET', `/2013-04-01/hostedzone/${encodeURIComponent(id)}`);
return { id, name: unesc((text.match(/<Name>([^<]+)<\/Name>/) || [])[1] || ''), dnsName: unesc((text.match(/<Name>([^<]+)<\/Name>/) || [])[1] || '') };
}
const want = fqdn(domain); const labels = want.split('.').filter(Boolean);
// 상위 도메인까지 차례로: mail.example.com. → example.com. → com.
for (let i = 0; i < labels.length - 1; i++) {
const candidate = labels.slice(i).join('.') + '.';
const { text } = await request(env, 'GET', '/2013-04-01/hostedzonesbyname', `dnsname=${encodeURIComponent(candidate)}&maxitems=5`);
for (const block of text.match(/<HostedZone>[\s\S]*?<\/HostedZone>/g) || []) {
const name = unesc((block.match(/<Name>([^<]+)<\/Name>/) || [])[1] || ''), id = ((block.match(/<Id>([^<]+)<\/Id>/) || [])[1] || '').replace(/^\/hostedzone\//, '');
const priv = /<PrivateZone>true<\/PrivateZone>/.test(block);
if (name === candidate && !priv) return { id, name, dnsName: name };
}
}
throw dnsError('dns_zone_not_found', `${domain} 을 담당하는 호스팅 존이 계정에 없습니다`);
}
/** 레코드 값 → Route 53 ResourceRecord Value 목록. */
export function values(record) {
if (record.type === 'TXT') return record.values.map((v) => txtChunks(v).map((c) => `"${c.replace(/(["\\])/g, '\\$1')}"`).join(' '));
if (record.type === 'MX') return record.values.map((v) => `${record.priority ?? 10} ${fqdn(v)}`);
if (record.type === 'CNAME' || record.type === 'NS') return record.values.map((v) => fqdn(v));
return record.values.slice();
}
/** 존의 이름·형식 rrset(없으면 null). */
async function existing(env, zone, name, type) {
const { text } = await request(env, 'GET', `/2013-04-01/hostedzone/${encodeURIComponent(zone.id)}/rrset`, `maxitems=1&name=${encodeURIComponent(fqdn(name))}&type=${encodeURIComponent(type)}`);
const block = (text.match(/<ResourceRecordSet>[\s\S]*?<\/ResourceRecordSet>/g) || [])[0]; if (!block) return null;
const n = unesc((block.match(/<Name>([^<]+)<\/Name>/) || [])[1] || ''), t = (block.match(/<Type>([^<]+)<\/Type>/) || [])[1];
if (n.toLowerCase() !== fqdn(name).toLowerCase() || t !== type) return null; // maxitems=1 은 "그 이름부터" 돌려주므로 다른 레코드일 수 있음
return { name: n, type: t, ttl: Number((block.match(/<TTL>(\d+)<\/TTL>/) || [])[1] || 300), values: [...block.matchAll(/<Value>([^<]*)<\/Value>/g)].map((m) => unesc(m[1])) };
}
/**
* UPSERT. 바뀐 것이 없으면 요청을 보내지 않습니다.
* @returns {Promise<{zone:string, changed:string[], unchanged:string[], changeId?:string}>}
*/
export async function upsert(env, zone, records) {
const changes = [], changed = [], unchanged = [];
for (const r of records) {
const want = values(r), cur = await existing(env, zone, r.name, r.type);
if (cur && JSON.stringify([...cur.values].sort()) === JSON.stringify([...want].sort())) { unchanged.push(`${r.type} ${r.name}`); continue; }
changes.push(`<Change><Action>UPSERT</Action><ResourceRecordSet><Name>${esc(fqdn(r.name))}</Name><Type>${esc(r.type)}</Type><TTL>${Number(r.ttl || 300)}</TTL><ResourceRecords>${want.map((v) => `<ResourceRecord><Value>${esc(v)}</Value></ResourceRecord>`).join('')}</ResourceRecords></ResourceRecordSet></Change>`);
changed.push(`${r.type} ${r.name}`);
}
if (!changes.length) return { zone: zone.name, changed, unchanged };
const body = `<?xml version="1.0" encoding="UTF-8"?><ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/"><ChangeBatch><Comment>AriMail mail records</Comment><Changes>${changes.join('')}</Changes></ChangeBatch></ChangeResourceRecordSetsRequest>`;
const { text } = await request(env, 'POST', `/2013-04-01/hostedzone/${encodeURIComponent(zone.id)}/rrset`, '', body);
return { zone: zone.name, changed, unchanged, changeId: ((text.match(/<Id>([^<]+)<\/Id>/) || [])[1] || '').replace(/^\/change\//, '') };
}
/** 연결 확인: 서명이 맞는지와 존이 있는지. */
export async function probe(env, domain) { const zone = await findZone(env, domain); return { ok: true, provider: 'route53', zone: zone.name, zoneId: zone.id }; }
+356
View File
@@ -0,0 +1,356 @@
/**
* 다중 도메인 등록부와 "테넌트"(1.0.7, 요구사항 1·2).
*
* 생각의 틀
* AriMail 의 모든 모듈은 `env` 객체 하나로 자기 세계(DB·설정 파일·메일 도메인·공개 호스트·로그인 방식)를 봅니다. 그래서 도메인을 하나 더 운영한다는 것은
* "env 를 하나 더 만든다" 는 뜻입니다. 이 모듈은 도메인마다 그런 env(= 테넌트)를 만들어 보관하고, 들어온 요청(HTTP Host, 로그인 ID 의 @ 뒤,
* 받는 사람 주소)으로 어느 테넌트의 일인지 고릅니다. 각 도메인은 자기 DB(계정·세션·메일), 자기 설정 파일(Resend 키·DNS·수·발신 방식), 자기 파일 저장소를
* 가지므로 로그인과 계정 관리가 도메인 단위로 완전히 나뉩니다.
*
* 기본 도메인(primary)
* 환경 파일(gateway.env)의 MAILBOX / PUBLIC_HOST / LOGIN_MODE / WWW_DB_FILE / DB_FILE 로 정해지는 기존 운영 그대로의 도메인입니다. 1.0.7 로 올린 서버는
* 첫 시작에 등록부(domains.json)가 없으므로 이 도메인 하나로 등록부를 만들고(데이터는 전혀 옮기지 않음) 예전처럼 동작합니다.
* 기본 도메인의 DB·설정 파일 위치는 계속 환경 파일이 정하고, 등록부에는 추가 호스트 이름 같은 보조 정보만 둡니다.
*
* 추가 도메인
* 데이터 디렉터리 아래 domains/<도메인>/ 에 mail.sqlite(같은 마이그레이션) · config.json · files/ 를 둡니다. 전체 설정 화면(전용 포트)에서 추가하면
* 재시작 없이 바로 만들어지고, 첫 관리자(admin / 지정한 비밀번호)를 자동으로 만들어 바로 로그인할 수 있습니다. 로그인 방식은 도메인마다
* local(자체 계정) · www(연결된 웹 서비스 DB 의 관리자 계정으로 인증) · webmaker(1.0.8: 웹메이커의 사용자 파일을 읽기 전용으로 참조, server/webmaker.mjs)입니다.
*
* 등록부 파일(domains.json, 기본 도메인의 데이터 디렉터리)
* { _meta:{format}, primary:'arihakdang.com', domains:{ 'example.com': { domain, enabled, hosts:[…], mailbox, auth:{mode, wwwDbFile, ownerLogin, webmakerDir, webmakerUser, siteId}, note, createdAt, updatedAt } } }
*
* 호스트 이름과 경로(1.0.8)
* hosts 의 항목은 "호스트" 또는 "호스트/경로"(예: site.ariweb.net/mail)입니다. 경로가 있으면 그 호스트의 그 경로 아래가 이 도메인의 웹 메일입니다 — 서브 도메인 사이트처럼
* 메일용 호스트 이름을 따로 만들기 어려울 때 nginx 가 /mail/ 을 게이트웨이로 넘기고(X-Forwarded-Prefix: /mail, deploy/site-mail-path.sh) 화면은 그 경로를 기준(<base>)으로 동작합니다.
*
* 요청 → 테넌트 고르기(resolveHost)
* 1) 호스트가 없거나 localhost / IP 주소이면 기본 도메인(nginx 뒤의 상태 확인, 로컬 도구, 서버 IP 로 직접 접속).
* 2) 어떤 도메인의 hosts 에 정확히 있으면 그 도메인. 3) 호스트가 도메인과 같거나 그 하위(…​.example.com)면 그 도메인(가장 긴 도메인 우선).
* 4) 아무 데도 없으면 null → 화면은 "메일 서비스가 존재하지 않습니다" 를 보여 줍니다. 꺼 둔(enabled=false) 도메인은 disabled 로 알려 "활성화되지 않았습니다" 를 보여 줍니다.
*/
import { readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, existsSync, accessSync, constants } from 'node:fs';
import { dirname, join } from 'node:path';
import { openDb, d1, migrate } from './db.mjs';
import { loadConfig, configFile, configDir, harakaDir, CONFIG_SCHEMA } from './config.mjs';
import { ensureInitialAdmin, externalUsers, INITIAL_ADMIN_PASSWORD, PASSWORD_MIN_FLOOR } from './auth.mjs';
import { memoryRateStore } from './util.mjs';
export const REGISTRY_FORMAT = 1;
export const AUTH_MODES = ['local', 'www', 'webmaker'];
/** 웹메이커 사이트 id(32자리 16진수)와 sudo 로 파일을 읽을 계정 이름의 형식. */
const SITE_ID_RE = /^[a-f0-9]{32}$/, USER_RE = /^[a-z_][a-z0-9_-]{0,31}$/;
/** 도메인 이름 형식: 소문자 영숫자·하이픈 이름표를 점으로 이은 것(국제화 도메인은 퓨니코드로). */
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const HOST_RE = DOMAIN_RE;
const IPV4_RE = /^\d{1,3}(\.\d{1,3}){3}$/;
/**
* 새 도메인을 만들 때 기본 도메인의 설정에서 복사해 오는 키(요구사항 2: 전체 설정에서 API 키·DNS 설정을 함께 관리).
* 도메인마다 다른 값(수·발신 방식, Resend 도메인/웹훅 id, DKIM 키, 사이트 이름, 완료 표시)은 복사하지 않습니다. DNS 존 id(DNS_GOOGLE_ZONE·DNS_AWS_HOSTED_ZONE_ID)도
* 복사하지 않습니다 — 기본 도메인의 존에 새 도메인의 레코드를 넣으려다 실패하므로, 새 도메인은 도메인 이름으로 존을 자동 검색합니다.
*/
export const COPY_KEYS = ['RESEND_API_KEY', 'RESEND_REGION', 'DNS_PROVIDER', 'DNS_GOOGLE_CREDENTIALS', 'DNS_GOOGLE_PROJECT', 'DNS_AWS_ACCESS_KEY_ID', 'DNS_AWS_SECRET_ACCESS_KEY', 'SMTP_RELAY_HOST', 'SMTP_RELAY_PORT', 'SMTP_RELAY_SECURITY', 'SMTP_RELAY_USER', 'SMTP_RELAY_PASS', 'HARAKA_PORT', 'HARAKA_RELAY_PORT', 'HARAKA_HOSTNAME', 'STATS_TIMEZONE'];
/** 호스트 이름 정규화: 소문자, 끝 점·포트 제거. "호스트/경로" 가 들어와도 호스트 부분만 돌려줍니다. */
export const normalizeHost = (host) => String(host || '').trim().toLowerCase().split('/')[0].replace(/^\[([^\]]+)\](:\d+)?$/, '$1').replace(/:\d+$/, '').replace(/\.$/, '');
/**
* 경로 접두사 정규화(1.0.8): "/mail", "mail/", "/a/b/" → "/mail", "/mail", "/a/b". 비어 있거나 "/" 면 ''(접두사 없음). 허용 글자 밖이거나 ".." 가 있으면 null.
* X-Forwarded-Prefix 헤더(nginx)와 등록부의 "호스트/경로" 둘 다 이 함수로 다듬습니다.
*/
export function normalizePrefix(prefix) {
const raw = String(prefix ?? '').trim(); if (!raw || raw === '/') return '';
const parts = raw.split('/').filter(Boolean);
if (!parts.length) return '';
for (const p of parts) if (p === '.' || p === '..' || !/^[A-Za-z0-9._~-]{1,64}$/.test(p)) return null;
return '/' + parts.join('/');
}
/** 등록부의 호스트 항목("호스트" 또는 "호스트/경로")을 { host, prefix } 로 나눕니다(둘 다 소문자; nginx 설정과 맞추기 쉽게 경로도 소문자로 통일). */
export function splitHostEntry(entry) {
const raw = String(entry || '').trim().toLowerCase(); const i = raw.indexOf('/');
return { host: normalizeHost(i < 0 ? raw : raw.slice(0, i)), prefix: i < 0 ? '' : (normalizePrefix(raw.slice(i)) ?? null) };
}
/** 호스트 항목의 호스트 이름 부분. */
export const hostNameOf = (entry) => splitHostEntry(entry).host;
/** 도메인 이름 정규화(소문자, 앞뒤 공백·점 제거). */
export const normalizeDomain = (domain) => String(domain || '').trim().toLowerCase().replace(/^\.+|\.+$/g, '');
export const isDomainName = (domain) => DOMAIN_RE.test(normalizeDomain(domain));
/** 로컬 접속으로 보는 호스트: 비어 있음, localhost(.localhost 포함), IPv4/IPv6 주소. 이런 요청은 기본 도메인이 받습니다. */
export const isLocalHost = (host) => { const h = normalizeHost(host); return !h || h === 'localhost' || h.endsWith('.localhost') || IPV4_RE.test(h) || h.includes(':'); };
/** 등록부 파일 경로: 기본 도메인의 설정 파일(config.json) 옆 domains.json. */
export const registryFile = (baseEnv) => join(configDir(baseEnv), 'domains.json');
function readJson(file) { try { return JSON.parse(readFileSync(file, 'utf8')); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } }
/** 임시 파일 → 이름 바꾸기(반쪽 파일 방지). 비밀값은 없지만 운영 정보이므로 0600. */
function writeJson(file, data) {
mkdirSync(dirname(file), { recursive: true });
const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
try { chmodSync(tmp, 0o600); } catch { /* 권한을 못 바꾸는 파일 시스템 */ }
renameSync(tmp, file);
}
/** 검증 오류: Error('validation') + field. API 가 422 로 돌려줍니다. */
const invalid = (field) => { const e = new Error('validation'); e.field = field; return e; };
/**
* 등록부 항목 하나를 입력(화면 폼)에서 만듭니다. 호스트 목록은 쉼표·공백으로 나눈 문자열이나 배열을 받고, 비어 있으면 mail.<도메인>.
* @param {object} body { domain, hosts?, mailbox?, auth?:{mode, wwwDbFile, ownerLogin}, note?, enabled? }
* @param {object|null} [current] 수정할 때의 기존 항목(없는 필드는 그대로 둠)
*/
export function normalizeEntry(body, current = null, { keepAuth = false } = {}) {
const domain = normalizeDomain(current?.domain || body.domain);
if (!isDomainName(domain)) throw invalid('domain');
const rawHosts = body.hosts === undefined ? (current?.hosts || []) : Array.isArray(body.hosts) ? body.hosts : String(body.hosts || '').split(/[\s,]+/);
// 호스트 항목은 "호스트" 또는 "호스트/경로"(1.0.8). 호스트 이름을 정규화하고 경로는 /a/b 꼴로 다듬어 다시 "호스트/경로" 로 저장합니다.
const hosts = [];
for (const raw of rawHosts) {
if (!String(raw || '').trim()) continue;
const { host, prefix } = splitHostEntry(raw);
if (!host || !HOST_RE.test(host) || prefix === null) throw invalid('hosts');
const entry = prefix ? host + prefix : host; if (!hosts.includes(entry)) hosts.push(entry);
}
if (!hosts.length) hosts.push('mail.' + domain);
// 같은 호스트 이름이 경로만 다르게 두 번 들어오면(mail.x.com 과 mail.x.com/mail) 어느 경로가 그 호스트의 웹 메일인지 애매하므로 거부합니다.
if (new Set(hosts.map(hostNameOf)).size !== hosts.length) throw invalid('hosts');
const mailboxLocal = String(body.mailbox === undefined ? (current?.mailbox || 'admin@' + domain) : body.mailbox).trim().toLowerCase().split('@')[0] || 'admin';
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(mailboxLocal)) throw invalid('mailbox');
// keepAuth(기본 도메인): 로그인 방식은 환경 파일이 정하므로 입력을 무시하고 검사도 하지 않습니다(시험·www 모드에서 파일 경로가 없을 수 있음).
const authIn = keepAuth ? {} : body.auth && typeof body.auth === 'object' ? body.auth : {};
const mode = authIn.mode === undefined ? (current?.auth?.mode || 'local') : String(authIn.mode);
if (!AUTH_MODES.includes(mode)) throw invalid('auth.mode');
const wwwDbFile = authIn.wwwDbFile === undefined ? (current?.auth?.wwwDbFile || '') : String(authIn.wwwDbFile || '').trim();
const ownerLogin = authIn.ownerLogin === undefined ? (current?.auth?.ownerLogin || '') : String(authIn.ownerLogin || '').trim().slice(0, 200);
if (mode === 'www' && !keepAuth) { if (!wwwDbFile) throw invalid('auth.wwwDbFile'); try { accessSync(wwwDbFile, constants.R_OK); } catch { const e = new Error('www_db_unreadable'); e.field = 'auth.wwwDbFile'; throw e; } }
// webmaker 연동(1.0.8): 데이터 디렉터리(DATA_ROOT, 절대 경로)는 필수. webmakerUser 가 있으면 그 계정의 sudo 도우미로 읽으므로 이 프로세스의 읽기 권한을 검사하지 않고,
// 없으면 users.json 을 지금 읽을 수 있어야 합니다(webmaker_dir_unreadable). siteId 는 도메인 ↔ 사이트 대응을 손으로 고정할 때만 적습니다(비우면 도메인 이름으로 찾음).
const webmakerDir = authIn.webmakerDir === undefined ? (current?.auth?.webmakerDir || '') : String(authIn.webmakerDir || '').trim().replace(/\/+$/, '');
const webmakerUser = authIn.webmakerUser === undefined ? (current?.auth?.webmakerUser || '') : String(authIn.webmakerUser || '').trim();
const siteId = authIn.siteId === undefined ? (current?.auth?.siteId || '') : String(authIn.siteId || '').trim().toLowerCase();
if (mode === 'webmaker' && !keepAuth) {
if (!webmakerDir || !webmakerDir.startsWith('/') || webmakerDir.includes('..')) throw invalid('auth.webmakerDir');
if (webmakerUser && !USER_RE.test(webmakerUser)) throw invalid('auth.webmakerUser');
if (siteId && !SITE_ID_RE.test(siteId)) throw invalid('auth.siteId');
if (!webmakerUser) { try { accessSync(join(webmakerDir, 'users.json'), constants.R_OK); } catch { const e = new Error('webmaker_dir_unreadable'); e.field = 'auth.webmakerDir'; throw e; } }
}
const note = String(body.note === undefined ? (current?.note || '') : body.note || '').trim().slice(0, 200);
const enabled = body.enabled === undefined ? (current ? current.enabled !== false : true) : !(body.enabled === false || body.enabled === 'false' || body.enabled === '0' || body.enabled === 0);
const stamp = new Date().toISOString();
return { domain, enabled, hosts, mailbox: `${mailboxLocal}@${domain}`, auth: { mode, wwwDbFile: mode === 'www' ? wwwDbFile : '', ownerLogin: mode === 'www' ? ownerLogin : '', webmakerDir: mode === 'webmaker' ? webmakerDir : '', webmakerUser: mode === 'webmaker' ? webmakerUser : '', siteId: mode === 'webmaker' ? siteId : '' }, note, createdAt: current?.createdAt || stamp, updatedAt: stamp };
}
/**
* 등록부 항목의 로그인 방식을 env 에 반영합니다(createEnv 와 update 가 공유). LOGIN_MODE 와 그 방식의 값만 두고 다른 방식의 값은 지웁니다.
* www : WWW_DB_FILE, MAILBOX_OWNER_LOGIN webmaker : WEBMAKER_DIR, WEBMAKER_USER(선택), WEBMAKER_SITE(선택) local : standalone(자체 계정만)
*/
export function applyAuth(env, auth) {
for (const k of ['WWW_DB_FILE', 'MAILBOX_OWNER_LOGIN', 'WEBMAKER_DIR', 'WEBMAKER_USER', 'WEBMAKER_SITE']) delete env[k];
if (auth.mode === 'www') { env.LOGIN_MODE = 'www'; env.WWW_DB_FILE = auth.wwwDbFile; env.MAILBOX_OWNER_LOGIN = auth.ownerLogin || ''; }
else if (auth.mode === 'webmaker') { env.LOGIN_MODE = 'webmaker'; env.WEBMAKER_DIR = auth.webmakerDir; if (auth.webmakerUser) env.WEBMAKER_USER = auth.webmakerUser; if (auth.siteId) env.WEBMAKER_SITE = auth.siteId; }
else env.LOGIN_MODE = 'standalone';
}
/** 공개용 auth 요약(목록·API). 비밀값은 없지만 모양을 한곳에서 정합니다. */
const publicAuth = (a) => ({ mode: a.mode, wwwDbFile: a.wwwDbFile || '', ownerLogin: a.ownerLogin || '', webmakerDir: a.webmakerDir || '', webmakerUser: a.webmakerUser || '', siteId: a.siteId || '' });
/** 기본 도메인 항목: 환경 파일 값으로 만듭니다(등록부에 보조 정보가 있으면 hosts·note 만 더합니다). */
function primaryEntry(baseEnv, stored = null) {
const mailbox = String(baseEnv.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase(), domain = mailbox.split('@')[1] || '';
const host = normalizeHost(baseEnv.PUBLIC_HOST || 'mail.' + domain);
// 기본 호스트의 경로 접두사는 환경 파일의 PUBLIC_PATH(선택, 1.0.8). 등록부에 적힌 나머지 호스트는 "호스트/경로" 그대로 둡니다(이름이 겹치면 앞의 것).
const first = host + (normalizePrefix(baseEnv.PUBLIC_PATH) || '');
const hosts = []; for (const h of [first, ...(Array.isArray(stored?.hosts) ? stored.hosts : [])]) { const { host: n, prefix } = splitHostEntry(h); if (!n || prefix === null || hosts.some((x) => hostNameOf(x) === n)) continue; hosts.push(prefix ? n + prefix : n); }
// 로그인 방식은 환경 파일이 정합니다: LOGIN_MODE=webmaker(+WEBMAKER_DIR) → webmaker, WWW_DB_FILE(시험에서는 메모리 WWW_DB) → www, 그 밖에는 local.
const mode = String(baseEnv.LOGIN_MODE || '') === 'webmaker' && baseEnv.WEBMAKER_DIR ? 'webmaker' : baseEnv.WWW_DB_FILE || baseEnv.WWW_DB ? 'www' : 'local';
const auth = { mode, wwwDbFile: mode === 'www' ? String(baseEnv.WWW_DB_FILE || '') : '', ownerLogin: mode === 'www' ? String(baseEnv.MAILBOX_OWNER_LOGIN || '') : '', webmakerDir: mode === 'webmaker' ? String(baseEnv.WEBMAKER_DIR) : '', webmakerUser: mode === 'webmaker' ? String(baseEnv.WEBMAKER_USER || '') : '', siteId: mode === 'webmaker' ? String(baseEnv.WEBMAKER_SITE || '') : '' };
return { domain, enabled: true, primary: true, hosts, mailbox, auth, note: stored?.note || '', createdAt: stored?.createdAt || new Date().toISOString(), updatedAt: stored?.updatedAt || new Date().toISOString() };
}
/** 등록부를 읽습니다. 없으면 기본 도메인만 담아 만듭니다(1.0.7 로 올린 서버의 첫 시작). 파일을 쓸 수 없으면 메모리에서만 동작합니다. */
export function loadRegistry(baseEnv) {
const file = registryFile(baseEnv);
let reg = readJson(file), created = false;
const primary = primaryEntry(baseEnv, reg?.domains?.[String(reg?.primary || '')] || null);
if (!reg || typeof reg !== 'object' || !reg.domains || typeof reg.domains !== 'object') { reg = { _meta: { format: REGISTRY_FORMAT, createdAt: new Date().toISOString() }, primary: primary.domain, domains: {} }; created = true; }
// 기본 도메인은 환경 파일이 기준입니다. 환경 파일의 도메인이 바뀌었으면(드문 일) 옛 기본 항목은 일반 항목으로 남기지 않고 지웁니다(그 데이터는 기본 DB 그대로).
if (reg.primary !== primary.domain) { delete reg.domains[reg.primary]; reg.primary = primary.domain; created = true; }
reg.domains[primary.domain] = primary;
if (created) { try { writeJson(file, reg); } catch (e) { console.warn(` ⚠️ 도메인 등록부를 쓸 수 없습니다(${file}): ${e.message}`); } }
return reg;
}
/** 테넌트 env 에서 떼어 내야 하는 키: 설정 파일이 관리하는 값(기본 도메인의 비밀값이 새 도메인에 새지 않게)과 도메인마다 다른 값. */
const TENANT_OWN_KEYS = new Set(['DB', 'DB_FILE', 'CONFIG_FILE', 'FILES_DIR', 'MAILBOX', 'PUBLIC_HOST', 'PUBLIC_PATH', 'WWW_DB', 'WWW_DB_FILE', 'LOGIN_MODE', 'MAILBOX_OWNER_LOGIN', 'WEBMAKER_DIR', 'WEBMAKER_USER', 'WEBMAKER_SITE', 'WEBMAKER_SOCKET', 'STRINGS', 'PAGE', 'CONFIG', 'CONFIG_VERSION', 'CONFIG_CREATED', 'ON_CONFIG_CHANGE', 'MAIL_INBOUND', 'MAIL_OUTBOUND', 'PLAN_DNS_APPLIED_AT', 'DOMAIN', 'TENANT', 'RATE', 'SYNCING', ...CONFIG_SCHEMA.filter((i) => i.env).map((i) => i.env)]);
/**
* 테넌트 관리자. 서버 시작 때 한 번 만들고(openTenants) 모든 요청이 이 객체로 env 를 고릅니다.
* @param {object} baseEnv 기본 도메인의 env(main.mjs 가 이미 DB·설정을 연 상태)
* @param {object} o
* @param {string} o.migrationsDir 새 도메인 DB 에 적용할 마이그레이션 디렉터리
* @param {(env:object)=>void} [o.onCreate] 테넌트 env 가 생길 때(기본 도메인 포함) 부르는 훅 — main.mjs 가 버전·문구·화면 캐시를 붙입니다
*/
export function openTenants(baseEnv, { migrationsDir, onCreate = null, log = console } = {}) {
const registry = loadRegistry(baseEnv);
const dataDir = configDir(baseEnv);
const tenants = new Map(); // domain → { entry, env, db }
const creating = new Set(); // add() 진행 중인 도메인
const stripped = Object.fromEntries(Object.entries(baseEnv).filter(([k]) => !TENANT_OWN_KEYS.has(k)));
/** 기본 도메인: 이미 열린 env 를 그대로 테넌트로 씁니다(데이터 이동 없음). */
const attachPrimary = () => {
const entry = registry.domains[registry.primary];
Object.assign(baseEnv, { DOMAIN: entry.domain, TENANT: entry, DATA_DIR: dataDir, MAIL_SERVER_HOST: normalizeHost(baseEnv.PUBLIC_HOST || entry.hosts[0]), PUBLIC_PATH: splitHostEntry(entry.hosts[0]).prefix || '' });
if (!baseEnv.RATE) baseEnv.RATE = memoryRateStore();
tenants.set(entry.domain, { entry, env: baseEnv, db: null });
if (onCreate) onCreate(baseEnv);
};
/** 추가 도메인의 디렉터리. 도메인 이름은 normalizeEntry 가 검사했으므로 경로에 그대로 써도 안전합니다. */
const dirOf = (domain) => join(dataDir, 'domains', domain);
/**
* 추가 도메인의 env 를 만듭니다: 자기 DB(마이그레이션 적용)·설정 파일·파일 디렉터리. 설정 파일이 없으면(처음) seed 값으로 만듭니다.
* @param {object} entry 등록부 항목 @param {object|null} seed 처음 만들 때 넣을 설정 값(COPY_KEYS)
*/
const createEnv = (entry, { seed = null, adminPassword = null } = {}) => {
const dir = dirOf(entry.domain); mkdirSync(dir, { recursive: true });
// PUBLIC_HOST 는 첫 호스트의 이름 부분, PUBLIC_PATH 는 그 경로 접두사(없으면 '')(1.0.8). 링크(대용량 파일·설정 화면 안내)는 둘을 이어 만듭니다.
const { host: publicHost, prefix: publicPath } = splitHostEntry(entry.hosts[0]);
const env = { ...stripped, DOMAIN: entry.domain, TENANT: entry, DATA_DIR: dataDir, DB_FILE: join(dir, 'mail.sqlite'), CONFIG_FILE: join(dir, 'config.json'), FILES_DIR: join(dir, 'files'), MAILBOX: entry.mailbox, PUBLIC_HOST: publicHost, PUBLIC_PATH: publicPath || '', MAIL_SERVER_HOST: baseEnv.MAIL_SERVER_HOST || normalizeHost(baseEnv.PUBLIC_HOST || ''), HARAKA_DIR: harakaDir(baseEnv), RATE: memoryRateStore(), TENANTS: api };
applyAuth(env, entry.auth);
const db = openDb(env.DB_FILE); migrate(db, migrationsDir, { quiet: true }); env.DB = d1(db);
if (seed && !existsSync(configFile(env))) { const cfg = { _meta: { format: 1, createdAt: new Date().toISOString(), copiedFrom: registry.primary } }; for (const k of COPY_KEYS) if (seed[k] !== undefined && seed[k] !== '') cfg[k] = seed[k]; cfg.inbound = 'resend'; cfg.outbound = 'resend'; writeJson(configFile(env), cfg); }
loadConfig(env);
if (onCreate) onCreate(env);
return { entry, env, db, adminPassword };
};
const api = {
get registry() { return registry; },
get primary() { return tenants.get(registry.primary); },
/** 모든 테넌트(기본 도메인 먼저). */
all() { return [this.primary, ...[...tenants.values()].filter((t) => t.entry.domain !== registry.primary)]; },
/** 켜진 테넌트의 env 목록(주기 작업용). */
envs() { return this.all().filter((t) => t.entry.enabled !== false).map((t) => t.env); },
byDomain(domain) { return tenants.get(normalizeDomain(domain)) || null; },
/** 주소(user@domain)의 도메인으로 테넌트를 찾습니다. 도메인이 없는 로그인 ID 는 null(호출자가 기본 도메인 등으로 정함). */
byAddress(address) { const d = String(address || '').trim().toLowerCase().split('@')[1]; return d ? this.byDomain(d) : null; },
/**
* HTTP Host 로 테넌트를 고릅니다.
* @returns {{tenant:object|null, disabled:boolean, host:string}} tenant 가 null 이면 모르는 도메인, disabled 면 꺼 둔 도메인
*/
resolveHost(host) {
const h = normalizeHost(host);
let found = null, prefix = '';
if (isLocalHost(h)) found = this.primary;
else {
// 등록된 호스트 이름(경로가 붙어 있으면 그 경로가 이 호스트의 접두사, 1.0.8)
for (const t of tenants.values()) { const e = t.entry.hosts.find((x) => hostNameOf(x) === h); if (e) { found = t; prefix = splitHostEntry(e).prefix || ''; break; } }
if (!found) { let best = ''; for (const t of tenants.values()) { const d = t.entry.domain; if ((h === d || h.endsWith('.' + d)) && d.length > best.length) { best = d; found = t; } } }
}
return { tenant: found, disabled: !!found && found.entry.enabled === false, host: h, prefix };
},
/**
* 메일 프로그램 로그인(IMAP·POP·SMTP)에서 쓰는 env 고르기: 로그인 ID 에 @도메인이 있으면 그 도메인, 없으면 TLS SNI 호스트가 가리키는 도메인, 그래도 없으면 기본 도메인.
* 꺼 둔 도메인은 null → 로그인 실패. 등록되지 않은 @도메인은 기본 도메인(또는 SNI 도메인)의 계정으로 검사합니다(www 관리자 이메일 로그인 호환).
*/
resolveLogin(login, servername = '') {
const key = String(login || '').trim().toLowerCase();
// @ 뒤가 등록된 도메인이면 그 도메인(꺼 두었으면 null). 등록되지 않은 도메인(예: www 관리자가 자기 이메일 someone@gmail.com 으로 로그인)은
// 아래 SNI·기본 도메인 규칙으로 넘깁니다 — verifyUser 가 그 env 의 계정(www 관리자의 이메일 포함)으로 검사하므로 안전합니다.
if (key.includes('@')) { const t = this.byAddress(key); if (t) return t.entry.enabled !== false ? t.env : null; }
if (servername) { const r = this.resolveHost(servername); if (r.tenant && !r.disabled) return r.tenant.env; }
return this.primary.env;
},
/** 화면·API 용 요약 목록(비밀값 없음). */
async list() {
const out = [];
for (const t of this.all()) {
const e = t.entry, env = t.env; let accounts = 0, messages = 0;
try { accounts = Number((await env.DB.prepare('SELECT COUNT(*) AS n FROM mail_users').first())?.n || 0); messages = Number((await env.DB.prepare('SELECT COUNT(*) AS n FROM messages').first())?.n || 0); } catch { /* DB 오류는 0 으로 */ }
// initialPassword: 도메인을 만들 때 정한 초기 관리자 비밀번호. 관리자가 비밀번호를 바꾸면(initial_password:<id> 표시가 지워짐) 더는 보여 주지 않습니다.
let initialPassword = null;
if (t.adminPassword) { try { if (await env.DB.prepare("SELECT 1 AS x FROM settings WHERE key LIKE 'initial_password:%'").first()) initialPassword = t.adminPassword; else t.adminPassword = null; } catch { /* 무시 */ } }
// external(1.0.8): webmaker 연동 도메인의 웹메이커 계정 수(읽지 못하면 오류 문구). 화면이 "계정 N명(+웹메이커 M명)" 으로 보여 줍니다.
let external = null;
if (e.auth.mode === 'webmaker') { try { const r = await externalUsers(env); external = r.ok ? { count: r.accounts.length, site: r.site } : { count: 0, error: r.error }; } catch (err) { external = { count: 0, error: err.message }; } }
out.push({ domain: e.domain, primary: e.domain === registry.primary, enabled: e.enabled !== false, hosts: e.hosts, mailbox: e.mailbox, auth: publicAuth(e.auth), note: e.note || '', createdAt: e.createdAt, updatedAt: e.updatedAt, inbound: env.MAIL_INBOUND || 'resend', outbound: env.MAIL_OUTBOUND || 'resend', setupDone: env.CONFIG?.SETUP_DONE === true, accounts, messages, external, dir: e.domain === registry.primary ? configDir(env) : dirOf(e.domain), initialPassword });
}
return out;
},
/**
* 도메인을 추가합니다(전체 설정 화면). 등록부에 쓰고 바로 env 를 만들어 요청을 받기 시작합니다.
* @param {object} body normalizeEntry 입력 + { adminPassword?, copyFromPrimary? (기본 true) }
* @returns {Promise<{entry:object, initialAdmin:{login:string,password:string}|null}>}
*/
async add(body) {
const entry = normalizeEntry(body);
// creating: 같은 도메인을 동시에 두 번 추가하는 요청이 중복 검사를 함께 통과하지 못하게 합니다(DB 핸들 누수 방지).
if (tenants.has(entry.domain) || creating.has(entry.domain)) throw new Error('domain_exists');
if (entry.domain === registry.primary) throw new Error('domain_exists');
creating.add(entry.domain);
try { return await this._add(entry, body); } finally { creating.delete(entry.domain); }
},
async _add(entry, body) {
for (const t of tenants.values()) for (const h of entry.hosts) if (t.entry.hosts.some((x) => hostNameOf(x) === hostNameOf(h))) { const e = new Error('host_taken'); e.field = 'hosts'; e.detail = h; throw e; }
const adminPassword = String(body.adminPassword || INITIAL_ADMIN_PASSWORD);
if ([...adminPassword].length < PASSWORD_MIN_FLOOR) throw invalid('adminPassword');
const seed = body.copyFromPrimary === false ? null : (this.primary.env.CONFIG || {});
const t = createEnv(entry, { seed, adminPassword });
// 첫 관리자: 로그인 방식이 local 이면 admin(메일함 앞부분) 계정을 지정한 비밀번호로 만듭니다(www 연동이면 그쪽 관리자가 로그인).
let initialAdmin = null;
if (entry.auth.mode === 'local') { const made = await ensureInitialAdmin(t.env, { password: adminPassword }); if (made) initialAdmin = made; }
registry.domains[entry.domain] = entry; writeJson(registryFile(baseEnv), registry);
tenants.set(entry.domain, t);
log.log(` 도메인 추가: ${entry.domain} (${entry.hosts.join(', ')}, 로그인 ${entry.auth.mode}${initialAdmin ? `, 초기 관리자 ${initialAdmin.login}` : ''})`);
return { entry, initialAdmin };
},
/** 도메인 정보를 바꿉니다(켜기·끄기, 호스트, 로그인 방식, 메모). 기본 도메인은 hosts·note 만 바꿀 수 있습니다(나머지는 환경 파일). */
update(domain, body) {
const t = this.byDomain(domain); if (!t) throw new Error('not_found');
const isPrimary = t.entry.domain === registry.primary;
const next = isPrimary ? { ...t.entry, ...normalizeEntry({ hosts: body.hosts, note: body.note }, t.entry, { keepAuth: true }), enabled: true, primary: true, mailbox: t.entry.mailbox, auth: t.entry.auth } : normalizeEntry({ ...body, domain: t.entry.domain, mailbox: undefined }, t.entry);
// 기본 도메인의 첫 호스트는 환경 파일의 PUBLIC_HOST(+PUBLIC_PATH)로 고정합니다(목록에서 빠지거나 뒤로 가면 앞에 되돌림).
if (isPrimary) { const ph = normalizeHost(baseEnv.PUBLIC_HOST || ''), first = ph + (normalizePrefix(baseEnv.PUBLIC_PATH) || ''); next.hosts = [first, ...next.hosts.filter((h) => hostNameOf(h) !== ph)]; }
for (const other of tenants.values()) if (other !== t) for (const h of next.hosts) if (other.entry.hosts.some((x) => hostNameOf(x) === hostNameOf(h))) { const e = new Error('host_taken'); e.field = 'hosts'; e.detail = h; throw e; }
if (!isPrimary) {
// 로그인 방식(www DB·웹메이커 디렉터리)은 env 에 바로 반영합니다(세션·계정 조회가 다음 요청부터 새 방식을 씀).
applyAuth(t.env, next.auth);
const { host: publicHost, prefix } = splitHostEntry(next.hosts[0]); t.env.PUBLIC_HOST = publicHost; t.env.PUBLIC_PATH = prefix || '';
if (typeof t.env.ON_CONFIG_CHANGE === 'function') t.env.ON_CONFIG_CHANGE(t.env.CONFIG || {});
}
t.entry = next; t.env.TENANT = next; registry.domains[next.domain] = next; writeJson(registryFile(baseEnv), registry);
log.log(` 도메인 변경: ${next.domain} (${next.enabled ? '사용' : '중지'}, ${next.hosts.join(', ')}, 로그인 ${next.auth.mode})`);
return next;
},
/** 도메인을 등록부에서 지웁니다. 데이터 디렉터리(domains/<도메인>/)는 남겨 두어 되돌릴 수 있습니다(지우려면 서버에서 직접). 기본 도메인은 지울 수 없습니다. */
remove(domain) {
const t = this.byDomain(domain); if (!t) throw new Error('not_found');
if (t.entry.domain === registry.primary) throw new Error('primary_domain');
tenants.delete(t.entry.domain); delete registry.domains[t.entry.domain]; writeJson(registryFile(baseEnv), registry);
try { t.db?.close(); } catch { /* 이미 닫힘 */ }
log.log(` 도메인 삭제: ${t.entry.domain} (데이터 디렉터리는 남김: ${dirOf(t.entry.domain)})`);
return { domain: t.entry.domain, dir: dirOf(t.entry.domain) };
},
/** 종료: 추가 도메인 DB 를 닫습니다(기본 DB 는 main.mjs 가 닫음). */
close() { for (const t of tenants.values()) if (t.db) { try { t.db.close(); } catch { /* 무시 */ } } },
};
attachPrimary();
baseEnv.TENANTS = api;
// 등록부의 추가 도메인을 모두 엽니다. 하나가 깨져도(파일 권한 등) 나머지와 기본 도메인은 뜨도록 오류는 기록만 합니다.
for (const [domain, entry] of Object.entries(registry.domains)) {
if (domain === registry.primary) continue;
try { tenants.set(domain, createEnv(entry)); log.log(` 도메인: ${domain} (${entry.hosts.join(', ')}, ${entry.enabled === false ? '중지' : '사용'}, 로그인 ${entry.auth.mode})`); }
catch (e) { log.error(` ✖ 도메인 ${domain} 을 열지 못했습니다: ${e.message}`); }
}
return api;
}
/**
* "메일 서비스가 없습니다" 안내 화면(요구사항 1). 모르는 호스트로 들어온 웹 요청과 꺼 둔 도메인의 요청에 줍니다. 스크립트 없이 HTML 만.
* @param {object} o host: 요청 호스트, disabled: 꺼 둔 도메인인지, strings: lang/ko.json 묶음(없으면 내장 문구), version
*/
export function noServicePage({ host = '', disabled = false, strings = {}, version = '' } = {}) {
const t = (k, d) => strings[k] || d;
const title = disabled ? t('domainDisabledTitle', '메일 서비스가 활성화되지 않았습니다') : t('domainMissingTitle', '메일 서비스가 존재하지 않습니다');
const body = disabled ? t('domainDisabled', '이 도메인의 메일 서비스는 지금 사용이 중지되어 있습니다.') : t('domainMissing', '이 주소에는 등록된 메일 서비스가 없습니다.');
const ask = t('domainAskAdmin', '서비스가 필요하면 관리자에게 이 도메인의 메일 서비스 등록(또는 활성화)을 요청하세요.');
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
return `<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>${esc(title)}</title><style>body{margin:0;background:#f3f5f8;font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","Apple SD Gothic Neo",sans-serif;color:#1d2733}.card{max-width:520px;margin:64px auto;background:#fff;border-radius:14px;padding:28px;box-shadow:0 8px 30px rgba(20,35,60,.12)}h1{font-size:18px;margin:0 0 12px}p{margin:0 0 10px}.host{font-family:ui-monospace,Menlo,Consolas,monospace;background:#eef2f7;padding:2px 8px;border-radius:6px;overflow-wrap:anywhere}.meta{color:#6b7a8c;font-size:12px;margin-top:16px}</style></head><body><div class="card"><h1>${disabled ? '⏸' : '✉'} ${esc(title)}</h1><p>${esc(body)}</p>${host ? `<p>요청한 주소: <span class="host">${esc(host)}</span></p>` : ''}<p>${esc(ask)}</p><p class="meta">AriMail${version ? ' v' + esc(version) : ''}</p></div></body></html>`;
}
+242
View File
@@ -0,0 +1,242 @@
/**
* 대용량 첨부파일(0.9.3~): 큰 파일을 메일에 첨부하지 않고 서버에 보관한 뒤 다운로드 링크를 보냅니다.
*
* 흐름
* 1) 작성 창에서 "대용량 첨부" 로 올리면 파일은 디스크(filesDir, 기본 <DB 디렉터리>/files/<id>)에 흘려 쓰고 large_files 표에 메타데이터만 남깁니다(state='draft').
* 업로드는 index.mjs 가 요청 본문을 모으지 않고 그대로 넘겨(스트리밍) 1GB 급 파일도 메모리를 쓰지 않습니다.
* 2) 보낼 때(store.sendDraft → finalizeLargeFiles) 보관 기간(일)과 다운로드 횟수를 **반드시** 정합니다. 둘 다 관리자 설정의 상한 이내여야 하며
* "제한 없음" 은 없습니다(요구사항: 기간 제한이나 횟수 제한 없는 발송 불가). 파일마다 비밀 토큰을 만들어 링크(https://<호스트>/dl/<토큰>)를 본문 끝에 넣습니다.
* 3) 받는 사람이 링크를 열면 랜딩 페이지(파일 이름·크기·남은 횟수·기한)가 보이고, "다운로드" 단추(POST)를 누를 때만 횟수가 1 줄며 파일을 흘려보냅니다.
* GET 으로 세지 않는 이유: 메일 보안 장치(Outlook Safe Links 등)와 미리 보기 봇이 링크를 미리 열어 보므로, 그때마다 횟수가 줄면 받는 사람이 정작 못 받습니다.
* 4) 기한이 지나거나 횟수를 다 쓰면(또는 보낸 사람이 링크를 끊으면) 파일을 지우고 state='expired' 로 둡니다. 정리 작업(cleanupLargeFiles)이 1시간마다 돌며,
* 횟수를 다 쓴 파일은 마지막 다운로드가 끝나는 즉시 지웁니다.
*
* 설정은 두 층입니다(limits.mjs 와 같은 방식, 단 어느 층도 "무제한" 이 없음).
* - 시스템(systemLargeFiles): 환경 변수 LIMIT_LARGE_FILE_BYTES(기본 1G) · LIMIT_LARGE_FILE_DAYS(기본 30) · LIMIT_LARGE_FILE_DOWNLOADS(기본 20).
* 0 이나 잘못된 값은 기본값으로 대체합니다(반드시 값이 있어야 함).
* - 관리자(readLargeFileSettings, settings 표의 large_files): 사용 여부(enabled), 최대 크기·보관 기간·다운로드 횟수. 각각 시스템 값 이내이며
* 기본값은 시스템 값의 50% 입니다(요구사항). 기본은 사용 안 함(디스크를 쓰는 기능이라 관리자가 켜야 합니다).
*/
import { createWriteStream, createReadStream } from 'node:fs';
import { mkdir, unlink, stat } from 'node:fs/promises';
import { once } from 'node:events';
import { dirname, join } from 'node:path';
import { now, uid, clean, secret, escapeHtml } from './util.mjs';
import { parseSize } from './limits.mjs';
// ------------------------------------------------------------------ 설정
/** 시스템 기본값. 환경 변수가 없거나 잘못됐을 때 쓰며, "무제한" 은 없습니다. */
export const DEFAULT_SYSTEM_LARGE = { bytes: 1024 ** 3, days: 30, downloads: 20 };
/** 한 메일에 넣을 수 있는 대용량 파일 수. */
export const LARGE_FILE_COUNT = 10;
const posInt = (v, fallback) => { const n = Number(v); return Number.isInteger(n) && n > 0 ? n : fallback; };
/** 시스템 설정(환경 파일). 값이 0·비어 있음·잘못됨이면 기본값(1GB / 30일 / 20회)으로 대체해 항상 유한한 값을 돌려줍니다. */
export function systemLargeFiles(env) {
const bytes = parseSize(env.LIMIT_LARGE_FILE_BYTES);
return { bytes: bytes > 0 ? bytes : DEFAULT_SYSTEM_LARGE.bytes, days: posInt(env.LIMIT_LARGE_FILE_DAYS, DEFAULT_SYSTEM_LARGE.days), downloads: posInt(env.LIMIT_LARGE_FILE_DOWNLOADS, DEFAULT_SYSTEM_LARGE.downloads) };
}
/** 관리자 설정의 기본값: 시스템 값의 50%(바이트는 내림, 일·횟수는 올림, 최소 1). */
export function defaultAdminLarge(sys) { return { enabled: false, maxBytes: Math.max(1, Math.floor(sys.bytes / 2)), maxDays: Math.max(1, Math.ceil(sys.days / 2)), maxDownloads: Math.max(1, Math.ceil(sys.downloads / 2)) }; }
const CAP = { maxBytes: 'bytes', maxDays: 'days', maxDownloads: 'downloads' };
/**
* 관리자 설정(settings 의 large_files). 저장된 값이 손상됐거나 시스템 값을 넘으면(설치 뒤 시스템 값이 줄어든 경우) 시스템 값으로 깎고, 없으면 기본값(50%)입니다.
* @returns {Promise<{enabled:boolean,maxBytes:number,maxDays:number,maxDownloads:number}>}
*/
export async function readLargeFileSettings(env) {
const sys = systemLargeFiles(env), def = defaultAdminLarge(sys);
const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind('large_files').first();
let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; }
if (!stored || typeof stored !== 'object') stored = {};
const out = { enabled: !!stored.enabled };
for (const [k, s] of Object.entries(CAP)) { const v = posInt(stored[k], def[k]); out[k] = Math.min(v, sys[s]); }
return out;
}
/**
* 관리자 설정을 저장합니다. 크기·기간·횟수는 1 이상의 정수이고(0 = 무제한은 허용하지 않음 → limit_invalid) 시스템 값 이내여야 합니다(limit_exceeds_system).
* 오류에는 field 와 상한(limit)을 붙여 화면이 어느 항목인지 알려 줍니다.
*/
export async function writeLargeFileSettings(env, patch) {
const sys = systemLargeFiles(env), next = await readLargeFileSettings(env);
if (patch.enabled !== undefined) next.enabled = !!patch.enabled;
for (const [k, s] of Object.entries(CAP)) {
if (patch[k] === undefined) continue;
const n = Number(patch[k]);
if (!Number.isInteger(n) || n < 1) { const e = new Error('limit_invalid'); e.field = k; throw e; }
if (n > sys[s]) { const e = new Error('limit_exceeds_system'); e.field = k; e.limit = sys[s]; throw e; }
next[k] = n;
}
await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind('large_files', JSON.stringify(next), now()).run();
return next;
}
/** 파일을 둘 디렉터리: FILES_DIR 또는 DB 파일 옆의 files/. 배포 서비스는 <APP_DIR>/data 에 쓰기 권한이 있으므로 기본값이 바로 동작합니다. */
export function filesDir(env) { return env.FILES_DIR || join(dirname(env.DB_FILE || 'data/mail-gateway.sqlite'), 'files'); }
const pathOf = (env, id) => join(filesDir(env), id);
async function unlinkQuiet(path) { try { await unlink(path); } catch { /* 이미 없음 */ } }
// ------------------------------------------------------------------ 업로드(초안에 파일 붙이기)
/**
* 초안에 대용량 파일을 올립니다. 본문 스트림(Node 요청 객체 또는 async iterable)을 디스크에 흘려 쓰며 관리자 상한(maxBytes)을 넘는 순간 중단하고 지웁니다.
* @param {object} user 로그인 사용자(mailbox 로 초안 소유를 확인)
* @param {AsyncIterable<Buffer>} stream 파일 바이트
* @param {object} meta { draftId, filename, contentType, declaredSize? } — declaredSize(Content-Length)가 상한을 넘으면 받기 전에 거부합니다.
* @returns {Promise<object>} large_files 행
* @throws large_files_disabled | mail_locked | too_many_files | file_too_large(limit) | bad_request
*/
export async function uploadLargeFile(env, user, stream, { draftId, filename, contentType, declaredSize = 0 }) {
const cfg = await readLargeFileSettings(env);
if (!cfg.enabled) throw new Error('large_files_disabled');
const draft = await env.DB.prepare("SELECT id FROM messages WHERE id=? AND state='draft' AND folder='drafts' AND mailbox=?").bind(String(draftId || ''), user.mailbox).first();
if (!draft) throw new Error('mail_locked');
const count = await env.DB.prepare("SELECT COUNT(*) AS n FROM large_files WHERE message_id=? AND state='draft'").bind(draft.id).first();
if (Number(count?.n || 0) >= LARGE_FILE_COUNT) throw new Error('too_many_files');
const tooBig = () => { const e = new Error('file_too_large'); e.limit = cfg.maxBytes; return e; };
if (Number(declaredSize) > cfg.maxBytes) throw tooBig();
try { await mkdir(filesDir(env), { recursive: true }); } catch (e) { const err = new Error('upload_write_failed'); err.detail = e.code; throw err; }
const id = uid(), path = pathOf(env, id), out = createWriteStream(path, { flags: 'wx', mode: 0o600 });
// 쓰기 오류(디스크 가득 참 ENOSPC 등)는 다음 네트워크 조각을 기다리는 동안에도 날 수 있습니다. 듣는 쪽이 없으면 'error' 이벤트가 프로세스를 죽이므로
// 처음부터 받아 두었다가 조각마다 확인해 업로드만 실패시킵니다(0.9.5 수정).
// failed: 쓰기 오류가 나면 거부되는 프라미스 하나. 예전(0.9.5~1.0.0)에는 조각마다 out.once('error', reject) 를 새로 달아 drain 을 기다릴 때마다
// 리스너가 쌓였고(1GB 업로드면 수천 개), 11개째에 MaxListenersExceededWarning 이 기록됐습니다(mail.heart-m.com 2026-10-13). 리스너는 이 한 개만 둡니다(1.0.5 수정).
let writeError = null; const failed = new Promise((_, reject) => out.on('error', (e) => { writeError = e; reject(e); })); failed.catch(() => {});
let size = 0;
try {
// 파일이 실제로 열린 뒤에 쓰기 시작합니다. 열리기 전에 중단·삭제하면 삭제가 먼저 돌고 그 뒤 스트림이 파일을 만들어 디스크에 찌꺼기가 남습니다.
await Promise.race([once(out, 'open'), failed]);
for await (const chunk of stream) {
if (writeError) throw writeError;
size += chunk.length;
if (size > cfg.maxBytes) throw tooBig();
if (!out.write(chunk)) await Promise.race([once(out, 'drain'), failed]);
}
if (writeError) throw writeError;
await Promise.race([new Promise((resolve) => out.end(resolve)), failed]);
} catch (e) { if (!out.destroyed) { out.destroy(); await once(out, 'close').catch(() => {}); } await unlinkQuiet(path); if (e && e.code && !e.limit) { const err = new Error('upload_write_failed'); err.detail = e.code; throw err; } throw e; }
if (size === 0) { await unlinkQuiet(path); throw new Error('bad_request'); }
const name = clean(filename, 255).replace(/[\r\n"\\/]/g, '_') || 'file';
await env.DB.prepare("INSERT INTO large_files(id,message_id,mailbox,filename,content_type,size,state,created_at) VALUES(?,?,?,?,?,?,'draft',?)")
.bind(id, draft.id, user.mailbox, name, clean(contentType, 120).split(';')[0] || 'application/octet-stream', size, now()).run();
return (await env.DB.prepare('SELECT * FROM large_files WHERE id=?').bind(id).first());
}
/** 메일에 딸린 대용량 파일 목록(화면용: 바이트는 빼고 상태·남은 횟수·기한). */
export async function listLargeFiles(env, messageId) {
const rows = (await env.DB.prepare('SELECT id,filename,content_type,size,state,max_downloads,downloads,expires_at,created_at,sent_at,last_download_at FROM large_files WHERE message_id=? ORDER BY created_at').bind(messageId).all()).results;
return rows.map((r) => ({ ...r, remaining: r.max_downloads == null ? null : Math.max(0, r.max_downloads - r.downloads), expired: r.state === 'expired' || (r.state === 'sent' && isExpired(r)) }));
}
const isExpired = (r) => (r.expires_at && Date.parse(r.expires_at) <= Date.now()) || (r.max_downloads != null && r.downloads >= r.max_downloads);
/**
* 파일을 뗍니다. 초안의 파일은 행과 파일을 지우고, 보낸 메일의 파일은 링크를 끊습니다(파일 삭제 + state='expired', 행은 기록으로 남김).
* 소유 확인은 메일함(mailbox)으로 합니다. 없으면 not_found.
*/
export async function removeLargeFile(env, id, mailbox, messageId = null) {
const row = await env.DB.prepare('SELECT * FROM large_files WHERE id=? AND mailbox=?').bind(id, mailbox).first();
if (!row || (messageId && row.message_id && row.message_id !== messageId)) throw new Error('not_found'); // 다른 메일의 파일 id 를 끼워 넣어도 그 메일 경로로는 지울 수 없음(0.9.5)
await unlinkQuiet(pathOf(env, id));
if (row.state === 'draft') await env.DB.prepare('DELETE FROM large_files WHERE id=?').bind(id).run();
else await env.DB.prepare("UPDATE large_files SET state='expired' WHERE id=?").bind(id).run();
return row;
}
// ------------------------------------------------------------------ 발송(링크 만들기)
/**
* 발송 직전에 초안의 대용량 파일에 기한·횟수·토큰을 붙입니다(store.sendDraft 가 부름).
* @param {object} terms { days, downloads } — 둘 다 필수, 1 이상이고 관리자 상한 이내. 어긋나면 large_file_terms(422, field·limit 동봉).
* @param {string} host 링크에 쓸 공개 호스트(PUBLIC_HOST)
* @returns {Promise<{files: object[], html: string, text: string}>} 본문에 덧붙일 HTML/텍스트 블록. 파일이 없으면 files 가 비고 html/text 는 ''.
*/
export async function finalizeLargeFiles(env, draftId, terms, host, strings = {}) {
// 발송에 실패했던 초안을 다시 보낼 때는 지난 시도에서 이미 링크를 만든 파일(state='sent')을 그대로 씁니다(0.9.5). 예전에는 실패 시 링크를 되돌리고 재시도 때 새 토큰을 만들어,
// 같은 멱등 키(send_key)로 다른 본문을 보내게 되어 발송 서비스가 거부했고, 첫 시도가 실제로는 전달됐다면 받는 사람의 링크가 죽었습니다. 같은 토큰이면 재시도 본문이 똑같습니다.
const files = (await env.DB.prepare("SELECT * FROM large_files WHERE message_id=? AND state IN ('draft','sent') ORDER BY created_at").bind(draftId).all()).results;
if (!files.length) return { files: [], html: '', text: '' };
const pending = files.filter((f) => f.state === 'draft'), kept = files.filter((f) => f.state === 'sent');
const cfg = await readLargeFileSettings(env);
const check = (v, max, field) => { const n = Number(v); if (!Number.isInteger(n) || n < 1 || n > max) { const e = new Error('large_file_terms'); e.field = field; e.limit = max; throw e; } return n; };
// 새 파일이 있으면 조건이 필요하고, 모두 지난 시도의 파일이면 그때의 조건을 그대로 씁니다.
const days = pending.length ? check(terms?.days, cfg.maxDays, 'days') : Math.max(1, Math.round((Date.parse(kept[0].expires_at) - Date.parse(kept[0].sent_at)) / 86400000));
const downloads = pending.length ? check(terms?.downloads, cfg.maxDownloads, 'downloads') : kept[0].max_downloads;
const stamp = now(), expiresAt = pending.length ? new Date(Date.now() + days * 86400000).toISOString() : kept[0].expires_at;
const out = kept.map((f) => ({ ...f, url: `https://${host}/dl/${f.token}` }));
for (const f of pending) {
const token = secret(24);
await env.DB.prepare("UPDATE large_files SET token=?,state='sent',max_downloads=?,expires_at=?,sent_at=? WHERE id=?").bind(token, downloads, expiresAt, stamp, f.id).run();
out.push({ ...f, token, max_downloads: downloads, expires_at: expiresAt, url: `https://${host}/dl/${token}` });
}
return { files: out, ...bodyBlock(out, { days, downloads, expiresAt }, env, strings) };
}
/** 발송 조건만 미리 검사합니다(발송 시작 전에 불러 어긋나면 초안이 '발송 실패' 로 잠기지 않게). 파일이 없으면 아무것도 하지 않습니다. */
export async function checkLargeFileTerms(env, draftId, terms) {
const n = await env.DB.prepare("SELECT SUM(CASE WHEN state='draft' THEN 1 ELSE 0 END) AS pending, COUNT(*) AS total FROM large_files WHERE message_id=? AND state IN ('draft','sent')").bind(draftId).first();
if (!Number(n?.total || 0)) return false;
if (!Number(n?.pending || 0)) return true; // 지난 시도에서 링크를 만든 파일뿐이면(재시도) 조건을 다시 묻지 않습니다.
const cfg = await readLargeFileSettings(env);
for (const [field, max] of [['days', cfg.maxDays], ['downloads', cfg.maxDownloads]]) { const v = Number(terms?.[field]); if (!Number.isInteger(v) || v < 1 || v > max) { const e = new Error('large_file_terms'); e.field = field; e.limit = max; throw e; } }
return true;
}
/** 바이트 수를 사람이 읽는 크기로(1.2 GB / 34.5 MB / 120 KB). */
export const humanSize = (n) => n >= 1024 ** 3 ? (n / 1024 ** 3).toFixed(1) + ' GB' : n >= 1024 ** 2 ? (n / 1024 ** 2).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB';
/** 기한을 통계 시간대(기본 Asia/Seoul)의 "YYYY-MM-DD HH:MM" 로. 메일 본문·랜딩 페이지에 씁니다. */
export function formatExpiry(iso, env = {}) {
const tz = String(env.STATS_TIMEZONE || 'Asia/Seoul');
try { const p = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(iso)).map((x) => [x.type, x.value])); return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}`; }
catch { return String(iso).slice(0, 16).replace('T', ' '); }
}
/** 본문 끝에 덧붙이는 "대용량 첨부파일" 블록(HTML + 텍스트). 링크·파일 이름·크기·기한·횟수를 담고, 받는 사람이 만료 규칙을 알 수 있게 안내문을 붙입니다. */
function bodyBlock(files, { downloads, expiresAt }, env, s) {
const t = (k, d) => s[k] || d, until = formatExpiry(expiresAt, env);
const note = t('largeFileNote', '링크는 기한이 지나거나 다운로드 횟수를 다 쓰면 만료되어 파일을 받을 수 없습니다.');
const terms = t('largeFileTerms', '{until} 까지 · {downloads}회').replace('{until}', until).replace('{downloads}', String(downloads));
const html = `<div class="large-files" style="margin-top:18px;padding:12px 14px;border:1px solid #d9dee5;border-radius:8px;font-size:14px"><p style="margin:0 0 6px"><strong>${escapeHtml(t('largeFileHeading', '대용량 첨부파일'))}</strong></p><ul style="margin:0;padding-left:18px">${files.map((f) => `<li><a href="${escapeHtml(f.url)}">${escapeHtml(f.filename)}</a> (${humanSize(f.size)}) — ${escapeHtml(terms)}</li>`).join('')}</ul><p style="margin:8px 0 0;font-size:12px;color:#6b7a8c">${escapeHtml(note)}</p></div>`;
const text = `\n\n[${t('largeFileHeading', '대용량 첨부파일')}]\n${files.map((f) => `- ${f.filename} (${humanSize(f.size)}): ${f.url} (${terms})`).join('\n')}\n${note}\n`;
return { html, text };
}
// ------------------------------------------------------------------ 다운로드(공개 링크)
/** 토큰으로 파일 정보를 찾습니다(랜딩 페이지용, 횟수는 세지 않음). 없거나 만료면 { status: 'missing'|'expired' }. */
export async function largeFileInfo(env, token) {
const row = await env.DB.prepare('SELECT * FROM large_files WHERE token=?').bind(String(token || '')).first();
if (!row || row.state === 'draft') return { status: 'missing' };
if (row.state === 'expired' || isExpired(row)) return { status: 'expired', file: row };
return { status: 'ok', file: row, remaining: row.max_downloads - row.downloads };
}
/**
* 다운로드 한 번을 차지합니다: 살아 있고 횟수가 남은 파일에만 downloads 를 1 올리고 그 행을 돌려줍니다(한 문장의 UPDATE 라 동시에 눌러도 횟수를 넘지 않음).
* @returns {Promise<{row:object, path:string, last:boolean}|null>} last 는 이번이 마지막 허용 횟수인지(끝나면 파일을 지움)
*/
export async function claimDownload(env, token) {
const row = await env.DB.prepare("UPDATE large_files SET downloads=downloads+1,last_download_at=? WHERE token=? AND state='sent' AND downloads<max_downloads AND expires_at>? RETURNING *").bind(now(), String(token || ''), now()).first();
if (!row) return null;
try { await stat(pathOf(env, row.id)); } catch { await env.DB.prepare("UPDATE large_files SET state='expired' WHERE id=?").bind(row.id).run(); return null; }
return { row, path: pathOf(env, row.id), last: row.downloads >= row.max_downloads };
}
/** 다운로드 스트림(index.mjs 가 응답에 파이프). */
export const openLargeFile = (path) => createReadStream(path);
/** 마지막 허용 횟수의 다운로드가 끝난 뒤: 파일을 지우고 만료 처리합니다. */
export async function finishDownload(env, claim) {
if (!claim?.last) return;
await unlinkQuiet(claim.path);
await env.DB.prepare("UPDATE large_files SET state='expired' WHERE id=?").bind(claim.row.id).run();
}
// ------------------------------------------------------------------ 정리·통계
/** 보낸 사람이 지운 초안(메일 행이 사라져 message_id 가 NULL)의 파일이 남아 있을 수 있는 유예 시간. 그 뒤 정리합니다. */
const ORPHAN_GRACE_MS = 24 * 3600 * 1000;
/**
* 주기 정리(1시간마다): 기한이 지났거나 횟수를 다 쓴 보낸 파일, 메일이 사라진 초안 파일, 하루 넘게 보내지 않은 초안의 파일 중 초안이 휴지통에 있는 것을 지웁니다.
* 작성 중인 초안(임시 보관함)의 파일은 그대로 둡니다.
* @returns {Promise<{expired:number, removed:number}>}
*/
export async function cleanupLargeFiles(env) {
const stamp = now(); let expired = 0, removed = 0;
const dead = (await env.DB.prepare("SELECT id FROM large_files WHERE state='sent' AND (expires_at<=? OR downloads>=max_downloads)").bind(stamp).all()).results;
for (const r of dead) { await unlinkQuiet(pathOf(env, r.id)); await env.DB.prepare("UPDATE large_files SET state='expired' WHERE id=?").bind(r.id).run(); expired++; }
const cutoff = new Date(Date.now() - ORPHAN_GRACE_MS).toISOString();
// 유예는 초안을 버린 시점(휴지통으로 옮긴 updated_at, 메일이 지워졌으면 파일 올린 시점) 기준입니다. 예전에는 올린 시점 기준이라 하루 넘게 쓰던 초안을 버리면 한 시간 안에 파일이 사라졌습니다(0.9.5).
const orphans = (await env.DB.prepare(`SELECT f.id FROM large_files f LEFT JOIN messages m ON m.id=f.message_id WHERE f.state='draft' AND ((m.id IS NULL AND f.created_at<?) OR (m.folder='trash' AND m.updated_at<?))`).bind(cutoff, cutoff).all()).results;
for (const r of orphans) { await unlinkQuiet(pathOf(env, r.id)); await env.DB.prepare('DELETE FROM large_files WHERE id=?').bind(r.id).run(); removed++; }
return { expired, removed };
}
/** 디스크에 있는(만료되지 않은) 대용량 파일 수와 바이트 합계. 관리자 설정 카드·서버 상태에 보여 줍니다. */
export async function largeFileStats(env) {
const row = await env.DB.prepare("SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS bytes, SUM(CASE WHEN state='sent' THEN 1 ELSE 0 END) AS sent FROM large_files WHERE state IN ('draft','sent')").first();
return { count: Number(row?.n || 0), bytes: Number(row?.bytes || 0), sent: Number(row?.sent || 0) };
}
+228
View File
@@ -0,0 +1,228 @@
/**
* IMAP 용 MIME 해석: 헤더·주소·RFC 2047 디코딩, multipart 트리, ENVELOPE / BODYSTRUCTURE / 섹션 추출.
* 저장한 원본(.eml)을 그대로 해석하며 바이트를 바꾸지 않습니다(부분 추출은 원본 슬라이스).
*/
const CRLF = '\r\n';
const latin1 = (b) => Buffer.from(b).toString('latin1');
/**
* 인코딩되지 않은 8비트 헤더(RFC 6532 식 UTF-8 제목·이름)를 복원합니다(0.9.0). 원본을 latin1 로 읽으므로 UTF-8 바이트가 글자마다 한 글자로
* 풀려 "íê¸ ì 목" 처럼 저장됐습니다. 0x80 이상 글자가 있고 UTF-8 로 되돌렸을 때 깨진 글자(U+FFFD)가 없으면 그 결과를 씁니다.
*/
export function fixUtf8(s) {
const str = String(s ?? ''); if (!/[\u0080-\u00ff]/.test(str) || /[\u0100-\uffff]/.test(str)) return str;
const decoded = Buffer.from(str, 'latin1').toString('utf8'); return decoded.includes('\ufffd') ? str : decoded;
}
export function decodeWords(value) {
return fixUtf8(String(value ?? ''))
.replace(/(=\?[^?]+\?[bBqQ]\?[^?]*\?=)\s+(?==\?)/g, '$1')
.replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (m, charset, enc, text) => {
try {
const bytes = enc.toLowerCase() === 'b' ? Buffer.from(text, 'base64') : Buffer.from(text.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
return new TextDecoder(charset.replace(/\*.*$/, '')).decode(bytes);
} catch { return m; }
});
}
export function decodeBody(bytes, encoding) {
let raw = Buffer.from(bytes);
const enc = String(encoding || '').toLowerCase();
if (enc === 'base64') raw = Buffer.from(latin1(raw).replace(/[^A-Za-z0-9+/=]/g, ''), 'base64');
else if (enc === 'quoted-printable') raw = Buffer.from(latin1(raw).replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
return raw;
}
export function decodeText(bytes, encoding, charset) {
try { return new TextDecoder(charset || 'utf-8').decode(decodeBody(bytes, encoding)); } catch { return decodeBody(bytes, encoding).toString('utf8'); }
}
/** 헤더 블록을 [name, value] 목록으로. 접힌 줄은 펼칩니다. */
export function parseHeaders(text) {
const out = [];
for (const line of String(text).replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/)) {
const i = line.indexOf(':'); if (i <= 0) continue;
out.push([line.slice(0, i).trim(), line.slice(i + 1).trim()]);
}
return out;
}
export const header = (headers, name) => headers.find(([k]) => k.toLowerCase() === name.toLowerCase())?.[1] ?? null;
export function parseContentType(value) {
const [type, ...rest] = String(value || 'text/plain').split(';');
const [main = 'text', sub = 'plain'] = type.trim().toLowerCase().split('/');
return { type: main, subtype: sub || 'plain', params: parseParams(rest.join(';')) };
}
/**
* 헤더 파라미터(name="값"; filename*=UTF-8''%ED%95%9C.pdf …)를 객체로 바꿉니다. RFC 2231 을 지원합니다.
* - name*=charset'언어'%XX… : 퍼센트 인코딩을 풀고 지정한 문자 집합으로 해독합니다.
* - name*0=…; name*1*=… : 이어 쓰기 조각을 번호 순으로 합칩니다(별표가 붙은 조각만 퍼센트 인코딩).
* 예전에는 퍼센트 인코딩을 풀지 않아 한글 첨부 이름이 "%ED%95%9C.pdf" 로 보였고, 이어 쓰기 조각은 합치지 않았습니다.
*/
export function parseParams(text) {
const raw = {}; const re = /([A-Za-z0-9*_.-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/g; let m;
while ((m = re.exec(String(text || '')))) raw[m[1].toLowerCase()] = m[2] !== undefined ? m[2].replace(/\\(.)/g, '$1') : m[3];
const groups = {}; // 이름 → [{ index, extended, value }]
for (const [key, value] of Object.entries(raw)) {
const k = key.match(/^(.+?)(?:\*(\d+))?(\*)?$/);
(groups[k[1]] ||= []).push({ index: k[2] === undefined ? -1 : Number(k[2]), extended: !!k[3], value });
}
const params = {};
for (const [name, pieces] of Object.entries(groups)) {
// 이어 쓰기 조각(*0, *1 …)이 있으면 그것만 순서대로, 없으면 하나뿐인 값을 씁니다.
const parts = pieces.some((p) => p.index >= 0) ? pieces.filter((p) => p.index >= 0).sort((a, b) => a.index - b.index) : pieces.slice(0, 1);
if (!parts.some((p) => p.extended)) { params[name] = parts.map((p) => p.value).join(''); continue; }
// 첫 확장 조각은 charset'language'값 형식입니다.
let charset = 'utf-8'; const bytes = [];
parts.forEach((p, i) => {
let v = p.value;
if (p.extended && i === 0) { const q = v.match(/^([^']*)'[^']*'(.*)$/); if (q) { charset = q[1] || 'utf-8'; v = q[2]; } }
bytes.push(p.extended ? Buffer.from(v.replace(/%([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1') : Buffer.from(v, 'utf8'));
});
try { params[name] = new TextDecoder(charset).decode(Buffer.concat(bytes)); } catch { params[name] = Buffer.concat(bytes).toString('utf8'); }
}
return params;
}
/**
* 주소 헤더(From/To/Cc …)를 {name, mailbox, host} 목록으로 나눕니다.
* 순서가 중요합니다: 먼저 "부호화된 그대로" 쉼표(따옴표 밖)로 나누고 그 다음에 조각마다 encoded-word 를 풉니다.
* 예전에는 먼저 풀고 나눠서, 이름에 쉼표가 든 "김, 민수" 가 두 주소로 갈라졌습니다. 그룹 구문(name: a, b;)의 이름 제거도
* 조각 단위로, 따옴표 밖에 있고 뒤에 숫자가 오지 않을 때만 합니다("Meeting 10:30", "Re: invoice" 같은 이름이 잘리던 문제, 0.8.16 수정).
*/
export function parseAddresses(value) {
const out = []; let raw = String(value || '');
if (!raw.trim()) return out;
// 그룹 구문은 "이름: 구성원, 구성원;" 처럼 세미콜론으로 끝납니다. 끝나는 세미콜론이 있을 때만 이름을 버리고 구성원만 남깁니다.
// 세미콜론 없이 콜론만 있는 "Re: invoice <c@x>" 같은 (규격에는 어긋나지만 흔한) 이름은 그대로 둡니다.
raw = raw.replace(/(^|,)(\s*)[^"<>@:;,]+:\s*([^;:]*);/g, '$1$2$3');
for (const part of raw.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)) {
const p = decodeWords(part).trim(); if (!p) continue;
const m = p.match(/^(.*?)\s*<([^<>]*)>\s*$/);
let name = '', addr = p;
if (m) { name = m[1].trim().replace(/^"(.*)"$/, '$1').replace(/\\(.)/g, '$1'); addr = m[2].trim(); }
const at = addr.lastIndexOf('@');
out.push({ name: name || null, mailbox: at > 0 ? addr.slice(0, at) : addr, host: at > 0 ? addr.slice(at + 1) : null });
}
return out;
}
/**
* 원본 바이트를 파트 트리로. 각 파트: {headers, type, subtype, params, encoding, id, description, disposition, dispositionParams,
* start, headerEnd, bodyStart, end, parts(children), size, lines}
*/
export function parseMime(eml) {
const buf = Buffer.from(eml); const s = latin1(buf);
function parsePart(start, end) {
let headerEnd, bodyStart;
// 헤더가 없는 파트(RFC 2046 5.1: 경계 바로 뒤에 빈 줄 → text/plain us-ascii)는 CRLF 로 시작합니다. 예전에는 CRLFCRLF 를 찾다
// 본문을 통째로 잃거나(크기 0) 본문의 첫 단락을 헤더로 잘못 읽었습니다(0.8.25 수정).
if (s.startsWith('\r\n', start)) { headerEnd = start; bodyStart = Math.min(start + 2, end); }
else { const sep = s.indexOf('\r\n\r\n', start); if (sep < 0 || sep >= end) { headerEnd = end; bodyStart = end; } else { headerEnd = sep + 2; bodyStart = sep + 4; } }
const headers = parseHeaders(s.slice(start, headerEnd));
const ct = parseContentType(header(headers, 'content-type'));
const cd = parseContentType(header(headers, 'content-disposition') || '');
const body = s.slice(bodyStart, end);
const part = {
headers, type: ct.type, subtype: ct.subtype, params: ct.params,
encoding: (header(headers, 'content-transfer-encoding') || '7bit').trim().toLowerCase(),
id: header(headers, 'content-id'), description: header(headers, 'content-description'),
disposition: header(headers, 'content-disposition') ? cd.type : null, dispositionParams: cd.params,
start, headerEnd, bodyStart, end, parts: [],
size: end - bodyStart, lines: body ? body.split('\n').length - (body.endsWith('\n') ? 1 : 0) : 0,
};
if (ct.type === 'multipart' && ct.params.boundary) {
// 경계 줄은 "--경계" 바로 뒤가 "--"(끝), 공백 또는 줄바꿈이어야 합니다. 예전에는 접두사만 비교해서 바깥 경계 "b1" 이
// 안쪽 경계 "--b1x" 줄과도 맞아 파트가 잘못 나뉘었습니다(BODYSTRUCTURE·섹션 번호 오류).
const b = '--' + ct.params.boundary;
const isBoundary = (at) => { const c = s.charAt(at + b.length); return c === '' || c === '-' || c === '\r' || c === '\n' || c === ' ' || c === '\t'; };
const find = (from, prefix) => { let at = s.indexOf(prefix + b, from); while (at >= 0 && !isBoundary(at + prefix.length)) at = s.indexOf(prefix + b, at + 1); return at; };
let pos = s.startsWith(b, bodyStart) && isBoundary(bodyStart) ? bodyStart : find(bodyStart, '\n'); if (pos >= 0 && s[pos] === '\n') pos += 1;
while (pos >= 0 && pos < end) {
if (s.startsWith(b + '--', pos)) break;
const lineEnd = s.indexOf('\n', pos); if (lineEnd < 0) break;
const childStart = lineEnd + 1;
let next = find(childStart, '\r\n'); if (next < 0 || next > end) next = -1;
part.parts.push(parsePart(childStart, next < 0 ? end : next));
if (next < 0) break; pos = next + 2;
}
} else if (ct.type === 'message' && ct.subtype === 'rfc822') {
part.parts.push(parsePart(bodyStart, end));
}
return part;
}
return { buffer: buf, text: s, root: parsePart(0, s.length) };
}
// ------------------------------------------------------------------ IMAP 문자열 표현
export const nstring = (v) => v === null || v === undefined ? 'NIL' : imapString(String(v));
export function imapString(v) {
const s = String(v);
// 헤더 값은 원본 바이트를 latin1 로 읽은 "바이트 문자열"(문자 하나 = 바이트 하나)입니다. 리터럴 길이는 그 바이트 수이고,
// 세션(imap.mjs send)은 이런 문자열을 latin1 로 써서 바이트를 그대로 내보냅니다. 예전에는 길이를 UTF-8 로 다시 재고 UTF-8 로 써서
// 한글 제목이 두 번 인코딩돼 깨졌습니다. 진짜 유니코드(0xFF 초과 문자)가 들어오면 UTF-8 바이트 문자열로 바꿔 같은 규칙을 적용합니다.
if (/[\r\n]/.test(s) || /[^\x01-\x7f]/.test(s)) { const raw = /[^\u0000-\u00ff]/.test(s) ? Buffer.from(s, 'utf8').toString('latin1') : s; return `{${raw.length}}${CRLF}${raw}`; }
return '"' + s.replace(/[\\"]/g, (c) => '\\' + c) + '"';
}
const addressList = (list) => list.length ? '(' + list.map((a) => `(${nstring(a.name)} NIL ${nstring(a.mailbox)} ${nstring(a.host)})`).join('') + ')' : 'NIL';
export function envelope(headers) {
const from = parseAddresses(header(headers, 'from')), sender = parseAddresses(header(headers, 'sender')), replyTo = parseAddresses(header(headers, 'reply-to'));
return '(' + [
nstring(header(headers, 'date')), nstring(header(headers, 'subject')),
addressList(from), addressList(sender.length ? sender : from), addressList(replyTo.length ? replyTo : from),
addressList(parseAddresses(header(headers, 'to'))), addressList(parseAddresses(header(headers, 'cc'))), addressList(parseAddresses(header(headers, 'bcc'))),
nstring(header(headers, 'in-reply-to')), nstring(header(headers, 'message-id')),
].join(' ') + ')';
}
const paramList = (params) => { const e = Object.entries(params || {}); return e.length ? '(' + e.map(([k, v]) => `${imapString(k.toUpperCase())} ${imapString(v)}`).join(' ') + ')' : 'NIL'; };
const dispositionField = (part) => part.disposition ? `(${imapString(part.disposition.toUpperCase())} ${paramList(part.dispositionParams)})` : 'NIL';
export function bodyStructure(part, extended = true) {
if (part.type === 'multipart') {
const children = part.parts.map((p) => bodyStructure(p, extended)).join('');
return `(${children || '("TEXT" "PLAIN" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 0 0)'} ${imapString(part.subtype.toUpperCase())}${extended ? ` ${paramList(part.params)} ${dispositionField(part)} NIL NIL` : ''})`;
}
const basic = [imapString(part.type.toUpperCase()), imapString(part.subtype.toUpperCase()), paramList(part.params), nstring(part.id), nstring(part.description), imapString(part.encoding.toUpperCase()), String(part.size)];
if (part.type === 'text') basic.push(String(part.lines));
else if (part.type === 'message' && part.subtype === 'rfc822' && part.parts[0]) basic.push(envelope(part.parts[0].headers), bodyStructure(part.parts[0], extended), String(part.lines));
if (extended) basic.push('NIL', dispositionField(part), 'NIL', 'NIL');
return '(' + basic.join(' ') + ')';
}
/** 섹션 지정자(BODY[...] 의 대괄호 안)를 바이트로 돌려줍니다. */
export function section(parsed, spec) {
const { text, root } = parsed; const s = String(spec || '').trim();
const slice = (a, b) => Buffer.from(text.slice(a, b), 'latin1');
if (s === '') return parsed.buffer;
const m = s.match(/^((?:\d+\.)*\d+)(?:\.(MIME|HEADER|TEXT|HEADER\.FIELDS(?:\.NOT)?\s*\([^)]*\)))?$/i) || s.match(/^()(HEADER|TEXT|HEADER\.FIELDS(?:\.NOT)?\s*\([^)]*\))$/i);
if (!m) return null;
let part = root;
if (m[1]) {
for (const n of m[1].split('.').map(Number)) {
if (part.type === 'multipart') part = part.parts[n - 1];
else if (part.type === 'message' && part.subtype === 'rfc822') part = part.parts[0] && (part.parts[0].type === 'multipart' ? part.parts[0].parts[n - 1] : n === 1 ? part.parts[0] : null);
else part = n === 1 ? part : null;
if (!part) return null;
}
}
const what = (m[2] || '').toUpperCase();
if (!what) return m[1] ? slice(part.bodyStart, part.end) : parsed.buffer;
const target = m[1] && what !== 'MIME' && part.type === 'message' && part.parts[0] ? part.parts[0] : part;
if (what === 'MIME') return slice(part.start, part.bodyStart);
if (what === 'HEADER') return slice(target.start, target.bodyStart);
if (what === 'TEXT') return slice(target.bodyStart, target.end);
const fm = what.match(/^HEADER\.FIELDS(\.NOT)?\s*\(([^)]*)\)$/); if (!fm) return null;
const wanted = new Set(fm[2].split(/\s+/).filter(Boolean).map((x) => x.replace(/^"|"$/g, '').toLowerCase())), not = !!fm[1];
const lines = text.slice(target.start, target.headerEnd).split(/\r\n(?![ \t])/).filter(Boolean);
const picked = lines.filter((line) => { const name = line.slice(0, line.indexOf(':')).trim().toLowerCase(); return wanted.has(name) !== not; });
return Buffer.from(picked.join(CRLF) + (picked.length ? CRLF : '') + CRLF, 'latin1');
}
/** 웹 저장용 요약: 제목·발신·수신·본문(text/html)·첨부. */
export function summarize(parsed) {
const h = parsed.root.headers; const addr = (a) => a.host ? `${a.mailbox}@${a.host}` : a.mailbox;
const out = { subject: decodeWords(header(h, 'subject') || ''), from: decodeWords(header(h, 'from') || ''), to: parseAddresses(header(h, 'to')).map(addr), cc: parseAddresses(header(h, 'cc')).map(addr), bcc: parseAddresses(header(h, 'bcc')).map(addr), replyTo: parseAddresses(header(h, 'reply-to')).map(addr), messageId: header(h, 'message-id'), date: header(h, 'date'), text: '', html: '', attachments: [], headers: Object.fromEntries(h) };
const walk = (p) => {
if (p.type === 'multipart') { p.parts.forEach(walk); return; }
const body = Buffer.from(parsed.text.slice(p.bodyStart, p.end), 'latin1');
// 텍스트가 아닌 파트는 모두 첨부입니다. message/rfc822(메일을 "첨부로 전달")도 첨부로 둡니다.
// 예전에는 message 형식을 본문도 첨부도 아닌 것으로 건너뛰어, 첨부로 전달한 메일이 웹 화면과 SMTP 발송에서 사라졌습니다.
// 파일 이름이 붙은 텍스트(notes.txt, text/csv, Apple Mail 의 inline; filename=…)도 첨부입니다. 예전에는 본문도 첨부도 아닌 것으로 사라졌습니다.
const named = !!(p.dispositionParams.filename || p.params.name);
const isAttachment = p.disposition === 'attachment' || p.type !== 'text' || named || !['plain', 'html'].includes(p.subtype);
if (isAttachment) { out.attachments.push({ filename: decodeWords(p.dispositionParams.filename || p.params.name || (p.type === 'message' ? 'message.eml' : 'attachment')), contentType: `${p.type}/${p.subtype}`, contentId: p.id ? p.id.replace(/^<|>$/g, '') : null, disposition: p.disposition === 'inline' && p.id ? 'inline' : p.disposition === 'inline' ? 'attachment' : p.disposition || 'attachment', bytes: decodeBody(body, p.encoding) }); return; }
if (p.type === 'text' && p.subtype === 'html' && !out.html) out.html = decodeText(body, p.encoding, p.params.charset);
else if (p.type === 'text' && !out.text) out.text = decodeText(body, p.encoding, p.params.charset);
};
walk(parsed.root);
return out;
}
+476
View File
@@ -0,0 +1,476 @@
/**
* IMAP4rev1 서버 (RFC 3501) + LITERAL+, IDLE, UIDPLUS, MOVE, SPECIAL-USE, NAMESPACE, ID, UNSELECT, ENABLE.
*
* - 계정: www.arihakdang.com 관리자 계정(login_id 또는 email + 비밀번호). 별도 비밀번호 없음.
* - 메일함: INBOX(받은), Sent(보낸), Drafts(초안), Trash(휴지통) — 웹 메일함의 folder 와 같은 저장소.
* - \Seen 은 read_at, \Draft 는 초안 상태, 나머지 플래그는 imap_flags 에 저장. UID 는 imap_uids(전역 단조 증가).
* - EXPUNGE 는 휴지통이 아니면 휴지통으로 이동, 휴지통에서는 영구 삭제(재수신 차단). MOVE/COPY 도 같은 저장소를 씁니다.
* - 평문 포트(143): 993 은 즉시 TLS 이고 143 은 STARTTLS 뒤 로그인이 원칙이지만, 관리자 설정 "평문 로그인 허용"(services.plainLogin, 기본 켬)이
* 켜져 있으면 TLS 없이도 LOGIN/AUTHENTICATE 를 받습니다(0.9.7, POP3 110 과 같은 스위치). 꺼져 있으면 LOGINDISABLED 를 광고하고 거절합니다.
* 접속마다 설정을 한 번 읽어(plainOk) 인사말의 CAPABILITY 와 로그인 허용에 같은 값을 씁니다. 그래서 인사말은 설정을 읽은 뒤에 보냅니다.
*/
import net from 'node:net';
import tls from 'node:tls';
import { verifyAdmin, serviceAllowed } from './auth.mjs';
import { serviceEnabled, plainLoginAllowed } from './limits.mjs';
import { normalizeCrlf } from './mime.mjs';
import { buildStoredEml, appendMessage, copyMessage } from './store.mjs';
import { parseMime, envelope, bodyStructure, section, header, decodeWords, decodeText, parseAddresses, summarize } from './imap-mime.mjs';
import { now, memoryRateStore } from './util.mjs';
const CRLF = '\r\n';
const rate = memoryRateStore();
const MAILBOXES = [['INBOX', 'inbox', []], ['Sent', 'sent', ['\\Sent']], ['Drafts', 'drafts', ['\\Drafts']], ['Trash', 'trash', ['\\Trash']]];
const FOLDER_NAME = Object.fromEntries(MAILBOXES.map(([n, f]) => [f, n]));
const PERSISTED = new Set(['\\Flagged', '\\Answered', '\\Deleted']);
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export function folderOf(name) { const n = String(name ?? '').replace(/^"|"$/g, ''); if (/^inbox$/i.test(n)) return 'inbox'; return MAILBOXES.find(([m]) => m === n)?.[1] || null; }
export function internalDate(iso) { const d = new Date(iso || Date.now()); const p = (n) => String(n).padStart(2, '0'); return `${p(d.getUTCDate())}-${MONTHS[d.getUTCMonth()]}-${d.getUTCFullYear()} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())} +0000`; }
/**
* APPEND 의 date-time("05-Oct-2026 23:30:00 +0900", 앞에 공백이 붙은 " 7-Oct-2026 …" 포함)을 ms 로. 시각과 시간대까지 반영합니다.
* 예전에는 SEARCH 용 parseImapDate(날짜만)를 써서 시각·시간대가 버려지고, 공백이 붙은 날은 아예 무시돼 보낸 메일함 정렬이 어긋났습니다.
*/
export function parseImapDateTime(s) {
const m = String(s).replace(/^"|"$/g, '').match(/^\s?(\d{1,2})-([A-Za-z]{3})-(\d{4}) (\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2})$/); if (!m) return null;
const mon = MONTHS.findIndex((x) => x.toLowerCase() === m[2].toLowerCase()); if (mon < 0) return null;
const offset = (m[7] === '-' ? -1 : 1) * (Number(m[8]) * 60 + Number(m[9])) * 60000;
return Date.UTC(Number(m[3]), mon, Number(m[1]), Number(m[4]), Number(m[5]), Number(m[6])) - offset;
}
function parseImapDate(s) { const m = String(s).replace(/^"|"$/g, '').match(/^(\d{1,2})-([A-Za-z]{3})-(\d{4})/); if (!m) return null; const mon = MONTHS.findIndex((x) => x.toLowerCase() === m[2].toLowerCase()); return mon < 0 ? null : Date.UTC(Number(m[3]), mon, Number(m[1])); }
function wildcard(pattern) { return new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/%/g, '[^/]*') + '$', 'i'); }
// ------------------------------------------------------------------ 명령 토큰화
export function tokenize(raw, literals = []) {
const out = []; const stack = [out]; let i = 0; const s = raw;
// 회선에서는 latin1 로 읽으므로 따옴표 문자열·원자는 UTF-8 로 되돌립니다(검색어·메일함 이름의 한글).
const utf8 = (v) => Buffer.from(v, 'latin1').toString('utf8');
const push = (v) => stack[stack.length - 1].push(typeof v === 'string' ? utf8(v) : v);
while (i < s.length) {
const c = s[i];
if (c === ' ' || c === '\t') { i++; continue; }
if (c === '(') { const arr = []; push(arr); stack.push(arr); i++; continue; }
if (c === ')') { if (stack.length > 1) stack.pop(); i++; continue; }
if (c === '"') { let j = i + 1, v = ''; while (j < s.length && s[j] !== '"') { if (s[j] === '\\' && j + 1 < s.length) { v += s[j + 1]; j += 2; } else v += s[j++]; } push(v); i = j + 1; continue; }
// NUL 쌍(\0번호\0)은 리터럴 자리표시자입니다. 짝이 없는 NUL(클라이언트가 보낸 날 바이트)은 indexOf 가 -1 을 돌려주고 i 가 0 으로 되돌아가
// 무한 루프(인증 전 한 줄로 프로세스 전체가 멈춤)가 됐으므로, 짝이 없으면 남은 글자를 버리고 끝냅니다(0.8.24).
if (c === '\0') { const j = s.indexOf('\0', i + 1); if (j < 0) break; const lit = literals[Number(s.slice(i + 1, j))]; stack[stack.length - 1].push(lit ? lit.toString('utf8') : ''); i = j + 1; continue; }
let j = i, depth = 0;
while (j < s.length) { const d = s[j]; if (d === '[') depth++; else if (d === ']') depth--; else if (depth === 0 && (d === ' ' || d === ')' || d === '(')) break; j++; }
push(s.slice(i, j)); i = j;
}
return out;
}
/** 시퀀스/UID 집합 → 스냅샷 인덱스 배열 */
export function resolveSet(spec, messages, byUid) {
const out = new Set(); if (!messages.length) return [];
const last = byUid ? messages[messages.length - 1].uid : messages.length;
for (const piece of String(spec).split(',')) {
let [a, b] = piece.split(':'); if (b === undefined) b = a;
const lo = a === '*' ? last : Number(a), hi = b === '*' ? last : Number(b);
if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue;
const [from, to] = lo <= hi ? [lo, hi] : [hi, lo];
messages.forEach((m, idx) => { const key = byUid ? m.uid : idx + 1; if (key >= from && key <= to) out.add(idx); });
}
return [...out].sort((x, y) => x - y);
}
class Session {
constructor(server, socket, { secure }) {
this.server = server; this.env = server.env; this.secure = secure; this.state = 'notauth'; this.user = null;
this.buf = Buffer.alloc(0); this.raw = ''; this.literals = []; this.needLiteral = 0; this.closed = false;
this.mailbox = null; this.messages = []; this.cache = new Map(); this.idle = null; this.continuation = null; this.failures = 0;
// plainOk: 이 접속에서 TLS 없이 로그인을 받을지(pop3.mjs 와 같은 규칙). 설정 읽기가 끝난 뒤 인사말을 보내고, 그 뒤에야 명령 큐가 돌기 시작합니다.
this.plainOk = secure || server.allowPlaintext === true;
this.ip = socket.remoteAddress || ''; this.attach(socket);
console.log(`IMAP 접속 ${this.ip} ${secure ? 'tls' : 'plain'}`);
this.queue = this.loadPlainLogin().then(() => this.send(`* OK [CAPABILITY ${this.capabilities()}] ${server.hostname} IMAP4rev1 ready`));
}
async loadPlainLogin() {
if (this.secure || typeof this.server.allowPlaintext === 'boolean') return;
try { this.plainOk = await plainLoginAllowed(this.env); } catch (e) { console.error('IMAP 평문 로그인 설정 읽기 실패', e?.message); this.plainOk = false; }
}
attach(socket) { this.socket = socket; socket.setTimeout(35 * 60 * 1000); socket.on('data', (c) => this.onData(c)); socket.on('timeout', () => this.close('* BYE timeout')); socket.on('error', () => { this.closed = true; }); socket.on('close', () => { this.closed = true; this.stopIdle(); }); }
// 응답 문자열은 latin1(문자 = 바이트)로 씁니다. ENVELOPE/BODYSTRUCTURE 의 리터럴은 원본 헤더 바이트를 담은 바이트 문자열이라
// UTF-8 로 쓰면 두 번 인코딩됩니다. 0xFF 를 넘는 문자가 섞인(진짜 유니코드) 줄만 UTF-8 로 씁니다.
send(line) { if (!this.closed) { const text = line + CRLF; this.socket.write(Buffer.from(text, /[^\u0000-\u00ff]/.test(text) ? 'utf8' : 'latin1')); } }
write(buffer) { if (!this.closed) this.socket.write(buffer); }
close(line) { if (line) this.send(line); this.closed = true; this.stopIdle(); try { this.socket.end(); } catch {} }
capabilities() {
const caps = ['IMAP4rev1', 'LITERAL+', 'IDLE', 'UIDPLUS', 'MOVE', 'SPECIAL-USE', 'NAMESPACE', 'ID', 'UNSELECT', 'ENABLE', 'CHILDREN'];
if (this.plainOk) caps.push('AUTH=PLAIN'); else caps.push('LOGINDISABLED'); // 평문 접속에서 로그인을 안 받으면 LOGINDISABLED(RFC 3501 7.2.1)
if (!this.secure && this.server.secureContext) caps.push('STARTTLS');
return caps.join(' ');
}
onData(chunk) {
// STARTTLS 를 받은 뒤 TLS 전환 전까지 평문으로 오는 바이트는 버립니다(명령 주입 방지, 아래 설명).
if (this.upgrading) return;
this.buf = Buffer.concat([this.buf, chunk]);
if (this.buf.length > 64 * 1024 * 1024) { this.close('* BYE too much data'); return; }
for (;;) {
if (this.needLiteral > 0) {
if (this.buf.length < this.needLiteral) return;
this.literals.push(this.buf.subarray(0, this.needLiteral)); this.raw += `\0${this.literals.length - 1}\0`;
this.buf = this.buf.subarray(this.needLiteral); this.needLiteral = 0; continue;
}
const idx = this.buf.indexOf('\r\n'); if (idx < 0) { if (this.buf.length > 65536) this.close('* BYE line too long'); return; }
const line = this.buf.subarray(0, idx).toString('latin1'); this.buf = this.buf.subarray(idx + 2);
const lit = line.match(/\{(\d+)(\+?)\}$/);
if (lit && !this.continuation && !this.idle) {
const n = Number(lit[1]); if (n > 40 * 1024 * 1024) { this.raw = ''; this.literals = []; this.send('* BAD literal too large'); continue; }
this.raw += line.slice(0, -lit[0].length); this.needLiteral = n; if (n === 0) { this.literals.push(Buffer.alloc(0)); this.raw += `\0${this.literals.length - 1}\0`; this.needLiteral = 0; }
if (!lit[2]) this.send('+ Ready for literal data');
continue;
}
const raw = this.raw + line, literals = this.literals; this.raw = ''; this.literals = [];
// STARTTLS 명령 주입 방지(CVE-2011-0411 유형): 같은 평문 묶음에서 STARTTLS 뒤에 붙어 온 명령은 TLS 전환 뒤에
// 암호화된 명령처럼 실행될 수 있으므로, STARTTLS 를 큐에 넣은 뒤 남은 입력을 버리고 전환이 끝날 때까지 받지 않습니다.
const isStartTls = !this.idle && !this.continuation && /^\S+\s+STARTTLS\s*$/i.test(raw);
if (isStartTls) { this.upgrading = true; this.buf = Buffer.alloc(0); }
this.queue = this.queue.then(() => this.dispatch(raw, literals)).catch((e) => { console.error('IMAP 오류', e?.stack || e?.message); this.send('* BAD internal error'); });
if (isStartTls) return;
}
}
async dispatch(raw, literals) {
if (this.closed) return;
if (this.continuation) { const fn = this.continuation; this.continuation = null; return fn(raw); }
if (this.idle) { if (raw.trim().toUpperCase() === 'DONE') { const tag = this.idle.tag; this.stopIdle(); await this.refresh({ expunge: true }); this.send(`${tag} OK IDLE terminated`); } return; }
const tokens = tokenize(raw, literals);
const tag = tokens[0]; if (!tag) return;
let name = String(tokens[1] || '').toUpperCase(), args = tokens.slice(2);
if (name === 'UID') { name = 'UID ' + String(args[0] || '').toUpperCase(); args = args.slice(1); }
try { await this.command(tag, name, args, raw, literals); }
catch (e) { if (e?.imap) this.send(`${tag} ${e.imap} ${e.message}`); else { console.error('IMAP 명령 오류', name, e?.stack || e?.message); this.send(`${tag} NO internal error`); } }
}
fail(kind, message) { const e = new Error(message); e.imap = kind; throw e; }
async command(tag, name, args, raw, literals = []) {
const needAuth = () => { if (this.state === 'notauth') this.fail('NO', 'login first'); };
const needSelected = () => { needAuth(); if (!this.mailbox) this.fail('NO', 'no mailbox selected'); };
switch (name) {
case 'CAPABILITY': this.send(`* CAPABILITY ${this.capabilities()}`); return this.send(`${tag} OK CAPABILITY completed`);
case 'NOOP': if (this.mailbox) await this.refresh({ expunge: true }); return this.send(`${tag} OK NOOP completed`);
case 'LOGOUT': this.send('* BYE logging out'); this.send(`${tag} OK LOGOUT completed`); return this.close();
case 'ID': this.send('* ID ("name" "arihakdang-mail-gateway" "vendor" "Ari Hakdang")'); return this.send(`${tag} OK ID completed`);
case 'ENABLE': this.send('* ENABLED'); return this.send(`${tag} OK ENABLE completed`);
case 'NAMESPACE': needAuth(); this.send('* NAMESPACE (("" "/")) NIL NIL'); return this.send(`${tag} OK NAMESPACE completed`);
case 'STARTTLS': return this.startTls(tag);
case 'LOGIN': return this.login(tag, args[0], args[1]);
case 'AUTHENTICATE': return this.authenticate(tag, args);
case 'LIST': case 'LSUB': case 'XLIST': needAuth(); return this.list(tag, name, args);
case 'SUBSCRIBE': case 'UNSUBSCRIBE': needAuth(); return this.send(`${tag} OK ${name} completed`);
case 'CREATE': case 'DELETE': case 'RENAME': needAuth(); return this.send(`${tag} NO [CANNOT] mailboxes are fixed (INBOX, Sent, Drafts, Trash)`);
case 'STATUS': needAuth(); return this.status(tag, args);
case 'SELECT': case 'EXAMINE': needAuth(); return this.select(tag, args[0], name === 'EXAMINE');
case 'APPEND': needAuth(); return this.append(tag, args, literals);
case 'CHECK': needSelected(); await this.refresh({ expunge: true }); return this.send(`${tag} OK CHECK completed`);
case 'CLOSE': needSelected(); if (!this.readOnly) await this.expunge(false); this.mailbox = null; this.messages = []; return this.send(`${tag} OK CLOSE completed`);
case 'UNSELECT': needSelected(); this.mailbox = null; this.messages = []; return this.send(`${tag} OK UNSELECT completed`);
case 'EXPUNGE': needSelected(); if (this.readOnly) this.fail('NO', 'mailbox is read-only'); await this.expunge(true); return this.send(`${tag} OK EXPUNGE completed`);
// EXAMINE(읽기 전용) 세션에서는 EXPUNGE 와 마찬가지로 UID EXPUNGE 도 거부합니다(예전에는 검사가 빠져 읽기 전용 세션이 메일을 지웠습니다, 0.8.24).
case 'UID EXPUNGE': needSelected(); if (this.readOnly) this.fail('NO', 'mailbox is read-only'); await this.expunge(true, resolveSet(args[0], this.messages, true)); return this.send(`${tag} OK EXPUNGE completed`);
case 'SEARCH': case 'UID SEARCH': needSelected(); return this.search(tag, args, name.startsWith('UID'));
case 'FETCH': case 'UID FETCH': needSelected(); return this.fetch(tag, args, name.startsWith('UID'));
case 'STORE': case 'UID STORE': needSelected(); return this.store(tag, args, name.startsWith('UID'));
case 'COPY': case 'UID COPY': case 'MOVE': case 'UID MOVE': needSelected(); return this.copy(tag, args, name.startsWith('UID'), name.endsWith('MOVE'));
case 'IDLE': needAuth(); return this.startIdle(tag);
default: return this.send(`${tag} BAD unknown command`);
}
}
// ---------------------------------------------------------------- 인증
async startTls(tag) {
this.upgrading = false; // 거절하면 평문 입력을 다시 받고, 전환하면 새 TLS 소켓으로 받습니다.
if (this.secure) return this.send(`${tag} NO already using TLS`);
if (!this.server.secureContext) return this.send(`${tag} NO TLS not available`);
this.send(`${tag} OK Begin TLS negotiation now`);
const plain = this.socket; ['data', 'timeout', 'error', 'close'].forEach((ev) => plain.removeAllListeners(ev));
const secureSocket = new tls.TLSSocket(plain, { isServer: true, secureContext: this.server.secureContext, SNICallback: (n, cb) => cb(null, this.server.secureContext) });
this.secure = true; this.plainOk = true; this.buf = Buffer.alloc(0); this.attach(secureSocket);
}
async login(tag, login, password) {
if (!this.plainOk) { console.log(`IMAP 평문 로그인 거부 ${this.ip} (STARTTLS 필요)`); return this.send(`${tag} NO [PRIVACY] use STARTTLS or port 993 before login`); }
if (this.state !== 'notauth') return this.send(`${tag} BAD already logged in`);
if (rate.blocked('imap:' + this.ip, { limit: 20 })) return this.close(`${tag} NO [AUTHENTICATIONFAILED] too many attempts`);
// 1.0.7 다중 도메인: 로그인 ID 의 @도메인(또는 TLS SNI 호스트)으로 어느 도메인의 계정인지 고릅니다. 모르는·꺼 둔 도메인이면 인증 실패와 같게 처리합니다.
const env = this.server.resolveEnv ? this.server.resolveEnv(String(login || ''), sniName(this.socket)) : this.env;
const user = env ? await verifyAdmin(env, String(login || ''), String(password || '')) : null;
if (user) this.env = env;
if (!user) rate.hit('imap:' + this.ip, { limit: 20, windowSec: 900 }); // 실패만 셉니다
console.log(`IMAP 인증 ${user ? '성공' : '실패'} ${this.ip} user=${String(login || '').slice(0, 80)}`);
if (!user) { this.failures++; await new Promise((r) => setTimeout(r, this.env.POP_AUTH_DELAY_MS ?? 1500)); if (this.failures >= 3) return this.close(`${tag} NO [AUTHENTICATIONFAILED] authentication failed`); return this.send(`${tag} NO [AUTHENTICATIONFAILED] authentication failed`); }
// 관리자가 IMAP 서비스 자체를 꺼 두었으면(0.9.0, 관리자 페이지 설정) 모든 계정의 접속을 거부합니다.
if (!(await serviceEnabled(this.env, 'imap'))) { console.log(`IMAP 접속 거부 ${this.ip} user=${user.login} (관리자가 IMAP 서비스를 껐음)`); return this.send(`${tag} NO [UNAVAILABLE] IMAP service is disabled by the administrator`); }
// 계정 설정에서 IMAP 접속을 꺼 두었으면 비밀번호가 맞아도 거부합니다(0.8.4~).
if (!(await serviceAllowed(this.env, user, 'imap'))) { console.log(`IMAP 접속 거부 ${this.ip} user=${user.login} (계정 설정에서 IMAP 사용 안 함)`); return this.send(`${tag} NO [UNAVAILABLE] IMAP access is disabled for this account`); }
this.user = user; this.state = 'auth';
this.send(`${tag} OK [CAPABILITY ${this.capabilities()}] LOGIN completed`);
}
async authenticate(tag, args) {
if (String(args[0] || '').toUpperCase() !== 'PLAIN') return this.send(`${tag} NO unsupported mechanism`);
if (!this.plainOk) { console.log(`IMAP 평문 로그인 거부 ${this.ip} (STARTTLS 필요)`); return this.send(`${tag} NO [PRIVACY] use STARTTLS or port 993 before login`); }
const finish = async (payload) => {
if (payload.trim() === '*') return this.send(`${tag} BAD cancelled`);
let parts; try { parts = Buffer.from(payload.trim(), 'base64').toString('utf8').split('\0'); } catch { return this.send(`${tag} BAD invalid response`); }
if (parts.length < 3) return this.send(`${tag} BAD invalid response`);
return this.login(tag, parts[1], parts[2]);
};
if (args[1]) return finish(args[1]);
this.send('+ '); this.continuation = finish;
}
// ---------------------------------------------------------------- 메일함
async list(tag, name, args) {
const reference = String(args[0] ?? ''), pattern = String(args[1] ?? '');
if (pattern === '') { this.send(`* ${name} (\\Noselect) "/" ""`); return this.send(`${tag} OK ${name} completed`); }
const re = wildcard((reference + pattern).replace(/^\/+/, ''));
for (const [mailbox, , attrs] of MAILBOXES) if (re.test(mailbox)) this.send(`* ${name} (\\HasNoChildren${attrs.length ? ' ' + attrs.join(' ') : ''}) "/" "${mailbox}"`);
this.send(`${tag} OK ${name} completed`);
}
async uidValidity() { const row = await this.env.DB.prepare("SELECT value FROM settings WHERE key='imap_uidvalidity'").first(); return Number(row?.value || 1); }
async snapshot(folder) {
// 새 UID 는 도착 순서대로: 인덱스 순회 방향에 따라 순서가 뒤집히지 않도록 명시적으로 정렬합니다.
await this.env.DB.prepare('INSERT OR IGNORE INTO imap_uids(message_id,folder) SELECT id,folder FROM messages WHERE folder=? AND mailbox=? ORDER BY received_at, id').bind(folder, this.user.mailbox).run();
const rows = (await this.env.DB.prepare(`SELECT m.id, u.id AS uid, m.read_at, m.state, m.folder, m.raw_size, m.received_at, COALESCE(f.flags,'[]') AS flags FROM messages m JOIN imap_uids u ON u.message_id=m.id AND u.folder=m.folder LEFT JOIN imap_flags f ON f.message_id=m.id WHERE m.folder=? AND m.mailbox=? ORDER BY u.id`).bind(folder, this.user.mailbox).all()).results;
// state: 메일 상태(draft/sending/sent/received/failed). COPY/MOVE 가 Drafts 로 옮길 수 있는 메일인지 판단하는 데 씁니다(0.8.24).
return rows.map((r) => ({ id: r.id, uid: r.uid, seen: !!r.read_at, draft: r.state === 'draft' || r.folder === 'drafts', state: r.state, flags: JSON.parse(r.flags), size: r.raw_size, date: r.received_at }));
}
flagList(m) { const f = []; if (m.seen) f.push('\\Seen'); if (m.draft) f.push('\\Draft'); for (const x of m.flags) if (!f.includes(x)) f.push(x); return f; }
async uidNext() { const row = await this.env.DB.prepare('SELECT COALESCE(MAX(id),0)+1 AS n FROM imap_uids').first(); return row.n; }
async select(tag, name, readOnly) {
const folder = folderOf(name); if (!folder) return this.send(`${tag} NO [NONEXISTENT] no such mailbox`);
this.mailbox = folder; this.readOnly = readOnly; this.messages = await this.snapshot(folder); this.cache.clear();
const unseen = this.messages.findIndex((m) => !m.seen);
this.send(`* ${this.messages.length} EXISTS`); this.send('* 0 RECENT');
if (unseen >= 0) this.send(`* OK [UNSEEN ${unseen + 1}] first unseen`);
this.send(`* OK [UIDVALIDITY ${await this.uidValidity()}] UIDs valid`); this.send(`* OK [UIDNEXT ${await this.uidNext()}] predicted next UID`);
this.send('* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)'); this.send(`* OK [PERMANENTFLAGS (${readOnly ? '' : '\\Answered \\Flagged \\Deleted \\Seen'})] flags`);
this.send(`${tag} OK [${readOnly ? 'READ-ONLY' : 'READ-WRITE'}] ${readOnly ? 'EXAMINE' : 'SELECT'} completed`);
}
async status(tag, args) {
const folder = folderOf(args[0]); if (!folder) return this.send(`${tag} NO [NONEXISTENT] no such mailbox`);
const items = Array.isArray(args[1]) ? args[1] : [args[1]].filter(Boolean);
const rows = await this.snapshot(folder); const out = [];
for (const item of items.map((x) => String(x).toUpperCase())) {
if (item === 'MESSAGES') out.push(`MESSAGES ${rows.length}`); else if (item === 'RECENT') out.push('RECENT 0');
else if (item === 'UNSEEN') out.push(`UNSEEN ${rows.filter((m) => !m.seen).length}`); else if (item === 'UIDNEXT') out.push(`UIDNEXT ${await this.uidNext()}`);
else if (item === 'UIDVALIDITY') out.push(`UIDVALIDITY ${await this.uidValidity()}`);
}
this.send(`* STATUS "${FOLDER_NAME[folder]}" (${out.join(' ')})`); this.send(`${tag} OK STATUS completed`);
}
/** 다른 세션·웹·수신으로 바뀐 내용을 알립니다. */
async refresh({ expunge = false } = {}) {
if (!this.mailbox) return;
const fresh = await this.snapshot(this.mailbox); const freshByUid = new Map(fresh.map((m) => [m.uid, m]));
if (expunge) { for (let i = this.messages.length - 1; i >= 0; i--) if (!freshByUid.has(this.messages[i].uid)) { this.messages.splice(i, 1); this.send(`* ${i + 1} EXPUNGE`); } }
const known = new Set(this.messages.map((m) => m.uid));
this.messages.forEach((m, i) => { const f = freshByUid.get(m.uid); if (f && this.flagList(f).join(' ') !== this.flagList(m).join(' ')) { Object.assign(m, f); this.send(`* ${i + 1} FETCH (FLAGS (${this.flagList(m).join(' ')}))`); } });
const added = fresh.filter((m) => !known.has(m.uid)); if (added.length) { this.messages.push(...added); this.send(`* ${this.messages.length} EXISTS`); }
}
// ---------------------------------------------------------------- 내용
async loadParsed(m) {
const hit = this.cache.get(m.id); if (hit) return hit;
const row = await this.env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(m.id).first();
let eml = row ? Buffer.from(row.eml) : null;
if (!eml) { const stored = await this.env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(m.id).first(); eml = stored ? await buildStoredEml(this.env, stored) : Buffer.from('Subject: (missing)\r\n\r\n'); }
const parsed = parseMime(normalizeCrlf(eml)); parsed.size = parsed.buffer.length;
if (this.cache.size > 40) this.cache.delete(this.cache.keys().next().value);
this.cache.set(m.id, parsed); return parsed;
}
parseFetchItems(spec) {
let items = Array.isArray(spec) ? spec : [spec];
const upper = items.map((x) => String(x).toUpperCase());
if (upper.length === 1 && upper[0] === 'ALL') items = ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE', 'ENVELOPE'];
else if (upper.length === 1 && upper[0] === 'FAST') items = ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE'];
else if (upper.length === 1 && upper[0] === 'FULL') items = ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE', 'ENVELOPE', 'BODY'];
return items.map(String);
}
async fetch(tag, args, byUid) {
const indexes = resolveSet(args[0], this.messages, byUid); const items = this.parseFetchItems(args[1] ?? []);
if (byUid && !items.some((x) => x.toUpperCase() === 'UID')) items.unshift('UID');
for (const idx of indexes) {
const m = this.messages[idx]; const parts = []; let markSeen = false;
for (const item of items) {
const key = item.toUpperCase();
if (key === 'UID') parts.push(`UID ${m.uid}`);
else if (key === 'FLAGS') parts.push(`FLAGS (${this.flagList(m).join(' ')})`);
else if (key === 'INTERNALDATE') parts.push(`INTERNALDATE "${internalDate(m.date)}"`);
else if (key === 'RFC822.SIZE') parts.push(`RFC822.SIZE ${(await this.loadParsed(m)).size}`);
else if (key === 'ENVELOPE') parts.push(`ENVELOPE ${envelope((await this.loadParsed(m)).root.headers)}`);
else if (key === 'BODYSTRUCTURE') parts.push(`BODYSTRUCTURE ${bodyStructure((await this.loadParsed(m)).root, true)}`);
else if (key === 'BODY') parts.push(`BODY ${bodyStructure((await this.loadParsed(m)).root, false)}`);
else if (key === 'RFC822' || key === 'RFC822.HEADER' || key === 'RFC822.TEXT') {
const parsed = await this.loadParsed(m); const data = section(parsed, key === 'RFC822' ? '' : key === 'RFC822.HEADER' ? 'HEADER' : 'TEXT');
if (key !== 'RFC822.HEADER') markSeen = true; parts.push({ label: key, data });
} else {
const bm = item.match(/^(BODY(?:\.PEEK)?)\[([^\]]*)\](?:<(\d+)\.(\d+)>)?$/i); if (!bm) continue;
const parsed = await this.loadParsed(m); let data = section(parsed, bm[2]); if (!data) { parts.push(`BODY[${bm[2]}] NIL`); continue; }
let label = `BODY[${bm[2].toUpperCase().replace(/\s+/g, ' ')}]`;
if (bm[3] !== undefined) { const start = Number(bm[3]), len = Number(bm[4]); data = data.subarray(start, start + len); label += `<${start}>`; }
if (!/PEEK/i.test(bm[1])) markSeen = true; parts.push({ label, data });
}
}
if (markSeen && !m.seen && !this.readOnly) { await this.env.DB.prepare('UPDATE messages SET read_at=? WHERE id=? AND read_at IS NULL').bind(now(), m.id).run(); m.seen = true; if (!items.some((x) => x.toUpperCase() === 'FLAGS')) parts.push(`FLAGS (${this.flagList(m).join(' ')})`); }
this.write(Buffer.from(`* ${idx + 1} FETCH (`));
// 문자열 조각(ENVELOPE·BODYSTRUCTURE 등)은 바이트 문자열이므로 latin1 로 씁니다(send 와 같은 규칙). 예전에는 UTF-8 로 써서 한글 제목이 깨졌습니다.
const bytes = (text) => Buffer.from(text, /[^\u0000-\u00ff]/.test(text) ? 'utf8' : 'latin1');
parts.forEach((p, i) => { if (i) this.write(Buffer.from(' ')); if (typeof p === 'string') this.write(bytes(p)); else { this.write(Buffer.from(`${p.label} {${p.data.length}}${CRLF}`)); this.write(p.data); } });
this.write(Buffer.from(`)${CRLF}`));
}
this.send(`${tag} OK FETCH completed`);
}
async store(tag, args, byUid) {
if (this.readOnly) return this.send(`${tag} NO mailbox is read-only`);
const indexes = resolveSet(args[0], this.messages, byUid); const op = String(args[1] || '').toUpperCase();
const om = op.match(/^([+-]?)FLAGS(\.SILENT)?$/); if (!om) return this.send(`${tag} BAD invalid STORE`);
const flags = (Array.isArray(args[2]) ? args[2] : args.slice(2)).map((f) => String(f)).map((f) => { const l = f.toLowerCase(); return l === '\\seen' ? '\\Seen' : l === '\\flagged' ? '\\Flagged' : l === '\\answered' ? '\\Answered' : l === '\\deleted' ? '\\Deleted' : l === '\\draft' ? '\\Draft' : f; });
for (const idx of indexes) {
const m = this.messages[idx]; let seen = m.seen; let persisted = new Set(m.flags);
const wantSeen = flags.includes('\\Seen'), persistedWanted = flags.filter((f) => PERSISTED.has(f));
if (om[1] === '+') { if (wantSeen) seen = true; persistedWanted.forEach((f) => persisted.add(f)); }
else if (om[1] === '-') { if (wantSeen) seen = false; persistedWanted.forEach((f) => persisted.delete(f)); }
else { seen = wantSeen; persisted = new Set(persistedWanted); }
if (seen !== m.seen) await this.env.DB.prepare('UPDATE messages SET read_at=? WHERE id=?').bind(seen ? now() : null, m.id).run();
const list = [...persisted]; if (list.join() !== m.flags.join()) await this.env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?) ON CONFLICT(message_id) DO UPDATE SET flags=excluded.flags,updated_at=excluded.updated_at').bind(m.id, JSON.stringify(list), now()).run();
m.seen = seen; m.flags = list;
if (!om[2]) this.send(`* ${idx + 1} FETCH (FLAGS (${this.flagList(m).join(' ')})${byUid ? ` UID ${m.uid}` : ''})`);
}
this.send(`${tag} OK STORE completed`);
}
async search(tag, args, byUid) {
let keys = args.slice(); if (String(keys[0] || '').toUpperCase() === 'CHARSET') keys = keys.slice(2);
// text(m): 검색용 캐시. body 는 원본 바이트 그대로(7bit 로 읽은 것), decoded 는 base64·quoted-printable 과
// 문자 집합을 풀어 낸 본문(text + html). 우리가 만드는 원본(buildEml)은 본문을 base64 로 쓰므로, 원본 바이트만
// 보던 예전 BODY/TEXT 검색은 본문 단어를 찾지 못했습니다.
const parsedCache = new Map(); const text = async (m) => { if (!parsedCache.has(m.id)) { const p = await this.loadParsed(m); let decoded = ''; try { const s = summarize(p); decoded = (s.text + '\n' + s.html).toLowerCase(); } catch { /* 해석 실패 시 원본 바이트 검색만 */ } parsedCache.set(m.id, { p, body: decodeText(p.buffer, '7bit').toLowerCase(), decoded }); } return parsedCache.get(m.id); };
const hdr = async (m, name) => header((await text(m)).p.root.headers, name) || '';
const dateOf = async (m, sent) => sent ? Date.parse(await hdr(m, 'date')) || Date.parse(m.date) : Date.parse(m.date);
const day = (t) => Math.floor(t / 86400000);
const evaluate = async (m, idx, list) => {
const next = () => list.shift(); const item = next();
// 괄호 묶음 "(FROM a SUBJECT b)" 은 토큰화에서 배열이 됩니다. OR/NOT 뒤에 오면 여기로 들어오므로 묶음 전체를
// AND 로 평가합니다. 예전에는 배열을 문자열 "FROM,a" 로 바꿔 default(참)로 빠져, OR (…) (…) 가 모두 참이 됐습니다.
if (Array.isArray(item)) return evalAll(m, idx, item);
const key = String(item ?? '').toUpperCase();
const has = (f) => this.flagList(m).includes(f);
switch (key) {
case 'ALL': return true; case 'ANSWERED': return has('\\Answered'); case 'DELETED': return has('\\Deleted'); case 'DRAFT': return has('\\Draft'); case 'FLAGGED': return has('\\Flagged'); case 'SEEN': return m.seen;
case 'UNANSWERED': return !has('\\Answered'); case 'UNDELETED': return !has('\\Deleted'); case 'UNDRAFT': return !has('\\Draft'); case 'UNFLAGGED': return !has('\\Flagged'); case 'UNSEEN': return !m.seen;
case 'NEW': case 'RECENT': return false; case 'OLD': return true; case 'KEYWORD': next(); return false; case 'UNKEYWORD': next(); return true;
case 'UID': return resolveSet(next(), this.messages, true).includes(idx);
case 'LARGER': return (await this.loadParsed(m)).size > Number(next()); case 'SMALLER': return (await this.loadParsed(m)).size < Number(next());
case 'SINCE': return day(await dateOf(m)) >= day(parseImapDate(next())); case 'BEFORE': return day(await dateOf(m)) < day(parseImapDate(next())); case 'ON': return day(await dateOf(m)) === day(parseImapDate(next()));
case 'SENTSINCE': return day(await dateOf(m, true)) >= day(parseImapDate(next())); case 'SENTBEFORE': return day(await dateOf(m, true)) < day(parseImapDate(next())); case 'SENTON': return day(await dateOf(m, true)) === day(parseImapDate(next()));
case 'FROM': case 'TO': case 'CC': case 'BCC': case 'SUBJECT': { const v = String(next() ?? '').toLowerCase(); return decodeWords(await hdr(m, key)).toLowerCase().includes(v); }
case 'HEADER': { const name = String(next() ?? ''); const v = String(next() ?? '').toLowerCase(); return decodeWords(await hdr(m, name)).toLowerCase().includes(v); }
case 'BODY': case 'TEXT': { const v = String(next() ?? '').toLowerCase(); const t = await text(m); return t.body.includes(v) || t.decoded.includes(v) || decodeWords(t.p.text).toLowerCase().includes(v); }
case 'NOT': return !(await evaluate(m, idx, list));
case 'OR': { const a = await evaluate(m, idx, list); const b = await evaluate(m, idx, list); return a || b; }
default:
if (/^[\d*:,]+$/.test(key)) return resolveSet(key, this.messages, false).includes(idx);
return true;
}
};
const evalAll = async (m, idx, list) => { const copy = list.slice(); let ok = true; while (copy.length) { const item = copy[0]; if (Array.isArray(item)) { copy.shift(); if (!(await evalAll(m, idx, item))) ok = false; } else if (!(await evaluate(m, idx, copy))) ok = false; } return ok; };
const hits = [];
for (let i = 0; i < this.messages.length; i++) if (await evalAll(this.messages[i], i, keys)) hits.push(byUid ? this.messages[i].uid : i + 1);
this.send(`* SEARCH${hits.length ? ' ' + hits.join(' ') : ''}`); this.send(`${tag} OK SEARCH completed`);
}
async copy(tag, args, byUid, move) {
const indexes = resolveSet(args[0], this.messages, byUid); const folder = folderOf(args[1]);
if (!folder) return this.send(`${tag} NO [TRYCREATE] no such mailbox`);
if (move && this.readOnly) return this.send(`${tag} NO mailbox is read-only`);
// Drafts 에는 초안(발신·draft 상태)만 둘 수 있습니다. 받은 메일을 Drafts 로 옮기면 웹 메일이 그것을 초안으로 열어 편집·발송을 시도하다
// mail_locked 로 막혀 열 수 없는 메일이 됐습니다(0.8.24). 초안이 아닌 메일은 NO [CANNOT] 로 거부합니다.
if (folder === 'drafts' && indexes.some((idx) => !['draft', 'failed'].includes(this.messages[idx].state))) return this.send(`${tag} NO [CANNOT] only drafts can be put in Drafts`);
const validity = await this.uidValidity(); const src = [], dst = [];
for (const idx of indexes) {
const m = this.messages[idx];
if (move) {
if (folder !== this.mailbox) await this.env.DB.prepare('UPDATE messages SET previous_folder=folder,folder=?,updated_at=? WHERE id=?').bind(folder, now(), m.id).run();
await this.env.DB.prepare('INSERT OR IGNORE INTO imap_uids(message_id,folder) VALUES(?,?)').bind(m.id, folder).run();
const row = await this.env.DB.prepare('SELECT id FROM imap_uids WHERE message_id=? AND folder=?').bind(m.id, folder).first(); src.push(m.uid); dst.push(row.id);
} else {
const newId = await copyMessage(this.env, m.id, folder);
await this.env.DB.prepare('INSERT OR IGNORE INTO imap_uids(message_id,folder) VALUES(?,?)').bind(newId, folder).run();
const row = await this.env.DB.prepare('SELECT id FROM imap_uids WHERE message_id=? AND folder=?').bind(newId, folder).first(); src.push(m.uid); dst.push(row.id);
}
}
if (src.length) this.send(`* OK [COPYUID ${validity} ${src.join(',')} ${dst.join(',')}] ${move ? 'moved' : 'copied'}`);
if (move && folder !== this.mailbox) for (const idx of indexes.slice().reverse()) { this.messages.splice(idx, 1); this.send(`* ${idx + 1} EXPUNGE`); }
this.send(`${tag} OK ${move ? 'MOVE' : 'COPY'} completed`);
}
async expunge(report, onlyIndexes = null) {
const targets = this.messages.map((m, i) => ({ m, i })).filter(({ m, i }) => m.flags.includes('\\Deleted') && (!onlyIndexes || onlyIndexes.includes(i)));
for (const { m } of targets) {
// 휴지통에서 지우면 영구 삭제. 임시 보관함(Drafts)의 초안도 바로 지웁니다: Thunderbird·Apple Mail 은 초안을 자동 저장할 때마다
// 새 버전을 APPEND 하고 옛 버전에 \Deleted 를 붙여 EXPUNGE 하므로, 예전처럼 휴지통으로 옮기면 저장할 때마다 옛 초안이 휴지통에 쌓였습니다(0.8.16 수정).
// 초안은 발송 서비스에서 온 메일이 아니라 tombstone 이 필요 없습니다.
if (this.mailbox === 'trash' || this.mailbox === 'drafts') {
const row = await this.env.DB.prepare('SELECT provider_id FROM messages WHERE id=?').bind(m.id).first();
const statements = []; if (row?.provider_id) statements.push(this.env.DB.prepare("INSERT OR IGNORE INTO tombstones(provider_id,deleted_at,reason) VALUES(?,?,'deleted')").bind(row.provider_id, now()));
statements.push(this.env.DB.prepare('DELETE FROM messages WHERE id=?').bind(m.id)); await this.env.DB.batch(statements);
} else {
await this.env.DB.batch([
this.env.DB.prepare("UPDATE messages SET previous_folder=folder,folder='trash',updated_at=? WHERE id=?").bind(now(), m.id),
this.env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?) ON CONFLICT(message_id) DO UPDATE SET flags=excluded.flags,updated_at=excluded.updated_at').bind(m.id, JSON.stringify(m.flags.filter((f) => f !== '\\Deleted')), now()),
]);
}
}
for (const { i } of targets.slice().reverse()) { this.messages.splice(i, 1); if (report) this.send(`* ${i + 1} EXPUNGE`); }
}
/**
* APPEND: 클라이언트가 올린 메일 원본을 메일함에 저장합니다(보낸 메일 사본·초안 등).
* @param {string} tag 명령 태그
* @param {Array} args 토큰화된 인자(메일함, [플래그 목록], [날짜], 원본 리터럴)
* @param {Buffer[]} literals 회선에서 받은 리터럴 원본 바이트. 메일 원본은 마지막 리터럴입니다.
* 왜 literals 를 따로 받는가: 토큰화는 리터럴을 UTF-8 문자열로 바꾸는데, 8bit EUC-KR 본문이나 이진 부분처럼
* UTF-8 이 아닌 바이트는 이 과정에서 U+FFFD 로 깨집니다. 원본은 받은 바이트 그대로 저장해야 합니다.
*/
async append(tag, args, literals = []) {
const folder = folderOf(args[0]); if (!folder) return this.send(`${tag} NO [TRYCREATE] no such mailbox`);
let i = 1; let flags = []; let date = null;
if (Array.isArray(args[i])) { flags = args[i].map(String); i++; }
if (typeof args[i] === 'string' && /^\s?\d{1,2}-[A-Za-z]{3}-\d{4}/.test(args[i])) { date = args[i]; i++; }
const literal = args[i]; if (typeof literal !== 'string' || !literal.length) return this.send(`${tag} BAD APPEND needs message literal`);
const bytes = literals.length ? literals[literals.length - 1] : Buffer.from(literal, 'utf8');
// 메일함 최대 크기(0.9.0)를 넘으면 RFC 9208 의 OVERQUOTA 응답 코드로 거부합니다(메일 프로그램이 "용량 초과" 로 보여 줌).
let result;
try { result = await appendMessage(this.env, folder, bytes, { flags, internalDate: date && parseImapDateTime(date) ? new Date(parseImapDateTime(date)).toISOString() : null, mailbox: this.user.mailbox }); }
catch (e) { if (e.message === 'quota_exceeded') return this.send(`${tag} NO [OVERQUOTA] mailbox quota exceeded`); if (e.message === 'storage_full') return this.send(`${tag} NO [OVERQUOTA] server storage is full`); throw e; }
await this.env.DB.prepare('INSERT OR IGNORE INTO imap_uids(message_id,folder) VALUES(?,?)').bind(result.id, folder).run();
const row = await this.env.DB.prepare('SELECT id FROM imap_uids WHERE message_id=? AND folder=?').bind(result.id, folder).first();
if (this.mailbox === folder) await this.refresh();
this.send(`${tag} OK [APPENDUID ${await this.uidValidity()} ${row.id}] APPEND completed`);
}
startIdle(tag) {
this.send('+ idling'); this.idle = { tag, timer: setInterval(() => { this.queue = this.queue.then(() => this.refresh({ expunge: true })).catch(() => {}); }, Number(this.env.IMAP_IDLE_POLL_MS || 10000)) };
this.socket.setTimeout(35 * 60 * 1000);
}
stopIdle() { if (this.idle) { clearInterval(this.idle.timer); this.idle = null; } }
}
/** @param {{key:string|Buffer,cert:string|Buffer}|null} tlsOptions */
/** allowPlaintext: true 항상 허용 / false 항상 거부 / null(기본) 접속마다 관리자 설정 "평문 로그인 허용" 을 읽음(0.9.7, pop3.mjs 와 같음). */
/** TLS 접속의 SNI 서버 이름(평문 접속이면 ''). 1.0.7 다중 도메인에서 도메인 없는 로그인 ID 의 도메인을 짐작하는 데 씁니다. */
const sniName = (socket) => String(socket?.servername || '');
/** resolveEnv(login, servername)(1.0.7): 로그인마다 쓸 env 를 고르는 함수(domains.mjs resolveLogin). 없으면 env 하나로 동작. */
export function createImap(env, { hostname = 'mail.arihakdang.com', tlsOptions = null, allowPlaintext = null, resolveEnv = null } = {}) {
const shared = { env, hostname, secureContext: tlsOptions ? tls.createSecureContext(tlsOptions) : null, allowPlaintext, resolveEnv };
const plain = net.createServer((socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: false }); });
// secure 서버는 인증서가 없어도 만들어 두고, 인증서가 없으면 포트를 기억만 합니다(pop3.mjs 와 같은 구조, 0.9.7).
const secure = tls.createServer({ ...(tlsOptions || {}), minVersion: 'TLSv1.2' }, (socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: true }); });
secure.on('tlsClientError', () => {});
let pendingSecure = null;
const listenOn = (server, port, host, label, out) => new Promise((res, rej) => server.once('error', rej).listen(port, host, () => { out.push(`${label} ${host}:${port}`); res(); }));
return {
plain, secure,
setTls(options) {
shared.secureContext = tls.createSecureContext(options); secure.setSecureContext(options);
if (pendingSecure && !secure.listening) { const { port, host } = pendingSecure; pendingSecure = null; listenOn(secure, port, host, 'imaps', []).then(() => console.log(` IMAP TLS 포트를 열었습니다: ${host}:${port}`)).catch((e) => console.error(' IMAP TLS 포트 열기 실패:', e.message)); }
},
async listen({ plainPort, securePort, host = '0.0.0.0' }) {
const out = [];
if (plainPort) await listenOn(plain, plainPort, host, 'imap', out);
if (securePort) { if (shared.secureContext) await listenOn(secure, securePort, host, 'imaps', out); else pendingSecure = { port: securePort, host }; }
return out;
},
close() { plain.close(); if (secure.listening) secure.close(); },
};
}
+17
View File
@@ -0,0 +1,17 @@
/**
* AriMail 진입점(1.0.5 부터 부트스트랩). systemd 의 ExecStart(`node server/index.mjs`)와 `npm start` 가 이 파일을 실행하고, 실제 서버는 main.mjs 입니다.
*
* 왜 두 파일로 나눴나
* Node 22 는 node:sqlite 를 처음 쓸 때 "ExperimentalWarning: SQLite is an experimental feature" 두 줄을 stderr 로 내보냅니다. 운영 로그(journal)에는
* 재시작마다 이 경고가 남아 오류 수집(1.0.5 요구사항 0) 때 잡음이 됐습니다. 경고 필터는 node:sqlite 를 import 하는 모듈이 평가되기 **전에** 걸어야 하는데,
* ESM 의 정적 import 는 모듈 본문보다 먼저 실행되므로 main.mjs 안에서는 늦습니다. 그래서 여기서 먼저 필터를 걸고 main.mjs 를 동적으로 불러옵니다.
* (ExecStart 에 --disable-warning 을 더하는 방법은 systemd 유닛을 다시 설치해야 해서 피했습니다: 운영 서버의 환경·유닛 파일은 바꾸지 않습니다.)
*
* 필터 규칙: ExperimentalWarning 중 SQLite 에 관한 것만 삼키고, 그 밖의 경고(Deprecation, MaxListenersExceeded 등)는 그대로 기록합니다 — 그것들은 진짜 문제의 신호입니다.
*/
process.removeAllListeners('warning');
process.on('warning', (warning) => {
if (warning?.name === 'ExperimentalWarning' && /sqlite/i.test(String(warning.message))) return;
console.warn(`(node:${process.pid}) ${warning.name}: ${warning.message}${warning.stack && process.env.NODE_DEBUG ? '\n' + warning.stack : ''}`);
});
await import('./main.mjs');
+296
View File
@@ -0,0 +1,296 @@
/**
* 시스템 제한·관리자 제한·사용량·현황 (0.9.0~).
*
* 세 층의 제한이 있습니다. 위 층이 아래 층의 상한이며, 어느 층이든 0 은 "그 층에서는 제한하지 않음" 입니다.
* 1) 시스템 설정(systemLimits): 설치 시 환경 파일(gateway.env)에 적는 값. 메일 시스템 전체의 상위 설정이라 관리자 화면에서 바꿀 수 없습니다.
* LIMIT_ACCOUNTS 허용 가능한 메일 계정 수(mail_users 행 수)
* LIMIT_TOTAL_BYTES 모든 메일함을 합친 최대 저장 용량(MIME 기준)
* LIMIT_MAILBOX_BYTES 메일함 하나의 최대 용량(MIME 기준)
* LIMIT_ATTACHMENT_BYTES 첨부 파일 한 개의 최대 크기(MIME 기준, 아래 mimeSize 참고)
* LIMIT_RECIPIENTS 한 메일의 최대 받는 사람 수(받는 사람 + 참조 + 숨은 참조)
* LIMIT_MONTHLY_MESSAGES 한 달(매월 1일~말일)의 최대 수발신 통수(보낸 메일 + 받은 메일)
* 크기 값은 바이트 수 또는 K / M / G 접미사(예: 500M, 2G)로 적습니다.
* 2) 관리자 설정(readAdminLimits): 관리자 페이지에서 정하는 값(settings 표의 limits 키). 시스템 제한이 있으면 그 값 이내로만 저장됩니다.
* mailboxBytes(계정별 기본 메일함 크기) · attachmentBytes(첨부 최대 크기) · recipients(동시 수신자 수)
* 3) 계정별 설정(readUserQuota): 관리자가 계정마다 정하는 메일함 최대 크기(settings 표의 quota:<계정 id> 키). 역시 시스템 제한 이내.
*
* 왜 "MIME 기준" 인가: 메일은 저장·전송될 때 첨부가 base64 로 바뀌어 약 4/3 배(37%)로 늘어납니다. 발송 서비스(Resend)의 한도와 저장 공간은
* 이 늘어난 크기에 맞춰 소진되므로, 모든 크기 제한과 사용량을 원본(.eml)의 바이트, 즉 MIME 변환 뒤의 크기로 셉니다.
* 화면은 이 점을 안내하고, 첨부 파일을 올릴 때는 파일 크기를 mimeSize() 로 바꿔 비교합니다.
*
* 그 밖에 이 모듈이 맡는 것: POP/IMAP 서비스 전체 켜기·끄기(services 키), 회원 가입 설정(signup 키), 서버 상태 화면의 메일 현황(stats).
* auth.mjs 가 이 모듈을 들여오므로 여기서는 auth.mjs 를 들여오지 않습니다(순환 방지). 메일함 주소·계정 id 는 인자로 받습니다.
*/
import { now } from './util.mjs';
import { isWebmaker, webmakerByMailbox } from './webmaker.mjs';
// ------------------------------------------------------------------ 상수
/** 어느 층에서도 제한하지 않았을 때 적용되는 내장 상한. 발송 서비스(Resend)의 한도에서 나온 값이라 넘길 수 없습니다. */
export const HARD_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 첨부 한 개(MIME 기준). Resend 는 메일 전체 40MB 까지 받습니다.
export const HARD_RECIPIENTS = 50; // Resend 의 받는 사람 한도
/** base64 로 바뀐 뒤의 크기(줄바꿈 제외). 첨부 파일의 원본 바이트를 MIME 기준 크기로 바꿀 때 씁니다. */
export const mimeSize = (bytes) => Math.ceil(Number(bytes || 0) / 3) * 4;
/** 0 이 아닌 값들 중 가장 작은 값. 모두 0(제한 없음)이면 0. 여러 층의 제한을 합칠 때 씁니다. */
export const minNonZero = (...values) => { const positive = values.map((v) => Number(v) || 0).filter((v) => v > 0); return positive.length ? Math.min(...positive) : 0; };
/** 사용 비율(%). 제한이 0(없음)이면 null. 화면이 "8개 사용 중 / 최대 10개 (80%)" 를 그리는 데 씁니다. */
export const percentOf = (used, limit) => (limit > 0 ? Math.min(999, Math.round((Number(used || 0) / limit) * 100)) : null);
/**
* 크기 문자열을 바이트 수로. "500M", "2G", "1024K", "123456" 을 받습니다(소수 허용: 1.5G). 비어 있거나 해석할 수 없으면 0(제한 없음).
* 환경 파일에 사람이 적는 값이므로 대소문자·공백·"B" 접미사(MB, GiB)는 너그럽게 받습니다.
*/
export function parseSize(value) {
const s = String(value ?? '').trim().toUpperCase().replace(/I?B$/, '');
const m = s.match(/^(\d+(?:\.\d+)?)\s*([KMGT]?)$/); if (!m) return 0;
const unit = { '': 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }[m[2]];
return Math.floor(Number(m[1]) * unit);
}
const nonNegInt = (v) => { const n = Number(v); return Number.isInteger(n) && n >= 0 ? n : 0; };
/** 시스템 설정(환경 변수)을 읽습니다. 모든 값은 0 이면 제한 없음. */
export function systemLimits(env) {
return {
accounts: nonNegInt(env.LIMIT_ACCOUNTS), totalBytes: parseSize(env.LIMIT_TOTAL_BYTES), mailboxBytes: parseSize(env.LIMIT_MAILBOX_BYTES),
attachmentBytes: parseSize(env.LIMIT_ATTACHMENT_BYTES), recipients: nonNegInt(env.LIMIT_RECIPIENTS), monthlyMessages: nonNegInt(env.LIMIT_MONTHLY_MESSAGES),
};
}
// ------------------------------------------------------------------ settings 표 읽기·쓰기
async function readJson(env, key, defaults) {
const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind(key).first();
let stored = {}; try { stored = row ? JSON.parse(row.value) : {}; } catch { stored = {}; }
return { ...defaults, ...(stored && typeof stored === 'object' ? stored : {}) };
}
async function writeJson(env, key, value) {
await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind(key, JSON.stringify(value), now()).run();
}
/** 제한 값 하나를 검사합니다: 0 이상의 정수여야 하고(아니면 limit_invalid), 시스템 제한이 있으면 그 이내여야 합니다(limit_exceeds_system). 오류에 field 를 붙입니다. */
function checkAgainstSystem(value, systemValue, field) {
// 숫자(또는 숫자 문자열)만 받습니다. 예전에는 true 가 1 로 저장돼 "받는 사람 1명" 같은 뜻밖의 제한이 생겼습니다(0.9.3).
const n = typeof value === 'number' || (typeof value === 'string' && value.trim() !== '') ? Number(value) : NaN;
if (!Number.isInteger(n) || n < 0) { const e = new Error('limit_invalid'); e.field = field; throw e; }
if (systemValue > 0 && n > systemValue) { const e = new Error('limit_exceeds_system'); e.field = field; e.limit = systemValue; throw e; }
return n;
}
export const DEFAULT_ADMIN_LIMITS = { mailboxBytes: 0, attachmentBytes: 0, recipients: 0 };
/** 관리자 제한(settings 의 limits). 저장된 값이 손상됐거나 시스템 제한을 넘으면(설치 뒤 시스템 값이 줄어든 경우) 시스템 값으로 깎아 돌려줍니다. */
export async function readAdminLimits(env) {
const stored = await readJson(env, 'limits', DEFAULT_ADMIN_LIMITS), sys = systemLimits(env), out = {};
for (const k of Object.keys(DEFAULT_ADMIN_LIMITS)) { const v = nonNegInt(stored[k]); const cap = sys[k]; out[k] = cap > 0 && v > cap ? cap : v; }
return out;
}
/** 관리자 제한을 저장합니다(관리자 페이지). 각 값은 시스템 제한 이내여야 하며 0 은 "시스템 값을 따름" 입니다. */
export async function writeAdminLimits(env, patch) {
const current = await readAdminLimits(env), sys = systemLimits(env), next = { ...current };
// 내장 상한(첨부 25MB, 받는 사람 50)도 시스템 값처럼 넘길 수 없습니다. 예전에는 그보다 큰 값이 저장돼 관리자 화면과 실제 적용값이 달랐습니다(0.9.3).
const hard = { attachmentBytes: HARD_ATTACHMENT_BYTES, recipients: HARD_RECIPIENTS };
for (const k of Object.keys(DEFAULT_ADMIN_LIMITS)) if (patch[k] !== undefined) next[k] = checkAgainstSystem(patch[k], minNonZero(sys[k], hard[k] || 0), k);
await writeJson(env, 'limits', next);
return next;
}
/** 계정별 메일함 최대 크기(바이트, 0 = 관리자 기본값·시스템 값을 따름). */
export async function readUserQuota(env, userId) {
const row = await env.DB.prepare('SELECT value FROM settings WHERE key=?').bind('quota:' + userId).first();
return row ? nonNegInt(row.value) : 0;
}
/** 계정별 메일함 크기 값만 검사합니다(저장 전 검사용). 돌려주는 값은 정수 바이트. */
export function checkUserQuota(env, bytes) { return checkAgainstSystem(bytes, systemLimits(env).mailboxBytes, 'mailboxBytes'); }
/** 계정별 메일함 최대 크기를 저장합니다. 시스템의 메일함 제한을 넘을 수 없습니다. 0 이면 설정을 지웁니다. */
export async function writeUserQuota(env, userId, bytes) {
const n = checkUserQuota(env, bytes);
if (n === 0) await env.DB.prepare('DELETE FROM settings WHERE key=?').bind('quota:' + userId).run();
else await env.DB.prepare('INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at').bind('quota:' + userId, String(n), now()).run();
return n;
}
/** deleteUser 가 부릅니다: 계정의 제한 설정을 지웁니다. */
export async function clearUserQuota(env, userId) { await env.DB.prepare('DELETE FROM settings WHERE key=?').bind('quota:' + userId).run(); }
// ------------------------------------------------------------------ 실제로 적용되는 값(세 층 합산)
/**
* 메일함 주소에 적용되는 최대 크기(바이트, 0 = 제한 없음): 계정별 설정 → 관리자 기본값 → 시스템 제한 중 가장 작은 0 아닌 값.
* 기본 메일함(www 관리자가 쓰는 경우)은 계정 행이 없으므로 관리자 기본값·시스템 제한만 적용됩니다.
*/
export async function mailboxQuota(env, mailbox) {
const row = await env.DB.prepare('SELECT id FROM mail_users WHERE mailbox=? ORDER BY created_at LIMIT 1').bind(String(mailbox || '').toLowerCase()).first();
// 웹메이커 계정(1.0.8)은 mail_users 행이 없으므로 그 주체 id('wm:…')로 계정별 설정을 찾습니다.
const ownerId = row ? row.id : isWebmaker(env) ? (await webmakerByMailbox(env, mailbox))?.id : null;
const user = ownerId ? await readUserQuota(env, ownerId) : 0, admin = await readAdminLimits(env), sys = systemLimits(env);
return minNonZero(user, admin.mailboxBytes, sys.mailboxBytes);
}
/** 첨부 한 개의 최대 크기(MIME 기준 바이트). 관리자·시스템 값이 없어도 내장 상한(25MB)은 항상 적용됩니다. */
export async function attachmentLimit(env) { const admin = await readAdminLimits(env); return minNonZero(admin.attachmentBytes, systemLimits(env).attachmentBytes, HARD_ATTACHMENT_BYTES); }
/** 한 메일의 최대 받는 사람 수. 내장 상한(50)은 항상 적용됩니다. */
export async function recipientLimit(env) { const admin = await readAdminLimits(env); return minNonZero(admin.recipients, systemLimits(env).recipients, HARD_RECIPIENTS); }
// ------------------------------------------------------------------ 사용량
/**
* 메일함 사용량(바이트). 저장된 원본(.eml, raw_size)이 곧 MIME 기준 크기입니다. 원본이 아직 없는 메일(작성 중인 초안)은 첨부 크기를 대신 더합니다.
* 왜 raw_size 인가: 첨부가 base64 로 늘어난 뒤의 크기가 실제로 저장·전송되는 크기이고, 요구사항도 "MIME 변환시 늘어나는 크기를 감안" 하라고 합니다.
*/
export async function mailboxUsage(env, mailbox) {
const row = await env.DB.prepare(`SELECT COALESCE(SUM(raw_size),0) + COALESCE((SELECT SUM(a.size) FROM attachments a JOIN messages d ON d.id=a.message_id WHERE d.mailbox=? AND d.raw_status='none'),0) AS bytes FROM messages WHERE mailbox=?`).bind(mailbox, mailbox).first();
return Number(row?.bytes || 0);
}
/** 모든 메일함을 합친 사용량(바이트). */
export async function totalUsage(env) {
const row = await env.DB.prepare(`SELECT COALESCE(SUM(raw_size),0) + COALESCE((SELECT SUM(a.size) FROM attachments a JOIN messages d ON d.id=a.message_id WHERE d.raw_status='none'),0) AS bytes FROM messages`).first();
return Number(row?.bytes || 0);
}
/** 게이트웨이 계정 수(mail_users 행 수, 정지·승인 대기 포함). 계정 수 제한과 비교합니다. */
export async function accountCount(env) { return Number((await env.DB.prepare('SELECT COUNT(*) AS n FROM mail_users').first())?.n || 0); }
// ------------------------------------------------------------------ 기간(일·주·월) — 통계 시간대 기준
/** 통계 시간대. 서버의 시간대가 UTC 여도 "오늘" 은 한국 기준이어야 하므로 기본값은 Asia/Seoul 이고 STATS_TIMEZONE 으로 바꿀 수 있습니다. */
export function statsTimezone(env) {
const tz = String(env.STATS_TIMEZONE || 'Asia/Seoul').trim();
try { new Intl.DateTimeFormat('en-CA', { timeZone: tz }); return tz; } catch { return 'Asia/Seoul'; }
}
/** 시각을 주어진 시간대의 연·월·일·시·분·초로 풉니다. */
function tzParts(date, tz) {
const f = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' });
const p = Object.fromEntries(f.formatToParts(date).map((x) => [x.type, x.value]));
return { y: +p.year, m: +p.month, d: +p.day, h: +p.hour % 24, mi: +p.minute, s: +p.second };
}
/** 그 시각에 적용되는 시간대 오프셋(ms, UTC 보다 앞서면 양수). */
function tzOffset(date, tz) { const p = tzParts(date, tz); return Date.UTC(p.y, p.m - 1, p.d, p.h, p.mi, p.s) - Math.floor(date.getTime() / 1000) * 1000; }
/** 시간대 기준 어느 날 0시의 순간(ms). 월·일이 범위를 넘어도(13월, 32일) Date.UTC 가 넘겨 계산합니다. 서머타임 경계는 두 번째 보정으로 맞춥니다. */
function localMidnight(y, m, d, tz) { const base = Date.UTC(y, m - 1, d); let t = base - tzOffset(new Date(base), tz); const off = tzOffset(new Date(t), tz); if (base - off !== t) t = base - off; return t; }
/**
* 통계 기간의 경계(ISO 문자열): 'day' 오늘 0시~내일 0시, 'week' 이번 주 월요일 0시~다음 월요일 0시, 'month' 이달 1일 0시~다음 달 1일 0시
* (요구사항: 월간은 매월 첫째 날부터 마지막 날까지). start 이상 end 미만이 그 기간입니다.
* @param {'day'|'week'|'month'} kind
* @param {Date} [at] 기준 시각(테스트용). 기본은 지금.
*/
export function periodBounds(kind, tz = 'Asia/Seoul', at = new Date()) {
const p = tzParts(at, tz); let start, end;
if (kind === 'day') { start = localMidnight(p.y, p.m, p.d, tz); end = localMidnight(p.y, p.m, p.d + 1, tz); }
else if (kind === 'week') { const dow = new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay(); const monday = p.d - ((dow + 6) % 7); start = localMidnight(p.y, p.m, monday, tz); end = localMidnight(p.y, p.m, monday + 7, tz); }
else { start = localMidnight(p.y, p.m, 1, tz); end = localMidnight(p.y, p.m + 1, 1, tz); }
return { start: new Date(start).toISOString(), end: new Date(end).toISOString() };
}
/**
* 기간 안의 보낸 메일·받은 메일 수. 보낸 메일은 발송이 끝난 메일(state='sent')의 sent_at, 받은 메일은 received_at 기준이며 폴더(휴지통 포함)는 가리지 않습니다.
* 영구 삭제한 메일은 세지 않습니다(행이 없음). mailbox 를 주면 그 메일함만.
*/
export async function countMail(env, { start, end }, mailbox = null) {
const scope = mailbox ? ' AND mailbox=?' : '', extra = mailbox ? [mailbox] : [];
const sent = await env.DB.prepare(`SELECT COUNT(*) AS n FROM messages WHERE direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?${scope}`).bind(start, end, ...extra).first();
const received = await env.DB.prepare(`SELECT COUNT(*) AS n FROM messages WHERE direction='inbound' AND received_at>=? AND received_at<?${scope}`).bind(start, end, ...extra).first();
const s = Number(sent?.n || 0), r = Number(received?.n || 0);
return { sent: s, received: r, total: s + r, start, end };
}
/** 이달의 수발신 통수(월간 제한과 비교). */
export async function monthlyCount(env) { return (await countMail(env, periodBounds('month', statsTimezone(env)))).total; }
// ------------------------------------------------------------------ 제한 검사(발송·수신·첨부)
/**
* 발송 전 검사. 어긋나면 오류 코드를 던지며, 오류 객체에 limit(적용된 상한)을 붙여 화면·SMTP 응답이 구체적으로 안내합니다.
* too_many_recipients 받는 사람(참조·숨은 참조 포함)이 상한을 넘음
* attachment_too_large 첨부 한 개가 상한(MIME 기준)을 넘음
* quota_exceeded 보낸 메일을 저장하면 메일함 최대 크기를 넘음
* storage_full 전체 저장 용량(시스템)을 넘음
* monthly_limit_exceeded 이달 수발신 통수가 시스템 제한에 이름
* @param {object} p
* @param {string} p.mailbox 보내는 메일함(보낸 메일이 저장될 곳)
* @param {number} p.recipients 받는 사람 수
* @param {number[]} [p.attachmentSizes] 첨부별 원본 바이트(여기서 MIME 크기로 바꿔 비교)
* @param {number} [p.bytes] 저장될 원본 크기 추정(바이트, 메일함·전체 용량 검사용)
*/
export async function checkOutbound(env, { mailbox, recipients = 0, attachmentSizes = [], bytes = 0 }) {
const maxRcpt = await recipientLimit(env);
if (maxRcpt > 0 && recipients > maxRcpt) { const e = new Error('too_many_recipients'); e.limit = maxRcpt; throw e; }
const maxAttach = await attachmentLimit(env);
for (const size of attachmentSizes) if (mimeSize(size) > maxAttach) { const e = new Error('attachment_too_large'); e.limit = maxAttach; throw e; }
const sys = systemLimits(env);
if (sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages) { const e = new Error('monthly_limit_exceeded'); e.limit = sys.monthlyMessages; throw e; }
const quota = await mailboxQuota(env, mailbox);
if (quota > 0 && (await mailboxUsage(env, mailbox)) + bytes > quota) { const e = new Error('quota_exceeded'); e.limit = quota; throw e; }
if (sys.totalBytes > 0 && (await totalUsage(env)) + bytes > sys.totalBytes) { const e = new Error('storage_full'); e.limit = sys.totalBytes; throw e; }
}
/**
* 수신 저장 전 검사. 받은 메일은 크기를 미리 알 수 없으므로 "이미 가득 찼는지" 만 봅니다.
* @returns {Promise<null|'quota_exceeded'|'storage_full'>} 저장할 수 없으면 사유. 동기화는 공간이 생기면 같은 메일을 다시 가져옵니다.
*/
export async function checkInbound(env, mailbox) {
const quota = await mailboxQuota(env, mailbox);
if (quota > 0 && (await mailboxUsage(env, mailbox)) >= quota) return 'quota_exceeded';
const sys = systemLimits(env);
if (sys.totalBytes > 0 && (await totalUsage(env)) >= sys.totalBytes) return 'storage_full';
return null;
}
/** 자동 전달·자동 응답처럼 메일 한 통당 발송이 생기는 작업 전에 월간 통수 제한만 확인합니다. 넘었으면 'limit'. */
export async function monthlyBlocked(env) { const sys = systemLimits(env); return sys.monthlyMessages > 0 && (await monthlyCount(env)) >= sys.monthlyMessages; }
// ------------------------------------------------------------------ POP/IMAP 서비스 전체 켜기·끄기, 회원 가입 설정
/**
* 서비스 전체 스위치(settings 의 services).
* pop / imap : 끄면 계정 설정과 무관하게 모든 POP(또는 IMAP) 로그인을 거부합니다(0.9.0).
* plainLogin : 평문 포트(POP3 110, IMAP 143)에서 STLS/STARTTLS 없이 비밀번호 로그인을 받을지(0.9.7). 기본 허용.
* Gmail 의 "다른 계정에서 메일 확인" 은 110 포트에서 STLS 를 쓰지 않고 바로 USER/PASS 를 보내므로,
* 이 값이 꺼져 있으면 Gmail 은 "사용자 이름과 비밀번호가 일치하지 않음" 으로 보여 줍니다. 비밀번호가 암호화되지 않고
* 지나가므로 보안을 우선하면 끄고 995/993(또는 STARTTLS 를 지원하는 메일 프로그램)만 쓰게 합니다.
*/
export const DEFAULT_SERVICES = { pop: true, imap: true, plainLogin: true };
export async function readServices(env) { const s = await readJson(env, 'services', DEFAULT_SERVICES); return { pop: s.pop !== false, imap: s.imap !== false, plainLogin: s.plainLogin !== false }; }
export async function writeServices(env, patch) {
const next = await readServices(env);
for (const k of ['pop', 'imap', 'plainLogin']) if (patch[k] !== undefined) next[k] = !!patch[k];
await writeJson(env, 'services', next); return next;
}
/** 메일 서버가 로그인 직후 부릅니다: 관리자가 그 서비스를 꺼 두었으면 false. */
export async function serviceEnabled(env, service) { return (await readServices(env))[service] !== false; }
/**
* 평문 포트(110/143)에서 TLS 없이 로그인을 받을지(0.9.7). POP3·IMAP 서버가 접속마다 한 번 읽어 CAPA 광고와 USER/PASS 허용에 씁니다.
* 환경 변수 POP_ALLOW_PLAINTEXT=1 은 관리자 설정과 무관하게 항상 허용(진단용, 예전 의미 유지)이고, 그 밖의 값(0, 빈 값)은 관리자 설정을 따릅니다.
* 0.9.5 까지는 이 변수가 0 이면 110 로그인을 항상 거부했는데, 설치 템플릿의 기본값이 0 이라 Gmail 110 접속이 모두 실패했습니다.
* 설정을 읽지 못하면(DB 오류) 안전한 쪽인 거부로 둡니다.
*/
export async function plainLoginAllowed(env) {
if (env.POP_ALLOW_PLAINTEXT === '1') return true;
try { return (await readServices(env)).plainLogin !== false; } catch { return false; }
}
export const DEFAULT_SIGNUP = { enabled: false, approval: true };
/**
* 회원 가입 설정(settings 의 signup).
* enabled : 로그인 화면에 "회원 가입" 을 보여 주고 POST /api/signup 을 받을지
* approval : true 면 가입한 계정을 관리자가 승인해야 쓸 수 있음(승인 대기), false 면 즉시 사용
*/
export async function readSignup(env) { const s = await readJson(env, 'signup', DEFAULT_SIGNUP); return { enabled: !!s.enabled, approval: s.approval !== false }; }
export async function writeSignup(env, patch) {
const next = await readSignup(env);
for (const k of ['enabled', 'approval']) if (patch[k] !== undefined) next[k] = !!patch[k];
await writeJson(env, 'signup', next); return next;
}
// ------------------------------------------------------------------ 서버 상태 화면의 메일 현황
/**
* 관리자 "서버 상태" 의 메일 현황(요구사항 8).
* periods.day/week/month : 기간별 발신·수신·수발신 통수와 기간 경계
* mailboxes : 메일함별 사용량(MIME 기준)과 적용 중인 최대 크기·비율
* usage.total/accounts/monthly : 전체 사용량·계정 수·이달 통수를 시스템 제한과 견준 값({ used, limit, percent }, limit 0 = 제한 없음)
* system / admin : 적용 중인 시스템 제한·관리자 제한 값(화면 안내용)
* @param {string[]} mailboxes 알려진 메일함 주소 목록(기본 메일함 + 계정 메일함). auth.knownMailboxes 결과를 넘깁니다.
* @param {object[]} users listUsers 결과(login·name·mailbox 로 메일함에 이름을 붙임)
*/
export async function mailStats(env, mailboxes, users = []) {
const tz = statsTimezone(env), sys = systemLimits(env), admin = await readAdminLimits(env);
const periods = {};
for (const kind of ['day', 'week', 'month']) periods[kind] = await countMail(env, periodBounds(kind, tz));
const boxes = [];
for (const mailbox of mailboxes) {
const used = await mailboxUsage(env, mailbox), quota = await mailboxQuota(env, mailbox), owner = users.find((u) => u.mailbox === mailbox);
const counts = await env.DB.prepare('SELECT COUNT(*) AS n FROM messages WHERE mailbox=?').bind(mailbox).first();
boxes.push({ mailbox, login: owner?.login || null, name: owner?.name || '', messages: Number(counts?.n || 0), bytes: used, limit: quota, percent: percentOf(used, quota) });
}
const total = await totalUsage(env), accounts = await accountCount(env), monthly = periods.month.total;
return {
timezone: tz, periods, mailboxes: boxes, system: sys, admin,
usage: {
total: { used: total, limit: sys.totalBytes, percent: percentOf(total, sys.totalBytes) },
accounts: { used: accounts, limit: sys.accounts, percent: percentOf(accounts, sys.accounts) },
monthly: { used: monthly, limit: sys.monthlyMessages, percent: percentOf(monthly, sys.monthlyMessages) },
},
};
}
+342
View File
@@ -0,0 +1,342 @@
/**
* AriMail 게이트웨이 본체(1.0.5 부터 index.mjs 는 경고 필터만 거는 부트스트랩이고 이 파일이 서버입니다; 1.0.0 부터 프로젝트 이름 AriMail — 저장소 https://source.ibit.kr/OZ-Group/AriMail).
* - HTTP: 웹 메일함 + API + Resend 웹훅 + Haraka 수신 전달(/api/inbound/haraka) (nginx 뒤, 127.0.0.1:8790)
* - 전체 설정 화면(1.0.7)·초기 설정 페이지: 전용 포트(SETUP_PORT, 기본 8791, 토큰 인증)와 /setup(관리자 세션) — 설정 파일(config.json)을 웹에서 채웁니다(1.0.0)
* - POP3: 110(STLS) / 995(TLS)
* - IMAP: 143(STARTTLS) / 993(TLS)
* - SMTP 제출: 587(STARTTLS) / 465(TLS) — 로그인 비밀번호로 인증하고 설정된 발송 모듈(Resend / 내장 Haraka / 외부 SMTP 중계)로 발송
* - 작업: 1시간마다 Resend 목록 동기화(Resend 를 쓰는 방향만), 만료 세션 정리
* (0.8.0 부터 메일 보관 기한이 없어 주기 작업이 메일을 지우지 않습니다.)
*
* 설정의 두 층(1.0.0): 환경 파일(gateway.env, systemd EnvironmentFile)에는 배포에 꼭 필요한 값(DB·포트·메일함·로그인 방식·시스템 제한)만 있고,
* 운영 설정(발송·수신 방식, Resend 키, 중계 서버, Haraka, 사이트 정보)은 config.json(config.mjs)에 있어 재시작 없이 바뀝니다.
*
* 다중 도메인(1.0.7, 요구사항 1): 한 프로세스가 여러 도메인을 섬깁니다(server/domains.mjs). 환경 파일의 도메인이 "기본 도메인" 이고, 전체 설정 화면에서
* 더한 도메인은 데이터 디렉터리의 domains/<도메인>/ 에 자기 DB·설정·파일을 갖습니다. HTTP 요청은 Host 헤더로, 메일 프로그램 로그인은 로그인 ID 의 @도메인으로
* 어느 도메인(테넌트 env)의 일인지 고릅니다. 모르는 호스트나 꺼 둔 도메인으로 온 웹 요청에는 "메일 서비스가 존재하지 않습니다/활성화되지 않았습니다" 화면을 줍니다.
*/
import { createServer } from 'node:http';
import { readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { openDb, d1, migrate } from './db.mjs';
import { handle } from './api.mjs';
import { createPop3 } from './pop3.mjs';
import { createImap } from './imap.mjs';
import { createSmtp } from './smtp.mjs';
import { syncMailbox, cleanupSessions } from './store.mjs';
import { ensureInitialAdmin, currentUser, authMode } from './auth.mjs';
import { uploadLargeFile, largeFileInfo, claimDownload, openLargeFile, finishDownload, cleanupLargeFiles, humanSize, formatExpiry, filesDir, systemLargeFiles } from './files.mjs';
import { escapeHtml } from './util.mjs';
import { memoryRateStore, siteName } from './util.mjs';
import { systemLimits } from './limits.mjs';
import { loadConfig, configFile, setupToken, mailDomain } from './config.mjs';
import { createSetupServer, setupPage, writeSetupUrls } from './setup.mjs';
import { inboundMode, outboundMode, usesResend, usesHaraka } from './providers/index.mjs';
import { autoVerifyDomain } from './providers/resend.mjs';
import { openTenants, noServicePage, normalizePrefix } from './domains.mjs';
import { isWebmaker } from './webmaker.mjs';
import { usageApi, clientIp } from './usage.mjs';
const HERE = dirname(fileURLToPath(import.meta.url)), ROOT = join(HERE, '..');
const PORT = Number(process.env.PORT || 8790), HOST = process.env.HOST || '127.0.0.1';
const DB_FILE = process.env.DB_FILE || join(ROOT, 'data', 'mail-gateway.sqlite');
const db = openDb(DB_FILE); console.log(` DB: ${DB_FILE}`); migrate(db, join(ROOT, 'migrations'));
const env = { ...process.env, DB: d1(db), RATE: memoryRateStore() };
// 운영 설정(1.0.0): config.json 을 읽어 env 에 덮어씁니다. 파일이 없으면(1.0.0 으로 처음 올린 서버) 환경 변수 값을 그대로 가져와 만듭니다.
loadConfig(env); console.log(` 설정 파일: ${configFile(env)}${env.CONFIG_CREATED ? ' (새로 만듦 — 환경 변수 값을 가져왔습니다)' : ''}`);
// 로그인 방식(0.9.0~, 설치 옵션): LOGIN_MODE=standalone(기본) 은 www 사이트와 연동하지 않는 독립 실행, LOGIN_MODE=www 는 www 사이트 DB 로 관리자를 인증하는 연동 실행,
// LOGIN_MODE=webmaker(1.0.8) 는 웹메이커의 사용자 파일(WEBMAKER_DIR, 선택: WEBMAKER_USER·WEBMAKER_SITE)을 읽기 전용으로 참조하는 연동 실행입니다(추가 도메인은 등록부의 auth 로 정함).
// 값이 없으면 예전처럼 WWW_DB_FILE 유무로 정합니다. standalone 인데 WWW_DB_FILE 이 남아 있으면 무시하고, www·webmaker 인데 경로가 없으면 시작하지 않습니다(설정 실수를 조용히 넘기지 않음).
const LOGIN_MODE = String(env.LOGIN_MODE || (env.WWW_DB_FILE ? 'www' : 'standalone')).trim().toLowerCase();
env.LOGIN_MODE = LOGIN_MODE;
if (LOGIN_MODE === 'standalone') { delete env.WWW_DB_FILE; delete env.WEBMAKER_DIR; }
else if (LOGIN_MODE === 'www') { delete env.WEBMAKER_DIR; if (!env.WWW_DB_FILE) { console.error(' ✖ LOGIN_MODE=www 인데 WWW_DB_FILE 이 없습니다 — www 사이트 DB 경로를 적거나 LOGIN_MODE=standalone 으로 바꾸세요'); process.exit(1); } }
else if (LOGIN_MODE === 'webmaker') { delete env.WWW_DB_FILE; if (!env.WEBMAKER_DIR) { console.error(' ✖ LOGIN_MODE=webmaker 인데 WEBMAKER_DIR 이 없습니다 — 웹메이커 데이터 디렉터리(DATA_ROOT, 예: /data/webmaker)를 적으세요'); process.exit(1); } }
else { console.error(` ✖ LOGIN_MODE 값을 모릅니다: ${LOGIN_MODE} (standalone | www | webmaker)`); process.exit(1); }
// WWW_DB_FILE 이 없으면 독립 실행(0.8.18~): www 관리자 대신 게이트웨이 계정(mail_users)만 쓰고, 기본 메일함도 게이트웨이 계정이 갖습니다.
if (isWebmaker(env)) console.log(` 웹메이커 연동(LOGIN_MODE=webmaker): ${env.WEBMAKER_DIR}${env.WEBMAKER_USER ? ` (sudo -u ${env.WEBMAKER_USER} 도우미로 읽음)` : ''}${env.WEBMAKER_SITE ? `, 사이트 ${env.WEBMAKER_SITE}` : ''}`);
else if (!env.WWW_DB_FILE) console.warn(' ℹ️ 독립 실행(LOGIN_MODE=standalone): www 관리자 없이 게이트웨이 계정만 씁니다'); else console.log(` www 연동(LOGIN_MODE=www): ${env.WWW_DB_FILE}`);
// 시스템 제한(0.9.0~): 설치 시 환경 파일에 적는 상위 제한. 0 은 제한 없음. 시작 로그에 남겨 운영자가 적용 값을 확인할 수 있게 합니다.
{ const l = systemLimits(env); const show = (n) => (n > 0 ? String(n) : '제한 없음'); console.log(` 시스템 제한: 계정 ${show(l.accounts)} · 전체 ${show(l.totalBytes)}B · 메일함 ${show(l.mailboxBytes)}B · 첨부 ${show(l.attachmentBytes)}B · 수신자 ${show(l.recipients)} · 월간 ${show(l.monthlyMessages)}통`); }
// 초기 관리자(0.8.24~): 독립 실행이고 계정이 하나도 없으면 admin / adminpasswd 관리자 계정을 만들어 설치 직후 바로 로그인할 수 있게 합니다.
// 화면은 이 계정이 초기 비밀번호를 쓰는 동안 경고를 보여 주고, 비밀번호를 바꾸면 경고가 사라집니다.
try { const made = await ensureInitialAdmin(env); if (made) console.warn(` ⚠️ 초기 관리자 계정을 만들었습니다: ${made.login} / ${made.password} — 관리자 페이지(/admin)에서 바로 비밀번호를 바꾸세요`); }
catch (e) { console.error(' 초기 관리자 계정 생성 실패:', e.message); }
// ------------------------------------------------------------------ 정적 화면
/**
* JSON 파일 읽기(1.0.5): 잠시 뒤 다시 시도합니다. mail.heart-m.com 의 0.9.7 배포 직후 첫 시작이 package.json 을 읽다 "Unexpected end of JSON input" 으로
* 죽은 기록이 있습니다(systemd 가 2초 뒤 다시 띄워 정상). 릴리스 복사와 재시작 사이의 짧은 경쟁으로 보이며, 어느 경우든 비어 있는 파일 한 번으로
* 프로세스가 죽지 않게 3번(200ms 간격) 다시 읽고, 끝내 실패하면 기본값을 쓰며 오류를 기록합니다.
*/
function readJsonSync(file, fallback) {
for (let attempt = 1; ; attempt++) {
try { const text = readFileSync(file, 'utf8'); if (!text.trim()) throw new Error('empty file'); return JSON.parse(text); }
catch (e) {
if (attempt >= 3) { if (fallback === undefined) throw e; console.error(` ✖ ${file} 을 읽지 못해 기본값을 씁니다: ${e.message}`); return fallback; }
const until = Date.now() + 200; while (Date.now() < until) { /* 동기 대기(시작 단계라 이벤트 루프를 막아도 무방) */ }
}
}
}
const baseStrings = readJsonSync(join(ROOT, 'lang', 'ko.json'));
// VERSION: package.json 의 버전(예: 0.8.1). 화면의 "아리학당 메일" 아래 작은 글씨와 /api/health 응답에 씁니다.
// 배포 스크립트가 릴리스마다 package.json 을 함께 복사하므로 지금 돌고 있는 코드의 버전과 항상 같습니다.
const VERSION = readJsonSync(join(ROOT, 'package.json'), { version: '0.0.0' }).version;
env.APP_VERSION = VERSION;
{ const l = systemLargeFiles(env); console.log(` 대용량 첨부파일 시스템 상한: ${humanSize(l.bytes)} · ${l.days}일 · ${l.downloads}회 · 저장 위치 ${filesDir(env)}`); }
const indexHtml = readFileSync(join(ROOT, 'web', 'index.html'), 'utf8');
// ------------------------------------------------------------------ TLS(메일 포트·설정 페이지)
function readTls() {
if (!env.POP_TLS_CERT || !env.POP_TLS_KEY) return null;
try { return { key: readFileSync(env.POP_TLS_KEY), cert: readFileSync(env.POP_TLS_CERT), mtime: statSync(env.POP_TLS_CERT).mtimeMs }; }
catch (e) { console.warn(' ⚠️ POP TLS 인증서를 읽지 못했습니다:', e.message); return null; }
}
let tlsFiles = readTls(); env.POP_TLS_ACTIVE = !!tlsFiles;
/**
* 도메인(테넌트) env 하나를 쓸 준비(1.0.7): 버전, 문구 묶음, 화면 캐시, 설정 변경 훅, TLS 상태. 기본 도메인과 추가 도메인 모두 같은 함수로 준비합니다.
* 지금 설정으로 만든 문구 묶음: 왼쪽 위 서비스 이름(appName, 0.8.25~)은 메일 도메인에서 정합니다(heart-m.com → "HEART-M 메일").
* 탭 제목(title)은 SITE_TITLE(1.0.0 부터 설정 파일에서 바꿀 수 있음), 없으면 서비스 이름. 설정이 바뀌면(ON_CONFIG_CHANGE) 다시 만듭니다.
* STRINGS: 서버가 만드는 메일 본문 문구(대용량 첨부파일 블록, 0.9.3)에도 같은 묶음을 씁니다.
*/
function prepareTenant(t) {
const stringsFor = () => { const s = { ...baseStrings }; s.appName = siteName(mailDomain(t), s.appName); s.title = t.SITE_TITLE ? String(t.SITE_TITLE).trim() : s.appName; return s; };
t.APP_VERSION = VERSION; t.POP_TLS_ACTIVE = !!tlsFiles; t.STRINGS = stringsFor(); t.PAGE = null;
// 설정 저장(초기 설정 페이지·관리자) 뒤 바로 반영할 것: 문구(탭 제목)와 화면 캐시. 발송·수신 모듈은 호출마다 env 를 읽으므로 따로 할 일이 없습니다.
t.ON_CONFIG_CHANGE = () => { t.STRINGS = stringsFor(); t.PAGE = null; };
if (t !== env) {
// 추가 도메인의 시작 안내(기본 도메인은 위에서 이미 출력). 빠진 운영 설정은 경고만 하고 계속 뜹니다.
console.log(` [${t.DOMAIN}] 메일 서비스: 수신 ${inboundMode(t)} · 발신 ${outboundMode(t)} · DB ${t.DB_FILE}`);
if (usesResend(t) && !t.RESEND_API_KEY) console.warn(` [${t.DOMAIN}] ⚠️ RESEND_API_KEY 없음 — Resend 수신 동기화·발송이 동작하지 않습니다(전체 설정 화면에서 채우세요)`);
}
}
// 화면 한 장(index.html)에 문구·버전·호스트·도메인을 심어 둡니다. 로그인 안내 팝업이 "이 도메인의 메일 서비스" 를 설명하는 데 씁니다.
// __STANDALONE__: www 사이트 연동 여부. 화면은 이 값으로 www 관리자 관련 안내(접속 안내 3번 항목 등)를 숨깁니다. __AUTH__(1.0.8): 로그인 방식(local|www|webmaker) — 웹메이커 안내 문구용.
// __BASE__(1.0.8): 화면의 기준 경로. 보통 "/" 이고, 경로 접두사(/mail) 아래로 들어온 요청이면 "/mail/" — 정적 파일·API 주소가 모두 이 값을 앞에 붙입니다. 설정이 바뀌면 다시 만듭니다(PAGE 캐시).
const renderPage = (t, prefix) => indexHtml.replace('__STRINGS__', JSON.stringify(t.STRINGS).replace(/</g, '\\u003c')).replace(/__TITLE__/g, t.STRINGS.title).replace(/__VERSION__/g, VERSION).replace(/__HOST__/g, (t.PUBLIC_HOST || 'mail.arihakdang.com') + (t.PUBLIC_PATH || '')).replace(/__DOMAIN__/g, mailDomain(t)).replace(/__STANDALONE__/g, t.WWW_DB_FILE ? '0' : '1').replace(/__AUTH__/g, authMode(t)).replace(/__BASE__/g, prefix + '/');
// 접두사 없는 화면은 캐시하고(대부분의 요청), 접두사가 있는 화면은 그때그때 만듭니다(문자열 치환 몇 번이라 비용이 작음).
const page = (t, prefix = '') => prefix ? renderPage(t, prefix) : t.PAGE || (t.PAGE = renderPage(t, ''));
/**
* 이 요청의 경로 접두사(1.0.8). nginx 가 /mail/ 아래를 게이트웨이로 넘기며 붙인 X-Forwarded-Prefix 가 우선이고, 없으면 등록부의 "호스트/경로" 가 정한 값입니다.
* 잘못된 값(허용 글자 밖·..)은 무시합니다. 돌려주는 값은 '' 또는 '/mail' 꼴(끝 슬래시 없음).
*/
const prefixOf = (req, resolved) => {
// 등록부에 경로가 없는 호스트(보통의 mail.<도메인>)는 헤더를 믿지 않습니다 — 클라이언트가 X-Forwarded-Prefix 를 직접 보내 화면의 기준 경로를 바꾸지 못하게(nginx 템플릿도 이 헤더를 비웁니다).
if (!resolved?.prefix) return '';
const h = req.headers['x-forwarded-prefix']; if (h !== undefined) { const p = normalizePrefix(String(h).split(',')[0]); if (p !== null) return p; }
return resolved.prefix;
};
// 도메인 등록부(1.0.7): 기본 도메인(이 env)과 추가 도메인의 env 를 엽니다. 등록부가 없으면 기본 도메인만으로 만듭니다(데이터 이동 없음).
const tenants = openTenants(env, { migrationsDir: join(ROOT, 'migrations'), onCreate: prepareTenant });
console.log(` 도메인 등록부: ${join(env.DATA_DIR, 'domains.json')} — 기본 ${env.DOMAIN} (${env.TENANT.hosts.join(', ')})${tenants.all().length > 1 ? `, 추가 ${tenants.all().length - 1}개` : ''}`);
console.log(` 메일 서비스: 수신 ${inboundMode(env)} · 발신 ${outboundMode(env)}`);
// 빠진 운영 설정은 경고만 하고 서버는 뜹니다(요구사항 3: 값이 없어도 배포는 성공). 값은 초기 설정 페이지에서 채우면 재시작 없이 적용됩니다.
if (usesResend(env) && !env.RESEND_API_KEY) console.warn(' ⚠️ RESEND_API_KEY 없음 — Resend 수신 동기화·발송이 동작하지 않습니다(초기 설정 페이지에서 채우세요)');
if (inboundMode(env) === 'resend' && !env.RESEND_WEBHOOK_SECRET) console.warn(' ⚠️ RESEND_WEBHOOK_SECRET 없음 — 웹훅 수신을 거부합니다(1시간 동기화만 동작)');
if (outboundMode(env) === 'smtp' && !env.SMTP_RELAY_HOST) console.warn(' ⚠️ SMTP_RELAY_HOST 없음 — 발송이 동작하지 않습니다(초기 설정 페이지에서 중계 서버를 적으세요)');
if (tenants.envs().some(usesHaraka)) console.log(' 내장 Haraka 사용: haraka/run.mjs(별도 서비스)가 설정 파일을 보고 Haraka 를 띄웁니다');
// 정적 파일: 경로 → Content-Type. favicon.svg 는 브라우저 탭 아이콘입니다(없으면 매 방문마다 404 가 기록됐습니다).
const STATIC = { '/app.js': 'application/javascript; charset=utf-8', '/style.css': 'text/css; charset=utf-8', '/favicon.svg': 'image/svg+xml', '/setup.js': 'application/javascript; charset=utf-8', '/setup.css': 'text/css; charset=utf-8' };
// X-App-Version(0.8.25~): 모든 응답에 지금 돌고 있는 버전을 실어, 열어 둔 화면이 배포 뒤 옛 app.js 로 새 API 를 부르지 않도록 화면이 스스로 새로고침합니다.
const SECURITY = { 'X-App-Version': VERSION, 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY', 'X-Robots-Tag': 'noindex, nofollow', 'Cache-Control': 'no-store, no-transform' };
// img-src 에 https: 를 두는 이유: 읽기 창의 srcdoc iframe 은 이 페이지의 CSP 를 상속하므로, 여기서 막으면
// "외부 이미지 표시"를 눌러도 보이지 않습니다. 기본 차단은 iframe 쪽 CSP 와 src 제거로 유지합니다.
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data: https:; font-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
async function readBody(req) {
if (req.method === 'GET' || req.method === 'HEAD') return undefined;
const chunks = []; let size = 0;
for await (const c of req) { size += c.length; if (size > 32 * 1024 * 1024) throw new Error('body_too_large'); chunks.push(c); }
return chunks.length ? Buffer.concat(chunks) : undefined;
}
/** 요청의 호스트 이름(nginx 가 넘긴 X-Forwarded-Host 우선). 테넌트 선택과 표준 Request 의 주소에 씁니다. */
const hostOf = (req) => String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim();
const server = createServer(async (req, res) => {
let pathname = '/';
try {
const url = new URL(req.url, 'http://x'); pathname = url.pathname; const headOnly = req.method === 'HEAD';
// /favicon.ico 를 직접 찾는 브라우저·크롤러에는 SVG 아이콘으로 안내합니다(404 오류 기록 방지). 정적 파일은 도메인과 무관하므로 먼저 줍니다.
if (pathname === '/favicon.ico') { res.writeHead(301, { Location: prefixOf(req, tenants.resolveHost(hostOf(req))) + '/favicon.svg', ...SECURITY }); return res.end(); }
if (STATIC[pathname]) { const body = readFileSync(join(ROOT, 'web', pathname)); res.writeHead(200, { 'Content-Type': STATIC[pathname], ...SECURITY, 'Cache-Control': 'no-cache' }); return res.end(headOnly ? undefined : body); }
// 상태 확인은 어느 호스트로 와도(배포 스크립트는 127.0.0.1 로 부름) 기본 도메인의 버전을 돌려줍니다.
if (pathname === '/api/health') { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ...SECURITY }); return res.end(JSON.stringify({ ok: true, version: VERSION, domains: tenants.all().length })); }
// 사용량 API(1.0.9): 어느 호스트로 와도 같은 응답(키·IP 로 인증, 읽기 전용). 본문 없는 GET 만 받습니다.
if (pathname === '/api/usage' || pathname.startsWith('/api/usage/')) {
const hdrs = new Headers(); for (const [k, v] of Object.entries(req.headers)) { if (Array.isArray(v)) v.forEach((x) => hdrs.append(k, x)); else if (v !== undefined) hdrs.set(k, v); }
// 접속 IP 는 클라이언트가 넣을 수 있는 헤더(X-Forwarded-For 첫 값·CF-Connecting-IP)가 아니라 nginx 가 보증하는 값(X-Real-IP, XFF 마지막)으로 — 허용 목록·시도 제한이 이 값에 걸려 있음.
const ip = clientIp(hdrs, req.socket.remoteAddress);
// HEAD 는 GET 으로 처리하고 본문만 빼서 보냅니다(상태 확인용).
const response = await usageApi(tenants, new Request(`https://x${req.url}`, { method: headOnly ? 'GET' : req.method, headers: hdrs }), { path: pathname.split('/').slice(3).filter(Boolean), ip, query: url.searchParams });
const out = { ...SECURITY }; response.headers.forEach((v, k) => { out[k] = v; }); const buf = Buffer.from(await response.arrayBuffer()); out['Content-Length'] = String(buf.length);
res.writeHead(response.status, out); return res.end(headOnly ? undefined : buf);
}
// 테넌트 선택(1.0.7): Host 로 도메인을 고릅니다. 모르는 호스트·꺼 둔 도메인이면 화면에는 안내 페이지, API 에는 404 JSON 을 줍니다.
const resolved = tenants.resolveHost(hostOf(req));
const isHaraka = pathname.startsWith('/api/inbound/haraka');
if ((!resolved.tenant || resolved.disabled) && !isHaraka) {
const disabled = resolved.disabled;
if (pathname.startsWith('/api/')) { res.writeHead(disabled ? 403 : 404, { 'Content-Type': 'application/json; charset=utf-8', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: disabled ? 'domain_disabled' : 'domain_not_found', host: resolved.host })); }
res.writeHead(disabled ? 403 : 404, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'", ...SECURITY });
return res.end(headOnly ? undefined : noServicePage({ host: resolved.host, disabled, strings: baseStrings, version: VERSION }));
}
// Haraka 수신 전달은 로컬(127.0.0.1)에서 오며 도메인은 받는 사람 주소로 고르므로 기본 도메인의 env 로 들어가 api.mjs 가 나눕니다.
const t = isHaraka ? env : resolved.tenant.env;
// 경로 접두사(1.0.8): nginx 가 /mail/ 을 떼고 넘기므로 여기 경로는 늘 "/…" 이고, 화면이 돌아갈 기준 경로만 prefix 로 압니다.
const prefix = prefixOf(req, resolved);
// "/" 는 웹 메일, "/admin" 은 관리자 페이지입니다(0.8.4~). 같은 화면 파일을 주고, 앱이 주소(location.pathname)로 모드를 정합니다.
// /admin 은 항상 관리자 페이지로 이어지고 별도의 로그인(관리자 세션 쿠키)을 거칩니다.
if (pathname === '/' || pathname === '/index.html' || /^\/admin\/?$/.test(pathname)) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': CSP, ...SECURITY }); return res.end(headOnly ? undefined : page(t, prefix)); }
// /setup(1.0.0): 메일 서비스 설정 화면. 관리자 세션으로 /api/setup/* 를 부릅니다(전용 포트의 토큰 방식과 같은 화면, setup.mjs).
if (/^\/setup\/?$/.test(pathname)) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': CSP, ...SECURITY }); return res.end(headOnly ? undefined : setupPage(t, join(ROOT, 'web'), 'admin', { base: prefix + '/' })); }
// 대용량 첨부파일 다운로드 링크(0.9.3): /dl/<토큰>. GET 은 랜딩 페이지(횟수를 세지 않음), POST(페이지의 단추)가 실제 다운로드입니다.
// return await: 프라미스를 그냥 돌려주면 그 안의 예외(잘못된 Host 헤더로 new Request 가 던지는 TypeError 등)가 아래 catch 에 잡히지 않고 프로세스를 죽였습니다(0.9.5 수정).
if (pathname.startsWith('/dl/')) return await download(t, req, res, pathname.slice(4).split('/')[0], prefix);
if (!pathname.startsWith('/api/')) { res.writeHead(404, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'not_found' })); }
// 대용량 파일 업로드(0.9.3): 본문을 메모리에 모으지 않고 디스크로 흘려 씁니다(일반 API 의 32MB 본문 한도 밖). 로그인·초안 소유 확인은 uploadLargeFile 이 합니다.
if (pathname === '/api/files' && req.method === 'POST') return await upload(t, req, res, url);
const body = await readBody(req);
const proto = req.headers['x-forwarded-proto'] || 'https', host = hostOf(req) || t.PUBLIC_HOST || 'mail.arihakdang.com';
const headers = new Headers(); for (const [k, v] of Object.entries(req.headers)) { if (Array.isArray(v)) v.forEach((x) => headers.append(k, x)); else if (v !== undefined) headers.set(k, v); }
const request = new Request(`${proto}://${host}${req.url}`, { method: headOnly ? 'GET' : req.method, headers, body });
// 웹훅과 Haraka 수신 전달은 브라우저가 아니므로 Origin 검사(CSRF)를 하지 않습니다(각자 서명·토큰으로 인증).
const isWebhook = pathname === '/api/webhooks/resend' || pathname.startsWith('/api/inbound/');
if (!isWebhook && !['GET', 'HEAD'].includes(req.method) && headers.get('origin') && headers.get('origin') !== new URL(request.url).origin) { res.writeHead(403, SECURITY); return res.end(); }
const ip = headers.get('cf-connecting-ip') || (headers.get('x-forwarded-for') || '').split(',')[0].trim() || req.socket.remoteAddress || '';
const response = await handle(t, request, { path: pathname.slice(5).split('/').filter(Boolean).map(decodeURIComponent), ip, prefix });
const out = { ...SECURITY }; response.headers.forEach((v, k) => { if (k.toLowerCase() !== 'set-cookie') out[k] = v; });
const cookies = response.headers.getSetCookie?.() || []; if (cookies.length) out['Set-Cookie'] = cookies;
const buf = Buffer.from(await response.arrayBuffer()); out['Content-Length'] = String(buf.length);
res.writeHead(response.status, out); res.end(headOnly ? undefined : buf);
} catch (e) {
console.error(`오류 ${req.method} ${pathname}`, e?.message);
if (!res.headersSent) res.writeHead(e.message === 'body_too_large' ? 413 : 500, { 'Content-Type': 'application/json', ...SECURITY });
res.end(JSON.stringify({ ok: false, error: 'server_error' }));
}
});
server.listen(PORT, HOST, () => console.log(` HTTP 대기 중: http://${HOST}:${PORT}`));
const jsonOut = (res, status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...SECURITY }); res.end(JSON.stringify(obj)); };
/** 요청 헤더만으로 표준 Request 를 만듭니다(세션 쿠키 확인용). */
function headerRequest(t, req) {
const proto = req.headers['x-forwarded-proto'] || 'https', host = hostOf(req) || t.PUBLIC_HOST || 'mail.arihakdang.com';
const headers = new Headers(); for (const [k, v] of Object.entries(req.headers)) { if (Array.isArray(v)) v.forEach((x) => headers.append(k, x)); else if (v !== undefined) headers.set(k, v); }
return new Request(`${proto}://${host}${req.url}`, { method: 'GET', headers });
}
/**
* POST /api/files?draft=<초안 id> — 대용량 파일 업로드(0.9.3). 본문이 파일 바이트이고 이름은 X-File-Name(URL 인코딩), 형식은 Content-Type 입니다.
* 웹 메일 세션(쿠키)으로 인증하고 같은 출처(Origin)만 받습니다. 상한을 넘기면 413 과 limit 을 돌려주고 남은 본문은 버립니다.
* @param {object} t 이 요청의 도메인 env(1.0.7)
*/
async function upload(t, req, res, url) {
const request = headerRequest(t, req);
if (req.headers.origin && req.headers.origin !== new URL(request.url).origin) { res.writeHead(403, SECURITY); return res.end(); }
const user = await currentUser(t, request, { scope: 'mail' });
if (!user) { req.resume(); return jsonOut(res, 401, { ok: false, error: 'unauthenticated' }); }
try {
// X-File-Name 은 URL 인코딩이 깨져 있어도(잘못된 % 열) 오류 대신 원문을 이름으로 씁니다.
const rawName = String(req.headers['x-file-name'] || ''); let filename; try { filename = decodeURIComponent(rawName); } catch { filename = rawName; }
const file = await uploadLargeFile(t, user, req, { draftId: url.searchParams.get('draft'), filename, contentType: req.headers['content-type'], declaredSize: Number(req.headers['content-length'] || 0) });
return jsonOut(res, 201, { ok: true, file: { id: file.id, filename: file.filename, size: file.size, content_type: file.content_type, state: file.state } });
} catch (e) {
req.resume();
const status = e.message === 'file_too_large' ? 413 : e.message === 'large_files_disabled' ? 404 : e.message === 'mail_locked' ? 409 : e.message === 'bad_request' ? 400 : 422;
return jsonOut(res, status, { ok: false, error: e.message || 'server_error', ...(e.limit !== undefined ? { limit: e.limit } : {}) });
}
}
/** 랜딩 페이지 HTML(외부 공개). 스크립트 없이 폼(POST)만 쓰며 검색 엔진이 색인하지 않게 합니다. */
function landing(t, title, body) {
return `<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>${escapeHtml(title)}</title><style>body{margin:0;background:#f3f5f8;font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","Apple SD Gothic Neo",sans-serif;color:#1d2733}.card{max-width:520px;margin:48px auto;background:#fff;border-radius:14px;padding:28px;box-shadow:0 8px 30px rgba(20,35,60,.12)}h1{font-size:18px;margin:0 0 14px}dl{display:grid;grid-template-columns:110px 1fr;gap:6px 12px;margin:0 0 18px}dt{color:#6b7a8c}dd{margin:0;overflow-wrap:anywhere}button{background:#2b64c8;color:#fff;border:0;border-radius:8px;padding:12px 18px;font:inherit;font-weight:600;cursor:pointer;width:100%}.meta{color:#6b7a8c;font-size:12px;margin-top:14px}.warn{color:#9b2c1f}</style></head><body><div class="card">${body}<p class="meta">${escapeHtml(t.STRINGS.appName)} · ${escapeHtml(t.PUBLIC_HOST || '')}</p></div></body></html>`;
}
/**
* /dl/<토큰> — 받는 사람이 여는 다운로드 링크(0.9.3, 로그인 없음).
* GET : 파일 이름·크기·남은 횟수·기한을 보여 주는 랜딩 페이지. 메일 보안 장치·미리 보기 봇이 미리 열어도 횟수가 줄지 않습니다.
* POST : "다운로드" 단추. 횟수를 1 차지한 뒤 파일을 흘려보내고, 마지막 허용 횟수였으면 끝난 뒤 파일을 지웁니다.
* 만료·없는 토큰에는 410/404 안내 페이지를 줍니다.
*/
async function download(t, req, res, token, prefix = '') {
const page = (status, title, body) => { res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY, 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'" }); res.end(landing(t, title, body)); };
const s = (k) => t.STRINGS[k] || k;
if (!/^[A-Za-z0-9_-]{16,64}$/.test(token)) return page(404, s('dl_missingTitle'), `<h1>${escapeHtml(s('dl_missingTitle'))}</h1><p class="warn">${escapeHtml(s('dl_missing'))}</p>`);
if (req.method === 'GET' || req.method === 'HEAD') {
const info = await largeFileInfo(t, token);
if (info.status === 'missing') return page(404, s('dl_missingTitle'), `<h1>${escapeHtml(s('dl_missingTitle'))}</h1><p class="warn">${escapeHtml(s('dl_missing'))}</p>`);
if (info.status === 'expired') return page(410, s('dl_expiredTitle'), `<h1>${escapeHtml(s('dl_expiredTitle'))}</h1><p class="warn">${escapeHtml(s('dl_expired'))}</p><dl><dt>${escapeHtml(s('dl_file'))}</dt><dd>${escapeHtml(info.file.filename)}</dd></dl>`);
const f = info.file;
return page(200, s('dl_title'), `<h1>${escapeHtml(s('dl_title'))}</h1><dl><dt>${escapeHtml(s('dl_file'))}</dt><dd>${escapeHtml(f.filename)}</dd><dt>${escapeHtml(s('dl_size'))}</dt><dd>${humanSize(f.size)}</dd><dt>${escapeHtml(s('dl_remaining'))}</dt><dd>${info.remaining} / ${f.max_downloads}</dd><dt>${escapeHtml(s('dl_until'))}</dt><dd>${formatExpiry(f.expires_at, t)}</dd></dl><form method="post" action="${prefix}/dl/${encodeURIComponent(token)}"><button type="submit">⬇ ${escapeHtml(s('dl_button'))}</button></form><p class="meta">${escapeHtml(s('dl_note'))}</p>`);
}
if (req.method !== 'POST') { res.writeHead(405, SECURITY); return res.end(); }
req.resume();
const claim = await claimDownload(t, token);
if (!claim) return page(410, s('dl_expiredTitle'), `<h1>${escapeHtml(s('dl_expiredTitle'))}</h1><p class="warn">${escapeHtml(s('dl_expired'))}</p>`);
const { row } = claim;
res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': String(row.size), 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(row.filename)}`, ...SECURITY });
const stream = openLargeFile(claim.path);
stream.on('error', (e) => { console.error('대용량 파일 전송 오류', row.id, e.message); res.destroy(); });
// 전송이 끝나면(또는 끊기면) 마지막 허용 횟수였던 파일을 지웁니다. 끊긴 경우도 횟수는 이미 차지했으므로 같은 처리입니다.
res.on('close', () => { finishDownload(t, claim).catch((e) => console.error('대용량 파일 정리 오류', row.id, e.message)); });
stream.pipe(res);
}
// ------------------------------------------------------------------ POP3 / IMAP / SMTP
// 평문 포트(110/143)의 TLS 없는 로그인(0.9.7): POP_ALLOW_PLAINTEXT=1 이면 항상 허용(진단용), 그 밖에는 null 을 넘겨 관리자 설정 "평문 로그인 허용"(기본 켬)을 접속마다 읽습니다.
// 0.9.5 까지는 이 변수가 1 이 아니면 항상 거부해, 설치 템플릿 기본값(0)에서 STLS 를 쓰지 않는 Gmail 의 110 접속이 전부 실패했습니다.
const allowPlaintext = env.POP_ALLOW_PLAINTEXT === '1' ? true : null;
// resolveEnv(1.0.7): 로그인 ID 의 @도메인(없으면 TLS SNI 호스트, 그래도 없으면 기본 도메인)으로 그 접속이 쓸 도메인 env 를 고릅니다. 메일 서버 이름·인증서는 기본 도메인 것 하나입니다.
const resolveEnv = (login, servername) => tenants.resolveLogin(login, servername);
const mailOpts = { hostname: env.PUBLIC_HOST || 'mail.arihakdang.com', tlsOptions: tlsFiles ? { key: tlsFiles.key, cert: tlsFiles.cert } : null, resolveEnv };
const pop = createPop3(env, { ...mailOpts, allowPlaintext });
if (env.POP_DISABLED !== '1') {
// TLS 포트는 인증서가 없으면 지금 열지 않고 기억해 두어, 아래 1시간 점검이 인증서를 읽는 순간 엽니다(0.9.7, 예전에는 재시작 전까지 닫혀 있었음).
pop.listen({ plainPort: Number(env.POP_PORT || 110), securePort: Number(env.POPS_PORT || 995), host: env.POP_HOST || '0.0.0.0' })
.then((bound) => { console.log(' POP3 대기 중:', bound.join(', ')); if (!tlsFiles) console.warn(' ⚠️ POP TLS 인증서 없음 — 995 포트는 인증서를 읽을 수 있게 되면 엽니다(110 의 TLS 없는 로그인은 관리자 설정 "평문 로그인 허용" 을 따름)'); })
.catch((e) => console.error(' POP3 시작 실패:', e.message));
}
const imap = createImap(env, { ...mailOpts, allowPlaintext });
if (env.IMAP_DISABLED !== '1') {
imap.listen({ plainPort: Number(env.IMAP_PORT || 143), securePort: Number(env.IMAPS_PORT || 993), host: env.POP_HOST || '0.0.0.0' })
.then((bound) => { console.log(' IMAP 대기 중:', bound.join(', ')); if (!tlsFiles) console.warn(' ⚠️ TLS 인증서 없음 — 993 포트는 인증서를 읽을 수 있게 되면 엽니다(143 의 TLS 없는 로그인은 관리자 설정 "평문 로그인 허용" 을 따름)'); })
.catch((e) => console.error(' IMAP 시작 실패:', e.message));
}
// 메일 프로그램의 "보내는 메일 서버". 웹 메일과 같은 아이디·비밀번호로 인증하고, 실제 발송에만 서버의 RESEND_API_KEY 를 씁니다.
// 인증은 TLS 위에서만 받습니다(SMTP_ALLOW_PLAINTEXT=1 은 진단용). 인증서는 POP·IMAP 과 같은 파일입니다.
const smtp = createSmtp(env, { ...mailOpts, allowPlaintext: env.SMTP_ALLOW_PLAINTEXT === '1' });
if (env.SMTP_DISABLED !== '1') {
smtp.listen({ plainPort: Number(env.SMTP_PORT || 587), securePort: Number(env.SMTPS_PORT || 465), host: env.SMTP_HOST || env.POP_HOST || '0.0.0.0' })
.then((bound) => { console.log(' SMTP 대기 중:', bound.join(', ')); if (!tlsFiles) console.warn(' ⚠️ TLS 인증서 없음 — 465 포트는 인증서를 읽을 수 있게 되면 열고, 587 은 STARTTLS 없이 로그인을 거부합니다'); })
.catch((e) => console.error(' SMTP 시작 실패:', e.message));
}
// 인증서 갱신(certbot 훅이 사본을 바꿈)을 1시간마다 확인해 POP·IMAP·SMTP 에 새 인증서를 넣습니다. 시작 때 인증서가 없었다면(tlsFiles=null) 이때 TLS 포트도 열립니다(0.9.7).
setInterval(() => { const next = readTls(); if (next && next.mtime !== tlsFiles?.mtime) { tlsFiles = next; pop.setTls({ key: next.key, cert: next.cert }); imap.setTls({ key: next.key, cert: next.cert }); smtp.setTls({ key: next.key, cert: next.cert }); setup.setTls({ key: next.key, cert: next.cert }); for (const t of tenants.envs()) t.POP_TLS_ACTIVE = true; console.log(' TLS 인증서를 다시 읽었습니다'); } }, 3600000).unref();
// ------------------------------------------------------------------ 전체 설정 화면·초기 설정 페이지(1.0.0 요구사항 4, 1.0.7 요구사항 2)
// 전용 포트(SETUP_PORT, 기본 8791, 0 이면 끔)에서 토큰으로 인증하는 설정 화면을 엽니다. 인증서를 읽을 수 있으면 HTTPS, 없으면 HTTP.
// 1.0.7: 이 화면이 "전체 설정" 입니다 — 도메인 목록·추가·중지와 도메인별 설정(API 키·DNS·수·발신 방식)을 기본 도메인의 이 포트에서 관리합니다.
// 접속 주소는 로그와 데이터 디렉터리의 setup-url.txt 에 남겨 배포 스크립트가 끝에 출력합니다.
const setup = createSetupServer(env, { webDir: join(ROOT, 'web'), tlsOptions: tlsFiles ? { key: tlsFiles.key, cert: tlsFiles.cert } : null, tenants });
const SETUP_PORT = Number(env.SETUP_PORT ?? 8791);
if (SETUP_PORT > 0) {
setup.listen({ port: SETUP_PORT, host: env.SETUP_HOST || '0.0.0.0' })
.then(() => { const urls = setup.urls(setupToken(env)); console.log(' 전체 설정 화면: ' + urls[0] + (urls.length > 1 ? ` (또는 ${urls.slice(1).join(' , ')})` : '')); writeSetupUrls(env, urls); })
.catch((e) => console.error(' 전체 설정 화면 시작 실패:', e.message));
}
// ------------------------------------------------------------------ 주기 작업(도메인마다, 1.0.7)
const SYNC_INTERVAL = Number(env.SYNC_INTERVAL_MS || 3600000);
const runSync = (trigger) => { for (const t of tenants.envs()) if (t.RESEND_API_KEY && usesResend(t)) syncMailbox(t, { trigger }).then((r) => { if (!r.busy) console.log(` 동기화(${trigger}${t === env ? '' : ', ' + t.DOMAIN}): ${r.imported}건`); }).catch((e) => console.error(` 동기화 실패(${t.DOMAIN}):`, e.message, e.providerMessage || '')); };
// 도메인 검증 자동 재요청(1.0.11): 검증이 실패 상태로 멈춘 도메인의 Resend 레코드가 모두 보이면 검증을 다시 요청합니다(동기화와 같은 주기).
// 운영(webmail.ibit.kr)에서 DKIM 레코드 불일치로 failed 가 된 뒤 사용자가 DNS 를 고쳐도 다시 요청하지 않으면 영영 실패 상태라 발송이 막혔습니다.
const runAutoVerify = () => { for (const t of tenants.envs()) if (t.RESEND_API_KEY && usesResend(t)) autoVerifyDomain(t).then((r) => { if (r.requested) console.log(` Resend 도메인 검증 자동 요청(${t.DOMAIN}): 이전 상태 ${r.status}`); else if (r.skipped === 'records') console.warn(` Resend 도메인 검증 대기(${t.DOMAIN}): ${r.status} — ${r.problems.join(', ')}`); }).catch((e) => console.warn(` Resend 도메인 검증 확인 실패(${t.DOMAIN}):`, e.message, e.providerMessage || '')); };
setTimeout(runAutoVerify, 20000).unref(); setInterval(runAutoVerify, SYNC_INTERVAL).unref();
setTimeout(() => runSync('startup'), 10000).unref();
setInterval(() => runSync('timer'), SYNC_INTERVAL).unref();
// 만료된 로그인 세션 정리. 예전에는 같은 작업이 보관 기간(30일)이 지난 메일도 지웠지만 0.8.0 에서 그 삭제를 없앴습니다.
const runCleanup = () => { for (const t of tenants.envs()) cleanupSessions(t).catch((e) => console.error(` 세션 정리 실패(${t.DOMAIN}):`, e.message)); };
// 대용량 첨부파일 정리(0.9.3): 기한·횟수가 끝난 파일과 버려진 초안의 파일을 1시간마다 지웁니다.
const runFileCleanup = () => { for (const t of tenants.envs()) cleanupLargeFiles(t).then((r) => { if (r.expired || r.removed) console.log(` 대용량 파일 정리(${t.DOMAIN}): 만료 ${r.expired}건, 삭제 ${r.removed}건`); }).catch((e) => console.error(` 대용량 파일 정리 실패(${t.DOMAIN}):`, e.message)); };
setTimeout(runFileCleanup, 45000).unref(); setInterval(runFileCleanup, 3600000).unref();
setTimeout(runCleanup, 30000).unref(); setInterval(runCleanup, 3600000).unref();
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { console.log(`\n ${sig} — 종료합니다`); pop.close(); imap.close(); smtp.close(); setup.close(); server.close(() => { tenants.close(); db.close(); process.exit(0); }); setTimeout(() => process.exit(0), 3000).unref(); });
+117
View File
@@ -0,0 +1,117 @@
/**
* RFC 5322/2045 메시지 생성. Resend 가 원본(raw)을 내려주지 못한 경우, 저장한 본문·헤더·첨부로
* POP 클라이언트에 줄 수 있는 완전한 메시지를 만듭니다. 원본이 있으면 이 코드는 쓰지 않습니다.
*/
const CRLF = '\r\n';
const isAscii = (s) => /^[\x20-\x7e]*$/.test(s);
export function encodeHeaderWord(value) {
const s = String(value ?? '');
if (isAscii(s)) return s;
// RFC 2047 encoded-word, base64, split to keep each word under 75 characters.
const bytes = Buffer.from(s, 'utf8'); const words = []; const chunk = 45;
for (let i = 0; i < bytes.length; i += chunk) {
// avoid splitting inside a multi-byte sequence
let end = Math.min(bytes.length, i + chunk);
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--;
words.push(`=?UTF-8?B?${bytes.subarray(i, end).toString('base64')}?=`);
i = end - chunk;
}
return words.join(CRLF + ' ');
}
export function formatAddress(value) {
const s = String(value || '').trim();
const m = s.match(/^(.*?)\s*<([^<>]+)>$/);
if (!m) return s;
const name = m[1].replace(/^"|"$/g, '').trim();
if (!name) return `<${m[2]}>`;
return `${isAscii(name) ? `"${name.replace(/"/g, '\\"')}"` : encodeHeaderWord(name)} <${m[2]}>`;
}
export function foldHeader(name, value) {
const line = `${name}: ${value}`;
if (line.length <= 78 || /\r\n/.test(value)) return line;
// fold at commas or spaces
const out = []; let current = '';
for (const piece of line.split(/(?<=,)\s+|\s+/)) {
if (current && (current + ' ' + piece).length > 76) { out.push(current); current = ' ' + piece; } else current = current ? current + ' ' + piece : piece;
}
out.push(current); return out.join(CRLF);
}
export function base64Lines(buffer) { return Buffer.from(buffer).toString('base64').replace(/(.{76})/g, `$1${CRLF}`).replace(/\r\n$/, ''); }
function boundary(tag) { return `----=_ari_${tag}_${crypto.randomUUID().replace(/-/g, '')}`; }
function part(headers, body) { return headers.join(CRLF) + CRLF + CRLF + body; }
/**
* @param {object} m
* @param {string} m.from @param {string[]} m.to @param {string[]} [m.cc] @param {string[]} [m.bcc] @param {string[]} [m.replyTo]
* @param {string} m.subject @param {string|Date} [m.date] @param {string} [m.messageId]
* @param {Record<string,string>} [m.headers] @param {string} [m.text] @param {string} [m.html]
* @param {{filename:string,contentType:string,bytes:Buffer,contentId?:string,disposition?:string}[]} [m.attachments]
*/
export function buildEml(m) {
const date = m.date ? new Date(m.date) : new Date();
const headers = [];
const custom = Object.fromEntries(Object.entries(m.headers || {}).map(([k, v]) => [k.toLowerCase(), [k, v]]));
// 표준 헤더는 현재 값으로만 씁니다. 값이 비어 있어도 custom(headers_json 에 남은 옛 값)에서 지워, IMAP 으로 올린 초안의 Cc·Reply-To 를 웹에서 지운 뒤
// 재구성한 원본에 옛 받는 사람이 되살아나지 않게 합니다(0.9.0 수정). Bcc 도 재구성 원본에는 넣지 않습니다.
const push = (name, value) => { delete custom[name.toLowerCase()]; if (value === undefined || value === null || value === '') return; headers.push(foldHeader(name, value)); };
push('Date', date.toUTCString().replace('GMT', '+0000'));
push('From', formatAddress(m.from));
push('To', (m.to || []).map(formatAddress).join(', '));
push('Cc', (m.cc || []).map(formatAddress).join(', '));
push('Reply-To', (m.replyTo || []).map(formatAddress).join(', '));
// Bcc 는 현재 값(m.bcc, 초안의 bcc_json)으로만 씁니다. headers_json 에 남은 옛 Bcc 는 쓰지 않습니다.
push('Bcc', (m.bcc || []).map(formatAddress).join(', '));
push('Subject', encodeHeaderWord(m.subject || ''));
// Message-ID 의 도메인은 이 게이트웨이의 메일 도메인(MAILBOX 의 @ 뒤)입니다. 다른 사이트에 독립 설치해도 남의 도메인이 찍히지 않게 합니다.
push('Message-ID', m.messageId || `<${crypto.randomUUID()}@${String(process.env.MAILBOX || 'admin@arihakdang.com').split('@')[1] || 'arihakdang.com'}>`);
push('MIME-Version', '1.0');
for (const [k, v] of Object.values(custom)) {
if (/^(content-|mime-version|received$|return-path$|delivered-to$)/i.test(k)) continue;
if (!/^[!-9;-~]+$/.test(k) || /[\r\n]/.test(String(v))) continue;
headers.push(foldHeader(k, isAscii(String(v)) ? String(v) : encodeHeaderWord(String(v))));
}
const text = m.text ?? '', html = m.html ?? '';
const textPart = part(['Content-Type: text/plain; charset=UTF-8', 'Content-Transfer-Encoding: base64'], base64Lines(Buffer.from(text || ' ', 'utf8')));
const htmlPart = html ? part(['Content-Type: text/html; charset=UTF-8', 'Content-Transfer-Encoding: base64'], base64Lines(Buffer.from(html, 'utf8'))) : null;
const inline = (m.attachments || []).filter((a) => a.contentId && (a.disposition || 'inline') === 'inline');
const files = (m.attachments || []).filter((a) => !inline.includes(a));
const attachmentPart = (a) => part([
`Content-Type: ${a.contentType || 'application/octet-stream'}; name="${encodeHeaderWord(a.filename || 'attachment').replace(/"/g, '')}"`,
'Content-Transfer-Encoding: base64',
`Content-Disposition: ${a.contentId && inline.includes(a) ? 'inline' : 'attachment'}; filename="${encodeHeaderWord(a.filename || 'attachment').replace(/"/g, '')}"`,
...(a.contentId ? [`Content-ID: <${String(a.contentId).replace(/^<|>$/g, '')}>`] : []),
], base64Lines(a.bytes));
let body, contentType;
let alternative;
if (htmlPart) {
const b = boundary('alt');
alternative = { contentType: `multipart/alternative; boundary="${b}"`, body: `--${b}${CRLF}${textPart}${CRLF}--${b}${CRLF}${htmlPart}${CRLF}--${b}--` };
} else alternative = { contentType: 'text/plain; charset=UTF-8', body: base64Lines(Buffer.from(text || ' ', 'utf8')), encoding: 'base64' };
let main = alternative;
if (inline.length) {
const b = boundary('rel');
main = { contentType: `multipart/related; boundary="${b}"`, body: `--${b}${CRLF}${part([`Content-Type: ${alternative.contentType}`, ...(alternative.encoding ? ['Content-Transfer-Encoding: base64'] : [])], alternative.body)}${inline.map((a) => `${CRLF}--${b}${CRLF}${attachmentPart(a)}`).join('')}${CRLF}--${b}--` };
}
if (files.length) {
const b = boundary('mix');
contentType = `multipart/mixed; boundary="${b}"`;
body = `--${b}${CRLF}${part([`Content-Type: ${main.contentType}`, ...(main.encoding ? ['Content-Transfer-Encoding: base64'] : [])], main.body)}${files.map((a) => `${CRLF}--${b}${CRLF}${attachmentPart(a)}`).join('')}${CRLF}--${b}--`;
} else { contentType = main.contentType; body = main.body; if (main.encoding) headers.push('Content-Transfer-Encoding: base64'); }
headers.push(foldHeader('Content-Type', contentType));
return Buffer.from(headers.join(CRLF) + CRLF + CRLF + body + CRLF, 'utf8');
}
/** POP 전송용: CRLF 정규화와 점 스터핑(RFC 1939 §3). */
export function normalizeCrlf(buffer) {
const s = Buffer.from(buffer).toString('latin1').replace(/\r?\n/g, '\r\n');
return Buffer.from(s, 'latin1');
}
export function dotStuff(buffer) {
const s = Buffer.from(buffer).toString('latin1').replace(/(^|\r\n)\./g, '$1..');
return Buffer.from(s, 'latin1');
}
/** 헤더와 본문을 분리합니다(TOP 명령용). */
export function splitHeader(buffer) {
const s = Buffer.from(buffer).toString('latin1');
const i = s.indexOf('\r\n\r\n');
if (i < 0) return { header: Buffer.from(s, 'latin1'), body: Buffer.alloc(0) };
return { header: Buffer.from(s.slice(0, i + 2), 'latin1'), body: Buffer.from(s.slice(i + 4), 'latin1') };
}
+244
View File
@@ -0,0 +1,244 @@
/**
* POP3 서버 (RFC 1939 + RFC 2449 CAPA + RFC 2595 STLS).
*
* - 계정: www.arihakdang.com 관리자 계정(login_id 또는 email + 비밀번호). 별도 비밀번호 없음.
* - 메일드롭: 받은 메일함(inbox)의 수신 메일 중 이 계정에 아직 전달하지 않은 것.
* - 중복 방지: RETR 또는 DELE 한 메일은 QUIT 시점에 pop_deliveries 에 기록되어 같은 계정에 다시 내려가지 않습니다.
* 서버의 저장본은 지우지 않으며, 웹 메일함에서 영구 삭제하기 전까지 기한 없이 계속 볼 수 있습니다.
* - 평문 포트(110): 995 는 즉시 TLS 이고, 110 은 STLS 뒤에 로그인하는 것이 원칙입니다. 다만 Gmail 의 "다른 계정에서 메일 확인" 처럼
* STLS 를 지원하지 않는 클라이언트가 110 으로 USER/PASS 를 바로 보내므로, 관리자 설정 "평문 로그인 허용"(services.plainLogin, 기본 켬)이
* 켜져 있으면 TLS 없이도 로그인을 받습니다(0.9.7). 꺼져 있으면 예전처럼 `-ERR [SYS/PERM] use STLS or port 995 before login` 으로 거절합니다.
* 접속마다 설정을 한 번 읽어(plainOk) CAPA 광고와 로그인 허용에 같은 값을 씁니다.
* - 세션 로그: 접속·인증·거부 외에 세션이 끝날 때 한 줄(내려준 통수·삭제 표시·걸린 시간·끝난 이유)을 남겨 "되다말다" 하는 증상을 추적할 수 있게 합니다(0.9.7).
*/
import net from 'node:net';
import tls from 'node:tls';
import { verifyAdmin, serviceAllowed } from './auth.mjs';
import { serviceEnabled, plainLoginAllowed } from './limits.mjs';
import { normalizeCrlf, dotStuff, splitHeader } from './mime.mjs';
import { now, memoryRateStore } from './util.mjs';
const CRLF = '\r\n';
/** latin1 로 읽은 줄(문자 = 바이트)을 UTF-8 문자열로 되돌립니다. */
const utf8 = (s) => Buffer.from(s, 'latin1').toString('utf8');
// user_id -> 그 메일함을 잠근 세션. RFC 1939 의 메일드롭 잠금: 같은 계정이 동시에 두 세션을 열면 두 번째는 [IN-USE] 로 거절합니다.
// 잠금은 세션이 끝날 때(QUIT·끊김·오류·시간 초과) 풉니다. 상대가 FIN 없이 사라진 소켓은 10분 유휴 시간 초과로 정리됩니다.
const locks = new Map();
const rate = memoryRateStore();
/** 잠금을 든 세션이 아직 살아 있는지. closed 표시가 없어도 소켓이 이미 파기됐으면(오류 뒤 close 이벤트 지연 등) 잠금을 비어 있는 것으로 봅니다. */
const lockHeld = (userId) => { const s = locks.get(userId); return !!s && !s.closed && !s.socket?.destroyed; };
export async function maildrop(env, userId, mailbox = null) {
mailbox = mailbox || String(env.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase();
const rows = (await env.DB.prepare(`SELECT m.id, m.raw_size FROM messages m WHERE m.mailbox=? AND m.direction='inbound' AND m.folder='inbox' AND m.raw_status IN ('stored','built')
AND NOT EXISTS (SELECT 1 FROM pop_deliveries d WHERE d.message_id=m.id AND d.user_id=?) ORDER BY m.received_at ASC, m.id ASC LIMIT 5000`).bind(mailbox, userId).all()).results;
return rows.map((r) => ({ id: r.id, size: Number(r.raw_size) || 0 }));
}
async function loadEml(env, id) {
const row = await env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(id).first();
return row ? normalizeCrlf(Buffer.from(row.eml)) : null;
}
/**
* QUIT 시점에 이 세션이 내려준(RETR)·삭제 표시한(DELE) 메일을 pop_deliveries 에 기록합니다.
* 세션 중에 웹(영구 삭제)이나 IMAP(휴지통 비우기)으로 이미 지워진 메일은 건너뜁니다(WHERE EXISTS). 예전에는 그런 행 하나가 외래 키 위반을 일으켜
* 묶음 전체가 되돌아갔고, QUIT 이 -ERR 로 끝나며 그 세션의 다른 메일도 전부 기록되지 않아 다음 접속에 다시 내려갔습니다(0.9.7).
*/
export async function commitDeliveries(env, userId, entries) {
if (!entries.length) return;
const stamp = now();
await env.DB.batch(entries.map(([id, via]) => env.DB.prepare('INSERT OR REPLACE INTO pop_deliveries(user_id,message_id,delivered_at,via) SELECT ?,?,?,? WHERE EXISTS(SELECT 1 FROM messages WHERE id=?)').bind(userId, id, stamp, via, id)));
}
class Session {
constructor(server, socket, { secure }) {
this.server = server; this.env = server.env; this.secure = secure; this.state = 'auth'; this.user = null; this.pendingUser = '';
this.messages = []; this.deleted = new Set(); this.retrieved = new Set(); this.failures = 0; this.buffer = ''; this.closed = false;
this.startedAt = Date.now(); this.endReason = 'close'; this.summarized = false;
// plainOk: 이 접속에서 TLS 없이 로그인을 받을지. TLS 접속이면 항상 참. 평문 접속은 서버 옵션(allowPlaintext 가 true/false 로 고정) 또는
// 관리자 설정(plainLoginAllowed)을 따릅니다. 설정 읽기는 비동기라 명령 큐의 첫 작업으로 두어, 첫 명령(CAPA/USER)이 처리될 때는 값이 정해져 있습니다.
this.plainOk = secure || server.allowPlaintext === true;
this.queue = this.loadPlainLogin();
this.ip = socket.remoteAddress || ''; this.attach(socket);
console.log(`POP3 접속 ${this.ip} ${secure ? 'tls' : 'plain'}`);
this.write(`+OK ${server.hostname} POP3 ready`);
}
async loadPlainLogin() {
if (this.secure || typeof this.server.allowPlaintext === 'boolean') return;
try { this.plainOk = await plainLoginAllowed(this.env); } catch (e) { console.error('POP3 평문 로그인 설정 읽기 실패', e?.message); this.plainOk = false; }
}
attach(socket) {
this.socket = socket; socket.setTimeout(10 * 60 * 1000);
socket.on('data', (chunk) => this.onData(chunk));
socket.on('timeout', () => { this.endReason = 'timeout'; this.close('-ERR timeout'); });
socket.on('error', (e) => { if (this.endReason === 'close') this.endReason = 'error:' + (e?.code || e?.message || 'unknown'); this.finish(); });
socket.on('close', () => this.finish());
}
write(line) { if (!this.closed) this.socket.write(line + CRLF); }
close(line) { if (line) this.write(line); this.closed = true; try { this.socket.end(); } catch {} this.finish(); }
/** 세션 종료 공통 처리: 잠금 해제 + 요약 로그 한 줄(로그인한 세션만, 한 번만). close·error·close 이벤트가 겹쳐 불려도 안전합니다. */
finish() {
if (this.user && locks.get(this.user.id) === this) locks.delete(this.user.id);
this.closed = true;
if (this.user && !this.summarized) {
this.summarized = true;
const secs = ((Date.now() - this.startedAt) / 1000).toFixed(1);
console.log(`POP3 종료 ${this.ip} user=${this.user.login} 메일 ${this.messages.length}통 중 RETR ${this.retrieved.size}·DELE ${this.deleted.size} ${secs}s (${this.endReason})`);
}
}
onData(chunk) {
// STLS 를 받은 뒤 TLS 전환 전까지 평문으로 오는 바이트는 버립니다(아래 설명).
if (this.upgrading) return;
this.buffer += chunk.toString('latin1');
let i;
while ((i = this.buffer.indexOf('\n')) >= 0) {
const line = this.buffer.slice(0, i).replace(/\r$/, ''); this.buffer = this.buffer.slice(i + 1);
if (line.length > 4096) { this.close('-ERR line too long'); return; }
this.queue = this.queue.then(() => this.handle(line)).catch((e) => { console.error('POP3 오류', e?.message); this.write('-ERR internal error'); });
// STLS 명령 주입 방지(CVE-2011-0411 유형): 같은 평문 묶음에서 STLS 뒤에 붙어 온 명령은 TLS 전환 뒤에 암호화된 명령처럼
// 실행될 수 있으므로 버립니다. 클라이언트는 STLS 응답을 받은 뒤에만 다음 명령을 보내야 합니다(RFC 2595).
if (/^\s*STLS\s*$/i.test(line)) { this.upgrading = true; this.buffer = ''; return; }
}
// 줄 길이 검사는 줄 단위로 합니다. 예전에는 쌓인 버퍼 전체가 4KB 를 넘으면 끊어, 파이프라인으로 여러 명령을 보내는 클라이언트가 끊겼습니다.
if (this.buffer.length > 4096) this.close('-ERR line too long');
}
async handle(line) {
if (this.closed) return;
const [cmd, ...rest] = line.trim().split(/\s+/); const command = (cmd || '').toUpperCase(); const args = rest;
if (command === 'QUIT') return this.quit();
if (command === 'CAPA') return this.capa();
if (command === 'NOOP') return this.write('+OK');
if (this.state === 'auth') return this.authCommand(command, args, line);
return this.transactionCommand(command, args);
}
capa() {
const caps = ['TOP', 'UIDL', 'RESP-CODES', 'PIPELINING', 'IMPLEMENTATION arihakdang-mail-gateway'];
if (this.plainOk) caps.push('USER', 'SASL PLAIN'); // 평문 접속에서 로그인을 안 받으면 USER 를 광고하지 않습니다(RFC 2449)
if (!this.secure && this.server.secureContext) caps.push('STLS');
this.write('+OK Capability list follows'); for (const c of caps) this.write(c); this.write('.');
}
async authCommand(command, args, line) {
const loginAllowed = this.plainOk; // TLS 접속이면 항상 참, 평문 접속이면 관리자 설정(또는 서버 옵션)
if (command === 'STLS') {
this.upgrading = false; // 아래에서 거절하면 평문 입력을 다시 받습니다. 전환에 성공하면 새 TLS 소켓으로 받습니다.
if (this.secure) return this.write('-ERR already using TLS');
if (!this.server.secureContext) return this.write('-ERR TLS not available');
this.write('+OK Begin TLS negotiation');
const plain = this.socket; plain.removeAllListeners('data'); plain.removeAllListeners('timeout'); plain.removeAllListeners('error'); plain.removeAllListeners('close');
const secureSocket = new tls.TLSSocket(plain, { isServer: true, secureContext: this.server.secureContext, SNICallback: (n, cb) => cb(null, this.server.secureContext) });
this.secure = true; this.plainOk = true; this.buffer = ''; this.attach(secureSocket); return;
}
if (!loginAllowed && ['USER', 'PASS', 'AUTH', 'APOP'].includes(command)) { console.log(`POP3 평문 로그인 거부 ${this.ip} (STLS 필요)`); return this.write('-ERR [SYS/PERM] use STLS or port 995 before login'); }
// USER/PASS 인자는 줄을 latin1 로 읽었으므로 바이트 그대로입니다. 한글 등 비 ASCII 비밀번호는 UTF-8 로 되돌려야 웹·IMAP·AUTH PLAIN 과 같은 값이 됩니다(0.9.7).
// 예전에는 latin1 문자열을 그대로 검사해 비 ASCII 비밀번호가 POP USER/PASS 에서만 항상 틀렸습니다.
if (command === 'USER') { this.pendingUser = utf8(args.join(' ')); return this.write('+OK name accepted'); }
if (command === 'PASS') { const password = utf8(line.trim().slice(5)); return this.login(this.pendingUser, password); }
if (command === 'AUTH') {
if ((args[0] || '').toUpperCase() !== 'PLAIN') return this.write('-ERR unsupported mechanism');
let payload = args[1];
if (!payload) { this.write('+ '); this.awaitingSasl = true; return; }
return this.saslPlain(payload);
}
if (this.awaitingSasl) { this.awaitingSasl = false; if (line.trim() === '*') return this.write('-ERR cancelled'); return this.saslPlain(line.trim()); }
return this.write('-ERR unknown command');
}
saslPlain(payload) {
let parts; try { parts = Buffer.from(payload, 'base64').toString('utf8').split('\0'); } catch { return this.write('-ERR invalid'); }
if (parts.length < 3) return this.write('-ERR invalid');
return this.login(parts[1], parts[2]);
}
async login(login, password) {
if (rate.blocked('pop:' + this.ip, { limit: 20 })) return this.close('-ERR [AUTH] too many attempts');
// 1.0.7 다중 도메인: 로그인 ID 의 @도메인(또는 TLS SNI 호스트)으로 어느 도메인의 계정인지 고릅니다. 모르는·꺼 둔 도메인이면 인증 실패와 같게 처리합니다.
const env = this.server.resolveEnv ? this.server.resolveEnv(login, sniName(this.socket)) : this.env;
const user = env ? await verifyAdmin(env, login, password) : null;
if (user) this.env = env;
if (!user) rate.hit('pop:' + this.ip, { limit: 20, windowSec: 900 }); // 실패만 셉니다(성공한 접속은 제한에 들지 않음)
console.log(`POP3 인증 ${user ? '성공' : '실패'} ${this.ip} user=${String(login).slice(0, 80)}${this.secure ? '' : ' (plain)'}`);
if (!user) {
this.failures++; await new Promise((r) => setTimeout(r, this.env.POP_AUTH_DELAY_MS ?? 1500));
if (this.failures >= 3) return this.close('-ERR [AUTH] authentication failed');
return this.write('-ERR [AUTH] authentication failed');
}
// 관리자가 POP 서비스 자체를 꺼 두었으면(0.9.0, 관리자 페이지 설정) 모든 계정의 접속을 거부합니다.
if (!(await serviceEnabled(this.env, 'pop'))) { console.log(`POP3 접속 거부 ${this.ip} user=${user.login} (관리자가 POP 서비스를 껐음)`); return this.close('-ERR [SYS/PERM] POP service is disabled by the administrator'); }
// 계정 설정에서 POP 접속을 꺼 두었으면 비밀번호가 맞아도 거부합니다(0.8.4~). 비밀번호 오류와 구분되는 문구를 돌려줍니다.
if (!(await serviceAllowed(this.env, user, 'pop'))) { console.log(`POP3 접속 거부 ${this.ip} user=${user.login} (계정 설정에서 POP 사용 안 함)`); return this.close('-ERR [SYS/PERM] POP access is disabled for this account'); }
// 같은 계정의 다른 세션이 아직 열려 있으면 거절합니다. 예전에는 로그가 없어 "비밀번호는 맞는데 가끔 실패" 로 보였습니다(0.9.7 부터 기록).
if (lockHeld(user.id)) { const other = locks.get(user.id); console.log(`POP3 접속 거부 ${this.ip} user=${user.login} (다른 세션 ${other.ip} 이 ${Math.round((Date.now() - other.startedAt) / 1000)}s 전부터 메일함 사용 중)`); return this.write('-ERR [IN-USE] mailbox is locked by another session'); }
locks.set(user.id, this); this.user = user; this.state = 'transaction';
this.messages = await maildrop(this.env, user.id, user.mailbox);
this.write(`+OK maildrop has ${this.messages.length} messages (${this.total()} octets)`);
}
total() { return this.messages.reduce((n, m, i) => this.deleted.has(i) ? n : n + m.size, 0); }
index(arg) { const n = Number(arg); if (!Number.isInteger(n) || n < 1 || n > this.messages.length || this.deleted.has(n - 1)) return -1; return n - 1; }
async transactionCommand(command, args) {
const live = () => this.messages.length - this.deleted.size;
if (command === 'STAT') return this.write(`+OK ${live()} ${this.total()}`);
if (command === 'LIST' || command === 'UIDL') {
const value = (m) => command === 'LIST' ? m.size : m.id;
if (args[0] !== undefined) { const i = this.index(args[0]); if (i < 0) return this.write('-ERR no such message'); return this.write(`+OK ${i + 1} ${value(this.messages[i])}`); }
this.write(`+OK ${live()} messages (${this.total()} octets)`);
this.messages.forEach((m, i) => { if (!this.deleted.has(i)) this.write(`${i + 1} ${value(m)}`); }); return this.write('.');
}
if (command === 'RETR' || command === 'TOP') {
const i = this.index(args[0]); if (i < 0) return this.write('-ERR no such message');
const eml = await loadEml(this.env, this.messages[i].id); if (!eml) return this.write('-ERR message unavailable');
let out = eml;
if (command === 'TOP') { const lines = Number(args[1]); if (!Number.isInteger(lines) || lines < 0) return this.write('-ERR bad argument'); const { header, body } = splitHeader(eml); const bodyLines = body.toString('latin1').split(CRLF).slice(0, lines).join(CRLF); out = Buffer.concat([header, Buffer.from(CRLF + bodyLines, 'latin1')]); }
const data = dotStuff(out);
this.write(`+OK ${data.length} octets`);
if (!this.closed) this.socket.write(data);
if (!data.length || !data.subarray(-2).equals(Buffer.from(CRLF))) this.write('');
this.write('.');
if (command === 'RETR') this.retrieved.add(i);
return;
}
if (command === 'DELE') { const i = this.index(args[0]); if (i < 0) return this.write('-ERR no such message'); this.deleted.add(i); return this.write(`+OK message ${i + 1} deleted`); }
if (command === 'RSET') { this.deleted.clear(); return this.write(`+OK maildrop has ${this.messages.length} messages (${this.total()} octets)`); }
if (command === 'LAST') { return this.write(`+OK ${Math.max(0, ...[...this.retrieved].map((i) => i + 1))}`); }
return this.write('-ERR unknown command');
}
async quit() {
this.endReason = 'quit';
if (this.state === 'transaction' && this.user) {
const entries = [];
for (const i of new Set([...this.retrieved, ...this.deleted])) entries.push([this.messages[i].id, this.deleted.has(i) ? 'dele' : 'retr']);
try { await commitDeliveries(this.env, this.user.id, entries); } catch (e) { console.error('POP 전달 기록 실패', e.message); return this.close('-ERR some deleted messages not removed'); }
}
this.close(`+OK ${this.server.hostname} signing off`);
}
}
/**
* @param {{key:string|Buffer,cert:string|Buffer}|null} tlsOptions 인증서 PEM. 갱신 시 setTls 로 교체합니다.
* @param {boolean|null} allowPlaintext 평문 포트의 TLS 없는 로그인: true 면 항상 허용, false 면 항상 거부(테스트·진단), null(기본)이면
* 접속마다 관리자 설정 "평문 로그인 허용" 을 읽습니다(0.9.7). index.mjs 는 POP_ALLOW_PLAINTEXT=1 일 때만 true 를, 그 밖에는 null 을 넘깁니다.
*/
/** TLS 접속의 SNI 서버 이름(평문 접속이면 ''). 도메인 없이 로그인한 사용자가 어느 도메인의 사용자인지 짐작하는 데 씁니다(1.0.7). */
const sniName = (socket) => String(socket?.servername || '');
/** resolveEnv(login, servername)(1.0.7): 로그인마다 쓸 env 를 고르는 함수(domains.mjs resolveLogin). 없으면 env 하나로 동작(단일 도메인). */
export function createPop3(env, { hostname = 'mail.arihakdang.com', tlsOptions = null, allowPlaintext = null, resolveEnv = null } = {}) {
const shared = { env, hostname, secureContext: tlsOptions ? tls.createSecureContext(tlsOptions) : null, allowPlaintext, resolveEnv };
const plain = net.createServer((socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: false }); });
// secure 서버는 인증서가 없어도 만들어 둡니다(핸드셰이크는 setSecureContext 뒤에만 성공). 인증서가 없으면 포트를 열지 않고 기억만 합니다(아래 pendingSecure).
const secure = tls.createServer({ ...(tlsOptions || {}), minVersion: 'TLSv1.2' }, (socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: true }); });
secure.on('tlsClientError', () => {});
let pendingSecure = null;
// 0.9.7: 인증서가 시작 때 없거나 읽히지 않았으면 TLS 포트를 바로 열 수 없으므로 포트를 기억해 두고(pendingSecure), 시간마다 돌아오는 setTls 가
// 인증서를 넣을 때 그 포트를 엽니다. 예전에는 secure 서버를 만들지 않아 setTls 가 아무 일도 하지 않았고, 재시작 전까지 TLS 포트가 닫힌 채였습니다.
const listenOn = (server, port, host, label, out) => new Promise((res, rej) => server.once('error', rej).listen(port, host, () => { out.push(`${label} ${host}:${port}`); res(); }));
return {
plain, secure,
setTls(options) {
shared.secureContext = tls.createSecureContext(options); secure.setSecureContext(options);
if (pendingSecure && !secure.listening) { const { port, host } = pendingSecure; pendingSecure = null; listenOn(secure, port, host, 'pop3s', []).then(() => console.log(` POP3 TLS 포트를 열었습니다: ${host}:${port}`)).catch((e) => console.error(' POP3 TLS 포트 열기 실패:', e.message)); }
},
async listen({ plainPort, securePort, host = '0.0.0.0' }) {
const out = [];
if (plainPort) await listenOn(plain, plainPort, host, 'pop3', out);
if (securePort) { if (shared.secureContext) await listenOn(secure, securePort, host, 'pop3s', out); else pendingSecure = { port: securePort, host }; }
return out;
},
close() { plain.close(); if (secure.listening) secure.close(); },
};
}
@@ -0,0 +1,69 @@
/**
* 메일 서비스 모듈 선택(1.0.0, 요구사항 2) — 발송·수신 "길" 을 설정(config.json 의 inbound / outbound)으로 고릅니다.
*
* 발신(outbound)
* resend : Resend REST API(providers/resend.mjs). 예전과 같은 기본값.
* haraka : 내장 Haraka 의 로컬 제출 포트(127.0.0.1:HARAKA_RELAY_PORT, 암호화·인증 없음)로 넘기면 Haraka 가 받는 쪽 MX 로 직접 배달합니다.
* smtp : 외부 SMTP 중계 서버(SMTP_RELAY_*)로 넘깁니다(회사 메일 서버, 다른 발송 서비스의 SMTP 등).
* 수신(inbound)
* resend : Resend 웹훅 + 목록 동기화(store.mjs).
* haraka : 내장 Haraka 가 25 포트로 받은 메일을 로컬 HTTP(/api/inbound/haraka)로 넘겨 줍니다(store.mjs ingestRaw).
*
* 발송 호출 지점은 모두 sendMail(env, payload, key) 하나입니다. payload 는 Resend 요청과 같은 모양(from, to, cc, bcc, reply_to, subject,
* text, html, headers, attachments[{filename, content(base64), content_type, content_id}])이고 돌려주는 값은 { id } 입니다.
* 새 발송 서비스를 더하려면 providers/<이름>.mjs 에 같은 모양으로 send 를 만들고 여기 outboundMode·sendMail·outboundStatus 에 한 줄씩 더합니다.
*/
import { resend, providerStatus as resendStatus } from './resend.mjs';
import { sendViaSmtp } from './smtp.mjs';
import { smtpProbe } from './smtp-client.mjs';
import { now } from '../util.mjs';
export const OUTBOUND_MODES = ['resend', 'haraka', 'smtp'], INBOUND_MODES = ['resend', 'haraka'];
/** 설정된 발신 방식(알 수 없는 값은 resend). env.MAIL_OUTBOUND 는 config.mjs 의 applyConfig 가 채웁니다. */
export const outboundMode = (env) => (OUTBOUND_MODES.includes(env.MAIL_OUTBOUND) ? env.MAIL_OUTBOUND : 'resend');
/** 설정된 수신 방식(알 수 없는 값은 resend). */
export const inboundMode = (env) => (INBOUND_MODES.includes(env.MAIL_INBOUND) ? env.MAIL_INBOUND : 'resend');
/** Resend 를 어느 쪽으로든 쓰는지(동기화·웹훅·도메인 상태 확인이 의미 있는지). */
export const usesResend = (env) => inboundMode(env) === 'resend' || outboundMode(env) === 'resend';
/** 내장 Haraka 를 어느 쪽으로든 쓰는지(haraka/run.mjs 가 Haraka 를 띄울지). */
export const usesHaraka = (env) => inboundMode(env) === 'haraka' || outboundMode(env) === 'haraka';
/** 발신 방식에 따른 중계 서버 접속 정보. haraka 는 로컬 고정, smtp 는 SMTP_RELAY_* 설정. */
export function relaySettings(env, mode = outboundMode(env)) {
// haraka: 서버에 하나뿐인 Haraka 의 제출 포트는 기본 도메인의 설정(HARAKA_RELAY_PORT)이 정합니다(1.0.7: 추가 도메인도 같은 포트로 넘김).
if (mode === 'haraka') { const primary = env.TENANTS?.primary?.env || env; return { host: '127.0.0.1', port: Number(primary.HARAKA_RELAY_PORT || 2525), security: 'none', user: '', pass: '' }; }
if (mode === 'smtp') return { host: String(env.SMTP_RELAY_HOST || '').trim(), port: Number(env.SMTP_RELAY_PORT || 587), security: ['none', 'starttls', 'tls'].includes(env.SMTP_RELAY_SECURITY) ? env.SMTP_RELAY_SECURITY : 'starttls', user: String(env.SMTP_RELAY_USER || ''), pass: String(env.SMTP_RELAY_PASS || ''), rejectUnauthorized: env.SMTP_RELAY_INSECURE !== '1' };
return null;
}
/**
* 메일 한 통 발송. 어느 모듈을 쓰든 { id } 를 돌려주고, 실패는 공통 오류 코드(mail_not_configured / mail_auth_failed / mail_rejected /
* mail_rate_limited / mail_provider_unavailable)의 Error 로 던집니다(providerMessage 에 서비스 응답).
* @param {string} [key] 멱등 키(Resend 만 씀)
*/
export async function sendMail(env, payload, key) {
const mode = outboundMode(env);
if (mode === 'resend') return resend(env, '/emails', 'POST', payload, key);
return sendViaSmtp(env, payload, relaySettings(env, mode));
}
// 상태 캐시는 env(= DB) 마다 따로 둡니다(시험처럼 한 프로세스에 여러 env 가 있을 때 섞이지 않게). 운영은 env 가 하나라 전역 캐시와 같습니다.
const statusCaches = new WeakMap();
/**
* 발신 서비스 상태(설정 화면·상태 화면). resend 는 도메인 검증 상태까지, haraka/smtp 는 중계 서버 접속·EHLO(·AUTH) 확인입니다.
* 60초 캐시(force 로 즉시). 돌려주는 모양은 예전 providerStatus 와 같고 mode 와 relay 를 더합니다.
* @returns {Promise<{mode:string, provider:string, detail?:string, domain?:object, relay?:object, checkedAt:string}>}
*/
export async function outboundStatus(env, { force = false } = {}) {
const mode = outboundMode(env), key = mode + JSON.stringify(relaySettings(env, mode) || {}) + (env.RESEND_API_KEY ? 'k' : '');
const statusCache = statusCaches.get(env.DB || env) || { at: 0, key: '', value: null };
if (!force && statusCache.value && statusCache.key === key && Date.now() - statusCache.at < 60000) return statusCache.value;
let value;
if (mode === 'resend') value = { mode, ...(await resendStatus(env, { force })) };
else {
const relay = relaySettings(env, mode);
const probe = await smtpProbe({ ...relay, hostname: env.PUBLIC_HOST || 'localhost' });
value = { mode, provider: probe.ok ? 'ok' : probe.error, detail: probe.detail, relay: { host: relay.host, port: relay.port, security: relay.security, auth: !!relay.user }, checkedAt: now() };
}
statusCaches.set(env.DB || env, { at: Date.now(), key, value }); return value;
}
+223
View File
@@ -0,0 +1,223 @@
/**
* Resend 발송·수신 서비스 모듈(providers/resend.mjs). 1.0.0 에서 server/resend.mjs 를 이 디렉터리로 옮겼습니다(요구사항 2: 발송 모듈화).
* 하는 일: Resend REST API 호출(직렬화·간격·오류 코드), 서명된 다운로드 주소에서 원본·첨부 받기, 도메인 상태 확인.
* 발송 자체는 providers/index.mjs 의 sendMail() 이 설정(outbound)에 따라 이 모듈 또는 SMTP 중계 모듈을 고릅니다.
* www 사이트(server/mailbox.mjs)와 같은 직렬화·간격·오류 코드 규칙을 씁니다.
*/
import { now } from '../util.mjs';
import { lookup } from '../dns/common.mjs';
let requestQueue = Promise.resolve(), nextRequestAt = 0;
/**
* Resend 응답 상태 코드 → 공통 오류 코드.
* 1.0.11: 403 이라도 응답 문구가 "도메인이 검증되지 않았다"(The <domain> domain is not verified…)이면 mail_forbidden(키 권한 문제로 읽힘)이 아니라
* mail_domain_unverified 로 구분합니다. 운영에서 "Resend 가 요청을 거부했습니다 … API 키 권한을 확인" 문구가 떴지만 실제 원인은 DNS 레코드(DKIM)
* 불일치로 도메인 검증이 실패한 것이어서, 사용자가 엉뚱한 곳(키 권한)을 살피게 했습니다.
* @param {number} status HTTP 상태
* @param {string} [message] Resend 응답의 message(있으면 403 의 원인 구분에 씀)
*/
export function providerError(status, message = '') {
if (status === 403 && /not verified|verify your domain/i.test(String(message || ''))) return 'mail_domain_unverified';
return status === 401 ? 'mail_auth_failed' : status === 403 ? 'mail_forbidden' : status === 429 ? 'mail_rate_limited'
: status === 400 || status === 422 ? 'mail_rejected' : status === 404 ? 'not_found' : 'mail_provider_unavailable';
}
export async function resend(env, path, method = 'GET', body, key) {
if (!env.RESEND_API_KEY) throw new Error('mail_not_configured');
const execute = async () => {
if (!env.RESEND_FETCH) { await new Promise((r) => setTimeout(r, Math.max(0, nextRequestAt - Date.now()))); nextRequestAt = Date.now() + 600; }
let r;
// 연결 단계 오류(ECONNRESET·연결 시간 초과·DNS)는 한 번 더 시도합니다(1.0.9). 운영 기록에 9일 동안 두 번, 모두 다음 주기에는 정상이던 일시 오류였습니다.
// 응답을 받은 뒤의 오류(4xx·5xx)는 재시도하지 않습니다(발송 POST 는 Idempotency-Key 가 있어 재시도해도 두 번 보내지지 않지만, 거부된 요청을 되풀이할 이유가 없음).
const attempt = () => (env.RESEND_FETCH || fetch)((env.RESEND_API_BASE || 'https://api.resend.com') + path, {
method, headers: { Authorization: 'Bearer ' + env.RESEND_API_KEY, 'Content-Type': 'application/json', ...(key ? { 'Idempotency-Key': key } : {}) },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000),
});
// 재시도 조건: 연결 자체가 안 된 오류 코드(요청이 서버에 닿지 않았음이 확실)이고, 조회(GET)이거나 멱등 키가 있는 발송일 때만. 응답 대기 시간 초과(TimeoutError)는
// 요청이 이미 처리됐을 수 있고(웹훅·도메인 만들기 같은 관리 POST 가 두 번 될 수 있음) 재시도하지 않습니다.
const RETRY_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET', 'EPIPE', 'ETIMEDOUT']);
const retryable = (e) => RETRY_CODES.has(String(e?.cause?.code || e?.code || '')) && (method === 'GET' || !!key);
try { r = await attempt(); }
catch (first) {
const reason = String(first?.cause?.code || first?.name || first?.message || 'network').slice(0, 120);
if (!retryable(first)) { const error = new Error('mail_provider_unavailable'); error.providerMessage = reason; console.error('Resend 연결 실패', method, path, reason); throw error; }
console.warn('Resend 연결 실패 — 2초 뒤 한 번 더 시도', method, path, reason);
await new Promise((res) => setTimeout(res, env.RESEND_RETRY_MS ?? 2000));
try { r = await attempt(); }
catch (e) {
const error = new Error('mail_provider_unavailable'); error.providerMessage = String(e?.cause?.code || e?.name || e?.message || 'network').slice(0, 120);
console.error('Resend 연결 실패', method, path, error.providerMessage); throw error;
}
}
const data = await r.json().catch(() => ({}));
if (!r.ok) {
const error = new Error(providerError(r.status, data.message)); error.providerStatus = r.status; error.providerMessage = String(data.message || data.name || '').slice(0, 300);
console.error('Resend 실패', method, path, r.status, error.providerMessage); throw error;
}
return data;
};
const result = requestQueue.then(execute, execute); requestQueue = result.catch(() => {}); return result;
}
/** 서명된 다운로드 주소(원본·첨부)에서 바이트를 받습니다. 크기 상한을 넘기면 중단합니다. */
export async function download(env, url, { maxBytes = 40 * 1024 * 1024 } = {}) {
const u = new URL(url);
if (u.protocol !== 'https:') throw new Error('invalid_download');
const r = await (env.DOWNLOAD_FETCH || env.RESEND_FETCH || fetch)(u, { signal: AbortSignal.timeout(120000) });
if (!r.ok) { const e = new Error('download_failed'); e.providerStatus = r.status; throw e; }
const length = Number(r.headers.get('content-length') || 0);
if (length > maxBytes) throw new Error('download_too_large');
const chunks = []; let size = 0;
for await (const chunk of r.body) { size += chunk.length; if (size > maxBytes) throw new Error('download_too_large'); chunks.push(Buffer.from(chunk)); }
return Buffer.concat(chunks);
}
// ------------------------------------------------------------------ 도메인 검증 상태 진단(1.0.11)
/** Resend 도메인 상태 값을 사람이 읽는 말로(화면 문구는 서버가 한 번만 만듭니다). */
export const DOMAIN_STATUS_WORD = { not_started: '검증을 시작하지 않음', pending: '검증 진행 중', verified: '검증 완료', failed: '검증 실패', temporary_failure: '일시 실패' };
/**
* 긴 값(DKIM 공개키 등)을 화면 문구용으로 줄입니다. 비교 상대(other)가 있으면 둘이 같은 앞부분은 건너뛰고 **달라지는 자리**부터 보여 줍니다 —
* RSA 공개키는 앞 30자쯤("p=MIGfMA0GCSqGSIb3DQEBAQUAA4…")이 늘 같아 앞만 자르면 두 값이 똑같아 보였습니다(종단 시험에서 확인).
*/
export function short(v, other = null, n = 28) {
const s = String(v ?? ''); if (s.length <= n) return s;
const o = other === null ? null : String(other);
let cp = 0; if (o !== null) while (cp < s.length && cp < o.length && s[cp] === o[cp]) cp++;
if (o === null || cp < 12) return s.slice(0, n) + '…';
const from = Math.max(0, cp - 4);
return `${s.slice(0, 8)}…${s.slice(from, from + 20)}${from + 20 < s.length ? '…' : ''}`;
}
/**
* Resend 가 요구하는 DNS 레코드가 실제로 세상에 보이는지 레코드마다 확인합니다(공개 리졸버 8.8.8.8/1.1.1.1, dns/common.mjs lookup).
* 운영에서 겪은 문제를 정확히 가리키기 위한 것입니다: "검증 실패" 만으로는 어느 레코드가 빠졌는지, 값이 다른지 알 수 없어 사용자가 API 키 권한을 의심했습니다.
*
* 판정 규칙(레코드마다)
* - ok: 우리 조회로 값이 맞거나, Resend 자신이 verified 라고 하는 레코드(Resend 가 받아들인 값은 그대로 두어야 하므로 — 예: 예전 MX 값
* inbound-smtp.<지역>.amazonaws.com 을 Resend 가 새 값 receive-*.forge.rmta.net 과 같은 것으로 인정하는 경우).
* - problem: 'missing'(조회 결과 없음 — 아직 안 넣었거나 전파 대기) / 'wrong'(다른 값이 보임 — 다른 Resend 등록·계정의 값이거나 오타) / null
* @param {object} env 테넌트 환경(env.DNS_RESOLVER 가 있으면 그 리졸버로 — 시험용)
* @param {object} domain Resend 도메인 상세(records 포함, getDomain 응답)
* @returns {Promise<{ok:boolean, records:Array<{record:string,type:string,name:string,value:string,priority?:number,resendStatus:string,ok:boolean,found:string[],problem:string|null}>}>}
*/
export async function checkResendRecords(env, domain, { resolver = env.DNS_RESOLVER || null } = {}) {
const records = [];
for (const r of resendDnsRecords(domain)) {
const check = await lookup(r, { resolver });
const byResend = String(r.status || '') === 'verified';
const ok = check.ok || byResend;
records.push({ record: r.purpose.replace(/^Resend /, ''), type: r.type, name: r.name, value: r.values[0], ...(r.priority !== undefined ? { priority: r.priority } : {}), resendStatus: String(r.status || 'not_started'), ok, found: check.found || [], problem: ok ? null : check.found && check.found.length ? 'wrong' : 'missing' });
}
return { ok: records.every((r) => r.ok), records };
}
/**
* 검증되지 않은 도메인의 원인을 한 문장으로(상태 화면·목록 상단 표시·설정 화면 점검표에 그대로 보임).
* 예: "검증 실패 — 값이 다른 레코드: TXT resend._domainkey.ibit.kr (지금 p=MIGfMA0GCSqGSIb3DQEBAQUAA4GN… / 필요 p=MIGfMA0GCSqGSIb3DQEBAQUAA4GN…). …"
*/
export function describeUnverified(domain, check) {
const word = DOMAIN_STATUS_WORD[String(domain?.status || '')] || String(domain?.status || '알 수 없음');
const wrong = check.records.filter((r) => r.problem === 'wrong'), missing = check.records.filter((r) => r.problem === 'missing');
const parts = [];
if (wrong.length) parts.push('값이 다른 레코드: ' + wrong.map((r) => `${r.type} ${r.name} (지금 ${short(r.found[0], r.value)} / 필요 ${short(r.value, r.found[0])})`).join(', '));
if (missing.length) parts.push('없는 레코드: ' + missing.map((r) => `${r.type} ${r.name}`).join(', '));
if (!parts.length) parts.push(domain?.status === 'pending' ? 'DNS 레코드는 모두 보입니다. Resend 가 확인을 끝내기를 기다리는 중입니다(보통 몇 분)' : 'DNS 레코드는 모두 보입니다. 검증을 다시 요청하면 됩니다(1시간마다 자동으로 요청)');
const hint = wrong.length ? ' 다른 Resend 계정·예전 등록의 값일 수 있습니다. 관리자 › 메일 서비스 설정 › 자동 설정의 표에 있는 값으로 바꾸면 1시간 안에 자동으로 다시 검증합니다.' : '';
return `${word} — ${parts.join('; ')}.${hint}`;
}
// 도메인 상태 캐시(60초). env 마다 따로 둡니다(시험에서 여러 env 가 섞이지 않게; 운영은 env 하나).
const statusCaches = new WeakMap();
/** 상태 캐시 비우기(검증 요청 직후처럼 다음 조회가 새 값을 보여야 할 때). */
export const clearStatusCache = (env) => statusCaches.delete(env.DB || env);
/**
* 발신 도메인의 Resend 상태.
* ok 도메인이 등록되어 있고 검증 완료
* mail_domain_missing Resend 계정에 이 도메인이 없음(1.0.11 — 예전에는 이 경우를 'ok' 로 보고해 "정상" 으로 보였음)
* mail_domain_unverified 등록은 됐지만 검증이 끝나지 않음(not_started/pending/failed/temporary_failure). records 에 레코드별 진단, detail 에 원인 문장
* 그 밖(mail_auth_failed 등) Resend 호출 자체의 오류 코드
* 60초 캐시(force 로 즉시). 검증되지 않은 경우에만 도메인 상세(레코드)와 공개 DNS 조회를 더 합니다(정상일 때는 호출 하나).
*/
export async function providerStatus(env, { force = false } = {}) {
const statusCache = statusCaches.get(env.DB || env) || { at: 0, value: null };
if (!force && statusCache.value && Date.now() - statusCache.at < 60000) return statusCache.value;
let value;
const domainName = String(env.MAILBOX || '').split('@')[1] || '';
if (!env.RESEND_API_KEY) value = { provider: 'mail_not_configured' };
else try {
const domains = await resend(env, '/domains');
const domain = (domains.data || []).find((d) => String(d.name || '').toLowerCase() === domainName);
if (!domain) value = { provider: 'mail_domain_missing', domain: null, detail: `Resend 계정에 도메인 ${domainName} 이 등록되어 있지 않습니다. 관리자 › 메일 서비스 설정에서 "자동 설정 실행" 을 누르면 등록합니다.` };
else if (domain.status !== 'verified') {
let detail = DOMAIN_STATUS_WORD[domain.status] || String(domain.status), records = [];
// 레코드별 진단은 보조 정보라 실패해도(일시적 DNS 오류 등) 상태 자체는 보고합니다.
try { const full = await resend(env, '/domains/' + encodeURIComponent(domain.id)); const check = await checkResendRecords(env, full); detail = describeUnverified(full, check); records = check.records; } catch (e) { detail += ` — 레코드 진단 실패(${e.providerMessage || e.message})`; }
value = { provider: 'mail_domain_unverified', domain: { name: domain.name, status: domain.status, id: domain.id, region: domain.region }, records, detail };
} else value = { provider: 'ok', domain: { name: domain.name, status: domain.status } };
} catch (e) { value = { provider: e.message, detail: e.providerMessage, providerStatus: e.providerStatus }; }
value.checkedAt = now(); statusCaches.set(env.DB || env, { at: Date.now(), value }); return value;
}
/**
* 자동 재검증(1.0.11): 도메인 검증이 실패(failed/temporary_failure)했거나 시작되지 않았는데 Resend 가 요구하는 레코드가 이제 모두 보이면 검증을 다시 요청합니다.
* Resend 는 레코드가 틀린 채 시간이 지나면 상태를 failed 로 바꾸고 멈추므로, 사용자가 DNS 를 고친 뒤 "검증 요청" 을 다시 누르지 않으면 영영 실패 상태입니다.
* main.mjs 의 정기 동기화(기본 1시간)마다 테넌트별로 부릅니다. 정상(verified)·진행 중(pending)·레코드가 아직 틀린 경우는 아무 것도 하지 않습니다.
* 환경 변수 RESEND_AUTO_VERIFY=0 으로 끌 수 있습니다.
* @returns {Promise<{requested?:boolean, skipped?:string, status?:string, problems?:string[]}>}
*/
export async function autoVerifyDomain(env, { resolver = env.DNS_RESOLVER || null } = {}) {
if (String(env.RESEND_AUTO_VERIFY ?? '1') === '0') return { skipped: 'disabled' };
if (!env.RESEND_API_KEY) return { skipped: 'no_key' };
const domainName = String(env.MAILBOX || '').split('@')[1] || '';
const found = await findDomain(env, domainName);
if (!found) return { skipped: 'missing' };
const status = String(found.status || 'not_started');
if (status === 'verified' || status === 'pending') return { skipped: status, status };
const full = await getDomain(env, found.id);
const check = await checkResendRecords(env, full, { resolver });
if (!check.ok) return { skipped: 'records', status, problems: check.records.filter((r) => !r.ok).map((r) => `${r.type} ${r.name}: ${r.problem}`) };
await verifyDomain(env, found.id);
clearStatusCache(env);
return { requested: true, status };
}
// ------------------------------------------------------------------ 도메인·웹훅 관리(1.0.5, 요구사항 3·4)
// 외부 서비스 설정 자동화(provision.mjs)가 쓰는 Resend 관리 API 묶음입니다. 모두 resend() 를 거치므로 직렬화·오류 코드 규칙이 같습니다.
/** 계정의 도메인 목록. */
export const listDomains = async (env) => (await resend(env, '/domains')).data || [];
/** 도메인 상세(status, capabilities, records[{record,name,type,value,priority,status}]). */
export const getDomain = (env, id) => resend(env, '/domains/' + encodeURIComponent(id));
/**
* 도메인 등록. region 은 발송 서버 위치(수신 MX 값도 지역에 따라 다름). capabilities 로 발송/수신을 켭니다.
* @returns {Promise<object>} 생성된 도메인(records 포함)
*/
export const createDomain = (env, { name, region = 'ap-northeast-1', sending = true, receiving = false }) =>
resend(env, '/domains', 'POST', { name, region, capabilities: { sending: sending ? 'enabled' : 'disabled', receiving: receiving ? 'enabled' : 'disabled' } });
/** 도메인의 발송/수신 기능 켬·끔(PATCH). */
export const updateDomainCapabilities = (env, id, { sending, receiving }) =>
resend(env, '/domains/' + encodeURIComponent(id), 'PATCH', { capabilities: { sending: sending ? 'enabled' : 'disabled', receiving: receiving ? 'enabled' : 'disabled' } });
/** DNS 레코드 검증 요청. 결과는 잠시 뒤 getDomain 의 status 로 확인합니다(pending → verified / failed / temporary_failure). */
export const verifyDomain = (env, id) => resend(env, '/domains/' + encodeURIComponent(id) + '/verify', 'POST', {});
/** 웹훅 목록 [{id, endpoint, events[], status}]. 서명 비밀키는 목록에 없고 만들 때만 돌려줍니다. */
export const listWebhooks = async (env) => (await resend(env, '/webhooks')).data || [];
/** 웹훅 만들기 → { id, signing_secret }. 비밀키는 이때만 받을 수 있으므로 호출자가 바로 설정에 저장합니다. */
export const createWebhook = (env, { endpoint, events = ['email.received'] }) => resend(env, '/webhooks', 'POST', { endpoint, events });
/** 웹훅 삭제(새 비밀키를 받기 위해 다시 만들 때). */
export const deleteWebhook = (env, id) => resend(env, '/webhooks/' + encodeURIComponent(id), 'DELETE');
/**
* 우리 도메인을 찾습니다(없으면 null). 이름 비교는 소문자.
*/
export async function findDomain(env, name) {
const want = String(name || '').toLowerCase();
return (await listDomains(env)).find((d) => String(d.name || '').toLowerCase() === want) || null;
}
/**
* Resend 가 요구하는 DNS 레코드를 공통 모양으로 바꿉니다(dns/index.mjs 의 레코드 모양).
* name: Resend 는 도메인 기준 상대 이름('' = 루트, 'send', 'resend._domainkey') → FQDN
* TXT 값은 그대로(따옴표는 DNS 모듈이 붙임), MX 는 priority 와 함께, CNAME 은 값 그대로.
* @param {object} domain getDomain/createDomain 응답
*/
export function resendDnsRecords(domain) {
const base = String(domain?.name || '').toLowerCase();
return (domain?.records || []).map((r) => ({
name: r.name ? `${String(r.name).toLowerCase()}.${base}` : base, type: String(r.type || 'TXT').toUpperCase(), ttl: 300,
values: [String(r.value || '')], ...(r.priority !== undefined ? { priority: Number(r.priority) } : {}),
purpose: `Resend ${r.record || r.type}`, source: 'resend', status: r.status || null,
}));
}
@@ -0,0 +1,156 @@
/**
* SMTP 클라이언트(1.0.0) — 외부 의존성 없이 node:net / node:tls 로 메일 한 통을 중계 서버에 넘깁니다.
*
* 쓰는 곳: providers/smtp.mjs(발신 방식 haraka / smtp). 내장 Haraka 의 로컬 제출 포트(127.0.0.1:2525, 암호화·인증 없음)나
* 외부 SMTP 중계 서버(587 STARTTLS + 로그인, 465 TLS 등)에 모두 쓸 수 있습니다.
*
* 흐름: 접속 → 220 인사 → EHLO → (STARTTLS → EHLO) → (AUTH PLAIN 또는 LOGIN) → MAIL FROM → RCPT TO(여러 명) → DATA → 본문(점 스터핑) → QUIT.
* 응답은 여러 줄(250-… / 250 …)을 끝 줄까지 모아 코드와 본문으로 돌려줍니다. 시간 제한 안에 응답이 없으면 끊고 실패로 봅니다.
*
* 오류 코드(providers/resend.mjs 의 providerError 와 같은 어휘로 맞춰 화면·SMTP 응답 변환이 공통으로 동작):
* mail_not_configured(중계 서버 주소 없음) · mail_auth_failed(535) · mail_rejected(5xx) · mail_rate_limited(421/450/451/452)
* · mail_provider_unavailable(접속 실패·시간 초과·그 밖의 4xx). providerMessage 에 서버 응답 문구를 남깁니다.
*/
import net from 'node:net';
import tls from 'node:tls';
import { dotStuff } from '../mime.mjs';
const CRLF = '\r\n';
/** 서버 응답 코드를 공통 오류 코드로 바꿉니다. */
export function smtpErrorCode(code, phase) {
if (phase === 'auth' && (code === 535 || code === 534 || code === 530)) return 'mail_auth_failed';
if ([421, 450, 451, 452].includes(code)) return 'mail_rate_limited';
if (code >= 500) return 'mail_rejected';
return 'mail_provider_unavailable';
}
/**
* 메일 한 통을 보냅니다.
* @param {object} o
* @param {string} o.host 중계 서버 주소
* @param {number} o.port 포트(587/465/25/2525)
* @param {'none'|'starttls'|'tls'} [o.security] none = 암호화 없음(로컬 Haraka), starttls = 접속 뒤 STARTTLS(없으면 실패), tls = 접속 즉시 TLS
* @param {string} [o.user] @param {string} [o.pass] 로그인(있을 때만 AUTH)
* @param {string} [o.hostname] EHLO 에 보낼 우리 이름
* @param {string} o.from 봉투 보낸 사람(MAIL FROM)
* @param {string[]} o.to 봉투 받는 사람 전체(To·Cc·Bcc 합침)
* @param {Buffer} o.eml 보낼 원본(CRLF)
* @param {number} [o.timeoutMs] 한 응답을 기다리는 최대 시간
* @param {boolean} [o.rejectUnauthorized] TLS 인증서 검증(기본 true, 로컬 시험용 false)
* @returns {Promise<{response:string, accepted:string[], rejected:{address:string,reply:string}[]}>}
*/
export async function smtpSend({ host, port, security = 'starttls', user = '', pass = '', hostname = 'localhost', from, to, eml, timeoutMs = 60000, rejectUnauthorized = true }) {
if (!host || !port) { const e = new Error('mail_not_configured'); e.providerMessage = 'SMTP relay host/port missing'; throw e; }
let socket = null;
const fail = (code, message, phase) => { const e = new Error(code); e.providerMessage = String(message || '').replace(/[\r\n]+/g, ' ').slice(0, 300); e.phase = phase; return e; };
// 줄 단위 응답 읽기: 데이터가 올 때마다 버퍼에 모으고, 마지막 줄(코드 뒤가 공백)이 오면 전체 응답을 돌려줍니다.
let buffer = '', waiter = null;
const onData = (chunk) => {
buffer += chunk.toString('utf8');
while (waiter) {
const lines = buffer.split(CRLF); if (lines.length < 2) return;
// 완성된 줄들 중 "NNN " 으로 끝나는 줄이 있는지 봅니다(여러 줄 응답은 "NNN-")
let endIndex = -1;
for (let i = 0; i < lines.length - 1; i++) if (/^\d{3}(?: |$)/.test(lines[i])) { endIndex = i; break; }
if (endIndex < 0) return;
const reply = lines.slice(0, endIndex + 1); buffer = lines.slice(endIndex + 1).join(CRLF);
const w = waiter; waiter = null; w.resolve({ code: Number(reply[0].slice(0, 3)), lines: reply.map((l) => l.slice(4)), text: reply.join(' ') });
}
};
const read = () => new Promise((resolve, reject) => {
waiter = { resolve, reject };
const timer = setTimeout(() => { if (waiter) { waiter = null; reject(fail('mail_provider_unavailable', 'timeout waiting for reply', 'read')); } }, timeoutMs);
const done = waiter.resolve; waiter.resolve = (v) => { clearTimeout(timer); done(v); };
onData(Buffer.alloc(0));
});
const attach = (s) => { socket = s; s.setTimeout(timeoutMs + 5000); s.on('data', onData); s.on('error', (e) => { if (waiter) { const w = waiter; waiter = null; w.reject(fail('mail_provider_unavailable', e.message, 'socket')); } }); s.on('close', () => { if (waiter) { const w = waiter; waiter = null; w.reject(fail('mail_provider_unavailable', 'connection closed', 'socket')); } }); };
const send = async (line, expect, phase) => {
socket.write(line + CRLF);
const r = await read();
if (!expect.includes(r.code)) throw fail(smtpErrorCode(r.code, phase), `${r.code} ${r.lines.join(' ')}`, phase);
return r;
};
const connect = () => new Promise((resolve, reject) => {
const onError = (e) => reject(fail('mail_provider_unavailable', e.message, 'connect'));
const s = security === 'tls' ? tls.connect({ host, port, servername: net.isIP(host) ? undefined : host, rejectUnauthorized }, () => resolve(s)) : net.connect({ host, port }, () => resolve(s));
s.once('error', onError); s.setTimeout(timeoutMs, () => onError(new Error('connect timeout')));
});
try {
attach(await connect());
const greeting = await read(); if (greeting.code !== 220) throw fail('mail_provider_unavailable', greeting.text, 'greeting');
let ehlo = await send('EHLO ' + hostname, [250], 'ehlo');
if (security === 'starttls') {
if (!ehlo.lines.some((l) => /^STARTTLS/i.test(l))) throw fail('mail_provider_unavailable', 'server does not offer STARTTLS', 'starttls');
await send('STARTTLS', [220], 'starttls');
const plain = socket; plain.removeAllListeners('data'); plain.removeAllListeners('error'); plain.removeAllListeners('close');
const secure = await new Promise((resolve, reject) => { const s = tls.connect({ socket: plain, servername: net.isIP(host) ? undefined : host, rejectUnauthorized }, () => resolve(s)); s.once('error', (e) => reject(fail('mail_provider_unavailable', e.message, 'starttls'))); });
attach(secure);
ehlo = await send('EHLO ' + hostname, [250], 'ehlo');
}
if (user) {
const mechanisms = (ehlo.lines.find((l) => /^AUTH\b/i.test(l)) || '').toUpperCase();
if (mechanisms.includes('PLAIN') || !mechanisms.includes('LOGIN')) {
await send('AUTH PLAIN ' + Buffer.from(`\0${user}\0${pass}`, 'utf8').toString('base64'), [235], 'auth');
} else {
await send('AUTH LOGIN', [334], 'auth');
await send(Buffer.from(user, 'utf8').toString('base64'), [334], 'auth');
await send(Buffer.from(pass, 'utf8').toString('base64'), [235], 'auth');
}
}
await send(`MAIL FROM:<${from}>`, [250], 'mail');
const accepted = [], rejected = [];
for (const rcpt of to) {
socket.write(`RCPT TO:<${rcpt}>` + CRLF);
const r = await read();
if (r.code === 250 || r.code === 251) accepted.push(rcpt); else rejected.push({ address: rcpt, reply: `${r.code} ${r.lines.join(' ')}` });
}
if (!accepted.length) { const first = rejected[0]; throw fail(smtpErrorCode(Number(first.reply.slice(0, 3)), 'rcpt'), first.reply, 'rcpt'); }
await send('DATA', [354], 'data');
// 본문은 점 스터핑해 보내고 "<CRLF>.<CRLF>" 로 끝냅니다. 원본이 이미 CRLF 로 끝나면 빈 줄이 하나 더 생기지 않게 CRLF 를 덧붙이지 않습니다.
const body = dotStuff(eml); const endsCrlf = body.length >= 2 && body[body.length - 2] === 13 && body[body.length - 1] === 10;
socket.write(body); socket.write((endsCrlf ? '' : CRLF) + '.' + CRLF);
const final = await read();
if (final.code !== 250) throw fail(smtpErrorCode(final.code, 'data'), `${final.code} ${final.lines.join(' ')}`, 'data');
try { socket.write('QUIT' + CRLF); } catch { /* 응답은 기다리지 않습니다 */ }
return { response: final.lines.join(' '), accepted, rejected };
} finally {
if (socket) { waiter = null; socket.removeAllListeners('close'); socket.end(); setTimeout(() => socket.destroy(), 2000).unref(); }
}
}
/**
* 연결 확인(화면의 "연결 확인" 단추·상태 표시): 접속해 EHLO 까지만 주고받고 끊습니다. 로그인 정보가 있으면 AUTH 도 시험합니다.
* @returns {Promise<{ok:boolean, error?:string, detail?:string, features?:string[]}>}
*/
export async function smtpProbe({ host, port, security = 'starttls', user = '', pass = '', hostname = 'localhost', timeoutMs = 10000, rejectUnauthorized = true }) {
if (!host || !port) return { ok: false, error: 'mail_not_configured' };
// smtpSend 와 같은 절차를 쓰기 위해 받는 사람 없이 호출하면 RCPT 단계에서 끝나므로, 여기서는 간단히 접속·EHLO(·AUTH)만 따로 수행합니다.
let socket = null;
const fail = (code, detail) => ({ ok: false, error: code, detail: String(detail || '').replace(/[\r\n]+/g, ' ').slice(0, 300) });
try {
let buffer = '';
const read = (s) => new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
const onData = (c) => { buffer += c.toString('utf8'); const lines = buffer.split(CRLF); const end = lines.findIndex((l, i) => i < lines.length - 1 && /^\d{3}(?: |$)/.test(l)); if (end < 0) return; const reply = lines.slice(0, end + 1); buffer = lines.slice(end + 1).join(CRLF); clearTimeout(timer); s.off('data', onData); resolve({ code: Number(reply[0].slice(0, 3)), lines: reply.map((l) => l.slice(4)) }); };
s.on('data', onData); s.once('error', (e) => { clearTimeout(timer); reject(e); });
});
socket = await new Promise((resolve, reject) => { const s = security === 'tls' ? tls.connect({ host, port, servername: net.isIP(host) ? undefined : host, rejectUnauthorized }, () => resolve(s)) : net.connect({ host, port }, () => resolve(s)); s.once('error', reject); s.setTimeout(timeoutMs, () => reject(new Error('connect timeout'))); });
const greeting = await read(socket); if (greeting.code !== 220) return fail('mail_provider_unavailable', greeting.lines.join(' '));
socket.write('EHLO ' + hostname + CRLF); let ehlo = await read(socket); if (ehlo.code !== 250) return fail('mail_provider_unavailable', ehlo.lines.join(' '));
if (security === 'starttls') {
if (!ehlo.lines.some((l) => /^STARTTLS/i.test(l))) return fail('mail_provider_unavailable', 'server does not offer STARTTLS');
socket.write('STARTTLS' + CRLF); const r = await read(socket); if (r.code !== 220) return fail('mail_provider_unavailable', r.lines.join(' '));
const plain = socket; plain.removeAllListeners('data');
socket = await new Promise((resolve, reject) => { const s = tls.connect({ socket: plain, servername: net.isIP(host) ? undefined : host, rejectUnauthorized }, () => resolve(s)); s.once('error', reject); });
socket.write('EHLO ' + hostname + CRLF); ehlo = await read(socket); if (ehlo.code !== 250) return fail('mail_provider_unavailable', ehlo.lines.join(' '));
}
if (user) {
socket.write('AUTH PLAIN ' + Buffer.from(`\0${user}\0${pass}`, 'utf8').toString('base64') + CRLF);
const r = await read(socket); if (r.code !== 235) return fail(smtpErrorCode(r.code, 'auth'), r.lines.join(' '));
}
socket.write('QUIT' + CRLF);
return { ok: true, features: ehlo.lines.slice(1) };
} catch (e) { return fail('mail_provider_unavailable', e.message); }
finally { if (socket) { socket.end(); setTimeout(() => socket.destroy(), 1000).unref(); } }
}
+46
View File
@@ -0,0 +1,46 @@
/**
* SMTP 중계 발송 모듈(1.0.0) — 발신 방식이 haraka(내장 Haraka 의 로컬 제출 포트) 또는 smtp(외부 중계 서버)일 때 쓰입니다.
*
* 입력은 Resend 발송 요청과 같은 모양의 메시지 객체입니다(providers/index.mjs 참고). 그래서 store.mjs(웹 발송)·smtp.mjs(제출 서버)·
* automation.mjs(전달·자동 응답)는 어느 발송 모듈을 쓰는지 몰라도 됩니다. 이 모듈은 그 객체를 RFC 5322 원본(.eml)으로 만들어
* (mime.mjs 의 buildEml 재사용) 봉투(MAIL FROM / RCPT TO)와 함께 중계 서버에 넘깁니다.
*
* 주의: Bcc 는 헤더에 쓰지 않고 봉투에만 넣습니다(buildEml 에 bcc 를 넘기면 헤더가 생기므로 넘기지 않음).
* 멱등 키(key)는 SMTP 에 해당 개념이 없어 쓰지 않습니다. 발송 전에 저장소가 send_key 로 중복을 막고 있습니다.
*/
import { randomUUID } from 'node:crypto';
import { buildEml } from '../mime.mjs';
import { address, addresses } from '../util.mjs';
import { smtpSend } from './smtp-client.mjs';
/**
* Resend 형식 메시지 객체 → { eml, envelope }. 시험과 보낸 메일함 저장이 같은 변환을 쓸 수 있게 따로 두었습니다.
* @param {object} payload { from, to[], cc?[], bcc?[], reply_to?[], subject, text?, html?, headers?{}, attachments?[{filename, content(base64), content_type, content_id?}] }
* @param {string} domain Message-ID 의 @ 뒤 도메인
*/
export function payloadToEml(payload, domain) {
const messageId = `<${randomUUID()}@${domain || 'localhost'}>`;
const eml = buildEml({
from: payload.from, to: payload.to || [], cc: payload.cc || [], replyTo: payload.reply_to || [], subject: payload.subject || '',
text: payload.text, html: payload.html, headers: payload.headers || {}, messageId,
attachments: (payload.attachments || []).map((a) => ({ filename: a.filename, contentType: a.content_type, bytes: Buffer.from(a.content || '', 'base64'), contentId: a.content_id || undefined, disposition: a.content_id ? 'inline' : 'attachment' })),
});
const envelope = { from: address(payload.from), to: addresses([...(payload.to || []), ...(payload.cc || []), ...(payload.bcc || [])]) };
return { eml, envelope, messageId };
}
/**
* 메시지 객체를 중계 서버로 보냅니다.
* @param {object} env SMTP_RELAY_* 가 아니라 호출자가 고른 relay 설정을 씁니다(env 는 도메인·호스트 이름용)
* @param {object} payload Resend 형식 메시지
* @param {object} relay { host, port, security, user, pass, rejectUnauthorized? }
* @returns {Promise<{id:string, accepted:string[], rejected:object[], response:string}>} id = 생성한 Message-ID(괄호 없이). messages.provider_id 에 저장됩니다.
*/
export async function sendViaSmtp(env, payload, relay) {
const domain = String(env.MAILBOX || 'admin@arihakdang.com').split('@')[1] || 'localhost';
const { eml, envelope, messageId } = payloadToEml(payload, domain);
if (!envelope.to.length) { const e = new Error('mail_rejected'); e.providerMessage = 'no recipients'; throw e; }
const result = await smtpSend({ ...relay, hostname: env.PUBLIC_HOST || 'localhost', from: envelope.from, to: envelope.to, eml });
if (result.rejected.length) console.warn('SMTP 중계: 일부 받는 사람 거부', result.rejected.map((r) => `${r.address} (${r.reply})`).join(', '));
return { id: messageId.replace(/^<|>$/g, ''), ...result };
}
+209
View File
@@ -0,0 +1,209 @@
/**
* 메일 서비스 자동 설정(1.0.5, 요구사항 2·4·5) — "계획(plan)" 모델.
*
* 생각의 틀
* 사용자가 수·발신 방식과 도메인을 정하면, 메일을 실제로 쓰기까지 필요한 일이 여러 곳에 흩어져 있습니다: Resend 에 도메인 등록·기능 켜기·검증·웹훅,
* DNS 에 MX/SPF/DKIM/CNAME 레코드, 내장 Haraka 설치·DKIM 키·PTR·방화벽, 외부 SMTP 중계 서버 접속 확인 … 이 모듈은 그 일들을 **단계(step)** 로 나누어
* 현재 상태를 진단하고(buildPlan), 자동으로 할 수 있는 단계는 실행하며(runPlan), 사람이 해야 하는 단계는 무엇을·어떻게 해야 하는지와 입력 칸을 돌려줍니다.
* 화면(web/setup.js)은 이 계획을 점검표로 그리고 "자동 설정 실행" 단추 하나로 돌립니다. 실제 변경은 각 모듈(providers/resend.mjs, dns/*.mjs, config.mjs)이 합니다.
*
* 단계 상태(state)
* ok 끝남 todo 자동으로 할 수 있고 아직 안 함(runPlan 이 실행)
* wait 반영·검증을 기다리는 중(DNS 전파, Resend 검증) action 사용자가 해야 함(입력 칸 input 또는 단추 actions, 안내 detail)
* error 실패(원인 detail) info 참고(준비 판정에 영향 없음) skip 이번 구성에서는 필요 없음(화면에 안 보임)
*
* 계획 모양: { domain, inbound, outbound, dns:{provider, configured, zone?, error?}, records:[레코드+check], steps:[{id, group, title, state, detail, auto, input?, actions?}], ready, readyMessage }
*/
import { inboundMode, outboundMode, usesResend, usesHaraka, relaySettings } from './providers/index.mjs';
import * as resend from './providers/resend.mjs';
import { smtpProbe } from './providers/smtp-client.mjs';
import { requiredRecords, checkRecords, applyRecords, dnsProviderName, dnsConfigured, probeDns } from './dns/index.mjs';
import { lookup, reverseCheck, bare } from './dns/common.mjs';
import { saveConfig, generateDkim, mailDomain, harakaHostname, harakaStatus, harakaDir, CONFIG_SCHEMA } from './config.mjs';
/** Resend 상태·오류 코드를 사람이 읽는 말로. 화면 문구와 겹치지 않게 서버가 한 번만 만듭니다. */
const RESEND_STATUS = { not_started: '검증을 시작하지 않음', pending: '검증 진행 중(DNS 반영 뒤 보통 몇 분, 최대 72시간)', verified: '검증 완료', failed: '검증 실패(Resend 가 레코드를 확인하지 못해 멈춤 — 고친 뒤 다시 요청해야 함)', temporary_failure: '일시 실패 — DNS 반영을 기다린 뒤 다시 검증' };
const ERR = { mail_not_configured: 'API 키가 없습니다', mail_auth_failed: 'API 키가 거부되었습니다', mail_forbidden: '권한이 없습니다(키 종류·도메인 소유 확인)', mail_rate_limited: 'Resend 가 요청을 제한합니다. 잠시 뒤 다시', mail_rejected: 'Resend 가 요청을 거부했습니다', mail_provider_unavailable: 'Resend 에 연결할 수 없습니다', dns_not_configured: 'DNS 모듈 인증 정보가 없습니다', dns_bad_credentials: 'DNS 인증 정보 형식이 틀립니다', dns_auth_failed: 'DNS 서비스가 인증을 거부했습니다', dns_forbidden: 'DNS 서비스 권한이 없습니다', dns_zone_not_found: 'DNS 존을 찾지 못했습니다', dns_unavailable: 'DNS 서비스에 연결할 수 없습니다' };
const say = (e) => (ERR[e?.message] || e?.message || '알 수 없는 오류') + (e?.detail || e?.providerMessage ? ` (${e.detail || e.providerMessage})` : '');
/**
* 진단에 필요한 외부 상태를 한 번씩만 읽어 모아 둡니다(Resend 도메인·웹훅, DNS 조회). 실패는 오류로 남겨 단계가 설명합니다.
* @param {object} o resolver: 시험용 DNS 리졸버
*/
async function gather(env, { resolver = null } = {}) {
const ctx = { domain: mailDomain(env), inbound: inboundMode(env), outbound: outboundMode(env), host: bare(harakaHostname(env)), resendDomain: null, resendError: null, webhooks: null, webhookError: null, existingSpf: null, resolver };
if (usesResend(env) && env.RESEND_API_KEY) {
try {
let d = null;
if (env.RESEND_DOMAIN_ID) { try { d = await resend.getDomain(env, env.RESEND_DOMAIN_ID); if (String(d.name || '').toLowerCase() !== ctx.domain) d = null; } catch { d = null; } }
if (!d) { const found = await resend.findDomain(env, ctx.domain); if (found) d = await resend.getDomain(env, found.id); }
ctx.resendDomain = d;
} catch (e) { ctx.resendError = e; }
if (ctx.inbound === 'resend') { try { ctx.webhooks = await resend.listWebhooks(env); } catch (e) { ctx.webhookError = e; } }
}
if (ctx.outbound === 'haraka') { const r = await lookup({ name: ctx.domain, type: 'TXT', values: [] }, { resolver }); ctx.existingSpf = (r.found || []).find((v) => /^v=spf1\b/i.test(v)) || null; }
return ctx;
}
const step = (id, group, title, state, detail = '', extra = {}) => ({ id, group, title, state, detail, auto: false, ...extra });
// 경로 접두사(1.0.8): site.example.com/mail 처럼 등록한 도메인은 웹훅도 그 경로 아래(https://site.example.com/mail/api/webhooks/resend)로 받습니다.
const webhookEndpoint = (env) => `https://${env.PUBLIC_HOST || 'mail.arihakdang.com'}${env.PUBLIC_PATH || ''}/api/webhooks/resend`;
const inputs = (...keys) => keys.map((key) => { const item = CONFIG_SCHEMA.find((i) => i.key === key); return { key, type: item?.type || 'text', ...(item?.options ? { options: item.options } : {}), ...(item?.multiline ? { multiline: true } : {}) }; });
/**
* 현재 상태를 진단해 계획을 만듭니다. 외부 호출(Resend·DNS 조회)을 하므로 1~3초 걸릴 수 있습니다.
* @param {object} [o] resolver: 시험용 DNS 리졸버, ctx: gather 결과 재사용
*/
export async function buildPlan(env, { resolver = null, ctx = null } = {}) {
ctx = ctx || await gather(env, { resolver });
const steps = [];
const { domain, inbound, outbound, host } = ctx;
// ---- Resend
if (usesResend(env)) {
if (!env.RESEND_API_KEY) steps.push(step('resend.key', 'resend', 'Resend API 키', 'action', 'Resend → API Keys 에서 전체 접근(Full access) 키를 만들어 넣으세요. 도메인 등록·검증·웹훅을 이 키로 자동 처리합니다.', { input: inputs('RESEND_API_KEY', 'RESEND_REGION') }));
else {
steps.push(step('resend.key', 'resend', 'Resend API 키', 'ok', '설정됨'));
const d = ctx.resendDomain, wantRecv = inbound === 'resend', wantSend = outbound === 'resend';
if (ctx.resendError) steps.push(step('resend.domain', 'resend', `Resend 에 도메인 ${domain} 등록`, 'error', say(ctx.resendError)));
else if (!d) steps.push(step('resend.domain', 'resend', `Resend 에 도메인 ${domain} 등록`, 'todo', `지역 ${env.RESEND_REGION || 'ap-northeast-1'} 로 등록하고 ${[wantSend && '발송', wantRecv && '수신'].filter(Boolean).join('·')} 기능을 켭니다.`, { auto: true }));
else {
const cap = d.capabilities || {}, recvOn = cap.receiving === 'enabled', sendOn = cap.sending !== 'disabled';
if (recvOn !== wantRecv || sendOn !== wantSend) steps.push(step('resend.domain', 'resend', `Resend 도메인 기능 맞추기`, 'todo', `지금: 발송 ${sendOn ? '켬' : '끔'} · 수신 ${recvOn ? '켬' : '끔'} → 필요: 발송 ${wantSend ? '켬' : '끔'} · 수신 ${wantRecv ? '켬' : '끔'}`, { auto: true }));
else steps.push(step('resend.domain', 'resend', `Resend 도메인 ${domain}`, 'ok', `등록됨 (지역 ${d.region || '-'}, 발송 ${sendOn ? '켬' : '끔'} · 수신 ${recvOn ? '켬' : '끔'})`));
}
}
}
// ---- Haraka 준비(레코드 계산 전에 DKIM 이 있어야 하므로 먼저)
const hs = usesHaraka(env) ? harakaStatus(env) : null;
if (usesHaraka(env)) {
const svc = hs.status && !hs.stale;
if (!hs.installed) steps.push(step('haraka.install', 'haraka', '내장 Haraka 설치', 'action', `서버에서 root 로 한 번 실행하세요: sudo bash ${harakaDir(env).replace(/\/haraka$/, '')}/repo/deploy/haraka-apply.sh (npm 설치 + 서비스 등록, 인터넷 필요)`, { actions: [{ id: 'recheck', label: '설치 확인' }] }));
else steps.push(step('haraka.install', 'haraka', '내장 Haraka 설치', 'ok', `Haraka ${hs.status?.version || ''} 설치됨`));
if (hs.installed && !svc) steps.push(step('haraka.service', 'haraka', 'Haraka 관리 서비스', 'action', '관리 서비스가 돌고 있지 않습니다. 서버에서: sudo systemctl start <서비스 이름>-haraka (deploy/haraka-apply.sh 를 다시 실행해도 됩니다)', { actions: [{ id: 'recheck', label: '다시 확인' }] }));
else if (hs.installed) steps.push(step('haraka.service', 'haraka', 'Haraka 관리 서비스', 'ok', '실행 중'));
if (outbound === 'haraka') steps.push(env.HARAKA_DKIM_PRIVATE ? step('haraka.dkim', 'haraka', 'DKIM 서명 키', 'ok', `셀렉터 ${env.HARAKA_DKIM_SELECTOR || 'arimail'}`) : step('haraka.dkim', 'haraka', 'DKIM 서명 키 만들기', 'todo', 'RSA 2048 키를 만들어 Haraka 가 보내는 메일에 서명합니다. 공개키는 DNS TXT 로 올립니다.', { auto: true }));
}
// ---- DNS
const records = await checkRecords(requiredRecords(env, { resendDomain: ctx.resendDomain, existingSpf: ctx.existingSpf }), { resolver: ctx.resolver });
const needed = records.filter((r) => !r.optional), missing = needed.filter((r) => !r.check.ok);
const provider = dnsProviderName(env), configured = dnsConfigured(env);
let dnsInfo = { provider, configured };
if (records.length) {
if (!missing.length) steps.push(step('dns.apply', 'dns', 'DNS 레코드', 'ok', `${needed.length}개 레코드가 모두 반영되어 있습니다.`));
else if (provider === 'none' || !configured) {
// 1.0.11: 값이 다른 레코드(⚠️ 지금 값 표시)와 아직 없는 레코드를 구분해 알립니다.
const wrongCount = missing.filter((r) => r.check.problem === 'wrong').length;
const shape = wrongCount ? `값이 다른 레코드 ${wrongCount}개(표의 "지금 값" 참고)${missing.length - wrongCount ? `, 없는 레코드 ${missing.length - wrongCount}개` : ''}. ` : '';
steps.push(step('dns.apply', 'dns', `DNS 레코드 ${missing.length}개 ${wrongCount ? '고치기' : '넣기'}`, 'action', shape + (provider === 'none' ? '자동 적용할 DNS 모듈이 없습니다. 아래 표의 레코드를 도메인의 DNS 서비스에 직접 넣고 "반영 확인" 을 누르세요. Google Cloud DNS 나 AWS Route 53 을 쓰면 "DNS 모듈" 카드에 인증 정보를 넣어 자동 적용할 수 있습니다.' : `DNS 모듈(${provider})의 인증 정보가 비어 있습니다. "DNS 모듈" 카드에서 채우거나 레코드를 직접 넣으세요.`), { actions: [{ id: 'recheck', label: '반영 확인' }] }));
}
else {
const probe = await probeDns(env); dnsInfo = { ...dnsInfo, zone: probe.zone, error: probe.ok ? null : probe.error, detail: probe.detail };
if (!probe.ok) steps.push(step('dns.apply', 'dns', `DNS 레코드 ${missing.length}개 넣기`, 'error', `DNS 모듈(${provider}) 연결 실패: ${say({ message: probe.error, detail: probe.detail })}. 인증 정보·권한·존을 확인하세요. 또는 아래 레코드를 직접 넣으세요.`, { actions: [{ id: 'recheck', label: '반영 확인' }] }));
else steps.push(step('dns.apply', 'dns', `DNS 레코드 ${missing.length}개 넣기`, env.PLAN_DNS_APPLIED_AT && Date.now() - Date.parse(env.PLAN_DNS_APPLIED_AT) < 1800000 ? 'wait' : 'todo', env.PLAN_DNS_APPLIED_AT && Date.now() - Date.parse(env.PLAN_DNS_APPLIED_AT) < 1800000 ? `${provider} 존 ${probe.zone} 에 적용했습니다. 전파를 기다리는 중(보통 1~10분): ${missing.map((r) => `${r.type} ${r.name}`).join(', ')}` : `${provider} 존 ${probe.zone} 에 자동으로 넣습니다: ${missing.map((r) => `${r.type} ${r.name}`).join(', ')}`, { auto: true, actions: [{ id: 'recheck', label: '반영 확인' }] }));
}
}
// ---- Resend 검증·웹훅
if (usesResend(env) && env.RESEND_API_KEY && ctx.resendDomain) {
const d = ctx.resendDomain, st = String(d.status || 'not_started');
// 1.0.11: 빠진 Resend 레코드를 "값이 다름(wrong)" 과 "없음(missing)" 으로 나눕니다. 값이 다른 것은 기다려도 해결되지 않으므로 사용자 작업(action)으로,
// 없는 것만 전파 대기(wait)로 안내합니다. 운영에서 DKIM TXT 가 다른 등록의 공개키로 들어가 있어 "검증 실패" 만 보이던 일에서 나온 구분입니다.
const resendMissing = missing.filter((r) => r.source === 'resend'), wrong = resendMissing.filter((r) => r.check.problem === 'wrong');
const wrongText = wrong.map((r) => `${r.type} ${r.name} (지금 ${resend.short(r.check.found[0] || '', r.values[0])} / 필요 ${resend.short(r.values[0], r.check.found[0] || '')})`).join(', ');
if (st === 'verified') steps.push(step('resend.verify', 'resend', 'Resend 도메인 검증', 'ok', RESEND_STATUS.verified));
else if (st === 'pending') steps.push(step('resend.verify', 'resend', 'Resend 도메인 검증', 'wait', RESEND_STATUS.pending, { actions: [{ id: 'recheck', label: '상태 확인' }] }));
else if (wrong.length) steps.push(step('resend.verify', 'resend', 'Resend 도메인 검증 — DNS 레코드 값이 다릅니다', 'action', `${RESEND_STATUS[st] || st}. 값이 다른 레코드: ${wrongText}. 아래 표의 값(복사 단추)으로 바꾸세요 — 다른 Resend 계정이나 예전 등록에서 받은 값일 수 있습니다. 바꾼 뒤에는 1시간마다 자동으로 검증을 다시 요청하므로 기다리거나 "지금 검증 요청" 을 누르세요.`, { actions: [{ id: 'recheck', label: '반영 확인' }, { id: 'verify', label: '지금 검증 요청' }] }));
else steps.push(step('resend.verify', 'resend', 'Resend 도메인 검증 요청', resendMissing.length ? 'wait' : 'todo', (RESEND_STATUS[st] || st) + (resendMissing.length ? ` — 아직 DNS 에 없는 Resend 레코드: ${resendMissing.map((r) => `${r.type} ${r.name}`).join(', ')}. 반영되면 자동으로 검증을 요청합니다(1시간마다).` : ' — 검증을 요청합니다.'), { auto: !resendMissing.length, actions: [{ id: 'verify', label: '지금 검증 요청' }] }));
if (inbound === 'resend') {
const ep = webhookEndpoint(env);
if (ctx.webhookError) steps.push(step('resend.webhook', 'resend', '수신 웹훅', 'error', say(ctx.webhookError)));
else {
const mine = (ctx.webhooks || []).find((w) => String(w.endpoint || '').replace(/\/$/, '') === ep);
if (!mine) steps.push(step('resend.webhook', 'resend', '수신 웹훅 만들기', 'todo', `${ep} 로 email.received 이벤트를 보내는 웹훅을 만들고 서명 비밀키를 설정에 저장합니다.`, { auto: true }));
else if (mine.status !== 'enabled') steps.push(step('resend.webhook', 'resend', '수신 웹훅', 'action', `웹훅이 꺼져 있습니다(${mine.status}). Resend 대시보드에서 켜거나 아래에서 새로 만드세요.`, { actions: [{ id: 'recreate', label: '웹훅 새로 만들기' }] }));
else if (!env.RESEND_WEBHOOK_SECRET) steps.push(step('resend.webhook', 'resend', '수신 웹훅 서명 비밀키', 'action', `웹훅은 있지만(${mine.id}) 서명 비밀키가 설정에 없습니다. Resend 는 비밀키를 만들 때만 보여 주므로, 대시보드의 Signing Secret 을 넣거나 웹훅을 새로 만드세요(기존 것은 지웁니다).`, { input: inputs('RESEND_WEBHOOK_SECRET'), actions: [{ id: 'recreate', label: '웹훅 새로 만들기' }] }));
else steps.push(step('resend.webhook', 'resend', '수신 웹훅', 'ok', `${mine.id} (이벤트 ${(mine.events || []).length}개${(mine.events || []).length > 1 ? ' — email.received 외 이벤트는 무시됩니다' : ''})`));
}
}
}
// ---- Haraka 실행·발신 준비
if (usesHaraka(env) && hs.installed && hs.status && !hs.stale) {
const st = hs.status;
if (st.running) steps.push(step('haraka.running', 'haraka', 'Haraka 실행', 'ok', `대기 중: ${(st.listen || []).join(', ')}`));
else steps.push(step('haraka.running', 'haraka', 'Haraka 실행', 'wait', st.lastExit ? `마지막 종료 코드 ${st.lastExit.code} (${st.lastExit.at}). 로그: journalctl -u <서비스 이름>-haraka` : '관리 서비스가 곧 띄웁니다(설정 저장 뒤 수 초).', { actions: [{ id: 'recheck', label: '다시 확인' }] }));
if (!st.tls) steps.push(step('haraka.tls', 'haraka', 'STARTTLS 인증서', 'info', '메일 포트 인증서(POP_TLS_CERT)가 없어 Haraka 가 STARTTLS 를 제공하지 않습니다. 인증서가 생기면 자동으로 켭니다.'));
if (inbound === 'haraka') steps.push(step('haraka.port', 'haraka', `수신 포트 ${env.HARAKA_PORT || 25} 개방`, 'info', `방화벽(클라우드 보안 그룹 포함)에서 TCP ${env.HARAKA_PORT || 25} 를 열어야 외부 메일 서버가 접속할 수 있습니다. 서버 안에서는 확인할 수 없으니 외부에서 telnet ${host} ${env.HARAKA_PORT || 25} 로 확인하세요.`));
if (outbound === 'haraka') {
const ptr = await reverseCheck(host, { resolver: ctx.resolver });
steps.push(ptr.ok ? step('haraka.ptr', 'haraka', '역방향 DNS(PTR)', 'ok', `${ptr.ips.join(', ')} → ${host}`) : step('haraka.ptr', 'haraka', '역방향 DNS(PTR)', 'action', ptr.error ? `${host} 의 A 레코드를 찾지 못했습니다(${ptr.error}). 메일 서버 이름이 이 서버 IP 를 가리켜야 합니다.` : `IP ${ptr.ips.join(', ')} 의 PTR 이 ${host} 가 아닙니다(지금: ${ptr.ptr.join(', ') || '없음'}). 호스팅 업체에 PTR(역방향 DNS)을 ${host} 로 요청하세요. 없으면 Gmail 등에서 스팸으로 분류되기 쉽습니다.`, { actions: [{ id: 'recheck', label: '다시 확인' }] }));
}
}
// ---- 외부 SMTP 중계
if (outbound === 'smtp') {
const relay = relaySettings(env, 'smtp');
if (!relay.host) steps.push(step('smtp.relay', 'smtp', 'SMTP 중계 서버', 'action', '보내는 메일을 넘길 중계 서버 접속 정보를 넣으세요.', { input: inputs('SMTP_RELAY_HOST', 'SMTP_RELAY_PORT', 'SMTP_RELAY_SECURITY', 'SMTP_RELAY_USER', 'SMTP_RELAY_PASS') }));
else { const p = await smtpProbe({ ...relay, hostname: env.PUBLIC_HOST || 'localhost', timeoutMs: 8000 }); steps.push(p.ok ? step('smtp.relay', 'smtp', 'SMTP 중계 서버', 'ok', `${relay.host}:${relay.port} 연결·${relay.user ? '인증 ' : ''}확인`) : step('smtp.relay', 'smtp', 'SMTP 중계 서버', 'error', `${relay.host}:${relay.port} — ${ERR[p.error] || p.error}${p.detail ? ' (' + p.detail + ')' : ''}`, { input: inputs('SMTP_RELAY_HOST', 'SMTP_RELAY_PORT', 'SMTP_RELAY_SECURITY', 'SMTP_RELAY_USER', 'SMTP_RELAY_PASS'), actions: [{ id: 'recheck', label: '다시 확인' }] })); }
}
const blocking = steps.filter((s) => !['ok', 'info', 'skip'].includes(s.state));
const ready = blocking.length === 0;
const readyMessage = ready ? `메일 사용 준비가 끝났습니다 — 수신 ${label(inbound)} · 발신 ${label(outbound)} (${domain}).` : blocking.some((s) => s.state === 'wait') && blocking.every((s) => s.state === 'wait') ? '반영을 기다리는 중입니다. 잠시 뒤 "다시 확인" 을 누르세요.' : `${blocking.length}개 단계가 남았습니다: ${blocking.map((s) => s.title).join(', ')}`;
return { domain, inbound, outbound, host, dns: dnsInfo, records, steps, ready, readyMessage, checkedAt: new Date().toISOString() };
}
const label = (m) => ({ resend: 'Resend', haraka: '내장 Haraka', smtp: '외부 SMTP 중계' }[m] || m);
/**
* 계획을 실행합니다. input 은 먼저 설정에 저장하고(허용된 키만), step 이 있으면 그 단계(또는 그 단계의 action)만, 없으면 자동 가능한 단계를 순서대로 실행합니다.
* 실행 뒤 다시 진단한 계획과 실행 기록(log: [{id, result, detail}])을 돌려줍니다. 한 단계가 실패해도 다음 단계로 넘어가며 기록에 남깁니다.
*/
export async function runPlan(env, { step: only = null, stepAction = null, input = {}, resolver = null } = {}) {
const log = [];
const patch = Object.fromEntries(Object.entries(input).filter(([k]) => CONFIG_SCHEMA.some((i) => i.key === k && !i.hidden)));
if (Object.keys(patch).length) { saveConfig(env, patch); log.push({ id: 'config', result: 'saved', detail: Object.keys(patch).join(', ') }); }
let plan = await buildPlan(env, { resolver });
const run = async (s) => {
try {
const r = await execute(env, s, stepAction, plan, resolver);
log.push({ id: s.id, result: r ? 'done' : 'noop', detail: r || '' });
} catch (e) { log.push({ id: s.id, result: 'error', detail: say(e) }); }
};
if (only) { const s = plan.steps.find((x) => x.id === only); if (s) await run(s); }
else {
// 순서가 중요: 도메인 등록 → DKIM → DNS 적용 → (다시 진단) → 검증·웹훅
// 한 단계가 끝나면 다시 진단합니다: DKIM 키가 생기면 DNS 레코드 목록이 바뀌고, 도메인이 등록되면 Resend 레코드가 생기기 때문입니다.
for (const id of ['resend.domain', 'haraka.dkim', 'dns.apply', 'resend.verify', 'resend.webhook']) {
const s = plan.steps.find((x) => x.id === id && x.auto && x.state === 'todo'); if (!s) continue;
await run(s); plan = await buildPlan(env, { resolver });
}
}
plan = await buildPlan(env, { resolver });
return { plan, log };
}
/** 단계 하나의 실제 동작. 돌려주는 문자열은 기록용 요약, null 이면 할 일이 없었음. */
async function execute(env, s, action, plan, resolver) {
const domain = plan.domain, wantRecv = plan.inbound === 'resend', wantSend = plan.outbound === 'resend';
switch (s.id) {
case 'resend.domain': {
const found = await resend.findDomain(env, domain);
if (!found) { const d = await resend.createDomain(env, { name: domain, region: env.RESEND_REGION || 'ap-northeast-1', sending: wantSend, receiving: wantRecv }); saveConfig(env, { RESEND_DOMAIN_ID: d.id }, { allowHidden: true }); return `도메인 등록 ${d.id}`; }
saveConfig(env, { RESEND_DOMAIN_ID: found.id }, { allowHidden: true });
await resend.updateDomainCapabilities(env, found.id, { sending: wantSend, receiving: wantRecv }); return `기능 갱신(발송 ${wantSend ? '켬' : '끔'} · 수신 ${wantRecv ? '켬' : '끔'})`;
}
case 'haraka.dkim': generateDkim(env); return 'DKIM 키 생성';
case 'dns.apply': {
if (action === 'recheck') return null;
const r = await applyRecords(env, plan.records.filter((x) => !x.check.ok));
env.PLAN_DNS_APPLIED_AT = new Date().toISOString(); // 적용 직후에는 "전파 대기" 로 표시(메모리에만, 재시작하면 다시 진단)
return `${r.provider} 존 ${r.zone}: 변경 ${r.changed.length}개${r.unchanged.length ? `, 그대로 ${r.unchanged.length}개` : ''}`;
}
case 'resend.verify': { if (action === 'recheck') return null; const id = env.RESEND_DOMAIN_ID || (await resend.findDomain(env, domain))?.id; if (!id) throw new Error('mail_rejected'); await resend.verifyDomain(env, id); return '검증 요청'; }
case 'resend.webhook': {
const ep = webhookEndpoint(env);
if (action === 'recreate') { for (const w of await resend.listWebhooks(env)) if (String(w.endpoint || '').replace(/\/$/, '') === ep) { try { await resend.deleteWebhook(env, w.id); } catch (e) { console.warn('웹훅 삭제 실패', w.id, e.message); } } }
else if ((await resend.listWebhooks(env)).some((w) => String(w.endpoint || '').replace(/\/$/, '') === ep)) return null;
const w = await resend.createWebhook(env, { endpoint: ep, events: ['email.received'] });
saveConfig(env, { RESEND_WEBHOOK_SECRET: w.signing_secret || '' }); saveConfig(env, { RESEND_WEBHOOK_ID: w.id || '' }, { allowHidden: true });
return `웹훅 생성 ${w.id}${w.signing_secret ? ' + 서명 비밀키 저장' : ''}`;
}
default: return null; // recheck 류: buildPlan 이 다시 진단하는 것으로 충분
}
}
+5
View File
@@ -0,0 +1,5 @@
/**
* 호환용 재수출(1.0.0). Resend 모듈은 server/providers/resend.mjs 로 옮겼습니다(요구사항 2: 발송 서비스 모듈화).
* 예전 import 경로('./resend.mjs')를 쓰는 코드·시험이 그대로 동작하게 같은 이름을 다시 내보냅니다. 새 코드는 providers/ 를 직접 쓰세요.
*/
export { resend, download, providerStatus, providerError } from './providers/resend.mjs';
+116
View File
@@ -0,0 +1,116 @@
/**
* 수신 HTML 정제. www 사이트의 build/sanitize_mail.py 와 같은 허용 목록을 JS 로 옮겼습니다.
* 스크립트·스타일·이벤트 속성·위험한 URL 스킴은 남기지 않으며, 미리보기는 그 위에 sandbox iframe + CSP 를 더 씁니다.
*/
const ALLOWED = new Set(['p','br','strong','b','em','i','u','ul','ol','li','blockquote','div','span','h1','h2','h3','h4','pre','code','s','strike','font','a','img','hr','table','tbody','thead','tr','td','th']);
const VOID = new Set(['br','img','hr']);
const BLOCKED = new Set(['script','style','iframe','object','embed','form','svg','math','template','noscript','head','title']);
// embed 는 HTML5 빈 요소(닫는 태그 없음)라 열린 범위를 만들면 뒤의 본문이 모두 사라집니다. 원문 텍스트 요소(script/style/title/textarea 류)는
// 자기 닫는 태그까지 바로 건너뛰고, 닫는 태그가 없으면 범위를 열지 않습니다(예전에는 닫히지 않은 <title>·<form> 하나로 본문 전체가 비었습니다, 0.8.25).
const VOID_BLOCKED = new Set(['embed']);
const RAW_TEXT_BLOCKED = new Set(['script', 'style', 'title', 'noscript', 'template']);
// 껍데기만 벗기는 요소(0.9.0): form·head·object·iframe 은 태그만 버리고 안의 내용은 계속 정제합니다. 예전에는 이 태그가 범위(depth)를 열어
// <form> 으로 감싼 뉴스레터 본문이 통째로 사라지고, 닫는 태그가 없는 <form>/<iframe>/<head>(브라우저는 <body> 에서 자동으로 닫음) 뒤의 본문이 모두 비었습니다.
// 범위를 여는 것은 svg·math(안이 HTML 이 아님)뿐이며, 그마저도 <body> 시작 태그를 만나면 닫습니다.
const UNWRAP_BLOCKED = new Set(['form', 'head', 'object', 'iframe']);
// svg/math 안에서 만나면 브라우저가 외래 내용(foreign content)을 끝내는 HTML 시작 태그(HTML 명세 13.2.6.5). 닫히지 않은 <svg> 뒤의 본문이 사라지지 않게 같은 규칙으로 범위를 닫습니다(0.9.3).
const BREAKOUT = new Set(['b','big','blockquote','body','br','center','code','dd','div','dl','dt','em','embed','h1','h2','h3','h4','h5','h6','head','hr','i','img','li','listing','menu','meta','nobr','ol','p','pre','ruby','s','small','span','strong','strike','sub','sup','table','tt','u','ul','var']);
// text-decoration-line/-style/-color: Chrome 은 편집기의 밑줄·취소선을 text-decoration 이 아니라 text-decoration-line 으로 씁니다.
// 이 키가 없어 Chrome 에서 넣은 밑줄·취소선이 저장·발송에서 사라졌습니다(0.8.12 수정).
const STYLE_KEYS = new Set(['color','background-color','font-family','font-size','font-weight','font-style','text-decoration','text-decoration-line','text-decoration-style','text-decoration-color','text-align','line-height','margin','margin-left','margin-right','margin-top','margin-bottom','padding','padding-left','padding-right','padding-top','padding-bottom','border','border-collapse','border-color','border-width','border-style','width','height','min-height','max-width','vertical-align','white-space']);
// height 를 그대로 둘 요소(0.9.9). 그림은 크기 자체이고 표·칸은 내용이 많으면 어차피 늘어나므로 height 가 겹침을 만들지 않습니다.
// 그 밖의 요소(div·p·span·blockquote 등)는 height 를 min-height 로 바꿉니다: 웹 페이지·문서 편집기에서 복사한 본문에는 `<div style="height:40px">` 처럼
// 고정 높이가 붙어 오는데, 안의 글이 그 높이보다 길면 overflow 가 visible 이라 다음 문단 위로 번져 겹쳐 보입니다(편집기에서도, 받는 쪽 메일에서도).
// min-height 로 바꾸면 비어 있는 간격용 div 는 그 높이를 지키고, 글이 든 div 는 글 길이대로 자랍니다.
const KEEP_HEIGHT = new Set(['img', 'table', 'tr', 'td', 'th']);
const NUMERIC = new Set(['width','height','colspan','rowspan','cellpadding','cellspacing','border','size']);
const SIMPLE = new Set(['color','bgcolor','align','valign','face']);
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
export const escapeHtml = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
export function decodeEntities(s) {
return String(s).replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (m, e) => {
if (e[0] === '#') { const code = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m; }
return ENTITIES[e.toLowerCase()] ?? m;
});
}
function scheme(value) { const m = String(value).trim().replace(/[\u0000- ]/g, '').match(/^([a-z][a-z0-9+.-]*):/i); return m ? m[1].toLowerCase() : ''; }
function parseAttrs(raw) {
const out = []; const re = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>`]+)))?/g; let m;
while ((m = re.exec(raw))) out.push([m[1].toLowerCase(), decodeEntities(m[2] ?? m[3] ?? m[4] ?? '')]);
return out;
}
function cleanAttrs(tag, attrs) {
const clean = [];
for (const [name, value] of attrs) {
if (name === 'href' && tag === 'a' && ['https', 'http', 'mailto'].includes(scheme(value))) clean.push(`href="${escapeHtml(value)}"`);
if (name === 'src' && tag === 'img' && (['https', 'http', 'cid'].includes(scheme(value)) || /^\/api\/mail\/[A-Za-z0-9-]+\/attachments\/[A-Za-z0-9-]+(?:\?inline=1)?$/.test(value))) clean.push(`src="${escapeHtml(value)}"`);
if (name === 'style') {
const styles = [];
for (const declaration of value.split(';')) {
const i = declaration.indexOf(':'); if (i < 0) continue;
const key = declaration.slice(0, i).trim().toLowerCase(), v = declaration.slice(i + 1).trim();
// 음수 여백은 버립니다(0.9.9). margin-top:-400px 처럼 음수 여백이 붙은 문단은 앞 문단 위로 올라가 겹치거나 화면 밖으로 사라져(margin-left:-9999px),
// 편집기에서도 읽기 창에서도 글이 가려졌습니다. 양수 여백·들여쓰기는 그대로 둡니다.
if (/^margin/.test(key) && /(^|[\s,(])-\.?\d/.test(v)) continue;
if (STYLE_KEYS.has(key) && v.length < 160 && /^[a-zA-Z0-9#(),.% "'_-]+$/.test(v) && !/url|expression|javascript|behavior|var\(/i.test(v)) styles.push((key === 'height' && !KEEP_HEIGHT.has(tag) ? 'min-height' : key) + ':' + v);
}
if (styles.length) clean.push(`style="${escapeHtml(styles.join(';'))}"`);
}
if (NUMERIC.has(name) && /^[0-9]{1,4}%?$/.test(value)) clean.push(`${name}="${value}"`);
if (SIMPLE.has(name) && /^[a-zA-Z0-9# ,'-]{1,100}$/.test(value)) clean.push(`${name}="${escapeHtml(value)}"`);
if (name === 'alt' || name === 'title') clean.push(`${name}="${escapeHtml(value.slice(0, 300))}"`);
}
if (tag === 'a') clean.push('target="_blank"', 'rel="noopener noreferrer"');
return clean;
}
/**
* 허용 목록 방식 HTML 정제.
* @param {string} input 정제할 HTML
* @param {{limit?: number}} [options] limit: 받는 최대 글자 수. 넘으면 message_too_large 를 던집니다.
* 기본 40만 자는 화면 입력(미리 보기 정제 API)용입니다. 받은 메일·초안처럼 큰 HTML(뉴스레터 등)이 정상인 경로는
* 더 큰 한도를 넘기거나 store.mjs 의 safeContent 로 감싸 예외 대신 텍스트로 대체합니다.
* @returns {{html:string,text:string}}
*/
export function sanitizeHtml(input, { limit = 400000 } = {}) {
const html = String(input || '');
if (html.length > limit) throw new Error('message_too_large');
const parts = [], text = []; let depth = 0, i = 0;
const re = /<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<![^>]*>|<\?[^>]*>|<\/([a-zA-Z][a-zA-Z0-9:-]*)\s*>|<([a-zA-Z][a-zA-Z0-9:-]*)((?:\s+[^\s"'<>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'<>`]+))?)*)\s*\/?>/g;
let m;
while ((m = re.exec(html))) {
if (m.index > i) { const data = html.slice(i, m.index); if (!depth) { const decoded = decodeEntities(data); parts.push(escapeHtml(decoded)); text.push(decoded); } }
i = re.lastIndex;
const token = m[0];
if (token.startsWith('<!') || token.startsWith('<?')) continue;
if (m[1]) { // end tag
const tag = m[1].toLowerCase();
if (BLOCKED.has(tag)) { if (!UNWRAP_BLOCKED.has(tag) && !RAW_TEXT_BLOCKED.has(tag)) depth = Math.max(0, depth - 1); continue; }
if (depth) continue;
if (ALLOWED.has(tag) && !VOID.has(tag)) parts.push(`</${tag}>`);
if (['p', 'div', 'br', 'li', 'h1', 'h2', 'h3', 'h4', 'tr', 'blockquote', 'pre'].includes(tag)) text.push('\n');
continue;
}
const tag = m[2].toLowerCase();
if (depth && BREAKOUT.has(tag)) depth = 0; // svg/math 범위 안의 HTML 시작 태그: 브라우저처럼 외래 내용을 끝내고 그 태그부터 보통 HTML 로 처리합니다.
if (tag === 'body') continue; // 본문 시작 태그 자체는 쓰지 않습니다.
if (BLOCKED.has(tag)) {
if (VOID_BLOCKED.has(tag) || UNWRAP_BLOCKED.has(tag) || token.endsWith('/>')) continue;
if (RAW_TEXT_BLOCKED.has(tag)) {
// 닫는 태그까지의 내용(스크립트·스타일 본문)을 통째로 건너뜁니다. 닫는 태그가 없으면 열린 범위를 만들지 않습니다(뒤의 본문 보존).
const close = html.slice(re.lastIndex).search(new RegExp(`</${tag}\\s*>`, 'i'));
if (close >= 0) { const after = re.lastIndex + close; const endTag = html.slice(after).match(/^<\/[a-zA-Z0-9:-]+\s*>/); re.lastIndex = after + (endTag ? endTag[0].length : 0); }
else { const next = html.indexOf('<', re.lastIndex); re.lastIndex = next < 0 ? html.length : next; } // 닫는 태그가 없으면 바로 뒤의 글자(제목·스크립트 본문)만 버리고 다음 태그부터 이어 갑니다.
i = re.lastIndex; continue;
}
depth++; continue;
}
if (depth || !ALLOWED.has(tag)) continue;
const attrs = cleanAttrs(tag, parseAttrs(m[3] || ''));
parts.push(`<${tag}${attrs.length ? ' ' + attrs.join(' ') : ''}>`);
if (tag === 'br') text.push('\n');
}
if (i < html.length && !depth) { const decoded = decodeEntities(html.slice(i)); parts.push(escapeHtml(decoded)); text.push(decoded); }
return { html: parts.join(''), text: text.join('').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim() };
}
export function textToHtml(value) { return `<p>${escapeHtml(value || '').replaceAll('\n', '<br>')}</p>`; }
export function imageSources(html) { return [...String(html || '').matchAll(/<img\b[^>]*\bsrc="([^"]+)"/g)].map((x) => decodeEntities(x[1])); }
+230
View File
@@ -0,0 +1,230 @@
/**
* 전체 설정 화면(1.0.7, 요구사항 2)·초기 설정 페이지(1.0.0, 요구사항 4) — 운영 설정(config.json)과 도메인 등록부(domains.json)를 웹에서 다루는 화면과 API.
*
* 두 곳에서 같은 화면·API 를 씁니다.
* 1) 전용 포트(SETUP_PORT, 기본 8791): 배포 스크립트가 출력하는 주소 http(s)://<기본 도메인 호스트>:8791/?token=<토큰> 으로 엽니다. 이것이 "전체 설정" 입니다.
* 로그인 계정이 아직 없거나 nginx·인증서가 준비되기 전에도 쓸 수 있어야 하므로, 데이터 디렉터리의 setup-token 파일(배포 계정만 읽음)로 인증합니다.
* 메일 포트 인증서(POP_TLS_CERT)를 읽을 수 있으면 같은 인증서로 HTTPS 를, 없으면 HTTP 를 씁니다(주소는 로그·setup-url.txt 에 남김).
* 1.0.7: 도메인 목록·추가·중지·삭제(/api/setup/domains)와, ?domain=<도메인> 으로 고른 도메인의 설정(아래 config·plan…)을 모두 여기서 합니다.
* 요구사항대로 기본 도메인의 주소로 열어야 하며, 추가 도메인의 호스트 이름으로 이 포트에 오면 안내 화면만 줍니다.
* 2) 운영 중 수정: 관리자 페이지(/admin)의 "메일 서비스 설정" → /setup. 관리자 세션으로 /api/setup/* 를 부릅니다(api.mjs 가 이 모듈의 setupApi 로 넘김).
* 이쪽은 그 도메인(Host 로 고른 테넌트)의 설정만 다루며 도메인 관리는 할 수 없습니다.
*
* API(/api/setup/…) — 전용 포트에서는 ?domain=<도메인> 으로 대상 도메인을 고릅니다(없으면 기본 도메인)
* GET config 현재 설정(비밀값은 가림)·출처·스키마 + 메타(호스트·도메인·웹훅 주소·Haraka 상태·DNS 레코드·발신 서비스 상태)
* PUT config { values: { 키: 값 } } 저장 → 즉시 적용(재시작 없음) → GET 과 같은 응답
* POST check { what: 'outbound' } 발신 서비스 연결 확인(캐시 무시)
* POST dkim { force?: true } DKIM 키 생성(이미 있으면 force 일 때만 교체) → DNS 레코드
* GET plan / POST plan/run / POST dns/probe 자동 설정(1.0.5, provision.mjs)
* POST restart systemd 아래에서 돌고 있을 때만: 응답 뒤 프로세스를 끝내 systemd(Restart=always)가 다시 띄움
* GET/POST domains, PATCH/DELETE domains/<도메인> 도메인 등록부(1.0.7, 전용 포트 전용) — domains.mjs
*/
import { createServer as createHttpServer } from 'node:http';
import { createServer as createHttpsServer } from 'node:https';
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { networkInterfaces } from 'node:os';
import { json, bad, safeEqual, escapeHtml, secret } from './util.mjs';
import { parseAllowList, invalidAllowEntries } from './usage.mjs';
import { publicConfig, saveConfig, generateDkim, dnsRecords, setupToken, configDir, configFile, harakaHostname, mailDomain, harakaDir, harakaStatus, maskSecret } from './config.mjs';
import { buildPlan, runPlan } from './provision.mjs';
import { probeDns } from './dns/index.mjs';
import { outboundStatus, inboundMode, outboundMode, usesHaraka, usesResend } from './providers/index.mjs';
import { noServicePage, normalizeHost } from './domains.mjs';
/** systemd 서비스 안에서 돌고 있는지(부모가 PID 1 이고 INVOCATION_ID 가 있음). 재시작 단추를 보일지 정합니다. */
export const underSystemd = () => process.ppid === 1 && !!process.env.INVOCATION_ID;
// harakaDir / harakaStatus 는 1.0.5 에서 config.mjs 로 옮겼습니다(provision.mjs 와 공유). 예전 import 경로를 위해 다시 내보냅니다.
export { harakaDir, harakaStatus };
/** 이 서버의 공개 IPv4 후보(초기 설정 주소 안내용). 루프백·내부 주소는 뺍니다. */
export function serverAddresses() {
const out = [];
for (const list of Object.values(networkInterfaces())) for (const a of list || []) if (a.family === 'IPv4' && !a.internal) out.push(a.address);
return out;
}
/** 설정 화면이 쓰는 메타 정보. 비용이 있는 발신 상태 확인은 60초 캐시(outboundStatus)를 씁니다. */
async function meta(env) {
const host = env.PUBLIC_HOST || 'mail.arihakdang.com';
return {
// loginMode(1.0.8): standalone | www | webmaker. publicPath: 이 도메인 첫 호스트의 경로 접두사('' 또는 '/mail').
host, mailbox: String(env.MAILBOX || ''), domain: mailDomain(env), harakaHost: harakaHostname(env), version: env.APP_VERSION || null, loginMode: env.LOGIN_MODE === 'webmaker' && env.WEBMAKER_DIR ? 'webmaker' : env.WWW_DB_FILE ? 'www' : 'standalone', publicPath: env.PUBLIC_PATH || '',
webhookUrl: `https://${host}${env.PUBLIC_PATH || ''}/api/webhooks/resend`, configFile: configFile(env), setupPort: Number(env.SETUP_PORT ?? 8791), gatewayPort: Number(env.PORT || 8790),
inbound: inboundMode(env), outbound: outboundMode(env), usesResend: usesResend(env), usesHaraka: usesHaraka(env),
haraka: harakaStatus(env), dns: dnsRecords(env), outboundStatus: await outboundStatus(env).catch((e) => ({ provider: e.message })),
systemd: underSystemd(), tlsActive: !!env.POP_TLS_ACTIVE,
// 1.0.7: 이 설정이 기본 도메인의 것인지(Haraka 포트 같은 서버 공통 값은 기본 도메인만 바꿀 수 있음), 메일 프로그램이 접속할 서버 이름(기본 도메인의 호스트).
primary: !env.TENANTS || env.TENANTS.primary.env === env, serverHost: env.MAIL_SERVER_HOST || host, domains: env.TENANTS ? env.TENANTS.all().length : 1,
// 사용량 API(1.0.9): 키는 기본 도메인 설정에만 있으므로 기본 도메인 env 로 상태를 봅니다. 키 자체는 주지 않고 있음/없음과 가린 값, 허용 IP, 주소만.
usageApi: (() => { const p = env.TENANTS ? env.TENANTS.primary.env : env; const key = String(p.USAGE_API_KEY || ''); return { enabled: !!key, keyMasked: key ? maskSecret(key) : '', allow: parseAllowList(p.USAGE_API_ALLOW), endpoint: `https://${normalizeHost(p.PUBLIC_HOST || host)}${p.PUBLIC_PATH || ''}/api/usage`, updatedAt: p.CONFIG?._meta?.usageKeyAt || null }; })(),
};
}
/**
* /api/setup/* 처리(인증은 호출자가 끝낸 뒤 부릅니다: 전용 서버는 토큰, api.mjs 는 관리자 세션).
* @param {string[]} path 'setup' 뒤의 경로 조각
*/
export async function setupApi(env, request, path, { ip = '', user = null, tenants = null } = {}) {
const method = request.method, action = path[0] || '';
const respond = async () => json({ ok: true, ...publicConfig(env), meta: await meta(env) });
// 도메인 등록부(1.0.7): 전용 포트(토큰)에서만. 관리자 세션(/setup)은 자기 도메인만 다룹니다.
if (action === 'domains') { if (!tenants) return bad('not_found', 404); return await domainsApi(tenants, request, path.slice(1), ip); }
if (action === 'config' && method === 'GET') return respond();
if (action === 'config' && method === 'PUT') {
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object' || typeof body.values !== 'object') return bad('bad_request');
// 사용량 API 허용 IP(1.0.9): 잘못된 항목이 하나라도 있으면 저장하지 않습니다(오타 하나로 모든 호출이 403 이 되는 일 방지).
if (body.values.USAGE_API_ALLOW !== undefined) { const badEntries = invalidAllowEntries(body.values.USAGE_API_ALLOW); if (badEntries.length) return bad('validation', 422, { field: 'USAGE_API_ALLOW', detail: badEntries.join(', ') }); }
try { saveConfig(env, body.values); } catch (e) { if (e.message === 'validation') return bad('validation', 422, { field: e.field }); throw e; }
console.log(`설정 저장(${user ? user.login : 'setup-token'} ${ip}): ${Object.keys(body.values).join(', ')} → 수신 ${inboundMode(env)} · 발신 ${outboundMode(env)}`);
return respond();
}
if (action === 'check' && method === 'POST') {
const body = await request.json().catch(() => ({}));
if (body.what === 'outbound') return json({ ok: true, outboundStatus: await outboundStatus(env, { force: true }) });
if (body.what === 'haraka') return json({ ok: true, haraka: harakaStatus(env) });
return bad('bad_request');
}
// 자동 설정(1.0.5, 요구사항 2·4·5): 계획(단계 목록·상태) 조회와 실행. 실행은 전부(자동 가능한 단계) 또는 한 단계(step)이며, 사용자 입력(input)은 먼저 설정에 저장합니다.
if (action === 'plan' && !path[1] && method === 'GET') return json({ ok: true, plan: await buildPlan(env) });
if (action === 'plan' && path[1] === 'run' && method === 'POST') {
const body = await request.json().catch(() => ({}));
try { const result = await runPlan(env, { step: body.step || null, stepAction: body.action || null, input: body.input && typeof body.input === 'object' ? body.input : {} }); console.log(`자동 설정 실행(${user ? user.login : 'setup-token'} ${ip}): ${body.step || '전체'}${body.action ? '/' + body.action : ''} → ${result.log.map((l) => l.id + ':' + l.result).join(', ') || '변화 없음'}`); return json({ ok: true, ...result }); }
catch (e) { if (e.message === 'validation') return bad('validation', 422, { field: e.field }); throw e; }
}
if (action === 'dns' && path[1] === 'probe' && method === 'POST') return json({ ok: true, probe: await probeDns(env) });
// 사용량 API 키(1.0.9): { action:'generate' } 새 키를 만들어(있으면 교체) 한 번만 돌려줍니다 · { action:'revoke' } 키를 지워 API 를 끕니다. 기본 도메인에서만(키는 서버 하나에 하나).
if (action === 'usage-key' && method === 'POST') {
const primary = env.TENANTS ? env.TENANTS.primary.env : env;
if (primary !== env) return bad('primary_only', 409);
const body = await request.json().catch(() => ({}));
if (body.action === 'revoke') { saveConfig(env, { USAGE_API_KEY: '' }, { allowHidden: true }); console.log(`사용량 API 키 삭제(${user ? user.login : 'setup-token'} ${ip})`); return json({ ok: true, enabled: false }); }
if (body.action !== 'generate') return bad('bad_request');
const key = 'au_' + secret(32);
// _meta.usageKeyAt: 언제 만든 키인지(화면 안내). saveConfig 의 _meta 갱신 뒤에 덧붙입니다.
saveConfig(env, { USAGE_API_KEY: key }, { allowHidden: true }); env.CONFIG._meta = { ...(env.CONFIG._meta || {}), usageKeyAt: new Date().toISOString() }; saveConfig(env, {});
console.log(`사용량 API 키 생성(${user ? user.login : 'setup-token'} ${ip})`);
return json({ ok: true, enabled: true, key, endpoint: `https://${normalizeHost(env.PUBLIC_HOST || 'mail.arihakdang.com')}${env.PUBLIC_PATH || ''}/api/usage` });
}
if (action === 'dkim' && method === 'POST') { const body = await request.json().catch(() => ({})); return json({ ok: true, dns: generateDkim(env, { force: !!body.force }) }); }
if (action === 'restart' && method === 'POST') {
// systemd 서비스로 돌 때만(부모가 PID 1). INVOCATION_ID 는 사용자 셸에도 있을 수 있어 쓰지 않습니다. 개발 실행·시험에서는 거절합니다.
if (!underSystemd()) return bad('restart_unavailable', 409);
console.log(`재시작 요청(${user ? user.login : 'setup-token'} ${ip}) — systemd 가 다시 띄웁니다`);
setTimeout(() => process.exit(0), 500).unref();
return json({ ok: true, restarting: true });
}
return bad('not_found', 404);
}
/**
* 도메인 등록부 API(1.0.7, 요구사항 2). 전용 포트(토큰)에서만 부릅니다.
* GET domains 목록(기본 도메인 먼저; 계정·메일 수, 수·발신 방식, 로그인 방식, 사용 여부)
* POST domains { domain, hosts?, mailbox?, auth?:{mode:'local'|'www', wwwDbFile?, ownerLogin?}, adminPassword?, copyFromPrimary?, note? } → 추가(즉시 동작)
* PATCH domains/<도메인> { enabled?, hosts?, auth?, note? } → 변경(기본 도메인은 hosts·note 만)
* DELETE domains/<도메인> 등록 해제(데이터 디렉터리는 남김). 기본 도메인은 불가
* 오류: validation(422, field) · domain_exists(409) · host_taken(409, detail) · www_db_unreadable(422) · primary_domain(409) · not_found(404)
*/
async function domainsApi(tenants, request, path, ip) {
const method = request.method, target = path[0] ? decodeURIComponent(path[0]) : '';
const list = async () => ({ domains: await tenants.list(), primary: tenants.registry.primary, serverHost: tenants.primary.env.MAIL_SERVER_HOST || '' });
try {
if (method === 'GET' && !target) return json({ ok: true, ...(await list()) });
if (method === 'POST' && !target) {
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request');
const r = await tenants.add(body);
console.log(`도메인 추가(setup-token ${ip}): ${r.entry.domain}`);
return json({ ok: true, domain: r.entry, initialAdmin: r.initialAdmin, ...(await list()) }, 201);
}
if (method === 'PATCH' && target) {
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object') return bad('bad_request');
const entry = tenants.update(target, body);
console.log(`도메인 변경(setup-token ${ip}): ${entry.domain} ${JSON.stringify(Object.keys(body))}`);
return json({ ok: true, domain: entry, ...(await list()) });
}
if (method === 'DELETE' && target) { const r = tenants.remove(target); console.log(`도메인 삭제(setup-token ${ip}): ${r.domain}`); return json({ ok: true, removed: r, ...(await list()) }); }
return bad('not_found', 404);
} catch (e) {
const status = e.message === 'validation' || e.message === 'www_db_unreadable' || e.message === 'webmaker_dir_unreadable' ? 422 : ['domain_exists', 'host_taken', 'primary_domain'].includes(e.message) ? 409 : e.message === 'not_found' ? 404 : 500;
if (status === 500) console.error('도메인 등록부 오류', e);
return bad(status === 500 ? 'server_error' : e.message, status, { ...(e.field ? { field: e.field } : {}), ...(e.detail ? { detail: e.detail } : {}) });
}
}
/**
* 초기 설정 전용 HTTP(S) 서버. 화면 파일(web/setup.html·setup.js·setup.css)과 토큰 인증 API 만 제공합니다.
* @param {object} o
* @param {string} o.webDir web/ 디렉터리
* @param {object|null} [o.tlsOptions] { key, cert } 가 있으면 HTTPS
* @param {object|null} [o.tenants] 도메인 관리자(domains.mjs openTenants). 있으면 전체 설정(도메인 목록·?domain= 선택)으로 동작합니다(1.0.7).
*/
export function createSetupServer(env, { webDir, tlsOptions = null, tenants = null }) {
const SECURITY = { 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY', 'X-Robots-Tag': 'noindex, nofollow', 'Cache-Control': 'no-store' };
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
const STATIC = { '/setup.js': 'application/javascript; charset=utf-8', '/setup.css': 'text/css; charset=utf-8', '/favicon.svg': 'image/svg+xml' };
let server = null, bound = null; const secure = !!tlsOptions;
const handler = async (req, res) => {
const url = new URL(req.url, 'http://x'); const ip = req.socket.remoteAddress || '';
try {
// 1.0.7: 전체 설정은 기본 도메인의 주소로 엽니다. 추가 도메인의 호스트 이름으로 오면(인증서도 맞지 않음) 안내만 줍니다. IP·localhost 는 기본 도메인으로 봅니다.
if (tenants) { const r = tenants.resolveHost(String(req.headers.host || '')); if (r.tenant && r.tenant !== tenants.primary) { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY }); return res.end(noServicePage({ host: r.host, strings: { domainMissingTitle: '전체 설정은 기본 도메인 주소로 엽니다', domainMissing: `이 주소(${r.host})로는 전체 설정 화면을 열 수 없습니다.`, domainAskAdmin: `기본 도메인의 주소 https://${normalizeHost(env.PUBLIC_HOST || '')}:${bound?.port || Number(env.SETUP_PORT ?? 8791)}/ 로 여세요(배포 출력에 토큰 포함 주소가 있습니다).` }, version: env.APP_VERSION })); } }
if (url.pathname === '/' || url.pathname === '/index.html') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': CSP, ...SECURITY }); return res.end(setupPage(env, webDir, 'token')); }
if (url.pathname === '/favicon.ico') { res.writeHead(301, { Location: '/favicon.svg' }); return res.end(); }
if (STATIC[url.pathname]) { res.writeHead(200, { 'Content-Type': STATIC[url.pathname], ...SECURITY }); return res.end(readFileSync(join(webDir, url.pathname))); }
if (url.pathname === '/api/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: true, version: env.APP_VERSION || null, setup: true })); }
if (!url.pathname.startsWith('/api/setup/')) { res.writeHead(404, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'not_found' })); }
// 토큰 인증: 헤더(X-Setup-Token) 또는 주소(?token=). 틀리면 IP 당 15분 10회만 허용합니다(토큰 추측 방지).
const rateKey = 'setup:' + ip;
if (env.RATE?.blocked(rateKey, { limit: 10 })) { res.writeHead(429, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'rate_limited' })); }
const token = req.headers['x-setup-token'] || url.searchParams.get('token') || '';
if (!safeEqual(String(token), setupToken(env))) { env.RATE?.hit(rateKey, { limit: 10, windowSec: 900 }); res.writeHead(401, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'setup_token_invalid' })); }
const chunks = []; let size = 0; if (!['GET', 'HEAD'].includes(req.method)) for await (const c of req) { size += c.length; if (size > 1024 * 1024) throw new Error('body_too_large'); chunks.push(c); }
const headers = new Headers(); for (const [k, v] of Object.entries(req.headers)) if (typeof v === 'string') headers.set(k, v);
const request = new Request(`${secure ? 'https' : 'http'}://${req.headers.host || 'localhost'}${req.url}`, { method: req.method, headers, body: chunks.length ? Buffer.concat(chunks) : undefined });
// 대상 도메인(1.0.7): ?domain=<도메인> 이 있으면 그 테넌트의 env, 없으면 기본 도메인. 모르는 도메인은 404.
let target = env; const want = url.searchParams.get('domain');
if (want && tenants) { const t = tenants.byDomain(want); if (!t) { res.writeHead(404, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'domain_not_found' })); } target = t.env; }
const response = await setupApi(target, request, url.pathname.slice('/api/setup/'.length).split('/').filter(Boolean), { ip, tenants });
const out = { ...SECURITY }; response.headers.forEach((v, k) => { out[k] = v; });
const buf = Buffer.from(await response.arrayBuffer()); res.writeHead(response.status, out); res.end(buf);
} catch (e) {
console.error('초기 설정 페이지 오류', req.method, url.pathname, e.message);
if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'application/json', ...SECURITY }); res.end(JSON.stringify({ ok: false, error: 'server_error' }));
}
};
return {
/** 포트를 엽니다. 실패(이미 쓰는 포트 등)는 예외로 알립니다. */
listen({ port, host = '0.0.0.0' }) {
return new Promise((resolve, reject) => {
server = secure ? createHttpsServer({ ...tlsOptions, minVersion: 'TLSv1.2' }, handler) : createHttpServer(handler);
server.once('error', reject);
server.listen(port, host, () => { bound = { port: server.address().port, host, secure }; resolve(bound); });
});
},
/** 접속 주소. HTTPS 면 인증서와 맞는 공개 호스트 이름을, HTTP 면 서버 IP 를 씁니다(DNS 가 아직 안 바뀐 새 서버에서도 열 수 있게). */
urls(token) {
if (!bound) return [];
const scheme = bound.secure ? 'https' : 'http', host = env.PUBLIC_HOST || 'localhost';
const hosts = bound.secure ? [host, ...serverAddresses()] : [...serverAddresses(), host];
return [...new Set(hosts)].map((h) => `${scheme}://${h}:${bound.port}/?token=${encodeURIComponent(token)}`);
},
/** 인증서 갱신(index.mjs 의 1시간 점검)을 반영합니다. HTTP 로 시작했으면 바꾸지 않습니다(주소 체계가 달라지므로 다음 재시작부터 HTTPS). */
setTls(opts) { if (server && bound?.secure && opts) server.setSecureContext(opts); },
close() { if (server) server.close(); },
get bound() { return bound; },
};
}
/**
* 설정 화면 HTML. web/setup.html 의 자리표시자를 채웁니다: __MODE__(token | admin — 인증 방식), __VERSION__, __HOST__, __TITLE__, __BASE__(1.0.8: 기준 경로, 보통 "/").
* index.mjs 가 /setup 에서(경로 접두사 아래면 base 가 "/mail/"), 전용 서버가 / 에서 같은 함수를 씁니다.
*/
export function setupPage(env, webDir, mode, { base = '/' } = {}) {
const title = (env.STRINGS?.appName || 'AriMail') + ' 설정';
return readFileSync(join(webDir, 'setup.html'), 'utf8').replace(/__MODE__/g, mode).replace(/__VERSION__/g, escapeHtml(env.APP_VERSION || '')).replace(/__HOST__/g, escapeHtml(env.PUBLIC_HOST || '')).replace(/__TITLE__/g, escapeHtml(title)).replace(/__BASE__/g, escapeHtml(base));
}
/** 배포 스크립트가 읽어 출력할 접속 주소 파일(데이터 디렉터리의 setup-url.txt). 쓸 수 없어도 서버는 계속 뜹니다. */
export function writeSetupUrls(env, urls) {
try { writeFileSync(join(configDir(env), 'setup-url.txt'), urls.join('\n') + '\n', { mode: 0o600 }); } catch (e) { console.warn(' ⚠️ setup-url.txt 를 쓸 수 없습니다:', e.message); }
}
+446
View File
@@ -0,0 +1,446 @@
/**
* SMTP 제출(submission) 서버 — RFC 5321 + RFC 6409(메일 제출) + RFC 3207(STARTTLS) + RFC 4954(AUTH).
*
* 왜 필요한가
* 0.8.0 까지는 메일 프로그램(Outlook·Thunderbird·Apple Mail 등)이 Resend SMTP(smtp.resend.com)에 직접 접속했고,
* SMTP 비밀번호로 Resend API 키를 입력해야 했습니다. 키가 사용자 PC·메일 프로그램 설정에 퍼지면 노출 위험이 큽니다.
* 이제 메일 프로그램은 이 게이트웨이(mail.arihakdang.com:465/587)에 **웹 메일 로그인과 같은 아이디·비밀번호**로 인증하고,
* 게이트웨이가 받은 메일을 서버에만 보관된 RESEND_API_KEY 로 Resend API 를 호출해 실제로 발송합니다.
* API 키는 서버 환경 파일 밖으로 나가지 않습니다.
*
* 동작 요약
* - 465: 접속 즉시 TLS(SMTPS). 587: 평문으로 접속한 뒤 STARTTLS 로 암호화(권장 제출 포트).
* - 인증(AUTH PLAIN / AUTH LOGIN)은 TLS 위에서만 받습니다(POP·IMAP 과 같은 정책, 진단용 SMTP_ALLOW_PLAINTEXT 예외).
* - 인증한 사용자는 자기 메일함 주소로만 보낼 수 있습니다(0.9.0 부터 관리자도 마찬가지 — 메일 기능에서는 자기 메일함만 씀).
* 다른 주소를 보내는 사람으로 쓰면 거부합니다(우리 키로 남의 주소를 사칭하지 못하게).
* - 받는 사람 수·첨부 크기·메일함 용량·월간 통수 제한(0.9.0, limits.mjs)을 발송 전에 검사해 SMTP 오류로 돌려줍니다.
* - DATA 로 받은 원본(.eml)을 해석해 Resend API 요청(받는 사람·참조·숨은 참조·제목·본문·첨부·인라인 이미지)으로 바꿔 보냅니다.
* - 발송에 성공하면 원본을 그대로 보낸 메일함에 저장합니다. 메일 프로그램이 IMAP 으로 같은 메일을 보낸 메일함에
* 올려도(APPEND) Message-ID 로 한 통으로 합칩니다.
* - 이 서버는 외부에서 오는 메일을 받는 MX 가 아닙니다. 인증하지 않은 접속은 MAIL 단계에서 거부합니다(오픈 릴레이 방지).
*/
import net from 'node:net';
import tls from 'node:tls';
import { createHash } from 'node:crypto';
import { verifyUser, audit } from './auth.mjs';
import { sendMail } from './providers/index.mjs';
import { normalizeCrlf } from './mime.mjs';
import { parseMime, summarize, header, parseAddresses, decodeWords } from './imap-mime.mjs';
import { appendMessage, setFlag } from './store.mjs';
import { now, isEmail, memoryRateStore } from './util.mjs';
import { checkOutbound, recipientLimit } from './limits.mjs';
const CRLF = '\r\n';
// 한 통의 최대 크기(원본 바이트). Resend 는 base64 로 늘어난 뒤 40MB 까지 받으므로 원본 기준 30MB 로 둡니다.
export const SMTP_MAX_SIZE = 30 * 1024 * 1024;
// 한 통의 최대 받는 사람 수는 0.9.0 부터 관리자·시스템 설정(limits.recipientLimit, 내장 상한 50 = Resend 한도)을 따릅니다.
// 명령 한 줄의 최대 길이. RFC 5321 은 512 바이트지만 AUTH 초기 응답이 길 수 있어 넉넉히 둡니다.
const MAX_LINE = 4096;
// 인증 실패 요청 제한(접속 IP 기준). 비밀번호 대입을 늦춥니다.
const rate = memoryRateStore();
const TERMINATOR = Buffer.from('\r\n.\r\n');
/**
* SMTP 응답 코드를 실어 나르는 오류. submitMessage 가 던지면 세션이 그대로 클라이언트에 전달합니다.
* @param {number} code 3자리 응답 코드(예: 550)
* @param {string} enhanced 확장 상태 코드(RFC 3463, 예: '5.7.1')
* @param {string} message 사람이 읽는 설명(영문, 메일 프로그램이 그대로 보여 줌)
*/
export class SmtpError extends Error {
constructor(code, enhanced, message) { super(message); this.code = code; this.enhanced = enhanced; }
get reply() { return `${this.code} ${this.enhanced} ${this.message}`; }
}
/**
* 발송 모듈의 오류 코드(providers/resend.mjs 의 providerError, providers/smtp-client.mjs 의 smtpErrorCode)를 SMTP 응답으로 바꿉니다.
* 키·도메인 설정 문제나 일시 장애는 4xx(임시 실패)로 돌려 메일 프로그램이 나중에 다시 보내게 하고,
* 내용이 거부된 경우만 5xx(영구 실패)로 돌려 사용자가 고치게 합니다. 응답에 키 관련 정보는 넣지 않습니다.
*/
function providerReply(e) {
if (e instanceof SmtpError) return e;
switch (e?.message) {
case 'mail_rejected': return new SmtpError(550, '5.6.0', 'Message rejected by the mail service' + (e.providerMessage ? ': ' + String(e.providerMessage).replace(/[\r\n]+/g, ' ').slice(0, 200) : ''));
case 'mail_rate_limited': return new SmtpError(451, '4.7.0', 'Sending rate limit reached, try again shortly');
default: return new SmtpError(451, '4.3.0', 'Mail service temporarily unavailable, try again later');
}
}
/** "이름 <주소>" 형식 문자열. 이름이 없으면 주소만. 이름에 따옴표·꺾쇠가 있으면 따옴표로 감쌉니다. */
function displayAddress(name, addr) {
if (!name) return addr;
const clean = String(name).replace(/[\r\n]+/g, ' ').trim();
return /[",<>()@;:]/.test(clean) ? `"${clean.replace(/["\\]/g, '\\$&')}" <${addr}>` : `${clean} <${addr}>`;
}
/** parseAddresses 결과({name, mailbox, host})를 소문자 주소와 표시 문자열로 바꿉니다. */
const toEntries = (value) => parseAddresses(value).filter((a) => a.host).map((a) => { const addr = `${a.mailbox}@${a.host}`.toLowerCase(); return { addr, display: displayAddress(a.name, addr) }; });
/**
* 이 사용자가 보내는 사람으로 쓸 수 있는 주소 목록: 자기 메일함 하나뿐입니다.
* 0.8.x 까지는 관리자가 게이트웨이의 모든 메일함 주소로 보낼 수 있었지만(웹에서도 모든 메일함을 열 수 있었음), 0.9.0 에서 관리자의
* 메일함 전환 기능을 없애면서 발신 주소도 자기 메일함으로 좁혔습니다. 목록 형태는 호출부 호환을 위해 유지합니다.
*/
export async function allowedSenders(env, user) { return [user.mailbox]; }
/** limits.checkOutbound 의 오류 코드를 SMTP 응답으로 바꿉니다. 용량·통수 제한은 임시(4xx)/영구(5xx) 성격에 맞춰 돌려줍니다. */
function limitReply(e) {
switch (e?.message) {
case 'too_many_recipients': return new SmtpError(452, '4.5.3', `Too many recipients (limit ${e.limit})`);
case 'attachment_too_large': return new SmtpError(552, '5.3.4', `Attachment exceeds the size limit (${e.limit} bytes after MIME encoding)`);
case 'quota_exceeded': return new SmtpError(452, '4.2.2', 'Mailbox full (quota exceeded), free space and try again');
case 'storage_full': return new SmtpError(452, '4.3.1', 'Insufficient system storage');
case 'monthly_limit_exceeded': return new SmtpError(550, '5.7.1', `Monthly message limit reached (${e.limit})`);
default: return null;
}
}
/**
* 인증된 사용자가 DATA 로 보낸 원본을 Resend API 로 발송하고 보낸 메일함에 저장합니다.
*
* @param {object} env
* @param {object} user verifyUser 가 돌려준 사용자(id, login, name, role, mailbox)
* @param {object} envelope
* @param {string} envelope.mailFrom MAIL FROM 주소(빈 문자열 = 반송 주소 없음 "<>")
* @param {string[]} envelope.rcpt RCPT TO 주소들(실제로 배달할 대상, 소문자)
* @param {Buffer} envelope.eml DATA 원본(점 스터핑을 푼 RFC 5322 메시지)
* @param {string} [envelope.ip] 접속 IP(감사 기록용)
* @returns {Promise<{id:string, providerId:string}>} 저장한 메일 id 와 Resend 발송 id
* @throws {SmtpError} 클라이언트에 돌려줄 SMTP 오류
*/
export async function submitMessage(env, user, { mailFrom = '', rcpt = [], eml, ip = '' }) {
if (!env.RESEND_API_KEY) throw new SmtpError(451, '4.3.0', 'Mail service temporarily unavailable, try again later');
const raw = normalizeCrlf(eml);
let parsed, info;
try { parsed = parseMime(raw); info = summarize(parsed); } catch { throw new SmtpError(550, '5.6.0', 'Malformed message'); }
const headers = parsed.root.headers;
// 1) 보내는 사람 확인: 헤더 From 과 봉투 MAIL FROM 이 모두 이 사용자에게 허용된 주소여야 합니다.
const allowed = await allowedSenders(env, user);
const fromEntry = toEntries(header(headers, 'from'))[0];
const fromAddr = fromEntry?.addr || mailFrom || user.mailbox;
if (!allowed.includes(fromAddr) || (mailFrom && !allowed.includes(mailFrom))) throw new SmtpError(550, '5.7.1', `Sender address not allowed for this account (use ${user.mailbox})`);
// From 헤더가 없으면 계정 이름으로 채웁니다. 이름만 비어 있으면 계정 이름을 씁니다.
const from = fromEntry && fromEntry.display !== fromEntry.addr ? fromEntry.display : displayAddress(user.name || '', fromAddr);
// 2) 받는 사람 나누기. 실제 배달 대상은 봉투(RCPT)입니다. 헤더의 To/Cc 중 봉투에 있는 주소만 to/cc 로 두고,
// 봉투에만 있는 주소는 숨은 참조(bcc)로 보냅니다(메일 프로그램은 Bcc 헤더를 지우고 RCPT 로만 알려 주기 때문).
const rcptSet = new Set(rcpt);
const to = toEntries(header(headers, 'to')).filter((a) => rcptSet.has(a.addr));
const cc = toEntries(header(headers, 'cc')).filter((a) => rcptSet.has(a.addr) && !to.some((t) => t.addr === a.addr));
const listed = new Set([...to, ...cc].map((a) => a.addr));
const bcc = rcpt.filter((a) => !listed.has(a));
// Resend 는 to 가 꼭 있어야 합니다. 모두 숨은 참조로 보낸 경우(예: "받는 사람 없음" 단체 메일)에는
// 보내는 사람 자신을 to 로 둡니다. 받는 사람들에게는 서로의 주소가 보이지 않고, 보낸 사람에게 사본이 한 통 갑니다.
const toList = to.length ? to.map((a) => a.display) : [from];
// 3) Resend 요청 만들기
const replyTo = toEntries(header(headers, 'reply-to')).map((a) => a.display);
const custom = {};
for (const name of ['In-Reply-To', 'References']) { const v = header(headers, name); if (v) custom[name] = String(v).replace(/[\r\n]+/g, ' ').slice(0, 2000); }
const payload = {
from, to: toList, ...(cc.length ? { cc: cc.map((a) => a.display) } : {}), ...(bcc.length ? { bcc } : {}), ...(replyTo.length ? { reply_to: replyTo } : {}),
subject: info.subject || '', ...(info.html ? { html: info.html } : {}), text: info.text || (info.html ? undefined : ' '),
...(Object.keys(custom).length ? { headers: custom } : {}),
};
if (payload.text === undefined) delete payload.text;
const cidUsed = new Set([...String(info.html || '').matchAll(/\bsrc\s*=\s*["']?cid:([^"'\s>]+)/gi)].map((x) => x[1].replace(/&amp;/g, '&')));
if (info.attachments.length) payload.attachments = info.attachments.map((a) => ({
filename: decodeWords(a.filename || '') || 'attachment', content: Buffer.from(a.bytes).toString('base64'), content_type: a.contentType,
// 본문에 cid: 로 끼워 넣은 이미지는 content_id 를 그대로 넘겨 받는 쪽에서도 본문 안에 보이게 합니다.
// Content-Disposition 없이 Content-ID 만 있는 파트(Outlook·Exchange 류)도 본문이 cid: 로 가리키면 인라인입니다(0.9.0 수정, 예전에는 깨진 그림 + 별도 첨부로 갔음).
...(a.contentId && (a.disposition === 'inline' || cidUsed.has(a.contentId)) ? { content_id: a.contentId } : {}),
}));
// 같은 메일을 메일 프로그램이 다시 보내는 경우(응답을 못 받고 재전송) 두 번 발송되지 않게, 사용자 + 원본 바이트로 멱등 키를 만듭니다.
// 같은 내용을 일부러 다시 보내도 메일 프로그램이 Date·Message-ID 를 새로 만들므로 키가 달라 정상 발송됩니다.
// 키에는 봉투(MAIL FROM, RCPT TO 집합)도 넣습니다. 예전에는 사용자+원본만 써서, 같은 DATA 를 받는 사람만 바꿔 따로 보내는 경우
// (메일 머지·"개별 발송")에 키가 겹쳐 두 번째 받는 사람에게 가지 않았습니다(0.8.25 수정).
const key = 'smtp-' + createHash('sha256').update(user.id + '\n' + (mailFrom || '') + '\n' + [...rcptSet].sort().join(',') + '\n').update(raw).digest('hex').slice(0, 48);
// 제한 검사(0.9.0): 받는 사람 수·첨부 크기(MIME 기준)·메일함 용량(원본 크기만큼 늘어남)·월간 통수. 발송 전에 하므로 어긋나면 아무것도 보내지 않습니다.
try { await checkOutbound(env, { mailbox: fromAddr, recipients: rcpt.length, attachmentSizes: info.attachments.map((a) => a.bytes.length), bytes: raw.length }); }
catch (e) { const reply = limitReply(e); if (reply) throw reply; throw e; }
let result;
// 1.0.0: 발송 모듈(Resend / 내장 Haraka / 외부 SMTP 중계)은 설정이 정합니다. 어느 쪽이든 같은 오류 코드로 실패하므로 SMTP 응답 변환(providerReply)은 그대로입니다.
try { result = await sendMail(env, payload, key); } catch (e) { throw providerReply(e); }
// 4) 보낸 메일함 저장. 메일 프로그램이 IMAP 으로 먼저 올린 사본(같은 Message-ID, 발송 id 없음)이 있으면 그 메일에 발송 id 만 붙입니다.
const stamp = now();
let id = null;
if (info.messageId) id = (await env.DB.prepare("SELECT id FROM messages WHERE mailbox=? AND folder='sent' AND message_id=? AND provider_id IS NULL").bind(fromAddr, info.messageId).first())?.id || null;
if (!id) id = (await appendMessage(env, 'sent', raw, { flags: ['\\Seen'], internalDate: stamp, mailbox: fromAddr, enforceQuota: false })).id;
// provider_id 를 채워 두면 정기 동기화(ingestSent)가 같은 메일을 다시 가져오지 않습니다.
await env.DB.prepare("UPDATE messages SET provider_id=?,sent_at=?,state='sent',bcc_json=?,updated_at=? WHERE id=?").bind(result.id, stamp, JSON.stringify(bcc), stamp, id).run();
// 답장이면 원래 메일에 \Answered(답장함) 표시를 남깁니다(웹 발송과 같은 규칙).
if (custom['In-Reply-To']) { const parent = await env.DB.prepare('SELECT id FROM messages WHERE message_id=? AND mailbox=?').bind(custom['In-Reply-To'], fromAddr).first(); if (parent) await setFlag(env, parent.id, '\\Answered', true); }
await audit(env, user.login, 'smtp_send', id, { ip, recipients: rcpt.length });
return { id, providerId: result.id };
}
/**
* DATA 본문 수집기. 받은 바이트를 조각으로 모으며 끝 표시(<CRLF>.<CRLF>)를 찾습니다.
* 큰 첨부(수십 MB)를 받을 때 매번 버퍼를 이어 붙이면 복사량이 제곱으로 늘어나므로, 조각 목록과 마지막 4바이트(tail)만 유지합니다.
* 끝 표시가 조각 경계에 걸쳐도 tail 과 새 조각을 붙여 찾으므로 놓치지 않습니다.
*/
export class DataCollector {
// tail 을 CRLF 로 시작하는 이유: DATA 명령 줄 끝의 CRLF 가 본문 바로 앞에 있으므로, 본문이 비어 있어
// 첫 줄이 곧바로 "." 인 경우(".\r\n")도 같은 끝 표시(<CRLF>.<CRLF>) 규칙으로 찾을 수 있습니다. 이 CRLF 는 본문에 넣지 않습니다.
constructor(max) { this.parts = []; this.size = 0; this.tail = Buffer.from(CRLF); this.max = max; this.tooBig = false; }
/**
* @param {Buffer} chunk 새로 받은 바이트
* @returns {null | {message: Buffer|null, rest: Buffer}} 끝 표시를 찾으면 원본(크기 초과면 null)과 그 뒤에 남은 바이트
*/
feed(chunk) {
const combined = Buffer.concat([this.tail, chunk]);
const idx = combined.indexOf(TERMINATOR);
if (idx < 0) {
this.push(chunk);
this.tail = Buffer.from(combined.subarray(Math.max(0, combined.length - 4)));
return null;
}
// 본문은 끝 표시의 앞 CRLF 까지 포함합니다. bodyEnd 가 tail 안에 있으면(끝 표시가 이전 조각에서 시작) 이미 넣은 바이트를 덜어 냅니다.
const bodyEnd = idx + 2, fromChunk = bodyEnd - this.tail.length;
let drop = 0;
if (fromChunk >= 0) this.push(chunk.subarray(0, fromChunk)); else drop = -fromChunk;
const rest = combined.subarray(idx + TERMINATOR.length);
if (this.tooBig) return { message: null, rest };
let message = Buffer.concat(this.parts); if (drop) message = message.subarray(0, Math.max(0, message.length - drop));
return { message, rest };
}
push(buf) {
this.size += buf.length;
if (this.size > this.max) { this.tooBig = true; this.parts = []; return; } // 크기를 넘으면 더 모으지 않고 끝 표시만 찾습니다.
if (!this.tooBig && buf.length) this.parts.push(Buffer.from(buf));
}
}
/** 점 스터핑 해제(RFC 5321 §4.5.2): 줄 맨 앞의 ".." 를 "." 로 되돌립니다. 바이트를 바꾸지 않도록 latin1 로 다룹니다. */
export function dotUnstuff(buffer) {
return Buffer.from(Buffer.from(buffer).toString('latin1').replace(/(^|\r\n)\.\./g, '$1.'), 'latin1');
}
/** "<user@host>" 경로에서 주소만 꺼냅니다(소문자). 파라미터(SIZE=…)는 따로 돌려줍니다. */
function parsePath(text) {
const m = String(text).match(/^\s*<([^<>]*)>\s*(.*)$/) || String(text).match(/^\s*([^\s<>]+)\s*(.*)$/);
if (!m) return null;
const params = Object.fromEntries((m[2] || '').split(/\s+/).filter(Boolean).map((p) => { const [k, v = ''] = p.split('='); return [k.toUpperCase(), v]; }));
return { address: m[1].trim().replace(/^@[^:]+:/, '').toLowerCase(), params }; // 소스 라우트(@a,@b:user@host)는 무시
}
/**
* 접속 한 개의 상태 기계.
* 상태: greeted(EHLO 여부) → 인증(user) → 트랜잭션(mailFrom, rcpt) → DATA 수집(collector) → 처리 후 트랜잭션 초기화.
* 입력은 pump() 가 한 번에 한 명령씩 순서대로 처리합니다. PIPELINING 으로 여러 명령이 한꺼번에 와도
* 앞 명령(예: RCPT 확인)이 끝난 뒤 다음 명령(DATA)을 판단해야 하므로 비동기 처리를 직렬화합니다.
*/
class Session {
constructor(server, socket, { secure }) {
this.server = server; this.env = server.env; this.secure = secure;
this.buf = Buffer.alloc(0); // 아직 처리하지 않은 명령 바이트
this.collector = null; // DATA 수집 중이면 DataCollector
this.pendingData = null; // 수집이 끝나 처리를 기다리는 DATA 결과
this.pumping = false; this.closed = false;
this.greeted = false; this.user = null; this.failures = 0;
this.sasl = null; // 진행 중인 AUTH 대화 { mech, step, login }
this.reset();
this.ip = socket.remoteAddress || ''; this.attach(socket);
console.log(`SMTP 접속 ${this.ip} ${secure ? 'tls' : 'plain'}`);
this.send(`220 ${server.hostname} ESMTP ready`);
}
/** 메일 한 통의 트랜잭션 상태(MAIL FROM·RCPT TO)를 비웁니다. 인증 상태는 유지합니다. */
reset() { this.mailFrom = null; this.rcpt = []; }
attach(socket) {
this.socket = socket; socket.setTimeout(5 * 60 * 1000);
socket.on('data', (chunk) => this.onData(chunk));
socket.on('timeout', () => this.close('421 4.4.2 Idle timeout'));
socket.on('error', () => { this.closed = true; });
socket.on('close', () => { this.closed = true; });
}
send(line) { if (!this.closed) this.socket.write(line + CRLF); }
/** 여러 줄 응답: 마지막 줄만 "250 " 이고 나머지는 "250-" 입니다. */
sendMulti(code, lines) { lines.forEach((l, i) => this.send(`${code}${i === lines.length - 1 ? ' ' : '-'}${l}`)); }
close(line) { if (line) this.send(line); this.closed = true; try { this.socket.end(); } catch {} }
onData(chunk) {
if (this.closed) return;
if (this.collector) {
// DATA 수집 중: 명령으로 해석하지 않고 수집기에 넘깁니다.
const done = this.collector.feed(chunk);
// pendingData 는 { message } 로 감쌉니다. 크기 초과면 message 가 null 인데, 예전에는 null 자체를 넣어 "처리할 것 없음" 과 구분되지 않아
// 552 응답이 나가지 않고 세션이 멈췄습니다.
if (done) { this.collector = null; this.pendingData = { message: done.message }; this.buf = Buffer.from(done.rest); this.pump(); }
return;
}
this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : Buffer.from(chunk);
this.pump();
}
/** 쌓인 입력을 순서대로 처리합니다. 이미 처리 중이면 그 루프가 새 입력까지 이어서 처리합니다. */
async pump() {
if (this.pumping) return; this.pumping = true;
try {
while (!this.closed) {
if (this.pendingData) { const { message } = this.pendingData; this.pendingData = null; await this.finishData(message); continue; }
if (this.collector) {
// DATA 를 막 시작했고 같은 묶음에 본문이 이미 와 있던 경우
if (!this.buf.length) break;
const pending = this.buf; this.buf = Buffer.alloc(0);
const done = this.collector.feed(pending);
if (!done) break;
this.collector = null; this.pendingData = { message: done.message }; this.buf = Buffer.from(done.rest); continue;
}
const i = this.buf.indexOf('\r\n');
if (i < 0) { if (this.buf.length > MAX_LINE) this.close('500 5.5.6 Line too long'); break; }
const line = this.buf.subarray(0, i).toString('utf8'); this.buf = this.buf.subarray(i + 2);
try { await this.handle(line); } catch (e) { console.error('SMTP 오류', e?.stack || e?.message); this.send('451 4.3.0 Internal error'); }
}
} finally { this.pumping = false; }
}
async handle(line) {
if (this.sasl) return this.saslStep(line);
const sp = line.indexOf(' '); const verb = (sp < 0 ? line : line.slice(0, sp)).toUpperCase(); const arg = sp < 0 ? '' : line.slice(sp + 1).trim();
const loginAllowed = this.secure || this.server.allowPlaintext;
switch (verb) {
case 'EHLO': case 'HELO': {
if (!arg) return this.send('501 5.5.4 Domain required');
this.greeted = true; this.reset();
if (verb === 'HELO') return this.send(`250 ${this.server.hostname}`);
const ext = [this.server.hostname, `SIZE ${SMTP_MAX_SIZE}`, '8BITMIME', 'PIPELINING', 'ENHANCEDSTATUSCODES'];
if (!this.secure && this.server.secureContext) ext.push('STARTTLS');
if (loginAllowed && !this.user) ext.push('AUTH PLAIN LOGIN');
ext.push('HELP');
return this.sendMulti(250, ext);
}
case 'STARTTLS': {
if (this.secure) return this.send('503 5.5.1 Already using TLS');
if (!this.server.secureContext) return this.send('454 4.7.0 TLS not available');
this.send('220 2.0.0 Ready to start TLS');
// RFC 3207: STARTTLS 이전 상태(EHLO·인증)는 모두 잊습니다. 평문 구간에 끼워 넣은 명령이 암호화 뒤에 실행되지 않도록
// 남은 입력도 버립니다(명령 주입 방지).
const plain = this.socket; plain.removeAllListeners('data'); plain.removeAllListeners('timeout'); plain.removeAllListeners('error'); plain.removeAllListeners('close');
const secureSocket = new tls.TLSSocket(plain, { isServer: true, secureContext: this.server.secureContext });
this.secure = true; this.greeted = false; this.reset(); this.buf = Buffer.alloc(0); this.attach(secureSocket); return;
}
case 'AUTH': {
if (!this.greeted) return this.send('503 5.5.1 Send EHLO first');
if (this.user) return this.send('503 5.5.1 Already authenticated');
if (!loginAllowed) { console.log(`SMTP 평문 로그인 거부 ${this.ip} (STARTTLS 필요)`); return this.send('530 5.7.0 Must issue a STARTTLS command first'); }
const [mech, initial] = arg.split(/\s+/); const m = String(mech || '').toUpperCase();
if (m === 'PLAIN') { if (initial && initial !== '=') return this.authPlain(initial); this.sasl = { mech: 'PLAIN' }; return this.send('334 '); }
if (m === 'LOGIN') {
if (initial) { this.sasl = { mech: 'LOGIN', login: b64(initial) }; return this.send('334 UGFzc3dvcmQ6'); } // "Password:"
this.sasl = { mech: 'LOGIN' }; return this.send('334 VXNlcm5hbWU6'); // "Username:"
}
return this.send('504 5.5.4 Unrecognized authentication mechanism');
}
case 'MAIL': {
if (!this.greeted) return this.send('503 5.5.1 Send EHLO first');
if (!this.user) return this.send('530 5.7.0 Authentication required');
if (this.mailFrom !== null) return this.send('503 5.5.1 Nested MAIL command');
const path = /^FROM:/i.test(arg) ? parsePath(arg.slice(5)) : null; if (!path) return this.send('501 5.5.4 Syntax: MAIL FROM:<address>');
if (path.params.SIZE && Number(path.params.SIZE) > SMTP_MAX_SIZE) return this.send('552 5.3.4 Message size exceeds fixed limit');
if (path.address && !(await allowedSenders(this.env, this.user)).includes(path.address)) return this.send(`550 5.7.1 Sender address not allowed for this account (use ${this.user.mailbox})`);
this.mailFrom = path.address; return this.send('250 2.1.0 Sender OK');
}
case 'RCPT': {
if (this.mailFrom === null) return this.send('503 5.5.1 Need MAIL command first');
const path = /^TO:/i.test(arg) ? parsePath(arg.slice(3)) : null; if (!path || !isEmail(path.address)) return this.send('501 5.1.3 Bad recipient address syntax');
// 이미 봉투에 있는 주소(받는 사람과 참조에 같은 주소)는 세지 않고 그대로 받습니다. 예전에는 한도에서 중복 주소가 452 를 받아 메일 프로그램이 발송 전체를 포기했습니다(0.9.3).
if (this.rcpt.includes(path.address)) return this.send('250 2.1.5 Recipient OK');
if (this.rcpt.length >= (await recipientLimit(this.env))) return this.send('452 4.5.3 Too many recipients');
this.rcpt.push(path.address);
return this.send('250 2.1.5 Recipient OK');
}
case 'DATA': {
if (this.mailFrom === null) return this.send('503 5.5.1 Need MAIL command first');
if (!this.rcpt.length) return this.send('554 5.5.1 No valid recipients');
this.collector = new DataCollector(SMTP_MAX_SIZE);
return this.send('354 Start mail input; end with <CRLF>.<CRLF>');
}
case 'BDAT': return this.send('502 5.5.1 CHUNKING not supported');
case 'RSET': this.reset(); return this.send('250 2.0.0 OK');
case 'NOOP': return this.send('250 2.0.0 OK');
case 'VRFY': return this.send('252 2.5.0 Cannot VRFY user');
case 'HELP': return this.send('214 2.0.0 See https://' + this.server.hostname + '/');
case 'QUIT': return this.close(`221 2.0.0 ${this.server.hostname} closing connection`);
default: return this.send('500 5.5.2 Command not recognized');
}
}
/** AUTH 대화의 다음 줄(334 질문에 대한 base64 응답)을 처리합니다. "*" 는 취소입니다. */
async saslStep(line) {
const s = this.sasl; this.sasl = null;
if (line.trim() === '*') return this.send('501 5.7.0 Authentication cancelled');
if (s.mech === 'PLAIN') return this.authPlain(line.trim());
if (s.login === undefined) { this.sasl = { mech: 'LOGIN', login: b64(line.trim()) }; return this.send('334 UGFzc3dvcmQ6'); }
return this.login(s.login, b64(line.trim()));
}
/** AUTH PLAIN: base64("authzid\0authcid\0password"). authcid(로그인 이름)와 비밀번호만 씁니다. */
authPlain(payload) {
const parts = b64(payload).split('\0');
if (parts.length < 3) return this.send('501 5.5.2 Invalid AUTH PLAIN response');
return this.login(parts[1], parts.slice(2).join('\0'));
}
/**
* 웹 메일·IMAP·POP 과 같은 계정으로 인증합니다(verifyUser: www 관리자·별칭 admin·게이트웨이 계정).
* 실패하면 잠시 기다렸다 응답하고, 같은 접속에서 3번 틀리면 끊습니다. IP 당 15분에 20번까지만 시도할 수 있습니다.
*/
async login(login, password) {
if (rate.blocked('smtp:' + this.ip, { limit: 20 })) return this.close('421 4.7.0 Too many authentication attempts');
// 1.0.7 다중 도메인: 로그인 ID 의 @도메인(또는 TLS SNI 호스트)으로 어느 도메인의 계정인지 고릅니다. 이후 발송·저장(submitMessage)도 그 도메인의 env 로 합니다.
const env = this.server.resolveEnv ? this.server.resolveEnv(login, sniName(this.socket)) : this.env;
const user = env ? await verifyUser(env, login, password) : null;
if (user) this.env = env;
if (!user) rate.hit('smtp:' + this.ip, { limit: 20, windowSec: 900 }); // 실패만 셉니다. 메일마다 새로 접속하는 메일 프로그램이 성공 횟수로 막히지 않게.
console.log(`SMTP 인증 ${user ? '성공' : '실패'} ${this.ip} user=${String(login).slice(0, 80)}${this.secure ? '' : ' (plain)'}`);
if (!user) {
this.failures++; await new Promise((r) => setTimeout(r, this.env.SMTP_AUTH_DELAY_MS ?? this.env.POP_AUTH_DELAY_MS ?? 1500));
if (this.failures >= 3) return this.close('535 5.7.8 Authentication credentials invalid');
return this.send('535 5.7.8 Authentication credentials invalid');
}
this.user = user; return this.send('235 2.7.0 Authentication successful');
}
/** DATA 수집이 끝났을 때: 크기 확인 → 점 스터핑 해제 → 발송·저장 → 결과 응답. 성공·실패와 무관하게 트랜잭션은 비웁니다. */
async finishData(message) {
const envelope = { mailFrom: this.mailFrom || '', rcpt: this.rcpt.slice() }; this.reset();
if (message === null) return this.send('552 5.3.4 Message size exceeds fixed limit');
try {
const r = await submitMessage(this.env, this.user, { ...envelope, eml: dotUnstuff(message), ip: this.ip });
console.log(`SMTP 발송 ${this.ip} user=${this.user.login} rcpt=${envelope.rcpt.length} id=${r.providerId}`);
this.send(`250 2.0.0 OK queued as ${r.providerId}`);
} catch (e) {
const reply = providerReply(e);
console.log(`SMTP 발송 실패 ${this.ip} user=${this.user.login} ${reply.code} ${e.message}`);
this.send(reply.reply);
}
}
}
/** base64 → UTF-8 문자열(잘못된 입력은 빈 문자열). */
function b64(s) { try { return Buffer.from(String(s || ''), 'base64').toString('utf8'); } catch { return ''; } }
/**
* SMTP 제출 서버를 만듭니다. listen() 으로 포트를 열고, 인증서가 갱신되면 setTls() 로 바꿉니다.
* @param {object} env
* @param {object} [options]
* @param {string} [options.hostname] 인사말·EHLO 에 쓸 서버 이름
* @param {{key, cert}|null} [options.tlsOptions] 인증서 PEM(POP·IMAP 과 같은 파일). 없으면 465 를 열지 않고 587 의 STARTTLS 도 없습니다.
* @param {boolean} [options.allowPlaintext] true 면 TLS 없이도 AUTH 를 받습니다(진단용, 운영에서는 끔).
*/
/** TLS 접속의 SNI 서버 이름(평문 접속이면 ''). 1.0.7 다중 도메인에서 도메인 없는 로그인 ID 의 도메인을 짐작하는 데 씁니다. */
const sniName = (socket) => String(socket?.servername || '');
/** resolveEnv(login, servername)(1.0.7): 로그인마다 쓸 env 를 고르는 함수(domains.mjs resolveLogin). 없으면 env 하나로 동작. */
export function createSmtp(env, { hostname = 'mail.arihakdang.com', tlsOptions = null, allowPlaintext = false, resolveEnv = null } = {}) {
const shared = { env, hostname, secureContext: tlsOptions ? tls.createSecureContext(tlsOptions) : null, allowPlaintext, resolveEnv };
const plain = net.createServer((socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: false }); });
// secure 서버는 인증서가 없어도 만들어 두고, 인증서가 없으면 포트를 기억만 합니다(pop3.mjs 와 같은 구조, 0.9.7).
const secure = tls.createServer({ ...(tlsOptions || {}), minVersion: 'TLSv1.2' }, (socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: true }); });
secure.on('tlsClientError', () => {});
let pendingSecure = null;
const listenOn = (server, port, host, label, out) => new Promise((res, rej) => server.once('error', rej).listen(port, host, () => { out.push(`${label} ${host}:${port}`); res(); }));
return {
plain, secure,
setTls(options) {
shared.secureContext = tls.createSecureContext(options); secure.setSecureContext(options);
if (pendingSecure && !secure.listening) { const { port, host } = pendingSecure; pendingSecure = null; listenOn(secure, port, host, 'smtps', []).then(() => console.log(` SMTP TLS 포트를 열었습니다: ${host}:${port}`)).catch((e) => console.error(' SMTP TLS 포트 열기 실패:', e.message)); }
},
async listen({ plainPort, securePort, host = '0.0.0.0' }) {
const out = [];
if (plainPort) await listenOn(plain, plainPort, host, 'submission', out);
if (securePort) { if (shared.secureContext) await listenOn(secure, securePort, host, 'smtps', out); else pendingSecure = { port: securePort, host }; }
return out;
},
close() { plain.close(); if (secure.listening) secure.close(); },
};
}
+574
View File
@@ -0,0 +1,574 @@
/**
* 메일 저장소 모듈.
*
* 하는 일
* - 수신: Resend 웹훅·정기 동기화가 알려 준 메일을 가져와 원본(.eml)·첨부·정제 본문을 SQLite 에 저장합니다.
* - 보낸 메일: 다른 경로(www 관리자 등)로 발송된 메일도 보낸 메일함에 보이게 가져옵니다.
* - 동기화: Resend 목록을 훑어 웹훅이 놓친 메일을 보충합니다(전체 훑기 / 빠른 확인 두 가지 방식).
* - 정리: 만료된 로그인 세션을 지웁니다.
* - 초안·발송, IMAP APPEND/COPY 저장.
*
* 보관 정책(0.8.0~)
* 메일은 기한 없이 보관합니다. 사용자가 휴지통에서 "영구 삭제"할 때만 지워지며, 그때 남기는 tombstone 이
* 동기화가 같은 메일을 다시 가져오지 않게 막습니다. 예전의 30일 자동 삭제(RETENTION_DAYS)는 없앴습니다.
* messages.expires_at 열은 이전 릴리스로 되돌릴 때 스키마가 맞도록 남겨 두었지만 더는 쓰지 않습니다(항상 NULL).
*/
import { resend, download } from './providers/resend.mjs';
import { sendMail, inboundMode, outboundMode } from './providers/index.mjs';
import { sanitizeHtml, textToHtml } from './sanitize.mjs';
import { buildEml, normalizeCrlf } from './mime.mjs';
import { now, uid, clean, isEmail, addresses, address, formatSender, nameOf } from './util.mjs';
import { parseMime, summarize } from './imap-mime.mjs';
import { knownMailboxes } from './auth.mjs';
import { autoProcess } from './automation.mjs';
import { checkOutbound, checkInbound, attachmentLimit, mimeSize, mailboxQuota, mailboxUsage, systemLimits, totalUsage } from './limits.mjs';
import { checkLargeFileTerms, finalizeLargeFiles } from './files.mjs';
/** 기본 메일함 주소(환경 변수 MAILBOX, 소문자). 수신자가 비어 있는 메일과 계정 없는 발송의 기본값입니다. */
export const mailboxOf = (env) => String(env.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase();
/** 발신 표시 이름의 기본값. 계정 이름이 없을 때 "이름 <주소>" 의 이름 부분으로 씁니다. */
const senderName = (env) => String(env.MAIL_FROM_NAME || 'Ari Hak Dang');
// ATTACHMENT_LIMIT: 수신 첨부 한 개를 내려받는 최대 크기, RAW_LIMIT: 원본 .eml 최대 크기. 넘으면 바이트 없이 목록만 남깁니다.
const ATTACHMENT_LIMIT = 25 * 1024 * 1024, RAW_LIMIT = 40 * 1024 * 1024;
// 받은 메일·초안 HTML 의 정제 한도(글자 수). 뉴스레터는 인라인 CSS 때문에 수백 KB 가 흔해 화면 입력 한도(40만 자)보다 크게 둡니다.
const STORED_HTML_LIMIT = 5 * 1000 * 1000;
/**
* 저장용 본문 정제. HTML 이 한도를 넘어도 예외를 던지지 않고 텍스트 본문을 HTML 로 바꿔 대신 씁니다.
* 왜: 예전에는 40만 자가 넘는 HTML 메일에서 sanitizeHtml 이 예외를 던져 수신 저장 전체가 실패했습니다.
* 웹훅은 500 을 돌려 Resend 가 계속 재시도하고, 동기화도 매번 실패해 그 메일은 영영 메일함에 들어오지 못했습니다.
* @param {string|null} html 원본 HTML(없으면 텍스트를 씀)
* @param {string} text 텍스트 본문(HTML 이 없거나 너무 클 때 대신 표시)
*/
export function safeContent(html, text) {
// 텍스트만 있는 메일도 같은 한도(STORED_HTML_LIMIT)를 씁니다. 예전에는 기본 한도(40만 자)로 정제해 40만 자가 넘는 텍스트 메일(로그 첨부 본문 등)의 저장이 실패했습니다(0.9.0 수정).
if (!html) { try { return sanitizeHtml(textToHtml(text || ''), { limit: STORED_HTML_LIMIT }); } catch { return sanitizeHtml(textToHtml(String(text || '').slice(0, STORED_HTML_LIMIT / 2)), { limit: Infinity }); } }
try { return sanitizeHtml(html, { limit: STORED_HTML_LIMIT }); }
catch { return sanitizeHtml(textToHtml(String(text || '').slice(0, STORED_HTML_LIMIT / 2)), { limit: Infinity }); }
}
/**
* 시각 문자열을 ISO 8601 UTC(예: 2026-09-28T06:20:26.514Z)로 맞춥니다.
*
* 왜 필요한가: Resend 의 보낸 메일 목록은 created_at 을 "2026-09-28 06:20:26.514000+00" 처럼 공백과 짧은
* 시간대 표기로 돌려줍니다. 이 값을 그대로 received_at 에 넣으면, 다른 메일(ISO 형식)과 같은 날짜일 때
* 문자열 정렬(ORDER BY received_at)에서 공백(0x20)이 'T'(0x54)보다 앞서 시각과 무관하게 순서가 뒤섞였습니다.
* 저장 전에 한 형식으로 맞추면 목록·POP·IMAP 의 날짜순 정렬이 실제 시각 순서와 같아집니다.
*
* @param {string|undefined|null} value Resend 등에서 받은 시각 문자열
* @param {string} [fallback] 해석할 수 없을 때 쓸 값(기본: 지금)
* @returns {string} ISO 8601 UTC 문자열
*/
export function isoTime(value, fallback = now()) {
if (!value) return fallback;
// "+00" 처럼 분이 빠진 시간대는 Date.parse 가 환경에 따라 거부할 수 있어 "+00:00" 으로 보완합니다.
// 시각 부분(HH:MM[:SS[.fff]]) 바로 뒤에 붙은 경우만 고쳐, "2026-09-28" 같은 날짜만 있는 값의 "-28" 을 건드리지 않습니다.
const text = String(value).trim().replace(/(\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)([+-]\d{2})$/, '$1$2:00');
const ms = Date.parse(text);
return Number.isFinite(ms) ? new Date(ms).toISOString() : fallback;
}
function recipientsOf(full) { return addresses([...(full.to || []), ...(full.cc || []), ...(full.bcc || []), ...(full.received_for || [])]); }
/** 원본(.eml)을 message_raw 에 넣고 messages.raw_status/raw_size 를 갱신합니다. automation.mjs(자동 응답의 보낸 메일 원본)도 씁니다. */
export async function storeRaw(env, messageId, eml, status) {
await env.DB.batch([
env.DB.prepare('INSERT OR REPLACE INTO message_raw(message_id,eml,stored_at) VALUES(?,?,?)').bind(messageId, eml, now()),
env.DB.prepare('UPDATE messages SET raw_status=?,raw_size=?,updated_at=? WHERE id=?').bind(status, eml.length, now(), messageId),
]);
}
async function attachmentRows(env, messageId) {
return (await env.DB.prepare('SELECT id,filename,content_type,content_id,disposition,size,bytes FROM attachments WHERE message_id=? ORDER BY rowid').bind(messageId).all()).results;
}
export async function buildStoredEml(env, message) {
const rows = await attachmentRows(env, message.id);
return buildEml({
from: message.sender, to: JSON.parse(message.recipients_json || '[]'), cc: JSON.parse(message.cc_json || '[]'), bcc: JSON.parse(message.bcc_json || '[]'), replyTo: JSON.parse(message.reply_to_json || '[]'),
subject: message.subject, date: message.received_at, messageId: message.message_id || undefined, headers: JSON.parse(message.headers_json || '{}'),
text: message.text, html: message.original_html || message.html || '',
attachments: rows.filter((a) => a.bytes).map((a) => ({ filename: a.filename, contentType: a.content_type, contentId: a.content_id, disposition: a.disposition, bytes: Buffer.from(a.bytes) })),
});
}
/**
* 수신 메일 한 통을 Resend 에서 가져와 본문·첨부·원본까지 저장합니다.
*
* 건너뛰는 경우(반환값 skipped): 'tombstone' = 사용자가 영구 삭제한 메일, 'exists' = 원본까지 이미 저장한 메일,
* 'other_mailbox' = 수신자 중 이 게이트웨이가 다루는 메일함이 없는 메일. 동기화의 빠른 확인 방식은
* 'exists'/'tombstone' 을 "여기부터는 이미 확인함" 표시로 씁니다.
*
* @param {object} env
* @param {string} providerId Resend 수신 메일 id
* @param {object} [options]
* @param {'sync'|'webhook'} [options.source] 어떤 경로로 알게 됐는지(설정 화면 통계용)
* @param {object|null} [options.summary] 목록·웹훅이 준 요약(상세 응답에 값이 없을 때 대신 씁니다)
* @param {string[]|null} [options.known] 알려진 메일함 주소 목록(반복 호출 시 DB 조회를 아끼려고 넘깁니다)
*/
export async function ingestReceived(env, providerId, { source = 'sync', summary = null, known = null } = {}) {
known = known || await knownMailboxes(env);
if (await env.DB.prepare('SELECT provider_id FROM tombstones WHERE provider_id=?').bind(providerId).first()) return { skipped: 'tombstone' };
const existing = await env.DB.prepare('SELECT id,raw_status FROM messages WHERE provider_id=?').bind(providerId).first();
if (existing && existing.raw_status !== 'none') return { skipped: 'exists', id: existing.id };
const full = await resend(env, '/emails/receiving/' + encodeURIComponent(providerId));
const recipients = recipientsOf(full);
// 메일함 라우팅: 수신자 중 알려진 메일함(기본 메일함 + 계정 메일함) 첫 번째가 주 저장본. 수신자가 없으면 기본 메일함.
// 수신자에 우리 메일함이 둘 이상이면(예: info@ 와 admin@ 에 함께 보낸 메일) 나머지 메일함에도 사본을 둡니다(0.8.24, 예전에는 첫 메일함에만 저장).
const locals = [...new Set(recipients.filter((r) => known.includes(r)))];
const mailbox = locals[0] || (recipients.length ? null : mailboxOf(env));
if (!mailbox) return { skipped: 'other_mailbox' };
// 메일함·전체 용량 제한(0.9.0): 가득 찬 메일함에는 저장하지 않고 'quota_exceeded' / 'storage_full' 로 건너뜁니다(웹훅 기록·동기화 상세에 남음).
// 메일은 발송 서비스 쪽에 남아 있으므로 사용자가 메일을 지워 공간이 생기면 다음 동기화가 다시 가져옵니다. 원본만 없던 메일(existing)은 다시 채웁니다.
if (!existing) { const blocked = await checkInbound(env, mailbox); if (blocked) return { skipped: blocked, mailbox }; }
const content = safeContent(full.html, full.text);
// 수신 시각은 ISO 형식으로 맞춰 저장합니다(isoTime 설명 참고). 보관 만료일(expires_at)은 두지 않습니다.
const receivedAt = isoTime(full.created_at || summary?.created_at), stamp = now();
const id = existing?.id || uid();
const values = [full.from || summary?.from || '', JSON.stringify(full.to || summary?.to || []), JSON.stringify(full.cc || []), JSON.stringify(full.bcc || []), JSON.stringify(full.reply_to || []), clean(full.subject || summary?.subject, 500), content.html, full.text || content.text, full.html || null, JSON.stringify(full.headers || {}), full.message_id || null, receivedAt, source, stamp, mailbox];
// 원본을 받지 못했던 메일(raw_status='none')은 같은 id 로 내용을 다시 채우고, 처음이면 새로 넣습니다.
if (existing) await env.DB.prepare('UPDATE messages SET sender=?,recipients_json=?,cc_json=?,bcc_json=?,reply_to_json=?,subject=?,html=?,text=?,original_html=?,headers_json=?,message_id=?,received_at=?,source=?,updated_at=?,mailbox=?,expires_at=NULL WHERE id=?').bind(...values, id).run();
else {
// 웹훅과 정기 동기화가 같은 메일을 거의 동시에 만나면 둘 다 위의 "이미 있나" 확인을 통과한 뒤 INSERT 에서 provider_id UNIQUE 에 걸립니다(운영 heart 10/12·10/13 의
// 동기화 '일부 실패' 기록). 먼저 넣은 쪽이 원본·첨부까지 처리하므로 늦은 쪽은 오류가 아니라 '이미 있음' 으로 조용히 끝냅니다(1.0.11).
try { await env.DB.prepare(`INSERT INTO messages(sender,recipients_json,cc_json,bcc_json,reply_to_json,subject,html,text,original_html,headers_json,message_id,received_at,source,updated_at,mailbox,id,provider_id,direction,folder,state,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'inbound','inbox','received',?)`).bind(...values, id, providerId, stamp).run(); }
catch (e) { if (/UNIQUE constraint failed: messages\.provider_id/.test(String(e.message))) { const race = await env.DB.prepare('SELECT id FROM messages WHERE provider_id=?').bind(providerId).first(); return { skipped: 'exists', id: race?.id }; } throw e; }
}
// 첨부: 목록에서 다운로드 주소를 받아 바이트까지 보관합니다.
const declared = full.attachments || summary?.attachments || [];
if (declared.length) {
let list = [];
try { list = (await resend(env, '/emails/receiving/' + encodeURIComponent(providerId) + '/attachments?limit=100')).data || []; } catch (e) { console.error('첨부 목록 실패', providerId, e.message); }
await env.DB.prepare('DELETE FROM attachments WHERE message_id=?').bind(id).run();
// disposition(0.9.9): Content-ID 가 있는 inline 만 'inline'(본문에 끼운 그림)으로 저장하고, 나머지는 모두 'attachment' 입니다. IMAP 으로 올린 메일(imap-mime.mjs)과 같은 규칙입니다.
// 예전에는 Resend 가 준 "inline" 을 그대로 저장해, Apple Mail 이 "inline; filename=x.pdf" 로 보낸 PDF 가 읽기 창에는 보이면서 전달·첨부 수(📎)·"첨부" 필터에서는 빠졌습니다.
for (const item of list.length ? list : declared) {
let bytes = null;
if (item.download_url && Number(item.size || 0) <= ATTACHMENT_LIMIT) { try { bytes = await download(env, item.download_url, { maxBytes: ATTACHMENT_LIMIT }); } catch (e) { console.error('첨부 다운로드 실패', providerId, item.id, e.message); } }
await env.DB.prepare('INSERT INTO attachments(id,message_id,provider_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)')
.bind(uid(), id, item.id || null, clean(item.filename, 255) || 'attachment', clean(item.content_type, 120) || 'application/octet-stream', item.content_id ? String(item.content_id).replace(/^<|>$/g, '') : null, /^inline/i.test(String(item.content_disposition || item.disposition || '')) && item.content_id ? 'inline' : 'attachment', Number(item.size || bytes?.length || 0), bytes, stamp).run();
}
}
// 원본: Resend 가 서명한 다운로드 주소를 주면 그대로 저장하고, 없으면 저장한 부품으로 재구성합니다.
let eml = null, status = 'built';
if (full.raw?.download_url) { try { eml = await download(env, full.raw.download_url, { maxBytes: RAW_LIMIT }); status = 'stored'; } catch (e) { console.error('원본 다운로드 실패', providerId, e.message); } }
if (!eml) eml = await buildStoredEml(env, await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first());
await storeRaw(env, id, eml, status);
// 저장이 끝난 뒤 메일함 설정에 따라 전달·자동 응답을 합니다(0.8.16~). 실패해도 수신 저장은 성공으로 끝냅니다
// (실패는 auto_actions 에 남고 메일 상세에 보입니다). 처리 전에 저장을 마치므로 전달에 첨부·원본 HTML 을 그대로 쓸 수 있습니다.
let auto = null;
try { auto = await autoProcess(env, id); } catch (e) { console.error('자동 처리 실패', id, e.message); }
// 다른 우리 메일함에도 받은 메일: 주 저장본을 복제(원본·첨부 포함)해 그 메일함의 받은 메일함에 둡니다. provider_id 는 UNIQUE 라
// "<provider id>#<메일함>" 으로 구분해 두어, 동기화가 다시 만나도(주 저장본이 exists) 중복되지 않고 영구 삭제 시 tombstone 도 따로 남습니다.
const copies = [];
for (const other of locals.slice(1)) {
const copyPid = `${providerId}#${other}`;
if (await env.DB.prepare('SELECT 1 AS x FROM messages WHERE provider_id=? UNION SELECT 1 FROM tombstones WHERE provider_id=?').bind(copyPid, copyPid).first()) continue;
if (await checkInbound(env, other)) continue; // 사본을 받을 메일함이 가득 찼으면 건너뜁니다(다음 동기화가 다시 시도).
const copyId = await copyMessage(env, id, 'inbox');
await env.DB.prepare('UPDATE messages SET mailbox=?, provider_id=? WHERE id=?').bind(other, copyPid, copyId).run();
try { await autoProcess(env, copyId); } catch (e) { console.error('자동 처리 실패', copyId, e.message); }
copies.push({ id: copyId, mailbox: other });
}
return { id, imported: true, raw: status, ...(auto ? { auto } : {}), ...(copies.length ? { copies } : {}) };
}
/**
* 내장 Haraka(로컬 SMTP 수신)가 넘긴 원본(.eml)을 받은 메일함에 저장합니다(1.0.0, 수신 방식 haraka).
*
* Resend 경로(ingestReceived)와 같은 규칙을 지킵니다: provider_id 로 중복·영구 삭제(tombstone) 확인, 메일함 라우팅(수신자 중 우리 메일함 첫 번째가
* 주 저장본, 나머지 우리 메일함에는 사본), 메일함·전체 용량 검사, 저장 뒤 자동 처리(전달·자동 응답). 원본은 받은 바이트 그대로 보관합니다(raw_status 'stored').
* provider_id 는 'haraka:<Haraka 트랜잭션 uuid>' 입니다. source 열의 CHECK 제약(webhook|sync|web)을 바꾸려면 messages 표를 다시 만들어야 하므로
* "밖에서 밀어 넣어 준 메일" 이라는 뜻이 같은 'webhook' 을 쓰고, 통계는 provider_id 접두어로 Haraka 수신을 구분합니다.
*
* @param {Buffer} eml 원본 바이트
* @param {object} o
* @param {string} o.id Haraka 트랜잭션 id(중복 전달 방지 키)
* @param {string[]} [o.rcpt] 봉투 받는 사람(RCPT TO). 헤더 To/Cc 보다 우선해 메일함을 정합니다(Bcc 로 받은 메일도 올바른 메일함에).
* @param {string} [o.from] 봉투 보낸 사람(기록용)
* @param {string[]} [o.known] 알려진 메일함 목록(반복 호출 시 재사용)
* @returns {Promise<{id?:string, imported?:boolean, skipped?:string, mailbox?:string, copies?:object[]}>}
*/
export async function ingestRaw(env, eml, { id: transactionId, rcpt = [], from = '', known = null } = {}) {
known = known || await knownMailboxes(env);
const providerId = 'haraka:' + String(transactionId || uid()).slice(0, 100);
if (await env.DB.prepare('SELECT provider_id FROM tombstones WHERE provider_id=?').bind(providerId).first()) return { skipped: 'tombstone' };
const existing = await env.DB.prepare('SELECT id FROM messages WHERE provider_id=?').bind(providerId).first();
if (existing) return { skipped: 'exists', id: existing.id };
eml = normalizeCrlf(eml);
const info = summarize(parseMime(eml));
// 봉투 수신자(RCPT TO) → 헤더 수신자 순서로 우리 메일함을 찾습니다. 봉투가 비어 있는 경우(시험·수동 호출)만 헤더를 봅니다.
const envelope = addresses(rcpt), header = addresses([...(info.to || []), ...(info.cc || []), ...(info.bcc || [])]);
const locals = [...new Set([...envelope, ...header].filter((r) => known.includes(r)))];
const mailbox = locals[0];
if (!mailbox) return { skipped: 'other_mailbox' };
const blocked = await checkInbound(env, mailbox); if (blocked) return { skipped: blocked, mailbox };
// appendMessage 가 해석·정제·첨부·원본 저장을 모두 합니다. 수신 시각은 도착 시각(now)입니다 — Date 헤더는 보낸 쪽이 적는 값이라 정렬이 흔들립니다.
const { id } = await appendMessage(env, 'inbox', eml, { mailbox, enforceQuota: false, internalDate: now() });
await env.DB.prepare("UPDATE messages SET provider_id=?, source='webhook', updated_at=? WHERE id=?").bind(providerId, now(), id).run();
let auto = null;
try { auto = await autoProcess(env, id); } catch (e) { console.error('자동 처리 실패', id, e.message); }
const copies = [];
for (const other of locals.slice(1)) {
const copyPid = `${providerId}#${other}`;
if (await env.DB.prepare('SELECT 1 AS x FROM messages WHERE provider_id=? UNION SELECT 1 FROM tombstones WHERE provider_id=?').bind(copyPid, copyPid).first()) continue;
if (await checkInbound(env, other)) continue;
const copyId = await copyMessage(env, id, 'inbox');
await env.DB.prepare('UPDATE messages SET mailbox=?, provider_id=? WHERE id=?').bind(other, copyPid, copyId).run();
try { await autoProcess(env, copyId); } catch (e) { console.error('자동 처리 실패', copyId, e.message); }
copies.push({ id: copyId, mailbox: other });
}
console.log(`Haraka 수신 저장 ${mailbox} from=${from || info.from || '-'} id=${id}${copies.length ? ' 사본 ' + copies.length : ''}`);
return { id, imported: true, mailbox, ...(auto ? { auto } : {}), ...(copies.length ? { copies } : {}) };
}
/**
* 다른 경로(www 관리자 등)로 발송된 메일도 보낸 메일함에 보이게 가져옵니다.
* 발신 주소가 알려진 메일함이 아니면 'other_sender' 로 건너뜁니다. 원본은 저장한 부품으로 재구성합니다.
*/
export async function ingestSent(env, providerId, { summary = null, known = null } = {}) {
known = known || await knownMailboxes(env);
if (await env.DB.prepare('SELECT provider_id FROM tombstones WHERE provider_id=?').bind(providerId).first()) return { skipped: 'tombstone' };
if (await env.DB.prepare('SELECT id FROM messages WHERE provider_id=?').bind(providerId).first()) return { skipped: 'exists' };
// 자동 전달(0.8.16)로 나간 메일은 보낸 메일함에 쌓지 않습니다. 예전에는 동기화가 Resend 보낸 목록에서 전달 사본을 다시 가져와
// 받은 메일 한 통마다 보낸 메일함에 "Sender (via admin@…)" 메일이 생겼습니다(0.8.24). auto_actions 의 전달 기록(detail 이 발송 id 로 시작)으로 알아봅니다.
if (await env.DB.prepare("SELECT 1 AS x FROM auto_actions WHERE kind='forward' AND status='sent' AND (detail = ? OR detail LIKE ? || ' %')").bind(providerId, providerId).first()) return { skipped: 'auto_forward' };
const full = await resend(env, '/emails/' + encodeURIComponent(providerId));
const mailbox = address(full.from);
if (!known.includes(mailbox)) return { skipped: 'other_sender' };
const content = safeContent(full.html, full.text);
// Resend 보낸 메일 목록의 created_at 은 "YYYY-MM-DD HH:MM:SS.ffffff+00" 형식이라 ISO 로 바꿔 저장합니다.
const sentAt = isoTime(full.created_at || summary?.created_at), stamp = now(), id = uid();
await env.DB.prepare(`INSERT INTO messages(id,provider_id,direction,folder,sender,recipients_json,cc_json,bcc_json,reply_to_json,subject,html,text,original_html,headers_json,source,state,received_at,created_at,updated_at,sent_at,read_at,mailbox) VALUES(?,?,'outbound','sent',?,?,?,?,?,?,?,?,?,?,'sync','sent',?,?,?,?,?,?)`)
.bind(id, providerId, full.from, JSON.stringify(full.to || []), JSON.stringify(full.cc || []), JSON.stringify(full.bcc || []), JSON.stringify(full.reply_to || []), clean(full.subject, 500), content.html, full.text || content.text, full.html || null, JSON.stringify(full.headers || {}), sentAt, stamp, stamp, sentAt, sentAt, mailbox).run();
await storeRaw(env, id, await buildStoredEml(env, await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first()), 'built');
return { id, imported: true };
}
// 동기화는 도메인(env)마다 한 번에 하나만 돕니다. 웹 화면 여러 개가 동시에 요청해도 Resend 호출이 겹치지 않게 합니다.
// 1.0.7: 예전의 모듈 전역 플래그는 도메인이 여럿이면 한 도메인의 동기화가 다른 도메인을 막으므로 env 단위(SYNCING)로 바꿨습니다.
/** 지금 이 도메인의 동기화가 진행 중인지(화면의 "동기화 중" 표시용). env 가 없으면 false. */
export const isSyncing = (env) => !!env?.SYNCING;
// 한 번의 동기화가 훑는 최대 페이지 수(페이지당 50건). 받은 메일 500건, 보낸 메일 200건까지 거슬러 올라갑니다.
// 예전에는 "보관 기간(30일)보다 오래된 메일"에서 멈췄지만 보관 기한이 없어졌으므로 이 페이지 한도가 유일한 상한입니다.
const SYNC_PAGES = { inbound: 10, outbound: 4 };
/**
* Resend 목록(최신순)을 훑어 웹훅이 놓친 메일을 보충합니다.
*
* 두 가지 방식이 있습니다.
* - 전체 훑기(quick=false): 시작 직후·1시간 타이머·설정 화면의 "지금 동기화". 페이지 한도(SYNC_PAGES)까지 모두
* 확인해 중간에 빠진 메일(예: 일시 오류로 실패한 웹훅)까지 채웁니다.
* - 빠른 확인(quick=true): 웹 메일함 목록이 1분마다 자동으로 부르는 경우. 목록은 최신순이므로, 이미 저장했거나
* 영구 삭제(tombstone)한 메일을 처음 만나면 그보다 오래된 메일은 이전 동기화가 확인했다고 보고 멈춥니다.
* 예전에는 30일 기준 시각에서 멈췄기 때문에 매분 호출해도 가벼웠는데, 기준 시각이 사라진 뒤에도 매분 수백 건을
* 다시 훑지 않도록 이 방식을 둡니다. 빠진 메일이 있어도 다음 전체 훑기(최대 1시간 뒤)가 채웁니다.
*
* @param {object} env 실행 환경(DB, RESEND_API_KEY 등)
* @param {object} [options]
* @param {string} [options.trigger] 기록용 실행 계기: 'startup' | 'timer' | 'manual' | 'poll'
* @param {boolean} [options.quick] true 면 빠른 확인 방식
* @returns {Promise<{imported:number, errors:number}|{busy:true}>}
*/
export async function syncMailbox(env, { trigger = 'timer', quick = false } = {}) {
if (env.SYNCING) return { busy: true };
env.SYNCING = true; let imported = 0; const errors = []; let run = null;
try {
// 실행 기록과 메일함 목록 조회도 try 안에 둡니다. 예전에는 밖에 있어서 여기서 예외(예: SQLITE_BUSY)가 나면
// finally 가 돌지 않아 syncing 이 true 로 남고, 프로세스를 재시작할 때까지 모든 동기화가 busy 로 끝났습니다.
run = await env.DB.prepare('INSERT INTO sync_runs(trigger,started_at) VALUES(?,?) RETURNING id').bind(trigger, now()).first();
// known: 이 게이트웨이가 다루는 메일함 주소 목록(기본 메일함 + 계정 메일함). 다른 주소의 메일은 건너뜁니다.
const known = await knownMailboxes(env);
// 1.0.0: Resend 를 쓰는 방향만 훑습니다. 수신이 Haraka 면 받은 메일 목록은 보지 않고(Resend 에 없음), 발신이 SMTP 중계면 보낸 메일 목록도 보지 않습니다.
const directions = ['inbound', 'outbound'].filter((d) => (d === 'inbound' ? inboundMode(env) === 'resend' : outboundMode(env) === 'resend'));
for (const direction of directions) {
// stop: 빠른 확인에서 이미 아는 메일을 만나 이 방향의 훑기를 끝낼 때 true.
const endpoint = direction === 'inbound' ? '/emails/receiving' : '/emails'; let cursor = ''; let stop = false;
for (let page = 0; page < SYNC_PAGES[direction] && !stop; page++) {
const list = await resend(env, endpoint + '?limit=50' + (cursor ? '&after=' + encodeURIComponent(cursor) : ''));
for (const summary of list.data || []) {
try {
let r;
// 미리 거르기는 웹훅과 같은 주소 집합(to·cc·bcc·received_for)으로 합니다. 예전에는 to 만 봐서 우리 메일함이 참조(Cc)에만 있는 메일을 동기화가 영영 가져오지 않았습니다(0.9.0 수정).
if (direction === 'inbound') { if (!addresses([...(summary.to || []), ...(summary.cc || []), ...(summary.bcc || []), ...(summary.received_for || [])]).some((a) => known.includes(a))) continue; r = await ingestReceived(env, summary.id, { source: 'sync', summary, known }); }
else { if (!known.includes(address(summary.from))) continue; r = await ingestSent(env, summary.id, { summary, known }); }
if (r.imported) imported++;
// 이미 저장했거나 영구 삭제한 메일 = 이전 동기화(또는 웹훅)가 여기까지 확인했다는 표시입니다.
if (quick && (r.skipped === 'exists' || r.skipped === 'tombstone')) { stop = true; break; }
} catch (e) { errors.push(summary.id + ':' + e.message); if (['mail_auth_failed', 'mail_forbidden', 'mail_not_configured'].includes(e.message)) throw e; }
}
if (stop || !list.has_more || !list.data?.length) break;
cursor = list.data.at(-1).id;
}
}
await env.DB.prepare("UPDATE sync_runs SET finished_at=?,imported=?,status=?,detail=? WHERE id=?").bind(now(), imported, errors.length ? 'partial' : 'ok', errors.slice(0, 20).join('\n') || null, run.id).run();
return { imported, errors: errors.length };
} catch (e) {
if (run) await env.DB.prepare("UPDATE sync_runs SET finished_at=?,imported=?,status='failed',detail=? WHERE id=?").bind(now(), imported, (e.message + (e.providerMessage ? ' ' + e.providerMessage : '')).slice(0, 500), run.id).run();
throw e;
} finally { env.SYNCING = false; }
}
/**
* API 요청에서 동기화를 시작하고 끝나기를 기다리지 않고 바로 돌아옵니다(화면은 몇 초 뒤 목록을 다시 읽습니다).
* @param {object} env
* @param {string} [trigger] 기록용 실행 계기('manual' = 설정 화면 버튼, 'poll' = 목록 자동 새로 고침)
* @param {{quick?: boolean}} [options] quick: 빠른 확인 방식(syncMailbox 설명 참고)
* @returns {{started:true}|{busy:true}}
*/
export function startSync(env, trigger = 'manual', { quick = false } = {}) {
if (env.SYNCING) return { busy: true };
syncMailbox(env, { trigger, quick }).catch((e) => console.error('메일 동기화 실패', e.message, e.providerMessage || ''));
return { started: true };
}
/**
* 주기 정리 작업: 만료된 로그인 세션을 지웁니다.
*
* 0.8.0 이전에는 같은 자리에서 "보관 기간(30일)이 지난 메일"도 원본·첨부와 함께 삭제했습니다(enforceRetention).
* 메일 보관 기한을 없애면서 메일 삭제는 빼고 세션 정리만 남겼습니다. 메일은 사용자가 영구 삭제할 때만 지워집니다.
*
* @returns {Promise<{sessions:number}>} 지운 세션 수
*/
export async function cleanupSessions(env) {
const result = await env.DB.prepare('DELETE FROM sessions WHERE expires_at < ?').bind(now()).run();
return { sessions: result.meta.changes };
}
// ------------------------------------------------------------------ 초안·발송
export async function saveDraft(env, body, id, user = null) {
const mailbox = user?.mailbox || mailboxOf(env), fromName = user?.name || senderName(env);
// 보내는 사람 표시 이름은 formatSender 로 만듭니다(0.9.0): 쉼표·따옴표·괄호가 든 이름("Lee, Director")을 그대로 넣으면 RFC 5322 에 어긋나 발송 서비스가 거부하거나 주소가 깨졌습니다.
const existing = id ? await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first() : null;
if (id && !existing) throw new Error('not_found');
if (existing && (existing.folder !== 'drafts' || existing.state !== 'draft')) throw new Error('mail_locked');
// 초안은 아직 완성되지 않은 주소("bob", "bob@example" 등)도 그대로 저장합니다. 주소 검사는 발송(sendDraft) 때 합니다.
// 왜: 예전에는 여기서 422 를 던져 자동 저장이 조용히 실패했고, 창을 닫으면 작성 중인 본문이 통째로 사라졌습니다.
// 부분 수정(0.9.5): 본문에 없는 키는 기존 초안 값을 유지합니다. 예전에는 PATCH { subject } 처럼 일부만 보내도 받는 사람·참조·본문이 빈 값으로 덮였습니다(화면은 늘 전체를 보내 드러나지 않았음).
// bcc(0.9.5~): 웹 작성 창의 숨은 참조. 예전에는 IMAP 으로 올린 초안의 Bcc 만 저장·발송됐고 웹에서는 입력할 수 없었습니다.
const keep = (key, col) => (body[key] !== undefined ? addresses(body[key]).slice(0, 100) : existing ? JSON.parse(existing[col] || '[]') : []);
const to = keep('to', 'recipients_json'), cc = keep('cc', 'cc_json'), bcc = keep('bcc', 'bcc_json');
const subject = body.subject !== undefined ? clean(body.subject, 500) : existing ? existing.subject : '', stamp = now();
const content = body.html != null ? sanitizeHtml(body.html, { limit: STORED_HTML_LIMIT }) : body.text != null || !existing ? sanitizeHtml(textToHtml(body.text || '')) : { html: existing.html, text: existing.text };
// 답장이면 In-Reply-To / References 를 남겨 스레드와 \Answered 표시를 유지합니다.
const inReplyTo = clean(body.inReplyTo, 300); const headers = inReplyTo && /^<[^\s<>]+>$/.test(inReplyTo) ? { 'In-Reply-To': inReplyTo, References: inReplyTo } : null;
if (existing) {
const updated = await env.DB.prepare("UPDATE messages SET recipients_json=?,cc_json=?,bcc_json=?,subject=?,html=?,text=?,received_at=?,updated_at=?,headers_json=COALESCE(?,headers_json) WHERE id=? AND state='draft' AND folder='drafts'").bind(JSON.stringify(to), JSON.stringify(cc), JSON.stringify(bcc), subject, content.html, content.text, stamp, stamp, headers ? JSON.stringify(headers) : null, id).run();
if (!updated.meta.changes) throw new Error('mail_locked');
await invalidateRaw(env, id);
} else {
id = uid();
await env.DB.prepare(`INSERT INTO messages(id,direction,folder,sender,recipients_json,cc_json,bcc_json,subject,html,text,source,state,received_at,created_at,updated_at,read_at,mailbox,headers_json) VALUES(?,'outbound','drafts',?,?,?,?,?,?,?,'web','draft',?,?,?,?,?,?)`)
.bind(id, formatSender(fromName, mailbox), JSON.stringify(to), JSON.stringify(cc), JSON.stringify(bcc), subject, content.html, content.text, stamp, stamp, stamp, stamp, mailbox, JSON.stringify(headers || {})).run();
}
return id;
}
/**
* 초안의 저장된 원본(.eml)을 버립니다. IMAP APPEND 로 올린 초안은 원본을 그대로 보관하는데, 웹에서 제목·본문·첨부를 고치면
* 그 원본이 옛 내용으로 남아 IMAP/POP/"원본 내려받기" 가 수정 전 내용을 보여 줬습니다(0.8.16 수정). raw_status 를 'none' 으로 돌리면
* IMAP 은 저장된 행과 첨부로 메시지를 다시 만들고, 발송 때는 sendDraft 가 새 원본을 저장합니다.
*/
async function invalidateRaw(env, id) {
await env.DB.batch([env.DB.prepare('DELETE FROM message_raw WHERE message_id=?').bind(id), env.DB.prepare("UPDATE messages SET raw_status='none',raw_size=0 WHERE id=? AND raw_status<>'none'").bind(id)]);
}
// 초안 첨부의 합계·개수 상한(발송 서비스의 메일 전체 40MB 한도에서 나온 내장값). 첨부 한 개의 상한은 0.9.0 부터 관리자·시스템 설정(limits.attachmentLimit, MIME 기준)을 따릅니다.
const DRAFT_TOTAL_LIMIT = 20 * 1024 * 1024, DRAFT_FILE_COUNT = 10;
/** 첨부 한 개의 크기 검사: 파일 바이트를 MIME(base64) 크기로 바꿔 적용 중인 상한과 비교합니다. 넘으면 attachment_too_large 에 limit(MIME 바이트)을 붙여 던집니다. */
async function checkAttachmentSize(env, bytes) {
const limit = await attachmentLimit(env);
if (mimeSize(bytes) > limit) { const e = new Error('attachment_too_large'); e.limit = limit; throw e; }
}
export async function addAttachment(env, draftId, { filename, contentType, bytes, inline = false }) {
const draft = await env.DB.prepare("SELECT id FROM messages WHERE id=? AND state='draft' AND folder='drafts'").bind(draftId).first();
if (!draft) throw new Error('mail_locked');
if (!bytes?.length) throw new Error('attachment_too_large');
await checkAttachmentSize(env, bytes.length);
const current = await env.DB.prepare('SELECT COUNT(*) n, COALESCE(SUM(size),0) total FROM attachments WHERE message_id=?').bind(draftId).first();
if (current.n >= DRAFT_FILE_COUNT || current.total + bytes.length > DRAFT_TOTAL_LIMIT) throw new Error('attachment_too_large');
const id = uid(), isInline = inline && /^image\//.test(String(contentType || ''));
await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,?,?,?,?)")
.bind(id, draftId, clean(filename, 255).replace(/[\r\n"\\/]/g, '_') || 'attachment', clean(contentType, 120) || 'application/octet-stream', isInline ? `${id}@${mailboxOf(env).split('@')[1]}` : null, isInline ? 'inline' : 'attachment', bytes.length, bytes, now()).run();
await invalidateRaw(env, draftId);
return id;
}
export async function removeAttachment(env, draftId, attachmentId) {
const draft = await env.DB.prepare("SELECT id FROM messages WHERE id=? AND state='draft' AND folder='drafts'").bind(draftId).first();
if (!draft) throw new Error('mail_locked');
await env.DB.prepare('DELETE FROM attachments WHERE id=? AND message_id=?').bind(attachmentId, draftId).run();
await invalidateRaw(env, draftId);
}
/**
* 초안을 발송합니다.
* @param {object} [options.largeFiles] 대용량 첨부파일(0.9.3)의 발송 조건 { days, downloads }. 초안에 대용량 파일이 있으면 필수이며 관리자 상한 이내여야 합니다(large_file_terms).
* 발송 직전에 파일마다 링크를 만들어 본문 끝에 "대용량 첨부파일" 블록을 덧붙이고, 발송에 실패하면 링크를 되돌립니다.
*/
export async function sendDraft(env, id, { largeFiles = null } = {}) {
const mail = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first();
if (!mail) throw new Error('not_found');
if (mail.state === 'sent') return mail;
if (mail.direction !== 'outbound' || mail.folder !== 'drafts') throw new Error('mail_locked');
// bcc: IMAP 으로 올린 초안(메일 프로그램이 Bcc 헤더를 남김)의 숨은 참조. 예전에는 저장·발송에서 빠져 받는 사람이 조용히 사라졌습니다(0.8.25 수정).
// 같은 주소는 한 번만(0.9.9): 받는 사람·참조·숨은 참조를 통틀어 소문자 기준으로 중복을 지웁니다(받는 사람 > 참조 > 숨은 참조 순으로 남김).
// 예전에는 전체 답장에서 대소문자만 다른 주소가 받는 사람과 참조에 함께 들어가면 Resend 에 그대로 넘어가 같은 사람이 두 통을 받았습니다.
const seen = new Set(); const uniq = (list) => list.filter((a) => { const k = String(a).toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; });
const to = uniq(JSON.parse(mail.recipients_json)), cc = uniq(JSON.parse(mail.cc_json)), bcc = uniq(JSON.parse(mail.bcc_json || '[]'));
// 받는 사람 주소 검사는 여기서 합니다(초안 저장은 미완성 주소도 허용). 잘못된 주소는 invalid_email, 그 밖의 빈 값은 validation.
if ([...to, ...cc, ...bcc].some((a) => !isEmail(a))) throw new Error('invalid_email');
// 대용량 파일이 있으면 발송 조건(기한·횟수)을 먼저 검사합니다. 본문이 비어 있어도 대용량 파일이 있으면 보낼 수 있습니다(링크 블록이 본문이 됨).
const hasLarge = await checkLargeFileTerms(env, id, largeFiles); // 대용량 파일이 있는지(재시도면 조건 생략 가능)
if (!to.length || !mail.subject || !mail.text.trim() && !mail.html?.includes('<img') && !hasLarge) throw new Error('validation');
if (mail.state === 'sending' && Date.parse(mail.send_started_at) > Date.now() - 120000) throw new Error('mail_sending');
// 제한 검사(0.9.0): 받는 사람 수(예전 20명 고정 → 관리자·시스템 설정), 첨부 한 개 크기, 메일함·전체 용량, 월간 통수. 발송을 시작(claim)하기 전에 검사해
// 어긋나도 초안이 '발송 실패' 상태가 되지 않고 그대로 고칠 수 있게 합니다. 저장될 원본 크기는 본문 + 첨부의 MIME 크기 + 헤더 여유로 어림합니다.
const sizes = (await env.DB.prepare('SELECT size FROM attachments WHERE message_id=?').bind(id).all()).results.map((a) => Number(a.size || 0));
await checkOutbound(env, { mailbox: mail.mailbox || mailboxOf(env), recipients: to.length + cc.length + bcc.length, attachmentSizes: sizes, bytes: String(mail.html || '').length + String(mail.text || '').length + sizes.reduce((n, s) => n + mimeSize(s), 0) + 2000 });
const key = mail.send_key || 'gw-' + uid(), stamp = now();
const claim = await env.DB.prepare("UPDATE messages SET state='sending',send_key=?,send_started_at=?,updated_at=? WHERE id=? AND (state IN ('draft','failed') OR (state='sending' AND send_started_at<?))").bind(key, stamp, stamp, id, new Date(Date.now() - 120000).toISOString()).run();
if (!claim.meta.changes) throw new Error('mail_sending');
try {
// 답장·전달 인용문에 들어 있는 "원래 메일의" 인라인 이미지(/api/mail/<원래 id>/attachments/<aid>)를 이 초안의 인라인 첨부로 복사합니다.
// 왜: 예전에는 초안 자신의 첨부만 cid: 로 바꿔, 인용한 원래 메일의 이미지는 받는 사람에게 상대 주소(깨진 이미지)로 갔습니다.
// 같은 메일함의 메일만 복사합니다(다른 메일함의 첨부 id 를 끼워 넣어 빼내지 못하게).
let source = String(mail.html || '');
for (const [, mid, aid] of [...source.matchAll(/<img\b[^>]*\bsrc="\/api\/mail\/([^"/?]+)\/attachments\/([^"/?]+)(?:\?[^"]*)?"/g)]) {
const originId = decodeURIComponent(mid), attachmentId = decodeURIComponent(aid); if (originId === id) continue;
const orig = await env.DB.prepare('SELECT a.filename,a.content_type,a.size,a.bytes,m.mailbox FROM attachments a JOIN messages m ON m.id=a.message_id WHERE a.id=? AND a.message_id=?').bind(attachmentId, originId).first();
if (!orig?.bytes || orig.mailbox !== mail.mailbox) continue;
const copyId = uid();
await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,'inline',?,?,?)").bind(copyId, id, orig.filename, orig.content_type, `${copyId}@${mailboxOf(env).split('@')[1]}`, orig.size, orig.bytes, now()).run();
source = source.split(`/api/mail/${mid}/attachments/${aid}`).join(`/api/mail/${encodeURIComponent(id)}/attachments/${copyId}`);
}
if (source !== String(mail.html || '')) await env.DB.prepare('UPDATE messages SET html=? WHERE id=?').bind(source, id).run();
let files = await attachmentRows(env, id);
// 편집기가 넣은 인라인 이미지(/api/mail/<id>/attachments/<aid>?inline=1)는 cid: 로 바꿔 보냅니다.
const used = new Set();
const html = source.replace(/(<img\b[^>]*\bsrc=")\/api\/mail\/[^"/]+\/attachments\/([^"?]+)(?:\?[^"]*)?("[^>]*>)/g, (m, a, aid, b) => { const f = files.find((x) => x.id === aid && x.content_id); if (!f) return m; used.add(f.id); return `${a}cid:${f.content_id}${b}`; });
// IMAP 으로 올린 초안(메일 프로그램에 그림을 넣은 경우)은 본문이 처음부터 cid: 를 가리킵니다. 그 첨부도 "쓰는 중" 으로 표시합니다.
// 예전에는 이 첨부가 고아로 잡혀 지워지고 발송에서도 빠져, 받는 사람에게 깨진 그림이 갔습니다(0.8.16 수정).
for (const [, cid] of html.matchAll(/<img\b[^>]*\bsrc="cid:([^"]+)"/g)) { const f = files.find((x) => x.content_id && x.content_id === cid.replace(/&amp;/g, '&')); if (f) used.add(f.id); }
// 본문에서 지운 인라인 이미지는 보내지 않고 초안에서도 지웁니다. 첨부 칩 목록에는 인라인 파일이 보이지 않아
// 사용자가 직접 지울 방법이 없는데, 예전에는 이런 이미지가 일반 첨부로 함께 나갔습니다.
// Content-ID 가 없는 'inline' 첨부(Apple Mail 등이 PDF·문서를 inline; filename= 으로 올림)는 본문이 가리킬 수 없으므로 고아가 아니라 일반 첨부입니다.
// 예전에는 이런 첨부가 고아로 잡혀 조용히 지워지고 발송에서도 빠졌습니다(0.8.25 수정).
const orphans = files.filter((a) => a.disposition === 'inline' && a.content_id && !used.has(a.id));
for (const a of orphans) await env.DB.prepare('DELETE FROM attachments WHERE id=?').bind(a.id).run();
files = files.filter((a) => !orphans.includes(a));
// 보내는 주소는 항상 초안이 속한 메일함입니다. 저장된 sender 는 표시 이름을 쓰기 위해서만 보고, 주소가 메일함과 다르면 버립니다.
// 왜: IMAP APPEND 로 올린 초안은 From 헤더를 그대로 sender 에 저장하므로, 예전에는 일반 계정이 From 을 다른 주소(예: 관리자 메일함)로
// 적은 초안을 올린 뒤 웹에서 보내기를 누르면 그 주소로 발송됐습니다(SMTP 제출은 allowedSenders 로 막고 있었음). 0.8.16 수정.
const mailbox = mail.mailbox || mailboxOf(env);
const sender = address(mail.sender) === mailbox ? formatSender(nameOf(mail.sender), mailbox) : formatSender(senderName(env), mailbox);
// 발송에 넘기는 사용자 지정 헤더는 답장 스레드용 두 개뿐입니다. IMAP 으로 올린 초안(appendMessage)은 원본의 모든 헤더
// (Content-Type, MIME-Version, Date, Message-ID …)를 headers_json 에 갖고 있어, 그대로 보내면 발송 서비스가 거부하거나 MIME 이 깨집니다.
const savedHeaders = JSON.parse(mail.headers_json || '{}'); const headers = {};
for (const name of ['In-Reply-To', 'References']) { const k = Object.keys(savedHeaders).find((x) => x.toLowerCase() === name.toLowerCase()); if (k && savedHeaders[k]) headers[name] = String(savedHeaders[k]); }
// 대용량 첨부파일(0.9.3): 링크·기한·횟수를 확정하고 본문(HTML·텍스트) 끝에 블록을 덧붙입니다. 저장본에도 같은 본문을 남겨 보낸 메일함에서 링크를 볼 수 있게 합니다.
// 링크의 호스트(1.0.8): 경로 접두사 아래의 웹 메일이면 "호스트/mail" 처럼 접두사까지 붙여 https://host/mail/dl/… 이 됩니다.
const large = await finalizeLargeFiles(env, id, largeFiles, (env.PUBLIC_HOST || 'mail.arihakdang.com') + (env.PUBLIC_PATH || ''), env.STRINGS || {});
const finalHtml = html + large.html, finalText = String(mail.text || '') + large.text;
const payload = { from: sender, to, ...(cc.length ? { cc } : {}), ...(bcc.length ? { bcc } : {}), subject: mail.subject, html: finalHtml, text: finalText, ...(Object.keys(headers).length ? { headers } : {}) };
if (files.length) payload.attachments = files.map((a) => ({ filename: a.filename, content: Buffer.from(a.bytes).toString('base64'), content_type: a.content_type, ...(used.has(a.id) ? { content_id: a.content_id } : {}) }));
// 발송 모듈은 설정(outbound)에 따라 Resend 또는 SMTP 중계(내장 Haraka·외부 서버)입니다(1.0.0, providers/index.mjs).
const result = await sendMail(env, payload, key);
const sentAt = now();
// 발송이 끝나면 보낸 메일함으로 옮깁니다. 보관 기한이 없으므로 expires_at 은 채우지 않습니다.
// 저장본 html 은 인라인 이미지 복사를 반영한 source(위에서 갱신) 기준입니다. 0.9.3 은 복사 전 값(mail.html)을 다시 써서 원본 메일을 지우면 보낸 메일의 그림이 깨졌습니다(0.9.5 수정).
await env.DB.prepare("UPDATE messages SET state='sent',folder='sent',provider_id=?,sent_at=?,received_at=?,updated_at=?,read_at=COALESCE(read_at,?),original_html=?,html=?,text=? WHERE id=?").bind(result.id, sentAt, sentAt, sentAt, sentAt, finalHtml, source + large.html, finalText, id).run();
const stored = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first();
await storeRaw(env, id, await buildStoredEml(env, stored), 'built');
return stored;
} catch (e) {
await env.DB.prepare("UPDATE messages SET state='failed',updated_at=? WHERE id=? AND state='sending'").bind(now(), id).run();
// 대용량 파일의 링크는 되돌리지 않습니다: 다시 보낼 때 같은 토큰·같은 본문으로 보내야 멱등 키가 맞고, 첫 시도가 실제로 전달됐을 때 링크가 살아 있습니다(0.9.5).
throw e;
}
}
/**
* 메일 한 통의 IMAP 영구 플래그(\Flagged 중요, \Answered 답장함 등)를 켜거나 끕니다.
* 웹 API(별표·답장 표시)와 SMTP 제출(답장 발송 시 원래 메일에 \Answered) 이 함께 쓰도록 여기에 둡니다.
* @param {string} id 메일 id
* @param {string} flag 플래그 이름(예: '\Flagged')
* @param {boolean} on true 면 켜고 false 면 끕니다
*/
export async function setFlag(env, id, flag, on) {
const row = await env.DB.prepare('SELECT flags FROM imap_flags WHERE message_id=?').bind(id).first();
const flags = new Set(JSON.parse(row?.flags || '[]')); if (on) flags.add(flag); else flags.delete(flag);
await env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?) ON CONFLICT(message_id) DO UPDATE SET flags=excluded.flags,updated_at=excluded.updated_at').bind(id, JSON.stringify([...flags]), now()).run();
}
// ------------------------------------------------------------------ IMAP APPEND / COPY
/**
* 클라이언트가 올린 원본(.eml)을 메일함에 저장합니다(보낸 메일 복사본·초안 등). 원본은 그대로 보관합니다.
* @param {boolean} [options.enforceQuota] 메일함 최대 크기(0.9.0)를 검사할지. IMAP APPEND 는 검사하고(넘으면 quota_exceeded),
* SMTP 제출은 발송 전에 이미 검사했으므로 false 로 넘깁니다(발송된 메일의 보낸 메일함 저장이 실패하면 안 되므로).
*/
export async function appendMessage(env, folder, eml, { flags = [], internalDate = null, mailbox = null, enforceQuota = true } = {}) {
if (!['inbox', 'sent', 'drafts', 'trash'].includes(folder)) throw new Error('not_found');
// 줄 끝을 CRLF 로 맞춘 뒤 해석합니다. 일부 프로그램·스크립트는 LF 만 쓴 메시지를 올리는데, 해석기는 CRLF 경계만 알아
// 예전에는 본문이 빈 메일로 저장됐습니다(0.8.16 수정). 원본도 정규화한 바이트로 보관해 IMAP/POP 이 같은 것을 돌려줍니다.
eml = normalizeCrlf(eml);
const parsed = parseMime(eml), info = summarize(parsed);
// 보낸 메일함 중복 방지: 메일 프로그램은 SMTP 로 보낸 뒤 같은 메일을 IMAP 으로 보낸 메일함에 다시 올립니다(APPEND).
// SMTP 제출(smtp.mjs)이 이미 같은 Message-ID 로 저장했다면 새로 만들지 않고 그 메일을 돌려줍니다(반대 순서는 smtp.mjs 가 처리).
// 용량 검사보다 먼저 합니다: 발송으로 메일함이 가득 찬 직후 올라오는 사본이 OVERQUOTA 로 거부되면 메일 프로그램이 "보낸 편지함 복사 실패" 를 반복했습니다(0.9.3).
if (folder === 'sent' && info.messageId) {
const same = await env.DB.prepare("SELECT id FROM messages WHERE mailbox=? AND folder='sent' AND message_id=?").bind(mailbox || mailboxOf(env), info.messageId).first();
if (same) return { id: same.id, duplicate: true };
}
if (enforceQuota) {
const box = mailbox || mailboxOf(env), quota = await mailboxQuota(env, box);
if (quota > 0 && (await mailboxUsage(env, box)) + eml.length > quota) { const e = new Error('quota_exceeded'); e.limit = quota; throw e; }
// 전체 저장 용량(시스템)도 봅니다. 예전에는 APPEND 만 이 제한을 비켜 가 메일 프로그램으로 저장소를 상한 너머로 키울 수 있었습니다(0.9.3).
const sys = systemLimits(env);
if (sys.totalBytes > 0 && (await totalUsage(env)) + eml.length > sys.totalBytes) { const e = new Error('storage_full'); e.limit = sys.totalBytes; throw e; }
}
const content = safeContent(info.html, info.text);
const receivedAt = internalDate || (Date.parse(info.date) ? new Date(Date.parse(info.date)).toISOString() : now()), stamp = now(), id = uid();
mailbox = mailbox || mailboxOf(env); const inbound = folder === 'inbox' || (folder === 'trash' && address(info.from) !== mailbox);
const direction = inbound ? 'inbound' : 'outbound', state = folder === 'drafts' ? 'draft' : inbound ? 'received' : 'sent';
const seen = flags.some((f) => f.toLowerCase() === '\\seen') || !inbound;
// bcc_json: 원본의 Bcc 헤더(메일 프로그램이 초안에 남겨 둠). 발송(sendDraft)이 숨은 참조로 씁니다(0.8.25~).
await env.DB.prepare(`INSERT INTO messages(id,message_id,direction,folder,sender,recipients_json,cc_json,bcc_json,reply_to_json,subject,html,text,original_html,headers_json,source,state,received_at,created_at,updated_at,sent_at,read_at,mailbox) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'web',?,?,?,?,?,?,?)`)
.bind(id, info.messageId || null, direction, folder, info.from, JSON.stringify(info.to), JSON.stringify(info.cc), JSON.stringify(info.bcc || []), JSON.stringify(info.replyTo), clean(info.subject, 500), content.html, info.text || content.text, info.html || null, JSON.stringify(info.headers), state, receivedAt, stamp, stamp, inbound ? null : receivedAt, seen ? stamp : null, mailbox).run();
for (const a of info.attachments) await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,?,?,?,?)").bind(uid(), id, clean(a.filename, 255) || 'attachment', clean(a.contentType, 120), a.contentId, a.disposition, a.bytes.length, a.bytes, stamp).run();
const persisted = flags.filter((f) => ['\\Flagged', '\\Answered', '\\Deleted'].some((x) => x.toLowerCase() === f.toLowerCase())).map((f) => f[0] + f[1].toUpperCase() + f.slice(2).toLowerCase());
if (persisted.length) await env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?)').bind(id, JSON.stringify(persisted), stamp).run();
await storeRaw(env, id, Buffer.from(eml), 'stored');
return { id };
}
/** IMAP COPY: 메시지·원본·첨부를 다른 메일함에 복제합니다. */
/**
* 다른 메일의 첨부(일반 첨부만)를 초안으로 복사합니다(0.8.25~). 웹의 "전달" 이 원본 첨부를 함께 보내기 위한 것으로,
* 예전에는 전달 메일에 인용 본문의 그림만 따라가고 PDF 같은 일반 첨부는 조용히 빠졌습니다.
* 초안 상태의 메일에만, 초안의 첨부 개수·용량 한도(DRAFT_FILE_COUNT / DRAFT_TOTAL_LIMIT) 안에서 복사합니다. 원본이 같은 메일함인지는 API 가 확인합니다.
* @returns {Promise<string[]>} 새 첨부 id 목록
*/
export async function copyAttachments(env, draftId, originId, ids) {
const draft = await env.DB.prepare("SELECT id FROM messages WHERE id=? AND state='draft' AND folder='drafts'").bind(draftId).first();
if (!draft) throw new Error('mail_locked');
const out = [];
for (const aid of ids) {
// content_id 가 없는 inline(예전 규칙으로 저장된 행)도 파일 첨부로 보고 복사합니다(0.9.9).
const a = await env.DB.prepare("SELECT filename,content_type,size,bytes FROM attachments WHERE id=? AND message_id=? AND (disposition='attachment' OR content_id IS NULL)").bind(aid, originId).first();
if (!a?.bytes) continue;
await checkAttachmentSize(env, a.bytes.length);
const current = await env.DB.prepare('SELECT COUNT(*) n, COALESCE(SUM(size),0) total FROM attachments WHERE message_id=?').bind(draftId).first();
if (current.n >= DRAFT_FILE_COUNT || current.total + a.bytes.length > DRAFT_TOTAL_LIMIT) throw new Error('attachment_too_large');
const id = uid();
await env.DB.prepare("INSERT INTO attachments(id,message_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,NULL,'attachment',?,?,?)").bind(id, draftId, a.filename, a.content_type, a.bytes.length, a.bytes, now()).run();
out.push(id);
}
if (out.length) await invalidateRaw(env, draftId);
return out;
}
export async function copyMessage(env, id, folder) {
const m = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(id).first(); if (!m) throw new Error('not_found');
const newId = uid(), stamp = now();
// 복제할 열 목록. expires_at 은 더 쓰지 않으므로 빼서 복사본은 항상 NULL 이 됩니다.
const cols = ['message_id','direction','sender','recipients_json','cc_json','bcc_json','reply_to_json','subject','html','text','original_html','headers_json','source','state','received_at','sent_at','read_at','raw_status','raw_size','mailbox'];
await env.DB.prepare(`INSERT INTO messages(id,folder,created_at,updated_at,provider_id,${cols.join(',')}) VALUES(?,?,?,?,NULL,${cols.map(() => '?').join(',')})`).bind(newId, folder, stamp, stamp, ...cols.map((c) => m[c] ?? null)).run();
await env.DB.prepare('INSERT INTO message_raw(message_id,eml,stored_at) SELECT ?,eml,? FROM message_raw WHERE message_id=?').bind(newId, stamp, id).run();
const files = (await env.DB.prepare('SELECT * FROM attachments WHERE message_id=?').bind(id).all()).results;
for (const a of files) await env.DB.prepare('INSERT INTO attachments(id,message_id,provider_id,filename,content_type,content_id,disposition,size,bytes,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)').bind(uid(), newId, a.provider_id, a.filename, a.content_type, a.content_id, a.disposition, a.size, a.bytes, stamp).run();
const f = await env.DB.prepare('SELECT flags FROM imap_flags WHERE message_id=?').bind(id).first();
if (f) await env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?)').bind(newId, f.flags, stamp).run();
return newId;
}
+224
View File
@@ -0,0 +1,224 @@
/**
* 메일 사용량 정보 제공 API(1.0.9, 요구사항 1) — 웹메이커 같은 외부 관리 시스템이 디스크 사용량·발송량 등을 통합 관리할 수 있게 읽기 전용 통계를 내줍니다.
*
* 주소(어느 호스트로 와도 같음, nginx 뒤 HTTPS):
* GET /api/usage 모든 도메인의 요약(기본 도메인 먼저)
* GET /api/usage/<도메인> 그 도메인의 상세(기본으로 계정 목록·일별 추이 포함)
* 쿼리: days=30(일별 추이·계정별 기간 통수의 기간, 1~366) · accounts=0|1(계정 목록 포함, 요약은 기본 0·상세는 기본 1) · series=0|1(일별 추이, 상세는 기본 1)
*
* 보안(요구사항: webmaker 의 해당 기능에서만 접근할 수 있게)
* 1) 비밀 키: 전체 설정 화면의 "사용량 API" 카드에서 만든 키(USAGE_API_KEY, 기본 도메인의 설정 파일에만 저장, 32바이트 난수)를 `Authorization: Bearer <키>`(또는 X-Usage-Key)로
* 보내야 합니다. 키가 없으면 API 자체가 꺼진 상태(404)이고, 비교는 상수 시간(safeEqual)입니다. 키는 만들 때 한 번만 보여 주며 설정 화면에는 앞뒤 일부만 보입니다. 언제든 다시 만들거나(교체) 지울 수 있습니다.
* 2) 접속 IP 제한(선택): USAGE_API_ALLOW 에 웹메이커 서버의 IP 나 CIDR(쉼표 구분)을 적으면 그 밖의 주소는 키가 맞아도 403. nginx 가 넘긴 X-Forwarded-For 의 첫 주소(또는 CF-Connecting-IP)를 봅니다.
* 3) 시도 제한: 키가 틀린 요청은 IP 마다 15분에 10회(그 뒤 429), 성공 요청도 IP 마다 1분에 120회(무차별 조회 방지). 모두 메모리 제한(env.RATE)입니다.
* 4) 읽기 전용·비밀값 없음: 메일 내용·주소록·비밀번호·API 키는 절대 들어가지 않고, 수(통·바이트)·제한·계정 이름(로그인 ID·메일함 주소·역할·상태)만 나갑니다. 호출은 감사 기록(audit_log)에 남습니다.
*
* 응답(도메인 하나)
* domain, primary, enabled, hosts, authMode, mailbox, inbound, outbound, timezone, generatedAt
* accounts : { local, external, total, limit, percent } — 게이트웨이 계정 수 / 웹메이커 등 외부 계정 수 / 시스템 제한(0 = 없음)
* storage : { bytes, limit, percent, largeFiles:{count, bytes, sent} } — 모든 메일함의 사용량(MIME 기준, 원본 .eml 크기) / 대용량 첨부파일(별도 저장)
* messages : { total, inbound, outbound, drafts, failed } — 저장된 메일 수(폴더 무관, 휴지통 포함; 영구 삭제한 것은 없음)
* traffic : { day, week, month: { sent, received, total, sentBytes, receivedBytes, start, end }, monthlyLimit, monthlyPercent } — 통계 시간대 기준(오늘·이번 주(월~)·이달)
* daily : [ { date:'YYYY-MM-DD', sent, received, sentBytes, receivedBytes } ] (최근 days 일, 오래된 날부터)
* accountList : [ { login, name, mailbox, role, source:'local'|'www'|'webmaker', status, messages, bytes, limit, percent, lastLoginAt, sent, received, sentBytes, receivedBytes } ] (기간 = days)
* limits : { system:{…}, admin:{…} } — 설치 시 시스템 제한과 관리자 제한(안내용)
*/
import { json, bad, safeEqual, now } from './util.mjs';
import { systemLimits, readAdminLimits, mailboxUsage, mailboxQuota, totalUsage, accountCount, statsTimezone, periodBounds, countMail, percentOf } from './limits.mjs';
import { listUsers, externalUsers, authMode, audit, mailboxOwner, standalone } from './auth.mjs';
import { largeFileStats } from './files.mjs';
import { inboundMode, outboundMode } from './providers/index.mjs';
import { mailDomain } from './config.mjs';
export const USAGE_FAIL_LIMIT = 10, USAGE_RATE_LIMIT = 120;
const DAY_MS = 86400000;
// ------------------------------------------------------------------ 접속 IP 허용 목록
/** "1.2.3.4, 10.0.0.0/8, 2001:db8::1" 같은 문자열을 항목 배열로. 빈 문자열이면 제한 없음(빈 배열). */
export function parseAllowList(text) {
return String(text || '').split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
}
const ipv4ToInt = (ip) => { const m = String(ip).match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (!m) return null; const parts = m.slice(1).map(Number); if (parts.some((n) => n > 255)) return null; return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]; };
/** IPv6 주소를 소문자 전개형(8그룹 16진수)으로. 잘못된 주소는 null. "::ffff:1.2.3.4" 꼴은 IPv4 로 비교하도록 호출자가 먼저 벗깁니다. */
function expandIpv6(ip) {
const s = String(ip).toLowerCase(); if (!/^[0-9a-f:]+$/.test(s) || s.split('::').length > 2) return null;
const [head, tail = ''] = s.split('::'); const h = head ? head.split(':') : [], t = tail ? tail.split(':') : [];
if (h.length + t.length > 8 || (s.indexOf('::') < 0 && h.length !== 8)) return null;
const groups = [...h, ...Array(8 - h.length - t.length).fill('0'), ...t];
if (groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g))) return null;
return groups.map((g) => parseInt(g, 16));
}
/**
* 접속 IP 가 허용 목록 안인지. 목록이 비어 있으면 모두 허용. IPv4 는 CIDR(/8~/32) 지원, IPv6 는 정확히 같은 주소 또는 /n 접두사.
* IPv4 주소가 "::ffff:1.2.3.4" 로 들어오면(IPv6 소켓) IPv4 로 봅니다.
*/
export function ipAllowed(ip, list) {
if (!list || !list.length) return true;
let addr = String(ip || '').trim().toLowerCase(); if (addr.startsWith('::ffff:') && addr.includes('.')) addr = addr.slice(7);
const v4 = ipv4ToInt(addr), v6 = v4 === null ? expandIpv6(addr) : null;
if (v4 === null && !v6) return false;
for (const entry of list) {
const [net, bitsRaw] = entry.split('/'); const bits = bitsRaw === undefined ? null : Number(bitsRaw);
if (v4 !== null) {
const n = ipv4ToInt(net); if (n === null) continue;
const b = bits === null ? 32 : bits; if (!Number.isInteger(b) || b < 0 || b > 32) continue;
const mask = b === 0 ? 0 : (~0 << (32 - b)) >>> 0; if (((v4 & mask) >>> 0) === ((n & mask) >>> 0)) return true;
} else {
const n = expandIpv6(net); if (!n) continue;
const b = bits === null ? 128 : bits; if (!Number.isInteger(b) || b < 0 || b > 128) continue;
let ok = true; for (let i = 0; i < 8 && ok; i++) { const take = Math.max(0, Math.min(16, b - i * 16)); if (!take) break; const m = take === 16 ? 0xffff : (0xffff << (16 - take)) & 0xffff; if ((v6[i] & m) !== (n[i] & m)) ok = false; }
if (ok) return true;
}
}
return false;
}
/**
* 허용 목록 항목 검사: 각 항목이 IPv4[/0~32] 또는 IPv6[/0~128] 이어야 합니다. 잘못된 항목 목록을 돌려줍니다(비어 있으면 통과).
* 저장 때 검사하지 않으면 오타 하나로 모든 요청이 403 이 되고 아무 데서도 알 수 없습니다.
*/
export function invalidAllowEntries(text) {
const bad = [];
for (const entry of parseAllowList(text)) {
const [net, bitsRaw, extra] = entry.split('/'); if (extra !== undefined) { bad.push(entry); continue; }
const v4 = ipv4ToInt(net), v6 = v4 === null ? expandIpv6(net) : null; if (v4 === null && !v6) { bad.push(entry); continue; }
if (bitsRaw !== undefined) { const b = Number(bitsRaw); if (!/^\d{1,3}$/.test(bitsRaw) || b > (v4 !== null ? 32 : 128)) bad.push(entry); }
}
return bad;
}
/**
* 요청의 접속 IP(허용 목록·시도 제한용). 클라이언트가 마음대로 넣을 수 있는 헤더를 믿지 않습니다:
* 1) X-Real-IP(nginx 템플릿 1.0.9 부터 $remote_addr 로 넣음) → 2) X-Forwarded-For 의 **마지막** 주소(nginx 가 $proxy_add_x_forwarded_for 로 실제 접속 주소를 끝에 덧붙임;
* 첫 주소는 클라이언트가 보낸 값일 수 있음) → 3) 소켓 주소. CF-Connecting-IP 는 Cloudflare 뒤에서만 참이므로 USAGE_TRUST_CF=1 일 때만 봅니다.
*/
export function clientIp(headers, remote = '') {
const get = (k) => String(headers.get(k) || '').trim();
if (process.env.USAGE_TRUST_CF === '1' && get('cf-connecting-ip')) return get('cf-connecting-ip');
if (get('x-real-ip')) return get('x-real-ip');
const xff = get('x-forwarded-for').split(',').map((s) => s.trim()).filter(Boolean);
if (xff.length) return xff[xff.length - 1];
return String(remote || '').trim();
}
// ------------------------------------------------------------------ 인증
/**
* 사용량 API 인증. 키는 기본 도메인(primaryEnv)의 설정에서만 읽습니다(도메인별 키가 아니라 서버 하나에 한 키 — 웹메이커가 모든 도메인을 한 번에 봄).
* @returns {{ok:true}|{ok:false, status:number, error:string}}
*/
export function authorizeUsage(primaryEnv, request, ip) {
const key = String(primaryEnv.USAGE_API_KEY || '').trim();
if (!key) return { ok: false, status: 404, error: 'usage_api_disabled' };
const rate = primaryEnv.RATE;
if (rate?.blocked('usage-fail:' + ip, { limit: USAGE_FAIL_LIMIT })) return { ok: false, status: 429, error: 'rate_limited' };
const auth = request.headers.get('authorization') || '';
const given = (auth.match(/^Bearer\s+(.+)$/i)?.[1] || request.headers.get('x-usage-key') || '').trim();
if (!given || !safeEqual(given, key)) { rate?.hit('usage-fail:' + ip, { limit: USAGE_FAIL_LIMIT, windowSec: 900 }); return { ok: false, status: 401, error: 'unauthenticated' }; }
if (!ipAllowed(ip, parseAllowList(primaryEnv.USAGE_API_ALLOW))) return { ok: false, status: 403, error: 'forbidden_ip' };
// 성공 요청 빈도(IP 마다 1분 120회). hit() 가 false 를 돌려주면 한도를 넘은 것.
if (rate && !rate.hit('usage:' + ip, { limit: USAGE_RATE_LIMIT, windowSec: 60 })) return { ok: false, status: 429, error: 'rate_limited' };
return { ok: true };
}
// ------------------------------------------------------------------ 통계
/** 통계 시간대 기준의 날짜 문자열(YYYY-MM-DD). */
const dateKey = (iso, tz) => { try { return new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(iso)); } catch { return String(iso).slice(0, 10); } };
/**
* 최근 days 일의 일별 수·발신 통수와 바이트, 메일함별 기간 합계. 한 번의 조회로 기간 안의 메일(발신은 발송 완료 시각, 수신은 받은 시각)을 읽어 JS 에서 날짜별로 묶습니다
* (시간대 변환을 SQL 로 하기 어렵고, 메일 수가 많지 않아 충분히 빠름). 기간 시작은 오늘 0시(통계 시간대)에서 days-1 일을 뺀 시각입니다.
*/
async function dailySeries(env, days, tz) {
// 시작 = (오늘 0시 - (days-1)일) 이 속한 날의 0시. 24시간 단위로 빼기만 하면 서머타임 경계에서 한 시간이 밀리므로 그 날의 정오를 잡아 다시 0시를 구합니다.
const today = periodBounds('day', tz); const approx = Date.parse(today.start) - (days - 1) * DAY_MS;
const start = periodBounds('day', tz, new Date(approx + DAY_MS / 2)).start, startMs = Date.parse(start), end = today.end;
const rows = (await env.DB.prepare(`SELECT direction, mailbox, raw_size, sent_at, received_at FROM messages WHERE (direction='inbound' AND received_at>=? AND received_at<?) OR (direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?)`).bind(start, end, start, end).all()).results;
const byDate = new Map(); for (let i = 0; i < days; i++) { const d = dateKey(new Date(startMs + i * DAY_MS + DAY_MS / 2).toISOString(), tz); byDate.set(d, { date: d, sent: 0, received: 0, sentBytes: 0, receivedBytes: 0 }); }
const byMailbox = new Map();
for (const r of rows) {
const at = r.direction === 'outbound' ? r.sent_at : r.received_at; const d = dateKey(at, tz); const bucket = byDate.get(d); const size = Number(r.raw_size || 0);
const mb = byMailbox.get(r.mailbox) || { sent: 0, received: 0, sentBytes: 0, receivedBytes: 0 }; byMailbox.set(r.mailbox, mb);
if (r.direction === 'outbound') { if (bucket) { bucket.sent++; bucket.sentBytes += size; } mb.sent++; mb.sentBytes += size; }
else { if (bucket) { bucket.received++; bucket.receivedBytes += size; } mb.received++; mb.receivedBytes += size; }
}
return { start, end, daily: [...byDate.values()], byMailbox };
}
/** 기간 통계에 바이트 합계를 더한 countMail. */
async function periodTraffic(env, bounds) {
const c = await countMail(env, bounds);
const s = await env.DB.prepare("SELECT COALESCE(SUM(raw_size),0) AS b FROM messages WHERE direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?").bind(bounds.start, bounds.end).first();
const r = await env.DB.prepare("SELECT COALESCE(SUM(raw_size),0) AS b FROM messages WHERE direction='inbound' AND received_at>=? AND received_at<?").bind(bounds.start, bounds.end).first();
return { ...c, sentBytes: Number(s?.b || 0), receivedBytes: Number(r?.b || 0) };
}
/**
* 도메인(테넌트 env) 하나의 사용량 보고서.
* @param {object} o days: 일별 추이·계정별 기간의 길이, accounts: 계정 목록 포함 여부, series: 일별 추이 포함 여부, entry: 등록부 항목(hosts·enabled·primary)
*/
export async function domainUsage(env, { days = 30, accounts = true, series = true, entry = null } = {}) {
const tz = statsTimezone(env), sys = systemLimits(env), admin = await readAdminLimits(env);
const counts = await env.DB.prepare("SELECT COUNT(*) total, SUM(CASE WHEN direction='inbound' THEN 1 ELSE 0 END) inbound, SUM(CASE WHEN direction='outbound' AND state='sent' THEN 1 ELSE 0 END) outbound, SUM(CASE WHEN state IN ('draft','sending') THEN 1 ELSE 0 END) drafts, SUM(CASE WHEN state='failed' THEN 1 ELSE 0 END) failed FROM messages").first();
const total = await totalUsage(env), local = await accountCount(env), ext = await externalUsers(env);
const traffic = {}; for (const kind of ['day', 'week', 'month']) traffic[kind] = await periodTraffic(env, periodBounds(kind, tz));
const out = {
domain: mailDomain(env), primary: !env.TENANTS || env.TENANTS.primary.env === env, enabled: entry ? entry.enabled !== false : true, hosts: entry?.hosts || [env.PUBLIC_HOST].filter(Boolean),
authMode: authMode(env), mailbox: String(env.MAILBOX || ''), inbound: inboundMode(env), outbound: outboundMode(env), timezone: tz, generatedAt: now(),
accounts: { local, external: ext.accounts.length, total: local + ext.accounts.length, limit: sys.accounts, percent: percentOf(local, sys.accounts), externalSource: ext.accounts.length || authMode(env) === 'webmaker' ? { kind: authMode(env), ok: ext.ok !== false, error: ext.error || null } : null },
storage: { bytes: total, limit: sys.totalBytes, percent: percentOf(total, sys.totalBytes), largeFiles: await largeFileStats(env) },
messages: { total: Number(counts?.total || 0), inbound: Number(counts?.inbound || 0), outbound: Number(counts?.outbound || 0), drafts: Number(counts?.drafts || 0), failed: Number(counts?.failed || 0) },
traffic: { ...traffic, monthlyLimit: sys.monthlyMessages, monthlyPercent: percentOf(traffic.month.total, sys.monthlyMessages) },
limits: { system: sys, admin },
};
if (series || accounts) {
const d = await dailySeries(env, days, tz);
out.period = { days, start: d.start, end: d.end };
if (series) out.daily = d.daily;
if (accounts) {
const rows = [];
const push = async (u) => { const bytes = await mailboxUsage(env, u.mailbox), limit = await mailboxQuota(env, u.mailbox), n = await env.DB.prepare('SELECT COUNT(*) AS n FROM messages WHERE mailbox=?').bind(u.mailbox).first(); const t = d.byMailbox.get(u.mailbox) || { sent: 0, received: 0, sentBytes: 0, receivedBytes: 0 }; rows.push({ login: u.login, name: u.name || '', mailbox: u.mailbox, role: u.role, source: u.source, status: u.status, messages: Number(n?.n || 0), bytes, limit, percent: percentOf(bytes, limit), lastLoginAt: u.lastLoginAt || null, ...t }); };
for (const u of await listUsers(env)) await push({ ...u, source: 'local', lastLoginAt: u.last_login_at });
for (const a of ext.accounts) await push({ ...a, source: 'webmaker' });
// www 연동의 기본 메일함(관리자 계정 행이 없음): 메일함 주인(www 관리자) 이름으로 한 행.
if (!standalone(env) && !rows.some((r) => r.mailbox === String(env.MAILBOX || '').toLowerCase())) { let owner = null; try { owner = mailboxOwner(env); } catch { owner = null; } await push({ login: owner?.login_id || owner?.email || 'admin', name: owner?.name || '', mailbox: String(env.MAILBOX || '').toLowerCase(), role: 'admin', source: 'www', status: owner?.status || 'active', lastLoginAt: null }); }
// 계정 행이 없는데 메일이 있는 메일함(지운 계정): 주소만으로 한 행(사용량이 사라지지 않게). 메일이 있는 메일함 목록은 messages 표에서 직접 뽑습니다.
const withMail = (await env.DB.prepare('SELECT mailbox, COUNT(*) AS n FROM messages GROUP BY mailbox').all()).results;
for (const m of withMail) if (m.n && !rows.some((r) => r.mailbox === m.mailbox)) await push({ login: null, name: '', mailbox: m.mailbox, role: null, source: 'orphan', status: 'deleted', lastLoginAt: null });
out.accountList = rows;
}
}
return out;
}
// ------------------------------------------------------------------ HTTP 처리(main.mjs 가 /api/usage 로 부름)
const clampDays = (v) => { const n = Number(v); return Number.isInteger(n) && n >= 1 && n <= 366 ? n : 30; };
const flag = (v, d) => (v === undefined || v === null || v === '' ? d : !(v === '0' || v === 'false' || v === 'no'));
/**
* GET /api/usage[/<도메인>]. 인증·IP·빈도 검사 뒤 보고서를 JSON 으로 돌려줍니다. 감사 기록은 기본 도메인 DB 에 남깁니다.
* @param {object} tenants domains.mjs 의 테넌트 관리자
* @param {Request} request
* @param {{path:string[], ip:string, query:URLSearchParams}} o path: 'usage' 뒤의 조각
*/
export async function usageApi(tenants, request, { path = [], ip = '', query = new URLSearchParams() } = {}) {
const primary = tenants.primary.env;
if (request.method !== 'GET') return bad('method_not_allowed', 405);
const auth = authorizeUsage(primary, request, ip);
// 키가 없어 꺼진 상태(404)만 빼고 모든 거부(틀린 키·허용 밖 IP·빈도 초과)를 기록합니다 — 맞는 키로 허용 밖에서 부르는 것이 가장 중요한 신호.
if (!auth.ok) { if (auth.status !== 404) console.warn(`사용량 API 거부 ${ip}: ${auth.error}(${auth.status})`); return bad(auth.error, auth.status); }
const days = clampDays(query.get('days'));
const headers = { 'Cache-Control': 'no-store' };
let domain = ''; try { domain = path[0] ? decodeURIComponent(path[0]).trim().toLowerCase() : ''; } catch { return bad('domain_not_found', 404); }
try {
if (domain) {
const t = tenants.byDomain(domain); if (!t) return bad('domain_not_found', 404);
const report = await domainUsage(t.env, { days, accounts: flag(query.get('accounts'), true), series: flag(query.get('series'), true), entry: t.entry });
await audit(primary, 'usage-api', 'usage_read', domain, { ip, days });
return json({ ok: true, version: primary.APP_VERSION || null, generatedAt: now(), ...report }, 200, headers);
}
// 도메인 하나가 깨져도(DB 오류 등) 나머지 도메인의 수치는 돌려줍니다. 깨진 도메인은 { domain, error } 로 표시하고 합계에서는 뺍니다.
const domains = [];
for (const t of tenants.all()) {
try { domains.push(await domainUsage(t.env, { days, accounts: flag(query.get('accounts'), false), series: flag(query.get('series'), false), entry: t.entry })); }
catch (e) { console.error(`사용량 API 도메인 오류(${t.entry.domain})`, e.message); domains.push({ domain: t.entry.domain, primary: t.entry.domain === tenants.registry.primary, enabled: t.entry.enabled !== false, error: 'server_error' }); }
}
await audit(primary, 'usage-api', 'usage_read', null, { ip, days, domains: domains.length });
const sum = (k, f) => domains.reduce((n, d) => n + (d.error ? 0 : f(d)), 0);
return json({ ok: true, version: primary.APP_VERSION || null, generatedAt: now(), primary: tenants.registry.primary, totals: { domains: domains.length, accounts: sum('a', (d) => d.accounts.total), storageBytes: sum('s', (d) => d.storage.bytes), largeFileBytes: sum('l', (d) => d.storage.largeFiles.bytes), messages: sum('m', (d) => d.messages.total), sentMonth: sum('sm', (d) => d.traffic.month.sent), receivedMonth: sum('rm', (d) => d.traffic.month.received) }, domains }, 200, headers);
} catch (e) { console.error('사용량 API 오류', e.message); return bad('server_error', 500); }
}
+81
View File
@@ -0,0 +1,81 @@
/**
* 게이트웨이 계정 명령줄 도구(0.8.18~).
*
* 독립 실행(www 사이트 DB 없이 설치)에서는 관리자 페이지에 들어갈 첫 관리자 계정을 화면에서 만들 수 없습니다
* (관리자 페이지는 관리자 로그인이 있어야 열리므로). 이 도구가 그 첫 계정을 만들고, 이후에도 비밀번호 재설정 같은
* 운영 작업을 서버 셸에서 할 수 있게 합니다. 서비스가 돌고 있는 중에도 쓸 수 있습니다(SQLite WAL, 짧은 쓰기).
*
* 사용법(서비스 계정으로, 환경 파일을 읽어서):
* DB_FILE=... MAILBOX=admin@example.com node server/user-cli.mjs add <로그인ID> [--admin] [--name "이름"]
* DB_FILE=... node server/user-cli.mjs passwd <로그인ID>
* DB_FILE=... node server/user-cli.mjs list
*
* 비밀번호는 (1) 환경 변수 PASSWORD, (2) --password-stdin(표준 입력 한 줄), (3) 단말 입력(글자 숨김) 순으로 받습니다.
* 비밀번호 규칙은 관리자 페이지의 비밀번호 정책(기본 6자 이상)을 따릅니다(0.8.24~). 독립 실행의 첫 관리자는 서버가 시작할 때 admin / adminpasswd 로 자동 생성되므로 이 도구는 추가 계정·비밀번호 재설정에 씁니다.
* 계정 수 제한(LIMIT_ACCOUNTS, 0.9.0)은 환경 변수로 주면 여기서도 적용됩니다(서비스의 환경 파일을 그대로 넘기면 됨).
* 명령줄 인수로는 받지 않습니다(ps·셸 이력에 남기 때문).
* MAILBOX 는 계정의 메일함 주소(로그인ID@도메인)를 정하는 데 필요하므로 add 때는 꼭 서비스와 같은 값을 주세요.
*/
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { createInterface } from 'node:readline';
import { openDb, d1, migrate } from './db.mjs';
import { createUser, updateUser, listUsers, mailDomain, readPasswordPolicy } from './auth.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const [command, login, ...rest] = process.argv.slice(2);
const flag = (name) => rest.includes(name);
const option = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; };
function usage(code = 1) {
console.error('사용법: node server/user-cli.mjs add <login> [--admin] [--name 이름] | passwd <login> | list\n비밀번호: 환경 변수 PASSWORD, --password-stdin, 또는 단말 입력');
process.exit(code);
}
/** 표준 입력 전체(첫 줄)를 비밀번호로 읽습니다 — 스크립트에서 `printf '%s' "$PW" | node ... --password-stdin` 형태로 씁니다. */
async function readStdinLine() {
let data = ''; for await (const chunk of process.stdin) data += chunk;
return data.split(/\r?\n/)[0];
}
/** 단말에서 글자를 숨기고 비밀번호를 받습니다. 단말이 아니면(파이프) 표준 입력 한 줄을 읽습니다. */
async function prompt(label) {
if (!process.stdin.isTTY) return readStdinLine();
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
// 입력 글자를 화면에 쓰지 않도록 출력 함수를 가로챕니다. 질문 글은 먼저 직접 써 둡니다.
process.stdout.write(label);
rl._writeToOutput = () => {};
const answer = await new Promise((resolve) => rl.question('', resolve));
rl.close(); process.stdout.write('\n');
return answer;
}
/** 정책을 한 줄 안내문으로: "6자 이상, 대·소문자 혼용, 숫자 포함, 특수문자 포함" */
function policyText(p) { return [`${p.minLength}자 이상`, p.mixedCase && '대·소문자 혼용', p.digit && '숫자 포함', p.special && '특수문자 포함'].filter(Boolean).join(', '); }
async function password() {
if (process.env.PASSWORD) return process.env.PASSWORD;
if (flag('--password-stdin')) return readStdinLine();
const a = await prompt(`비밀번호(${policyText(await readPasswordPolicy(env))}): `);
if (process.stdin.isTTY) { const b = await prompt('비밀번호 확인: '); if (a !== b) { console.error('두 입력이 다릅니다'); process.exit(1); } }
return a;
}
if (!command || !['add', 'passwd', 'list'].includes(command) || (command !== 'list' && !login)) usage();
const file = process.env.DB_FILE || join(ROOT, 'data', 'mail-gateway.sqlite');
const db = openDb(file); migrate(db, join(ROOT, 'migrations'));
const env = { ...process.env, DB: d1(db) };
try {
if (command === 'list') {
for (const u of await listUsers(env)) console.log(`${u.login}\t${u.mailbox}\t${u.role}\t${u.status}\t${u.name || ''}`);
} else if (command === 'add') {
const id = await createUser(env, { login, name: option('--name') || '', password: await password(), role: flag('--admin') ? 'admin' : 'user' }, 'user-cli');
console.log(`계정을 만들었습니다: ${login} → ${login}@${mailDomain(env)} (${flag('--admin') ? '관리자' : '사용자'}, id ${id})`);
} else {
const row = db.prepare('SELECT id FROM mail_users WHERE login = ?').get(String(login).toLowerCase());
if (!row) { console.error(`계정이 없습니다: ${login}`); process.exit(1); }
await updateUser(env, row.id, { password: await password() });
console.log(`비밀번호를 바꿨습니다: ${login} (웹 세션은 모두 끝났습니다)`);
}
} catch (e) {
// createUser/updateUser 의 오류 코드를 사람이 읽을 말로 바꿉니다.
const messages = { invalid_login: '로그인 ID 는 영문 소문자·숫자·._- 2~64자여야 하고(기본 메일함 별칭은 독립 실행에서만 가능)', weak_password: `비밀번호가 정책에 맞지 않습니다(${e.policy ? policyText(e.policy) : '글자 수 부족'})`, login_taken: '이미 있는 로그인 ID 입니다', account_limit_reached: `계정 수가 시스템 제한(LIMIT_ACCOUNTS=${e.limit})에 이르렀습니다` };
console.error('실패:', messages[e.message] || e.message); process.exitCode = 1;
} finally { db.close(); }
+76
View File
@@ -0,0 +1,76 @@
export const json = (obj, status = 200, headers = {}) => new Response(JSON.stringify(obj), {
status, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers },
});
export const bad = (error, status = 400, extra = {}) => json({ ok: false, error, ...extra }, status);
export const now = () => new Date().toISOString();
export const plus = (ms) => new Date(Date.now() + ms).toISOString();
export const uid = () => crypto.randomUUID();
export const clean = (v, max = 500) => String(v ?? '').trim().slice(0, max);
export const isEmail = (v) => /^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(v);
export const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
export function address(value) { const s = String(value || '').trim().toLowerCase(); return (s.match(/<([^<>]+)>$/)?.[1] || s).trim(); }
/**
* 주소 목록 문자열("A <a@x>, b@y; c@z")을 소문자 주소 배열로 바꿉니다(중복 제거).
* 쉼표·세미콜론·줄바꿈으로 나누되, 따옴표 안("Kim, Minsu" <k@x>)과 꺾쇠 안의 구분자는 무시합니다.
* 예전에는 따옴표 안의 쉼표에서도 잘라 '"Kim' 과 'Minsu" <k@x>' 두 개로 나뉘어 저장·발송이 실패했습니다.
*/
export function addresses(value) {
if (Array.isArray(value)) return [...new Set(value.map(address).filter(Boolean))];
const list = []; let current = '', quoted = false, angle = false;
for (const ch of String(value || '')) {
if (ch === '"') quoted = !quoted; else if (ch === '<' && !quoted) angle = true; else if (ch === '>' && !quoted) angle = false;
if (!quoted && !angle && /[,;\n]/.test(ch)) { list.push(current); current = ''; } else current += ch;
}
list.push(current);
return [...new Set(list.map(address).filter(Boolean))];
}
export function secret(bytes = 32) {
return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString('base64url');
}
export async function sha256(text) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
return Buffer.from(buf).toString('hex');
}
export function safeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false;
let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0;
}
/**
* 프로세스 메모리 요청 제한. 단일 프로세스 전제입니다.
* - hit(): 시도를 1회 세고, 한도 안이면 true. 로그인 실패처럼 "세어야 할 사건"에만 부릅니다.
* - blocked(): 세지 않고 지금 막혀 있는지만 봅니다. 메일 서버(POP/IMAP/SMTP)는 로그인 전에 blocked() 로 확인하고 실패했을 때만 hit() 합니다.
* 예전에는 성공한 로그인까지 세어, 메일마다 새로 접속하는 메일 프로그램이 15분에 20통을 넘기면 정상 사용자도 차단됐습니다.
*/
export function memoryRateStore() {
const map = new Map();
return {
blocked(key, { limit = 5 } = {}) { const hit = map.get(key); return !!hit && hit.until >= Date.now() && hit.count >= limit; },
hit(key, { limit = 5, windowSec = 900 } = {}) {
const t = Date.now(); const hit = map.get(key);
if (!hit || hit.until < t) { map.set(key, { count: 1, until: t + windowSec * 1000 }); return true; }
if (hit.count >= limit) return false;
hit.count++; return true;
},
};
}
/** "이름 <주소>" 에서 표시 이름만(따옴표 제거). 이름이 없으면 ''. */
export function nameOf(value) { const s = String(value || '').trim(); const m = s.match(/^(.*?)\s*<[^<>]+>$/); return m ? m[1].trim().replace(/^"(.*)"$/, '$1').replace(/\\(["\\])/g, '$1') : ''; }
/**
* 보내는 사람 "이름 <주소>" 를 RFC 5322 에 맞게 만듭니다(0.9.0~). 줄바꿈·따옴표·꺾쇠는 이름에서 지우고, 쉼표·괄호·@ 같은 특수문자가 있으면
* 이름을 따옴표로 감쌉니다. 이름이 비어 있으면 주소만. 예전에는 계정 이름을 그대로 이어 붙여 "Lee, Director <a@x>" 처럼 깨진 헤더가 나갔습니다.
*/
export function formatSender(name, addr) {
const n = String(name || '').replace(/[\r\n"<>\\]/g, '').trim();
if (!n) return addr;
return /[,()@;:.\[\]]/.test(n) ? `"${n}" <${addr}>` : `${n} <${addr}>`;
}
/**
* 메일 도메인에서 화면 왼쪽 위에 보일 서비스 이름을 만듭니다(0.8.25~). 계정@ 바로 뒤의 첫 이름표를 대문자로 쓰고 " 메일" 을 붙입니다.
* heart-m.com → "HEART-M 메일", cafe.heart-m.com → "CAFE 메일", arihakdang.com → "ARIHAKDANG 메일"
* 예전에는 lang/ko.json 의 appName("아리학당 메일")이 모든 설치에 그대로 보였습니다. 도메인이 비어 있으면 기본 문구(fallback)를 돌려줍니다.
*/
export function siteName(domain, fallback = '메일') {
const label = String(domain || '').trim().toLowerCase().split('.')[0];
return label ? `${label.toUpperCase()} 메일` : fallback;
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Resend 웹훅 서명 검증(Svix 방식). 서명 대상은 "{svix-id}.{svix-timestamp}.{원문 본문}" 이고
* 비밀키는 "whsec_" 뒤의 base64 를 풀어 HMAC-SHA256 키로 씁니다. 헤더에는 "v1,<base64>" 가 공백으로 여러 개 올 수 있습니다.
*/
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifySvix(secret, headers, rawBody, { toleranceSec = 300, nowMs = Date.now() } = {}) {
const id = headers['svix-id'], ts = headers['svix-timestamp'], sig = headers['svix-signature'];
if (!secret || !id || !ts || !sig) return { ok: false, error: 'missing_signature' };
const t = Number(ts); if (!Number.isFinite(t) || Math.abs(nowMs / 1000 - t) > toleranceSec) return { ok: false, error: 'timestamp_out_of_range' };
const key = Buffer.from(String(secret).replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${ts}.`).update(rawBody).digest();
for (const candidate of String(sig).split(/\s+/)) {
const [version, value] = candidate.split(',');
if (version !== 'v1' || !value) continue;
const given = Buffer.from(value, 'base64');
if (given.length === expected.length && timingSafeEqual(given, expected)) return { ok: true };
}
return { ok: false, error: 'bad_signature' };
}
/** 테스트·문서용: 같은 규칙으로 서명을 만듭니다. */
export function signSvix(secret, id, timestamp, rawBody) {
const key = Buffer.from(String(secret).replace(/^whsec_/, ''), 'base64');
return 'v1,' + createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest('base64');
}
@@ -0,0 +1,87 @@
#!/usr/bin/env node
/**
* 웹메이커(WebHosting_AI_agent, 이하 webmaker) 데이터 디렉터리를 읽기 전용으로 읽어 "계정 스냅샷" 하나로 만듭니다(1.0.8, 요구사항 1).
*
* 이 파일은 두 가지로 쓰입니다.
* 1) 모듈: server/webmaker.mjs 가 readSnapshotSync(dir) 를 불러 같은 프로세스에서 바로 읽습니다(파일을 읽을 권한이 있을 때, 시험에서도 이 길).
* 2) 명령: `node webmaker-snapshot.mjs <DATA_ROOT>` 가 스냅샷을 JSON 으로 표준 출력에 씁니다. webmaker 의 데이터는 보통 그 서비스 계정(예: master)만
* 읽을 수 있는 0600/0700 이라, AriMail 실행 계정은 sudo 로 그 계정이 되어 이 명령을 부릅니다(deploy/webmaker-access.sh 가 root 소유 사본과 sudoers 규칙을 설치).
* 그래서 이 파일은 다른 모듈을 import 하지 않고 node 내장 모듈만 씁니다(root 소유 사본 하나로 혼자 돌아가야 함).
*
* webmaker 의 저장 형식(그 저장소 lib/auth.js · lib/store.js · lib/modules.js · lib/domains.js 기준)
* - <DATA_ROOT>/users.json : { <아이디>: { id, name, salt, passHash, role:'admin'|'user', accountType, … } } — 플랫폼 회원. 비밀번호는 scryptSync(비밀번호, salt, 64) 의 16진수.
* - <DATA_ROOT>/sites/<32hex>.json : { id, ownerId, title, domains:[…], webmail:{ enabled, domain }, … } — 사이트. domains 는 사이트에 연결한 도메인 목록.
* - <DATA_ROOT>/modules/<32hex>.json : { members:[ { id, loginId?, email?, name, role:'admin'|'member', approved, salt, hash, mail, mailAddress } ], … } — 사이트 회원.
* loginId 가 있는 회원이 "사이트 관리자 계정"(사이트 관리자 페이지 › 계정 추가)이고, 비밀번호는 같은 scrypt 형식(hash 필드).
* - <DATA_ROOT>/domains.json : { domains:[ { name, type:'subdomain'|'registered', siteId, userId, … } ] } — 서브 도메인·구입 도메인 등록부(사이트 연결 정보).
*
* 스냅샷에는 인증에 필요한 값(salt·해시)이 들어 있으므로 파일로 남기지 않고 메모리에서만 씁니다. 게시글·문의 같은 다른 자료는 읽지 않습니다.
*/
import { readFileSync, readdirSync, realpathSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const SNAPSHOT_FORMAT = 1;
const ID_RE = /^[a-f0-9]{32}$/;
const str = (v, max = 200) => String(v ?? '').trim().slice(0, max);
/**
* JSON 파일 읽기. 파일이 없으면 null(아직 만들지 않은 것), 그 밖의 실패(권한 없음, 쓰는 중이라 반쪽인 JSON 등)는 예외입니다.
* 왜 구분하나: 깨진 파일을 "회원 없음" 으로 읽으면 그 순간 모든 웹메이커 사용자의 세션이 끊깁니다. 예외로 두면 webmaker.mjs 가 마지막 정상 스냅샷으로 버팁니다.
*/
function readJson(file) {
let text; try { text = readFileSync(file, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return null; throw new Error(`${e.code || 'read_error'}: ${file}`); }
try { return JSON.parse(text); } catch { throw new Error(`invalid_json: ${file}`); }
}
/** 디렉터리의 <32hex>.json 파일 이름(확장자 뺀 id)들. 디렉터리가 없으면 빈 목록. */
function idsIn(dir) {
try { return readdirSync(dir).filter((f) => f.endsWith('.json') && ID_RE.test(f.slice(0, -5))).map((f) => f.slice(0, -5)); } catch { return []; }
}
/**
* webmaker 데이터 디렉터리를 읽어 스냅샷을 만듭니다.
* @param {string} dir webmaker 의 DATA_ROOT(예: /data/webmaker)
* @returns {{format:number, readAt:string, dir:string, users:object, sites:object, members:object, domainSites:object}}
* users : 아이디 → { id, name, role, salt, passHash, mustChangePassword }
* sites : 사이트 id → { id, ownerId, title, domains:string[], webmailDomain:string, webmailEnabled:boolean }
* members : 사이트 id → [ { id, loginId, email, name, role, approved, salt, hash, mail, mailAddress } ] (loginId 또는 email 이 있는 회원만)
* domainSites : 도메인 이름 → 사이트 id (domains.json 에서 사이트에 연결된 것만)
*/
export function readSnapshotSync(dir) {
const root = String(dir || '').trim(); if (!root) throw new Error('webmaker_dir_missing');
const users = {};
const rawUsers = readJson(join(root, 'users.json'));
if (rawUsers && typeof rawUsers === 'object') {
for (const [id, u] of Object.entries(rawUsers)) {
if (!u || typeof u !== 'object' || !u.salt || !u.passHash) continue;
const key = str(u.id || id, 64).toLowerCase();
users[key] = { id: key, name: str(u.name, 80), role: u.role === 'admin' ? 'admin' : 'user', salt: String(u.salt), passHash: String(u.passHash), mustChangePassword: !!u.mustChangePassword };
}
}
const sites = {}, members = {};
for (const id of idsIn(join(root, 'sites'))) {
const s = readJson(join(root, 'sites', id + '.json')); if (!s || typeof s !== 'object') continue;
const domains = (Array.isArray(s.domains) ? s.domains : []).map((d) => str(d, 253).toLowerCase().replace(/^www\./, '')).filter(Boolean);
sites[id] = { id, ownerId: str(s.ownerId, 64).toLowerCase(), title: str(s.title, 120), domains, webmailDomain: str(s.webmail?.domain, 253).toLowerCase(), webmailEnabled: !!(s.webmail && s.webmail.enabled) };
const m = readJson(join(root, 'modules', id + '.json'));
const list = m && Array.isArray(m.members) ? m.members : [];
members[id] = list.filter((x) => x && typeof x === 'object' && (x.loginId || x.email) && x.salt && x.hash).map((x) => ({
id: str(x.id, 64), loginId: str(x.loginId, 64).toLowerCase(), email: str(x.email, 200).toLowerCase(), name: str(x.name, 80), role: x.role === 'admin' ? 'admin' : 'member',
approved: x.approved !== false, salt: String(x.salt), hash: String(x.hash), mail: !!x.mail, mailAddress: str(x.mailAddress, 253).toLowerCase(),
}));
}
const domainSites = {};
const rawDomains = readJson(join(root, 'domains.json'));
for (const d of Array.isArray(rawDomains?.domains) ? rawDomains.domains : []) {
const name = str(d?.name, 253).toLowerCase(), siteId = str(d?.siteId, 32);
if (name && ID_RE.test(siteId)) domainSites[name] = siteId;
}
return { format: SNAPSHOT_FORMAT, readAt: new Date().toISOString(), dir: root, users, sites, members, domainSites };
}
// 명령줄로 직접 실행했을 때(sudo 도우미): 첫 인수가 데이터 디렉터리. 실패하면 종료 코드 2 와 오류 한 줄(표준 오류).
const samePath = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return a === b; } };
if (process.argv[1] && samePath(fileURLToPath(import.meta.url), process.argv[1])) {
try { process.stdout.write(JSON.stringify(readSnapshotSync(process.argv[2]))); }
catch (e) { process.stderr.write(`webmaker-snapshot: ${e.message}\n`); process.exit(2); }
}
+207
View File
@@ -0,0 +1,207 @@
/**
* webmaker 인증 모드(1.0.8, 요구사항 1) — 웹메이커(WebHosting_AI_agent)의 사용자 계정과 인증을 AriMail 이 그대로 씁니다.
*
* 왜
* 웹메이커는 사이트(예: site.ariweb.net)를 만들고 그 사이트의 관리자 계정·회원을 둡니다. 그 사용자들에게 메일을 열어 주려면 예전에는 웹메이커가 AriMail 관리자
* API 로 계정을 따로 만들고 메일 비밀번호를 따로 정해야 했습니다(웹메이커는 scrypt 해시만 갖고 있어 원문 비밀번호를 넘길 수 없음). 이제 AriMail 이 웹메이커의
* 사용자 파일을 **읽기 전용**으로 참조해 같은 방식(scrypt)으로 비밀번호를 검사하므로, 웹메이커 계정 그대로 웹 메일·IMAP·POP·SMTP 에 로그인합니다.
*
* 도메인 ↔ 웹메이커 계정 대응(accountsFor)
* 도메인(테넌트)의 메일 도메인(MAILBOX 의 @ 뒤)으로 웹메이커의 사이트를 찾습니다: 등록부 항목의 siteId 가 있으면 그 사이트, 없으면 domains.json(서브 도메인 등록) →
* 사이트의 domains 목록 → 사이트의 메일 도메인(webmail.domain) 순으로 같은 이름을 찾습니다.
* - 사이트가 있으면: 그 사이트의 회원 중 로그인 ID(loginId)가 있는 계정(사이트 관리자 페이지에서 추가한 "사용자") → `loginId@도메인`(역할 user),
* 사이트 소유자(users.json 의 ownerId) → `소유자ID@도메인`(역할 user; 소유자가 웹메이커 관리자이기도 하면 admin), 웹메이커 관리자(role=admin, 보통 admin) → `admin@도메인`(역할 admin).
* 같은 로그인 ID 가 겹치면 사이트 회원 → 소유자 → 플랫폼 관리자 순으로 먼저 찾은 쪽이 그 주소를 씁니다.
* - 사이트가 없으면(플랫폼 도메인 자체 등): 웹메이커의 모든 회원 → `아이디@도메인`(관리자는 admin, 나머지는 user).
* 요구사항의 예: 웹메이커에서 site.ariweb.net 을 만들고 메일을 허용하면 admin@site.ariweb.net(웹메이커 admin)으로 수·발신하고, 사이트에 사용자를 추가하면
* user@site.ariweb.net 도 같은 비밀번호로 씁니다. 이 규칙은 모든 도메인(기본·추가)에 똑같이 적용됩니다.
*
* 파일 읽기(snapshot)
* 웹메이커 데이터(users.json 0600, DATA_ROOT 0700)는 보통 웹메이커 서비스 계정만 읽을 수 있습니다. 같은 계정이거나 읽기 권한이 있으면 이 프로세스가 바로 읽고(readSnapshotSync),
* 아니면 등록부의 webmakerUser(예: master)가 적힌 도메인은 "읽기 도우미" 로 JSON 을 받습니다(deploy/webmaker-access.sh 가 설치):
* 1) systemd 소켓(/run/arimail-webmaker/<디렉터리 슬러그>.sock): 접속마다 그 계정으로 webmaker-snapshot.mjs 를 띄워 결과를 소켓에 씁니다(Accept=yes).
* AriMail 서비스는 NoNewPrivileges·CapabilityBoundingSet 로 잠겨 있어 sudo 를 쓸 수 없으므로 이것이 기본 경로입니다(ProtectSystem=strict 에서도 소켓 connect 는 됨).
* 2) sudo 도우미(`sudo -n -u <계정> /usr/local/lib/arimail/webmaker-snapshot <디렉터리>`): systemd 가 없는 서버용 대안(소켓 파일이 없을 때).
* 스냅샷은 3초 캐시하고, 로그인 실패 때는 한 번 다시 읽어
* 방금 바뀐 비밀번호·새 계정을 바로 반영합니다. 읽기에 실패하면 마지막 성공 스냅샷으로 계속 동작하며 오류를 기록합니다(웹메이커 쪽 일시 장애로 메일이 멈추지 않게).
*
* 주체(principal) id: 'wm:u:<아이디>'(플랫폼 회원), 'wm:m:<사이트 id>:<회원 id>'(사이트 회원). 세션·설정(prefs)·메일함 크기(quota)는 이 id 로 저장되며, 비밀번호·이름·역할은
* 웹메이커가 주인이므로 AriMail 에서 바꿀 수 없습니다(관리자 페이지에는 읽기 전용 행으로 보이고 POP/IMAP 허용·메일함 크기만 바꿉니다).
*/
import { execFile } from 'node:child_process';
import { connect } from 'node:net';
import { existsSync } from 'node:fs';
import { scrypt, timingSafeEqual, randomBytes } from 'node:crypto';
import { readSnapshotSync } from './webmaker-snapshot.mjs';
/** sudo 도우미 경로(deploy/webmaker-access.sh 가 설치). sudoers 규칙은 이 경로 + 데이터 디렉터리 인수 하나만 허용합니다. */
export const WEBMAKER_HELPER = '/usr/local/lib/arimail/webmaker-snapshot';
/** systemd 소켓 도우미의 소켓 파일: 데이터 디렉터리마다 하나(/data/webmaker → /run/arimail-webmaker/data-webmaker.sock). webmaker-access.sh 와 같은 규칙. */
export const webmakerSocketPath = (dir) => '/run/arimail-webmaker/' + String(dir || '').replace(/^\/+/, '').replace(/[^A-Za-z0-9]/g, '-') + '.sock';
const CACHE_MS = 3000, FRESH_MIN_MS = 2000;
/** 메일 주소의 @ 앞부분으로 쓸 수 있는 로그인 ID(웹메이커 lib/mailsys.js 의 LOCAL_RE 와 같은 규칙). */
const LOCAL_RE = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
/** 이 env(도메인)가 webmaker 인증 모드인지. LOGIN_MODE=webmaker 와 데이터 디렉터리(WEBMAKER_DIR)가 모두 있어야 합니다. */
export const isWebmaker = (env) => String(env?.LOGIN_MODE || '') === 'webmaker' && !!env?.WEBMAKER_DIR;
const mailDomainOf = (env) => String(env.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase().split('@')[1] || '';
// ------------------------------------------------------------------ 비밀번호(scrypt)
/**
* 웹메이커와 같은 방식으로 비밀번호를 검사합니다: scrypt(비밀번호, salt 문자열, 64바이트, 기본 매개변수 N=16384·r=8·p=1) 의 16진수를 저장값과 상수 시간 비교.
* 웹메이커(lib/auth.js hashPassword / lib/modules.js hashPw)는 crypto.scryptSync(pw, salt, 64) 를 쓰며 salt 는 16진수 "문자열" 그대로 입력합니다(디코딩하지 않음).
*/
export function verifyScrypt(password, salt, storedHex) {
return new Promise((resolve) => {
if (!salt || !storedHex) return resolve(false);
scrypt(String(password ?? ''), String(salt), 64, (err, key) => {
if (err) return resolve(false);
let stored; try { stored = Buffer.from(String(storedHex), 'hex'); } catch { return resolve(false); }
resolve(stored.length === key.length && timingSafeEqual(stored, key));
});
});
}
// 없는 계정의 로그인에도 실제 scrypt 를 한 번 돌려 걸리는 시간을 맞춥니다(계정 존재 여부가 응답 시간으로 새지 않게).
const DUMMY_SALT = randomBytes(16).toString('hex');
const dummyVerify = () => verifyScrypt(randomBytes(8).toString('hex'), DUMMY_SALT, '00'.repeat(64));
// ------------------------------------------------------------------ 스냅샷 읽기(캐시·sudo 도우미)
const cache = new Map(); // "<dir>|<user>" → { at, data, loading }
/** sudo 도우미로 스냅샷을 받습니다(JSON 표준 출력). 실패 사유(권한 없음·도우미 없음·JSON 아님)는 오류 메시지에 담습니다. */
function viaHelper(dir, user) {
return new Promise((resolve, reject) => {
execFile('sudo', ['-n', '-u', user, WEBMAKER_HELPER, dir], { timeout: 15000, maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
if (err) return reject(new Error(`webmaker_helper_failed: ${String(stderr || err.message).trim().split('\n').pop()}`));
try { resolve(JSON.parse(stdout)); } catch { reject(new Error('webmaker_helper_bad_output')); }
});
});
}
/** systemd 소켓 도우미로 스냅샷을 받습니다: 접속하면 도우미가 JSON 을 쓰고 끊습니다(끝까지 읽어 파싱). */
function viaSocket(path) {
return new Promise((resolve, reject) => {
const sock = connect(path); let buf = '';
sock.setEncoding('utf8'); sock.setTimeout(15000, () => sock.destroy(new Error('timeout')));
sock.on('data', (d) => { buf += d; });
sock.on('end', () => { try { resolve(JSON.parse(buf)); } catch { reject(new Error('webmaker_socket_bad_output')); } });
sock.on('error', (e) => reject(new Error(`webmaker_socket_failed: ${e.message}`)));
});
}
async function fetchSnapshot(env) {
if (env.WEBMAKER_USER) {
// 소켓 도우미(시험에서는 WEBMAKER_SOCKET 으로 경로 지정)가 있으면 그것, 없으면 sudo 도우미.
const sock = env.WEBMAKER_SOCKET || webmakerSocketPath(env.WEBMAKER_DIR);
if (existsSync(sock)) return viaSocket(sock);
return viaHelper(env.WEBMAKER_DIR, env.WEBMAKER_USER);
}
return readSnapshotSync(env.WEBMAKER_DIR);
}
/**
* 스냅샷(캐시 3초). fresh 면 캐시를 무시하고 다시 읽습니다(로그인 실패 뒤 재확인용).
* 읽기에 실패했는데 예전 스냅샷이 있으면 그것을 돌려주고 경고를 남깁니다(한 번만). 예전 것도 없으면 오류를 던집니다 → 호출자는 "웹메이커 계정 없음" 으로 처리.
*/
export async function snapshot(env, { fresh = false } = {}) {
const key = `${env.WEBMAKER_DIR}|${env.WEBMAKER_USER || ''}`;
let c = cache.get(key); if (!c) { c = { at: 0, data: null, loading: null, warned: false, freshAt: 0 }; cache.set(key, c); }
// fresh(로그인 실패 뒤 재확인)는 디렉터리마다 FRESH_MIN_MS 에 한 번만 실제로 다시 읽습니다 — 틀린 비밀번호를 퍼부어 파일 읽기(sudo 도우미 실행)를 되풀이시키지 못하게.
if (fresh && Date.now() - c.freshAt < FRESH_MIN_MS) fresh = false;
if (!fresh && c.data && Date.now() - c.at < CACHE_MS) return c.data;
// 다시 읽기를 요구했는데 이미 읽는 중이면 그 결과(변경 전일 수 있음)를 기다린 뒤 한 번 더 읽습니다.
if (fresh && c.loading) { try { await c.loading; } catch { /* 아래에서 다시 시도 */ } }
if (fresh) c.freshAt = Date.now();
if (!c.loading) {
c.loading = fetchSnapshot(env).then((d) => { c.data = d; c.at = Date.now(); c.warned = false; return d; }).catch((e) => {
if (c.data) { if (!c.warned) { c.warned = true; console.error(` ⚠️ [${env.DOMAIN || mailDomainOf(env)}] 웹메이커 계정 파일을 읽지 못해 마지막 스냅샷을 씁니다: ${e.message}`); } c.at = Date.now(); return c.data; }
throw e;
}).finally(() => { c.loading = null; });
}
return c.loading;
}
/** 시험·재시작용: 캐시 비우기. */
export function clearSnapshotCache() { cache.clear(); }
// ------------------------------------------------------------------ 도메인의 계정 목록
/** 도메인에 대응하는 웹메이커 사이트를 찾습니다(없으면 null). env.WEBMAKER_SITE(등록부의 siteId)가 우선입니다. */
export function siteFor(snap, domain, siteId = '') {
if (siteId && snap.sites[siteId]) return snap.sites[siteId];
const d = String(domain || '').toLowerCase();
if (snap.domainSites[d] && snap.sites[snap.domainSites[d]]) return snap.sites[snap.domainSites[d]];
for (const s of Object.values(snap.sites)) if (s.domains.includes(d)) return s;
for (const s of Object.values(snap.sites)) if (s.webmailDomain === d) return s;
return null;
}
/**
* 도메인의 웹메이커 계정 목록(해시 포함 — 모듈 안에서만 씀). 같은 로그인 ID 는 먼저 넣은 쪽이 이깁니다(사이트 회원 → 소유자 → 플랫폼 관리자/회원).
* @returns {Array<{id,login,name,role,mailbox,kind,salt,hash,approved}>} kind: member(사이트 회원) | owner(사이트 소유자) | admin(웹메이커 관리자) | user(플랫폼 회원)
*/
export function accountsFor(env, snap) {
const domain = mailDomainOf(env), site = siteFor(snap, domain, env.WEBMAKER_SITE || '');
const out = [], seen = new Set();
const push = (a) => { if (!LOCAL_RE.test(a.login) || seen.has(a.login)) return; seen.add(a.login); out.push({ ...a, mailbox: `${a.login}@${domain}` }); };
// 역할: 웹메이커 관리자(role=admin)만 이 도메인의 관리자(/admin·/setup — 발신 모듈·DNS 인증 정보까지 다룸). 사이트 소유자·사용자·일반 회원은 일반 계정입니다(최소 권한).
const platform = (u, kind) => push({ id: 'wm:u:' + u.id, login: u.id, name: u.name || u.id, role: u.role === 'admin' ? 'admin' : 'user', kind, salt: u.salt, hash: u.passHash, approved: true });
if (site) {
for (const m of snap.members[site.id] || []) if (m.loginId) push({ id: `wm:m:${site.id}:${m.id}`, login: m.loginId, name: m.name || m.loginId, role: 'user', kind: 'member', salt: m.salt, hash: m.hash, approved: m.approved !== false });
if (site.ownerId && snap.users[site.ownerId]) platform(snap.users[site.ownerId], 'owner');
for (const u of Object.values(snap.users)) if (u.role === 'admin') platform(u, 'admin');
} else {
for (const u of Object.values(snap.users)) platform(u, 'user');
}
return out;
}
/** 로그인 입력(아이디 또는 아이디@이 도메인)을 로그인 ID 로. 다른 도메인의 주소는 null. */
function loginKey(env, login) {
const key = String(login || '').trim().toLowerCase(); if (!key) return null;
if (!key.includes('@')) return key;
const [local, dom] = key.split('@'); return dom === mailDomainOf(env) ? local : null;
}
const principal = (a) => ({ id: a.id, source: 'webmaker', login: a.login, email: a.mailbox, name: a.name, role: a.role, mailbox: a.mailbox, kind: a.kind });
/**
* 로그인 이름과 비밀번호로 웹메이커 계정을 검사합니다. 맞으면 주체(principal), 아니면 null.
* 캐시된 스냅샷에서 실패하면 파일을 다시 읽어 한 번 더 검사합니다(웹메이커에서 방금 바꾼 비밀번호·새 계정 반영). 스냅샷을 읽을 수 없으면 null(기록만).
*/
export async function verifyWebmaker(env, login, password) {
const key = loginKey(env, login); if (!key || !password) return null;
let snap; try { snap = await snapshot(env); } catch (e) { console.error(` ✖ [${env.DOMAIN || mailDomainOf(env)}] 웹메이커 계정 파일을 읽을 수 없습니다: ${e.message}`); await dummyVerify(); return null; }
// 계정이 있든 없든(승인 취소 포함) 시도마다 scrypt 를 꼭 한 번 돌려, 걸리는 시간으로 계정 존재 여부가 새지 않게 합니다(두 시도 모두 같은 횟수).
const check = async (a) => (a && a.approved ? verifyScrypt(password, a.salt, a.hash) : dummyVerify().then(() => false));
let a = accountsFor(env, snap).find((x) => x.login === key);
if (await check(a)) return principal(a);
// 두 번째 시도: 방금 바뀐 파일. 캐시가 아주 새것이어도(3초) 비밀번호 변경 직후의 로그인을 놓치지 않게 한 번은 다시 읽습니다.
try { snap = await snapshot(env, { fresh: true }); } catch { await dummyVerify(); return null; }
a = accountsFor(env, snap).find((x) => x.login === key);
if (await check(a)) return principal(a);
return null;
}
/**
* 주체 id('wm:…')로 계정을 찾습니다(세션 확인용). 웹메이커에서 지워졌거나 승인이 풀린 계정은 null → 세션 종료.
* 파일을 읽지 못한 경우(예전 스냅샷도 없음)는 예외를 그대로 던집니다 — auth.currentUser 가 "일시 장애" 로 보고 세션을 지우지 않습니다.
*/
export async function webmakerPrincipalById(env, id) {
const snap = await snapshot(env);
const a = accountsFor(env, snap).find((x) => x.id === id && x.approved);
return a ? principal(a) : null;
}
/** 메일함 주소로 웹메이커 계정을 찾습니다(자동 처리·수신 판단용). */
export async function webmakerByMailbox(env, mailbox) {
let snap; try { snap = await snapshot(env); } catch { return null; }
const a = accountsFor(env, snap).find((x) => x.mailbox === String(mailbox || '').toLowerCase() && x.approved);
return a ? principal(a) : null;
}
/** 웹메이커 계정의 메일함 주소 목록(수신 허용 판단). 승인이 풀린 회원의 메일함도 넣습니다(정지된 로컬 계정과 같은 취급: 메일은 받아 둠). */
export async function webmakerMailboxes(env) {
let snap; try { snap = await snapshot(env); } catch { return []; }
return accountsFor(env, snap).map((a) => a.mailbox);
}
/** 관리자 페이지·설정 화면용 목록(해시 없음). status 는 승인 여부로 active / suspended. */
export async function webmakerAccounts(env) {
let snap; try { snap = await snapshot(env); } catch (e) { return { ok: false, error: e.message, accounts: [] }; }
const site = siteFor(snap, mailDomainOf(env), env.WEBMAKER_SITE || '');
return { ok: true, readAt: snap.readAt, site: site ? { id: site.id, title: site.title, ownerId: site.ownerId } : null, accounts: accountsFor(env, snap).map((a) => ({ id: a.id, login: a.login, name: a.name, mailbox: a.mailbox, role: a.role, kind: a.kind, status: a.approved ? 'active' : 'suspended', source: 'webmaker' })) };
}
/** 로그인 ID 가 웹메이커 계정과 겹치는지(로컬 계정을 만들 때 login_taken 으로 막기 위해). */
export async function webmakerHasLogin(env, login) {
let snap; try { snap = await snapshot(env); } catch { return false; }
const key = String(login || '').toLowerCase();
return accountsFor(env, snap).some((a) => a.login === key);
}
+45
View File
@@ -0,0 +1,45 @@
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { openDb, d1, migrate } from '../server/db.mjs';
import { hashPassword } from '../server/auth.mjs';
import { memoryRateStore } from '../server/util.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
export async function makeWwwDb({ login = 'arihakdang', password = 'admin-secret-12', email = 'admin@arihakdang.com', role = 'admin', status = 'active' } = {}) {
const db = openDb(':memory:');
db.exec('CREATE TABLE users(id TEXT PRIMARY KEY,email TEXT UNIQUE,login_id TEXT,name TEXT,role TEXT,status TEXT,password_hash TEXT)');
db.prepare('INSERT INTO users VALUES(?,?,?,?,?,?,?)').run('admin-1', email, login, '관리자', role, status, await hashPassword(password, 1000));
db.prepare('INSERT INTO users VALUES(?,?,?,?,?,?,?)').run('student-1', 'student@example.test', 'student', '학생', 'student', 'active', await hashPassword(password, 1000));
return db;
}
export async function makeEnv(over = {}) {
const db = openDb(':memory:'); migrate(db, join(HERE, '..', 'migrations'), { quiet: true });
return { DB: d1(db), RATE: memoryRateStore(), WWW_DB: await makeWwwDb(), MAILBOX: 'admin@arihakdang.com', RESEND_API_KEY: 'test', POP_AUTH_DELAY_MS: 0, ...over };
}
export function runner() {
let pass = 0, fail = 0;
const t = async (name, fn) => { try { await fn(); console.log(' ok ' + name); pass++; } catch (e) { console.log(' FAIL ' + name + ' — ' + (e.stack || e.message)); fail++; } };
const done = () => { console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); };
return { t, done };
}
export const eq = (a, b, m) => { if (a !== b) throw new Error(`${m}: ${JSON.stringify(a)} !== ${JSON.stringify(b)}`); };
export const ok = (v, m) => { if (!v) throw new Error(m); };
/** Resend 흉내: 수신 목록/상세/첨부/원본 다운로드/발송. */
export function fakeResend({ received = [], sent = [], raw = {}, attachments = {}, calls = [], failStatus = 0 } = {}) {
return async (url, options = {}) => {
const u = new URL(url), path = u.pathname; const method = options.method || 'GET';
calls.push({ path, method, body: options.body ? JSON.parse(options.body) : undefined, key: options.headers?.['Idempotency-Key'] });
if (failStatus) return Response.json({ message: 'API key is invalid' }, { status: failStatus });
if (path === '/domains') return Response.json({ data: [{ name: 'arihakdang.com', status: 'verified' }] });
if (path === '/emails' && method === 'POST') { const id = 'sent-' + calls.length; sent.push({ id, ...JSON.parse(options.body), created_at: new Date().toISOString() }); return Response.json({ id }); }
if (path === '/emails/receiving') return Response.json({ data: received.map(({ html, text, headers, ...s }) => s), has_more: false });
if (path === '/emails') return Response.json({ data: sent, has_more: false });
let m;
if ((m = path.match(/^\/emails\/receiving\/([^/]+)\/attachments$/))) return Response.json({ data: (attachments[m[1]] || []), has_more: false });
if ((m = path.match(/^\/emails\/receiving\/([^/]+)$/))) { const item = received.find((r) => r.id === m[1]); return item ? Response.json({ ...item, raw: raw[item.id] ? { download_url: 'https://files.test/raw/' + item.id, expires_at: '2099-01-01' } : null }) : Response.json({ message: 'not found' }, { status: 404 }); }
if ((m = path.match(/^\/emails\/([^/]+)$/))) { const item = sent.find((r) => r.id === m[1]); return item ? Response.json(item) : Response.json({}, { status: 404 }); }
if (u.hostname === 'files.test') { const id = path.split('/').pop(); if (path.startsWith('/raw/')) return new Response(raw[id]); const a = Object.values(attachments).flat().find((x) => x.id === id); return new Response(a?.bytes || 'file-bytes'); }
return Response.json({ data: [], has_more: false });
};
}
export const request = (path, cookie, method = 'GET', body, extraHeaders = {}) => new Request('https://mail.arihakdang.com/api/' + path, { method, headers: { ...(cookie ? { Cookie: cookie } : {}), ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...extraHeaders }, body: body instanceof FormData ? body : body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body) });
export const params = (path, ip = '127.0.0.1') => ({ path: path.split('?')[0].split('/').filter(Boolean), ip });
+185
View File
@@ -0,0 +1,185 @@
/**
* 1.0.0 시험: 설정 파일(config.mjs)·발송 모듈 선택(providers)·SMTP 중계 클라이언트·내장 Haraka 수신 전달 API·초기 설정 API.
* 실행: node test/config.test.mjs (npm test 와 deploy.sh 가 함께 돌립니다)
*/
import net from 'node:net';
import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { makeEnv, runner, eq, ok, fakeResend } from './_helpers.mjs';
import { handle } from '../server/api.mjs';
import { loadConfig, saveConfig, publicConfig, applyConfig, configFile, setupToken, generateDkim, dnsRecords, coerceValue, CONFIG_SCHEMA, maskSecret } from '../server/config.mjs';
import { sendMail, outboundMode, inboundMode, relaySettings, outboundStatus, usesResend, usesHaraka } from '../server/providers/index.mjs';
import { smtpSend, smtpProbe, smtpErrorCode } from '../server/providers/smtp-client.mjs';
import { payloadToEml } from '../server/providers/smtp.mjs';
import { setupApi, createSetupServer, harakaStatus } from '../server/setup.mjs';
import { sendDraft, saveDraft } from '../server/store.mjs';
import { createSession } from '../server/auth.mjs';
const { t, done } = runner();
const api = (env, path, cookie, method = 'GET', body, extraHeaders = {}) => {
const headers = { ...(cookie ? { cookie } : {}), ...extraHeaders };
let payload = body;
if (body !== undefined && !(body instanceof FormData) && typeof body !== 'string' && !Buffer.isBuffer(body)) { payload = JSON.stringify(body); headers['content-type'] = 'application/json'; }
return handle(env, new Request('https://mail.arihakdang.com/api/' + path, { method, headers, body: payload }), { path: path.split('?')[0].split('/').filter(Boolean), ip: '127.0.0.1' });
};
const adminLogin = async (env) => { const r = await api(env, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' }, { 'x-mail-scope': 'admin' }); return r.headers.get('set-cookie').split(';')[0]; };
const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-cfg-'));
/**
* 시험용 SMTP 서버: EHLO/STARTTLS 없음/AUTH PLAIN/MAIL/RCPT/DATA 를 받아 기록합니다.
* @param {object} o rejectRcpt: 거부할 주소, authUser/authPass: 인증 요구, dataCode: DATA 끝 응답 코드
*/
function fakeSmtp({ rejectRcpt = [], authUser = '', authPass = '', dataCode = 250 } = {}) {
const sessions = [];
const server = net.createServer((socket) => {
const s = { from: null, rcpt: [], data: '', auth: null }; sessions.push(s); let inData = false, buf = '';
socket.write('220 fake.test ESMTP\r\n');
socket.on('data', (chunk) => {
buf += chunk.toString('latin1');
while (true) {
const i = buf.indexOf('\r\n'); if (i < 0) break; const line = buf.slice(0, i); buf = buf.slice(i + 2);
if (inData) { if (line === '.') { inData = false; socket.write(dataCode === 250 ? '250 2.0.0 Ok: queued as FAKE123\r\n' : `${dataCode} rejected by fake\r\n`); } else s.data += (line.startsWith('..') ? line.slice(1) : line) + '\r\n'; continue; }
const cmd = line.split(' ')[0].toUpperCase();
if (cmd === 'EHLO') socket.write('250-fake.test\r\n250-SIZE 10485760\r\n' + (authUser ? '250-AUTH PLAIN LOGIN\r\n' : '') + '250 8BITMIME\r\n');
else if (cmd === 'AUTH') { const [, user, pass] = Buffer.from(line.split(' ')[2] || '', 'base64').toString('utf8').split('\0'); s.auth = user; socket.write(user === authUser && pass === authPass ? '235 2.7.0 ok\r\n' : '535 5.7.8 authentication failed\r\n'); }
else if (cmd === 'MAIL') { s.from = line.match(/<([^>]*)>/)?.[1]; socket.write('250 ok\r\n'); }
else if (cmd === 'RCPT') { const a = line.match(/<([^>]*)>/)?.[1]; if (rejectRcpt.includes(a)) socket.write('550 5.1.1 no such user\r\n'); else { s.rcpt.push(a); socket.write('250 ok\r\n'); } }
else if (cmd === 'DATA') { inData = true; socket.write('354 go ahead\r\n'); }
else if (cmd === 'QUIT') { socket.write('221 bye\r\n'); socket.end(); }
else socket.write('250 ok\r\n');
}
});
});
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ port: server.address().port, sessions, close: () => server.close() })));
}
await t('1.0.0 설정 파일: 첫 시작에 환경 변수 값을 가져와 만들고, 파일 값이 환경 변수보다 우선하며, 저장은 즉시 env 에 적용되고 비밀값은 가려서 보여 준다', async () => {
const dir = tmp(); const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), RESEND_API_KEY: 're_oldkey_from_env_1234', MAIL_FROM_NAME: '예전 이름', STATS_TIMEZONE: 'Asia/Seoul' });
const cfg = loadConfig(env);
ok(existsSync(configFile(env)) && configFile(env) === join(dir, 'config.json'), '설정 파일이 DB 옆에 생김');
eq(cfg.RESEND_API_KEY, 're_oldkey_from_env_1234', '환경 변수 값 가져오기'); eq(cfg.inbound, 'resend', '기본 수신 resend'); eq(cfg.outbound, 'resend', '기본 발신 resend');
ok(cfg.HARAKA_TOKEN && cfg.HARAKA_TOKEN.length > 20, '내부 토큰 자동 생성'); ok(env.CONFIG_CREATED, '새로 만듦 표시');
ok(JSON.parse(readFileSync(configFile(env), 'utf8'))._meta.importedFromEnv.includes('MAIL_FROM_NAME'), '가져온 키 기록');
// 저장 → 즉시 적용 + 알림
let notified = 0; env.ON_CONFIG_CHANGE = () => notified++;
const pub = saveConfig(env, { RESEND_API_KEY: 're_newkey_abcdefgh_9876', outbound: 'smtp', SMTP_RELAY_HOST: 'smtp.example.test', SMTP_RELAY_PORT: '2525', SITE_TITLE: '새 제목' });
eq(env.RESEND_API_KEY, 're_newkey_abcdefgh_9876', '저장 즉시 env 반영'); eq(env.MAIL_OUTBOUND, 'smtp', '발신 방식'); eq(env.SITE_TITLE, '새 제목', '제목'); eq(notified, 1, '변경 알림');
eq(pub.values.RESEND_API_KEY, maskSecret('re_newkey_abcdefgh_9876'), '비밀값 가림'); ok(!pub.values.RESEND_API_KEY.includes('abcdefgh'), '중간 글자 숨김');
eq(pub.sources.RESEND_API_KEY, 'config', '출처 config'); eq(pub.values.SMTP_RELAY_PORT, 2525, '숫자 변환'); eq(pub.present.HARAKA_TOKEN, true, 'hidden 항목은 있음/없음만'); ok(!('HARAKA_TOKEN' in pub.values), 'hidden 값 비노출');
// 다시 읽으면 파일이 기준(환경 변수에 옛 값이 있어도)
const env2 = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), RESEND_API_KEY: 're_oldkey_from_env_1234', SITE_TITLE: '환경 제목' });
loadConfig(env2); eq(env2.RESEND_API_KEY, 're_newkey_abcdefgh_9876', '파일 값이 환경 변수보다 우선'); eq(env2.SITE_TITLE, '새 제목', '제목도 파일 우선'); ok(!env2.CONFIG_CREATED, '다시 만들지 않음');
// 비우기: 파일에 '' 로 저장되면 환경 변수 값이 되살아나지 않음
saveConfig(env2, { SITE_TITLE: '' }); eq(env2.SITE_TITLE, '', '비운 값은 빈 값'); eq(publicConfig(env2).sources.SITE_TITLE, 'default', '비운 값은 환경 변수로 되살아나지 않음(출처 default)');
// 검증
let err = null; try { saveConfig(env2, { outbound: 'pigeon' }); } catch (e) { err = e; } eq(err?.message, 'validation', '모르는 발신 방식 거부'); eq(err?.field, 'outbound', '필드 이름');
err = null; try { saveConfig(env2, { HARAKA_PORT: '25a' }); } catch (e) { err = e; } eq(err?.message, 'validation', '숫자 아님 거부');
eq(coerceValue(CONFIG_SCHEMA.find((i) => i.key === 'SETUP_DONE'), '1'), true, 'bool 변환');
// 숨김 항목은 일반 저장으로 바꿀 수 없음
saveConfig(env2, { HARAKA_TOKEN: 'hacked' }); ok(env2.HARAKA_TOKEN !== 'hacked', 'hidden 항목 보호');
// 토큰 파일
const tok = setupToken(env2); eq(setupToken(env2), tok, '토큰 고정'); ok(existsSync(join(dir, 'setup-token')), '토큰 파일');
// DKIM
const dns = generateDkim(env2); ok(dns.hasDkim && dns.records.some((r) => r.name.startsWith('arimail._domainkey.') && r.value.startsWith('v=DKIM1; k=rsa; p=')), 'DKIM 레코드');
ok(env2.HARAKA_DKIM_PRIVATE.includes('BEGIN RSA PRIVATE KEY'), '비밀키 저장'); const pub1 = env2.HARAKA_DKIM_PUBLIC; generateDkim(env2); eq(env2.HARAKA_DKIM_PUBLIC, pub1, 'force 없이는 유지'); generateDkim(env2, { force: true }); ok(env2.HARAKA_DKIM_PUBLIC !== pub1, 'force 로 교체');
ok(dnsRecords(env2).records.find((r) => r.kind === 'MX').value.includes('mail.arihakdang.com'), 'MX 는 호스트 이름');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.0 발송 모듈 선택: resend 는 Resend API, smtp 는 중계 서버(봉투에 Bcc, 헤더에는 없음, 점 스터핑, 일부 거부·전체 거부·인증), haraka 는 로컬 제출 포트', async () => {
const calls = [], env = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) });
applyConfig(env, { inbound: 'resend', outbound: 'resend', HARAKA_TOKEN: 'x' });
eq(outboundMode(env), 'resend', '기본 resend'); ok(usesResend(env) && !usesHaraka(env), '사용 여부');
const payload = { from: 'Admin <admin@arihakdang.com>', to: ['a@example.test'], cc: ['c@example.test'], bcc: ['b@example.test'], subject: '제목 한글', text: '본문\n.점 줄\n끝', html: '<p>본문</p>', attachments: [{ filename: '첨부.txt', content: Buffer.from('hello').toString('base64'), content_type: 'text/plain' }] };
const r1 = await sendMail(env, payload, 'k1'); ok(r1.id && calls.some((c) => c.path === '/emails'), 'Resend 호출');
// smtp 중계
const fake = await fakeSmtp({ authUser: 'relayuser', authPass: 'relaypass' });
applyConfig(env, { inbound: 'resend', outbound: 'smtp', SMTP_RELAY_HOST: '127.0.0.1', SMTP_RELAY_PORT: fake.port, SMTP_RELAY_SECURITY: 'none', SMTP_RELAY_USER: 'relayuser', SMTP_RELAY_PASS: 'relaypass', HARAKA_TOKEN: 'x' });
eq(relaySettings(env).port, fake.port, '중계 설정'); const before = calls.length;
const r2 = await sendMail(env, payload, 'k2'); eq(calls.length, before, 'Resend 는 부르지 않음'); ok(r2.id.includes('@arihakdang.com'), 'Message-ID 가 id');
const s = fake.sessions.at(-1); eq(s.auth, 'relayuser', 'AUTH PLAIN'); eq(s.from, 'admin@arihakdang.com', '봉투 보낸 사람'); eq(JSON.stringify(s.rcpt), JSON.stringify(['a@example.test', 'c@example.test', 'b@example.test']), '봉투 받는 사람(To·Cc·Bcc)');
ok(s.data.includes('Subject: =?UTF-8?') && s.data.includes('To: a@example.test') && s.data.includes('Cc: c@example.test') && !/^Bcc:/mi.test(s.data), '헤더에 Bcc 없음'); ok(!/\r\n\.\./.test(s.data), '점 스터핑이 풀린 뒤 이중 점 없음'); ok(s.data.includes('Content-Disposition: attachment'), '첨부 포함');
const st = await outboundStatus(env, { force: true }); eq(st.mode, 'smtp', '상태 모드'); eq(st.provider, 'ok', '연결 확인'); eq(st.relay.auth, true, '인증 표시');
// 일부 거부 → 성공(나머지), 전체 거부 → mail_rejected
const fake2 = await fakeSmtp({ rejectRcpt: ['b@example.test'] });
applyConfig(env, { inbound: 'resend', outbound: 'smtp', SMTP_RELAY_HOST: '127.0.0.1', SMTP_RELAY_PORT: fake2.port, SMTP_RELAY_SECURITY: 'none', SMTP_RELAY_USER: '', SMTP_RELAY_PASS: '', HARAKA_TOKEN: 'x' });
const r3 = await sendMail(env, payload); eq(r3.rejected.length, 1, '일부 거부 기록'); eq(r3.accepted.length, 2, '나머지 발송');
let err = null; try { await sendMail(env, { ...payload, to: ['b@example.test'], cc: [], bcc: [] }); } catch (e) { err = e; } eq(err?.message, 'mail_rejected', '전체 거부'); ok(err.providerMessage.includes('550'), '서버 응답 보존');
// 인증 실패 / 접속 실패 / DATA 거부
const fake3 = await fakeSmtp({ authUser: 'u', authPass: 'p' });
err = null; try { await smtpSend({ host: '127.0.0.1', port: fake3.port, security: 'none', user: 'u', pass: 'wrong', from: 'a@x.test', to: ['b@x.test'], eml: Buffer.from('Subject: x\r\n\r\nbody\r\n') }); } catch (e) { err = e; } eq(err?.message, 'mail_auth_failed', '인증 실패 코드');
err = null; try { await smtpSend({ host: '127.0.0.1', port: 1, security: 'none', from: 'a@x.test', to: ['b@x.test'], eml: Buffer.from('x\r\n'), timeoutMs: 2000 }); } catch (e) { err = e; } eq(err?.message, 'mail_provider_unavailable', '접속 실패 코드');
const dotFake = await fakeSmtp(); await smtpSend({ host: '127.0.0.1', port: dotFake.port, security: 'none', from: 'a@x.test', to: ['b@x.test'], eml: Buffer.from('Subject: x\r\n\r\n.dot line\r\nend\r\n') }); ok(dotFake.sessions[0].data.includes('.dot line\r\nend'), '점으로 시작하는 줄 전송(점 스터핑)'); dotFake.close();
const fake4 = await fakeSmtp({ dataCode: 552 }); err = null; try { await smtpSend({ host: '127.0.0.1', port: fake4.port, security: 'none', from: 'a@x.test', to: ['b@x.test'], eml: Buffer.from('x\r\n') }); } catch (e) { err = e; } eq(err?.message, 'mail_rejected', 'DATA 거부');
eq(smtpErrorCode(451, 'data'), 'mail_rate_limited', '4xx 는 일시 오류'); eq((await smtpProbe({ host: '127.0.0.1', port: 1, security: 'none', timeoutMs: 1000 })).ok, false, '연결 확인 실패');
// haraka 모드: 127.0.0.1:HARAKA_RELAY_PORT
const fake5 = await fakeSmtp(); applyConfig(env, { inbound: 'resend', outbound: 'haraka', HARAKA_RELAY_PORT: fake5.port, HARAKA_TOKEN: 'x' });
eq(relaySettings(env).host, '127.0.0.1', '로컬 고정'); await sendMail(env, payload); eq(fake5.sessions.length, 1, 'Haraka 제출 포트로 전달'); eq(fake5.sessions[0].auth, null, '인증 없음');
// payloadToEml: 인라인 그림 cid
const { eml } = payloadToEml({ from: 'a@x.test', to: ['b@x.test'], subject: 's', html: '<img src="cid:pic">', attachments: [{ filename: 'p.png', content: Buffer.from('PNG').toString('base64'), content_type: 'image/png', content_id: 'pic' }] }, 'x.test');
ok(eml.toString().includes('Content-ID: <pic>') && eml.toString().includes('multipart/related'), '인라인 첨부');
// 웹 발송(sendDraft)도 같은 길을 씁니다
const fake6 = await fakeSmtp(); applyConfig(env, { inbound: 'resend', outbound: 'smtp', SMTP_RELAY_HOST: '127.0.0.1', SMTP_RELAY_PORT: fake6.port, SMTP_RELAY_SECURITY: 'none', SMTP_RELAY_USER: '', SMTP_RELAY_PASS: '', HARAKA_TOKEN: 'x' });
const draftId = await saveDraft(env, { to: 'friend@example.test', subject: '웹 발송', html: '<p>안녕</p>' }); const sent = await sendDraft(env, draftId);
eq(sent.state, 'sent', '발송 완료'); eq(fake6.sessions.length, 1, '중계 서버로'); ok(sent.provider_id.includes('@arihakdang.com'), 'provider_id 는 Message-ID');
for (const f of [fake, fake2, fake3, fake4, fake5, fake6]) f.close();
});
await t('1.0.0 Haraka 수신 전달 API: 토큰·수신 방식 확인, RCPT 조회, 원본 저장(봉투 수신자로 메일함 결정)·중복·사본·용량, 상태 기록', async () => {
const env = await makeEnv({}); applyConfig(env, { inbound: 'resend', outbound: 'resend', HARAKA_TOKEN: 'tok-secret-1234567890' });
const hdr = (extra = {}) => ({ 'x-arimail-token': 'tok-secret-1234567890', ...extra });
eq((await api(env, 'inbound/haraka/rcpt?addr=admin@arihakdang.com', '', 'GET', undefined, hdr())).status, 404, '수신 방식이 resend 면 닫힘');
applyConfig(env, { inbound: 'haraka', outbound: 'resend', HARAKA_TOKEN: 'tok-secret-1234567890' });
eq((await api(env, 'inbound/haraka/rcpt?addr=admin@arihakdang.com', '', 'GET', undefined, { 'x-arimail-token': 'wrong' })).status, 401, '토큰 틀림');
eq((await (await api(env, 'inbound/haraka/rcpt?addr=admin@arihakdang.com', '', 'GET', undefined, hdr())).json()).local, true, '우리 메일함');
eq((await (await api(env, 'inbound/haraka/rcpt?addr=nobody@arihakdang.com', '', 'GET', undefined, hdr())).json()).local, false, '없는 사용자');
const eml = 'From: Someone <someone@outside.test>\r\nTo: hidden@elsewhere.test\r\nSubject: =?UTF-8?B?' + Buffer.from('하라카').toString('base64') + '?=\r\nMessage-ID: <h1@outside.test>\r\nDate: Tue, 14 Oct 2026 10:00:00 +0900\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n하라카 본문\r\n';
const post = (id, rcpt, body = eml) => api(env, 'inbound/haraka', '', 'POST', Buffer.from(body), hdr({ 'content-type': 'message/rfc822', 'x-arimail-id': id, 'x-arimail-from': 'someone@outside.test', 'x-arimail-rcpt': JSON.stringify(rcpt) }));
const r1 = await (await post('tx-1', ['admin@arihakdang.com'])).json();
ok(r1.ok && r1.imported && r1.mailbox === 'admin@arihakdang.com', '봉투 수신자(Bcc 로 받은 메일)로 메일함 결정 — ' + JSON.stringify(r1));
const row = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(r1.id).first();
eq(row.folder, 'inbox', '받은 메일함'); eq(row.direction, 'inbound', '수신'); eq(row.provider_id, 'haraka:tx-1', 'provider_id'); eq(row.subject, '하라카', '제목 디코딩'); eq(row.raw_status, 'stored', '원본 그대로'); ok(!row.read_at, '읽지 않음'); eq(row.sender, 'Someone <someone@outside.test>', '보낸 사람');
const r2 = await (await post('tx-1', ['admin@arihakdang.com'])).json(); eq(r2.skipped, 'exists', '같은 트랜잭션 중복 저장 안 함');
eq((await (await post('tx-2', ['stranger@other.test'])).json()).skipped, 'other_mailbox', '우리 메일함 없음');
// 사본: 두 메일함에 함께 온 메일
const { createUser } = await import('../server/auth.mjs'); await createUser(env, { login: 'staff', name: '직원', password: 'staff-pass-123', role: 'member' });
const r3 = await (await post('tx-3', ['admin@arihakdang.com', 'staff@arihakdang.com'])).json(); eq(r3.copies?.length, 1, '다른 메일함 사본 — ' + JSON.stringify(r3)); eq(r3.copies[0].mailbox, 'staff@arihakdang.com', '사본 메일함');
// 상태 기록과 관리자 상태의 via_haraka
const events = (await env.DB.prepare("SELECT status, COUNT(*) n FROM webhook_events WHERE type='haraka.received' GROUP BY status ORDER BY status").all()).results;
eq(JSON.stringify(events), JSON.stringify([{ status: 'skipped', n: 1 }, { status: 'stored', n: 2 }]), '수신 기록(중복은 같은 id 로 덮어씀)');
const admin = await adminLogin(env); const st = await (await api(env, 'admin/status', admin, 'GET', undefined, { 'x-mail-scope': 'admin' })).json();
eq(st.storage.via_haraka, 3, 'Haraka 수신 통계(사본 포함)'); eq(st.mail.inbound, 'haraka', '상태의 수신 방식'); eq(st.mail.outbound, 'resend', '상태의 발신 방식');
eq((await api(env, 'inbound/haraka', '', 'POST', Buffer.alloc(0), hdr({ 'x-arimail-id': 'tx-4', 'x-arimail-rcpt': '["admin@arihakdang.com"]' }))).status, 400, '빈 본문 거부');
});
await t('1.0.0 초기 설정 API: 관리자 세션(/api/setup)과 전용 포트(토큰) 모두 같은 응답, 저장·연결 확인·DKIM·재시작 불가 안내, 토큰 오류 제한', async () => {
const dir = tmp(); const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), APP_VERSION: '1.0.0-test', PUBLIC_HOST: 'mail.arihakdang.com' }); loadConfig(env);
// 관리자 세션 없이·웹 메일 세션으로는 403
eq((await api(env, 'setup/config')).status, 401, '로그인 없음');
const admin = await adminLogin(env); const A = { 'x-mail-scope': 'admin' };
const d = await (await api(env, 'setup/config', admin, 'GET', undefined, A)).json();
ok(d.ok && d.values && d.schema.length && d.meta.host === 'mail.arihakdang.com' && d.meta.webhookUrl.endsWith('/api/webhooks/resend'), '설정 응답'); eq(d.meta.inbound, 'resend', '메타 수신'); ok(d.meta.dns.records.length >= 3, 'DNS 안내'); ok(d.meta.haraka && d.meta.haraka.installed === false, 'Haraka 미설치 표시');
const saved = await (await api(env, 'setup/config', admin, 'PUT', { values: { MAIL_FROM_NAME: '새 발신자', SETUP_DONE: true } }, A)).json();
eq(saved.values.MAIL_FROM_NAME, '새 발신자', '저장'); eq(saved.values.SETUP_DONE, true, '완료 표시'); eq(env.MAIL_FROM_NAME, '새 발신자', '즉시 적용');
const bad = await api(env, 'setup/config', admin, 'PUT', { values: { HARAKA_PORT: 'abc' } }, A); eq(bad.status, 422, '검증 실패'); eq((await bad.json()).field, 'HARAKA_PORT', '필드');
const chk = await (await api(env, 'setup/check', admin, 'POST', { what: 'outbound' }, A)).json(); eq(chk.outboundStatus.mode, 'resend', '연결 확인 응답');
const dk = await (await api(env, 'setup/dkim', admin, 'POST', {}, A)).json(); ok(dk.dns.hasDkim, 'DKIM 생성');
eq((await api(env, 'setup/restart', admin, 'POST', {}, A)).status, 409, 'systemd 아닐 때 재시작 불가');
// 전용 포트 서버(토큰)
const webDir = join(dir, 'web'); writeFileSync; const { mkdirSync } = await import('node:fs'); mkdirSync(webDir); writeFileSync(join(webDir, 'setup.html'), '<!doctype html><title>__TITLE__</title><body data-mode="__MODE__">v__VERSION__ __HOST__</body>'); writeFileSync(join(webDir, 'setup.js'), '//'); writeFileSync(join(webDir, 'setup.css'), '');
const server = createSetupServer(env, { webDir, tlsOptions: null }); const bound = await server.listen({ port: 0, host: '127.0.0.1' });
const base = `http://127.0.0.1:${bound.port}`; const token = setupToken(env);
const page = await (await fetch(base + '/')).text(); ok(page.includes('data-mode="token"') && page.includes('v1.0.0-test'), '화면 자리표시자 치환');
eq((await fetch(base + '/api/setup/config')).status, 401, '토큰 없음');
eq((await fetch(base + '/api/setup/config', { headers: { 'X-Setup-Token': 'nope' } })).status, 401, '토큰 틀림');
const ok1 = await (await fetch(base + '/api/setup/config?token=' + encodeURIComponent(token))).json(); eq(ok1.values.MAIL_FROM_NAME, '새 발신자', '주소 토큰으로 읽기');
const put = await (await fetch(base + '/api/setup/config', { method: 'PUT', headers: { 'X-Setup-Token': token, 'Content-Type': 'application/json' }, body: JSON.stringify({ values: { SITE_TITLE: '토큰 저장' } }) })).json(); eq(put.values.SITE_TITLE, '토큰 저장', '헤더 토큰으로 저장');
for (let i = 0; i < 10; i++) await fetch(base + '/api/setup/config', { headers: { 'X-Setup-Token': 'guess' + i } });
eq((await fetch(base + '/api/setup/config', { headers: { 'X-Setup-Token': token } })).status, 429, '틀린 시도 10회 뒤 차단(맞는 토큰도)');
ok(server.urls(token)[0].includes(':' + bound.port + '/?token='), '접속 주소'); server.close();
ok(harakaStatus(env).installed === false && harakaStatus(env).status === null, 'Haraka 상태 파일 없음');
rmSync(dir, { recursive: true, force: true });
});
done();
+226
View File
@@ -0,0 +1,226 @@
/**
* 1.0.7 시험: 다중 도메인(server/domains.mjs) — 등록부 생성, 호스트·로그인 ID 로 테넌트 고르기, 도메인 추가(자기 DB·설정·초기 관리자)·중지·삭제,
* 전체 설정 API(/api/setup/domains, ?domain=), IMAP 로그인의 도메인 분리, Haraka 수신 전달의 도메인 나누기, 안내 화면, Haraka 관리 프로세스의 다중 도메인 설정 생성.
* 실행: node test/domains.test.mjs (npm test 와 deploy.sh 가 함께 돌립니다)
*/
import net from 'node:net';
import http from 'node:http';
import { spawn } from 'node:child_process';
import { mkdtempSync, existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeEnv, runner, eq, ok, request, params } from './_helpers.mjs';
import { handle } from '../server/api.mjs';
import { loadConfig, saveConfig, configFile, setupToken } from '../server/config.mjs';
import { openTenants, loadRegistry, registryFile, normalizeEntry, noServicePage, isLocalHost, COPY_KEYS } from '../server/domains.mjs';
import { createImap } from '../server/imap.mjs';
import { updateUser } from '../server/auth.mjs';
import { createSetupServer, setupApi } from '../server/setup.mjs';
const { t, done } = runner();
const HERE = dirname(fileURLToPath(import.meta.url)), ROOT = join(HERE, '..');
const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-dom-'));
const api = (env, path, cookie, method, body, headers) => handle(env, request(path, cookie, method, body, headers), params(path));
/** 기본 도메인 env: 파일 DB 위치(DB_FILE)만 있으면 되고 DB 자체는 메모리입니다(main.mjs 처럼 loadConfig 를 먼저 부릅니다). */
async function base(over = {}) {
const dir = tmp();
const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), PUBLIC_HOST: 'mail.arihakdang.com', RESEND_API_KEY: 're_primary_key_123456', DNS_PROVIDER: 'route53', DNS_AWS_ACCESS_KEY_ID: 'AKIA', DNS_AWS_SECRET_ACCESS_KEY: 'sekret', ...over });
loadConfig(env);
return { env, dir };
}
const open = (env) => openTenants(env, { migrationsDir: join(ROOT, 'migrations'), log: { log() {}, error() {} } });
await t('등록부: 첫 시작에 환경 파일의 도메인으로 domains.json 을 만들고, 호스트·IP·localhost·하위 호스트·모르는 호스트를 규칙대로 고른다', async () => {
const { env, dir } = await base();
const tenants = open(env);
ok(existsSync(registryFile(env)) && registryFile(env) === join(dir, 'domains.json'), '등록부 파일');
const reg = loadRegistry(env); eq(reg.primary, 'arihakdang.com', '기본 도메인'); eq(reg.domains['arihakdang.com'].hosts[0], 'mail.arihakdang.com', '기본 호스트');
eq(env.DOMAIN, 'arihakdang.com', 'env.DOMAIN'); eq(env.MAIL_SERVER_HOST, 'mail.arihakdang.com', '메일 서버 이름');
for (const h of ['mail.arihakdang.com', 'MAIL.arihakdang.com:8790', '127.0.0.1:8790', 'localhost', '[::1]:8790', '', 'arihakdang.com', 'webmail.arihakdang.com']) eq(tenants.resolveHost(h).tenant?.entry.domain, 'arihakdang.com', '기본 도메인으로: ' + h);
eq(tenants.resolveHost('mail.other.test').tenant, null, '모르는 호스트'); eq(tenants.resolveHost('notarihakdang.com').tenant, null, '접미사만 비슷한 호스트');
ok(isLocalHost('10.0.0.5') && isLocalHost('localhost') && !isLocalHost('mail.x.com'), 'isLocalHost');
eq(tenants.resolveLogin('admin', '').DOMAIN, 'arihakdang.com', '도메인 없는 로그인 → 기본'); eq(tenants.resolveLogin('admin@arihakdang.com', ''), env, '@기본 도메인'); eq(tenants.resolveLogin('x@unknown.test', '').DOMAIN, 'arihakdang.com', '@등록되지 않은 도메인(www 관리자 이메일 로그인)은 기본 도메인에서 검사');
eq((await tenants.list()).length, 1, '목록 1개'); tenants.close();
});
await t('도메인 추가: 자기 디렉터리에 DB·설정(기본 도메인의 키·DNS 설정 복사)·초기 관리자(admin)가 생기고 계정은 도메인마다 따로이며, 중복·형식·www DB 오류를 거른다', async () => {
const { env, dir } = await base();
const tenants = open(env);
const r = await tenants.add({ domain: 'Second.test', hosts: 'mail.second.test, webmail.second.test', adminPassword: 'secondpw1', note: '둘째' });
eq(r.entry.domain, 'second.test', '소문자 정규화'); eq(r.entry.hosts.join(','), 'mail.second.test,webmail.second.test', '호스트 목록'); eq(r.initialAdmin?.login, 'admin', '초기 관리자'); eq(r.initialAdmin?.password, 'secondpw1', '지정한 비밀번호');
ok(existsSync(join(dir, 'domains', 'second.test', 'mail.sqlite')) && existsSync(join(dir, 'domains', 'second.test', 'config.json')), '도메인 디렉터리');
const sec = tenants.byDomain('second.test').env;
eq(sec.MAILBOX, 'admin@second.test', '메일함'); eq(sec.PUBLIC_HOST, 'mail.second.test', '공개 호스트'); eq(sec.MAIL_SERVER_HOST, 'mail.arihakdang.com', '메일 서버 이름은 기본 도메인'); eq(sec.LOGIN_MODE, 'standalone', '자체 계정'); ok(!sec.WWW_DB && !sec.WWW_DB_FILE, 'www DB 없음');
eq(sec.RESEND_API_KEY, 're_primary_key_123456', 'API 키 복사'); eq(sec.DNS_PROVIDER, 'route53', 'DNS 설정 복사'); eq(sec.DNS_AWS_SECRET_ACCESS_KEY, 'sekret', 'DNS 비밀 복사'); eq(sec.DNS_AWS_HOSTED_ZONE_ID, undefined, '존 id 는 복사 안 함(도메인으로 자동 검색)'); eq(sec.MAIL_INBOUND, 'resend', '수신 기본');
ok(COPY_KEYS.includes('RESEND_API_KEY') && !COPY_KEYS.includes('HARAKA_DKIM_PRIVATE'), '복사 키 목록');
ok(JSON.parse(readFileSync(configFile(sec), 'utf8'))._meta.copiedFrom === 'arihakdang.com', '설정 파일 메타');
// 로그인: 둘째 도메인의 admin 은 둘째 env 에서만, 기본 도메인의 www 관리자는 둘째 env 에서 거부
const login = await api(sec, 'login', '', 'POST', { login: 'admin', password: 'secondpw1' }); eq(login.status, 200, '둘째 도메인 로그인');
const cookie = login.headers.get('set-cookie').split(';')[0]; const session = await (await api(sec, 'session', cookie)).json();
eq(session.mailbox, 'admin@second.test', '세션 메일함'); eq(session.domain, 'second.test', '세션 도메인'); eq(session.initialPassword, true, '초기 비밀번호 경고');
const st = await (await api(sec, 'settings', cookie)).json(); eq(st.imap.host, 'mail.arihakdang.com', '메일 프로그램 서버 이름은 기본 도메인'); eq(st.smtp.host, 'mail.arihakdang.com', 'SMTP 서버 이름'); eq(st.host, 'mail.second.test', '웹 메일 호스트는 자기 것'); eq(st.mailbox, 'admin@second.test', '설정 화면 메일함');
eq((await api(sec, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' })).status, 401, '기본 도메인 계정은 둘째에서 거부');
eq((await api(env, 'login', '', 'POST', { login: 'admin', password: 'secondpw1' })).status, 401, '둘째 계정은 기본에서 거부');
eq((await api(env, 'session', cookie)).status, 200, '세션 응답'); eq((await (await api(env, 'session', cookie)).json()).user, null, '둘째 세션 쿠키는 기본 도메인에서 비로그인');
// 호스트·로그인 ID 로 고르기
eq(tenants.resolveHost('webmail.second.test').tenant.entry.domain, 'second.test', '추가 호스트'); eq(tenants.resolveHost('imap.second.test').tenant.entry.domain, 'second.test', '하위 호스트'); eq(tenants.resolveLogin('admin@second.test', '').DOMAIN, 'second.test', '@둘째');
eq(tenants.resolveLogin('admin', 'mail.second.test').DOMAIN, 'second.test', 'SNI 로 둘째'); eq(tenants.resolveLogin('admin', 'mail.arihakdang.com').DOMAIN, 'arihakdang.com', 'SNI 로 기본');
// 오류
for (const [body, code, field] of [[{ domain: 'second.test' }, 'domain_exists'], [{ domain: 'arihakdang.com' }, 'domain_exists'], [{ domain: 'third.test', hosts: 'mail.second.test' }, 'host_taken', 'hosts'], [{ domain: 'bad domain' }, 'validation', 'domain'], [{ domain: 'third.test', adminPassword: 'abc' }, 'validation', 'adminPassword'], [{ domain: 'third.test', auth: { mode: 'www', wwwDbFile: '/nonexistent/x.sqlite' } }, 'www_db_unreadable', 'auth.wwwDbFile'], [{ domain: 'third.test', auth: { mode: 'ldap' } }, 'validation', 'auth.mode']]) {
let err = null; try { await tenants.add(body); } catch (e) { err = e; }
eq(err?.message, code, JSON.stringify(body)); if (field) eq(err?.field, field, 'field ' + code);
}
eq((await tenants.list()).length, 2, '목록 2개'); const second = (await tenants.list())[1]; eq(second.accounts, 1, '계정 수'); eq(second.initialPassword, 'secondpw1', '목록의 초기 비밀번호 안내');
// 관리자가 비밀번호를 바꾸면 초기 비밀번호 안내가 사라집니다
await updateUser(sec, (await sec.DB.prepare('SELECT id FROM mail_users').first()).id, { password: 'changedpw1' }); eq((await tenants.list())[1].initialPassword, null, '비밀번호 변경 뒤 안내 없음');
// 같은 도메인을 동시에 추가하면 하나만 성공합니다
const race = await Promise.allSettled([tenants.add({ domain: 'race.test' }), tenants.add({ domain: 'race.test' })]); eq(race.filter((x) => x.status === 'fulfilled').length, 1, '동시 추가 하나만'); eq(race.find((x) => x.status === 'rejected')?.reason?.message, 'domain_exists', '다른 하나는 중복');
// 복사 없이 추가 + 등록부 다시 읽기(재시작 흉내)
await tenants.add({ domain: 'third.test', copyFromPrimary: false }); eq(tenants.byDomain('third.test').env.RESEND_API_KEY, undefined, '복사 안 함');
tenants.close();
const env2 = (await base({ DB_FILE: join(dir, 'mail-gateway.sqlite') })).env; const again = open(env2);
eq(again.all().map((x) => x.entry.domain).sort().join(','), 'arihakdang.com,race.test,second.test,third.test', '재시작 뒤 등록부'); eq(again.byDomain('second.test').env.RESEND_API_KEY, 're_primary_key_123456', '재시작 뒤 설정 유지');
eq((await api(again.byDomain('second.test').env, 'login', '', 'POST', { login: 'admin@second.test', password: 'changedpw1' })).status, 200, '재시작 뒤 로그인(바꾼 비밀번호)'); again.close();
});
await t('중지·수정·삭제: 중지한 도메인은 disabled 로 알려 로그인·수신을 막고, 기본 도메인은 지울 수 없으며, 삭제해도 데이터 디렉터리는 남는다', async () => {
const { env, dir } = await base();
const tenants = open(env);
await tenants.add({ domain: 'second.test' });
tenants.update('second.test', { enabled: false });
const r = tenants.resolveHost('mail.second.test'); ok(r.tenant && r.disabled, '중지 표시'); eq(tenants.resolveLogin('admin@second.test', ''), null, '중지 도메인 로그인 불가'); eq(tenants.envs().length, 1, '주기 작업 대상에서 제외');
tenants.update('second.test', { enabled: true, hosts: 'post.second.test', note: '메모' });
eq(tenants.resolveHost('post.second.test').disabled, false, '다시 사용'); eq(tenants.byDomain('second.test').env.PUBLIC_HOST, 'post.second.test', '호스트 변경이 env 에 반영'); eq(tenants.resolveHost('mail.second.test').tenant.entry.domain, 'second.test', '옛 호스트도 하위 호스트 규칙으로');
// 로그인 방식 바꾸기(www): 읽을 수 있는 SQLite 파일이면 허용
const www = join(dir, 'www.sqlite'); writeFileSync(www, '');
tenants.update('second.test', { auth: { mode: 'www', wwwDbFile: www, ownerLogin: 'owner' } }); eq(tenants.byDomain('second.test').env.WWW_DB_FILE, www, 'www DB 반영'); eq(tenants.byDomain('second.test').env.LOGIN_MODE, 'www', 'www 방식');
tenants.update('second.test', { auth: { mode: 'local' } }); ok(!tenants.byDomain('second.test').env.WWW_DB_FILE, '자체 계정으로 복귀');
const p = tenants.update('arihakdang.com', { hosts: 'alias.arihakdang.com', enabled: false, auth: { mode: 'local' } });
eq(p.enabled, true, '기본 도메인은 끌 수 없음'); eq(p.hosts.join(','), 'mail.arihakdang.com,alias.arihakdang.com', '기본 호스트는 항상 포함'); eq(p.auth.mode, 'www', '기본 도메인의 로그인 방식은 환경 파일');
let err = null; try { tenants.remove('arihakdang.com'); } catch (e) { err = e; } eq(err?.message, 'primary_domain', '기본 도메인 삭제 불가');
const removed = tenants.remove('second.test'); eq(tenants.resolveHost('post.second.test').tenant, null, '삭제 뒤 모르는 호스트'); ok(existsSync(join(removed.dir, 'mail.sqlite')), '데이터 디렉터리 유지');
ok(!loadRegistry(env).domains['second.test'], '등록부에서 제거'); tenants.close();
});
await t('전체 설정 API: 전용 포트에서 도메인 목록·추가·수정·삭제와 ?domain= 선택이 동작하고, 추가 도메인 호스트로 열면 안내 화면, 관리자 세션(/setup)에서는 도메인 관리가 없다', async () => {
const { env } = await base({ SETUP_PORT: '0' });
const tenants = open(env); env.APP_VERSION = '1.0.7-test';
const setup = createSetupServer(env, { webDir: join(ROOT, 'web'), tenants });
const port = 40000 + Math.floor(Math.random() * 20000); await setup.listen({ port, host: '127.0.0.1' });
const token = setupToken(env); eq(readFileSync(join(dirname(env.DB_FILE), 'setup-token'), 'utf8').trim(), token, '토큰 파일');
const call = (path, method = 'GET', body, host = '127.0.0.1') => fetch(`http://127.0.0.1:${port}${path}`, { method, headers: { 'X-Setup-Token': token, Host: host, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
try {
let r = await call('/api/setup/domains'); let d = await r.json(); eq(r.status, 200, '목록'); eq(d.primary, 'arihakdang.com', 'primary'); eq(d.domains.length, 1, '1개'); eq(d.serverHost, 'mail.arihakdang.com', '메일 서버 이름');
r = await call('/api/setup/domains', 'POST', { domain: 'second.test', adminPassword: 'secondpw1' }); d = await r.json(); eq(r.status, 201, '추가'); eq(d.initialAdmin.login, 'admin', '초기 관리자'); eq(d.domains.length, 2, '목록 갱신');
r = await call('/api/setup/domains', 'POST', { domain: 'second.test' }); eq(r.status, 409, '중복'); eq((await r.json()).error, 'domain_exists', '중복 코드');
r = await call('/api/setup/domains', 'POST', { domain: 'x y' }); eq(r.status, 422, '형식'); eq((await r.json()).field, 'domain', '필드');
// ?domain= 으로 둘째 도메인의 설정을 읽고 씁니다
r = await call('/api/setup/config?domain=second.test'); d = await r.json(); eq(d.meta.mailbox, 'admin@second.test', '둘째 설정'); eq(d.meta.primary, false, '기본 아님'); eq(d.meta.serverHost, 'mail.arihakdang.com', '서버 이름');
r = await call('/api/setup/config?domain=second.test', 'PUT', { values: { MAIL_FROM_NAME: '둘째' } }); d = await r.json(); eq(d.values.MAIL_FROM_NAME, '둘째', '둘째 저장'); eq(tenants.byDomain('second.test').env.MAIL_FROM_NAME, '둘째', '둘째 env 적용'); ok(env.MAIL_FROM_NAME !== '둘째', '기본 도메인은 그대로');
r = await call('/api/setup/config'); d = await r.json(); eq(d.meta.mailbox, 'admin@arihakdang.com', '기본 설정'); eq(d.meta.primary, true, '기본'); eq(d.meta.domains, 2, '도메인 수');
r = await call('/api/setup/config?domain=nope.test'); eq(r.status, 404, '모르는 도메인'); eq((await r.json()).error, 'domain_not_found', '코드');
r = await call('/api/setup/plan?domain=second.test'); d = await r.json(); eq(d.plan.domain, 'second.test', '둘째 도메인의 자동 설정 계획');
r = await call('/api/setup/domains/second.test', 'PATCH', { enabled: false }); d = await r.json(); eq(r.status, 200, '중지'); eq(d.domains[1].enabled, false, '중지 반영');
r = await call('/api/setup/domains/second.test', 'DELETE'); eq(r.status, 200, '삭제'); eq((await r.json()).domains.length, 1, '삭제 뒤 1개');
r = await call('/api/setup/domains/arihakdang.com', 'DELETE'); eq(r.status, 409, '기본 삭제 불가');
// 추가 도메인의 호스트 이름으로 전용 포트에 오면 안내 화면(기본 도메인 주소로 열라고)
await tenants.add({ domain: 'second.test' });
// fetch 는 Host 헤더를 바꿀 수 없으므로 http 모듈로 직접 보냅니다
const rawGet = (host) => new Promise((resolve, reject) => { http.get({ host: '127.0.0.1', port, path: '/', headers: { Host: host } }, (res) => { let body = ''; res.setEncoding('utf8'); res.on('data', (c) => { body += c; }); res.on('end', () => resolve({ status: res.statusCode, body })); }).on('error', reject); });
let g = await rawGet('mail.second.test'); eq(g.status, 404, '다른 호스트'); ok(g.body.includes('기본 도메인 주소로'), '안내 문구');
g = await rawGet('mail.arihakdang.com'); eq(g.status, 200, '기본 호스트'); g = await rawGet('127.0.0.1:' + port); eq(g.status, 200, 'IP'); r = await call('/api/health'); eq((await r.json()).setup, true, '상태 확인');
} finally { setup.close(); tenants.close(); }
// 관리자 세션 쪽(api.mjs → setupApi, tenants 없음): domains 는 404
const r2 = await setupApi(env, new Request('https://mail.arihakdang.com/api/setup/domains'), ['domains'], { ip: '127.0.0.1', user: { login: 'x' } }); eq(r2.status, 404, '관리자 세션에서는 도메인 관리 없음');
});
await t('IMAP: 로그인 ID 의 @도메인으로 도메인을 고르고(admin@second.test → 둘째 DB), 도메인 없는 ID 는 기본 도메인, 모르는·중지한 도메인은 거부한다', async () => {
const { env } = await base({ POP_AUTH_DELAY_MS: 0 });
const tenants = open(env);
await tenants.add({ domain: 'second.test', adminPassword: 'secondpw1' }); await tenants.add({ domain: 'off.test', adminPassword: 'offpw123' }); tenants.update('off.test', { enabled: false });
const imap = createImap(env, { hostname: 'mail.test', resolveEnv: (l, s) => tenants.resolveLogin(l, s) }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
const session = async (login, password) => { const c = imapClient(port); await c.greeting(); const r = await c.cmd(`LOGIN "${login}" "${password}"`); return { c, r }; };
try {
let { c, r } = await session('admin@second.test', 'secondpw1'); ok(/OK/.test(r), '둘째 로그인: ' + r.trim()); r = await c.cmd('SELECT INBOX'); ok(/OK/.test(r), '둘째 SELECT'); c.end();
({ c, r } = await session('admin@second.test', 'admin-secret-12')); ok(/NO/.test(r), '둘째에 기본 비밀번호 거부'); c.end();
({ c, r } = await session('arihakdang', 'admin-secret-12')); ok(/OK/.test(r), '도메인 없는 ID 는 기본 도메인'); c.end();
({ c, r } = await session('admin@off.test', 'offpw123')); ok(/NO/.test(r), '중지한 도메인 거부'); c.end();
({ c, r } = await session('admin@nowhere.test', 'secondpw1')); ok(/NO/.test(r), '모르는 도메인은 기본 도메인에서 검사 → 계정 없음'); c.end();
({ c, r } = await session('ADMIN@arihakdang.com', 'admin-secret-12')); ok(/OK/.test(r), 'www 관리자 이메일 로그인(기본 도메인)'); c.end();
} finally { imap.close(); tenants.close(); }
});
await t('Haraka 수신 전달: 받는 사람 주소의 도메인으로 저장할 DB 를 고르고, 토큰은 기본 도메인 것이며, 수신 방식이 haraka 인 도메인만 받는다', async () => {
const { env } = await base();
const tenants = open(env);
await tenants.add({ domain: 'second.test' }); await tenants.add({ domain: 'third.test' });
const sec = tenants.byDomain('second.test').env, third = tenants.byDomain('third.test').env;
eq((await api(env, 'inbound/haraka/rcpt?addr=admin@second.test', '', 'GET', undefined, { 'X-AriMail-Token': env.HARAKA_TOKEN })).status, 404, '아무 도메인도 haraka 수신이 아니면 404');
saveConfig(sec, { inbound: 'haraka' }); // 둘째만 haraka 수신
const hdr = { 'X-AriMail-Token': env.HARAKA_TOKEN };
eq((await api(env, 'inbound/haraka/rcpt?addr=admin@second.test', '', 'GET', undefined, { 'X-AriMail-Token': sec.HARAKA_TOKEN })).status, 401, '둘째 도메인의 토큰은 거부(토큰은 기본 도메인 것)');
const rc = async (addr) => (await (await api(env, 'inbound/haraka/rcpt?addr=' + addr, '', 'GET', undefined, hdr)).json()).local;
eq(await rc('admin@second.test'), true, '둘째 메일함'); eq(await rc('nobody@second.test'), false, '둘째 없는 사용자'); eq(await rc('admin@third.test'), false, '셋째는 haraka 수신 아님'); eq(await rc('admin@arihakdang.com'), false, '기본은 haraka 수신 아님');
const eml = Buffer.from('From: sender@example.test\r\nTo: admin@second.test\r\nSubject: hello second\r\nMessage-ID: <h1@example.test>\r\n\r\nbody\r\n');
const post = (rcpt, id) => handle(env, new Request('https://x/api/inbound/haraka', { method: 'POST', headers: { ...hdr, 'X-AriMail-Id': id, 'X-AriMail-From': 'sender@example.test', 'X-AriMail-Rcpt': JSON.stringify(rcpt), 'Content-Type': 'message/rfc822' }, body: eml }), { path: ['inbound', 'haraka'], ip: '127.0.0.1' });
let r = await post(['admin@second.test'], 'tx1'); eq(r.status, 200, '저장'); const r1 = await r.json(); ok(r1.imported && r1.mailbox === 'admin@second.test', '둘째 메일함에 저장 — ' + JSON.stringify(r1));
// 두 도메인의 주소가 한 트랜잭션에 함께 오면 각 DB 에 저장하고 합계를 돌려줍니다
saveConfig(third, { inbound: 'haraka' });
r = await post(['admin@second.test', 'admin@third.test'], 'tx2'); const r2 = await r.json(); eq(r2.imported, 2, '두 도메인 합계'); eq(r2.results.length, 2, '도메인별 결과');
eq(Number((await third.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n), 1, '셋째 DB 에 저장'); saveConfig(third, { inbound: 'resend' });
eq(Number((await sec.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n), 2, '둘째 DB 에 저장'); eq(Number((await env.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n), 0, '기본 DB 에는 없음');
eq((await sec.DB.prepare("SELECT status FROM webhook_events WHERE id='haraka:tx1'").first()).status, 'stored', '둘째의 수신 기록');
r = await post(['admin@second.test'], 'tx1'); eq((await r.json()).skipped, 'exists', '재시도는 중복'); eq((await sec.DB.prepare("SELECT status FROM webhook_events WHERE id='haraka:tx1'").first()).status, 'stored', '기록 유지');
tenants.close();
});
await t('안내 화면과 항목 정규화: 모르는 도메인·중지한 도메인 문구, 호스트 기본값 mail.<도메인>, 로그인 방식 검사', async () => {
const html = noServicePage({ host: 'mail.nowhere.test', strings: {}, version: '1.0.7' });
ok(html.includes('메일 서비스가 존재하지 않습니다') && html.includes('mail.nowhere.test') && html.includes('관리자에게'), '모르는 도메인 문구');
ok(noServicePage({ disabled: true }).includes('활성화되지 않았습니다'), '중지 문구'); ok(noServicePage({ host: '<x>' }).includes('&lt;x&gt;'), '이스케이프');
const e = normalizeEntry({ domain: ' Example.COM. ' }); eq(e.domain, 'example.com', '도메인'); eq(e.hosts[0], 'mail.example.com', '기본 호스트'); eq(e.mailbox, 'admin@example.com', '기본 메일함'); eq(e.auth.mode, 'local', '기본 로그인'); eq(e.enabled, true, '기본 사용');
const e2 = normalizeEntry({ domain: 'example.com', mailbox: 'Postmaster', hosts: ['Mail.Example.com', 'mail.example.com', 'a.example.com:443'] }); eq(e2.mailbox, 'postmaster@example.com', '메일함 로컬'); eq(e2.hosts.join(','), 'mail.example.com,a.example.com', '중복·포트 제거');
let err = null; try { normalizeEntry({ domain: 'example.com', hosts: 'bad host' }); } catch (x) { err = x; } eq(err?.field, 'hosts', '호스트 검사');
err = null; try { normalizeEntry({ domain: 'example.com', auth: { mode: 'www' } }); } catch (x) { err = x; } eq(err?.field, 'auth.wwwDbFile', 'www 는 DB 파일 필수');
});
await t('Haraka 관리 프로세스: 등록부의 켜진 도메인 중 수신 haraka 인 도메인을 domains= 에 넣고, 발신 haraka + DKIM 키가 있는 도메인마다 dkim/<도메인>/ 키 파일을 만든다', async () => {
const dir = tmp(), data = join(dir, 'data'), haraka = join(dir, 'haraka'); mkdirSync(data, { recursive: true }); mkdirSync(join(haraka, 'node_modules', 'Haraka', 'bin'), { recursive: true }); mkdirSync(join(haraka, 'node_modules', 'Haraka', 'config'), { recursive: true });
// 가짜 Haraka: 실행 파일은 그냥 대기하는 스크립트(설정 생성만 봅니다)
writeFileSync(join(haraka, 'node_modules', 'Haraka', 'bin', 'haraka'), 'setInterval(() => {}, 1000);\n'); writeFileSync(join(haraka, 'node_modules', 'Haraka', 'package.json'), '{"version":"0.0.0-test"}');
writeFileSync(join(data, 'config.json'), JSON.stringify({ inbound: 'haraka', outbound: 'haraka', HARAKA_TOKEN: 'tok', HARAKA_DKIM_PRIVATE: 'KEY-PRIMARY', HARAKA_DKIM_SELECTOR: 'p1' }));
writeFileSync(join(data, 'domains.json'), JSON.stringify({ primary: 'a.test', domains: { 'a.test': { domain: 'a.test', enabled: true, hosts: ['mail.a.test'] }, 'b.test': { domain: 'b.test', enabled: true, hosts: ['mail.b.test'] }, 'c.test': { domain: 'c.test', enabled: false, hosts: ['mail.c.test'] }, 'd.test': { domain: 'd.test', enabled: true, hosts: ['mail.d.test'] } } }));
mkdirSync(join(data, 'domains', 'b.test'), { recursive: true }); writeFileSync(join(data, 'domains', 'b.test', 'config.json'), JSON.stringify({ inbound: 'haraka', outbound: 'haraka', HARAKA_DKIM_PRIVATE: 'KEY-B', HARAKA_DKIM_SELECTOR: 'sel-b' }));
mkdirSync(join(data, 'domains', 'c.test'), { recursive: true }); writeFileSync(join(data, 'domains', 'c.test', 'config.json'), JSON.stringify({ inbound: 'haraka', outbound: 'haraka', HARAKA_DKIM_PRIVATE: 'KEY-C' }));
mkdirSync(join(data, 'domains', 'd.test'), { recursive: true }); writeFileSync(join(data, 'domains', 'd.test', 'config.json'), JSON.stringify({ inbound: 'resend', outbound: 'haraka' })); // 발신만, 키 없음
// 예전에 서명하던 도메인의 디렉터리(지워져야 함)
mkdirSync(join(haraka, 'config', 'dkim', 'old.test'), { recursive: true }); writeFileSync(join(haraka, 'config', 'dkim', 'old.test', 'private'), 'OLD'); writeFileSync(join(haraka, 'config', 'dkim.private.key'), 'OLD-SINGLE');
const child = spawn(process.execPath, [join(ROOT, 'haraka', 'run.mjs')], { env: { ...process.env, DB_FILE: join(data, 'mail-gateway.sqlite'), HARAKA_DIR: haraka, HARAKA_CHECK_MS: '200', MAILBOX: 'admin@a.test', PUBLIC_HOST: 'mail.a.test', PORT: '18990' }, stdio: ['ignore', 'pipe', 'pipe'] });
let out = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
try {
const until = Date.now() + 8000; while (Date.now() < until && !existsSync(join(haraka, 'config', 'dkim', 'b.test', 'private'))) await new Promise((r) => setTimeout(r, 100));
const ini = readFileSync(join(haraka, 'config', 'arimail.ini'), 'utf8'); ok(ini.includes('domains=a.test,b.test\n'), 'domains= 에 켜진 haraka 수신 도메인만: ' + ini);
eq(readFileSync(join(haraka, 'config', 'dkim', 'b.test', 'selector'), 'utf8'), 'sel-b\n', 'b 셀렉터'); eq(readFileSync(join(haraka, 'config', 'dkim', 'b.test', 'private'), 'utf8'), 'KEY-B\n', 'b 키');
eq(readFileSync(join(haraka, 'config', 'dkim', 'a.test', 'selector'), 'utf8'), 'p1\n', '기본 도메인 셀렉터'); ok(!existsSync(join(haraka, 'config', 'dkim', 'c.test')) && !existsSync(join(haraka, 'config', 'dkim', 'd.test')) && !existsSync(join(haraka, 'config', 'dkim', 'old.test')), '중지·키 없음·옛 도메인 디렉터리 없음');
ok(readFileSync(join(haraka, 'config', 'plugins'), 'utf8').includes('dkim'), 'dkim 플러그인 켜짐'); ok(!readFileSync(join(haraka, 'config', 'dkim.ini'), 'utf8').includes('domain=') && !existsSync(join(haraka, 'config', 'dkim.private.key')), '단일 도메인 폴백 없음(키 없는 도메인은 서명하지 않음)');
const status = JSON.parse(readFileSync(join(data, 'haraka-status.json'), 'utf8')); eq(status.domains.join(','), 'a.test,b.test', '상태 파일 domains'); eq(status.signers.join(','), 'a.test,b.test', '상태 파일 signers'); eq(status.dkim, true, 'dkim 표시');
} catch (e) { e.message += '\n--- run.mjs 출력:\n' + out; throw e; } finally { child.kill('SIGTERM'); await new Promise((r) => child.on('exit', r)); rmSync(dir, { recursive: true, force: true }); }
});
/** IMAP 시험 클라이언트(test/imap.test.mjs 와 같은 최소 구현). */
function imapClient(port) {
const socket = net.connect(port, '127.0.0.1'); let buffer = ''; const waiters = [];
socket.setEncoding('latin1'); socket.on('data', (d) => { buffer += d; flush(); });
function flush() {
while (waiters.length) {
const w = waiters[0]; const re = w.tag ? new RegExp(`(^|\\r\\n)${w.tag} (OK|NO|BAD)[^\\r]*\\r\\n`) : /^\+ [^\r]*\r\n|^\* [^\r]*\r\n/;
const m = buffer.match(re); if (!m) return;
const end = m.index + m[0].length; waiters.shift(); w.resolve(buffer.slice(0, end)); buffer = buffer.slice(end);
}
}
let n = 0;
return { greeting: () => new Promise((resolve) => { waiters.push({ resolve }); flush(); }), cmd: (line) => { const tag = 'A' + (++n); socket.write(`${tag} ${line}\r\n`); return new Promise((resolve) => { waiters.push({ tag, resolve }); flush(); }); }, end: () => socket.end() };
}
done();
File diff suppressed because it is too large Load Diff
+208
View File
@@ -0,0 +1,208 @@
import net from 'node:net';
import { makeEnv, runner, eq, ok, fakeResend } from './_helpers.mjs';
import { ingestReceived, saveDraft } from '../server/store.mjs';
import { createImap, tokenize, resolveSet } from '../server/imap.mjs';
import { buildEml } from '../server/mime.mjs';
import { writeServices } from '../server/limits.mjs';
const { t, done } = runner();
const sample = (id, extra = {}) => ({ id, from: 'Sender <sender@example.test>', to: ['admin@arihakdang.com'], subject: '받은 메일 ' + id, html: '<p>내용 <b>' + id + '</b></p>', text: '내용 ' + id, created_at: new Date().toISOString(), headers: {}, message_id: '<' + id + '@example.test>', ...extra });
function client(port) {
const socket = net.connect(port, '127.0.0.1'); let buffer = ''; const waiters = [];
socket.setEncoding('latin1'); socket.on('data', (d) => { buffer += d; flush(); });
function flush() {
while (waiters.length) {
const w = waiters[0]; const re = w.tag ? new RegExp(`(^|\\r\\n)${w.tag} (OK|NO|BAD)[^\\r]*\\r\\n`) : /^\+ [^\r]*\r\n|^\* [^\r]*\r\n/;
const m = buffer.match(re); if (!m) return;
const end = m.index + m[0].length; waiters.shift(); w.resolve(buffer.slice(0, end)); buffer = buffer.slice(end);
}
}
let n = 0;
return {
greeting: () => new Promise((resolve) => { waiters.push({ resolve }); flush(); }),
cmd: (line) => { const tag = 'A' + (++n); socket.write(`${tag} ${line}\r\n`); return new Promise((resolve) => { waiters.push({ tag, resolve }); flush(); }); },
raw: (text) => socket.write(text),
// 바이트 그대로 보내는 명령(8bit APPEND 시험용). cmd 는 문자열을 UTF-8 로 보내므로 따로 둡니다.
bytes: (tag, buf) => { socket.write(buf); return new Promise((resolve) => { waiters.push({ tag, resolve }); flush(); }); },
untagged: () => new Promise((resolve) => { waiters.push({ resolve }); flush(); }),
end: () => socket.end(),
};
}
await t('토큰화와 집합 해석', async () => {
const tokens = tokenize('A1 FETCH 1:* (FLAGS BODY.PEEK[HEADER.FIELDS (From To)]<0.100> "quoted \\"x\\"")');
eq(tokens[2], '1:*', '집합'); eq(tokens[3][1], 'BODY.PEEK[HEADER.FIELDS (From To)]<0.100>', '대괄호 원자'); eq(tokens[3][2], 'quoted "x"', '따옴표');
const msgs = [{ uid: 3 }, { uid: 7 }, { uid: 9 }];
eq(resolveSet('2:*', msgs, false).join(','), '1,2', '시퀀스'); eq(resolveSet('7:*', msgs, true).join(','), '1,2', 'UID 범위'); eq(resolveSet('9,3', msgs, true).join(','), '0,2', 'UID 목록');
});
await t('0.9.7 IMAP 평문 로그인 스위치: 기본 허용이면 인사말에 AUTH=PLAIN, 관리자가 끄면 LOGINDISABLED 와 거절', async () => {
const env = await makeEnv();
const imap = createImap(env, { hostname: 'mail.test' }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
try {
let c = client(port); let g = await c.greeting(); ok(/AUTH=PLAIN/.test(g) && !/LOGINDISABLED/.test(g), '기본: AUTH=PLAIN 광고: ' + g);
ok((await c.cmd('LOGIN arihakdang admin-secret-12')).includes('OK'), '평문 로그인 허용'); await c.cmd('LOGOUT'); c.end();
await writeServices(env, { plainLogin: false });
c = client(port); g = await c.greeting(); ok(/LOGINDISABLED/.test(g) && !/AUTH=PLAIN/.test(g), '끄면 LOGINDISABLED: ' + g);
ok(/NO \[PRIVACY\]/.test(await c.cmd('LOGIN arihakdang admin-secret-12')), 'LOGIN 거절'); ok(/NO \[PRIVACY\]/.test(await c.cmd('AUTHENTICATE PLAIN')), 'AUTHENTICATE 거절'); c.end();
const fixed = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port2 = port + 1; await fixed.listen({ plainPort: port2, host: '127.0.0.1' });
try { c = client(port2); ok(/AUTH=PLAIN/.test(await c.greeting()), 'allowPlaintext:true 는 설정과 무관하게 허용'); c.end(); } finally { fixed.close(); }
} finally { imap.close(); }
});
await t('IMAP: 로그인·LIST·SELECT·FETCH·STORE·SEARCH·MOVE·EXPUNGE·APPEND·IDLE 가 웹 메일함과 같은 저장소를 쓴다', async () => {
const at = (s) => new Date(Date.now() - s * 1000).toISOString();
const raw = { a: Buffer.from('From: Sender <sender@example.test>\r\nTo: admin@arihakdang.com\r\nSubject: raw a\r\nMessage-ID: <a@example.test>\r\nDate: Mon, 28 Sep 2026 06:00:00 +0000\r\nContent-Type: text/plain\r\n\r\nbody a\r\n') };
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('a', { created_at: at(30) }), sample('b', { created_at: at(20) }), sample('c', { created_at: at(10) })], raw }), IMAP_IDLE_POLL_MS: 200 });
for (const id of ['a', 'b', 'c']) await ingestReceived(env, id);
await saveDraft(env, { to: 'x@example.test', subject: '초안', html: '<p>초안 본문</p>' });
const imap = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
try {
const c = client(port); ok((await c.greeting()).includes('IMAP4rev1'), '인사');
ok((await c.cmd('CAPABILITY')).includes('IDLE'), 'CAPABILITY');
ok(/NO/.test(await c.cmd('LOGIN student admin-secret-12')), '학생 거부');
ok(/NO/.test(await c.cmd('LOGIN arihakdang wrong')), '비밀번호 오류');
ok(/A\d+ OK/.test(await c.cmd('LOGIN arihakdang admin-secret-12')), '로그인');
const list = await c.cmd('LIST "" "*"'); ok(list.includes('"INBOX"') && list.includes('\\Sent') && list.includes('\\Trash'), 'LIST');
const status = await c.cmd('STATUS INBOX (MESSAGES UNSEEN)'); ok(status.includes('MESSAGES 3') && status.includes('UNSEEN 3'), 'STATUS');
const select = await c.cmd('SELECT INBOX'); ok(select.includes('* 3 EXISTS') && select.includes('UIDVALIDITY') && select.includes('READ-WRITE'), 'SELECT');
const fetch1 = await c.cmd('FETCH 1 (UID FLAGS RFC822.SIZE ENVELOPE BODYSTRUCTURE BODY.PEEK[HEADER.FIELDS (Subject)])');
ok(fetch1.includes('UID 1') && fetch1.includes('FLAGS ()') && fetch1.includes('"raw a"') && fetch1.includes('Subject: raw a'), 'FETCH 요약'); ok(!fetch1.includes('\\Seen'), 'PEEK 는 읽음 처리 없음');
const body = await c.cmd('UID FETCH 1 BODY[]'); ok(body.includes('body a') && body.includes('FLAGS (\\Seen)'), 'BODY[] 원본 + 읽음');
eq((await env.DB.prepare("SELECT read_at FROM messages WHERE provider_id='a'").first()).read_at !== null, true, '웹 읽음 상태 공유');
ok((await c.cmd('STORE 2 +FLAGS (\\Flagged \\Seen)')).includes('\\Flagged'), 'STORE'); ok((await c.cmd('SEARCH UNSEEN')).includes('* SEARCH 3'), 'SEARCH UNSEEN');
ok((await c.cmd('SEARCH FLAGGED')).includes('* SEARCH 2'), 'SEARCH FLAGGED'); ok((await c.cmd('UID SEARCH SUBJECT "raw"')).includes('* SEARCH 1'), 'SEARCH SUBJECT');
ok((await c.cmd('SEARCH OR SUBJECT "받은 메일 c" FROM "nobody"')).includes('* SEARCH 3'), 'SEARCH OR');
const move = await c.cmd('UID MOVE 3 Trash'); ok(move.includes('COPYUID') && move.includes('* 3 EXPUNGE'), 'MOVE');
eq((await env.DB.prepare("SELECT folder FROM messages WHERE provider_id='c'").first()).folder, 'trash', '웹 휴지통 공유');
await c.cmd('STORE 2 +FLAGS (\\Deleted)'); ok((await c.cmd('EXPUNGE')).includes('* 2 EXPUNGE'), 'EXPUNGE');
eq((await env.DB.prepare("SELECT folder FROM messages WHERE provider_id='b'").first()).folder, 'trash', '삭제 → 휴지통 이동');
ok((await c.cmd('SELECT Trash')).includes('* 2 EXISTS'), '휴지통 2통');
await c.cmd('STORE 1:* +FLAGS.SILENT (\\Deleted)'); await c.cmd('EXPUNGE');
eq((await env.DB.prepare('SELECT COUNT(*) n FROM messages WHERE folder=\'trash\'').first()).n, 0, '휴지통 영구 삭제');
eq((await env.DB.prepare('SELECT COUNT(*) n FROM tombstones').first()).n, 2, '재수신 차단');
const drafts = await c.cmd('SELECT Drafts'); ok(drafts.includes('* 1 EXISTS'), '초안 폴더');
const dfetch = await c.cmd('FETCH 1 (FLAGS BODY.PEEK[TEXT])'); ok(dfetch.includes('\\Draft') && /초안|7LSI7JWI/.test(dfetch), '초안 원본 즉시 생성');
const eml = buildEml({ from: 'Ari Hak Dang <admin@arihakdang.com>', to: ['friend@example.test'], subject: '클라이언트가 보낸 메일', text: '복사본', html: '<p>복사본</p>' });
const appendResp = await c.cmd(`APPEND Sent (\\Seen) {${eml.length}+}\r\n${eml.toString('latin1')}`); ok(appendResp.includes('APPENDUID'), 'APPEND 저장');
const sentRow = await env.DB.prepare("SELECT subject,folder,direction,read_at,raw_status FROM messages WHERE subject='클라이언트가 보낸 메일'").first();
eq(sentRow.folder, 'sent', '보낸 메일함'); eq(sentRow.direction, 'outbound', '발신'); ok(sentRow.read_at && sentRow.raw_status === 'stored', '읽음·원본 저장');
ok((await c.cmd('SELECT INBOX')).includes('* 1 EXISTS'), '받은 메일함 1통');
c.raw('A99 IDLE\r\n'); const idleStart = await c.untagged(); ok(idleStart.startsWith('+ '), 'IDLE 시작');
await ingestReceived(env, 'b'); // 삭제된 b 는 재수신 차단 → 변화 없음
await env.DB.prepare("INSERT INTO tombstones VALUES('never','x','deleted')").run();
const fresh = sample('d', { created_at: new Date().toISOString() }); env.RESEND_FETCH = fakeResend({ received: [fresh] }); await ingestReceived(env, 'd');
const exists = await c.untagged(); ok(exists.includes('EXISTS'), 'IDLE 로 새 메일 알림');
c.raw('DONE\r\n'); await new Promise((r) => setTimeout(r, 100));
ok(/A\d+ OK/.test(await c.cmd('LOGOUT')) || true, 'LOGOUT'); c.end();
const p = createImap(env, { hostname: 'mail.test', allowPlaintext: false }); const port2 = port + 1; await p.listen({ plainPort: port2, host: '127.0.0.1' });
const c2 = client(port2); const g = await c2.greeting(); ok(g.includes('LOGINDISABLED'), '평문 로그인 비활성'); ok(/NO/.test(await c2.cmd('LOGIN arihakdang admin-secret-12')), '평문 로그인 거부'); c2.end(); p.close();
} finally { imap.close(); }
});
await t('IMAP: 돌아온 메일은 새 UID, 괄호 묶음 OR/NOT 검색, base64 본문 검색, 8bit APPEND 원본 보존', async () => {
const at = (s) => new Date(Date.now() - s * 1000).toISOString();
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('a', { created_at: at(30), text: '고유단어 uniqueword 본문' }), sample('b', { created_at: at(20) })] }) });
for (const id of ['a', 'b']) await ingestReceived(env, id);
const imap = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
try {
// 계정 설정에서 IMAP 을 끄면 비밀번호가 맞아도 거부되고, 켜면 들어갑니다(0.8.4).
const { writePrefs } = await import('../server/auth.mjs'); const admin = { id: 'www:admin-1' };
await writePrefs(env, admin, { imap: false });
const c = client(port); await c.greeting(); ok((await c.cmd('LOGIN arihakdang admin-secret-12')).includes('IMAP access is disabled'), 'IMAP 꺼진 계정 거부');
await writePrefs(env, admin, { imap: true }); ok(/A\d+ OK/.test(await c.cmd('LOGIN arihakdang admin-secret-12')), 'IMAP 켜면 로그인');
ok((await c.cmd('SELECT INBOX')).includes('* 2 EXISTS'), 'SELECT');
// 괄호 묶음: 예전에는 묶음이 항상 참으로 평가되어 OR 는 전부, NOT 은 아무것도 돌려주지 않았습니다.
ok(/\* SEARCH\r\n/.test(await c.cmd('SEARCH OR (FROM nomatch) (FROM zzz)')), 'OR 묶음 둘 다 거짓 → 결과 없음');
ok((await c.cmd('SEARCH NOT (SUBJECT "받은 메일 a")')).includes('* SEARCH 2'), 'NOT 묶음');
ok((await c.cmd('SEARCH OR (SUBJECT "받은 메일 a") (FROM nomatch)')).includes('* SEARCH 1\r\n'), 'OR 묶음 한쪽 참');
// 본문 검색: 저장 원본은 base64 이므로 풀어서 찾아야 합니다.
ok((await c.cmd('SEARCH BODY uniqueword')).includes('* SEARCH 1\r\n'), 'BODY 는 base64 본문을 풀어서 찾는다');
// 휴지통에 갔다가 돌아온 메일은 예전 UID(1)가 아니라 더 큰 새 UID 를 받아야 합니다(RFC 3501 UID 단조 증가).
await c.cmd('UID MOVE 1 Trash'); await c.cmd('SELECT Trash'); const back = await c.cmd('UID MOVE 1:* INBOX');
const m = back.match(/COPYUID \d+ \S+ (\d+)/); ok(m && Number(m[1]) > 2, '돌아온 메일은 새 UID: ' + back);
const sel = await c.cmd('SELECT INBOX'); ok(sel.includes('* 2 EXISTS'), '받은 메일함 2통');
ok((await c.cmd('UID FETCH 3:* (UID)')).includes('UID ' + m[1]), 'UIDNEXT 이후 조회에 보인다');
// 웹에서 옮겼다 되돌린 경우도 같은 규칙(트리거)이 적용됩니다.
const bId = (await env.DB.prepare("SELECT id FROM messages WHERE provider_id='b'").first()).id;
const before = (await env.DB.prepare("SELECT id FROM imap_uids WHERE message_id=? AND folder='inbox'").bind(bId).first()).id;
await env.DB.prepare("UPDATE messages SET folder='trash' WHERE id=?").bind(bId).run(); await env.DB.prepare("UPDATE messages SET folder='inbox' WHERE id=?").bind(bId).run();
ok(!(await env.DB.prepare("SELECT 1 FROM imap_uids WHERE message_id=? AND folder='inbox'").bind(bId).first()), '떠난 폴더의 UID 행 삭제');
ok((await c.cmd('NOOP')) && (await env.DB.prepare("SELECT id FROM imap_uids WHERE message_id=? AND folder='inbox'").bind(bId).first()).id > before, '웹 이동 후 새 UID');
// 8bit APPEND: EUC-KR 바이트(UTF-8 이 아님)가 저장 원본에 그대로 남아야 합니다.
const head = Buffer.from('From: x@example.test\r\nTo: admin@arihakdang.com\r\nSubject: euc\r\nContent-Type: text/plain; charset=euc-kr\r\nContent-Transfer-Encoding: 8bit\r\n\r\n', 'latin1');
const eml = Buffer.concat([head, Buffer.from([0xc7, 0xd1, 0xb1, 0xdb, 0x0d, 0x0a])]);
const resp = await c.bytes('Z1', Buffer.concat([Buffer.from(`Z1 APPEND INBOX {${eml.length}+}\r\n`), eml, Buffer.from('\r\n')]));
ok(resp.includes('Z1 OK'), 'APPEND 완료');
const row = await env.DB.prepare("SELECT r.eml FROM messages m JOIN message_raw r ON r.message_id=m.id WHERE m.subject='euc'").first();
ok(Buffer.from(row.eml).equals(eml), '8bit 원본 바이트 보존');
// APPEND 의 날짜·시각·시간대가 그대로 저장되고(앞에 공백 붙은 날 포함), ENVELOPE 의 한글 제목이 UTF-8 로 나갑니다.
// 제목을 인코딩하지 않은(raw 8bit UTF-8) 헤더로 올립니다. ENVELOPE 는 이 바이트를 리터럴로 돌려줘야 합니다.
const dated = Buffer.from('From: Ari <admin@arihakdang.com>\r\nTo: x@example.test\r\nSubject: 한글 제목\r\nMessage-ID: <dt@x>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nx\r\n', 'utf8');
const ap = await c.bytes('Z2', Buffer.concat([Buffer.from(`Z2 APPEND Sent (\\Seen) " 7-Oct-2026 23:30:00 +0900" {${dated.length}+}\r\n`), dated, Buffer.from('\r\n')])); ok(ap.includes('APPENDUID'), 'APPEND 날짜: ' + ap);
eq((await env.DB.prepare("SELECT received_at FROM messages WHERE message_id='<dt@x>'").first()).received_at, '2026-10-07T14:30:00.000Z', '시각·시간대 반영');
await c.cmd('SELECT Sent'); const found = await c.cmd('UID SEARCH HEADER Message-ID dt@x'); const uid = found.match(/SEARCH (\d+)/)[1];
const fetched = await c.cmd(`UID FETCH ${uid} ENVELOPE`); ok(Buffer.from(fetched, 'latin1').toString('utf8').includes('한글 제목'), 'ENVELOPE 한글 제목 UTF-8: ' + JSON.stringify(Buffer.from(fetched, 'latin1').toString('utf8').slice(0, 160)));
c.end();
} finally { imap.close(); }
});
await t('0.8.16 오류 수정: 임시 보관함에서 EXPUNGE 한 초안은 휴지통에 남지 않고, LF 만 쓴 APPEND 도 본문이 저장된다', async () => {
const env = await makeEnv({ RESEND_FETCH: fakeResend({}) });
const imap = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
try {
const c = client(port); await c.greeting(); await c.cmd('LOGIN arihakdang admin-secret-12');
// Thunderbird 자동 저장 흉내: v1 APPEND → v2 APPEND → v1 에 \Deleted → UID EXPUNGE. 예전에는 v1 이 휴지통으로 갔습니다.
const draft = (n) => Buffer.from(`From: 관리자 <admin@arihakdang.com>\r\nTo: a@example.test\r\nSubject: draft v${n}\r\nMessage-ID: <d${n}@x>\r\nContent-Type: text/plain\r\n\r\nbody v${n}\r\n`);
for (const n of [1, 2]) ok((await c.bytes('D' + n, Buffer.concat([Buffer.from(`D${n} APPEND Drafts (\\Draft \\Seen) {${draft(n).length}+}\r\n`), draft(n), Buffer.from('\r\n')]))).includes('APPENDUID'), 'APPEND ' + n);
await c.cmd('SELECT Drafts'); const uid = (await c.cmd('UID SEARCH HEADER Message-ID d1@x')).match(/SEARCH (\d+)/)[1];
await c.cmd(`UID STORE ${uid} +FLAGS.SILENT (\\Deleted)`); ok(/OK/.test(await c.cmd(`UID EXPUNGE ${uid}`)), 'UID EXPUNGE');
const rows = (await env.DB.prepare('SELECT subject,folder FROM messages ORDER BY subject').all()).results;
eq(rows.map((r) => r.subject + ':' + r.folder).join(','), 'draft v2:drafts', '옛 초안은 영구 삭제, 휴지통 비어 있음');
// LF 만 쓴 multipart 메시지도 본문을 해석합니다.
const lf = Buffer.from('From: a@example.test\nTo: admin@arihakdang.com\nSubject: lf only\nMessage-ID: <lf@x>\nContent-Type: multipart/alternative; boundary="b1"\n\n--b1\nContent-Type: text/plain\n\nplain body\n--b1\nContent-Type: text/html\n\n<p>html body</p>\n--b1--\n');
ok((await c.bytes('L1', Buffer.concat([Buffer.from(`L1 APPEND INBOX {${lf.length}+}\r\n`), lf, Buffer.from('\r\n')]))).includes('APPENDUID'), 'LF APPEND');
const m = await env.DB.prepare("SELECT text,html FROM messages WHERE message_id='<lf@x>'").first(); eq(m.text, 'plain body', 'LF 텍스트'); ok(m.html.includes('html body'), 'LF HTML');
c.end();
} finally { imap.close(); }
});
await t('0.8.24 오류 수정: 짝 없는 NUL 한 줄에 멈추지 않고, 읽기 전용(EXAMINE) 세션의 UID EXPUNGE 는 거부하며, 받은 메일은 Drafts 로 옮길 수 없다', async () => {
eq(JSON.stringify(tokenize('a1 \0')), '["a1"]', '짝 없는 NUL 은 버림(예전에는 무한 루프)');
eq(JSON.stringify(tokenize('a1 \0zzz')), '["a1"]', '짝 없는 NUL 뒤 글자도 버림');
const env = await makeEnv({ RESEND_FETCH: fakeResend({ received: [sample('a'), sample('b')] }) });
await ingestReceived(env, 'a'); await ingestReceived(env, 'b');
const imap = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 43000 + Math.floor(Math.random() * 1000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
try {
const c1 = client(port), c2 = client(port); await c1.greeting(); await c2.greeting();
await c1.cmd('LOGIN arihakdang admin-secret-12'); await c2.cmd('LOGIN arihakdang admin-secret-12');
await c1.cmd('SELECT INBOX');
// 두 메일의 received_at 이 같아 UID 순서가 id(무작위)에 따라 바뀔 수 있으므로 UID 를 DB 에서 찾습니다.
const uidOf = async (pid) => (await env.DB.prepare("SELECT u.id FROM imap_uids u JOIN messages m ON m.id=u.message_id WHERE m.provider_id=? AND u.folder='inbox'").bind(pid).first()).id;
const ua = await uidOf('a'), ub = await uidOf('b');
await c1.cmd(`UID STORE ${ua} +FLAGS (\\Deleted)`);
await c2.cmd('EXAMINE INBOX');
ok(/NO .*read-only/.test(await c2.cmd(`UID EXPUNGE ${ua}`)), '읽기 전용 UID EXPUNGE 거부');
ok(/NO .*read-only/.test(await c2.cmd('EXPUNGE')), '읽기 전용 EXPUNGE 거부');
eq((await env.DB.prepare("SELECT folder FROM messages WHERE provider_id='a'").first()).folder, 'inbox', '메일은 그대로');
ok(/NO \[CANNOT\]/.test(await c1.cmd(`UID MOVE ${ub} Drafts`)), '받은 메일 → Drafts 거부');
ok(/NO \[CANNOT\]/.test(await c1.cmd(`UID COPY ${ub} Drafts`)), '받은 메일 COPY → Drafts 거부');
ok(/OK/.test((await c1.cmd(`UID MOVE ${ub} Trash`)).split('\r\n').filter(Boolean).pop()), '휴지통으로는 이동');
eq((await env.DB.prepare("SELECT folder FROM messages WHERE provider_id='b'").first()).folder, 'trash', '휴지통 반영');
// 짝 없는 NUL 을 회선으로 보내도 서버가 계속 응답합니다.
const c3 = client(port); await c3.greeting(); const raw = await c3.cmd('\0'); ok(/BAD|NO|OK/.test(raw), 'NUL 줄에도 응답'); ok(/OK/.test(await c3.cmd('CAPABILITY')), '이후 명령 정상');
c1.end(); c2.end(); c3.end();
} finally { imap.close(); }
});
await t('0.9.0: 관리자가 IMAP 서비스를 끄면 로그인을 거부하고, 메일함 최대 크기를 넘는 APPEND 는 OVERQUOTA 로 거부한다', async () => {
const env = await makeEnv({ LIMIT_MAILBOX_BYTES: '2K' });
const imap = createImap(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
try {
await writeServices(env, { imap: false });
let c = client(port); await c.greeting(); const r = await c.cmd('LOGIN arihakdang admin-secret-12'); ok(/NO \[UNAVAILABLE\].*administrator/.test(r), '서비스 꺼짐: ' + r); c.end();
await writeServices(env, { imap: true });
c = client(port); await c.greeting(); ok(/OK/.test(await c.cmd('LOGIN arihakdang admin-secret-12')), '다시 켜면 로그인');
const small = buildEml({ from: 'admin@arihakdang.com', to: ['a@example.test'], subject: 's', text: 'short' });
ok(/OK \[APPENDUID/.test(await c.cmd(`APPEND Drafts {${small.length}+}\r\n${small.toString('latin1')}`)), '작은 초안 저장');
const big = buildEml({ from: 'admin@arihakdang.com', to: ['a@example.test'], subject: 'big', text: 'x'.repeat(3000) });
ok(/NO \[OVERQUOTA\]/.test(await c.cmd(`APPEND Drafts {${big.length}+}\r\n${big.toString('latin1')}`)), '용량 초과 APPEND 거부');
c.end();
} finally { imap.close(); }
});
done();
+255
View File
@@ -0,0 +1,255 @@
/**
* 1.0.5 시험: DNS 모듈(Google Cloud DNS·Route 53·공개 조회), 필요한 레코드 계산(SPF 병합), Resend 도메인·웹훅 모듈, 자동 설정 계획(buildPlan/runPlan).
* 외부 서비스는 모두 가짜 fetch(env.DNS_FETCH / env.RESEND_FETCH)와 가짜 리졸버로 대신합니다.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { generateKeyPairSync } from 'node:crypto';
import { makeEnv, runner, eq, ok } from './_helpers.mjs';
import { loadConfig, applyConfig, saveConfig } from '../server/config.mjs';
import { mergeSpf, requiredRecords, applyRecords, checkRecords, probeDns } from '../server/dns/index.mjs';
import { txtChunks, lookup, reverseCheck } from '../server/dns/common.mjs';
import * as google from '../server/dns/google.mjs';
import * as route53 from '../server/dns/route53.mjs';
import { resendDnsRecords, findDomain, providerStatus, providerError, clearStatusCache, autoVerifyDomain } from '../server/providers/resend.mjs';
import { buildPlan, runPlan } from '../server/provision.mjs';
const { t, done } = runner();
const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-prov-'));
const envFor = async (dir, over = {}) => { const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), MAILBOX: 'admin@example.test', PUBLIC_HOST: 'mail.example.test', RESEND_API_KEY: '', ...over }); loadConfig(env); return env; };
/** 가짜 리졸버: 표에 있는 값만 돌려주고 없으면 ENOTFOUND. */
function fakeResolver(table = {}) {
const get = (name, type) => { const v = table[`${type} ${name.toLowerCase()}`]; if (v === undefined) { const e = new Error('nf'); e.code = 'ENOTFOUND'; throw e; } return v; };
return { resolveTxt: async (n) => get(n, 'TXT').map((v) => txtChunks(v)), resolveMx: async (n) => get(n, 'MX'), resolveCname: async (n) => get(n, 'CNAME'), resolve4: async (n) => get(n, 'A'), reverse: async (ip) => get(ip, 'PTR'), table };
}
/** 가짜 Resend(도메인·웹훅 관리 부분만) — 상태를 기억합니다. */
function fakeResendAdmin(state = {}) {
state.domains = state.domains || []; state.webhooks = state.webhooks || []; state.calls = [];
return async (url, options = {}) => {
const u = new URL(url), path = u.pathname, method = options.method || 'GET', body = options.body ? JSON.parse(options.body) : null; state.calls.push({ path, method, body });
if (!options.headers?.Authorization?.includes('re_good')) return Response.json({ message: 'API key is invalid' }, { status: 401 });
let m;
if (path === '/domains' && method === 'GET') return Response.json({ data: state.domains.map(({ records, ...d }) => d) });
if (path === '/domains' && method === 'POST') { const d = { id: 'dom-' + (state.domains.length + 1), name: body.name, status: 'not_started', region: body.region, capabilities: body.capabilities, records: [{ record: 'DKIM', name: 'resend._domainkey', type: 'TXT', value: 'p=PUBKEY', status: 'not_started' }, { record: 'SPF', name: 'send', type: 'CNAME', value: 'send.forge.rmta.net', status: 'not_started' }, ...(body.capabilities.receiving === 'enabled' ? [{ record: 'Receiving', name: '', type: 'MX', value: `inbound-smtp.${body.region}.amazonaws.com`, priority: 9, status: 'not_started' }] : [])] }; state.domains.push(d); return Response.json(d); }
if ((m = path.match(/^\/domains\/([^/]+)\/verify$/))) { const d = state.domains.find((x) => x.id === m[1]); if (!d) return Response.json({}, { status: 404 }); d.status = state.verifyResult || 'pending'; return Response.json({ object: 'domain', id: d.id }); }
if ((m = path.match(/^\/domains\/([^/]+)$/))) { const d = state.domains.find((x) => x.id === m[1]); if (!d) return Response.json({ message: 'not found' }, { status: 404 }); if (method === 'PATCH') { d.capabilities = body.capabilities; if (body.capabilities.receiving === 'enabled' && !d.records.some((r) => r.type === 'MX')) d.records.push({ record: 'Receiving', name: '', type: 'MX', value: `inbound-smtp.${d.region}.amazonaws.com`, priority: 9 }); if (body.capabilities.receiving === 'disabled') d.records = d.records.filter((r) => r.type !== 'MX'); return Response.json({ object: 'domain', id: d.id }); } return Response.json(d); }
if (path === '/webhooks' && method === 'GET') return Response.json({ data: state.webhooks });
if (path === '/webhooks' && method === 'POST') { state.seq = (state.seq || 0) + 1; const w = { id: 'wh-' + state.seq, endpoint: body.endpoint, events: body.events, status: 'enabled' }; state.webhooks.push(w); return Response.json({ object: 'webhook', id: w.id, signing_secret: 'whsec_' + w.id }); }
if ((m = path.match(/^\/webhooks\/([^/]+)$/)) && method === 'DELETE') { state.webhooks = state.webhooks.filter((w) => w.id !== m[1]); return Response.json({ object: 'webhook', id: m[1], deleted: true }); }
return Response.json({ message: 'unexpected ' + method + ' ' + path }, { status: 500 });
};
}
/** 가짜 Google Cloud DNS + OAuth. zone 의 rrsets 를 기억합니다. */
function fakeGoogle(state = {}) {
state.rrsets = state.rrsets || {}; state.calls = []; state.changes = [];
return async (url, options = {}) => {
const u = new URL(url), method = options.method || 'GET', body = options.body && !(options.body instanceof URLSearchParams) ? JSON.parse(options.body) : null; state.calls.push({ host: u.hostname, path: u.pathname, method, body });
if (u.hostname === 'oauth2.googleapis.com') { const p = options.body; ok(String(p.get('assertion')).split('.').length === 3, 'JWT 3 부분'); return Response.json({ access_token: 'ya29.test', expires_in: 3600 }); }
if (!options.headers?.Authorization?.includes('ya29.test')) return Response.json({ error: { message: 'unauth' } }, { status: 401 });
if (u.pathname.endsWith('/managedZones') && method === 'GET') return Response.json({ managedZones: [{ name: 'other-zone', dnsName: 'other.test.', visibility: 'public' }, { name: 'example-zone', dnsName: 'example.test.', visibility: 'public' }, { name: 'priv', dnsName: 'example.test.', visibility: 'private' }] });
if (u.pathname.includes('/rrsets')) { const key = `${u.searchParams.get('type')} ${u.searchParams.get('name')}`; return Response.json({ rrsets: state.rrsets[key] ? [state.rrsets[key]] : [] }); }
if (u.pathname.endsWith('/changes') && method === 'POST') { state.changes.push(body); for (const d of body.deletions || []) delete state.rrsets[`${d.type} ${d.name}`]; for (const a of body.additions || []) state.rrsets[`${a.type} ${a.name}`] = a; return Response.json({ status: 'pending' }); }
return Response.json({ error: { message: 'unexpected ' + u.pathname } }, { status: 500 });
};
}
/** 가짜 Route 53: 서명 헤더만 확인하고 XML 로 답합니다. */
function fakeRoute53(state = {}) {
state.calls = []; state.rrsets = state.rrsets || {};
return async (url, options = {}) => {
const u = new URL(url), method = options.method || 'GET'; state.calls.push({ path: u.pathname, query: u.search, method, headers: options.headers, body: options.body });
const auth = options.headers?.Authorization || '';
if (!/^AWS4-HMAC-SHA256 Credential=AKIATEST\/\d{8}\/us-east-1\/route53\/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=[0-9a-f]{64}$/.test(auth) && !/SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date/.test(auth)) return new Response('<ErrorResponse><Error><Code>InvalidSignature</Code><Message>bad</Message></Error></ErrorResponse>', { status: 403 });
if (u.pathname === '/2013-04-01/hostedzonesbyname') { const name = u.searchParams.get('dnsname'); return new Response(`<ListHostedZonesByNameResponse><HostedZones>${name === 'example.test.' ? '<HostedZone><Id>/hostedzone/Z123</Id><Name>example.test.</Name><Config><PrivateZone>false</PrivateZone></Config></HostedZone>' : ''}</HostedZones></ListHostedZonesByNameResponse>`); }
if (u.pathname.endsWith('/rrset') && method === 'GET') { const key = `${u.searchParams.get('type')} ${u.searchParams.get('name')}`; const r = state.rrsets[key]; return new Response(`<ListResourceRecordSetsResponse><ResourceRecordSets>${r ? `<ResourceRecordSet><Name>${r.name}</Name><Type>${r.type}</Type><TTL>300</TTL><ResourceRecords>${r.values.map((v) => `<ResourceRecord><Value>${v.replace(/"/g, '&quot;')}</Value></ResourceRecord>`).join('')}</ResourceRecords></ResourceRecordSet>` : ''}</ResourceRecordSets></ListResourceRecordSetsResponse>`); }
if (u.pathname.endsWith('/rrset') && method === 'POST') { state.lastBody = options.body; for (const m of options.body.matchAll(/<ResourceRecordSet><Name>([^<]+)<\/Name><Type>([^<]+)<\/Type><TTL>\d+<\/TTL><ResourceRecords>(.*?)<\/ResourceRecords>/g)) state.rrsets[`${m[2]} ${m[1]}`] = { name: m[1], type: m[2], values: [...m[3].matchAll(/<Value>([^<]*)<\/Value>/g)].map((x) => x[1].replace(/&quot;/g, '"')) }; return new Response('<ChangeResourceRecordSetsResponse><ChangeInfo><Id>/change/C999</Id><Status>PENDING</Status></ChangeInfo></ChangeResourceRecordSetsResponse>'); }
return new Response('<ErrorResponse><Error><Code>Unexpected</Code></Error></ErrorResponse>', { status: 500 });
};
}
const saKey = () => generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } }).privateKey;
await t('1.0.5 DNS 공통: SPF 병합, TXT 255자 분할, 공개 조회 비교(TXT/MX/CNAME, 없음=not_found), PTR 확인', async () => {
eq(mergeSpf('', ['mx', 'a:mail.example.test']), 'v=spf1 mx a:mail.example.test ~all', '새 SPF');
eq(mergeSpf('v=spf1 include:_spf.google.com -all', ['mx', 'a:mail.example.test']), 'v=spf1 include:_spf.google.com mx a:mail.example.test -all', '기존 SPF 에 끼워 넣기(all 유지)');
eq(mergeSpf('v=spf1 mx a:mail.example.test ~all', ['mx', 'a:mail.example.test']), 'v=spf1 mx a:mail.example.test ~all', '이미 있으면 그대로');
eq(txtChunks('x'.repeat(600)).map((c) => c.length).join(','), '255,255,90', '분할'); eq(txtChunks('').length, 1, '빈 값도 조각 하나');
const r = fakeResolver({ 'TXT example.test': ['v=spf1 mx ~all', 'other'], 'MX example.test': [{ priority: 10, exchange: 'mail.example.test' }], 'CNAME send.example.test': ['send.forge.rmta.net'], 'A mail.example.test': ['203.0.113.5'], 'PTR 203.0.113.5': ['mail.example.test'] });
eq((await lookup({ name: 'example.test', type: 'TXT', values: ['v=spf1 mx ~all'] }, { resolver: r })).ok, true, 'TXT 일치');
eq((await lookup({ name: 'example.test', type: 'TXT', values: ['v=spf1 -all'] }, { resolver: r })).ok, false, 'TXT 불일치');
eq((await lookup({ name: 'example.test', type: 'MX', values: ['mail.example.test'], priority: 10 }, { resolver: r })).ok, true, 'MX 일치(우선순위 포함)');
eq((await lookup({ name: 'example.test', type: 'MX', values: ['mail.example.test'], priority: 9 }, { resolver: r })).ok, false, 'MX 우선순위 다름');
eq((await lookup({ name: 'send.example.test', type: 'CNAME', values: ['send.forge.rmta.net.'] }, { resolver: r })).ok, true, 'CNAME 끝 점 무시');
const nf = await lookup({ name: 'nope.example.test', type: 'TXT', values: ['x'] }, { resolver: r }); eq(nf.ok, false, '없음'); eq(nf.error, 'not_found', 'not_found 코드');
eq((await reverseCheck('mail.example.test', { resolver: r })).ok, true, 'PTR 일치'); eq((await reverseCheck('mail.example.test', { resolver: fakeResolver({ 'A mail.example.test': ['203.0.113.5'], 'PTR 203.0.113.5': ['vps-1.hosting.test'] }) })).ok, false, 'PTR 다름');
});
await t('1.0.5 필요한 레코드: 수·발신 방식별 구성(Haraka MX·SPF·DKIM·DMARC, Resend 레코드 변환, 수신이 Resend 가 아니면 Resend MX 제외)', async () => {
const dir = tmp(); const env = await envFor(dir);
applyConfig(env, { inbound: 'haraka', outbound: 'haraka', HARAKA_DKIM_PUBLIC: 'PUB', HARAKA_DKIM_SELECTOR: 'arimail', HARAKA_TOKEN: 'x' });
const recs = requiredRecords(env, { existingSpf: 'v=spf1 include:spf.protection.outlook.com -all' });
eq(recs.map((r) => `${r.type} ${r.name}${r.optional ? '?' : ''}`).join(' | '), 'MX example.test | TXT example.test | TXT arimail._domainkey.example.test | TXT _dmarc.example.test?', 'Haraka 수·발신 레코드');
eq(recs[0].values[0], 'mail.example.test', 'MX 는 호스트 이름'); eq(recs[1].values[0], 'v=spf1 include:spf.protection.outlook.com mx a:mail.example.test -all', '기존 SPF 병합'); ok(recs[2].values[0].startsWith('v=DKIM1; k=rsa; p=PUB'), 'DKIM');
const resendDomain = { name: 'Example.test', records: [{ record: 'DKIM', name: 'resend._domainkey', type: 'TXT', value: 'p=K' }, { record: 'SPF', name: 'send', type: 'CNAME', value: 'send.forge.rmta.net' }, { record: 'Receiving', name: '', type: 'MX', value: 'inbound-smtp.ap-northeast-1.amazonaws.com', priority: 9 }] };
eq(resendDnsRecords(resendDomain).map((r) => `${r.type} ${r.name}`).join(' | '), 'TXT resend._domainkey.example.test | CNAME send.example.test | MX example.test', 'Resend 레코드 → FQDN');
applyConfig(env, { inbound: 'haraka', outbound: 'resend', HARAKA_TOKEN: 'x' });
const mixed = requiredRecords(env, { resendDomain });
eq(mixed.map((r) => `${r.type} ${r.name}`).join(' | '), 'MX example.test | TXT resend._domainkey.example.test | CNAME send.example.test', '수신 Haraka + 발신 Resend: Resend MX 는 빠지고 우리 MX 만');
applyConfig(env, { inbound: 'resend', outbound: 'resend', HARAKA_TOKEN: 'x' });
eq(requiredRecords(env, { resendDomain }).filter((r) => r.type === 'MX')[0].priority, 9, '수신 Resend: Resend MX(우선순위 9)');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.5 Google Cloud DNS 모듈: 서비스 계정 JWT 인증, 존 자동 선택(공개·가장 긴 일치), rrdatas 변환(TXT 따옴표·분할, MX 우선순위), UPSERT(기존 삭제+추가, 같으면 생략), 오류 코드', async () => {
const dir = tmp(); const env = await envFor(dir); const g = {}; env.DNS_FETCH = fakeGoogle(g);
applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'google', DNS_GOOGLE_CREDENTIALS: JSON.stringify({ client_email: 'sa@p.iam.gserviceaccount.com', private_key: saKey(), project_id: 'proj-1' }), HARAKA_TOKEN: 'x' });
const zone = await google.findZone(env, 'example.test'); eq(zone.name, 'example-zone', '공개 존 선택(사설 제외)');
const longTxt = 'v=DKIM1; p=' + 'A'.repeat(300), rr = google.rrdata({ type: 'TXT', values: [longTxt] })[0];
eq(rr, txtChunks(longTxt).map((c) => `"${c}"`).join(' '), 'TXT 255자 분할·따옴표'); eq(rr.split('" "').length, 2, '두 조각'); eq(rr.replace(/"/g, '').replace(/ /g, ''), longTxt.replace(/ /g, ''), '이어 붙이면 원본');
eq(google.rrdata({ type: 'MX', values: ['mail.example.test'], priority: 10 })[0], '10 mail.example.test.', 'MX 형식'); eq(google.rrdata({ type: 'CNAME', values: ['send.forge.rmta.net'] })[0], 'send.forge.rmta.net.', 'CNAME 끝 점');
g.rrsets['TXT example.test.'] = { name: 'example.test.', type: 'TXT', ttl: 300, rrdatas: ['"v=spf1 include:old ~all"'] };
const r1 = await google.upsert(env, zone, [{ name: 'example.test', type: 'TXT', ttl: 300, values: ['v=spf1 include:old mx ~all'] }, { name: 'example.test', type: 'MX', ttl: 300, values: ['mail.example.test'], priority: 10 }]);
eq(r1.changed.length, 2, '두 레코드 변경'); const ch = g.changes[0]; eq(ch.deletions.length, 1, '기존 TXT 삭제'); eq(ch.additions.length, 2, '추가 2'); eq(ch.additions[0].rrdatas[0], '"v=spf1 include:old mx ~all"', '새 SPF');
const r2 = await google.upsert(env, zone, [{ name: 'example.test', type: 'MX', ttl: 300, values: ['mail.example.test'], priority: 10 }]); eq(r2.unchanged.length, 1, '같으면 생략'); eq(g.changes.length, 1, '변경 요청 없음');
eq((await probeDns(env)).ok, true, '연결 확인'); eq((await probeDns(env)).project, 'proj-1', '프로젝트 id 는 JSON 에서');
applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'google', DNS_GOOGLE_CREDENTIALS: '{bad', HARAKA_TOKEN: 'x' }); eq((await probeDns(env)).error, 'dns_bad_credentials', 'JSON 오류 코드');
applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'google', DNS_GOOGLE_CREDENTIALS: '', HARAKA_TOKEN: 'x' }); eq((await probeDns(env)).error, 'dns_not_configured', '없음 코드');
env.DNS_FETCH = fakeGoogle({}); applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'google', DNS_GOOGLE_CREDENTIALS: JSON.stringify({ client_email: 'a@b', private_key: saKey(), project_id: 'p' }), MAILBOX: 'x', HARAKA_TOKEN: 'x' }); env.MAILBOX = 'admin@nozone.test'; eq((await probeDns(env)).error, 'dns_zone_not_found', '존 없음 코드');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.5 Route 53 모듈: SigV4 서명 헤더, 존 검색(상위 도메인까지), UPSERT XML(TXT 따옴표, MX), 같으면 생략, 오류 코드', async () => {
const dir = tmp(); const env = await envFor(dir); const a = {}; env.DNS_FETCH = fakeRoute53(a);
applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'route53', DNS_AWS_ACCESS_KEY_ID: 'AKIATEST', DNS_AWS_SECRET_ACCESS_KEY: 'secret', HARAKA_TOKEN: 'x' });
const zone = await route53.findZone(env, 'mail.example.test'); eq(zone.id, 'Z123', '상위 도메인 존 찾기'); ok(/x-amz-date/.test(JSON.stringify(a.calls[0].headers)), '서명 헤더');
const r1 = await route53.upsert(env, zone, [{ name: 'example.test', type: 'TXT', ttl: 300, values: ['v=spf1 mx ~all'] }, { name: 'example.test', type: 'MX', ttl: 300, values: ['mail.example.test'], priority: 10 }]);
eq(r1.changed.length, 2, '변경 2'); eq(r1.changeId, 'C999', '변경 id'); ok(a.lastBody.includes('<Action>UPSERT</Action>') && a.lastBody.includes('<Value>&quot;v=spf1 mx ~all&quot;</Value>') && a.lastBody.includes('<Value>10 mail.example.test.</Value>'), 'XML 본문');
const r2 = await route53.upsert(env, zone, [{ name: 'example.test', type: 'TXT', ttl: 300, values: ['v=spf1 mx ~all'] }]); eq(r2.unchanged.length, 1, '같으면 생략');
applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'route53', DNS_AWS_ACCESS_KEY_ID: 'AKIAWRONG', DNS_AWS_SECRET_ACCESS_KEY: 'x', HARAKA_TOKEN: 'x' }); eq((await probeDns(env)).error, 'dns_auth_failed', '서명 거부 코드');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.5 자동 설정 계획(Resend 수·발신 + Google DNS): 키 없음 → 입력 요청, 도메인 등록·기능·DNS 적용·검증·웹훅(비밀키 저장)을 자동으로 처리하고 반영 뒤 준비 완료', async () => {
const dir = tmp(); const env = await envFor(dir); const rs = { verifyResult: 'pending' }, g = {}; env.RESEND_FETCH = fakeResendAdmin(rs); env.DNS_FETCH = fakeGoogle(g);
const resolver = fakeResolver({});
applyConfig(env, { inbound: 'resend', outbound: 'resend', HARAKA_TOKEN: 'x' });
let p = await buildPlan(env, { resolver });
eq(p.steps.find((s) => s.id === 'resend.key').state, 'action', 'API 키 없음 → 사용자 입력'); ok(p.steps.find((s) => s.id === 'resend.key').input.some((i) => i.key === 'RESEND_API_KEY'), '입력 칸'); eq(p.ready, false, '준비 안 됨');
// 키를 입력하면(runPlan input) 저장 뒤 진단: 도메인 등록 todo, DNS 모듈 없음 → action
let r = await runPlan(env, { step: 'resend.key', input: { RESEND_API_KEY: 're_good_key_123456789', RESEND_REGION: 'ap-northeast-1' }, resolver });
eq(env.RESEND_API_KEY, 're_good_key_123456789', '입력 저장'); p = r.plan;
eq(p.steps.find((s) => s.id === 'resend.domain').state, 'todo', '도메인 등록 자동 가능');
// 전체 실행: 도메인 등록(발송+수신) → DNS 모듈 없음(action) → 검증은 레코드 대기
r = await runPlan(env, { resolver }); p = r.plan;
eq(rs.domains.length, 1, '도메인 등록됨'); eq(rs.domains[0].capabilities.receiving, 'enabled', '수신 켬'); eq(env.RESEND_DOMAIN_ID, 'dom-1', '도메인 id 저장');
eq(p.steps.find((s) => s.id === 'dns.apply').state, 'action', 'DNS 모듈 없음 → 직접 넣기 안내'); ok(p.records.some((x) => x.type === 'MX' && x.priority === 9), 'Resend 수신 MX 포함'); eq(p.steps.find((s) => s.id === 'resend.verify').state, 'wait', '레코드 반영 전에는 검증 대기');
eq(p.steps.find((s) => s.id === 'resend.webhook').state, 'ok', '웹훅은 만들어짐'); eq(rs.webhooks[0].events[0], 'email.received', '웹훅 이벤트'); eq(env.RESEND_WEBHOOK_SECRET, 'whsec_wh-1', '서명 비밀키 자동 저장');
// DNS 모듈 연결 → 자동 적용 → 전파 대기
saveConfig(env, { DNS_PROVIDER: 'google', DNS_GOOGLE_CREDENTIALS: JSON.stringify({ client_email: 'sa@p', private_key: saKey(), project_id: 'proj' }) });
r = await runPlan(env, { resolver }); p = r.plan;
ok(g.changes.length === 1 && g.changes[0].additions.length === 3, 'Google 에 3개 레코드 적용 — ' + JSON.stringify(g.changes.map((c) => c.additions.map((x) => x.type + ' ' + x.name))));
eq(p.steps.find((s) => s.id === 'dns.apply').state, 'wait', '적용 뒤 전파 대기'); eq(p.ready, false, '아직');
// 전파됨(리졸버에 값이 보임) → 검증 요청 자동 → pending → 사용자가 확인 → verified
Object.assign(resolver.table, { 'TXT resend._domainkey.example.test': ['p=PUBKEY'], 'CNAME send.example.test': ['send.forge.rmta.net'], 'MX example.test': [{ priority: 9, exchange: 'inbound-smtp.ap-northeast-1.amazonaws.com' }] });
r = await runPlan(env, { resolver }); p = r.plan;
eq(p.steps.find((s) => s.id === 'dns.apply').state, 'ok', '레코드 반영 확인'); eq(rs.domains[0].status, 'pending', '검증 요청됨'); eq(p.steps.find((s) => s.id === 'resend.verify').state, 'wait', '검증 진행 중 안내');
rs.domains[0].status = 'verified'; p = await buildPlan(env, { resolver });
eq(p.ready, true, '준비 완료'); ok(p.readyMessage.includes('준비가 끝났습니다'), '완료 메시지');
// 웹훅 비밀키를 잃은 경우: action + 새로 만들기 → 새 비밀키 저장(기존 삭제)
saveConfig(env, { RESEND_WEBHOOK_SECRET: '' }); p = await buildPlan(env, { resolver }); eq(p.steps.find((s) => s.id === 'resend.webhook').state, 'action', '비밀키 없음 → 사용자 선택');
r = await runPlan(env, { step: 'resend.webhook', stepAction: 'recreate', resolver }); eq(rs.webhooks.length, 1, '기존 삭제 후 하나'); eq(env.RESEND_WEBHOOK_SECRET, 'whsec_wh-2', '새 비밀키 저장'); eq(r.plan.steps.find((s) => s.id === 'resend.webhook').state, 'ok', '웹훅 정상');
// API 키가 틀리면 오류 단계로 안내
saveConfig(env, { RESEND_API_KEY: 're_bad' }); p = await buildPlan(env, { resolver }); eq(p.steps.find((s) => s.id === 'resend.domain').state, 'error', '키 거부 → 오류'); ok(p.steps.find((s) => s.id === 'resend.domain').detail.includes('거부'), '오류 문구');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.5 자동 설정 계획(내장 Haraka 수·발신 + Route 53): 미설치 안내, DKIM 자동 생성, SPF 병합 적용, PTR 안내; 외부 SMTP 중계는 접속 정보 입력 요청', async () => {
const dir = tmp(); const env = await envFor(dir, { HARAKA_DIR: join(dir, 'haraka') }); const a = {}; env.DNS_FETCH = fakeRoute53(a);
const resolver = fakeResolver({ 'TXT example.test': ['v=spf1 include:_spf.google.com ~all'], 'A mail.example.test': ['203.0.113.9'], 'PTR 203.0.113.9': ['vps.hosting.test'] });
applyConfig(env, { inbound: 'haraka', outbound: 'haraka', DNS_PROVIDER: 'route53', DNS_AWS_ACCESS_KEY_ID: 'AKIATEST', DNS_AWS_SECRET_ACCESS_KEY: 'secret', HARAKA_TOKEN: 'x' });
let p = await buildPlan(env, { resolver });
eq(p.steps.find((s) => s.id === 'haraka.install').state, 'action', 'Haraka 미설치 → 명령 안내'); ok(p.steps.find((s) => s.id === 'haraka.install').detail.includes('haraka-apply.sh'), '명령');
eq(p.steps.find((s) => s.id === 'haraka.dkim').state, 'todo', 'DKIM 자동 생성 가능'); ok(!p.steps.some((s) => s.id === 'resend.key'), 'Resend 단계 없음');
const r = await runPlan(env, { resolver }); p = r.plan;
ok(env.HARAKA_DKIM_PRIVATE, 'DKIM 생성됨'); ok(a.lastBody && a.lastBody.includes('_dmarc') === false, 'DMARC(권장)는 자동 적용하지 않음');
ok(a.lastBody.includes('v=spf1 include:_spf.google.com mx a:mail.example.test ~all'), '기존 SPF 에 병합해 적용'); ok(a.lastBody.includes('arimail._domainkey.example.test.'), 'DKIM TXT 적용'); ok(a.lastBody.includes('<Value>10 mail.example.test.</Value>'), 'MX 적용');
eq(p.steps.find((s) => s.id === 'dns.apply').state, 'wait', '전파 대기');
ok(!p.steps.some((s) => s.id === 'haraka.ptr'), 'Haraka 관리 서비스가 없으면 실행 단계는 아직'); eq(p.ready, false, '설치 전');
// 외부 SMTP 중계
applyConfig(env, { inbound: 'resend', outbound: 'smtp', RESEND_API_KEY: '', HARAKA_TOKEN: 'x' });
p = await buildPlan(env, { resolver }); const relay = p.steps.find((s) => s.id === 'smtp.relay'); eq(relay.state, 'action', '중계 서버 정보 요청'); ok(relay.input.some((i) => i.key === 'SMTP_RELAY_HOST'), '입력 칸');
rmSync(dir, { recursive: true, force: true });
});
/** 운영(webmail.ibit.kr)에서 겪은 상태를 흉내 낸 Resend 도메인: 검증 실패, DKIM 은 다른 공개키가 DNS 에 있고, 수신 MX 는 예전 값이지만 Resend 는 verified. */
const failedDomain = () => ({ id: 'dom-9', name: 'example.test', status: 'failed', region: 'ap-northeast-1', capabilities: { sending: 'enabled', receiving: 'enabled' }, records: [
{ record: 'DKIM', name: 'resend._domainkey', type: 'TXT', value: 'p=NEWKEY', status: 'failed' },
{ record: 'SPF', name: 'send', type: 'MX', value: 'feedback-smtp.ap-northeast-1.amazonses.com', priority: 10, status: 'failed' },
{ record: 'SPF', name: 'send', type: 'TXT', value: 'v=spf1 include:amazonses.com ~all', status: 'failed' },
{ record: 'Receiving', name: '', type: 'MX', value: 'receive-apne1.forge.rmta.net', priority: 10, status: 'verified' },
] });
const liveDns = () => ({ 'TXT resend._domainkey.example.test': ['p=OLDKEY'], 'MX send.example.test': [{ priority: 10, exchange: 'feedback-smtp.ap-northeast-1.amazonses.com' }], 'TXT send.example.test': ['v=spf1 include:amazonses.com ~all'], 'MX example.test': [{ priority: 10, exchange: 'inbound-smtp.ap-northeast-1.amazonaws.com' }] });
await t('1.0.11 발신 도메인 상태 진단: 미등록 → mail_domain_missing(예전에는 ok), 검증 실패 → mail_domain_unverified 와 레코드별 wrong/missing·원인 문장, Resend 가 verified 라 한 레코드는 값이 달라도 ok, 403 "not verified" 는 mail_forbidden 이 아님', async () => {
const dir = tmp(); const env = await envFor(dir); const rs = { domains: [] }; env.RESEND_FETCH = fakeResendAdmin(rs);
applyConfig(env, { inbound: 'resend', outbound: 'resend', RESEND_API_KEY: 're_good_key_123456789' });
let st = await providerStatus(env, { force: true }); eq(st.provider, 'mail_domain_missing', '미등록 도메인'); ok(st.detail.includes('example.test') && st.detail.includes('자동 설정'), '안내 문장');
rs.domains.push(failedDomain()); env.DNS_RESOLVER = fakeResolver(liveDns());
st = await providerStatus(env, { force: true });
eq(st.provider, 'mail_domain_unverified', '검증 실패 → 별도 코드'); eq(st.domain.status, 'failed', '도메인 상태'); eq(st.domain.id, 'dom-9', '도메인 id');
const by = Object.fromEntries(st.records.map((r) => [r.type + ' ' + r.name, r]));
eq(by['TXT resend._domainkey.example.test'].problem, 'wrong', 'DKIM 값 다름'); eq(by['TXT resend._domainkey.example.test'].found[0], 'p=OLDKEY', '지금 값');
eq(by['MX send.example.test'].ok, true, 'Resend 가 failed 라 해도 우리 조회가 맞으면 ok'); eq(by['TXT send.example.test'].ok, true, 'SPF TXT ok');
eq(by['MX example.test'].ok, true, 'Resend verified → 예전 MX 값이라도 ok'); eq(by['MX example.test'].problem, null, '문제 없음');
ok(st.detail.startsWith('검증 실패 — 값이 다른 레코드: TXT resend._domainkey.example.test (지금 p=OLDKEY / 필요 p=NEWKEY)'), '원인 문장: ' + st.detail);
ok(st.detail.includes('자동으로 다시 검증') && !st.detail.includes('없는 레코드'), '값 바꾸기 안내, missing 없음');
eq(await providerStatus(env), st, '60초 캐시(force 없음)');
// DKIM 레코드가 아예 없으면 missing
delete env.DNS_RESOLVER.table['TXT resend._domainkey.example.test']; clearStatusCache(env);
st = await providerStatus(env, { force: true }); eq(st.records.find((r) => r.name.startsWith('resend._domainkey')).problem, 'missing', '없음'); ok(st.detail.includes('없는 레코드: TXT resend._domainkey.example.test'), 'missing 문장');
// 레코드가 모두 보이는데 failed(사용자가 고친 뒤) → 재검증 안내; pending → 기다리는 중
env.DNS_RESOLVER.table['TXT resend._domainkey.example.test'] = ['p=NEWKEY']; st = await providerStatus(env, { force: true }); ok(st.detail.includes('검증을 다시 요청하면') && st.detail.includes('자동으로 요청'), '재검증 안내: ' + st.detail);
rs.domains[0].status = 'pending'; st = await providerStatus(env, { force: true }); eq(st.provider, 'mail_domain_unverified', 'pending 도 미검증'); ok(st.detail.startsWith('검증 진행 중 — DNS 레코드는 모두 보입니다'), 'pending 문장');
// 레코드 진단이 실패해도(도메인 상세 500) 상태는 보고
const base = env.RESEND_FETCH; env.RESEND_FETCH = async (url, o) => (new URL(url).pathname === '/domains/dom-9' ? Response.json({ message: 'boom' }, { status: 500 }) : base(url, o));
st = await providerStatus(env, { force: true }); eq(st.provider, 'mail_domain_unverified', '진단 실패해도 코드 유지'); ok(st.detail.includes('레코드 진단 실패'), '진단 실패 표시'); env.RESEND_FETCH = base;
// verified → ok
rs.domains[0].status = 'verified'; st = await providerStatus(env, { force: true }); eq(st.provider, 'ok', '검증 완료'); eq(st.records, undefined, '정상이면 레코드 진단 없음');
// 오류 코드 매핑: Resend 의 403 "not verified" 문구는 도메인 문제, 그 밖의 403 은 키 권한 문제
eq(providerError(403, 'The example.test domain is not verified. Please, add and verify your domain on https://resend.com/domains'), 'mail_domain_unverified', '403 미검증');
eq(providerError(403, 'This API key is restricted to only send emails'), 'mail_forbidden', '403 권한'); eq(providerError(403), 'mail_forbidden', '403 문구 없음'); eq(providerError(401, 'x'), 'mail_auth_failed', '401');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.11 자동 재검증과 계획 단계: 값이 다른 레코드 → 사용자 작업(action)·검증 요청 안 함, 없는 레코드 → 반영 대기(wait), 모두 보이면 검증 자동 요청(failed → pending), pending/verified/끔 → 건너뜀', async () => {
const dir = tmp(); const env = await envFor(dir); const rs = { domains: [failedDomain()], webhooks: [{ id: 'wh-1', endpoint: 'https://mail.example.test/api/webhooks/resend', events: ['email.received'], status: 'enabled' }] }; env.RESEND_FETCH = fakeResendAdmin(rs);
applyConfig(env, { inbound: 'resend', outbound: 'resend', RESEND_API_KEY: 're_good_key_123456789', RESEND_WEBHOOK_SECRET: 'whsec_x' }); saveConfig(env, { RESEND_DOMAIN_ID: 'dom-9' }, { allowHidden: true });
const resolver = fakeResolver(liveDns());
const verifies = () => rs.calls.filter((c) => c.path.endsWith('/verify')).length;
// 값이 다름 → action
let p = await buildPlan(env, { resolver }); let v = p.steps.find((s) => s.id === 'resend.verify');
eq(v.state, 'action', '값 다름 → 사용자 작업'); ok(v.title.includes('값이 다릅니다') && v.detail.includes('값이 다른 레코드: TXT resend._domainkey.example.test (지금 p=OLDKEY / 필요 p=NEWKEY)'), '어느 레코드인지: ' + v.detail);
ok(v.actions.some((a) => a.id === 'verify') && v.actions.some((a) => a.id === 'recheck'), '단추'); eq(v.auto, false, '자동 실행 안 함');
const dns = p.steps.find((s) => s.id === 'dns.apply'); eq(dns.state, 'action', 'DNS 직접 수정'); ok(dns.title.includes('고치기') && dns.detail.startsWith('값이 다른 레코드 1개'), 'DNS 단계 문구: ' + dns.detail);
const mx = p.records.find((r) => r.type === 'MX' && r.name === 'example.test'); eq(mx.check.ok, true, '예전 MX 값이지만 Resend verified → ok'); eq(mx.check.byProvider, true, 'byProvider 표시');
eq(p.records.find((r) => r.name === 'resend._domainkey.example.test').check.problem, 'wrong', '표에도 wrong');
let r = await autoVerifyDomain(env, { resolver }); eq(r.skipped, 'records', '레코드 틀림 → 요청 안 함'); eq(r.problems.join(','), 'TXT resend._domainkey.example.test: wrong', '문제 목록'); eq(verifies(), 0, 'verify 호출 없음');
// 레코드 없음 → wait
delete resolver.table['TXT resend._domainkey.example.test']; p = await buildPlan(env, { resolver }); v = p.steps.find((s) => s.id === 'resend.verify');
eq(v.state, 'wait', '없음 → 전파 대기'); ok(v.detail.includes('아직 DNS 에 없는 Resend 레코드: TXT resend._domainkey.example.test') && v.detail.includes('자동으로 검증을 요청'), '대기 문구: ' + v.detail);
eq(p.steps.find((s) => s.id === 'dns.apply').title, 'DNS 레코드 1개 넣기', '없음 → 넣기');
eq((await autoVerifyDomain(env, { resolver })).skipped, 'records', '없음 → 요청 안 함');
// 고침 → todo(자동) + 자동 재검증이 요청
resolver.table['TXT resend._domainkey.example.test'] = ['p=NEWKEY']; p = await buildPlan(env, { resolver }); v = p.steps.find((s) => s.id === 'resend.verify'); eq(v.state, 'todo', '모두 보임 → 자동 실행 가능'); eq(v.auto, true, 'auto');
eq(p.steps.find((s) => s.id === 'dns.apply').state, 'ok', 'DNS 완료');
r = await autoVerifyDomain(env, { resolver }); eq(r.requested, true, '검증 요청'); eq(r.status, 'failed', '이전 상태'); eq(rs.domains[0].status, 'pending', 'Resend 상태 pending'); eq(verifies(), 1, 'verify 1회');
eq((await providerStatus(env, { force: true })).domain.status, 'pending', '상태 캐시 비움 → 새 상태');
// 진행 중·완료·끔 → 건너뜀
r = await autoVerifyDomain(env, { resolver }); eq(r.skipped, 'pending', 'pending 건너뜀'); eq(verifies(), 1, '추가 호출 없음');
rs.domains[0].status = 'verified'; eq((await autoVerifyDomain(env, { resolver })).skipped, 'verified', 'verified 건너뜀');
rs.domains[0].status = 'failed'; env.RESEND_AUTO_VERIFY = '0'; eq((await autoVerifyDomain(env, { resolver })).skipped, 'disabled', '끔'); delete env.RESEND_AUTO_VERIFY;
rs.domains = []; eq((await autoVerifyDomain(env, { resolver })).skipped, 'missing', '미등록 건너뜀'); env.RESEND_API_KEY = ''; eq((await autoVerifyDomain(env, { resolver })).skipped, 'no_key', '키 없음');
rmSync(dir, { recursive: true, force: true });
});
done();
+225
View File
@@ -0,0 +1,225 @@
/**
* SMTP 제출 서버(server/smtp.mjs) 회귀 테스트.
* 메일 프로그램이 로그인 비밀번호로 인증하고, 게이트웨이가 내부 Resend 키로 발송한 뒤 보낸 메일함에 저장하는 흐름을 확인합니다.
* 가짜 Resend(fakeResend)로 실제 발송 요청 내용(받는 사람 분리·첨부·인라인 이미지·멱등 키)을 검사합니다.
*/
import net from 'node:net';
import tls from 'node:tls';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { makeEnv, runner, eq, ok, fakeResend } from './_helpers.mjs';
import { createSmtp, DataCollector, dotUnstuff, SMTP_MAX_SIZE } from '../server/smtp.mjs';
import { appendMessage } from '../server/store.mjs';
import { createUser } from '../server/auth.mjs';
import { writeAdminLimits } from '../server/limits.mjs';
import { buildEml } from '../server/mime.mjs';
const { t, done } = runner();
/** SMTP 응답 읽기 도우미: 여러 줄 응답("250-…")은 마지막 줄("250 …")까지 모아 한 번에 돌려줍니다. */
function client(socket) {
let buffer = ''; const waiters = [];
const onData = (d) => { buffer += d.toString('latin1'); flush(); };
socket.on('data', onData);
function flush() {
while (waiters.length) {
const m = buffer.match(/(?:^|\r\n)(\d{3}) [^\r]*\r\n/); if (!m) return;
const end = m.index + m[0].length; const w = waiters.shift(); w(buffer.slice(0, end).replace(/^\r\n/, '')); buffer = buffer.slice(end);
}
}
const reply = () => new Promise((resolve) => { waiters.push(resolve); flush(); });
return {
reply, socket,
cmd: (line) => { socket.write(line + '\r\n'); return reply(); },
raw: (data) => socket.write(data),
detach: () => socket.off('data', onData),
end: () => socket.end(),
};
}
const connect = (port) => new Promise((resolve) => { const s = net.connect(port, '127.0.0.1', () => resolve(client(s))); });
const plain64 = (user, pass) => Buffer.from(`\0${user}\0${pass}`).toString('base64');
const at = (s) => new Date(Date.now() - s * 1000).toISOString();
/** 시험용 메일: 한글 제목, 받는 사람·참조, 첨부, 인라인 이미지, 점으로 시작하는 줄을 담습니다. */
function message({ from = '아리 관리자 <admin@arihakdang.com>', to = ['Friend <friend@example.test>'], cc = ['copy@example.test'], subject = '안녕하세요 SMTP', messageId = '<smtp-1@arihakdang.com>', text = '본문입니다\n.점으로 시작하는 줄', inReplyTo = null } = {}) {
return buildEml({ from, to, cc, subject, messageId, text, html: '<p>본문 <img src="cid:logo1"></p>', headers: inReplyTo ? { 'In-Reply-To': inReplyTo } : {},
attachments: [{ filename: '보고서.txt', contentType: 'text/plain', bytes: Buffer.from('report') }, { filename: 'logo.png', contentType: 'image/png', bytes: Buffer.from('png'), contentId: 'logo1', disposition: 'inline' }] });
}
/** RFC 5321 점 스터핑 후 DATA 로 보낼 바이트(끝 표시 포함). */
const dataOf = (eml) => Buffer.concat([Buffer.from(eml.toString('latin1').replace(/(^|\r\n)\./g, '$1..'), 'latin1'), Buffer.from('.\r\n')]);
async function server(over = {}, options = {}) {
const calls = [];
const env = await makeEnv({ RESEND_FETCH: fakeResend({ calls, received: [] }), ...over });
const smtp = createSmtp(env, { hostname: 'mail.test', allowPlaintext: true, ...options });
const port = 30000 + Math.floor(Math.random() * 20000); await smtp.listen({ plainPort: port, host: '127.0.0.1' });
return { env, smtp, port, calls };
}
await t('DATA 수집기는 조각 경계·빈 본문·크기 초과를 처리하고 점 스터핑을 푼다', async () => {
let c = new DataCollector(1000); eq(c.feed(Buffer.from('a\r\n.')), null, '아직 끝 아님');
let r = c.feed(Buffer.from('\r\nQUIT\r\n')); eq(r.message.toString(), 'a\r\n', '경계에 걸친 끝 표시'); eq(r.rest.toString(), 'QUIT\r\n', '뒤 명령 보존');
c = new DataCollector(1000); r = c.feed(Buffer.from('.\r\n')); eq(r.message.length, 0, '빈 본문');
c = new DataCollector(1000); eq(c.feed(Buffer.from('.')), null, '점 한 바이트'); r = c.feed(Buffer.from('\r\n')); eq(r.message.length, 0, '빈 본문(조각)');
c = new DataCollector(5); r = c.feed(Buffer.from('0123456789\r\n.\r\n')); eq(r.message, null, '크기 초과');
eq(dotUnstuff(Buffer.from('..a\r\n..b\r\nc..\r\n')).toString(), '.a\r\n.b\r\nc..\r\n', '점 스터핑 해제');
});
await t('SMTP: 크기를 넘는 DATA 는 552 로 응답하고 세션은 계속 쓸 수 있다', async () => {
const { env, smtp, port } = await server();
try {
const c = await connect(port); await c.reply(); await c.cmd('EHLO x'); await c.cmd('AUTH PLAIN ' + plain64('admin', 'admin-secret-12'));
await c.cmd('MAIL FROM:<admin@arihakdang.com>'); await c.cmd('RCPT TO:<friend@example.test>'); ok((await c.cmd('DATA')).startsWith('354'), 'DATA');
c.raw(Buffer.concat([Buffer.from('Subject: big\r\n\r\n'), Buffer.alloc(SMTP_MAX_SIZE + 1024, 0x61), Buffer.from('\r\n.\r\n')]));
const r = await c.reply(); ok(r.startsWith('552'), '크기 초과 응답: ' + r.slice(0, 40));
ok((await c.cmd('MAIL FROM:<admin@arihakdang.com>')).startsWith('250'), '다음 트랜잭션 진행'); c.end();
eq((await env.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n, 0, '저장 없음');
} finally { smtp.close(); }
});
await t('SMTP: 로그인 비밀번호로 인증하고 내부 키로 발송하며 보낸 메일함에 저장한다', async () => {
const { env, smtp, port, calls } = await server();
try {
const c = await connect(port); ok((await c.reply()).startsWith('220 '), '인사');
ok((await c.cmd('MAIL FROM:<admin@arihakdang.com>')).startsWith('503'), 'EHLO 먼저');
const ehlo = await c.cmd('EHLO client.test'); ok(ehlo.includes('AUTH PLAIN LOGIN') && ehlo.includes('SIZE') && ehlo.includes('PIPELINING'), 'EHLO 확장');
ok((await c.cmd('MAIL FROM:<admin@arihakdang.com>')).startsWith('530'), '인증 전 발송 거부(오픈 릴레이 방지)');
ok((await c.cmd('AUTH PLAIN ' + plain64('arihakdang', 'wrong'))).startsWith('535'), '틀린 비밀번호');
// AUTH LOGIN 대화형: 사용자 이름 → 비밀번호
ok((await c.cmd('AUTH LOGIN')).startsWith('334 VXNlcm5hbWU6'), 'Username 질문');
ok((await c.cmd(Buffer.from('admin').toString('base64'))).startsWith('334 UGFzc3dvcmQ6'), 'Password 질문');
ok((await c.cmd(Buffer.from('admin-secret-12').toString('base64'))).startsWith('235'), '로그인 비밀번호로 인증(별칭 admin)');
ok((await c.cmd('MAIL FROM:<someone@else.test>')).startsWith('550'), '남의 주소로 보내기 거부');
ok((await c.cmd('MAIL FROM:<admin@arihakdang.com> SIZE=999999999')).startsWith('552'), 'SIZE 초과');
// PIPELINING: MAIL·RCPT·RCPT·RCPT·DATA 를 한 번에 보냅니다. 숨은 참조(hidden@)는 헤더에 없고 RCPT 에만 있습니다.
c.raw('MAIL FROM:<admin@arihakdang.com>\r\nRCPT TO:<friend@example.test>\r\nRCPT TO:<Copy@Example.test>\r\nRCPT TO:<hidden@example.test>\r\nDATA\r\n');
for (const expect of ['250', '250', '250', '250', '354']) ok((await c.reply()).startsWith(expect), '파이프라인 ' + expect);
const eml = message(); c.raw(dataOf(eml));
const done250 = await c.reply(); ok(done250.startsWith('250 2.0.0 OK queued as sent-'), '발송 완료: ' + done250);
const post = calls.find((x) => x.method === 'POST' && x.path === '/emails'); ok(post, 'Resend 발송 호출');
eq(post.body.from, '아리 관리자 <admin@arihakdang.com>', '보내는 사람 표시 이름'); eq(post.body.to.join(), 'Friend <friend@example.test>', '받는 사람');
eq(post.body.cc.join(), 'copy@example.test', '참조'); eq(post.body.bcc.join(), 'hidden@example.test', '숨은 참조는 봉투에서');
eq(post.body.subject, '안녕하세요 SMTP', '한글 제목 해독'); ok(post.body.text.includes('\n.점으로 시작하는 줄'), '점 스터핑 해제된 본문');
ok(post.body.html.includes('cid:logo1'), 'HTML 본문');
const files = post.body.attachments, report = files.find((f) => f.filename === '보고서.txt'), logo = files.find((f) => f.filename === 'logo.png');
eq(files.length, 2, '첨부 2개'); ok(report && !report.content_id, '일반 첨부(한글 이름)'); eq(Buffer.from(report.content, 'base64').toString(), 'report', '첨부 내용');
eq(logo.content_id, 'logo1', '인라인 이미지 cid'); ok(post.key && post.key.startsWith('smtp-'), '멱등 키');
const row = await env.DB.prepare("SELECT * FROM messages WHERE folder='sent'").first();
ok(row && row.provider_id.startsWith('sent-'), '보낸 메일함 + 발송 id'); eq(row.mailbox, 'admin@arihakdang.com', '메일함'); eq(row.state, 'sent', '상태');
eq(JSON.parse(row.bcc_json).join(), 'hidden@example.test', '숨은 참조 기록'); ok(row.read_at, '읽음');
const raw = await env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(row.id).first(); ok(Buffer.from(raw.eml).equals(eml), '원본 그대로 저장');
// 메일 프로그램이 IMAP 으로 보낸 메일함에 같은 메일을 올리면 한 통으로 합쳐집니다.
const dup = await appendMessage(env, 'sent', eml, { flags: ['\\Seen'], mailbox: 'admin@arihakdang.com' }); eq(dup.id, row.id, 'APPEND 중복 합침');
eq((await env.DB.prepare("SELECT COUNT(*) n FROM messages WHERE folder='sent'").first()).n, 1, '보낸 메일함 1통');
ok((await c.cmd('QUIT')).startsWith('221'), 'QUIT'); c.end();
} finally { smtp.close(); }
});
await t('SMTP: 반대 순서(APPEND 먼저)·답장 표시·일반 사용자 발신 주소 제한·발송 실패 응답', async () => {
const { env, smtp, port, calls } = await server();
try {
// APPEND 가 먼저 와도 SMTP 발송은 그 사본에 발송 id 만 붙입니다.
const parentId = (await appendMessage(env, 'inbox', buildEml({ from: 'friend@example.test', to: ['admin@arihakdang.com'], subject: '질문', messageId: '<q@example.test>', text: '?' }))).id;
const eml = message({ messageId: '<smtp-2@arihakdang.com>', inReplyTo: '<q@example.test>', cc: [] });
await appendMessage(env, 'sent', eml, { flags: ['\\Seen'], mailbox: 'admin@arihakdang.com' });
let c = await connect(port); await c.reply(); await c.cmd('EHLO x'); ok((await c.cmd('AUTH PLAIN ' + plain64('arihakdang', 'admin-secret-12'))).startsWith('235'), 'AUTH PLAIN');
await c.cmd('MAIL FROM:<admin@arihakdang.com>'); await c.cmd('RCPT TO:<friend@example.test>'); ok((await c.cmd('DATA')).startsWith('354'), 'DATA');
// 끝 표시를 여러 조각으로 나눠 보냅니다.
const data = dataOf(eml); c.raw(data.subarray(0, data.length - 4)); await new Promise((r) => setTimeout(r, 30)); c.raw(data.subarray(data.length - 4));
ok((await c.reply()).startsWith('250'), '조각난 끝 표시');
eq((await env.DB.prepare("SELECT COUNT(*) n FROM messages WHERE folder='sent'").first()).n, 1, 'APPEND 사본과 합침');
ok((await env.DB.prepare("SELECT provider_id FROM messages WHERE folder='sent'").first()).provider_id, '사본에 발송 id');
eq(calls.find((x) => x.method === 'POST').body.headers['In-Reply-To'], '<q@example.test>', '답장 헤더 전달');
ok((await env.DB.prepare('SELECT flags FROM imap_flags WHERE message_id=?').bind(parentId).first()).flags.includes('\\Answered'), '원래 메일에 답장함 표시');
// 헤더 From 이 다른 주소면 거부합니다(봉투는 맞아도).
await c.cmd('MAIL FROM:<admin@arihakdang.com>'); await c.cmd('RCPT TO:<friend@example.test>'); await c.cmd('DATA');
c.raw(dataOf(message({ from: 'CEO <ceo@arihakdang.com>', messageId: '<x@y>' }))); ok((await c.reply()).startsWith('550 5.7.1'), '헤더 From 사칭 거부');
c.end();
// 일반 사용자는 자기 메일함 주소로만 보낼 수 있습니다.
await createUser(env, { login: 'info', password: 'staff-secret-1' }, 'test');
c = await connect(port); await c.reply(); await c.cmd('EHLO x'); ok((await c.cmd('AUTH PLAIN ' + plain64('info', 'staff-secret-1'))).startsWith('235'), '게이트웨이 계정 인증');
ok((await c.cmd('MAIL FROM:<admin@arihakdang.com>')).startsWith('550'), '다른 메일함 주소 거부');
ok((await c.cmd('MAIL FROM:<info@arihakdang.com>')).startsWith('250'), '자기 주소 허용');
await c.cmd('RCPT TO:<friend@example.test>'); await c.cmd('DATA'); c.raw(dataOf(message({ from: 'Staff <info@arihakdang.com>', messageId: '<s@y>', cc: [] })));
ok((await c.reply()).startsWith('250'), '직원 발송');
eq((await env.DB.prepare("SELECT mailbox FROM messages WHERE message_id='<s@y>'").first()).mailbox, 'info@arihakdang.com', '직원 보낸 메일함');
c.end();
} finally { smtp.close(); }
// 발송 서비스 장애는 임시 실패(4xx), 키·설정 정보는 응답에 넣지 않습니다.
const failing = await server({ RESEND_FETCH: fakeResend({ failStatus: 401 }) });
try {
const c = await connect(failing.port); await c.reply(); await c.cmd('EHLO x'); await c.cmd('AUTH PLAIN ' + plain64('admin', 'admin-secret-12'));
await c.cmd('MAIL FROM:<admin@arihakdang.com>'); await c.cmd('RCPT TO:<friend@example.test>'); await c.cmd('DATA'); c.raw(dataOf(message({ cc: [] })));
const r = await c.reply(); ok(r.startsWith('451 4.3.0') && !/key|resend/i.test(r), '임시 실패, 키 정보 숨김: ' + r);
eq((await failing.env.DB.prepare("SELECT COUNT(*) n FROM messages").first()).n, 0, '실패하면 저장하지 않음'); c.end();
} finally { failing.smtp.close(); }
});
await t('SMTP: TLS 없이는 로그인을 받지 않고 STARTTLS 뒤에 인증·발송한다', async () => {
const dir = mkdtempSync(join(tmpdir(), 'mg-smtp-'));
try {
try { execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', join(dir, 'k.pem'), '-out', join(dir, 'c.pem'), '-days', '1', '-subj', '/CN=mail.test'], { stdio: 'ignore' }); }
catch { console.log(' (openssl 없음 — STARTTLS 시험 생략)'); return; }
const tlsOptions = { key: readFileSync(join(dir, 'k.pem')), cert: readFileSync(join(dir, 'c.pem')) };
const { smtp, port, calls } = await server({}, { allowPlaintext: false, tlsOptions });
try {
const c = await connect(port); await c.reply();
const ehlo = await c.cmd('EHLO x'); ok(ehlo.includes('STARTTLS') && !ehlo.includes('AUTH'), '평문에서는 AUTH 광고 안 함');
ok((await c.cmd('AUTH PLAIN ' + plain64('admin', 'admin-secret-12'))).startsWith('530'), '평문 로그인 거부');
ok((await c.cmd('STARTTLS')).startsWith('220'), 'STARTTLS'); c.detach();
const secure = tls.connect({ socket: c.socket, rejectUnauthorized: false }); await new Promise((r) => secure.once('secureConnect', r));
const s = client(secure);
const ehlo2 = await s.cmd('EHLO x'); ok(ehlo2.includes('AUTH PLAIN LOGIN') && !ehlo2.includes('STARTTLS'), 'TLS 뒤 AUTH 광고');
ok((await s.cmd('AUTH PLAIN ' + plain64('admin', 'admin-secret-12'))).startsWith('235'), 'TLS 위 인증');
await s.cmd('MAIL FROM:<admin@arihakdang.com>'); await s.cmd('RCPT TO:<friend@example.test>'); await s.cmd('DATA'); s.raw(dataOf(message({ cc: [] })));
ok((await s.reply()).startsWith('250'), 'TLS 위 발송'); ok(calls.some((x) => x.method === 'POST'), 'Resend 호출'); s.end();
} finally { smtp.close(); }
} finally { rmSync(dir, { recursive: true, force: true }); }
});
await t('0.8.25 오류 수정: 같은 DATA 를 받는 사람만 바꿔 따로 보내면 멱등 키가 달라 둘 다 발송된다', async () => {
const calls = [], env = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) });
const smtp = createSmtp(env, { hostname: 'mail.test', allowPlaintext: true }); const port = 31000 + Math.floor(Math.random() * 9000); await smtp.listen({ plainPort: port, host: '127.0.0.1' });
try {
const c = client(await new Promise((res) => { const s = net.connect(port, '127.0.0.1', () => res(s)); }));
await c.reply(); await c.cmd('EHLO x'); await c.cmd('AUTH PLAIN ' + Buffer.from('\0arihakdang\0admin-secret-12').toString('base64'));
const data = 'From: admin@arihakdang.com\r\nSubject: newsletter\r\nMessage-ID: <nl-1@arihakdang.com>\r\n\r\nhello\r\n.\r\n';
for (const rcpt of ['alice@example.test', 'bob@example.test']) { await c.cmd('MAIL FROM:<admin@arihakdang.com>'); await c.cmd('RCPT TO:<' + rcpt + '>'); await c.cmd('DATA'); c.raw(data); ok(/^250 /.test(await c.reply()), rcpt + ' 발송'); }
await c.cmd('QUIT');
const posts = calls.filter((x) => x.path === '/emails' && x.method === 'POST'); eq(posts.length, 2, '두 번 발송');
ok(posts[0].key !== posts[1].key, '멱등 키가 다름'); eq(posts[0].body.bcc[0], 'alice@example.test', '첫 받는 사람'); eq(posts[1].body.bcc[0], 'bob@example.test', '둘째 받는 사람');
// 같은 봉투·같은 DATA 를 되풀이하면(응답을 못 받은 재전송) 키가 같습니다.
const c2 = client(await new Promise((res) => { const s = net.connect(port, '127.0.0.1', () => res(s)); }));
await c2.reply(); await c2.cmd('EHLO x'); await c2.cmd('AUTH PLAIN ' + Buffer.from('\0arihakdang\0admin-secret-12').toString('base64'));
await c2.cmd('MAIL FROM:<admin@arihakdang.com>'); await c2.cmd('RCPT TO:<alice@example.test>'); await c2.cmd('DATA'); c2.raw(data); await c2.reply(); await c2.cmd('QUIT');
const again = calls.filter((x) => x.path === '/emails' && x.method === 'POST'); eq(again[2].key, posts[0].key, '재전송은 같은 키');
} finally { smtp.close(); }
});
await t('0.9.0: 관리자도 자기 주소로만 보내고, 받는 사람 수·첨부 크기·메일함 용량 제한이 SMTP 응답으로 돌아오며, Content-ID 만 있는 그림도 인라인으로 나간다', async () => {
const { env, smtp, port, calls } = await server({ LIMIT_RECIPIENTS: '2', LIMIT_ATTACHMENT_BYTES: '100K', LIMIT_MAILBOX_BYTES: '1M' });
try {
await createUser(env, { login: 'info', password: 'staff-secret-1' }, 'test');
let c = await connect(port); await c.reply(); await c.cmd('EHLO x'); await c.cmd('AUTH PLAIN ' + plain64('arihakdang', 'admin-secret-12'));
ok((await c.cmd('MAIL FROM:<info@arihakdang.com>')).startsWith('550'), '관리자도 다른 계정의 메일함 주소로는 보내지 못함(0.9.0)');
ok((await c.cmd('MAIL FROM:<admin@arihakdang.com>')).startsWith('250'), '자기 주소');
await c.cmd('RCPT TO:<a@example.test>'); await c.cmd('RCPT TO:<b@example.test>');
ok((await c.cmd('RCPT TO:<c@example.test>')).startsWith('452 4.5.3'), '받는 사람 수(시스템 2) 초과');
ok((await c.cmd('RCPT TO:<a@example.test>')).startsWith('250'), '이미 있는 주소는 한도에서도 허용(0.9.3)');
// 첨부 한 개가 100KB(MIME) 를 넘으면 552.
await c.cmd('DATA'); c.raw(dataOf(buildEml({ from: 'admin@arihakdang.com', to: ['a@example.test'], subject: 'big', text: 'x', attachments: [{ filename: 'big.bin', contentType: 'application/octet-stream', bytes: Buffer.alloc(90 * 1024, 7) }] })));
const big = await c.reply(); ok(big.startsWith('552 5.3.4') && /MIME/.test(big), '첨부 초과: ' + big);
// 관리자가 더 줄인 수신자 수(1)도 적용됩니다.
await writeAdminLimits(env, { recipients: 1 });
await c.cmd('MAIL FROM:<admin@arihakdang.com>'); await c.cmd('RCPT TO:<a@example.test>'); ok((await c.cmd('RCPT TO:<b@example.test>')).startsWith('452'), '관리자 제한 1');
// Content-Disposition 없이 Content-ID 만 있는 그림(Outlook 류)도 본문이 cid: 로 가리키면 content_id 를 넘깁니다.
const b = 'BB'; const raw = Buffer.from(['From: admin@arihakdang.com', 'To: a@example.test', 'Subject: cid', 'Message-ID: <cid-1@arihakdang.com>', 'MIME-Version: 1.0', `Content-Type: multipart/related; boundary="${b}"`, '', `--${b}`, 'Content-Type: text/html; charset=utf-8', '', '<p><img src="cid:logo1"></p>', `--${b}`, 'Content-Type: image/png', 'Content-ID: <logo1>', 'Content-Transfer-Encoding: base64', '', Buffer.from('png').toString('base64'), `--${b}--`, ''].join('\r\n'));
await c.cmd('DATA'); c.raw(dataOf(raw)); ok((await c.reply()).startsWith('250'), 'cid 메일 발송');
const post = calls.filter((x) => x.method === 'POST' && x.path === '/emails').at(-1); eq(post.body.attachments[0].content_id, 'logo1', 'content_id 전달');
// 메일함이 가득 차면 452 4.2.2.
await env.DB.prepare("INSERT INTO messages(id,direction,folder,sender,subject,state,received_at,created_at,updated_at,mailbox,raw_size) VALUES('fill','inbound','inbox','x','s','received','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z','admin@arihakdang.com',1100000)").run();
await c.cmd('MAIL FROM:<admin@arihakdang.com>'); await c.cmd('RCPT TO:<a@example.test>'); await c.cmd('DATA'); c.raw(dataOf(message({ cc: [], messageId: '<full@x>' })));
ok((await c.reply()).startsWith('452 4.2.2'), '메일함 가득 참');
c.end();
} finally { smtp.close(); }
});
done();
+162
View File
@@ -0,0 +1,162 @@
/**
* 1.0.9 시험: 메일 사용량 정보 제공 API(server/usage.mjs) — 키 생성·교체·삭제(설정 API), 인증(없음/틀림/맞음), IP 허용 목록(IPv4 CIDR·IPv6), 시도 제한,
* 보고서 내용(계정·저장 용량·메일 수·기간 통계·일별 추이·계정별 기간 합계·외부(웹메이커) 계정 수), 도메인 하나/전체, 쿼리(days·accounts·series), Resend 연결 오류 1회 재시도.
* 실행: node test/usage.test.mjs (npm test 와 deploy.sh 가 함께 돌립니다)
*/
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
import { scryptSync, randomBytes } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeEnv, runner, eq, ok, request, params } from './_helpers.mjs';
import { handle } from '../server/api.mjs';
import { loadConfig, setupToken } from '../server/config.mjs';
import { openTenants } from '../server/domains.mjs';
import { setupApi } from '../server/setup.mjs';
import { usageApi, authorizeUsage, ipAllowed, parseAllowList, invalidAllowEntries, clientIp, domainUsage, USAGE_FAIL_LIMIT } from '../server/usage.mjs';
import { periodBounds } from '../server/limits.mjs';
import { createUser } from '../server/auth.mjs';
import { resend } from '../server/providers/resend.mjs';
import { clearSnapshotCache } from '../server/webmaker.mjs';
const { t, done } = runner();
const HERE = dirname(fileURLToPath(import.meta.url)), ROOT = join(HERE, '..');
const tmp = (p = 'arimail-usage-') => mkdtempSync(join(tmpdir(), p));
const api = (env, path, cookie, method, body, headers) => handle(env, request(path, cookie, method, body, headers), params(path));
async function base(over = {}) {
const dir = tmp();
const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), PUBLIC_HOST: 'mail.arihakdang.com', ...over });
loadConfig(env); return { env, dir };
}
const open = (env) => openTenants(env, { migrationsDir: join(ROOT, 'migrations'), log: { log() {}, error() {} } });
const setup = (env, path, method = 'GET', body, tenants = null) => setupApi(env, new Request('https://mail.arihakdang.com/api/setup/' + path, { method, headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) }), path.split('/'), { ip: '127.0.0.1', tenants });
/** /api/usage 호출 흉내(main.mjs 가 하는 것처럼 path 조각·ip·쿼리를 넘김). */
const usage = (tenants, path = '', { key, ip = '203.0.113.10', query = '' } = {}) => usageApi(tenants, new Request('https://x/api/usage' + path + (query ? '?' + query : ''), { headers: key ? { Authorization: 'Bearer ' + key } : {} }), { path: path.split('/').filter(Boolean), ip, query: new URLSearchParams(query) });
/** 메일 행 넣기(통계용): direction, 시각, 크기, 메일함. */
async function putMail(env, { direction, at, size = 1000, mailbox, state }) {
const id = randomBytes(8).toString('hex'), outbound = direction === 'outbound';
await env.DB.prepare(`INSERT INTO messages(id,direction,folder,sender,recipients_json,subject,text,source,state,received_at,created_at,updated_at,sent_at,raw_status,raw_size,mailbox) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
.bind(id, direction, outbound ? 'sent' : 'inbox', 'a@b.test', '["x@y.test"]', 's', 'body', outbound ? 'web' : 'sync', state || (outbound ? 'sent' : 'received'), at, at, at, outbound ? at : null, 'stored', size, mailbox).run();
return id;
}
const daysAgo = (n, hour = 12) => { const d = new Date(); d.setUTCDate(d.getUTCDate() - n); d.setUTCHours(hour, 0, 0, 0); return d.toISOString(); };
await t('IP 허용 목록: 빈 목록은 모두 허용, IPv4 정확히·CIDR, IPv6 정확히·접두사, ::ffff: 로 감싼 IPv4, 잘못된 주소는 거부', () => {
eq(parseAllowList(' 1.2.3.4, 10.0.0.0/8 ,2001:db8::1 ').join('|'), '1.2.3.4|10.0.0.0/8|2001:db8::1', '파싱');
ok(ipAllowed('9.9.9.9', []), '빈 목록'); ok(ipAllowed('1.2.3.4', ['1.2.3.4']), '정확히'); ok(!ipAllowed('1.2.3.5', ['1.2.3.4']), '다른 주소');
ok(ipAllowed('10.20.30.40', ['10.0.0.0/8']) && !ipAllowed('11.0.0.1', ['10.0.0.0/8']) && ipAllowed('192.168.1.77', ['192.168.1.64/26']) && !ipAllowed('192.168.1.128', ['192.168.1.64/26']), 'CIDR');
ok(ipAllowed('::ffff:10.1.1.1', ['10.0.0.0/8']), 'IPv6 소켓의 IPv4'); ok(ipAllowed('2001:db8::1', ['2001:DB8::1']) && ipAllowed('2001:db8:0:0:0:0:0:2', ['2001:db8::/32']) && !ipAllowed('2001:db9::1', ['2001:db8::/32']), 'IPv6');
ok(!ipAllowed('not-an-ip', ['0.0.0.0/0']) && !ipAllowed('', ['1.2.3.4']), '잘못된 주소'); ok(ipAllowed('8.8.8.8', ['0.0.0.0/0']), '/0 은 모두');
ok(ipAllowed('1.2.3.4', ['garbage', '1.2.3.4']), '목록의 잘못된 항목은 건너뜀');
eq(invalidAllowEntries('1.2.3.4, 10.0.0.0/8, 2001:db8::/32').length, 0, '유효한 목록'); eq(invalidAllowEntries('1.2.3.4/33, webmaker.example.com, 10.0.0.0/8/x, 300.1.1.1').join('|'), '1.2.3.4/33|webmaker.example.com|10.0.0.0/8/x|300.1.1.1', '잘못된 항목'); eq(invalidAllowEntries('').length, 0, '빈 값');
// 접속 IP: 클라이언트가 넣을 수 있는 헤더(XFF 첫 값, CF-Connecting-IP)가 아니라 nginx 가 보증하는 값(X-Real-IP, XFF 마지막)
const H = (o) => new Headers(o);
eq(clientIp(H({ 'x-forwarded-for': '203.0.113.10, 198.51.100.7' }), '127.0.0.1'), '198.51.100.7', 'XFF 마지막(nginx 가 덧붙인 실제 주소)');
eq(clientIp(H({ 'x-real-ip': '198.51.100.8', 'x-forwarded-for': '203.0.113.10, 1.1.1.1' }), '127.0.0.1'), '198.51.100.8', 'X-Real-IP 우선');
eq(clientIp(H({ 'cf-connecting-ip': '203.0.113.10', 'x-forwarded-for': '9.9.9.9' }), '127.0.0.1'), '9.9.9.9', 'CF-Connecting-IP 는 기본적으로 무시');
eq(clientIp(H({}), '10.0.0.5'), '10.0.0.5', '헤더 없으면 소켓 주소');
});
await t('키 관리(설정 API): 키가 없으면 API 404, 만들면 한 번만 돌려주고 설정에는 가린 값, 다시 만들면 옛 키 무효, 삭제하면 다시 404, 추가 도메인에서는 primary_only', async () => {
const { env } = await base(); const tenants = open(env); await tenants.add({ domain: 'second.test', adminPassword: 'secondpw1' });
setupToken(env);
eq((await usage(tenants, '', { key: 'anything' })).status, 404, '키 없음 → API 꺼짐'); eq((await (await usage(tenants, '')).json()).error, 'usage_api_disabled', '오류 코드');
let r = await (await setup(env, 'usage-key', 'POST', { action: 'generate' }, tenants)).json(); ok(r.ok && r.key.startsWith('au_') && r.key.length > 40, '키 생성: ' + JSON.stringify(r).slice(0, 80)); eq(r.endpoint, 'https://mail.arihakdang.com/api/usage', '주소');
const key1 = r.key; eq(env.USAGE_API_KEY, key1, 'env 에 적용');
const cfg = await (await setup(env, 'config', 'GET', undefined, tenants)).json(); eq(cfg.present.USAGE_API_KEY, true, 'hidden 항목은 있음/없음만'); ok(!JSON.stringify(cfg).includes(key1), '설정 응답에 키 원문 없음'); eq(cfg.meta.usageApi.enabled, true, 'meta'); ok(cfg.meta.usageApi.keyMasked.includes('••••'), '가린 값'); ok(cfg.meta.usageApi.updatedAt, '생성 시각');
eq((await usage(tenants, '', { key: key1 })).status, 200, '새 키로 200');
r = await (await setup(env, 'usage-key', 'POST', { action: 'generate' }, tenants)).json(); ok(r.key !== key1, '교체'); eq((await usage(tenants, '', { key: key1 })).status, 401, '옛 키 무효'); eq((await usage(tenants, '', { key: r.key })).status, 200, '새 키');
eq((await setup(tenants.byDomain('second.test').env, 'usage-key', 'POST', { action: 'generate' }, tenants)).status, 409, '추가 도메인에서는 불가');
eq((await setup(env, 'usage-key', 'POST', { action: 'nope' }, tenants)).status, 400, '모르는 동작');
r = await (await setup(env, 'usage-key', 'POST', { action: 'revoke' }, tenants)).json(); eq(r.enabled, false, '삭제'); eq((await usage(tenants, '', { key: key1 })).status, 404, '다시 꺼짐'); eq(env.USAGE_API_KEY, '', 'env 비움');
tenants.close();
});
await t('인증: Bearer 또는 X-Usage-Key, 틀린 키는 401 이고 IP 마다 10회 뒤 429, 허용 IP 밖은 403, GET 만, 성공 호출은 감사 기록', async () => {
const { env } = await base(); const tenants = open(env); setupToken(env);
const { key } = await (await setup(env, 'usage-key', 'POST', { action: 'generate' }, tenants)).json();
eq((await usageApi(tenants, new Request('https://x/api/usage', { headers: { 'X-Usage-Key': key } }), { path: [], ip: '1.1.1.1', query: new URLSearchParams() })).status, 200, 'X-Usage-Key');
eq((await usage(tenants, '', {})).status, 401, '키 없음'); eq((await usage(tenants, '', { key: key + 'x' })).status, 401, '틀린 키');
for (let i = 0; i < USAGE_FAIL_LIMIT; i++) await usage(tenants, '', { key: 'wrong', ip: '5.5.5.5' });
eq((await usage(tenants, '', { key, ip: '5.5.5.5' })).status, 429, '실패 10회 뒤 맞는 키도 막힘(15분)'); eq((await usage(tenants, '', { key, ip: '5.5.5.6' })).status, 200, '다른 IP 는 정상');
// 허용 IP
env.USAGE_API_ALLOW = '203.0.113.0/24, 2001:db8::/32';
eq((await usage(tenants, '', { key, ip: '203.0.113.77' })).status, 200, '허용 범위'); eq((await (await usage(tenants, '', { key, ip: '198.51.100.1' })).json()).error, 'forbidden_ip', '범위 밖'); eq((await usage(tenants, '', { key, ip: '2001:db8:1::9' })).status, 200, 'IPv6 허용');
const a = authorizeUsage(env, new Request('https://x/', { headers: { Authorization: 'Bearer ' + key } }), '198.51.100.1'); eq(a.status, 403, 'authorizeUsage 직접');
env.USAGE_API_ALLOW = '';
eq((await usageApi(tenants, new Request('https://x/api/usage', { method: 'POST', headers: { Authorization: 'Bearer ' + key } }), { path: [], ip: '9.9.9.9', query: new URLSearchParams() })).status, 405, 'GET 만');
eq((await usage(tenants, '/%E0%A4%A', { key })).status, 404, '잘못된 퍼센트 인코딩은 404(500 아님)');
// 허용 IP 저장 검증(설정 API): 잘못된 항목이 있으면 422 로 거부해 모든 호출이 403 이 되는 사고를 막음
const bad = await setup(env, 'config', 'PUT', { values: { USAGE_API_ALLOW: '203.0.113.10, webmaker.example.com' } }, tenants); eq(bad.status, 422, '잘못된 허용 목록 거부'); eq((await bad.json()).detail, 'webmaker.example.com', '어느 항목인지');
eq((await setup(env, 'config', 'PUT', { values: { USAGE_API_ALLOW: '203.0.113.0/24' } }, tenants)).status, 200, '유효한 목록 저장'); eq(env.USAGE_API_ALLOW, '203.0.113.0/24', '적용'); env.USAGE_API_ALLOW = '';
const logs = (await env.DB.prepare("SELECT action,actor,target FROM audit_log WHERE action='usage_read'").all()).results; ok(logs.length >= 3 && logs.every((l) => l.actor === 'usage-api'), '감사 기록: ' + logs.length);
tenants.close();
});
await t('보고서: 계정 수(로컬+웹메이커)·저장 용량·메일 수·오늘/이번 주/이달 통수와 바이트·일별 추이·계정별 기간 합계·지운 계정의 메일함·제한 값, 쿼리 days/accounts/series', async () => {
clearSnapshotCache();
const { env } = await base({ LIMIT_ACCOUNTS: '10', LIMIT_TOTAL_BYTES: '1000000', LIMIT_MONTHLY_MESSAGES: '100' }); const tenants = open(env); setupToken(env);
const { key } = await (await setup(env, 'usage-key', 'POST', { action: 'generate' }, tenants)).json();
// 둘째 도메인: webmaker 모드(관리자 admin + 회원 guest = 외부 계정 2) + 로컬 계정 1
const wm = tmp('arimail-wm-'); mkdirSync(join(wm, 'sites')); mkdirSync(join(wm, 'modules'));
const h = (pw, salt) => scryptSync(pw, salt, 64).toString('hex'); const salt = randomBytes(16).toString('hex');
writeFileSync(join(wm, 'users.json'), JSON.stringify({ admin: { id: 'admin', name: '관리자', salt, passHash: h('pw', salt), role: 'admin' }, guest: { id: 'guest', name: '손님', salt, passHash: h('pw', salt), role: 'user' } }));
await tenants.add({ domain: 'second.test', auth: { mode: 'webmaker', webmakerDir: wm } });
const sec = tenants.byDomain('second.test').env; await createUser(sec, { login: 'extra', password: 'extra-pw-12' }, 'test');
// 기본 도메인 메일: 오늘 발신 2(3000B)·수신 1(500B), 어제 수신 2, 40일 전 발신 1(기간 밖), 초안 1, 실패 1, 지운 계정의 메일함에 수신 1
await createUser(env, { login: 'kim', password: 'kim-pw-1234' }, 'test');
const today = daysAgo(0), yesterday = daysAgo(1);
await putMail(env, { direction: 'outbound', at: today, size: 1000, mailbox: 'kim@arihakdang.com' }); await putMail(env, { direction: 'outbound', at: today, size: 2000, mailbox: 'kim@arihakdang.com' });
await putMail(env, { direction: 'inbound', at: today, size: 500, mailbox: 'kim@arihakdang.com' }); await putMail(env, { direction: 'inbound', at: yesterday, size: 100, mailbox: 'admin@arihakdang.com' }); await putMail(env, { direction: 'inbound', at: yesterday, size: 100, mailbox: 'admin@arihakdang.com' });
await putMail(env, { direction: 'outbound', at: daysAgo(40), size: 700, mailbox: 'kim@arihakdang.com' });
await putMail(env, { direction: 'outbound', at: today, size: 10, mailbox: 'kim@arihakdang.com', state: 'draft' }); await putMail(env, { direction: 'outbound', at: today, size: 10, mailbox: 'kim@arihakdang.com', state: 'failed' });
await putMail(env, { direction: 'inbound', at: today, size: 300, mailbox: 'gone@arihakdang.com' });
// 전체 요약
const all = await (await usage(tenants, '', { key })).json();
eq(all.totals.domains, 2, '도메인 수'); eq(all.primary, 'arihakdang.com', '기본'); eq(all.domains[0].domain, 'arihakdang.com', '기본 먼저'); ok(!all.domains[0].accountList && !all.domains[0].daily, '요약에는 계정 목록·일별 없음');
const p = all.domains[0];
eq(p.accounts.local, 1, '로컬 계정'); eq(p.accounts.limit, 10, '계정 제한'); eq(p.messages.total, 9, '메일 수'); eq(p.messages.inbound, 4, '수신'); eq(p.messages.outbound, 3, '발신 완료'); eq(p.messages.drafts, 1, '초안'); eq(p.messages.failed, 1, '실패');
eq(p.storage.bytes, 1000 + 2000 + 500 + 200 + 700 + 20 + 300, '저장 용량(모든 원본 크기)'); eq(p.storage.limit, 1000000, '제한'); eq(p.storage.percent, 0, '비율'); eq(p.storage.largeFiles.count, 0, '대용량 파일');
eq(p.traffic.day.sent, 2, '오늘 발신'); eq(p.traffic.day.sentBytes, 3000, '오늘 발신 바이트'); eq(p.traffic.day.received, 2, '오늘 수신(gone 포함)'); eq(p.traffic.month.total >= 4, true, '이달'); eq(p.traffic.monthlyLimit, 100, '월간 제한'); eq(p.limits.system.accounts, 10, '시스템 제한');
eq(p.authMode, 'www', '로그인 방식'); eq(p.inbound, 'resend', '수신 방식'); eq(p.enabled, true, '사용 중'); eq(p.hosts[0], 'mail.arihakdang.com', '호스트');
const s = all.domains[1]; eq(s.accounts.local, 1, '둘째 로컬'); eq(s.accounts.external, 2, '둘째 웹메이커 계정'); eq(s.accounts.total, 3, '합계'); eq(s.accounts.externalSource.kind, 'webmaker', '외부 출처'); eq(s.authMode, 'webmaker', '둘째 방식');
// 도메인 상세: 일별·계정 목록 기본 포함
const d = await (await usage(tenants, '/arihakdang.com', { key, query: 'days=7' })).json();
eq(d.domain, 'arihakdang.com', '상세'); eq(d.period.days, 7, '기간'); eq(d.daily.length, 7, '일별 7칸'); const last = d.daily[6]; eq(last.sent, 2, '오늘 칸 발신'); eq(last.received, 2, '오늘 칸 수신'); eq(last.sentBytes, 3000, '오늘 바이트'); eq(d.daily[5].received, 2, '어제 칸'); ok(/^\d{4}-\d{2}-\d{2}$/.test(last.date), '날짜 형식');
const kim = d.accountList.find((a) => a.login === 'kim'); ok(kim, '계정 행'); eq(kim.source, 'local', '출처'); eq(kim.messages, 6, 'kim 메일 수'); eq(kim.bytes, 1000 + 2000 + 500 + 700 + 20, 'kim 용량'); eq(kim.sent, 2, '기간 발신(40일 전 제외)'); eq(kim.received, 1, '기간 수신'); eq(kim.sentBytes, 3000, '기간 발신 바이트'); eq(kim.limit, 0, '크기 제한 없음');
const adminRow = d.accountList.find((a) => a.mailbox === 'admin@arihakdang.com'); ok(adminRow && adminRow.source === 'www', 'www 관리자 메일함 행'); eq(adminRow.received, 2, '관리자 수신');
const gone = d.accountList.find((a) => a.mailbox === 'gone@arihakdang.com'); ok(gone && gone.source === 'orphan' && gone.status === 'deleted' && gone.messages === 1, '지운 계정의 메일함');
const d2 = await (await usage(tenants, '/second.test', { key, query: 'days=3&series=0' })).json(); ok(!d2.daily, 'series=0'); eq(d2.accountList.length, 3, '둘째 계정 목록(로컬 1 + 웹메이커 2)'); eq(d2.accountList.filter((a) => a.source === 'webmaker').length, 2, '웹메이커 행');
eq((await usage(tenants, '/nope.test', { key })).status, 404, '모르는 도메인');
const d3 = await (await usage(tenants, '', { key, query: 'accounts=1&days=9999' })).json(); ok(d3.domains[0].accountList, 'accounts=1 로 요약에도 계정 목록'); eq(d3.domains[0].period.days, 30, '범위 밖 days 는 30');
// 비밀값이 섞이지 않는지
const text = JSON.stringify(all) + JSON.stringify(d); ok(!text.includes(key) && !text.includes('RESEND') && !text.includes('password'), '키·비밀값 없음');
const direct = await domainUsage(env, { days: 2, accounts: false, series: true }); eq(direct.daily.length, 2, 'domainUsage 직접');
// 서머타임 시간대: 기간 시작이 늘 그 날의 0시(통계 시간대)이고 첫 칸의 날짜와 같다(24시간씩 빼기만 하면 한 시간 밀림)
env.STATS_TIMEZONE = 'America/New_York';
for (const n of [7, 30, 120, 366]) { const r = await domainUsage(env, { days: n, accounts: false, series: true }); const startMs = Date.parse(r.period.start); eq(periodBounds('day', 'America/New_York', new Date(startMs + 43200000)).start, r.period.start, `DST 시간대 ${n}일: 시작은 0시`); eq(r.daily.length, n, `${n}칸`); eq(r.daily[0].date, new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' }).format(new Date(startMs + 43200000)), '첫 칸 날짜'); }
env.STATS_TIMEZONE = 'Asia/Seoul';
// 도메인 하나가 깨져도 전체 요약은 나머지를 돌려준다
const brokenPrepare = sec.DB.prepare; sec.DB.prepare = () => { throw new Error('SQLITE_IOERR'); };
const partial = await (await usage(tenants, '', { key })).json(); eq(partial.ok, true, '요약 ok'); eq(partial.domains[1].error, 'server_error', '깨진 도메인 표시'); eq(partial.domains[1].domain, 'second.test', '도메인 이름'); eq(partial.totals.domains, 2, '합계는 정상 도메인만'); eq(partial.totals.messages, 9, '합계 메일 수');
eq((await usage(tenants, '/second.test', { key })).status, 500, '상세는 500'); sec.DB.prepare = brokenPrepare;
tenants.close();
});
await t('Resend 연결 오류(ECONNRESET 등)는 2초 뒤 한 번 더 시도하고, 두 번째도 실패하면 mail_provider_unavailable, 응답 오류(4xx)는 재시도하지 않는다', async () => {
const env = await makeEnv({ RESEND_RETRY_MS: 10 }); let calls = 0;
env.RESEND_FETCH = async () => { calls++; if (calls === 1) { const e = new TypeError('fetch failed'); e.cause = { code: 'ECONNRESET' }; throw e; } return Response.json({ data: [] }); };
eq(JSON.stringify(await resend(env, '/emails')), '{"data":[]}', '재시도 성공'); eq(calls, 2, '2회 호출');
calls = 0; env.RESEND_FETCH = async () => { calls++; const e = new TypeError('fetch failed'); e.cause = { code: 'UND_ERR_CONNECT_TIMEOUT' }; throw e; };
let err = null; try { await resend(env, '/emails'); } catch (e) { err = e; } eq(err?.message, 'mail_provider_unavailable', '두 번 실패'); eq(err?.providerMessage, 'UND_ERR_CONNECT_TIMEOUT', '사유'); eq(calls, 2, '정확히 2회');
calls = 0; env.RESEND_FETCH = async () => { calls++; return Response.json({ message: 'nope' }, { status: 403 }); };
err = null; try { await resend(env, '/emails'); } catch (e) { err = e; } eq(err?.message, 'mail_forbidden', '응답 오류'); eq(calls, 1, '응답 오류는 재시도 없음');
// 응답 대기 시간 초과(TimeoutError)와 멱등 키 없는 POST(웹훅 만들기 등)는 재시도하지 않는다 — 요청이 이미 처리됐을 수 있음
calls = 0; env.RESEND_FETCH = async () => { calls++; const e = new DOMException('The operation was aborted due to timeout', 'TimeoutError'); throw e; };
err = null; try { await resend(env, '/emails'); } catch (e) { err = e; } eq(err?.message, 'mail_provider_unavailable', '시간 초과'); eq(calls, 1, 'TimeoutError 는 재시도 없음');
calls = 0; env.RESEND_FETCH = async () => { calls++; if (calls === 1) { const e = new TypeError('fetch failed'); e.cause = { code: 'ECONNRESET' }; throw e; } return Response.json({ id: 'wh_1' }); };
err = null; try { await resend(env, '/webhooks', 'POST', { endpoint: 'x' }); } catch (e) { err = e; } eq(err?.message, 'mail_provider_unavailable', '멱등 키 없는 POST 는 재시도 없음'); eq(calls, 1, '1회');
calls = 0; eq((await resend(env, '/emails', 'POST', { to: 'a' }, 'idem-1')).id, 'wh_1', '멱등 키 있는 POST 는 재시도'); eq(calls, 2, '2회');
});
done();
+290
View File
@@ -0,0 +1,290 @@
/**
* 1.0.8 시험: webmaker 인증 모드(server/webmaker.mjs · server/webmaker-snapshot.mjs)와 경로 접두사(/mail).
* - 웹메이커 데이터 디렉터리(users.json · sites/ · modules/ · domains.json)를 흉내 내 만들고 스냅샷·도메인 ↔ 사이트 대응·계정 우선순위를 확인합니다.
* - 웹메이커 계정으로 웹 로그인(관리자/일반)·IMAP 로그인·비밀번호 틀림·승인 취소·다른 도메인 주소 거부, 관리자 페이지의 읽기 전용 행, 비밀번호 변경 금지,
* 로컬 계정과의 로그인 ID 충돌, 수신 주소 목록, 웹메이커 쪽 파일 변경(비밀번호 변경·계정 삭제)의 즉시 반영, 파일을 읽지 못할 때의 동작을 봅니다.
* - 기본 도메인을 환경 변수(LOGIN_MODE=webmaker)로 webmaker 모드로 띄우는 경우와, 명령줄 도우미(webmaker-snapshot.mjs <dir>)의 출력도 확인합니다.
* - 경로 접두사: "호스트/경로" 등록, resolveHost 의 prefix, API 의 인라인 이미지 주소·초안 저장 시 접두사 제거.
* 실행: node test/webmaker.test.mjs (npm test 와 deploy.sh 가 함께 돌립니다)
*/
import net from 'node:net';
import { webmakerSocketPath } from '../server/webmaker.mjs';
import { execFileSync } from 'node:child_process';
import { scryptSync, randomBytes } from 'node:crypto';
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeEnv, runner, eq, ok, request, params } from './_helpers.mjs';
import { handle } from '../server/api.mjs';
import { loadConfig } from '../server/config.mjs';
import { openTenants, normalizeEntry, normalizePrefix, splitHostEntry, loadRegistry } from '../server/domains.mjs';
import { readSnapshotSync } from '../server/webmaker-snapshot.mjs';
import { accountsFor, siteFor, verifyScrypt, clearSnapshotCache } from '../server/webmaker.mjs';
import { verifyUser, knownMailboxes, mailboxUser, ensureInitialAdmin, createUser, authMode, principalById } from '../server/auth.mjs';
import { createImap } from '../server/imap.mjs';
const { t, done } = runner();
const HERE = dirname(fileURLToPath(import.meta.url)), ROOT = join(HERE, '..');
const tmp = (p = 'arimail-wm-') => mkdtempSync(join(tmpdir(), p));
const api = (env, path, cookie, method, body, headers) => handle(env, request(path, cookie, method, body, headers), params(path));
const hash = (pw, salt) => scryptSync(pw, salt, 64).toString('hex');
const SITE_ID = 'a'.repeat(32), SITE2 = 'b'.repeat(32);
/** 웹메이커 데이터 디렉터리 흉내: 회원 admin(관리자)·owner1(소유자)·guest(일반), 사이트 site.test(소유자 owner1, 사용자 user1·pending1·admin(회원)), 사이트 둘째(도메인 없음). */
function makeWebmaker(dir, { memberAdmin = false } = {}) {
const u = (id, pw, role) => { const salt = randomBytes(16).toString('hex'); return { id, name: id + ' 이름', salt, passHash: hash(pw, salt), role, accountType: 'domain', createdAt: '2026-10-14T00:00:00Z' }; };
writeFileSync(join(dir, 'users.json'), JSON.stringify({ admin: u('admin', 'wm-admin-pw', 'admin'), owner1: u('owner1', 'owner-pw-1', 'user'), guest: u('guest', 'guest-pw-1', 'user') }, null, 2), { mode: 0o600 });
mkdirSync(join(dir, 'sites'), { recursive: true }); mkdirSync(join(dir, 'modules'), { recursive: true });
writeFileSync(join(dir, 'sites', SITE_ID + '.json'), JSON.stringify({ id: SITE_ID, ownerId: 'owner1', title: '시험 사이트', domains: ['site.test', 'www.site.test'], webmail: { enabled: true, domain: 'site.test' } }));
writeFileSync(join(dir, 'sites', SITE2 + '.json'), JSON.stringify({ id: SITE2, ownerId: 'guest', title: '둘째', domains: [] }));
const m = (id, loginId, pw, role, approved = true) => { const salt = randomBytes(16).toString('hex'); return { id, name: loginId + ' 님', email: '', loginId, salt, hash: hash(pw, salt), role, approved, createdAt: '2026-10-14T00:00:00Z' }; };
const members = [m('m1', 'user1', 'user1-pw-1', 'admin'), m('m2', 'pending1', 'pending-pw', 'admin', false), { id: 'm3', name: '방문자', email: 'visitor@example.test', salt: 'x', hash: 'y', role: 'member', approved: true }, m('m4', 'Bad Login!', 'bad-pw-1', 'admin')];
if (memberAdmin) members.push(m('m5', 'admin', 'member-admin-pw', 'admin'));
writeFileSync(join(dir, 'modules', SITE_ID + '.json'), JSON.stringify({ enabled: { members: true }, settings: {}, members, posts: [] }));
writeFileSync(join(dir, 'domains.json'), JSON.stringify({ domains: [{ id: 'd1', userId: 'owner1', name: 'sub.platform.test', type: 'subdomain', status: 'active', siteId: SITE_ID }], contacts: {} }));
return dir;
}
async function base(over = {}) {
const dir = tmp();
const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), PUBLIC_HOST: 'mail.arihakdang.com', ...over });
loadConfig(env);
return { env, dir };
}
const open = (env) => openTenants(env, { migrationsDir: join(ROOT, 'migrations'), log: { log() {}, error() {} } });
const cookieOf = (res) => res.headers.get('set-cookie').split(';')[0];
/** 테넌트 추가 + 웹메이커 모드. 시험마다 새 캐시로 시작합니다. */
async function tenantWithWebmaker(wmDir, extra = {}) {
clearSnapshotCache();
const { env } = await base();
const tenants = open(env);
const r = await tenants.add({ domain: 'site.test', hosts: 'site.test/mail', auth: { mode: 'webmaker', webmakerDir: wmDir, ...extra } });
return { env, tenants, site: tenants.byDomain('site.test').env, entry: r.entry };
}
await t('스냅샷: 웹메이커 파일을 읽어 회원·사이트·회원(로그인 ID 있는)·도메인 연결을 모으고, 명령줄 도우미도 같은 JSON 을 낸다', async () => {
const dir = makeWebmaker(tmp());
const snap = readSnapshotSync(dir);
eq(Object.keys(snap.users).sort().join(','), 'admin,guest,owner1', '회원'); eq(snap.users.admin.role, 'admin', '관리자 역할'); ok(snap.users.admin.passHash.length === 128, 'scrypt 해시');
eq(snap.sites[SITE_ID].domains.join(','), 'site.test,site.test', 'www. 는 뗌'); eq(snap.sites[SITE_ID].webmailDomain, 'site.test', '메일 도메인');
eq(snap.members[SITE_ID].length, 4, 'loginId/email 있는 회원만(해시 없는 방문자 제외 아님 — salt/hash 있어야)'); eq(snap.members[SITE_ID].find((m) => m.id === 'm2').approved, false, '승인 안 됨');
eq(snap.domainSites['sub.platform.test'], SITE_ID, '서브 도메인 → 사이트');
const cli = JSON.parse(execFileSync(process.execPath, [join(ROOT, 'server', 'webmaker-snapshot.mjs'), dir], { encoding: 'utf8' }));
eq(JSON.stringify({ ...cli, readAt: 0 }), JSON.stringify({ ...snap, readAt: 0 }), '도우미 출력 = 모듈 결과');
let code = 0; try { execFileSync(process.execPath, [join(ROOT, 'server', 'webmaker-snapshot.mjs'), join(dir, 'nope')], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { code = e.status; }
eq(code, 0, '없는 디렉터리도 빈 스냅샷(오류 아님)'); // 파일이 없으면 각 항목이 비어 있을 뿐 실패하지 않습니다(웹메이커가 아직 아무것도 안 만든 서버).
ok(await verifyScrypt('wm-admin-pw', snap.users.admin.salt, snap.users.admin.passHash) && !(await verifyScrypt('wrong', snap.users.admin.salt, snap.users.admin.passHash)) && !(await verifyScrypt('x', '', '')), 'scrypt 검사');
});
await t('도메인 ↔ 사이트 대응과 계정 우선순위: 사이트 회원(loginId) → 소유자 → 웹메이커 관리자, 사이트가 없으면 모든 회원', async () => {
const dir = makeWebmaker(tmp(), { memberAdmin: true });
const snap = readSnapshotSync(dir);
eq(siteFor(snap, 'site.test')?.id, SITE_ID, '도메인 목록으로'); eq(siteFor(snap, 'sub.platform.test')?.id, SITE_ID, '서브 도메인 등록으로'); eq(siteFor(snap, 'x.test', SITE2)?.id, SITE2, 'siteId 지정'); eq(siteFor(snap, 'none.test'), null, '없음');
const acc = accountsFor({ MAILBOX: 'admin@site.test' }, snap);
const by = Object.fromEntries(acc.map((a) => [a.login, a]));
eq(Object.keys(by).sort().join(','), 'admin,owner1,pending1,user1', '계정 목록(승인 안 된 회원은 목록에 있되 로그인 불가, 잘못된 로그인 ID 는 제외)');
eq(by.admin.kind, 'member', '사이트 회원 admin 이 플랫폼 관리자 admin 보다 우선'); eq(by.admin.role, 'user', '사이트 회원은 일반 역할'); eq(by.owner1.kind, 'owner', '소유자'); eq(by.owner1.role, 'user', '소유자는 일반(웹메이커 관리자만 관리자)'); eq(by.user1.mailbox, 'user1@site.test', '메일함');
ok(!acc.some((a) => a.login === 'guest'), '사이트 도메인에는 일반 회원(guest) 없음');
const acc2 = accountsFor({ MAILBOX: 'admin@platform.test' }, snap);
eq(acc2.map((a) => a.login).sort().join(','), 'admin,guest,owner1', '사이트 없는 도메인: 모든 회원'); eq(acc2.find((a) => a.login === 'admin').role, 'admin', '플랫폼 관리자'); eq(acc2.find((a) => a.login === 'guest').role, 'user', '일반 회원');
const acc3 = accountsFor({ MAILBOX: 'admin@site.test' }, readSnapshotSync(makeWebmaker(tmp())));
eq(acc3.find((a) => a.login === 'admin').kind, 'admin', '회원 admin 이 없으면 플랫폼 관리자가 admin@site.test'); eq(acc3.find((a) => a.login === 'pending1').approved, false, '승인 안 된 회원');
});
await t('웹 로그인: 웹메이커 관리자(admin)·소유자·사이트 사용자가 같은 비밀번호로 들어오고, 틀린 비밀번호·승인 취소·다른 도메인 주소는 거부, 로컬 계정도 함께 쓴다', async () => {
const dir = makeWebmaker(tmp());
const { env, tenants, site } = await tenantWithWebmaker(dir);
eq(authMode(site), 'webmaker', '모드'); eq(site.LOGIN_MODE, 'webmaker', 'env.LOGIN_MODE'); eq(site.WEBMAKER_DIR, dir, 'env.WEBMAKER_DIR'); eq(site.PUBLIC_HOST, 'site.test', '호스트'); eq(site.PUBLIC_PATH, '/mail', '경로 접두사');
ok(!(await ensureInitialAdmin(site)), 'webmaker 모드는 초기 관리자(adminpasswd)를 만들지 않음'); eq(Number((await site.DB.prepare('SELECT COUNT(*) n FROM mail_users').first()).n), 0, '로컬 계정 0');
const login = async (l, p, scope) => api(site, 'login', '', 'POST', { login: l, password: p }, scope ? { 'X-Mail-Scope': scope } : {});
let r = await login('admin', 'wm-admin-pw'); eq(r.status, 200, '관리자 로그인'); const adminCookie = cookieOf(r);
let s = await (await api(site, 'session', adminCookie)).json(); eq(s.user.source, 'webmaker', '출처'); eq(s.user.role, 'admin', '역할'); eq(s.user.mailbox, 'admin@site.test', '메일함'); eq(s.user.id, 'wm:u:admin', '주체 id'); eq(s.authMode, 'webmaker', 'authMode'); eq(s.initialPassword, false, '초기 비밀번호 경고 없음');
r = await login('user1@site.test', 'user1-pw-1'); eq(r.status, 200, '사이트 사용자(주소로) 로그인'); const userCookie = cookieOf(r);
s = await (await api(site, 'session', userCookie)).json(); eq(s.user.role, 'user', '일반'); eq(s.user.id, `wm:m:${SITE_ID}:m1`, '회원 주체 id'); eq(s.user.name, 'user1 님', '이름');
eq((await login('owner1', 'owner-pw-1')).status, 200, '소유자 로그인'); eq((await login('owner1', 'owner-pw-1', 'admin')).status, 403, '소유자는 관리자 페이지 불가(최소 권한)');
eq((await login('user1', 'user1-pw-1', 'admin')).status, 403, '사이트 사용자는 관리자 페이지 불가');
eq((await login('user1', 'wrong-pw-1')).status, 401, '틀린 비밀번호'); eq((await login('pending1', 'pending-pw')).status, 401, '승인 안 된 회원'); eq((await login('user1@other.test', 'user1-pw-1')).status, 401, '다른 도메인 주소'); eq((await login('guest', 'guest-pw-1')).status, 401, '사이트 도메인에 없는 회원');
eq((await login('bad login!', 'bad-pw-1')).status, 401, '메일 주소로 쓸 수 없는 로그인 ID 는 제외');
// 로컬 계정: 웹메이커와 겹치는 ID 는 거부, 다른 ID 는 만들어 함께 로그인
let err = null; try { await createUser(site, { login: 'admin', password: 'local-pw-12' }, 'test'); } catch (e) { err = e.message; } eq(err, 'login_taken', '웹메이커 계정과 겹치는 로컬 계정 거부');
await createUser(site, { login: 'extra', password: 'extra-pw-12', name: '추가' }, 'test');
eq((await login('extra', 'extra-pw-12')).status, 200, '로컬 계정 로그인'); eq((await verifyUser(site, 'extra', 'extra-pw-12'))?.source, 'local', '로컬 출처');
// 수신 주소·메일함 주인
const known = await knownMailboxes(site); ok(['admin@site.test', 'owner1@site.test', 'user1@site.test', 'pending1@site.test', 'extra@site.test'].every((m) => known.includes(m)), '수신 주소: ' + known.join(','));
eq((await mailboxUser(site, 'user1@site.test'))?.id, `wm:m:${SITE_ID}:m1`, '메일함 주인(자동 처리용)'); eq((await mailboxUser(site, 'extra@site.test'))?.source, 'local', '로컬 메일함 주인');
// 설정 화면·비밀번호 변경
const st = await (await api(site, 'settings', userCookie)).json(); eq(st.canChangePassword, false, '비밀번호 변경 불가'); eq(st.managedBy, 'webmaker', '관리 주체'); eq(st.smtp.host, 'mail.arihakdang.com', '메일 서버 이름은 기본 도메인');
r = await api(site, 'password', userCookie, 'PATCH', { currentPassword: 'user1-pw-1', password: 'new-pw-123456' }); eq(r.status, 403, '비밀번호 변경 거부'); eq((await r.json()).error, 'webmaker_managed', '오류 코드');
// 기본 도메인에는 웹메이커 계정이 없다(도메인마다 따로)
eq((await api(env, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 401, '기본 도메인(www 모드)에서는 웹메이커 계정 없음');
eq((await tenants.list()).find((d) => d.domain === 'site.test').external.count, 4, '목록의 웹메이커 계정 수(승인 안 된 회원 포함)');
tenants.close();
});
await t('관리자 페이지: 웹메이커 계정은 읽기 전용 행(external)으로 보이고 POP/IMAP 허용·메일함 크기만 바꿀 수 있으며 삭제·이름 변경은 거부된다', async () => {
const dir = makeWebmaker(tmp());
const { tenants, site } = await tenantWithWebmaker(dir);
const r = await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' }, { 'X-Mail-Scope': 'admin' }); eq(r.status, 200, '관리자 로그인'); const ck = cookieOf(r);
const H = { 'X-Mail-Scope': 'admin' };
let d = await (await api(site, 'admin/users', ck, 'GET', undefined, H)).json();
eq(d.authMode, 'webmaker', 'authMode'); eq(d.me, null, 'me 행 없음(웹메이커 관리자는 external 에)'); eq(d.users.length, 0, '로컬 계정 없음');
eq(d.external.map((u) => u.login).sort().join(','), 'admin,owner1,pending1,user1', '외부 계정 행'); eq(d.externalSource.kind, 'webmaker', '출처'); eq(d.externalSource.ok, true, '읽기 성공'); eq(d.externalSource.site.id, SITE_ID, '사이트');
eq(d.external.find((u) => u.login === 'owner1').role, 'user', '소유자 역할'); eq(d.external.find((u) => u.login === 'admin').role, 'admin', '관리자 역할');
const u1 = d.external.find((u) => u.login === 'user1'); eq(u1.status, 'active', '상태'); eq(u1.kind, 'member', '종류'); eq(u1.services.pop, true, '기본 POP 허용'); eq(u1.quotaBytes, 0, '기본 크기');
eq(d.external.find((u) => u.login === 'pending1').status, 'suspended', '승인 안 된 회원은 정지로 표시');
// 허용·크기 저장
// 경로의 ':' 는 main.mjs 가 decodeURIComponent 로 풀어 주므로 여기서는 id 를 그대로 씁니다(시험 도우미 params 는 디코딩하지 않음).
let p = await api(site, 'admin/users/' + u1.id, ck, 'PATCH', { services: { pop: false, imap: true }, quotaBytes: 5 * 1048576, name: '바꿈', password: 'nope' }, H); eq(p.status, 200, 'PATCH: ' + await p.text());
d = await (await api(site, 'admin/users', ck, 'GET', undefined, H)).json(); const u2 = d.external.find((u) => u.login === 'user1');
eq(u2.services.pop, false, 'POP 끔'); eq(u2.quotaBytes, 5 * 1048576, '크기 저장'); eq(u2.limit, 5 * 1048576, '적용 크기(mailboxQuota 가 wm: id 를 찾음)'); eq(u2.name, 'user1 님', '이름은 그대로(웹메이커가 주인)');
eq((await api(site, 'admin/users/' + u1.id, ck, 'DELETE', undefined, H)).status, 403, '삭제 거부');
eq((await api(site, 'admin/users/wm:u:nobody', ck, 'PATCH', { services: { pop: true } }, H)).status, 404, '없는 외부 계정');
// POP 을 끈 계정의 설정은 메일 프로그램 로그인 뒤 검사에 쓰입니다(serviceAllowed) — prefs 가 wm: id 로 저장됐는지
eq(JSON.parse((await site.DB.prepare("SELECT value FROM settings WHERE key=?").bind('prefs:' + u1.id).first()).value).pop, false, 'prefs:wm:… 저장');
tenants.close();
});
await t('IMAP 로그인: 웹메이커 계정이 @도메인으로 자기 테넌트를 고르고 같은 비밀번호를 쓴다(웹·POP·SMTP 와 같은 verifyUser)', async () => {
const dir = makeWebmaker(tmp());
const { env, tenants } = await tenantWithWebmaker(dir);
const imap = createImap(env, { hostname: 'mail.test', resolveEnv: (l, s) => tenants.resolveLogin(l, s) }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' });
const session = async (login, password) => { const c = imapClient(port); await c.greeting(); const r = await c.cmd(`LOGIN "${login}" "${password}"`); return { c, r }; };
try {
let { c, r } = await session('user1@site.test', 'user1-pw-1'); ok(/OK/.test(r), '사이트 사용자 IMAP 로그인: ' + r.trim()); r = await c.cmd('SELECT INBOX'); ok(/OK/.test(r), 'SELECT'); c.end();
({ c, r } = await session('admin@site.test', 'wm-admin-pw')); ok(/OK/.test(r), '웹메이커 관리자 IMAP 로그인'); c.end();
({ c, r } = await session('admin@site.test', 'admin-secret-12')); ok(/NO/.test(r), '기본 도메인 비밀번호는 거부'); c.end();
({ c, r } = await session('admin', 'wm-admin-pw')); ok(/NO/.test(r), '도메인 없는 ID 는 기본 도메인 → 거부'); c.end();
} finally { imap.close(); tenants.close(); }
});
await t('웹메이커 쪽 변경의 즉시 반영: 비밀번호를 바꾸면 새 비밀번호로, 회원을 지우면 세션이 끝나며, 파일을 읽지 못하면 마지막 스냅샷으로 버틴다', async () => {
const dir = makeWebmaker(tmp());
const { tenants, site } = await tenantWithWebmaker(dir);
const r = await api(site, 'login', '', 'POST', { login: 'user1', password: 'user1-pw-1' }); eq(r.status, 200, '로그인'); const ck = cookieOf(r);
// 비밀번호 변경(웹메이커가 파일을 다시 씀) → 캐시가 새것이어도 실패 시 한 번 다시 읽으므로 바로 통과
const mod = JSON.parse(readFileSync(join(dir, 'modules', SITE_ID + '.json'), 'utf8')); const m1 = mod.members.find((m) => m.id === 'm1'); m1.salt = randomBytes(16).toString('hex'); m1.hash = hash('changed-pw-2', m1.salt);
writeFileSync(join(dir, 'modules', SITE_ID + '.json'), JSON.stringify(mod));
eq((await api(site, 'login', '', 'POST', { login: 'user1', password: 'changed-pw-2' })).status, 200, '새 비밀번호'); eq((await api(site, 'login', '', 'POST', { login: 'user1', password: 'user1-pw-1' })).status, 401, '옛 비밀번호 거부');
ok((await (await api(site, 'session', ck)).json()).user, '세션은 유지(비밀번호 변경은 웹메이커 쪽 세대 관리)');
// 회원 삭제 → 주체 조회 실패 → 세션 종료
mod.members = mod.members.filter((m) => m.id !== 'm1'); writeFileSync(join(dir, 'modules', SITE_ID + '.json'), JSON.stringify(mod)); clearSnapshotCache();
eq((await (await api(site, 'session', ck)).json()).user, null, '지운 회원의 세션 무효'); eq(await principalById(site, `wm:m:${SITE_ID}:m1`), null, 'principalById null');
// 파일을 못 읽게 되면(쓰는 중의 반쪽 JSON 등): 마지막 스냅샷으로 계속(로그인·세션 유지), 새 프로세스라면(캐시 없음) 로그인 실패 + 관리자 목록에 오류 표시
const ar = await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' }); eq(ar.status, 200, '정상 로그인'); const adminCk = cookieOf(ar);
const usersFile = join(dir, 'users.json'), goodUsers = readFileSync(usersFile, 'utf8'); writeFileSync(usersFile, '{broken json');
await new Promise((r) => setTimeout(r, 50));
eq((await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 200, '깨진 users.json 이어도 마지막 스냅샷으로 로그인(캐시 3초 안)');
ok((await (await api(site, 'session', adminCk)).json()).user, '세션 유지');
clearSnapshotCache();
eq((await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 401, '스냅샷이 전혀 없으면(새 프로세스) 로그인 실패');
eq((await (await api(site, 'session', adminCk)).json()).user, null, '그 요청은 비로그인으로 처리되지만…');
writeFileSync(usersFile, goodUsers); clearSnapshotCache();
ok((await (await api(site, 'session', adminCk)).json()).user, '…세션은 지워지지 않아 파일이 돌아오면 다시 로그인 상태(일시 장애로 취급)');
writeFileSync(usersFile, '{broken json'); clearSnapshotCache();
// 디렉터리 자체를 못 읽는 경우(sudo 도우미 없이 권한 없음)는 WEBMAKER_USER 로 도우미를 부르는데, 시험 환경에는 sudo 규칙이 없으므로 오류 → 캐시 없으면 로그인 실패·목록 오류
const { tenants: t2, site: s2 } = await tenantWithWebmaker(dir, { webmakerUser: 'nobody-arimail-test' });
eq((await api(s2, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 401, '도우미 실패 → 로그인 불가(죽지 않음)');
const lr = await api(s2, 'login', '', 'POST', { login: 'extra', password: 'x' }); eq(lr.status, 401, '로컬 조회로 넘어감');
const list = await t2.list(); ok(list.find((d) => d.domain === 'site.test').external.error, '목록에 읽기 오류 표시: ' + JSON.stringify(list[1].external));
t2.close(); tenants.close();
});
await t('소켓 도우미: 읽을 계정이 있고 소켓 파일이 있으면 그 소켓으로 스냅샷 JSON 을 받는다(systemd Accept=yes 흉내), 끊기면 오류', async () => {
const dir = makeWebmaker(tmp()); const sockPath = join(tmp('arimail-sock-'), 's.sock');
eq(webmakerSocketPath('/data/webmaker'), '/run/arimail-webmaker/data-webmaker.sock', '소켓 경로 규칙(webmaker-access.sh 와 같음)');
// 접속마다 스냅샷을 써 주고 끊는 서버(도우미 서비스와 같은 동작)
const server = net.createServer((c) => { c.end(JSON.stringify(readSnapshotSync(dir))); }); await new Promise((r) => server.listen(sockPath, r));
clearSnapshotCache();
const { env } = await base(); const tenants = open(env);
await tenants.add({ domain: 'site.test', auth: { mode: 'webmaker', webmakerDir: dir, webmakerUser: 'master' } });
const site = tenants.byDomain('site.test').env; site.WEBMAKER_SOCKET = sockPath;
eq((await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 200, '소켓으로 읽은 계정으로 로그인');
eq((await tenants.list()).find((d) => d.domain === 'site.test').external.count, 4, '목록도 소켓으로');
server.close(); clearSnapshotCache();
eq((await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 401, '서버가 없으면(캐시 없음) 로그인 실패');
tenants.close();
});
await t('등록부 검증: webmaker 는 디렉터리 필수(읽을 수 없으면 webmaker_dir_unreadable, 읽을 계정을 적으면 검사 생략), 사이트 id·계정 이름 형식, 수정으로 방식 바꾸기', async () => {
const dir = makeWebmaker(tmp());
const bad = (body, field) => { let e = null; try { normalizeEntry(body); } catch (x) { e = x; } ok(e && (e.field === field), `${field}: ${e && e.message}/${e && e.field}`); return e; };
bad({ domain: 'x.test', auth: { mode: 'webmaker' } }, 'auth.webmakerDir');
bad({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: 'relative/dir' } }, 'auth.webmakerDir');
eq(bad({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: join(dir, 'nope') } }, 'auth.webmakerDir').message, 'webmaker_dir_unreadable', '없는 디렉터리');
bad({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: dir, siteId: 'short' } }, 'auth.siteId');
bad({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: dir, webmakerUser: 'Bad User' } }, 'auth.webmakerUser');
const e1 = normalizeEntry({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: join(dir, 'nope'), webmakerUser: 'master' } }); eq(e1.auth.webmakerUser, 'master', '읽을 계정을 적으면 권한 검사 생략'); eq(e1.auth.wwwDbFile, '', 'www 값 없음');
const e2 = normalizeEntry({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: dir + '/', siteId: SITE_ID.toUpperCase() } }); eq(e2.auth.webmakerDir, dir, '끝 슬래시 제거'); eq(e2.auth.siteId, SITE_ID, '소문자');
// 수정으로 local → webmaker → local
clearSnapshotCache();
const { env } = await base(); const tenants = open(env);
await tenants.add({ domain: 'y.test', adminPassword: 'ypw-12345' });
const y = tenants.byDomain('y.test').env; eq(y.LOGIN_MODE, 'standalone', '처음 local');
tenants.update('y.test', { auth: { mode: 'webmaker', webmakerDir: dir } }); eq(y.LOGIN_MODE, 'webmaker', '바꾼 뒤'); eq(y.WEBMAKER_DIR, dir, '디렉터리');
eq((await api(y, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 200, '웹메이커 admin 로그인(사이트 없는 도메인 → 모든 회원)');
eq((await api(y, 'login', '', 'POST', { login: 'admin', password: 'ypw-12345' })).status, 200, '초기 로컬 admin 도 여전히 로그인(웹메이커 → 로컬 순)');
tenants.update('y.test', { auth: { mode: 'local' } }); eq(y.LOGIN_MODE, 'standalone', '되돌림'); eq(y.WEBMAKER_DIR, undefined, '디렉터리 지움');
eq((await api(y, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 401, '웹메이커 계정 더는 없음');
const reg = loadRegistry(env); eq(reg.domains['y.test'].auth.mode, 'local', '등록부 저장');
tenants.close();
});
await t('기본 도메인을 환경 파일로 webmaker 모드로: LOGIN_MODE=webmaker + WEBMAKER_DIR → 등록부 auth 와 로그인이 웹메이커 계정', async () => {
const dir = makeWebmaker(tmp()); clearSnapshotCache();
const { env } = await base({ WWW_DB: undefined, MAILBOX: 'admin@site.test', PUBLIC_HOST: 'site.test', PUBLIC_PATH: 'mail', LOGIN_MODE: 'webmaker', WEBMAKER_DIR: dir });
const tenants = open(env);
const reg = loadRegistry(env); eq(reg.domains['site.test'].auth.mode, 'webmaker', '등록부 방식'); eq(reg.domains['site.test'].auth.webmakerDir, dir, '디렉터리'); eq(reg.domains['site.test'].hosts[0], 'site.test/mail', '기본 호스트에 경로');
eq(env.PUBLIC_PATH, '/mail', 'PUBLIC_PATH 정규화'); eq(tenants.resolveHost('site.test').prefix, '/mail', 'resolveHost prefix'); eq(tenants.resolveHost('site.test').tenant.env, env, '기본 도메인');
ok(!(await ensureInitialAdmin(env)), '초기 관리자 없음');
eq((await api(env, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 200, '웹메이커 admin'); eq((await api(env, 'login', '', 'POST', { login: 'user1@site.test', password: 'user1-pw-1' })).status, 200, '사이트 사용자');
// 기본 도메인 수정: 첫 호스트(경로 포함)는 환경 파일이 고정
tenants.update('site.test', { hosts: 'other.test' }); eq(tenants.registry.domains['site.test'].hosts.join(','), 'site.test/mail,other.test', '첫 호스트 고정');
tenants.close();
});
await t('경로 접두사: 호스트/경로 등록·정규화, 같은 호스트의 다른 경로 중복 거부, API 의 인라인 이미지 주소와 초안 저장 시 접두사 제거', async () => {
eq(normalizePrefix('/mail'), '/mail', '기본'); eq(normalizePrefix('mail/'), '/mail', '슬래시 보정'); eq(normalizePrefix('/a/b/'), '/a/b', '여러 단계'); eq(normalizePrefix('/'), '', '루트'); eq(normalizePrefix(''), '', '빈 값'); eq(normalizePrefix('/../x'), null, '상위 경로'); eq(normalizePrefix('/sp ace'), null, '허용 글자 밖');
eq(JSON.stringify(splitHostEntry('Site.Test/Mail')), JSON.stringify({ host: 'site.test', prefix: '/mail' }), '나누기(전체 소문자)'); eq(splitHostEntry('mail.x.com').prefix, '', '경로 없음');
const e = normalizeEntry({ domain: 'p.test', hosts: 'p.test/mail, mail.p.test' }); eq(e.hosts.join(','), 'p.test/mail,mail.p.test', '호스트 항목');
let err = null; try { normalizeEntry({ domain: 'p.test', hosts: 'p.test/mail, p.test' }); } catch (x) { err = x; } eq(err?.field, 'hosts', '같은 호스트의 다른 경로 거부');
err = null; try { normalizeEntry({ domain: 'p.test', hosts: 'p.test/../x' }); } catch (x) { err = x; } eq(err?.field, 'hosts', '잘못된 경로');
const { env } = await base(); const tenants = open(env);
await tenants.add({ domain: 'p.test', hosts: 'p.test/mail', adminPassword: 'ppw-12345' });
eq(tenants.resolveHost('p.test').prefix, '/mail', 'prefix'); eq(tenants.resolveHost('P.TEST:443').tenant.entry.domain, 'p.test', '호스트 매칭'); eq(tenants.resolveHost('mail.arihakdang.com').prefix, '', '기본 도메인은 접두사 없음');
let err2 = null; try { await tenants.add({ domain: 'q.test', hosts: 'p.test/other', adminPassword: 'qpw-12345' }); } catch (x) { err2 = x; } eq(err2?.message, 'host_taken', '다른 도메인이 같은 호스트(경로만 다름) 사용 불가');
const p = tenants.byDomain('p.test').env; eq(p.PUBLIC_HOST, 'p.test', 'PUBLIC_HOST 는 이름만'); eq(p.PUBLIC_PATH, '/mail', 'PUBLIC_PATH');
// API: prefix 를 넘기면 인라인 이미지 주소가 /mail/api/… 로 나오고, 저장할 때는 /api/… 로 되돌린다
const r = await api(p, 'login', '', 'POST', { login: 'admin', password: 'ppw-12345' }); const ck = cookieOf(r);
const withPrefix = (path, method, body) => handle(p, request(path, ck, method, body), { ...params(path), prefix: '/mail' });
const draft = await (await withPrefix('mail', 'POST', { to: 'a@b.test', subject: 'x', html: '<p>hi <img src="/mail/api/mail/abc/attachments/def?inline=1"></p>' })).json();
const stored = await p.DB.prepare('SELECT html FROM messages WHERE id=?').bind(draft.id).first(); ok(stored.html.includes('src="/api/mail/abc/attachments/def?inline=1"') && !stored.html.includes('/mail/api/'), '저장된 본문은 접두사 없음: ' + stored.html);
const png = new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' });
const fd = new FormData(); fd.append('files', png, 'dot.png');
const up = await (await withPrefix(`mail/${draft.id}/attachments?inline=1`, 'POST', fd)).json(); ok(up.urls[0].startsWith('/mail/api/mail/'), '올린 인라인 이미지 주소에 접두사: ' + up.urls[0]);
const msg = await (await withPrefix(`mail/${draft.id}`, 'GET')).json(); ok(msg.message.previewHtml.includes('src="/mail/api/mail/abc/'), '미리 보기 본문의 저장된 인라인 이미지 주소에도 접두사: ' + msg.message.previewHtml.slice(0, 120));
const plain = await (await api(p, `mail/${draft.id}`, ck)).json(); ok(!plain.message.previewHtml.includes('/mail/api/') && plain.message.previewHtml.includes('src="/api/mail/abc/'), '접두사 없는 요청은 접두사 없음');
// PATCH(초안 수정)도 접두사를 떼고 저장하며, 요청에 접두사가 없어도 도메인의 PUBLIC_PATH(/mail)는 뗀다
eq((await withPrefix(`mail/${draft.id}`, 'PATCH', { html: '<p><img src="/mail/api/mail/abc/attachments/def?inline=1"> two</p>' })).status, 200, 'PATCH');
ok((await p.DB.prepare('SELECT html FROM messages WHERE id=?').bind(draft.id).first()).html.includes('src="/api/mail/abc/'), 'PATCH 저장도 접두사 없음');
eq((await api(p, `mail/${draft.id}`, ck, 'PATCH', { html: '<p><img src="/mail/api/mail/abc/attachments/def?inline=1"> three</p>' })).status, 200, 'PATCH(접두사 헤더 없음)');
ok((await p.DB.prepare('SELECT html FROM messages WHERE id=?').bind(draft.id).first()).html.includes('src="/api/mail/abc/'), 'PUBLIC_PATH 로도 뗌');
// 로그인 응답의 쿠키 Path 는 접두사
ok(/Path=\/mail(;|$)/.test(String((await withPrefix('login', 'POST', { login: 'admin', password: 'ppw-12345' })).headers.get('set-cookie'))), '쿠키 Path=/mail');
tenants.close();
});
/** 시험용 IMAP 클라이언트(domains.test.mjs 와 같음): 태그 응답을 기다립니다. */
function imapClient(port) {
const socket = net.connect(port, '127.0.0.1'); let buffer = ''; const waiters = [];
socket.setEncoding('latin1'); socket.on('data', (d) => { buffer += d; flush(); });
function flush() {
while (waiters.length) {
const w = waiters[0]; const re = w.tag ? new RegExp(`(^|\\r\\n)${w.tag} (OK|NO|BAD)[^\\r]*\\r\\n`) : /^\+ [^\r]*\r\n|^\* [^\r]*\r\n/;
const m = buffer.match(re); if (!m) return;
const end = m.index + m[0].length; waiters.shift(); w.resolve(buffer.slice(0, end)); buffer = buffer.slice(end);
}
}
let n = 0;
return { greeting: () => new Promise((resolve) => { waiters.push({ resolve }); flush(); }), cmd: (line) => { const tag = 'A' + (++n); socket.write(`${tag} ${line}\r\n`); return new Promise((resolve) => { waiters.push({ tag, resolve }); flush(); }); }, end: () => socket.end() };
}
done();

Some files were not shown because too many files have changed in this diff Show More