Эх сурвалжийг харах

fix: sync DNS automation and release assets

AI-Co-Authored-By: Codex
chendeben 1 сар өмнө
parent
commit
91ca585819
57 өөрчлөгдсөн 784 нэмэгдсэн , 204 устгасан
  1. 4 0
      .dockerignore
  2. 9 9
      .env.example
  3. 55 0
      .github/ISSUE_TEMPLATE/bug_report.yml
  4. 5 0
      .github/ISSUE_TEMPLATE/config.yml
  5. 30 0
      .github/ISSUE_TEMPLATE/feature_request.yml
  6. 12 0
      .github/dependabot.yml
  7. 18 0
      .github/pull_request_template.md
  8. 30 0
      .github/workflows/ci.yml
  9. 3 3
      .gitignore
  10. 31 0
      AGENTS.md
  11. 9 0
      CHANGELOG.md
  12. 27 0
      CODE_OF_CONDUCT.md
  13. 49 0
      CONTRIBUTING.md
  14. 21 0
      LICENSE
  15. 86 108
      README.md
  16. 30 0
      SECURITY.md
  17. 14 0
      SUPPORT.md
  18. 0 20
      certs/mail-send.ss5.xyz.crt
  19. 1 1
      docker-compose.yml
  20. 1 1
      docker/postfix/entrypoint.sh
  21. 1 0
      package-lock.json
  22. 19 1
      package.json
  23. 1 1
      public/app.js
  24. 0 0
      public/assets/index-BhoF66Ow.css
  25. 0 0
      public/assets/index-Bv1_CvmS.js
  26. 0 1
      public/assets/index-CApQI6z4.js
  27. 0 1
      public/assets/index-CDcbky2i.js
  28. 0 1
      public/assets/index-CLNbnZjS.js
  29. 0 0
      public/assets/index-CQhlXgMS.js
  30. 0 0
      public/assets/index-Cnh93foy.js
  31. 0 1
      public/assets/index-D13udm4g.js
  32. 0 0
      public/assets/index-aEHvatu0.js
  33. 0 0
      public/assets/index-psgafNtk.css
  34. 0 0
      public/assets/login-Bf3wwaVX.js
  35. 0 0
      public/assets/login-CsuWLBKI.js
  36. 0 0
      public/assets/login-CxIl1qG4.js
  37. 0 0
      public/assets/login-Dw1J1na6.js
  38. 0 0
      public/assets/styles-BqV0Lkls.css
  39. 0 0
      public/assets/styles-C5VOfA7x.js
  40. 0 0
      public/assets/styles-CGFhCGkE.js
  41. 0 0
      public/assets/styles-CLwaXWnz.js
  42. 0 0
      public/assets/styles-DMRLjM8Z.css
  43. 0 0
      public/assets/styles-DmsKpU2U.js
  44. 2 2
      public/index.html
  45. 2 2
      public/login.html
  46. 8 2
      scripts/deploy-remote.sh
  47. 96 24
      src/dns-providers.js
  48. 13 1
      src/frontend/App.tsx
  49. 13 0
      src/frontend/domain-model.js
  50. 2 0
      src/frontend/i18n/index.js
  51. 1 1
      src/server.js
  52. 9 9
      test/delivery-tracker.test.js
  53. 142 10
      test/dns-providers.test.js
  54. 3 3
      test/frontend-api-token-model.test.js
  55. 25 0
      test/frontend-domain-model.test.js
  56. 1 1
      test/server-admin-api.test.js
  57. 11 1
      vite.config.ts

+ 4 - 0
.dockerignore

@@ -4,6 +4,10 @@ node_modules
 npm-debug.log
 data
 postfix-spool
+certs
+tmp
+logs
 *.sqlite
 *.sqlite-*
 .env
+docs/superpowers

+ 9 - 9
.env.example

@@ -1,36 +1,36 @@
 APP_PORT=3025
-APP_BASE_URL=http://mail-send.ss5.xyz
+APP_BASE_URL=https://mailhub.example.com
 ADMIN_USER=admin
-ADMIN_EMAIL=admin@mailhub.local
+ADMIN_EMAIL=admin@example.com
 ADMIN_PASSWORD=change-this-admin-password
 SESSION_SECRET=change-this-long-random-session-secret
 
 # Legacy admin send token. New users should generate their own token in the web UI.
 API_TOKEN=change-this-legacy-api-token
 SUBMISSION_ENABLED=true
-SUBMISSION_HOST=in.ss5.xyz
+SUBMISSION_HOST=smtp.mailhub.example.com
 SUBMISSION_BIND=0.0.0.0
 SUBMISSION_PORTS=25:smtp,587:smtp,465:smtps,2525:smtp
 SUBMISSION_ALT_PORT=2525
 SUBMISSION_ALLOW_INSECURE_AUTH=false
-SUBMISSION_TLS_CERT=/certs/mail-send.ss5.xyz.crt
-SUBMISSION_TLS_KEY=/certs/mail-send.ss5.xyz.key
+SUBMISSION_TLS_CERT=/certs/mailhub.example.com.crt
+SUBMISSION_TLS_KEY=/certs/mailhub.example.com.key
 SUBMISSION_USERNAME=change-this-smtp-user
 SUBMISSION_PASSWORD=change-this-smtp-password
 
 # Default outbound identity used in SPF, HELO, and Postfix myhostname.
-MAIL_HOSTNAME=in.ss5.xyz
-SENDING_IP=8.231.54.11
+MAIL_HOSTNAME=smtp.mailhub.example.com
+SENDING_IP=203.0.113.10
 
 # Extra SPF mechanisms to preserve coexistence with third-party senders.
 # Examples: include:spf.mailjet.com include:_netblocks.m.feishu.cn
-DEFAULT_SPF_MECHANISMS=include:spf.mailjet.com
+DEFAULT_SPF_MECHANISMS=
 DNS_RESOLVERS=1.1.1.1,8.8.8.8
 
 # SMTP service used by the web API. In docker-compose this is the internal Postfix service.
 SMTP_HOST=postfix
 SMTP_PORT=25
-SMTP_HELO=in.ss5.xyz
+SMTP_HELO=smtp.mailhub.example.com
 SEND_REQUIRES_VERIFIED=false
 DELIVERY_TRACKING_ENABLED=true
 POSTFIX_LOG_POLL_INTERVAL_MS=5000

+ 55 - 0
.github/ISSUE_TEMPLATE/bug_report.yml

@@ -0,0 +1,55 @@
+name: Bug report
+description: Report a reproducible problem in MailHub.
+title: "fix: "
+labels:
+  - bug
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thanks for taking the time to report a bug. Please remove secrets, tokens, private keys, real customer data, and production-only identifiers before submitting.
+  - type: textarea
+    id: summary
+    attributes:
+      label: Summary
+      description: What happened?
+    validations:
+      required: true
+  - type: textarea
+    id: steps
+    attributes:
+      label: Steps to reproduce
+      description: Provide a minimal reproduction.
+      placeholder: |
+        1. ...
+        2. ...
+        3. ...
+    validations:
+      required: true
+  - type: textarea
+    id: expected
+    attributes:
+      label: Expected behavior
+    validations:
+      required: true
+  - type: textarea
+    id: actual
+    attributes:
+      label: Actual behavior
+    validations:
+      required: true
+  - type: input
+    id: node
+    attributes:
+      label: Node.js version
+      placeholder: "24.x"
+  - type: input
+    id: docker
+    attributes:
+      label: Docker version
+  - type: textarea
+    id: logs
+    attributes:
+      label: Relevant logs
+      description: Paste only sanitized logs.
+      render: shell

+ 5 - 0
.github/ISSUE_TEMPLATE/config.yml

@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+  - name: Security vulnerability
+    url: https://github.com/chendeben/MailHub/security/advisories/new
+    about: Please report security vulnerabilities privately.

+ 30 - 0
.github/ISSUE_TEMPLATE/feature_request.yml

@@ -0,0 +1,30 @@
+name: Feature request
+description: Suggest an improvement for MailHub.
+title: "feat: "
+labels:
+  - enhancement
+body:
+  - type: textarea
+    id: problem
+    attributes:
+      label: Problem
+      description: What user problem would this solve?
+    validations:
+      required: true
+  - type: textarea
+    id: proposal
+    attributes:
+      label: Proposal
+      description: Describe the smallest useful change.
+    validations:
+      required: true
+  - type: textarea
+    id: alternatives
+    attributes:
+      label: Alternatives considered
+      description: What other approaches did you consider?
+  - type: textarea
+    id: context
+    attributes:
+      label: Additional context
+      description: Add screenshots, API examples, or deployment details if useful. Remove sensitive data.

