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:
co-authored by
Claude Fable 5.1
parent
9ca4cee341
commit
c7aea8d419
+11
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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 작업으로만 설치·갱신한다.
|
||||
@@ -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": "사용자 그룹을 먼저 선택하세요" }
|
||||
}
|
||||
@@ -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) }));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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))]; })) };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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'; }
|
||||
@@ -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
@@ -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);
|
||||
@@ -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)}`; }
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
@@ -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));
|
||||
@@ -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 })); },
|
||||
};
|
||||
}
|
||||
@@ -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_*` |
|
||||
Executable
+52
@@ -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
|
||||
Executable
+54
@@ -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/ 준비 완료. 전체 설정 화면에서 이 호스트를 가진 도메인을 추가(또는 호스트 목록에 넣기)했는지 확인하세요."
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+54
@@ -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"; }
|
||||
Executable
+84
@@ -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"
|
||||
Executable
+22
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Executable
+85
@@ -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)."
|
||||
Executable
+64
@@ -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
|
||||
Executable
+29
@@ -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/"
|
||||
Executable
+131
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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분 캐시를 두는 편이 좋습니다.
|
||||
@@ -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 권한 없이 뜹니다.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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); });
|
||||
@@ -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": "웹메이커 데이터 디렉터리를 읽을 수 없습니다."
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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(/&/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),
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 }; }
|
||||
@@ -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 }; }
|
||||
}
|
||||
@@ -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) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c]));
|
||||
const unesc = (v) => String(v).replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/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 }; }
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>`;
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(); },
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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) },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(); });
|
||||
@@ -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') };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(); } }
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 이 다시 진단하는 것으로 충분
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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])); }
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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(/&/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(); },
|
||||
};
|
||||
}
|
||||
@@ -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(/&/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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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(); }
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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();
|
||||
@@ -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('<x>'), '이스케이프');
|
||||
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
@@ -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();
|
||||
@@ -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, '"')}</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(/"/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>"v=spf1 mx ~all"</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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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
Reference in New Issue
Block a user