+ 12 - 0
.github/dependabot.yml

@@ -0,0 +1,12 @@
+version: 2
+updates:
+  - package-ecosystem: "npm"
+    directory: "/"
+    schedule:
+      interval: "weekly"
+    open-pull-requests-limit: 5
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "weekly"
+    open-pull-requests-limit: 5

+ 18 - 0
.github/pull_request_template.md

@@ -0,0 +1,18 @@
+## Summary
+
+- 
+
+## Verification
+
+- [ ] `npm test`
+- [ ] `npm run build`
+
+## Security Checklist
+
+- [ ] No `.env`, database, token, password, certificate, or private key is included.
+- [ ] Example domains, IPs, and emails use safe placeholder values.
+- [ ] New configuration is documented in `.env.example`.
+
+## Notes
+
+- 

+ 30 - 0
.github/workflows/ci.yml

@@ -0,0 +1,30 @@
+name: CI
+
+on:
+  pull_request:
+  push:
+    branches:
+      - main
+      - master
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: "24"
+          cache: npm
+
+      - name: Install dependencies
+        run: npm ci
+
+      - name: Run tests
+        run: npm test
+
+      - name: Build
+        run: npm run build

+ 3 - 3
.gitignore

@@ -7,12 +7,12 @@ data/
 *.sqlite-shm
 *.sqlite-wal
 
-certs/*.key
-certs/*.pem
-certs/*.bak-*
+certs/*
+!certs/.gitkeep
 *.log
 logs/
 tmp/
 .cache/
+docs/superpowers/
 node_modules/
 npm-debug.log*

+ 31 - 0
AGENTS.md

@@ -0,0 +1,31 @@
+# Repository Guidelines
+
+## 项目结构与模块组织
+
+MailHub 是基于 Node.js ESM 的 Docker 化发信控制面板、SMTP Submission 服务和发送 API。服务端代码位于 `src/`:`server.js` 负责 HTTP 路由与运行配置,`db.js` 负责 SQLite 持久化,`mailer.js` 与 `submission.js` 处理发信链路,DKIM 与 DNS 能力拆分为独立模块。浏览器资源在 `public/`,测试在 `test/`,Docker 与 Postfix 相关文件在 `Dockerfile`、`docker-compose.yml` 和 `docker/postfix/`。运行期数据应保留在已忽略路径,如 `data/`、`.env` 和证书私钥文件。
+
+## 构建、测试与开发命令
+
+- `npm test`:使用 `node --test` 运行 Node 内置测试套件。
+- `npm run dev`:以 `NODE_ENV=development` 启动 `src/server.js`。
+- `npm start`:启动生产入口。
+- `docker compose up -d --build`:构建并启动应用与 Postfix 服务。
+- `docker compose logs -f app postfix`:部署或排障时跟踪服务日志。
+
+Node.js 版本需满足 `package.json` 中的 `>=24.0.0`。
+
+## 编码风格与命名规范
+
+使用 ES modules,保持文件职责单一、实现直接。默认使用 `const`,仅在需要重新赋值时使用 `let`。遵循现有 JavaScript 风格:两空格缩进、保留分号、变量和函数使用描述性 camelCase,浏览器资源文件名使用 kebab-case。注释应简短且有价值,并与周围代码语言保持一致。
+
+## 测试指南
+
+测试使用 Node 内置 `node:test`。测试文件放在 `test/` 下,并使用 `*.test.js` 后缀,尽量对应被测模块,例如 `test/dkim.test.js`。修改数据库迁移、认证边界、DNS 服务商逻辑或邮件签名链路时,应补充相应覆盖。提交 PR 前运行 `npm test`。
+
+## 提交与 Pull Request 规范
+
+提交历史使用类似 Conventional Commits 的前缀,如 `feat:`、`fix:`、`docs:`、`test:`。提交标题应简洁、聚焦单一变更。PR 应包含变更摘要、验证步骤、关联 issue;涉及 UI 或 API 行为变化时,补充截图或请求示例。
+
+## 安全与配置提示
+
+不要提交 `.env`、SQLite 数据库、API Token、SMTP 密码或证书私钥。以 `.env.example` 为起点配置环境,替换默认管理员凭据,设置足够强的 `SESSION_SECRET`。生产发信前确认 SPF、DKIM、DMARC、PTR 以及云防火墙和系统防火墙规则。

+ 9 - 0
CHANGELOG.md

@@ -0,0 +1,9 @@
+# Changelog
+
+本项目遵循简洁、面向用户的变更记录格式。
+
+## Unreleased
+
+- 准备首次开源发布。
+- 增加社区文档、GitHub 模板和安全说明。
+- 泛化示例配置,移除个人部署信息。

+ 27 - 0
CODE_OF_CONDUCT.md

@@ -0,0 +1,27 @@
+# Code of Conduct
+
+## Our Pledge
+
+我们希望 MailHub 社区保持专业、尊重和包容。参与者应当以建设性的方式沟通,尊重不同经验背景,并围绕项目质量解决问题。
+
+## Expected Behavior
+
+- 使用友善、清晰、专业的语言。
+- 尊重不同观点和经验。
+- 聚焦技术事实、复现步骤和可验证结果。
+- 接受建设性反馈,并在讨论中保持耐心。
+
+## Unacceptable Behavior
+
+- 人身攻击、骚扰、侮辱或歧视性表达。
+- 公开他人的隐私信息或敏感凭据。
+- 在 issue、PR 或讨论中发布垃圾信息。
+- 明知有安全风险仍引导他人执行危险操作。
+
+## Enforcement
+
+维护者可以编辑、隐藏或删除违反本准则的内容,也可以临时或永久限制相关参与者的互动权限。
+
+## Scope
+
+本准则适用于项目仓库、issue、pull request、讨论区以及与项目相关的公开交流场景。

+ 49 - 0
CONTRIBUTING.md

@@ -0,0 +1,49 @@
+# Contributing
+
+感谢你愿意参与 MailHub。这个项目优先接受聚焦、可验证、易维护的改动。
+
+## 开发环境
+
+```bash
+npm install
+npm test
+npm run build
+```
+
+Node.js 版本需满足 `package.json` 中的 `>=24.0.0`。
+
+## 工作方式
+
+- 提交 issue 前先搜索是否已有相同问题。
+- PR 尽量保持单一主题,避免混入无关格式化或重构。
+- 修改行为时补充或更新测试。
+- 修改 UI 或 API 行为时,在 PR 中提供截图、请求示例或验证步骤。
+- 不要提交 `.env`、数据库、证书、私钥、API Token、SMTP 密码或真实生产配置。
+
+## 代码风格
+
+- 使用 ES modules。
+- JavaScript/TypeScript 使用两空格缩进和分号。
+- 默认使用 `const`,仅在需要重新赋值时使用 `let`。
+- 保持文件职责单一,优先选择直观实现。
+- 注释保持简短,只解释不明显的业务约束或安全原因。
+
+## Pull Request 检查清单
+
+- [ ] 已运行 `npm test`。
+- [ ] 已运行 `npm run build`。
+- [ ] 已检查 `git diff`,确认没有个人信息或密钥。
+- [ ] 文档已随行为变化更新。
+- [ ] 新增配置项已同步 `.env.example`。
+
+## Commit 信息
+
+推荐使用类似 Conventional Commits 的前缀:
+
+- `feat:` 新功能
+- `fix:` 修复
+- `docs:` 文档
+- `test:` 测试
+- `chore:` 维护
+
+标题应简洁,并聚焦单一变更。

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 MailHub contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 86 - 108
README.md

@@ -1,93 +1,86 @@
 # MailHub
 
-MailHub 是一个 Docker 化的多用户发信域名管理面板、SMTP Submission 服务和发送 API。它负责:
+MailHub 是一个 Docker 化的多用户发信控制面板、SMTP Submission 服务和发送 API。它面向需要自托管出站邮件能力的团队,提供域名验证、DKIM 签名、DNS 配置提示、SMTP 凭据和 API Token 管理。
 
-- 开放注册用户账号,并隔离每个用户的域名、SMTP 凭据、DNS API 凭据和发送记录。
-- 为每个发信域名生成验证 TXT、DKIM key、SPF、DMARC、发信主机 A 记录建议。
-- 支持 Cloudflare、阿里云 DNS、腾讯云 DNSPod API,一键写入基础发信 DNS。
-- 读取公网 DNS,检查 SPF、DKIM、DMARC、PTR、发信主机 A 记录状态。
-- 通过内部 Postfix 出站队列发送邮件,并按发件域名加 DKIM 签名。
+## 功能
 
-## 运行
+- 多用户账号体系,隔离域名、SMTP 凭据、DNS API 凭据、API Token 和发送记录。
+- 为发信域名生成验证 TXT、DKIM、SPF、DMARC、发信主机 A 记录和 PTR 检查提示。
+- 支持 Cloudflare、阿里云 DNS、腾讯云 DNSPod API 的基础 DNS 写入。
+- 检查公网 DNS 中的 SPF、DKIM、DMARC、PTR 和发信主机 A 记录状态。
+- 通过内部 Postfix 出站队列发送邮件,并按发件域名添加 DKIM 签名。
+- 提供 SMTP Submission 和 HTTP 发送 API。
+- 内置 React + Ant Design 管理界面。
 
-```bash
-cd /www/wwwroot/mail.ss5.xyz
-docker compose up -d --build
-docker compose ps
-docker compose logs -f app postfix
-```
-
-管理面板默认监听宿主机 `127.0.0.1:3025`,nginx 可反代到公网域名。
-
-## 发布流程
-
-生产发布采用 Git 拉取式部署,避免直接覆盖远端目录:
+## 技术栈
 
-```bash
-npm run release:check
-git status --short
-git add <changed-files>
-git commit -m "feat: ..."
-git push origin master
-npm run deploy:remote
-```
+- Node.js ESM,要求 Node.js `>=24.0.0`
+- SQLite 持久化
+- React、Vite、Ant Design
+- Docker Compose + Postfix
+- Node 内置 `node:test`
 
-`npm run deploy:remote` 会在远端 `/www/wwwroot/mail.ss5.xyz` 执行:
+## 快速开始
 
 ```bash
-git fetch origin master
-git checkout master
-git pull --ff-only origin master
+cp .env.example .env
+npm install
+npm test
+npm run build
 docker compose up -d --build
-docker compose ps
-```
-
-脚本默认目标是 `root@192.227.215.183:/www/wwwroot/mail.ss5.xyz`。如需覆盖,可设置:
-
-```bash
-MAILHUB_DEPLOY_REMOTE=root@example.com \
-MAILHUB_DEPLOY_DIR=/www/wwwroot/mail.ss5.xyz \
-MAILHUB_DEPLOY_BRANCH=master \
-MAILHUB_DEPLOY_GIT_URL=git@git.ss5.xyz:chendeben/MailSend.git \
-npm run deploy:remote
+docker compose logs -f app postfix
 ```
 
-首次使用前,远端服务器必须具备 Git 仓库读取权限,例如给 `git@git.ss5.xyz:chendeben/MailSend.git` 配置 deploy key。远端如果还没有 `origin`,脚本会使用 `MAILHUB_DEPLOY_GIT_URL` 或本地 `origin` 自动补齐
+默认管理面板通过 `APP_PORT` 暴露到宿主机 `127.0.0.1:3025`。生产环境建议使用 Nginx、Caddy 或其他反向代理提供 HTTPS。
 
-远端如果存在未提交变更或无法快进,`git pull --ff-only` 会失败;此时应先人工确认远端状态。确认可以收纳远端工作区时再显式加:
+首次启动前务必修改 `.env` 中的默认管理员密码、`SESSION_SECRET`、SMTP 凭据和域名/IP 配置。
 
-```bash
-MAILHUB_DEPLOY_STASH_REMOTE=1 npm run deploy:remote
-```
-
-该开关只会 `git stash push -u` 远端工作区,不会覆盖 `.env`、`data/`、`certs/` 等 ignored 生产数据。
+## 配置
 
-## 账号和权限
+以 `.env.example` 为模板创建 `.env`。常用配置包括:
 
-- 首次启动会根据 `.env` 中的 `ADMIN_USER`、`ADMIN_EMAIL`、`ADMIN_PASSWORD` 创建或修复一个 admin 用户。
-- 普通用户可在 `/register` 开放注册。
-- 用户只能看到自己的域名、SMTP 凭据、DNS 凭据、API Token 和发送记录。
-- admin 用户可在网页里修改系统业务设置、查看用户列表、禁用或启用用户。
+- `APP_BASE_URL`:管理面板和发送 API 的公网访问地址。
+- `MAIL_HOSTNAME`:出站邮件 HELO、Postfix `myhostname` 和 DNS 指南中的发信主机名。
+- `SENDING_IP`:发信服务器公网 IP。
+- `SESSION_SECRET`:会话和服务端加密使用的随机密钥,生产环境必须使用强随机值。
+- `SUBMISSION_HOST`、`SUBMISSION_PORTS`:SMTP Submission 对外连接信息。
+- `SUBMISSION_TLS_CERT`、`SUBMISSION_TLS_KEY`:TLS 证书路径。证书文件应放在本地 `certs/`,不要提交到 Git。
+- `DEFAULT_SPF_MECHANISMS`:需要保留的第三方 SPF include,例如事务邮件服务商。
+- `SEND_REQUIRES_VERIFIED`:是否要求域名 DNS 验证通过后才能发信。
 
 ## SMTP 发信
 
-MailHub 提供公网 SMTP Submission。所有发信端口都需要 SMTP AUTH,未认证不会转发邮件,避免开放中继。
-
-连接信息:
+MailHub 提供公网 SMTP Submission。所有发信端口都需要 SMTP AUTH,未认证请求不会转发邮件,避免开放中继。
 
 ```txt
-Host: 由 SUBMISSION_HOST 配置,例如 in.example.com
+Host: 由 SUBMISSION_HOST 配置,例如 smtp.mailhub.example.com
 Port 25:   SMTP + STARTTLS + AUTH
 Port 587:  SMTP Submission + STARTTLS + AUTH
 Port 465:  SMTPS implicit TLS + AUTH
 Port 2525: SMTP + STARTTLS + AUTH
-Username: 用户在网页“SMTP 凭据”中配置,可复制
-Password: 用户在网页“SMTP 凭据”中配置,可复制
+Username: 用户在网页“SMTP 凭据”中配置
+Password: 用户在网页“SMTP 凭据”中配置
 ```
 
 SMTP 密码会同时保存哈希和服务端加密密文:哈希用于认证,加密密文用于用户本人在网页复制。旧数据如果只有哈希,无法反解,需要用户重新设置一次密码后才能复制。
 
-`.env` 中的 `SUBMISSION_USERNAME` 和 `SUBMISSION_PASSWORD` 只用于首次给 admin 初始化 SMTP 凭据。初始化后以每个用户在面板保存的 SMTP 凭据为准,修改后无需重启。
+## 发送 API
+
+每个用户可以在面板生成自己的发送 API Token。Token 只在创建时完整显示,之后列表只显示前缀。
+
+```bash
+curl -X POST https://mailhub.example.com/api/send \
+  -H "Authorization: Bearer <USER_API_TOKEN>" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "from": "noreply@example.com",
+    "to": "user@example.net",
+    "subject": "Hello from MailHub",
+    "text": "Signed with DKIM and queued by MailHub."
+  }'
+```
+
+发送时会校验 `From` 所属域名必须归当前 Token 所属用户所有。`.env` 中的 `API_TOKEN` 仅作为旧版 admin 兼容发送 token,新用户应使用网页生成的用户级 Token。
 
 ## DNS API 一键配置
 
@@ -97,68 +90,53 @@ SMTP 密码会同时保存哈希和服务端加密密文:哈希用于认证,
 - 阿里云 DNS:AccessKeyId 和 AccessKeySecret。
 - 腾讯云 DNSPod:SecretId 和 SecretKey。
 
-域名绑定 DNS 凭据后,点击“一键配置 DNS”会写入或更新:
-
-- `_mailhub.<domain>` TXT 验证记录。
-- `<selector>._domainkey.<domain>` DKIM TXT。
-- 根域 SPF TXT,并合并为一条 `v=spf1`。
-- `_dmarc.<domain>` DMARC TXT。
-- 发信主机 A 记录。
+域名绑定 DNS 凭据后,点击“一键配置 DNS”会写入或更新验证 TXT、DKIM TXT、SPF TXT、DMARC TXT 和发信主机 A 记录。PTR 反向解析只检测和提示,通常必须在云服务器或 IP 服务商控制台配置。
 
-PTR 反向解析只检测和提示,通常必须在云服务器或 IP 服务商控制台配置。
+## 部署
 
-## 网页化系统设置
+生产环境建议:
 
-admin 可在面板中修改以下业务设置:
+1. 将仓库部署到服务器目录,例如 `/opt/mailhub`。
+2. 基于 `.env.example` 创建 `.env`,填写真实域名、IP、证书路径和强随机密钥。
+3. 将 TLS 证书放在本地 `certs/` 目录,确保私钥不会进入 Git。
+4. 运行 `docker compose up -d --build`。
+5. 使用反向代理把 HTTPS 流量转发到 `127.0.0.1:${APP_PORT}`。
+6. 在云防火墙和系统防火墙中放行需要的 SMTP 端口。
 
-- `APP_BASE_URL`
-- `MAIL_HOSTNAME`
-- `SENDING_IP`
-- `DEFAULT_SPF_MECHANISMS`
-- `DMARC_POLICY`
-- `DMARC_RUA`
-- `SEND_REQUIRES_VERIFIED`
-
-基础设施设置仍通过 `.env` 配置并需要重启,例如监听端口、数据目录、SMTP 中继、TLS 证书路径和 `SESSION_SECRET`。
-
-## 发送 API
-
-每个用户在面板生成自己的发送 API Token。Token 只在创建时完整显示并自动复制,之后列表只显示前缀。
+可选的远程部署脚本需要显式提供目标服务器和目录:
 
 ```bash
-curl -X POST http://mail-send.example.com/api/send \
-  -H "Authorization: Bearer <USER_API_TOKEN>" \
-  -H "Content-Type: application/json" \
-  -d '{
-    "from": "noreply@example.com",
-    "to": "user@example.com",
-    "subject": "Hello from MailHub",
-    "text": "Signed with DKIM and queued by MailHub."
-  }'
+MAILHUB_DEPLOY_REMOTE=deploy@example.com \
+MAILHUB_DEPLOY_DIR=/opt/mailhub \
+MAILHUB_DEPLOY_BRANCH=main \
+npm run deploy:remote
 ```
 
-发送时会校验 `From` 所属域名必须归当前 Token 所属用户所有。`.env` 中的 `API_TOKEN` 仅作为旧版 admin 兼容发送 token,新用户应使用网页生成的用户级 Token
+脚本会要求本地 HEAD 已推送到对应远端分支,然后在目标目录执行 `git pull --ff-only` 和 `docker compose up -d --build`。如果目标工作区存在未提交变更,脚本会停止;确认可暂存远端工作区时,可显式设置 `MAILHUB_DEPLOY_STASH_REMOTE=1`。
 
 ## 测试
 
 ```bash
 npm test
+npm run build
 ```
 
-测试覆盖:
-
-- 旧 SQLite 数据迁移到 admin 用户。
-- 多用户域名、发送记录、SMTP 凭据、API Token 隔离。
-- SMTP 密码可复制明文与哈希认证。
-- Cloudflare、阿里云 DNS、腾讯云 DNSPod 的 mocked API 写入流程。
+测试覆盖数据库迁移、多用户隔离、SMTP 凭据、API Token、DNS 服务商逻辑、DKIM、投递日志解析和前端纯逻辑模型。
 
-## 生产检查
+## 安全清单
 
-- 修改默认 admin 密码并设置足够长的 `SESSION_SECRET`。
-- 不要提交 `.env`、SQLite 数据库、证书私钥或 DNS/API 密钥。
-- 服务器出站 25 端口没有被云厂商拦截。
-- 服务器入站 `25/465/587/2525` 已在云防火墙和系统防火墙放行。
-- SPF、DKIM、DMARC 均通过。
-- 发信 IP 的 PTR 指向 `MAIL_HOSTNAME`。
-- `MAIL_HOSTNAME` 的 A 记录指回发信 IP。
+- 不要提交 `.env`、SQLite 数据库、API Token、SMTP 密码、DNS API 密钥、证书私钥或真实生产证书。
+- 替换默认管理员凭据,设置足够长的 `SESSION_SECRET`。
+- 生产发信前确认 SPF、DKIM、DMARC、PTR 和发信主机 A 记录。
+- 确认服务器出站 25 端口没有被云厂商拦截。
+- 确认入站 `25/465/587/2525` 已在云防火墙和系统防火墙放行。
 - 新 IP 先小流量预热,避免突然大批量发送。
+- 遵守适用法律、服务商政策和收件人同意要求;不要使用 MailHub 发送垃圾邮件。
+
+## 贡献
+
+欢迎提交 issue 和 pull request。开始前请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)、[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) 和 [SECURITY.md](SECURITY.md)。
+
+## 许可证
+
+MailHub 使用 MIT License。详见 [LICENSE](LICENSE)。

+ 30 - 0
SECURITY.md

@@ -0,0 +1,30 @@
+# Security Policy
+
+## Supported Versions
+
+当前只维护默认分支的最新代码。发布正式版本后,本节会补充受支持版本范围。
+
+## Reporting a Vulnerability
+
+请不要在公开 issue 中披露未修复漏洞。
+
+推荐通过 GitHub Security Advisories 私下报告安全问题。如果仓库尚未启用该功能,请通过维护者在仓库主页公布的安全联系方式报告。
+
+报告中请尽量包含:
+
+- 受影响的版本或提交。
+- 复现步骤。
+- 影响范围。
+- 可行的缓解建议。
+
+## Sensitive Data
+
+MailHub 可能处理 SMTP 密码、API Token、DNS API 密钥和 TLS 私钥。请不要在 issue、PR、日志或截图中提交这些内容。
+
+## Operational Guidance
+
+- 生产环境必须替换默认管理员密码和 `SESSION_SECRET`。
+- `.env`、SQLite 数据库、证书和私钥不应进入 Git。
+- DNS API Token 应使用最小权限。
+- 公网 SMTP 端口必须启用认证,避免开放中继。
+- 使用本项目发送邮件时应遵守适用法律、服务商政策和收件人同意要求。

+ 14 - 0
SUPPORT.md

@@ -0,0 +1,14 @@
+# Support
+
+## Getting Help
+
+- 使用问题和功能建议:请创建 GitHub issue。
+- 安全问题:请按 [SECURITY.md](SECURITY.md) 私下报告。
+- 部署问题:请附上脱敏后的 `.env` 片段、Docker 日志、Node.js 版本和复现步骤。
+
+## Before Opening an Issue
+
+- 确认你正在使用受支持的 Node.js 版本。
+- 运行 `npm test` 和 `npm run build`。
+- 检查 DNS、TLS 证书、SMTP 端口和防火墙配置。
+- 移除日志和截图中的 Token、密码、IP 白名单和私钥内容。

+ 0 - 20
certs/mail-send.ss5.xyz.crt

@@ -1,20 +0,0 @@
------BEGIN CERTIFICATE-----
-MIIDNTCCAh2gAwIBAgIUFiGiIskO26Bc9kHABTLLHnzLm9cwDQYJKoZIhvcNAQEL
-BQAwFTETMBEGA1UEAwwKaW4uc3M1Lnh5ejAeFw0yNjA3MDcwOTQ5NTNaFw0yNzA3
-MDcwOTQ5NTNaMBUxEzARBgNVBAMMCmluLnNzNS54eXowggEiMA0GCSqGSIb3DQEB
-AQUAA4IBDwAwggEKAoIBAQCCRMZU49TbyEA0IXwKpKh+/KyBxWS/DyQ7NN3YgYHw
-WcgK2E1YzLbhgL47WBfbPyX9aEwLGtDzOFfiWnheoOzcyi5cqAQA1i1nye5RVAkC
-zUTLoP0+s9eLv46p9krHqGsoKu5gMG0vPLiOrZdvjFT4rbZqNEMcfrjcihktsw+s
-XD95MJPVqkijvZ2S1QbSr8wtMlYyIko5q/whaVcEFQdQ9/kgYEM8Dq01Nb+WA9fJ
-iBDcRuxIvRC2tBdEAnWB3OyRoyYzTqYUiIAVVs1tPcHSGovgKpN2B27xYXxHjccu
-GkwKJyajcXhpcKoeestR830Gwfvm6gFpSobG1MtzEpNLAgMBAAGjfTB7MB0GA1Ud
-DgQWBBTDD8ql1lRYGy3hf5q8JN82SkWPTzAfBgNVHSMEGDAWgBTDD8ql1lRYGy3h
-f5q8JN82SkWPTzAPBgNVHRMBAf8EBTADAQH/MCgGA1UdEQQhMB+CCmluLnNzNS54
-eXqCEW1haWwtc2VuZC5zczUueHl6MA0GCSqGSIb3DQEBCwUAA4IBAQBLOkSRkRt6
-Mfeu0dCkCblVeiX4bVF84UQWtNCnl+4Jg6WqOfzrcJ6epEH/Ot0nGG/tX89Zr66b
-XrdgQDE7P+/eGtQsw1cIPY+9bRydYCqEY9ICifbuz7rMm2+IzzFZd2ZEoxeYkLvY
-M5UcPKF2Qbpuh4g5UgzVckRrDOl4pfvr2nUIYrbyauGZ3MBgmwP9cxw6ukYbaKR2
-fjJUu7HIDPWVzmtN5p/T4l2bLd86HPQf+5nIaENoEuYQDPr6fBXZy5X1oPqDQpyA
-gfLuFW7QdvIX1qafQKwwuyCJPIsl1UJka90yenjJwQnXQ5S1eapYeJz8tQDtolVl
-MHy6V9UuoQ2t
------END CERTIFICATE-----

+ 1 - 1
docker-compose.yml

@@ -38,7 +38,7 @@ services:
       - .env
     environment:
       POSTFIX_LOG_FILE: /var/log/mailhub/mail.log
-    hostname: ${MAIL_HOSTNAME:-ali.ss5.xyz}
+    hostname: ${MAIL_HOSTNAME:-mailhub.local}
     volumes:
       - ./data/postfix-logs:/var/log/mailhub
     healthcheck:

+ 1 - 1
docker/postfix/entrypoint.sh

@@ -1,7 +1,7 @@
 #!/usr/bin/env bash
 set -euo pipefail
 
-MAIL_HOSTNAME="${MAIL_HOSTNAME:-ali.ss5.xyz}"
+MAIL_HOSTNAME="${MAIL_HOSTNAME:-mailhub.local}"
 MAIL_ORIGIN_DOMAIN="${MAIL_ORIGIN_DOMAIN:-${MAIL_HOSTNAME#*.}}"
 POSTFIX_LOG_FILE="${POSTFIX_LOG_FILE:-/dev/stdout}"
 

+ 1 - 0
package-lock.json

@@ -7,6 +7,7 @@
     "": {
       "name": "mailhub",
       "version": "1.0.0",
+      "license": "MIT",
       "dependencies": {
         "@ant-design/icons": "^6.3.2",
         "@ant-design/plots": "^2.6.8",

+ 19 - 1
package.json

@@ -1,9 +1,27 @@
 {
   "name": "mailhub",
   "version": "1.0.0",
-  "private": true,
   "type": "module",
   "description": "Dockerized outbound mail control panel with DNS guidance and DKIM signing.",
+  "license": "MIT",
+  "author": "MailHub contributors",
+  "keywords": [
+    "email",
+    "smtp",
+    "dkim",
+    "dns",
+    "mail",
+    "postfix",
+    "self-hosted"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+ssh://git@github.com/chendeben/MailHub.git"
+  },
+  "bugs": {
+    "url": "https://github.com/chendeben/MailHub/issues"
+  },
+  "homepage": "https://github.com/chendeben/MailHub#readme",
   "scripts": {
     "start": "node src/server.js",
     "dev": "NODE_ENV=development node src/server.js",

+ 1 - 1
public/app.js

@@ -1100,7 +1100,7 @@ function renderWarnings(warnings) {
 
 function apiExample(domain) {
   const token = state.apiTokens[0] ? `${state.apiTokens[0].tokenPrefix}...` : '<USER_API_TOKEN>';
-  return `curl -X POST ${state.config?.appBaseUrl || 'https://mail.ss5.xyz'}/api/send \\
+  return `curl -X POST ${state.config?.appBaseUrl || window.location.origin}/api/send \\
   -H 'Authorization: Bearer ${token}' \\
   -H 'Content-Type: application/json' \\
   -d '{

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
public/assets/index-BhoF66Ow.css


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
public/assets/index-Bv1_CvmS.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 1
public/assets/index-CApQI6z4.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 1
public/assets/index-CDcbky2i.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 1
public/assets/index-CLNbnZjS.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
public/assets/index-CQhlXgMS.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
public/assets/index-Cnh93foy.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 1
public/assets/index-D13udm4g.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
public/assets/index-aEHvatu0.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
public/assets/index-psgafNtk.css


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
public/assets/login-Bf3wwaVX.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
public/assets/login-CsuWLBKI.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
public/assets/login-CxIl1qG4.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
public/assets/login-Dw1J1na6.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
public/assets/styles-BqV0Lkls.css


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
public/assets/styles-C5VOfA7x.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
public/assets/styles-CGFhCGkE.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
public/assets/styles-CLwaXWnz.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
public/assets/styles-DMRLjM8Z.css


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
public/assets/styles-DmsKpU2U.js


+ 2 - 2
public/index.html

@@ -4,8 +4,8 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub</title>
-    <script type="module" crossorigin src="/assets/index-2fgog3BM.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-BzBORaN9.js">
+    <script type="module" crossorigin src="/assets/index-CApQI6z4.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-CGFhCGkE.js">
     <link rel="stylesheet" crossorigin href="/assets/styles-B6t-ADxX.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">
   </head>

+ 2 - 2
public/login.html

@@ -4,8 +4,8 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub Auth</title>
-    <script type="module" crossorigin src="/assets/login-Dw1J1na6.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-BzBORaN9.js">
+    <script type="module" crossorigin src="/assets/login-CsuWLBKI.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-CGFhCGkE.js">
     <link rel="stylesheet" crossorigin href="/assets/styles-B6t-ADxX.css">
   </head>
   <body>

+ 8 - 2
scripts/deploy-remote.sh

@@ -1,12 +1,18 @@
 #!/usr/bin/env bash
 set -euo pipefail
 
-remote="${MAILHUB_DEPLOY_REMOTE:-root@192.227.215.183}"
-remote_dir="${MAILHUB_DEPLOY_DIR:-/www/wwwroot/mail.ss5.xyz}"
+remote="${MAILHUB_DEPLOY_REMOTE:-}"
+remote_dir="${MAILHUB_DEPLOY_DIR:-}"
 branch="${MAILHUB_DEPLOY_BRANCH:-$(git branch --show-current)}"
 git_url="${MAILHUB_DEPLOY_GIT_URL:-$(git remote get-url origin)}"
 stash_remote="${MAILHUB_DEPLOY_STASH_REMOTE:-0}"
 
+if [[ -z "${remote}" || -z "${remote_dir}" ]]; then
+  echo "Set MAILHUB_DEPLOY_REMOTE and MAILHUB_DEPLOY_DIR before deploying." >&2
+  echo "Example: MAILHUB_DEPLOY_REMOTE=deploy@example.com MAILHUB_DEPLOY_DIR=/opt/mailhub npm run deploy:remote" >&2
+  exit 1
+fi
+
 if [[ -z "${branch}" ]]; then
   echo "Unable to detect current git branch." >&2
   exit 1

+ 96 - 24
src/dns-providers.js

@@ -114,8 +114,10 @@ class CloudflareProvider {
   async resolveZoneName(record, domain) {
     const host = record?.host || '';
     const domainName = domain?.domain || '';
-    if (this.zoneName && (!host || isHostInZone(host, this.zoneName))) return this.zoneName;
-    const candidates = cloudflareZoneCandidates(domainName || host);
+    const candidates = uniqueZoneCandidates([
+      this.zoneName,
+      ...zoneCandidates(domainName || host)
+    ]).filter((candidate) => !host || isHostInZone(host, candidate));
     for (const candidate of candidates) {
       const zoneId = await this.lookupZoneId(candidate, { optional: true });
       if (zoneId) return candidate;
@@ -172,9 +174,10 @@ class AliyunProvider {
     return response.DomainRecords?.Record?.length >= 0 ? this.zoneName : 'ok';
   }
 
-  async upsert(record) {
-    const rr = relativeName(record.host, this.zoneName);
-    const existing = await this.listRecords(record.type, rr);
+  async upsert(record, domain) {
+    const zoneName = await this.resolveZoneName(record, domain);
+    const rr = relativeName(record.host, zoneName);
+    const existing = await this.listRecords(zoneName, record.type, rr);
     const match = pickExisting(record, existing);
     const params = {
       RR: rr,
@@ -184,23 +187,23 @@ class AliyunProvider {
     };
     if (match) {
       await this.request('UpdateDomainRecord', { ...params, RecordId: match.id });
-      await this.deleteExtras(existing, match, record);
+      await this.deleteExtras(zoneName, existing, match, record);
       return 'updated';
     }
-    await this.request('AddDomainRecord', { DomainName: this.zoneName, ...params });
-    await this.deleteExtras(existing, null, record);
+    await this.request('AddDomainRecord', { DomainName: zoneName, ...params });
+    await this.deleteExtras(zoneName, existing, null, record);
     return 'created';
   }
 
-  async deleteExtras(records, kept, desired) {
+  async deleteExtras(zoneName, records, kept, desired) {
     if (!['spf', 'dmarc'].includes(desired.key)) return;
     const extras = records.filter((record) => record.id !== kept?.id && recordMatchesKind(desired, record.value));
     for (const record of extras) await this.request('DeleteDomainRecord', { RecordId: record.id });
   }
 
-  async listRecords(type, rr) {
+  async listRecords(zoneName, type, rr) {
     const response = await this.request('DescribeDomainRecords', {
-      DomainName: this.zoneName,
+      DomainName: zoneName,
       RRKeyWord: rr === '@' ? '' : rr,
       TypeKeyWord: type,
       PageSize: 100
@@ -215,6 +218,24 @@ class AliyunProvider {
       }));
   }
 
+  async resolveZoneName(record, domain) {
+    const host = record?.host || '';
+    const candidates = uniqueZoneCandidates([
+      this.zoneName,
+      ...zoneCandidates(domain?.domain || host)
+    ]).filter((candidate) => isHostInZone(host, candidate));
+    for (const candidate of candidates) {
+      const rr = relativeName(host, candidate);
+      try {
+        await this.listRecords(candidate, record.type, rr);
+        return candidate;
+      } catch (error) {
+        if (!isAliyunZoneMissingError(error)) throw error;
+      }
+    }
+    return this.zoneName;
+  }
+
   async request(action, params) {
     if (!this.zoneName) throw new Error('阿里云 DNS 需要 zoneName。');
     if (!this.credentials.accessKeyId || !this.credentials.accessKeySecret) {
@@ -233,7 +254,11 @@ class AliyunProvider {
     const signed = signAliyun({ ...common, ...params }, this.credentials.accessKeySecret);
     const response = await fetch(`${ALIYUN_ENDPOINT}?${signed}`);
     const data = await response.json().catch(() => ({}));
-    if (!response.ok || data.Code) throw new Error(data.Message || data.Code || `Aliyun HTTP ${response.status}`);
+    if (!response.ok || data.Code) {
+      const error = new Error(data.Message || data.Code || `Aliyun HTTP ${response.status}`);
+      error.code = data.Code || '';
+      throw error;
+    }
     return data;
   }
 }
@@ -251,12 +276,13 @@ class DnspodProvider {
     return this.zoneName;
   }
 
-  async upsert(record) {
-    const subDomain = relativeName(record.host, this.zoneName);
-    const existing = await this.listRecords(record.type, subDomain);
+  async upsert(record, domain) {
+    const zoneName = await this.resolveZoneName(record, domain);
+    const subDomain = relativeName(record.host, zoneName);
+    const existing = await this.listRecords(zoneName, record.type, subDomain);
     const match = pickExisting(record, existing);
     const params = {
-      Domain: this.zoneName,
+      Domain: zoneName,
       SubDomain: subDomain,
       RecordType: record.type,
       RecordLine: '默认',
@@ -265,25 +291,25 @@ class DnspodProvider {
     };
     if (match) {
       await this.request('ModifyRecord', { ...params, RecordId: Number(match.id) });
-      await this.deleteExtras(existing, match, record);
+      await this.deleteExtras(zoneName, existing, match, record);
       return 'updated';
     }
     await this.request('CreateRecord', params);
-    await this.deleteExtras(existing, null, record);
+    await this.deleteExtras(zoneName, existing, null, record);
     return 'created';
   }
 
-  async deleteExtras(records, kept, desired) {
+  async deleteExtras(zoneName, records, kept, desired) {
     if (!['spf', 'dmarc'].includes(desired.key)) return;
     const extras = records.filter((record) => record.id !== kept?.id && recordMatchesKind(desired, record.value));
     for (const record of extras) {
-      await this.request('DeleteRecord', { Domain: this.zoneName, RecordId: Number(record.id) });
+      await this.request('DeleteRecord', { Domain: zoneName, RecordId: Number(record.id) });
     }
   }
 
-  async listRecords(type, subDomain) {
+  async listRecords(zoneName, type, subDomain) {
     const response = await this.request('DescribeRecordList', {
-      Domain: this.zoneName,
+      Domain: zoneName,
       Subdomain: subDomain,
       RecordType: type,
       Limit: 100
@@ -298,6 +324,24 @@ class DnspodProvider {
       }));
   }
 
+  async resolveZoneName(record, domain) {
+    const host = record?.host || '';
+    const candidates = uniqueZoneCandidates([
+      this.zoneName,
+      ...zoneCandidates(domain?.domain || host)
+    ]).filter((candidate) => isHostInZone(host, candidate));
+    for (const candidate of candidates) {
+      const subDomain = relativeName(host, candidate);
+      try {
+        await this.listRecords(candidate, record.type, subDomain);
+        return candidate;
+      } catch (error) {
+        if (!isDnsPodZoneMissingError(error)) throw error;
+      }
+    }
+    return this.zoneName;
+  }
+
   async request(action, payload) {
     if (!this.zoneName) throw new Error('腾讯云 DNSPod 需要 zoneName。');
     if (!this.credentials.secretId || !this.credentials.secretKey) throw new Error('腾讯云 SecretId 和 SecretKey 不能为空。');
@@ -317,7 +361,9 @@ class DnspodProvider {
     });
     const data = await response.json().catch(() => ({}));
     if (!response.ok || data.Response?.Error) {
-      throw new Error(data.Response?.Error?.Message || `Tencent Cloud HTTP ${response.status}`);
+      const error = new Error(data.Response?.Error?.Message || `Tencent Cloud HTTP ${response.status}`);
+      error.code = data.Response?.Error?.Code || '';
+      throw error;
     }
     return data.Response;
   }
@@ -370,7 +416,7 @@ function normalizeZoneName(value) {
   return String(value || '').replace(/\.$/, '').toLowerCase();
 }
 
-function cloudflareZoneCandidates(name) {
+function zoneCandidates(name) {
   const clean = normalizeZoneName(name);
   const parts = clean.split('.').filter(Boolean);
   const candidates = [];
@@ -380,6 +426,32 @@ function cloudflareZoneCandidates(name) {
   return candidates;
 }
 
+function uniqueZoneCandidates(candidates) {
+  const seen = new Set();
+  const output = [];
+  for (const candidate of candidates) {
+    const clean = normalizeZoneName(candidate);
+    if (!clean || seen.has(clean)) continue;
+    seen.add(clean);
+    output.push(clean);
+  }
+  return output;
+}
+
+function isDnsPodZoneMissingError(error) {
+  const code = String(error?.code || '');
+  const message = String(error?.message || '');
+  return /NoDataOfRecord|ResourceNotFound|DomainNotExists|InvalidParameter\.Domain/i.test(code)
+    || /domain not found|domain does not exist|域名.*(不存在|没有)|没有.*域名/i.test(message);
+}
+
+function isAliyunZoneMissingError(error) {
+  const code = String(error?.code || '');
+  const message = String(error?.message || '');
+  return /InvalidDomainName|DomainRecordNotBelongToUser|DomainNotExists|DomainNameNotFound/i.test(code)
+    || /domain not found|domain does not exist|域名.*(不存在|没有)|没有.*域名/i.test(message);
+}
+
 function outOfZoneResult(record, zoneName) {
   const base = {
     key: record.key,

+ 13 - 1
src/frontend/App.tsx

@@ -13,6 +13,7 @@ import SendingLogs from '../pages/SendingLogs';
 import Settings from '../pages/Settings';
 import SmtpCredentials from '../pages/SmtpCredentials';
 import { I18nProvider, useI18n } from './i18n/react';
+import { buildDnsApplyFeedback } from './domain-model.js';
 import { api } from './services/api';
 import './styles.css';
 import type {
@@ -201,7 +202,18 @@ function MailHubConsole() {
   }
 
   async function applyDns(domain: Domain) {
-    const result = await runAction(async () => api.applyDns(domain.id), t('actions.dnsApplyCompleted'));
+    const result = await runAction(async () => api.applyDns(domain.id));
+    if (result) {
+      const feedback = buildDnsApplyFeedback(result.apply, {
+        completed: t('actions.dnsApplyCompleted'),
+        partial: t('actions.dnsApplyPartial')
+      });
+      if (feedback.type === 'warning') {
+        message.warning(feedback.message);
+      } else {
+        message.success(feedback.message);
+      }
+    }
     if (result?.domain) {
       replaceDomain(result.domain);
       setInitialDomainTab('dns');

+ 13 - 0
src/frontend/domain-model.js

@@ -48,6 +48,19 @@ export function isDomainVerified(domain = {}) {
   return Boolean(domain.status?.verified) || buildDomainHealth(domain).status === 'success';
 }
 
+export function buildDnsApplyFeedback(apply, messages = {}) {
+  const completed = messages.completed || 'DNS 写入请求已完成';
+  const partial = messages.partial || 'DNS 写入部分失败';
+  if (!apply || apply.ok) return { type: 'success', message: completed };
+
+  const failed = Array.isArray(apply.results) ? apply.results.filter((result) => !result.ok) : [];
+  const firstError = failed.find((result) => result.error)?.error || '';
+  return {
+    type: 'warning',
+    message: firstError ? `${partial}:${firstError}` : partial
+  };
+}
+
 export function getDnsRecordOrder() {
   return [...REQUIRED_DNS_KEYS];
 }

+ 2 - 0
src/frontend/i18n/index.js

@@ -258,6 +258,7 @@ const messages = {
     'actions.dnsCheckCompleted': 'DNS 检查已完成',
     'actions.dnsCheckRefreshed': 'DNS 检查已刷新',
     'actions.dnsApplyCompleted': 'DNS 写入请求已完成',
+    'actions.dnsApplyPartial': 'DNS 写入部分失败',
     'actions.domainSaved': '域名配置已保存',
     'actions.domainDeleted': '域名已删除',
     'actions.testMailQueued': '已提交到发信队列',
@@ -532,6 +533,7 @@ const messages = {
     'actions.dnsCheckCompleted': 'DNS check completed',
     'actions.dnsCheckRefreshed': 'DNS check refreshed',
     'actions.dnsApplyCompleted': 'DNS write request completed',
+    'actions.dnsApplyPartial': 'DNS write partially failed',
     'actions.domainSaved': 'Domain settings saved',
     'actions.domainDeleted': 'Domain deleted',
     'actions.testMailQueued': 'Submitted to sending queue',

+ 1 - 1
src/server.js

@@ -92,7 +92,7 @@ const envConfig = {
 
 const defaultSettings = {
   appBaseUrl: process.env.APP_BASE_URL || 'http://127.0.0.1:3000',
-  mailHostname: process.env.MAIL_HOSTNAME || 'ali.ss5.xyz',
+  mailHostname: process.env.MAIL_HOSTNAME || 'mailhub.local',
   sendingIp: process.env.SENDING_IP || '',
   defaultSpfMechanisms: process.env.DEFAULT_SPF_MECHANISMS || 'include:spf.mailjet.com',
   dmarcPolicy: process.env.DMARC_POLICY || 'none',

+ 9 - 9
test/delivery-tracker.test.js

@@ -25,12 +25,12 @@ test('extracts postfix queue ids from SMTP queue responses', () => {
 
 test('parses postfix delivery status lines', () => {
   const event = parsePostfixLogLine(
-    'Jul 08 04:15:21 in postfix/smtp[300]: 1DAEBC3EC8: to=<chendeben@qq.com>, relay=mx3.qq.com[203.205.219.57]:25, delay=3.4, delays=0.04/0.11/1.7/1.6, dsn=2.0.0, status=sent (250 OK: queued as.)'
+    'Jul 08 04:15:21 in postfix/smtp[300]: 1DAEBC3EC8: to=<recipient@example.net>, relay=mx.example.net[203.0.113.25]:25, delay=3.4, delays=0.04/0.11/1.7/1.6, dsn=2.0.0, status=sent (250 OK: queued as.)'
   );
 
   assert.equal(event.queueId, '1DAEBC3EC8');
-  assert.equal(event.recipient, 'chendeben@qq.com');
-  assert.equal(event.relay, 'mx3.qq.com[203.205.219.57]:25');
+  assert.equal(event.recipient, 'recipient@example.net');
+  assert.equal(event.relay, 'mx.example.net[203.0.113.25]:25');
   assert.equal(event.dsn, '2.0.0');
   assert.equal(event.status, 'sent');
   assert.equal(event.response, '250 OK: queued as.');
@@ -44,7 +44,7 @@ test('updates send events from postfix delivery attempts', () => {
     userId: user.id,
     domainId: domain.id,
     sender: 'noreply@sender.example.com',
-    recipients: ['chendeben@qq.com'],
+    recipients: ['recipient@example.net'],
     subject: 'Tracked',
     status: 'queued',
     detail: '250 2.0.0 Ok: queued as 1DAEBC3EC8'
@@ -53,8 +53,8 @@ test('updates send events from postfix delivery attempts', () => {
   const updated = updateSendEventDelivery('1DAEBC3EC8', {
     at: '2026-07-08T04:15:21.000Z',
     queueId: '1DAEBC3EC8',
-    recipient: 'chendeben@qq.com',
-    relay: 'mx3.qq.com[203.205.219.57]:25',
+    recipient: 'recipient@example.net',
+    relay: 'mx.example.net[203.0.113.25]:25',
     dsn: '2.0.0',
     status: 'sent',
     response: '250 OK: queued as.',
@@ -68,13 +68,13 @@ test('updates send events from postfix delivery attempts', () => {
   assert.equal(event.deliveredAt, '2026-07-08T04:15:21.000Z');
   assert.equal(event.deliveryAttempts.length, 1);
   assert.equal(event.deliveryAttempts[0].status, 'sent');
-  assert.equal(event.deliveryAttempts[0].recipient, 'chendeben@qq.com');
+  assert.equal(event.deliveryAttempts[0].recipient, 'recipient@example.net');
 
   updateSendEventDelivery('1DAEBC3EC8', {
     at: '2026-07-08T04:15:21.000Z',
     queueId: '1DAEBC3EC8',
-    recipient: 'chendeben@qq.com',
-    relay: 'mx3.qq.com[203.205.219.57]:25',
+    recipient: 'recipient@example.net',
+    relay: 'mx.example.net[203.0.113.25]:25',
     dsn: '2.0.0',
     status: 'sent',
     response: '250 OK: queued as.',

+ 142 - 10
test/dns-providers.test.js

@@ -65,6 +65,53 @@ test('aliyun provider signs and sends create/update record actions', async () =>
   assert.ok(actions.includes('UpdateDomainRecord'));
 });
 
+test('aliyun one-click dns falls back to parent zone for subdomain sending domains', async () => {
+  const calls = [];
+  globalThis.fetch = async (url) => {
+    const params = new URL(String(url)).searchParams;
+    calls.push({
+      action: params.get('Action'),
+      domainName: params.get('DomainName'),
+      rr: params.get('RR')
+    });
+    if (params.get('DomainName') === 'notify.example.com') {
+      return json({
+        Code: 'InvalidDomainName.NoExist',
+        Message: 'domain not found'
+      });
+    }
+    if (params.get('Action') === 'DescribeDomainRecords') {
+      return json({ DomainRecords: { Record: [] } });
+    }
+    return json({});
+  };
+
+  const result = await applyDnsSetup(
+    { ...domainFixture(), domain: 'notify.example.com', senderHost: 'smtp.example.com' },
+    { ...aliyunCredential(), zoneName: 'notify.example.com' },
+    {
+      records: [
+        {
+          key: 'verification',
+          host: '_mailhub.notify.example.com',
+          type: 'TXT',
+          value: 'mailhub-verification=token',
+          status: 'missing'
+        }
+      ]
+    }
+  );
+
+  assert.equal(result.ok, true);
+  assert.ok(calls.some((call) => call.action === 'DescribeDomainRecords' && call.domainName === 'notify.example.com'));
+  assert.ok(calls.some((call) => call.action === 'DescribeDomainRecords' && call.domainName === 'example.com'));
+  assert.ok(calls.some((call) => (
+    call.action === 'AddDomainRecord'
+    && call.domainName === 'example.com'
+    && call.rr === '_mailhub.notify'
+  )));
+});
+
 test('dnspod provider signs and sends create record actions', async () => {
   const actions = [];
   globalThis.fetch = async (url, options = {}) => {
@@ -86,6 +133,54 @@ test('dnspod provider signs and sends create record actions', async () => {
   assert.ok(actions.includes('CreateRecord'));
 });
 
+test('dnspod one-click dns falls back to parent zone for subdomain sending domains', async () => {
+  const calls = [];
+  globalThis.fetch = async (url, options = {}) => {
+    assert.equal(String(url), 'https://dnspod.tencentcloudapi.com');
+    const payload = JSON.parse(options.body);
+    calls.push({ action: options.headers['X-TC-Action'], payload });
+    if (payload.Domain === 'notify.example.com') {
+      return json({
+        Response: {
+          Error: {
+            Code: 'ResourceNotFound.NoDataOfRecord',
+            Message: 'domain not found'
+          }
+        }
+      });
+    }
+    if (options.headers['X-TC-Action'] === 'DescribeRecordList') {
+      return json({ Response: { RecordList: [] } });
+    }
+    return json({ Response: { RecordId: 123 } });
+  };
+
+  const result = await applyDnsSetup(
+    { ...domainFixture(), domain: 'notify.example.com', senderHost: 'smtp.example.com' },
+    { ...dnspodCredential(), zoneName: 'notify.example.com' },
+    {
+      records: [
+        {
+          key: 'verification',
+          host: '_mailhub.notify.example.com',
+          type: 'TXT',
+          value: 'mailhub-verification=token',
+          status: 'missing'
+        }
+      ]
+    }
+  );
+
+  assert.equal(result.ok, true);
+  assert.ok(calls.some((call) => call.action === 'DescribeRecordList' && call.payload.Domain === 'notify.example.com'));
+  assert.ok(calls.some((call) => call.action === 'DescribeRecordList' && call.payload.Domain === 'example.com'));
+  assert.ok(calls.some((call) => (
+    call.action === 'CreateRecord'
+    && call.payload.Domain === 'example.com'
+    && call.payload.SubDomain === '_mailhub.notify'
+  )));
+});
+
 test('one-click dns setup only applies records under the user domain zone', async () => {
   const calls = [];
   globalThis.fetch = async (url, options = {}) => {
@@ -106,7 +201,7 @@ test('one-click dns setup only applies records under the user domain zone', asyn
       },
       {
         key: 'sender-a',
-        host: 'in.ss5.xyz',
+        host: 'smtp.example.com',
         type: 'A',
         value: '127.0.0.1',
         status: 'ok'
@@ -157,24 +252,61 @@ test('cloudflare one-click dns discovers the parent zone for subdomain sending d
   const calls = [];
   globalThis.fetch = async (url, options = {}) => {
     calls.push({ url: String(url), method: options.method || 'GET' });
-    if (String(url).includes('/zones?name=sender.a4sky.com')) {
+    if (String(url).includes('/zones?name=sender.example.com')) {
       return json({ success: true, result: [] });
     }
-    if (String(url).includes('/zones?name=a4sky.com')) {
-      return json({ success: true, result: [{ id: 'zone-a4sky', name: 'a4sky.com' }] });
+    if (String(url).includes('/zones?name=example.com')) {
+      return json({ success: true, result: [{ id: 'zone-example', name: 'example.com' }] });
     }
     if (String(url).includes('/dns_records?')) return json({ success: true, result: [] });
     return json({ success: true, result: { id: 'ok' } });
   };
 
   const result = await applyDnsSetup(
-    { ...domainFixture(), domain: 'sender.a4sky.com', senderHost: 'in.ss5.xyz' },
-    cloudflareCredential(),
+    { ...domainFixture(), domain: 'sender.example.com', senderHost: 'smtp.example.com' },
+    { ...cloudflareCredential(), zoneName: 'example.org' },
+    {
+      records: [
+        {
+          key: 'verification',
+          host: '_mailhub.sender.example.com',
+          type: 'TXT',
+          value: 'mailhub-verification=token',
+          status: 'missing'
+        }
+      ]
+    }
+  );
+
+  assert.equal(result.ok, true);
+  assert.equal(result.results[0].ok, true);
+  assert.ok(calls.some((call) => call.url.includes('/zones?name=sender.example.com')));
+  assert.ok(calls.some((call) => call.url.includes('/zones?name=example.com')));
+  assert.ok(calls.some((call) => call.method === 'POST' && call.url.includes('/zones/zone-example/dns_records')));
+});
+
+test('cloudflare one-click dns falls back from configured child zone to parent zone', async () => {
+  const calls = [];
+  globalThis.fetch = async (url, options = {}) => {
+    calls.push({ url: String(url), method: options.method || 'GET' });
+    if (String(url).includes('/zones?name=notify.example.com')) {
+      return json({ success: true, result: [] });
+    }
+    if (String(url).includes('/zones?name=example.com')) {
+      return json({ success: true, result: [{ id: 'zone-example', name: 'example.com' }] });
+    }
+    if (String(url).includes('/dns_records?')) return json({ success: true, result: [] });
+    return json({ success: true, result: { id: 'ok' } });
+  };
+
+  const result = await applyDnsSetup(
+    { ...domainFixture(), domain: 'notify.example.com', senderHost: 'smtp.example.com' },
+    { ...cloudflareCredential(), zoneName: 'notify.example.com' },
     {
       records: [
         {
           key: 'verification',
-          host: '_mailhub.sender.a4sky.com',
+          host: '_mailhub.notify.example.com',
           type: 'TXT',
           value: 'mailhub-verification=token',
           status: 'missing'
@@ -185,9 +317,9 @@ test('cloudflare one-click dns discovers the parent zone for subdomain sending d
 
   assert.equal(result.ok, true);
   assert.equal(result.results[0].ok, true);
-  assert.ok(calls.some((call) => call.url.includes('/zones?name=sender.a4sky.com')));
-  assert.ok(calls.some((call) => call.url.includes('/zones?name=a4sky.com')));
-  assert.ok(calls.some((call) => call.method === 'POST' && call.url.includes('/zones/zone-a4sky/dns_records')));
+  assert.ok(calls.some((call) => call.url.includes('/zones?name=notify.example.com')));
+  assert.ok(calls.some((call) => call.url.includes('/zones?name=example.com')));
+  assert.ok(calls.some((call) => call.method === 'POST' && call.url.includes('/zones/zone-example/dns_records')));
 });
 
 function cloudflareCredential() {

+ 3 - 3
test/frontend-api-token-model.test.js

@@ -29,15 +29,15 @@ test('formats token prefixes without pretending the full secret is available', (
 
 test('builds API usage examples with endpoint, bearer token, and message body', () => {
   const examples = buildApiUsageExamples({
-    endpoint: 'https://mail-send.ss5.xyz/api/send',
+    endpoint: 'https://mailhub.example.com/api/send',
     token: 'mh_example_token',
     from: 'noreply@example.com',
     to: 'user@example.com'
   });
 
   assert.match(examples.curl, /Authorization: Bearer mh_example_token/);
-  assert.match(examples.curl, /https:\/\/mail-send\.ss5\.xyz\/api\/send/);
-  assert.match(examples.nodeFetch, /fetch\('https:\/\/mail-send\.ss5\.xyz\/api\/send'/);
+  assert.match(examples.curl, /https:\/\/mailhub\.example\.com\/api\/send/);
+  assert.match(examples.nodeFetch, /fetch\('https:\/\/mailhub\.example\.com\/api\/send'/);
   assert.match(examples.nodeFetch, /from: 'noreply@example.com'/);
   assert.match(examples.requestBody, /"to": "user@example.com"/);
   assert.match(examples.successResponse, /"queued": true/);

+ 25 - 0
test/frontend-domain-model.test.js

@@ -1,6 +1,7 @@
 import assert from 'node:assert/strict';
 import { test } from 'node:test';
 import {
+  buildDnsApplyFeedback,
   buildDomainHealth,
   getRecordStatusMeta,
   getRequiredDnsRecords
@@ -68,6 +69,30 @@ test('builds domain health from required DNS records only', () => {
   });
 });
 
+test('builds warning feedback for partial DNS apply failures', () => {
+  const feedback = buildDnsApplyFeedback({
+    ok: false,
+    results: [
+      { key: 'verification', type: 'TXT', host: '_mailhub.notify.example.com', ok: true },
+      {
+        key: 'dkim',
+        type: 'TXT',
+        host: 'mh._domainkey.notify.example.com',
+        ok: false,
+        error: 'domain not found'
+      }
+    ]
+  }, {
+    completed: 'DNS 写入请求已完成',
+    partial: 'DNS 写入部分失败'
+  });
+
+  assert.deepEqual(feedback, {
+    type: 'warning',
+    message: 'DNS 写入部分失败:domain not found'
+  });
+});
+
 function record(key, status) {
   return {
     key,

+ 1 - 1
test/server-admin-api.test.js

@@ -37,7 +37,7 @@ test('admin API routes respond once and keep the server alive', async () => {
       headers: { Cookie: cookie }
     });
     assert.equal(settings.status, 200);
-    assert.equal((await settings.json()).settings.mailHostname, 'ali.ss5.xyz');
+    assert.equal((await settings.json()).settings.mailHostname, 'mailhub.local');
 
     const exited = await waitForExit(child, 300);
     assert.equal(exited, false);

+ 11 - 1
vite.config.ts

@@ -1,9 +1,19 @@
 import react from '@vitejs/plugin-react';
+import { rmSync } from 'node:fs';
 import { resolve } from 'node:path';
 import { defineConfig } from 'vite';
 
 export default defineConfig({
-  plugins: [react()],
+  plugins: [
+    {
+      name: 'clean-generated-assets',
+      apply: 'build',
+      buildStart() {
+        rmSync(resolve(__dirname, 'public/assets'), { recursive: true, force: true });
+      }
+    },
+    react()
+  ],
   publicDir: false,
   build: {
     outDir: 'public',

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно