ソースを参照

feat: delegate mailbox access to Dovecot

AI-Co-Authored-By: Codex
chendeben 1 ヶ月 前
コミット
19fe9e920b

+ 14 - 0
.env.example

@@ -21,6 +21,9 @@ MAILHUB_CERT_SOURCE_DIR=
 SUBMISSION_USERNAME=change-this-smtp-user
 SUBMISSION_PASSWORD=change-this-smtp-password
 
+# Legacy Node protocol backend only. Docker Compose always exposes Dovecot on
+# 143/993 and 110/995; use IMAP_BIND/POP3_BIND or a Compose override/firewall
+# when either public protocol must be restricted.
 IMAP_ENABLED=true
 IMAP_BIND=0.0.0.0
 IMAP_PORTS=143:imap,993:imaps
@@ -29,6 +32,17 @@ POP3_BIND=0.0.0.0
 POP3_PORTS=110:pop3,995:pop3s
 MAIL_ACCESS_ALLOW_INSECURE_AUTH=false
 
+# Docker deployments delegate IMAP/POP3 to Dovecot. The authentication bridge
+# is available only on the private Compose network and uses a generated secret.
+MAIL_ACCESS_BACKEND=dovecot
+DOVECOT_AUTH_ENABLED=true
+DOVECOT_AUTH_HOST=0.0.0.0
+DOVECOT_AUTH_PORT=3001
+DOVECOT_AUTH_SECRET_FILE=/run/secrets/dovecot_auth_secret
+# Relative path for host-side migration commands. Compose overrides this with /data/maildir.
+MAILDIR_ROOT=./data/maildir
+MAILDIR_SYNC_INTERVAL_MS=5000
+
 # Default outbound identity used in SPF, HELO, and Postfix myhostname.
 MAIL_HOSTNAME=smtp.mailhub.example.com
 SENDING_IP=203.0.113.10

+ 0 - 4
Dockerfile

@@ -17,10 +17,6 @@ EXPOSE 25
 EXPOSE 465
 EXPOSE 587
 EXPOSE 2525
-EXPOSE 110
-EXPOSE 143
-EXPOSE 993
-EXPOSE 995
 VOLUME ["/data"]
 
 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \

+ 30 - 10
README.md

@@ -1,6 +1,6 @@
 # MailHub
 
-MailHub 是一个 Docker 化的多用户发信控制面板、SMTP Submission 服务和发送 API。它面向需要自托管出站邮件能力的团队,提供域名验证、DKIM 签名、DNS 配置提示、SMTP 凭据和 API Token 管理。
+MailHub 是一个 Docker 化的多用户邮件控制面板、SMTP Submission 服务和收发 API。它面向需要自托管邮件能力的团队,提供域名验证、DKIM 签名、DNS 配置提示、邮箱账号、SMTP 凭据和 API Token 管理。
 
 ## 功能
 
@@ -10,6 +10,7 @@ MailHub 是一个 Docker 化的多用户发信控制面板、SMTP Submission 服
 - 检查公网 DNS 中的 SPF、DKIM、DMARC、PTR 和发信主机 A 记录状态。
 - 通过内部 Postfix 出站队列发送邮件,并按发件域名添加 DKIM 签名。
 - 提供 SMTP Submission 和 HTTP 发送 API。
+- 通过 Dovecot 提供完整 IMAP/POP3 客户端兼容,Maildir 作为邮件真源。
 - 内置 React + Ant Design 管理界面。
 
 ## 技术栈
@@ -17,7 +18,7 @@ MailHub 是一个 Docker 化的多用户发信控制面板、SMTP Submission 服
 - Node.js ESM,要求 Node.js `>=24.0.0`
 - SQLite 持久化
 - React、Vite、Ant Design
-- Docker Compose + Postfix
+- Docker Compose + Postfix + Dovecot
 - Node 内置 `node:test`
 
 ## 快速开始
@@ -27,8 +28,9 @@ cp .env.example .env
 npm install
 npm test
 npm run build
+npm run prepare:dovecot
 docker compose up -d --build
-docker compose logs -f app postfix
+docker compose logs -f app postfix dovecot
 ```
 
 默认管理面板通过 `APP_PORT` 暴露到宿主机 `127.0.0.1:3025`。生产环境建议使用 Nginx、Caddy 或其他反向代理提供 HTTPS。
@@ -45,6 +47,8 @@ docker compose logs -f app postfix
 - `SESSION_SECRET`:会话和服务端加密使用的随机密钥,生产环境必须使用强随机值。
 - `SUBMISSION_HOST`、`SUBMISSION_PORTS`:SMTP Submission 对外连接信息。
 - `SUBMISSION_TLS_CERT`、`SUBMISSION_TLS_KEY`:TLS 证书路径。证书文件应放在本地 `certs/`,不要提交到 Git。
+- `MAIL_ACCESS_BACKEND`:Docker 部署使用 `dovecot`;仅本地协议回归时可使用 `legacy`。
+- `MAILDIR_ROOT`、`MAILDIR_SYNC_INTERVAL_MS`:Maildir 持久化目录及管理界面索引同步周期。
 - `DEFAULT_SPF_MECHANISMS`:需要保留的第三方 SPF include,例如事务邮件服务商。
 - `SEND_REQUIRES_VERIFIED`:是否要求域名 DNS 验证通过后才能发信。
 - `LIST_UNSUBSCRIBE_MAILTO`、`LIST_UNSUBSCRIBE_URL`:可选退订头配置,支持 `{eventId}`、`{recipient}`、`{sender}`、`{domain}`、`{userId}` 占位符。
@@ -69,6 +73,19 @@ Password: 用户在网页“SMTP 凭据”中配置
 
 SMTP 密码会同时保存哈希和服务端加密密文:哈希用于认证,加密密文用于用户本人在网页复制。旧数据如果只有哈希,无法反解,需要用户重新设置一次密码后才能复制。
 
+## IMAP / POP3 收信
+
+Docker Compose 中由 Dovecot 独占 IMAP/POP3 端口,MailHub Node 服务不再直接实现客户端协议。邮箱地址和原密码继续由 MailHub 认证,Vesta 导入的 MD5-CRYPT 密码首次登录成功后会自动升级为 scrypt,不需要用户改密码。Compose 默认固定开放 IMAP 与 POP3;旧的 `IMAP_ENABLED`、`POP3_ENABLED`、`IMAP_PORTS`、`POP3_PORTS` 只用于本地 `legacy` 协议回归。若生产环境不提供某个协议,应通过 `IMAP_BIND` / `POP3_BIND`、Compose override 或防火墙限制对应端口。
+
+```txt
+IMAP: 143 STARTTLS / 993 implicit TLS
+POP3: 110 STLS / 995 implicit TLS
+Username: 完整邮箱地址
+Password: 邮箱原密码或在 MailHub 设置的新密码
+```
+
+邮件原始字节、文件夹、已读和 flags 以 `data/maildir/` 为准;SQLite 保留管理界面/API 的检索索引。Dovecot 内的移动、已读、APPEND 和 EXPUNGE 会由后台同步到该索引。首次切换应短暂停止 app 与 Dovecot,再通过 app 镜像运行 `node scripts/migrate-sqlite-maildir.js`;迁移可重复执行,且会跳过已经切换到 Maildir 的邮件。
+
 ## API Token 与收发 API
 
 每个用户可以在面板生成自己的 API Token。新 Token 会以哈希用于认证,并以服务端加密密文供所属账号在面板完整查看和复制;升级前创建、仅保存哈希的历史 Token 无法反解,可在面板确认后重新生成。
@@ -128,9 +145,12 @@ curl -H "Authorization: Bearer <INBOUND_API_TOKEN>" \
 1. 将仓库部署到服务器目录,例如 `/opt/mailhub`。
 2. 基于 `.env.example` 创建 `.env`,填写真实域名、IP、证书路径和强随机密钥。
 3. 将 TLS 证书放在本地 `certs/` 目录,确保私钥不会进入 Git。如证书由宝塔等主机端工具续期,可在 `.env` 中设置 `MAILHUB_CERT_SOURCE_DIR`,该目录需包含 `fullchain.pem` 和 `privkey.pem`。
-4. 运行 `docker compose up -d --build`。
-5. 使用反向代理把 HTTPS 流量转发到 `127.0.0.1:${APP_PORT}`。
-6. 在云防火墙和系统防火墙中放行需要的 SMTP 端口。
+4. 以 root(或 Linux 宿主 UID 1000)运行 `npm run prepare:dovecot`,生成仅 app 与 Dovecot 可读的内部认证 secret;脚本会在不兼容的 Linux UID 下直接失败,避免启动后才发现 Maildir/secret 无权限。
+5. 运行 `docker compose build app postfix`,然后用 `docker compose stop app dovecot` 开始短维护窗口。
+6. 运行 `docker compose run --rm --no-deps app node scripts/migrate-sqlite-maildir.js`,把现有 SQLite 邮件可重入地写入 Maildir;迁移完成前不要让旧 IMAP 或 SMTP 入站继续写邮件。
+7. 运行 `docker compose up -d` 恢复服务。
+8. 使用反向代理把 HTTPS 流量转发到 `127.0.0.1:${APP_PORT}`。
+9. 在云防火墙和系统防火墙中放行需要的 SMTP、IMAP 和 POP3 端口。
 
 可选的远程部署脚本需要显式提供目标服务器和目录:
 
@@ -141,9 +161,9 @@ MAILHUB_DEPLOY_BRANCH=main \
 npm run deploy:remote
 ```
 
-脚本会要求本地 HEAD 已推送到对应远端分支,然后在目标目录执行 `git pull --ff-only` 和 `docker compose up -d --build`,并等待 `app`、`postfix` 都进入健康状态。如果目标工作区存在未提交变更,脚本会停止;确认可暂存远端工作区时,可显式设置 `MAILHUB_DEPLOY_STASH_REMOTE=1`。
+脚本会要求本地 HEAD 已推送到对应远端分支,然后在目标目录执行 `git pull --ff-only`、准备 Dovecot secret、离线同步证书、预拉取 Dovecot 镜像、迁移现有 Maildir、重建服务,并等待 `app`、`postfix`、`dovecot` 都进入健康状态。如果迁移写入或最终 SQLite 切换事务失败,脚本会清理未提交的 Maildir 文件并恢复切换前容器;切换事务一旦成功则不会自动启动 legacy 协议栈,以免已读、移动或删除状态在两个真源间分叉。此时若新服务启动失败,应保持 Maildir 数据不动并修复后向前恢复。目标工作区存在未提交变更时脚本会停止;确认可暂存远端工作区时,可显式设置 `MAILHUB_DEPLOY_STASH_REMOTE=1`。
 
-配置 `MAILHUB_CERT_SOURCE_DIR` 后,发布脚本会先把主机证书复制为受控快照,再校验有效期、主机名和公私钥匹配。同步过程带并发锁和失败回滚,目标证书固定为 `0644`,私钥固定为 `0640` 并授权给实际运行中的 app 容器组。证书变化后会重启 app,并通过容器端口 `465`、`993` 的 SNI、证书链、主机名和 SHA-256 指纹确认服务已加载新证书。
+配置 `MAILHUB_CERT_SOURCE_DIR` 后,发布脚本会先把主机证书复制为受控快照,再校验有效期、主机名和公私钥匹配。同步过程带并发锁和失败回滚,目标证书固定为 `0644`,私钥固定为 `0640` 并授权给容器运行组。证书变化后会同时重启 app 与 Dovecot,并通过 `465`、`993` 的 SNI、证书链、主机名和 SHA-256 指纹确认两个服务已加载新证书。
 
 同一脚本可由宝塔计划任务定期执行;宝塔仍负责申请和续期证书,MailHub 只读取续期结果:
 
@@ -151,7 +171,7 @@ npm run deploy:remote
 cd "/opt/mailhub" && MAILHUB_CERT_RESTART=1 ./scripts/sync-tls-certificate.sh
 ```
 
-默认验证容器端口 `465 993`。若部署明确关闭了其中一个 TLS 服务,可通过 `MAILHUB_CERT_VERIFY_ENDPOINTS` 调整;离线同步且无法检测 app 容器组时,必须显式设置 `MAILHUB_CERT_READER_GID`。
+默认验证公网语义端口 `465 993`,其中 `993` 会自动映射到 Dovecot 的 rootless 容器端口 `31993`。若部署明确关闭了其中一个 TLS 服务,可通过 `MAILHUB_CERT_VERIFY_ENDPOINTS` 调整;离线同步且无法检测 app 容器组时,必须显式设置 `MAILHUB_CERT_READER_GID`。
 
 ## 测试
 
@@ -168,7 +188,7 @@ npm run build
 - 替换默认管理员凭据,设置足够长的 `SESSION_SECRET`。
 - 生产发信前确认 SPF、DKIM、DMARC、PTR 和发信主机 A 记录。
 - 确认服务器出站 25 端口没有被云厂商拦截。
-- 确认入站 `25/465/587/2525` 已在云防火墙和系统防火墙放行。
+- 确认入站 `25/465/587/2525` 以及所需的 `110/143/993/995` 已在云防火墙和系统防火墙放行。
 - 新 IP 先小流量预热,避免突然大批量发送。
 - 遵守适用法律、服务商政策和收件人同意要求;不要使用 MailHub 发送垃圾邮件。
 

+ 64 - 4
docker-compose.yml

@@ -10,19 +10,28 @@ services:
       PORT: 3000
       DATA_DIR: /data
       POSTFIX_LOG_FILE: /data/postfix-logs/mail.log
+      MAIL_ACCESS_BACKEND: dovecot
+      MAILDIR_ROOT: /data/maildir
+      DOVECOT_AUTH_ENABLED: "true"
+      DOVECOT_AUTH_HOST: 0.0.0.0
+      DOVECOT_AUTH_PORT: 3001
+      DOVECOT_AUTH_SECRET_FILE: /run/secrets/dovecot_auth_secret
     ports:
       - "127.0.0.1:${APP_PORT:-3025}:3000"
       - "${SUBMISSION_BIND:-0.0.0.0}:25:25"
       - "${SUBMISSION_BIND:-0.0.0.0}:465:465"
       - "${SUBMISSION_BIND:-0.0.0.0}:587:587"
       - "${SUBMISSION_BIND:-0.0.0.0}:${SUBMISSION_ALT_PORT:-2525}:2525"
-      - "${IMAP_BIND:-0.0.0.0}:143:143"
-      - "${IMAP_BIND:-0.0.0.0}:993:993"
-      - "${POP3_BIND:-0.0.0.0}:110:110"
-      - "${POP3_BIND:-0.0.0.0}:995:995"
+    expose:
+      - "3001"
     volumes:
       - ./data:/data
       - ./certs:/certs:ro
+    secrets:
+      - dovecot_auth_secret
+    networks:
+      - mailhub
+      - dovecot_internal
     depends_on:
       postfix:
         condition: service_started
@@ -33,6 +42,46 @@ services:
       retries: 3
       start_period: 15s
 
+  dovecot:
+    image: dovecot/dovecot:2.4.4
+    container_name: mailhub-dovecot
+    restart: unless-stopped
+    hostname: ${MAIL_HOSTNAME:-mailhub.local}
+    environment:
+      SUBMISSION_TLS_CERT: ${SUBMISSION_TLS_CERT:-/certs/mailhub.example.com.crt}
+      SUBMISSION_TLS_KEY: ${SUBMISSION_TLS_KEY:-/certs/mailhub.example.com.key}
+    ports:
+      - "${IMAP_BIND:-0.0.0.0}:143:31143"
+      - "${IMAP_BIND:-0.0.0.0}:993:31993"
+      - "${POP3_BIND:-0.0.0.0}:110:31110"
+      - "${POP3_BIND:-0.0.0.0}:995:31995"
+    volumes:
+      - ./data/maildir:/srv/vmail
+      - ./certs:/certs:ro
+      - ./docker/dovecot/auth.conf:/etc/dovecot/conf.d/auth.conf:ro
+      - ./docker/dovecot/ssl.conf:/etc/dovecot/conf.d/ssl.conf:ro
+      - ./docker/dovecot/mailhub.conf:/etc/dovecot/conf.d/zz-mailhub.conf:ro
+      - ./docker/dovecot/auth.lua:/etc/dovecot/auth.lua:ro
+    secrets:
+      - dovecot_auth_secret
+    networks:
+      - dovecot_internal
+    depends_on:
+      app:
+        condition: service_healthy
+    read_only: true
+    tmpfs:
+      - /tmp:mode=1777
+      - /run/dovecot:mode=1777
+    security_opt:
+      - no-new-privileges:true
+    healthcheck:
+      test: ["CMD-SHELL", "test -r /run/secrets/dovecot_auth_secret && test -s /run/secrets/dovecot_auth_secret && test -w /srv/vmail && doveadm service status imap-login pop3-login"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+      start_period: 15s
+
   postfix:
     build:
       context: ./docker/postfix
@@ -45,9 +94,20 @@ services:
     hostname: ${MAIL_HOSTNAME:-mailhub.local}
     volumes:
       - ./data/postfix-logs:/var/log/mailhub
+    networks:
+      - mailhub
     healthcheck:
       test: ["CMD-SHELL", "postfix status >/dev/null 2>&1 || exit 1"]
       interval: 30s
       timeout: 5s
       retries: 3
       start_period: 20s
+
+secrets:
+  dovecot_auth_secret:
+    file: ./data/secrets/dovecot_auth_secret
+
+networks:
+  mailhub:
+  dovecot_internal:
+    internal: true

+ 21 - 0
docker/dovecot/auth.conf

@@ -0,0 +1,21 @@
+auth_mechanisms = plain login
+auth_allow_cleartext = no
+
+import_environment {
+  SUBMISSION_TLS_CERT = %{env:SUBMISSION_TLS_CERT}
+  SUBMISSION_TLS_KEY = %{env:SUBMISSION_TLS_KEY}
+}
+
+passdb lua {
+  lua_file = /etc/dovecot/auth.lua
+  use_worker = yes
+}
+
+userdb static {
+  allow_all_users = yes
+  fields {
+    uid = 1000
+    gid = 1000
+    home = /srv/vmail/%{user | lower}
+  }
+}

+ 118 - 0
docker/dovecot/auth.lua

@@ -0,0 +1,118 @@
+local json = require "json"
+
+local auth_url = "http://app:3001/internal/dovecot/auth"
+local secret_file = "/run/secrets/dovecot_auth_secret"
+local http_client
+local shared_secret
+
+local function read_secret(path)
+  local file = io.open(path, "r")
+  if file == nil then
+    error("Dovecot authentication secret is unavailable")
+  end
+
+  local value = file:read("*a")
+  file:close()
+  value = string.gsub(value or "", "^%s+", "")
+  value = string.gsub(value, "%s+$", "")
+  if #value < 32 or #value > 512 or string.find(value, "%s") ~= nil then
+    error("Dovecot authentication secret is invalid")
+  end
+  return value
+end
+
+function script_init()
+  shared_secret = read_secret(secret_file)
+  http_client = dovecot.http.client {
+    auto_retry = "no",
+    request_max_attempts = 1,
+    connect_timeout = "1s",
+    request_timeout = "2s",
+    request_absolute_timeout = "2s"
+  }
+  return 0
+end
+
+local function failure(result)
+  return result, nil
+end
+
+local function valid_user(value)
+  if type(value) ~= "string" or #value == 0 or #value > 320 then
+    return false
+  end
+  if string.find(value, "/", 1, true) ~= nil
+      or string.find(value, "\\", 1, true) ~= nil
+      or string.find(value, "\0", 1, true) ~= nil
+      or string.find(value, "%s") ~= nil then
+    return false
+  end
+  return string.find(value, "^[^@]+@[^@]+%.[^@]+$") ~= nil
+end
+
+local function first_nonempty_string(...)
+  for index = 1, select("#", ...) do
+    local value = select(index, ...)
+    if value ~= nil then
+      local normalized = tostring(value)
+      if normalized ~= "" then
+        return normalized
+      end
+    end
+  end
+  return ""
+end
+
+function auth_password_verify(request, password)
+  local protocol = string.lower(first_nonempty_string(request.protocol, request.service))
+  local remote_ip = first_nonempty_string(
+    request.remote_ip,
+    request.real_remote_ip,
+    request.rip,
+    request.real_rip
+  )
+  if protocol ~= "imap" and protocol ~= "pop3" then
+    return failure(dovecot.auth.PASSDB_RESULT_USER_DISABLED)
+  end
+
+  local http_request = http_client:request {
+    url = auth_url,
+    method = "POST"
+  }
+  http_request:add_header("content-type", "application/json")
+  http_request:add_header("authorization", "Bearer " .. shared_secret)
+  http_request:set_payload(json.encode {
+    username = request.user,
+    password = password,
+    service = protocol,
+    remoteIp = remote_ip
+  })
+
+  local submitted, response = pcall(function()
+    return http_request:submit()
+  end)
+  if not submitted then
+    return failure(dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE)
+  end
+
+  local status = response:status()
+  if status ~= 200 then
+    return failure(dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE)
+  end
+
+  local decoded, payload = pcall(json.decode, response:payload())
+  if not decoded or type(payload) ~= "table" then
+    return failure(dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE)
+  end
+  if payload.authenticated == false then
+    return failure(dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH)
+  end
+  if payload.authenticated ~= true then
+    return failure(dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE)
+  end
+  if not valid_user(payload.user) then
+    return failure(dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE)
+  end
+
+  return dovecot.auth.PASSDB_RESULT_OK, { user = string.lower(payload.user) }
+end

+ 56 - 0
docker/dovecot/mailhub.conf

@@ -0,0 +1,56 @@
+protocols = imap pop3
+
+mail_driver = maildir
+mail_home = /srv/vmail/%{user | lower}
+mail_path = ~/mail
+mailbox_list_layout = maildir++
+mailbox_list_storage_escape_char = ^
+# MailHub's Maildir adapter stores folder names in IMAP Modified UTF-7 so it
+# remains compatible with imported Vesta/Dovecot Maildir++ trees.
+mailbox_list_utf8 = no
+mail_uid = 1000
+mail_gid = 1000
+
+ssl = yes
+
+namespace inbox {
+  inbox = yes
+  separator = /
+
+  mailbox Archive {
+    auto = subscribe
+    special_use = \Archive
+  }
+  mailbox Drafts {
+    auto = subscribe
+    special_use = \Drafts
+  }
+  mailbox Junk {
+    auto = subscribe
+    special_use = \Junk
+  }
+  mailbox Sent {
+    auto = subscribe
+    special_use = \Sent
+  }
+  mailbox Trash {
+    auto = subscribe
+    special_use = \Trash
+  }
+}
+
+service imap-login {
+  chroot =
+
+  inet_listener imaps {
+    ssl = yes
+  }
+}
+
+service pop3-login {
+  chroot =
+
+  inet_listener pop3s {
+    ssl = yes
+  }
+}

+ 2 - 0
docker/dovecot/ssl.conf

@@ -0,0 +1,2 @@
+ssl_server_cert_file = $ENV:SUBMISSION_TLS_CERT
+ssl_server_key_file = $ENV:SUBMISSION_TLS_KEY

+ 2 - 0
package.json

@@ -32,6 +32,8 @@
     "test:ui": "vitest run",
     "release:check": "npm test && npm run test:ui && npm run build",
     "import:vesta": "node scripts/import-vesta-maildir.js",
+    "prepare:dovecot": "bash scripts/prepare-dovecot.sh",
+    "migrate:maildir": "node scripts/migrate-sqlite-maildir.js",
     "deploy:remote": "bash scripts/deploy-remote.sh"
   },
   "engines": {

+ 89 - 4
scripts/deploy-remote.sh

@@ -72,6 +72,54 @@ wait_for_compose_health() {
   return 1
 }
 
+verify_mail_runtime() {
+  docker compose exec -T app node -e '
+    const fs = require("node:fs");
+    const secret = fs.readFileSync("/run/secrets/dovecot_auth_secret", "utf8").trim();
+    fs.accessSync("/data/maildir", fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);
+    const probe = `/data/maildir/.mailhub-app-health-${process.pid}`;
+    try {
+      fs.writeFileSync(probe, "", { mode: 0o600, flag: "wx" });
+    } finally {
+      try { fs.unlinkSync(probe); } catch {}
+    }
+    fetch("http://app:3001/internal/dovecot/auth", {
+      method: "POST",
+      headers: {
+        authorization: `Bearer ${secret}`,
+        "content-type": "application/json"
+      },
+      body: JSON.stringify({
+        username: "mailhub-healthcheck@invalid.invalid",
+        password: "mailhub-healthcheck-invalid-password",
+        service: "imap",
+        remoteIp: "127.0.0.1"
+      }),
+      signal: AbortSignal.timeout(5000)
+    }).then(async (response) => {
+      const payload = await response.json();
+      if (response.status !== 200 || payload.authenticated !== false) process.exit(1);
+    }).catch(() => process.exit(1));
+  ' >/dev/null 2>&1 || {
+    echo "MailHub authentication bridge or app Maildir access check failed." >&2
+    return 1
+  }
+
+  docker compose exec -T --user 1000:1000 dovecot sh -ec '
+    test -r /run/secrets/dovecot_auth_secret
+    test -s /run/secrets/dovecot_auth_secret
+    probe="/srv/vmail/.mailhub-dovecot-health-$$"
+    trap '\''rm -f -- "$probe"'\'' 0 1 2 15
+    umask 077
+    : >"$probe"
+    rm -f -- "$probe"
+    trap - 0 1 2 15
+  ' >/dev/null 2>&1 || {
+    echo "Dovecot secret or Maildir write access check failed." >&2
+    return 1
+  }
+}
+
 if ! git remote get-url origin >/dev/null 2>&1; then
   git remote add origin "${git_url}"
 fi
@@ -87,11 +135,31 @@ if [[ -n "$(git status --porcelain)" ]]; then
 fi
 
 previous_revision="$(git rev-parse HEAD)"
+stopped_app_container=""
+stopped_dovecot_container=""
+mail_services_stopped_for_migration=0
+maildir_cutover_committed=0
 on_deploy_exit() {
   local status=$?
   trap - EXIT
   if [[ "${status}" != "0" ]]; then
-    echo "Deployment failed. Previous revision was ${previous_revision}; inspect the running containers before rollback." >&2
+    echo "Deployment failed. Previous revision was ${previous_revision}; inspect the running containers before recovery." >&2
+    if [[ "${mail_services_stopped_for_migration}" == "1" ]]; then
+      echo "Restarting the pre-migration MailHub mail services." >&2
+      if [[ -n "${stopped_app_container}" ]]; then
+        docker start "${stopped_app_container}" >/dev/null 2>&1 || \
+          echo "Unable to restart the pre-migration app container ${stopped_app_container}." >&2
+      fi
+      if [[ -n "${stopped_dovecot_container}" ]]; then
+        docker start "${stopped_dovecot_container}" >/dev/null 2>&1 || \
+          echo "Unable to restart the pre-migration Dovecot container ${stopped_dovecot_container}." >&2
+      fi
+    elif [[ "${maildir_cutover_committed}" == "1" ]]; then
+      docker compose stop app dovecot >/dev/null 2>&1 || \
+        echo "Unable to stop the post-cutover mail services; inspect their port exposure immediately." >&2
+      echo "Maildir cutover is already committed; legacy mail services will not be restarted because that would create two conflicting sources of truth." >&2
+      echo "The maintenance window remains active; recover the current Compose services or perform an explicit revision rollback before reopening mail traffic." >&2
+    fi
     docker compose ps >&2 || true
   fi
   exit "${status}"
@@ -101,10 +169,27 @@ trap on_deploy_exit EXIT
 git fetch origin "${branch}"
 git checkout "${branch}"
 git pull --ff-only origin "${branch}"
-docker compose up -d --build
-wait_for_compose_health app postfix
+./scripts/prepare-dovecot.sh
+if [[ "$(id -u)" == "0" ]]; then
+  MAILHUB_CERT_READER_GID=1000 MAILHUB_CERT_RESTART=0 ./scripts/sync-tls-certificate.sh
+else
+  MAILHUB_CERT_RESTART=0 ./scripts/sync-tls-certificate.sh
+fi
+docker compose build app postfix
+docker compose pull dovecot
+stopped_app_container="$(docker compose ps --all --quiet app 2>/dev/null | tail -n 1 || true)"
+stopped_dovecot_container="$(docker compose ps --all --quiet dovecot 2>/dev/null | tail -n 1 || true)"
+mail_services_stopped_for_migration=1
+docker compose stop app dovecot
+docker compose run --rm --no-deps app node scripts/migrate-sqlite-maildir.js
+maildir_cutover_committed=1
+mail_services_stopped_for_migration=0
+docker compose up -d
+wait_for_compose_health app postfix dovecot
+verify_mail_runtime
 MAILHUB_CERT_RESTART=1 ./scripts/sync-tls-certificate.sh
-wait_for_compose_health app postfix
+wait_for_compose_health app postfix dovecot
+verify_mail_runtime
 docker compose ps
 trap - EXIT
 REMOTE

+ 45 - 0
scripts/migrate-sqlite-maildir.js

@@ -0,0 +1,45 @@
+#!/usr/bin/env node
+
+import crypto from 'node:crypto';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+import { initDatabase } from '../src/db.js';
+import { maildirRootFromEnvironment } from '../src/maildir-store.js';
+import { migrateInboundMessagesToMaildir } from '../src/maildir-sync.js';
+import { loadMailHubEnvironment } from './import-vesta-maildir.js';
+
+export async function runSqliteMaildirMigration({ env = process.env, output = process.stdout } = {}) {
+  loadMailHubEnvironment({ env });
+  const dataDir = path.resolve(env.DATA_DIR || path.join(process.cwd(), 'data'));
+  const maildirRoot = maildirRootFromEnvironment(env);
+  const secret = String(env.SESSION_SECRET || crypto
+    .createHash('sha256')
+    .update(`${env.ADMIN_PASSWORD || 'change-this-admin-password'}:${env.API_TOKEN || ''}`)
+    .digest('hex'));
+  initDatabase(dataDir, secret);
+
+  let lastReported = 0;
+  const report = await migrateInboundMessagesToMaildir({
+    root: maildirRoot,
+    onProgress(progress) {
+      if (progress.processed - lastReported < 250) return;
+      lastReported = progress.processed;
+      output.write(`Maildir migration progress: processed=${progress.processed} written=${progress.written} reused=${progress.reused}\n`);
+    }
+  });
+  output.write(`Maildir migration complete: processed=${report.processed} written=${report.written} reused=${report.reused}\n`);
+  return report;
+}
+
+function isMainModule() {
+  const scriptPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
+  return scriptPath && import.meta.url === pathToFileURL(scriptPath).href;
+}
+
+if (isMainModule()) {
+  runSqliteMaildirMigration().catch((error) => {
+    console.error(`Maildir migration failed: ${error.message || error}`);
+    process.exitCode = 1;
+  });
+}

+ 60 - 0
scripts/prepare-dovecot.sh

@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+project_dir="${MAILHUB_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)}"
+data_dir="${MAILHUB_DATA_DIR:-${project_dir}/data}"
+
+fail() {
+  echo "Dovecot preparation failed: $*" >&2
+  exit 1
+}
+
+command -v openssl >/dev/null 2>&1 || fail "openssl is required."
+[[ ! -L "${data_dir}" ]] || fail "data directory must not be a symbolic link."
+mkdir -p "${data_dir}"
+data_dir="$(cd "${data_dir}" && pwd -P)"
+secret_file="${MAILHUB_DOVECOT_SECRET_FILE:-${data_dir}/secrets/dovecot_auth_secret}"
+maildir_root="${MAILHUB_MAILDIR_ROOT:-${data_dir}/maildir}"
+host_uid="$(id -u)"
+
+if [[ "$(uname -s)" == "Linux" && "${host_uid}" != "0" && "${host_uid}" != "1000" ]]; then
+  fail "Linux preparation must run as root or host uid 1000 so the rootless containers can read and write their bind mounts."
+fi
+
+case "${secret_file}" in
+  "${data_dir}"/*) ;;
+  *) fail "secret file must stay inside the MailHub data directory." ;;
+esac
+case "${maildir_root}" in
+  "${data_dir}"/*) ;;
+  *) fail "Maildir root must stay inside the MailHub data directory." ;;
+esac
+
+[[ ! -L "${secret_file}" ]] || fail "secret file must not be a symbolic link."
+[[ ! -L "${maildir_root}" ]] || fail "Maildir root must not be a symbolic link."
+mkdir -p "$(dirname "${secret_file}")" "${maildir_root}"
+
+if [[ ! -f "${secret_file}" ]]; then
+  umask 077
+  temporary_secret="$(mktemp "$(dirname "${secret_file}")/.dovecot-auth.XXXXXX")"
+  trap 'rm -f -- "${temporary_secret:-}"' EXIT
+  openssl rand -hex 32 >"${temporary_secret}"
+  chmod 0600 "${temporary_secret}"
+  mv "${temporary_secret}" "${secret_file}"
+  trap - EXIT
+fi
+
+[[ -f "${secret_file}" ]] || fail "secret path must be a regular file."
+secret="$(tr -d '\r\n' <"${secret_file}")"
+[[ "${secret}" =~ ^[0-9a-fA-F]+$ ]] || fail "secret must contain only hexadecimal characters."
+(( ${#secret} >= 64 && ${#secret} <= 512 )) || fail "secret must contain 64-512 hexadecimal characters."
+
+if [[ "${host_uid}" == "0" ]]; then
+  # Both the Node application and Dovecot's rootless mail processes use
+  # uid/gid 1000. Compose file-backed secrets preserve host ownership on Linux.
+  chown 1000:1000 "${secret_file}" "${maildir_root}"
+fi
+chmod 0700 "${maildir_root}"
+chmod 0400 "${secret_file}"
+
+echo "Dovecot storage and authentication secret are ready."

+ 37 - 10
scripts/sync-tls-certificate.sh

@@ -122,20 +122,48 @@ wait_for_app_health() {
   return 1
 }
 
+wait_for_dovecot_health() {
+  local attempt
+  for attempt in $(seq 1 45); do
+    if docker compose exec -T dovecot doveconf -n >/dev/null 2>&1; then
+      return 0
+    fi
+    sleep 2
+  done
+  return 1
+}
+
+restart_tls_services() {
+  docker compose restart app dovecot
+  wait_for_app_health || fail "MailHub app did not become healthy after the certificate restart."
+  wait_for_dovecot_health || fail "Dovecot did not become healthy after the certificate restart."
+}
+
 verify_container_access() {
   docker compose exec -T app node -e \
     "const fs=require('node:fs');const tls=require('node:tls');tls.createSecureContext({cert:fs.readFileSync(process.argv[1]),key:fs.readFileSync(process.argv[2])});" \
     "${container_cert}" "${container_key}" >/dev/null 2>&1 \
     || fail "MailHub app cannot read or parse the synchronized certificate pair."
+  docker compose exec -T dovecot sh -c \
+    'test -r "$1" && test -r "$2"' -- "${container_cert}" "${container_key}" >/dev/null 2>&1 \
+    || fail "Dovecot cannot read the synchronized certificate pair."
 }
 
 tls_endpoint_matches() {
-  local port="$1"
+  local port="$1" service="app" container_port="$1"
   local mapping mapped_host mapped_port connect_host connection output presented
   local expected_fingerprint actual_fingerprint
+  local -a protocol_args=()
 
   [[ "${port}" =~ ^[0-9]+$ ]] || return 1
-  mapping="$(docker compose port app "${port}" 2>/dev/null | head -n 1 || true)"
+  case "${port}" in
+    25|587|2525) protocol_args=(-starttls smtp) ;;
+    110) service="dovecot"; container_port="31110"; protocol_args=(-starttls pop3) ;;
+    143) service="dovecot"; container_port="31143"; protocol_args=(-starttls imap) ;;
+    993) service="dovecot"; container_port="31993" ;;
+    995) service="dovecot"; container_port="31995" ;;
+  esac
+  mapping="$(docker compose port "${service}" "${container_port}" 2>/dev/null | head -n 1 || true)"
   [[ -n "${mapping}" ]] || return 1
   mapped_port="${mapping##*:}"
   if [[ "${mapping}" == \[*\]:* ]]; then
@@ -156,10 +184,10 @@ tls_endpoint_matches() {
   presented="${work_dir}/tls-${port}.pem"
 
   if command -v timeout >/dev/null 2>&1; then
-    if ! timeout 15 openssl s_client -connect "${connection}" -servername "${mail_hostname}" -showcerts \
+    if ! timeout 15 openssl s_client "${protocol_args[@]}" -connect "${connection}" -servername "${mail_hostname}" -showcerts \
       -verify_hostname "${mail_hostname}" -verify_return_error \
       < /dev/null >"${output}" 2>/dev/null; then return 1; fi
-  elif ! openssl s_client -connect "${connection}" -servername "${mail_hostname}" -showcerts \
+  elif ! openssl s_client "${protocol_args[@]}" -connect "${connection}" -servername "${mail_hostname}" -showcerts \
     -verify_hostname "${mail_hostname}" -verify_return_error \
     < /dev/null >"${output}" 2>/dev/null; then
     return 1
@@ -206,8 +234,9 @@ on_exit() {
     if restore_previous_pair; then
       if [[ "${restart_app}" == "1" ]]; then
         set +e
-        docker compose restart app >/dev/null 2>&1
+        docker compose restart app dovecot >/dev/null 2>&1
         wait_for_app_health >/dev/null 2>&1
+        wait_for_dovecot_health >/dev/null 2>&1
         set -e
       fi
       echo "Previous MailHub certificate pair restored." >&2
@@ -303,8 +332,7 @@ if [[ -f "${target_cert}" && -f "${target_key}" ]] \
       fi
     done
     if [[ "${live_certificate_current}" == "0" ]]; then
-      docker compose restart app
-      wait_for_app_health || fail "MailHub app did not become healthy after the certificate restart."
+      restart_tls_services
       for port in ${verify_endpoints}; do
         verify_tls_endpoint "${port}"
       done
@@ -335,8 +363,7 @@ validate_pair "${target_cert}" "${target_key}" synchronized
 if [[ "${restart_app}" == "1" ]]; then
   cd "${project_dir}"
   verify_container_access
-  docker compose restart app
-  wait_for_app_health || fail "MailHub app did not become healthy after the certificate restart."
+  restart_tls_services
   for port in ${verify_endpoints}; do
     verify_tls_endpoint "${port}"
   done
@@ -354,5 +381,5 @@ fi
 
 echo "MailHub TLS certificate synchronized for ${mail_hostname}."
 if [[ "${restart_app}" == "1" ]]; then
-  echo "MailHub app restarted and verified on TLS endpoints: ${verify_endpoints}."
+  echo "MailHub app and Dovecot restarted and verified on TLS endpoints: ${verify_endpoints}."
 fi

+ 526 - 7
src/db.js

@@ -3,7 +3,12 @@ import crypto from 'node:crypto';
 import path from 'node:path';
 import { DatabaseSync } from 'node:sqlite';
 import { dkimPublicFromPrivateKey } from './dkim.js';
-import { hashPassword, isLegacyPasswordHash, verifyPassword } from './password-hash.js';
+import {
+  consumeDummyPasswordVerification,
+  hashPassword,
+  isLegacyPasswordHash,
+  verifyPassword
+} from './password-hash.js';
 import { decryptTrackingTarget, hashTrackingToken } from './tracking.js';
 import {
   MAX_WEBHOOK_ATTEMPTS,
@@ -201,6 +206,13 @@ export function initDatabase(dataDir, secret = '') {
       import_source TEXT NOT NULL DEFAULT '',
       import_source_key TEXT NOT NULL DEFAULT '',
       pop3_size INTEGER NOT NULL DEFAULT 0,
+      storage_backend TEXT NOT NULL DEFAULT 'sqlite',
+      storage_key TEXT NOT NULL DEFAULT '',
+      storage_relpath TEXT NOT NULL DEFAULT '',
+      storage_sha256 TEXT NOT NULL DEFAULT '',
+      storage_size INTEGER NOT NULL DEFAULT 0,
+      storage_mtime_ms INTEGER NOT NULL DEFAULT 0,
+      indexed_at TEXT,
       received_at TEXT NOT NULL,
       created_at TEXT NOT NULL,
       updated_at TEXT NOT NULL,
@@ -210,6 +222,17 @@ export function initDatabase(dataDir, secret = '') {
       FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE
     );
 
+    CREATE TABLE IF NOT EXISTS inbound_maildir_migration_staging (
+      message_id INTEGER PRIMARY KEY,
+      storage_key TEXT NOT NULL,
+      storage_relpath TEXT NOT NULL,
+      storage_sha256 TEXT NOT NULL DEFAULT '',
+      storage_size INTEGER NOT NULL DEFAULT 0,
+      storage_mtime_ms INTEGER NOT NULL DEFAULT 0,
+      indexed_at TEXT NOT NULL,
+      FOREIGN KEY(message_id) REFERENCES inbound_messages(id) ON DELETE CASCADE
+    );
+
     CREATE TABLE IF NOT EXISTS inbound_folders (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       mailbox_id INTEGER NOT NULL,
@@ -363,6 +386,13 @@ export function initDatabase(dataDir, secret = '') {
   ensureColumn('inbound_messages', 'import_source', "TEXT NOT NULL DEFAULT ''");
   ensureColumn('inbound_messages', 'import_source_key', "TEXT NOT NULL DEFAULT ''");
   ensureColumn('inbound_messages', 'pop3_size', 'INTEGER NOT NULL DEFAULT 0');
+  ensureColumn('inbound_messages', 'storage_backend', "TEXT NOT NULL DEFAULT 'sqlite'");
+  ensureColumn('inbound_messages', 'storage_key', "TEXT NOT NULL DEFAULT ''");
+  ensureColumn('inbound_messages', 'storage_relpath', "TEXT NOT NULL DEFAULT ''");
+  ensureColumn('inbound_messages', 'storage_sha256', "TEXT NOT NULL DEFAULT ''");
+  ensureColumn('inbound_messages', 'storage_size', 'INTEGER NOT NULL DEFAULT 0');
+  ensureColumn('inbound_messages', 'storage_mtime_ms', 'INTEGER NOT NULL DEFAULT 0');
+  ensureColumn('inbound_messages', 'indexed_at', 'TEXT');
   backfillInboundPop3Sizes();
   ensureColumn('webhooks', 'mailbox_id', 'INTEGER');
   migrateWebhookDeliveriesForInbound();
@@ -389,6 +419,9 @@ export function initDatabase(dataDir, secret = '') {
     CREATE UNIQUE INDEX IF NOT EXISTS idx_inbound_messages_import_source_key
       ON inbound_messages(import_source, import_source_key)
       WHERE import_source != '' AND import_source_key != '';
+    CREATE UNIQUE INDEX IF NOT EXISTS idx_inbound_messages_maildir_storage_key
+      ON inbound_messages(mailbox_id, storage_key)
+      WHERE storage_backend = 'maildir' AND storage_key != '';
     CREATE INDEX IF NOT EXISTS idx_inbound_folders_mailbox ON inbound_folders(mailbox_id, deleted_at);
     CREATE INDEX IF NOT EXISTS idx_webhooks_user_mailbox ON webhooks(user_id, mailbox_id);
     CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_status_next ON webhook_deliveries(status, next_attempt_at);
@@ -1154,7 +1187,10 @@ export function getInboundMailboxByAddress(address, { includeHash = false, inclu
 
 export function verifyInboundMailboxCredential(username, password) {
   const mailboxAddress = normalizeInboundAddress(username);
-  if (!mailboxAddress) return null;
+  if (!mailboxAddress) {
+    consumeDummyPasswordVerification(password);
+    return null;
+  }
   const row = requireDb()
     .prepare(`
       SELECT
@@ -1178,8 +1214,18 @@ export function verifyInboundMailboxCredential(username, password) {
       LIMIT 1
     `)
     .get(mailboxAddress, now());
-  if (!row?.password_hash || row.user_status !== 'active' || !verifyPassword(password, row.password_hash)) return null;
-  if (isLegacyPasswordHash(row.password_hash)) {
+  if (!row?.password_hash || row.user_status !== 'active') {
+    consumeDummyPasswordVerification(password);
+    return null;
+  }
+  const legacyPasswordHash = isLegacyPasswordHash(row.password_hash);
+  if (!verifyPassword(password, row.password_hash)) {
+    // MD5-CRYPT is much faster than the current scrypt format. Match the
+    // missing-account cost so failed logins cannot identify legacy mailboxes.
+    if (legacyPasswordHash) consumeDummyPasswordVerification(password);
+    return null;
+  }
+  if (legacyPasswordHash) {
     const upgradedAt = now();
     requireDb()
       .prepare(`
@@ -1260,14 +1306,17 @@ export function createInboundMessage(mailbox, message = {}) {
   const rawMessageBytes = message.rawMessageBytes === undefined || message.rawMessageBytes === null
     ? Buffer.from(rawMessage, 'utf8')
     : Buffer.from(message.rawMessageBytes);
+  const storage = normalizeInboundStorage(message.storage, rawMessageBytes);
   if (!isStandardInboundFolder(folder)) createInboundFolder(mailbox, folder);
   const result = requireDb()
     .prepare(`
       INSERT INTO inbound_messages (
         mailbox_id, user_id, domain_id, folder, sender, recipients_json, subject, message_id,
         raw_message, raw_message_bytes, text_body, html_body, preview, read_state, pop3_size,
+        storage_backend, storage_key, storage_relpath, storage_sha256, storage_size,
+        storage_mtime_ms, indexed_at,
         received_at, created_at, updated_at
-      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'false', ?, ?, ?, ?)
+      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'false', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     `)
     .run(
       mailbox.id,
@@ -1284,6 +1333,13 @@ export function createInboundMessage(mailbox, message = {}) {
       htmlBody,
       inboundPreview(textBody || htmlToText(htmlBody)),
       canonicalPop3MessageSize(rawMessageBytes),
+      storage.backend,
+      storage.key,
+      storage.relpath,
+      storage.sha256,
+      storage.size,
+      storage.mtimeMs,
+      storage.indexedAt,
       receivedAt,
       receivedAt,
       receivedAt
@@ -1291,6 +1347,17 @@ export function createInboundMessage(mailbox, message = {}) {
   return getInboundMessage(mailbox.userId, result.lastInsertRowid);
 }
 
+export function createInboundMessageWithWebhook(mailbox, message = {}) {
+  return withInboundWebhookTransaction(() => {
+    const inboundMessage = createInboundMessage(mailbox, message);
+    enqueueInboundWebhookDeliveries(inboundMessage, {
+      database: requireDb(),
+      manageTransactions: false
+    });
+    return inboundMessage;
+  });
+}
+
 export const importedInboundMessageLookupSql = `
   SELECT id
   FROM inbound_messages
@@ -1327,6 +1394,7 @@ export function createImportedInboundMessage(mailbox, message = {}) {
   const textBody = String(message.textBody || '');
   const htmlBody = String(message.htmlBody || '');
   const rawMessageBytes = Buffer.from(message.rawMessageBytes || Buffer.alloc(0));
+  const storage = normalizeInboundStorage(message.storage, rawMessageBytes);
   const flags = normalizeImportedStringList(message.flags);
   const keywords = normalizeImportedStringList(message.keywords);
   const read = message.read === undefined
@@ -1340,8 +1408,10 @@ export function createImportedInboundMessage(mailbox, message = {}) {
         mailbox_id, user_id, domain_id, folder, sender, recipients_json, subject, message_id,
         raw_message, raw_message_bytes, text_body, html_body, preview, read_state,
         flags_json, keywords_json, import_source, import_source_key,
+        storage_backend, storage_key, storage_relpath, storage_sha256, storage_size,
+        storage_mtime_ms, indexed_at,
         pop3_size, received_at, created_at, updated_at
-      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     `)
     .run(
       mailbox.id,
@@ -1361,6 +1431,13 @@ export function createImportedInboundMessage(mailbox, message = {}) {
       JSON.stringify(keywords),
       importSource,
       sourceKey,
+      storage.backend,
+      storage.key,
+      storage.relpath,
+      storage.sha256,
+      storage.size,
+      storage.mtimeMs,
+      storage.indexedAt,
       canonicalPop3MessageSize(rawMessageBytes),
       receivedAt,
       insertedAt,
@@ -1374,6 +1451,360 @@ export function createImportedInboundMessage(mailbox, message = {}) {
   return { created: false, message: { id: Number(concurrent.id) } };
 }
 
+export function createImportedInboundMessageWithWebhook(mailbox, message = {}) {
+  return withInboundWebhookTransaction(() => {
+    const result = createImportedInboundMessage(mailbox, message);
+    if (!result.created) return result;
+    const inboundMessage = getInboundMessage(mailbox.userId, result.message.id);
+    if (!inboundMessage) throw new Error('收信邮件索引写入失败。');
+    enqueueInboundWebhookDeliveries(inboundMessage, {
+      database: requireDb(),
+      manageTransactions: false
+    });
+    return result;
+  });
+}
+
+function withInboundWebhookTransaction(operation) {
+  const database = requireDb();
+  database.exec('BEGIN IMMEDIATE');
+  try {
+    const result = operation();
+    database.exec('COMMIT');
+    return result;
+  } catch (error) {
+    try {
+      database.exec('ROLLBACK');
+    } catch {
+      // ignore rollback errors when no transaction is open
+    }
+    throw error;
+  }
+}
+
+export function listInboundMaildirMigrationCandidates(afterId = 0, limit = 250, { mailboxId = 0 } = {}) {
+  const cleanAfterId = Math.max(0, Number(afterId) || 0);
+  const cleanLimit = Math.min(1000, Math.max(1, Number(limit) || 250));
+  const cleanMailboxId = Math.max(0, Number(mailboxId) || 0);
+  return requireDb()
+    .prepare(`
+      SELECT
+        msg.id,
+        msg.mailbox_id,
+        msg.folder,
+        msg.raw_message,
+        msg.raw_message_bytes,
+        msg.flags_json,
+        msg.keywords_json,
+        msg.read_state,
+        msg.received_at,
+        msg.storage_backend,
+        msg.storage_key,
+        msg.storage_relpath,
+        m.address AS mailbox_address
+      FROM inbound_messages msg
+      JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
+      WHERE msg.id > ?
+        AND (? = 0 OR msg.mailbox_id = ?)
+        AND msg.deleted_at IS NULL
+        AND m.deleted_at IS NULL
+        AND (msg.storage_backend != 'maildir' OR msg.storage_key = '')
+      ORDER BY msg.id ASC
+      LIMIT ?
+    `)
+    .all(cleanAfterId, cleanMailboxId, cleanMailboxId, cleanLimit)
+    .map((row) => ({
+      id: Number(row.id),
+      mailboxId: Number(row.mailbox_id),
+      mailboxAddress: row.mailbox_address,
+      folder: row.folder || 'INBOX',
+      rawMessageBytes: row.raw_message_bytes
+        ? (Buffer.isBuffer(row.raw_message_bytes) ? row.raw_message_bytes : Buffer.from(row.raw_message_bytes))
+        : Buffer.from(row.raw_message || '', 'utf8'),
+      flags: normalizeImportedStringList(safeJson(row.flags_json, [])),
+      keywords: normalizeImportedStringList(safeJson(row.keywords_json, [])),
+      read: row.read_state === 'true',
+      receivedAt: row.received_at,
+      storageBackend: row.storage_backend || 'sqlite',
+      storageKey: row.storage_key || '',
+      storageRelpath: row.storage_relpath || ''
+    }));
+}
+
+export function recordInboundMessageMaildirStorage(id, storage = {}) {
+  const normalized = normalizeInboundStorage({ ...storage, backend: 'maildir' });
+  const result = requireDb()
+    .prepare(`
+      UPDATE inbound_messages
+      SET storage_backend = 'maildir', storage_key = ?, storage_relpath = ?,
+          storage_sha256 = ?, storage_size = ?, storage_mtime_ms = ?, indexed_at = ?,
+          updated_at = ?
+      WHERE id = ? AND deleted_at IS NULL
+    `)
+    .run(
+      normalized.key,
+      normalized.relpath,
+      normalized.sha256,
+      normalized.size,
+      normalized.mtimeMs,
+      normalized.indexedAt,
+      now(),
+      Number(id)
+    );
+  return result.changes > 0;
+}
+
+export function stageInboundMessageMaildirStorageBatch(records = []) {
+  const normalizedRecords = records.map((record) => ({
+    id: Number(record?.id),
+    storage: normalizeInboundStorage({ ...record?.storage, backend: 'maildir' })
+  }));
+  if (!normalizedRecords.length) return 0;
+  if (normalizedRecords.some((record) => !Number.isSafeInteger(record.id) || record.id <= 0)) {
+    throw new Error('Maildir 迁移邮件 ID 不正确。');
+  }
+
+  const database = requireDb();
+  const stage = database.prepare(`
+    INSERT INTO inbound_maildir_migration_staging (
+      message_id, storage_key, storage_relpath, storage_sha256,
+      storage_size, storage_mtime_ms, indexed_at
+    ) VALUES (?, ?, ?, ?, ?, ?, ?)
+    ON CONFLICT(message_id) DO UPDATE SET
+      storage_key = excluded.storage_key,
+      storage_relpath = excluded.storage_relpath,
+      storage_sha256 = excluded.storage_sha256,
+      storage_size = excluded.storage_size,
+      storage_mtime_ms = excluded.storage_mtime_ms,
+      indexed_at = excluded.indexed_at
+  `);
+  database.exec('BEGIN IMMEDIATE');
+  try {
+    for (const record of normalizedRecords) {
+      const storage = record.storage;
+      stage.run(
+        record.id,
+        storage.key,
+        storage.relpath,
+        storage.sha256,
+        storage.size,
+        storage.mtimeMs,
+        storage.indexedAt
+      );
+    }
+    database.exec('COMMIT');
+    return normalizedRecords.length;
+  } catch (error) {
+    try {
+      database.exec('ROLLBACK');
+    } catch {
+      // ignore rollback errors when no transaction is open
+    }
+    throw error;
+  }
+}
+
+export function listInboundMaildirMigrationStaging(afterMessageId = 0, limit = 250) {
+  const cleanAfterId = Math.max(0, Number(afterMessageId) || 0);
+  const cleanLimit = Math.min(1000, Math.max(1, Number(limit) || 250));
+  return requireDb().prepare(`
+    SELECT s.message_id, s.storage_key, s.storage_relpath, m.address AS mailbox_address
+    FROM inbound_maildir_migration_staging s
+    JOIN inbound_messages msg ON msg.id = s.message_id
+    JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
+    WHERE s.message_id > ?
+    ORDER BY s.message_id ASC
+    LIMIT ?
+  `).all(cleanAfterId, cleanLimit).map((row) => ({
+    messageId: Number(row.message_id),
+    storageKey: row.storage_key,
+    storageRelpath: row.storage_relpath,
+    mailboxAddress: row.mailbox_address
+  }));
+}
+
+export function clearInboundMaildirMigrationStaging() {
+  return Number(requireDb().prepare('DELETE FROM inbound_maildir_migration_staging').run().changes || 0);
+}
+
+export function commitInboundMaildirMigrationStaging(expectedCount = null) {
+  const database = requireDb();
+  let expected = null;
+  if (expectedCount !== null) {
+    expected = Number(expectedCount);
+    if (!Number.isSafeInteger(expected) || expected < 0) {
+      throw new Error('Maildir 迁移预期数量不正确。');
+    }
+  }
+  const update = database.prepare(`
+    UPDATE inbound_messages
+    SET storage_backend = 'maildir', storage_key = ?, storage_relpath = ?,
+        storage_sha256 = ?, storage_size = ?, storage_mtime_ms = ?, indexed_at = ?,
+        updated_at = ?
+    WHERE id = ? AND deleted_at IS NULL
+      AND (storage_backend != 'maildir' OR storage_key = '')
+  `);
+  const updatedAt = now();
+  database.exec('BEGIN IMMEDIATE');
+  try {
+    const total = Number(database.prepare(`
+      SELECT COUNT(*) AS total FROM inbound_maildir_migration_staging
+    `).get()?.total || 0);
+    if (expected !== null && total !== expected) {
+      throw new Error(`Maildir 迁移暂存数量不一致:预期 ${expected},实际 ${total}。`);
+    }
+    let changed = 0;
+    for (const row of database.prepare(`
+      SELECT * FROM inbound_maildir_migration_staging ORDER BY message_id ASC
+    `).iterate()) {
+      const result = update.run(
+        row.storage_key,
+        row.storage_relpath,
+        row.storage_sha256,
+        row.storage_size,
+        row.storage_mtime_ms,
+        row.indexed_at,
+        updatedAt,
+        row.message_id
+      );
+      if (result.changes !== 1) {
+        throw new Error(`邮件 ${row.message_id} 的 Maildir 切换状态已发生变化。`);
+      }
+      changed += Number(result.changes);
+    }
+    if (changed !== total) throw new Error('Maildir 批量切换未完整提交。');
+    database.prepare('DELETE FROM inbound_maildir_migration_staging').run();
+    database.exec('COMMIT');
+    return changed;
+  } catch (error) {
+    try {
+      database.exec('ROLLBACK');
+    } catch {
+      // ignore rollback errors when no transaction is open
+    }
+    throw error;
+  }
+}
+
+export function listInboundMailboxesForStorage() {
+  return requireDb()
+    .prepare(`
+      SELECT m.*, d.domain, 0 AS message_count, 0 AS unread_count, NULL AS last_message_at
+      FROM inbound_mailboxes m
+      JOIN domains d ON d.id = m.domain_id
+      JOIN users u ON u.id = m.user_id
+      WHERE m.deleted_at IS NULL AND m.status = 'active' AND u.status = 'active'
+        AND (m.expires_at IS NULL OR m.expires_at = '' OR m.expires_at > ?)
+      ORDER BY m.id ASC
+    `)
+    .all(now())
+    .map((row) => publicInboundMailbox(row));
+}
+
+export function listInboundMaildirIndex(mailboxId) {
+  return requireDb()
+    .prepare(`
+      SELECT id, storage_key, storage_relpath, storage_size, storage_mtime_ms,
+             folder, flags_json, keywords_json, read_state, deleted_at
+      FROM inbound_messages
+      WHERE mailbox_id = ? AND storage_backend = 'maildir' AND storage_key != ''
+      ORDER BY id ASC
+    `)
+    .all(Number(mailboxId))
+    .map((row) => ({
+      id: Number(row.id),
+      storageKey: row.storage_key,
+      storageRelpath: row.storage_relpath || '',
+      storageSize: Number(row.storage_size || 0),
+      storageMtimeMs: Number(row.storage_mtime_ms || 0),
+      folder: row.folder || 'INBOX',
+      flags: normalizeImportedStringList(safeJson(row.flags_json, [])),
+      keywords: normalizeImportedStringList(safeJson(row.keywords_json, [])),
+      read: row.read_state === 'true',
+      deleted: Boolean(row.deleted_at)
+    }));
+}
+
+export function syncInboundMessageMaildirMetadata(mailbox, storage = {}, message = {}) {
+  if (!mailbox?.id || !mailbox?.userId) throw new Error('收信邮箱不存在。');
+  const normalized = normalizeInboundStorage({ ...storage, backend: 'maildir' });
+  const folder = normalizeInboundFolder(message.folder) || 'INBOX';
+  const flags = normalizeImportedStringList(message.flags);
+  const keywords = normalizeImportedStringList(message.keywords);
+  const read = message.read === undefined
+    ? flags.some((flag) => flag.toLowerCase() === '\\seen')
+    : Boolean(message.read);
+  if (!isStandardInboundFolder(folder)) createInboundFolder(mailbox, folder);
+  const result = requireDb()
+    .prepare(`
+      UPDATE inbound_messages
+      SET folder = ?, read_state = ?, flags_json = ?, keywords_json = ?,
+          storage_relpath = ?,
+          storage_sha256 = CASE WHEN ? != '' THEN ? ELSE storage_sha256 END,
+          storage_size = ?, storage_mtime_ms = ?,
+          indexed_at = ?, deleted_at = NULL, updated_at = ?
+      WHERE mailbox_id = ? AND storage_backend = 'maildir' AND storage_key = ?
+    `)
+    .run(
+      folder,
+      boolString(read),
+      JSON.stringify(flags),
+      JSON.stringify(keywords),
+      normalized.relpath,
+      normalized.sha256,
+      normalized.sha256,
+      normalized.size,
+      normalized.mtimeMs,
+      normalized.indexedAt,
+      now(),
+      Number(mailbox.id),
+      normalized.key
+    );
+  return result.changes > 0;
+}
+
+export function markMissingInboundMaildirMessages(mailboxId, presentStorageKeys) {
+  const present = presentStorageKeys instanceof Set
+    ? presentStorageKeys
+    : new Set(Array.isArray(presentStorageKeys) ? presentStorageKeys : []);
+  const missing = listInboundMaildirIndex(mailboxId)
+    .filter((message) => !message.deleted && !present.has(message.storageKey))
+    .map((message) => message.id);
+  if (!missing.length) return 0;
+  const placeholders = missing.map(() => '?').join(', ');
+  const updatedAt = now();
+  return Number(requireDb()
+    .prepare(`
+      UPDATE inbound_messages
+      SET deleted_at = ?, indexed_at = ?, updated_at = ?
+      WHERE mailbox_id = ? AND storage_backend = 'maildir' AND id IN (${placeholders})
+    `)
+    .run(updatedAt, updatedAt, updatedAt, Number(mailboxId), ...missing).changes || 0);
+}
+
+export function getInboundMessageMaildirStorage(userId, id) {
+  const row = requireDb()
+    .prepare(`
+      SELECT msg.id, msg.storage_backend, msg.storage_key, msg.storage_relpath,
+             msg.flags_json, msg.read_state, m.address AS mailbox_address
+      FROM inbound_messages msg
+      JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
+      WHERE msg.id = ? AND msg.user_id = ? AND msg.deleted_at IS NULL
+      LIMIT 1
+    `)
+    .get(Number(id), Number(userId));
+  if (!row) return null;
+  return {
+    id: Number(row.id),
+    backend: row.storage_backend || 'sqlite',
+    key: row.storage_key || '',
+    relpath: row.storage_relpath || '',
+    mailboxAddress: row.mailbox_address,
+    flags: normalizeImportedStringList(safeJson(row.flags_json, [])),
+    read: row.read_state === 'true'
+  };
+}
+
 export function isDataMigrationComplete(name) {
   return Boolean(requireDb()
     .prepare('SELECT 1 FROM data_migrations WHERE name = ?')
@@ -1593,6 +2024,56 @@ export function createInboundFolder(mailbox, folder) {
   return { name, standard: false };
 }
 
+export function syncInboundMaildirFolders(mailbox, folders = []) {
+  if (!mailbox?.id || !mailbox?.userId) throw new Error('收信邮箱不存在。');
+  const names = [...new Set(folders
+    .map((folder) => normalizeInboundFolder(folder))
+    .filter((folder) => folder && !isStandardInboundFolder(folder)))];
+  const present = new Set(names);
+  const database = requireDb();
+  const existing = database.prepare(`
+    SELECT id, name, deleted_at
+    FROM inbound_folders
+    WHERE mailbox_id = ? AND user_id = ?
+  `).all(Number(mailbox.id), mailbox.userId);
+  const timestamp = now();
+  const upsert = database.prepare(`
+    INSERT INTO inbound_folders (mailbox_id, user_id, name, subscribed, created_at, updated_at, deleted_at)
+    VALUES (?, ?, ?, 'true', ?, ?, NULL)
+    ON CONFLICT(mailbox_id, name) DO UPDATE SET
+      subscribed = 'true', updated_at = excluded.updated_at, deleted_at = NULL
+  `);
+  const remove = database.prepare(`
+    UPDATE inbound_folders
+    SET deleted_at = ?, updated_at = ?
+    WHERE id = ? AND deleted_at IS NULL
+  `);
+  database.exec('BEGIN IMMEDIATE');
+  try {
+    let createdOrRestored = 0;
+    let deleted = 0;
+    const existingByName = new Map(existing.map((row) => [row.name, row]));
+    for (const name of names) {
+      const row = existingByName.get(name);
+      upsert.run(Number(mailbox.id), mailbox.userId, name, timestamp, timestamp);
+      if (!row || row.deleted_at) createdOrRestored += 1;
+    }
+    for (const row of existing) {
+      if (present.has(row.name) || row.deleted_at) continue;
+      deleted += Number(remove.run(timestamp, timestamp, row.id).changes || 0);
+    }
+    database.exec('COMMIT');
+    return { createdOrRestored, deleted };
+  } catch (error) {
+    try {
+      database.exec('ROLLBACK');
+    } catch {
+      // ignore rollback errors when no transaction is open
+    }
+    throw error;
+  }
+}
+
 export function inboundFolderExists(mailbox, folder) {
   if (!mailbox?.id || !mailbox?.userId) return false;
   const name = normalizeInboundFolder(folder);
@@ -4636,7 +5117,7 @@ function normalizeEmail(value) {
 
 function normalizeInboundAddress(value) {
   const email = normalizeEmail(value);
-  if (!email) return '';
+  if (!email || /[\/\\\u0000]/.test(email)) return '';
   const [localPart, domain] = email.split('@');
   if (!localPart || !domain) return '';
   return `${localPart}@${domain}`;
@@ -4678,6 +5159,44 @@ function normalizeImportedStringList(values) {
   return [...new Set(list.map((value) => String(value || '').trim()).filter(Boolean))];
 }
 
+function normalizeInboundStorage(storage, rawMessageBytes = null) {
+  if (!storage || storage.backend !== 'maildir') {
+    return {
+      backend: 'sqlite',
+      key: '',
+      relpath: '',
+      sha256: '',
+      size: 0,
+      mtimeMs: 0,
+      indexedAt: null
+    };
+  }
+  const key = String(storage.key || '').trim().toLowerCase();
+  const relpath = String(storage.relpath || '').trim().replace(/\\/g, '/');
+  const suppliedSha256 = String(storage.sha256 || '').trim().toLowerCase();
+  const bytes = rawMessageBytes === null || rawMessageBytes === undefined
+    ? null
+    : Buffer.from(rawMessageBytes);
+  const sha256 = suppliedSha256 || (bytes ? crypto.createHash('sha256').update(bytes).digest('hex') : '');
+  const size = storage.size === undefined || storage.size === null
+    ? (bytes?.length || 0)
+    : Number(storage.size);
+  const mtimeMs = Number(storage.mtimeMs || 0);
+  if (!/^[a-z0-9][a-z0-9._-]{0,191}$/.test(key)) throw new Error('Maildir 存储标识不正确。');
+  if (
+    !relpath
+    || relpath.length > 1024
+    || relpath.startsWith('/')
+    || relpath.split('/').some((part) => !part || part === '.' || part === '..')
+    || /[\r\n\u0000]/.test(relpath)
+  ) throw new Error('Maildir 存储路径不正确。');
+  if (sha256 && !/^[a-f0-9]{64}$/.test(sha256)) throw new Error('Maildir 内容摘要不正确。');
+  if (!Number.isSafeInteger(size) || size < 0) throw new Error('Maildir 邮件大小不正确。');
+  if (!Number.isSafeInteger(mtimeMs) || mtimeMs < 0) throw new Error('Maildir 修改时间不正确。');
+  const indexedAt = storage.indexedAt ? normalizeImportedReceivedAt(storage.indexedAt) : now();
+  return { backend: 'maildir', key, relpath, sha256, size, mtimeMs, indexedAt };
+}
+
 function canonicalPop3MessageSize(value) {
   const bytes = Buffer.isBuffer(value)
     ? value

+ 199 - 0
src/dovecot-auth-server.js

@@ -0,0 +1,199 @@
+import crypto from 'node:crypto';
+import { readFileSync } from 'node:fs';
+import http from 'node:http';
+import { isIP } from 'node:net';
+
+import { authenticateWithRateLimit, authenticationRateLimiter } from './auth-rate-limit.js';
+import { verifyInboundMailboxCredential } from './db.js';
+
+const authPath = '/internal/dovecot/auth';
+const defaultBodyLimit = 8 * 1024;
+const defaultRequestTimeoutMs = 5_000;
+
+export function createDovecotAuthServer(options = {}) {
+  const sharedSecretDigest = digestSecret(readSharedSecret(options.secretFile));
+  const limiter = options.authRateLimiter || authenticationRateLimiter;
+  const verifyCredential = options.verifyCredential || verifyInboundMailboxCredential;
+  const logger = options.logger || console;
+  const requestTimeoutMs = positiveInteger(options.requestTimeoutMs, defaultRequestTimeoutMs);
+
+  const server = http.createServer((req, res) => {
+    void handleRequest(req, res, {
+      sharedSecretDigest,
+      limiter,
+      verifyCredential,
+      logger,
+      bodyLimit: defaultBodyLimit
+    });
+  });
+  server.requestTimeout = requestTimeoutMs;
+  server.headersTimeout = requestTimeoutMs;
+  server.keepAliveTimeout = 1_000;
+  return server;
+}
+
+export function startDovecotAuthServer(options = {}) {
+  const server = createDovecotAuthServer(options);
+  const host = String(options.host || '0.0.0.0');
+  const port = Number(options.port ?? 3001);
+  server.listen(port, host, () => {
+    options.onListening?.(server);
+  });
+  return server;
+}
+
+async function handleRequest(req, res, context) {
+  setPrivateHeaders(res);
+  const pathname = requestPathname(req);
+  if (pathname !== authPath) return sendJson(res, 404, { error: 'Not found.' });
+  if (req.method !== 'POST') {
+    res.setHeader('Allow', 'POST');
+    return sendJson(res, 405, { error: 'Method not allowed.' });
+  }
+  if (!validBearerSecret(req.headers.authorization, context.sharedSecretDigest)) {
+    return sendJson(res, 401, { error: 'Unauthorized.' });
+  }
+  if (requestContentType(req) !== 'application/json') {
+    return sendJson(res, 415, { error: 'Unsupported media type.' });
+  }
+
+  const contentLength = parseContentLength(req.headers['content-length']);
+  if (contentLength === null) return sendJson(res, 400, { error: 'Invalid request.' });
+  if (contentLength > context.bodyLimit) return sendTooLarge(req, res);
+
+  let body;
+  try {
+    body = await readJson(req, context.bodyLimit);
+  } catch (error) {
+    if (error instanceof RequestTooLargeError) return sendTooLarge(req, res);
+    return sendJson(res, 400, { error: 'Invalid request.' });
+  }
+
+  const request = normalizeAuthRequest(body);
+  if (!request) return sendJson(res, 400, { error: 'Invalid request.' });
+
+  try {
+    const authenticated = authenticateWithRateLimit({
+      limiter: context.limiter,
+      ip: request.remoteIp,
+      account: request.username,
+      authenticate: () => context.verifyCredential(request.username, request.password)
+    });
+    if (!authenticated) return sendJson(res, 200, { authenticated: false });
+    const user = canonicalMailboxAddress(authenticated);
+    if (!user) throw new Error('Credential verifier returned an invalid mailbox');
+    return sendJson(res, 200, { authenticated: true, user });
+  } catch {
+    context.logger.error?.('Dovecot authentication bridge request failed.');
+    return sendJson(res, 503, { error: 'Service unavailable.' });
+  }
+}
+
+function readSharedSecret(filePath) {
+  if (!filePath || typeof filePath !== 'string') {
+    throw new Error('Dovecot authentication secret file is required');
+  }
+  const secret = readFileSync(filePath, 'utf8').trim();
+  const bytes = Buffer.byteLength(secret, 'utf8');
+  if (bytes < 32 || bytes > 512 || /\s/.test(secret)) {
+    throw new Error('Dovecot authentication secret must be a 32-512 byte token');
+  }
+  return secret;
+}
+
+function validBearerSecret(header, expectedDigest) {
+  const match = String(header || '').match(/^Bearer\s+([^\s]+)$/i);
+  const actualDigest = digestSecret(match?.[1] || '');
+  return Boolean(match) && crypto.timingSafeEqual(actualDigest, expectedDigest);
+}
+
+function digestSecret(value) {
+  return crypto.createHash('sha256').update(value).digest();
+}
+
+function requestPathname(req) {
+  try {
+    return new URL(req.url || '/', 'http://mailhub.internal').pathname;
+  } catch {
+    return '';
+  }
+}
+
+function requestContentType(req) {
+  return String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase();
+}
+
+function parseContentLength(value) {
+  if (value === undefined) return 0;
+  const raw = String(value);
+  if (!/^\d+$/.test(raw)) return null;
+  const parsed = Number(raw);
+  return Number.isSafeInteger(parsed) ? parsed : null;
+}
+
+async function readJson(req, limit) {
+  const chunks = [];
+  let bytes = 0;
+  for await (const chunk of req) {
+    bytes += chunk.length;
+    if (bytes > limit) throw new RequestTooLargeError();
+    chunks.push(chunk);
+  }
+  if (!chunks.length) throw new Error('Request body is required');
+  const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
+  if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Object body is required');
+  return body;
+}
+
+function normalizeAuthRequest(body) {
+  if (typeof body.username !== 'string' || typeof body.password !== 'string') return null;
+  if (typeof body.remoteIp !== 'string') return null;
+  const username = body.username.trim();
+  const remoteIp = body.remoteIp.trim();
+  const service = String(body.service || 'imap').trim().toLowerCase();
+  if (!username || Buffer.byteLength(username, 'utf8') > 320 || /[\r\n\u0000]/.test(username)) return null;
+  if (Buffer.byteLength(body.password, 'utf8') > defaultBodyLimit) return null;
+  if (!isIP(remoteIp) || !['imap', 'pop3'].includes(service)) return null;
+  return { username, password: body.password, remoteIp };
+}
+
+function canonicalMailboxAddress(authenticated) {
+  const rawAddress = String(authenticated?.mailbox?.address || '');
+  const address = rawAddress.toLowerCase();
+  if (
+    rawAddress !== rawAddress.trim()
+    || Buffer.byteLength(address, 'utf8') > 320
+    || /[\/\\\u0000\s]/.test(address)
+    || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)
+  ) return '';
+  return address;
+}
+
+function setPrivateHeaders(res) {
+  res.setHeader('Cache-Control', 'no-store');
+  res.setHeader('Pragma', 'no-cache');
+  res.setHeader('X-Content-Type-Options', 'nosniff');
+}
+
+function sendJson(res, status, payload, headers = {}) {
+  if (res.writableEnded) return;
+  const body = JSON.stringify(payload);
+  res.writeHead(status, {
+    'Content-Type': 'application/json; charset=utf-8',
+    'Content-Length': String(Buffer.byteLength(body)),
+    ...headers
+  });
+  res.end(body);
+}
+
+function sendTooLarge(req, res) {
+  req.resume();
+  return sendJson(res, 413, { error: 'Request too large.' }, { Connection: 'close' });
+}
+
+function positiveInteger(value, fallback) {
+  const parsed = Number(value);
+  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
+}
+
+class RequestTooLargeError extends Error {}

+ 34 - 7
src/mail-access.js

@@ -330,9 +330,15 @@ class ImapSession {
     if (!this.selected) return this.write(`${tag} NO Select a mailbox first`);
     const criteria = parseImapSearchCriteria(rest);
     if (criteria.error) return this.write(`${tag} BAD ${criteria.error}`);
+    const messageSets = criteria.messageSets.map((criterion) => new Set(
+      resolveMessageSet(criterion.value, this.messages, criterion.byUid).map((entry) => entry.message.id)
+    ));
     const values = this.messages
       .map((message, index) => ({ message, seq: index + 1 }))
-      .filter(({ message }) => matchesImapSearchCriteria(message, this.deletedUids, criteria.flags))
+      .filter(({ message }) => (
+        messageSets.every((set) => set.has(message.id))
+        && matchesImapSearchCriteria(message, this.deletedUids, criteria.flags)
+      ))
       .map(({ message, seq }) => byUid ? message.id : seq);
     this.write(`* SEARCH ${values.join(' ')}`.trimEnd());
     this.write(`${tag} OK SEARCH completed`);
@@ -737,11 +743,11 @@ function tokenizeImap(value) {
 function parseImapSearchCriteria(value) {
   let tokens = tokenizeImap(value);
   if (String(tokens[0] || '').toUpperCase() === 'CHARSET') {
-    if (!tokens[1]) return { error: 'SEARCH CHARSET expects a name', flags: [] };
-    if (tokens[1].toUpperCase() !== 'UTF-8') return { error: 'Unsupported SEARCH charset', flags: [] };
+    if (!tokens[1]) return { error: 'SEARCH CHARSET expects a name', flags: [], messageSets: [] };
+    if (tokens[1].toUpperCase() !== 'UTF-8') return { error: 'Unsupported SEARCH charset', flags: [], messageSets: [] };
     tokens = tokens.slice(2);
   }
-  if (!tokens.length) return { error: 'SEARCH expects criteria', flags: [] };
+  if (!tokens.length) return { error: 'SEARCH expects criteria', flags: [], messageSets: [] };
 
   const flagCriteria = {
     SEEN: ['\\Seen', true],
@@ -756,14 +762,35 @@ function parseImapSearchCriteria(value) {
     UNDRAFT: ['\\Draft', false]
   };
   const flags = [];
-  for (const token of tokens) {
+  const messageSets = [];
+  for (let index = 0; index < tokens.length; index += 1) {
+    const token = tokens[index];
     const criterion = token.toUpperCase();
     if (criterion === 'ALL') continue;
-    if (!flagCriteria[criterion]) return { error: `Unsupported SEARCH criterion: ${token}`, flags: [] };
+    if (criterion === 'UID') {
+      const set = tokens[index + 1];
+      if (!isImapMessageSet(set)) return { error: 'SEARCH UID expects a message set', flags: [], messageSets: [] };
+      messageSets.push({ value: set, byUid: true });
+      index += 1;
+      continue;
+    }
+    if (isImapMessageSet(token)) {
+      messageSets.push({ value: token, byUid: false });
+      continue;
+    }
+    if (!flagCriteria[criterion]) return { error: `Unsupported SEARCH criterion: ${token}`, flags: [], messageSets: [] };
     const [flag, present] = flagCriteria[criterion];
     flags.push({ flag, present });
   }
-  return { error: '', flags };
+  return { error: '', flags, messageSets };
+}
+
+function isImapMessageSet(value) {
+  const number = '[1-9]\\d*';
+  const item = `(?:${number}|\\*)(?::(?:${number}|\\*))?`;
+  const input = String(value || '');
+  if (!new RegExp(`^${item}(?:,${item})*$`).test(input)) return false;
+  return input.split(/[,:]/).every((part) => part === '*' || Number(part) <= 0xffffffff);
 }
 
 function matchesImapSearchCriteria(message, deletedUids, criteria) {

+ 540 - 0
src/maildir-store.js

@@ -0,0 +1,540 @@
+import crypto from 'node:crypto';
+import path from 'node:path';
+import {
+  mkdir,
+  link,
+  open,
+  readFile,
+  readdir,
+  rename,
+  stat,
+  unlink,
+  utimes
+} from 'node:fs/promises';
+
+import { decodeModifiedUtf7, encodeModifiedUtf7 } from './imap-utf7.js';
+
+const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Archive'];
+const storageKeyPattern = /^[a-z0-9][a-z0-9._-]{0,191}$/;
+const managedStorageKeyPattern = /^((?:mhdb|mhsmtp|mhappend|mhvesta)-[a-z0-9-]+)\./;
+const imapFlagByMaildirFlag = new Map([
+  ['D', '\\Draft'],
+  ['F', '\\Flagged'],
+  ['P', '\\Passed'],
+  ['R', '\\Answered'],
+  ['S', '\\Seen'],
+  ['T', '\\Deleted']
+]);
+const maildirFlagByImapFlag = new Map(
+  [...imapFlagByMaildirFlag].map(([maildirFlag, imapFlag]) => [imapFlag.toLowerCase(), maildirFlag])
+);
+
+export function maildirRootFromEnvironment(env = process.env) {
+  return path.resolve(env.MAILDIR_ROOT || path.join(env.DATA_DIR || path.join(process.cwd(), 'data'), 'maildir'));
+}
+
+export function maildirHomePath(root, address) {
+  const cleanRoot = path.resolve(String(root || ''));
+  const cleanAddress = normalizeStorageAddress(address);
+  const home = path.resolve(cleanRoot, cleanAddress);
+  if (path.dirname(home) !== cleanRoot) throw new Error('Maildir 邮箱路径不正确。');
+  return home;
+}
+
+export function maildirFolderPath(root, address, folder = 'INBOX') {
+  const mailRoot = path.join(maildirHomePath(root, address), 'mail');
+  const cleanFolder = normalizeFolder(folder);
+  if (cleanFolder === 'INBOX') return mailRoot;
+  const encoded = cleanFolder
+    .split('/')
+    // Maildir++ uses a literal dot as its hierarchy separator. Dovecot's
+    // official storage escape keeps literal dots and the escape character
+    // distinct from hierarchy separators.
+    .map((segment, index) => encodeMaildirFolderSegment(segment, index === 0))
+    .join('.');
+  return path.join(mailRoot, `.${encoded}`);
+}
+
+export async function ensureMaildirMailbox(root, address, folders = standardFolders, { durable = false } = {}) {
+  const uniqueFolders = new Set(['INBOX', ...folders.map(normalizeFolder)]);
+  const directories = [];
+  for (const folder of uniqueFolders) {
+    const folderPath = maildirFolderPath(root, address, folder);
+    for (const bucket of ['tmp', 'new', 'cur']) {
+      const directory = path.join(folderPath, bucket);
+      await mkdir(directory, { recursive: true, mode: 0o700 });
+      directories.push(directory);
+    }
+  }
+  if (durable) await flushMaildirFiles([], { root, directories });
+  return maildirHomePath(root, address);
+}
+
+export async function writeMaildirMessage({
+  root,
+  address,
+  rawMessageBytes,
+  folder = 'INBOX',
+  flags = [],
+  keywords = [],
+  read = undefined,
+  receivedAt = new Date(),
+  storageKey = '',
+  durable = true
+}) {
+  const bytes = Buffer.from(rawMessageBytes || Buffer.alloc(0));
+  const cleanFolder = normalizeFolder(folder);
+  const cleanStorageKey = normalizeStorageKey(storageKey || newMaildirStorageKey('smtp'));
+  const timestamp = normalizeDate(receivedAt);
+  const folderPath = maildirFolderPath(root, address, cleanFolder);
+  await ensureMaildirMailbox(root, address, [cleanFolder]);
+
+  const keywordFlags = await ensureDovecotKeywords(folderPath, keywords);
+  const maildirFlags = `${encodeMaildirFlags(flags, read)}${keywordFlags}`
+    .split('')
+    .sort()
+    .join('');
+  const baseName = createMaildirFilename(cleanStorageKey, bytes);
+  // Maildir flags and Dovecot keyword letters are valid only in cur/. Keep
+  // truly unflagged unread deliveries in new/ so Dovecot can announce them as new mail.
+  const destinationBucket = maildirFlags ? 'cur' : 'new';
+  const destinationName = maildirFlags ? `${baseName}:2,${maildirFlags}` : baseName;
+  const tmpPath = path.join(folderPath, 'tmp', `${baseName}.${crypto.randomBytes(6).toString('hex')}`);
+  const destinationPath = path.join(folderPath, destinationBucket, destinationName);
+  const handle = await open(tmpPath, 'wx', 0o600);
+  try {
+    await handle.writeFile(bytes);
+    if (durable) await handle.sync();
+  } finally {
+    await handle.close();
+  }
+  await utimes(tmpPath, timestamp, timestamp);
+  try {
+    await link(tmpPath, destinationPath);
+  } catch (error) {
+    await unlink(tmpPath).catch(() => null);
+    if (error?.code === 'EEXIST') throw new Error(`Maildir 存储标识冲突:${cleanStorageKey}`);
+    throw error;
+  }
+  await unlink(tmpPath);
+  if (durable) await syncDirectoryChain(path.dirname(destinationPath), path.resolve(root));
+  return storageMetadata({
+    root,
+    address,
+    filePath: destinationPath,
+    storageKey: cleanStorageKey,
+    bytes,
+    timestamp
+  });
+}
+
+export async function scanMaildirMailbox({ root, address }) {
+  const folderDirectories = await listMaildirFolderDirectories(root, address);
+  if (!folderDirectories.length) return [];
+
+  const messages = [];
+  for (const folder of folderDirectories) {
+    const keywordMap = await readDovecotKeywords(folder.path);
+    for (const bucket of ['new', 'cur']) {
+      let entries = [];
+      try {
+        entries = await readdir(path.join(folder.path, bucket), { withFileTypes: true });
+      } catch (error) {
+        if (error?.code === 'ENOENT') continue;
+        throw error;
+      }
+      const files = entries.filter((entry) => entry.isFile() && !entry.name.startsWith('.'));
+      const scanned = await mapWithConcurrency(files, 32, async (entry) => {
+        const filePath = path.join(folder.path, bucket, entry.name);
+        const fileStat = await stat(filePath);
+        const rawFlags = parseRawMaildirFlags(entry.name);
+        const { flags, keywords } = decodeMaildirFlags(rawFlags, keywordMap);
+        const baseName = entry.name.replace(/:2,[^/]*$/, '');
+        return {
+          storageKey: storageKeyFromFilename(baseName),
+          filePath,
+          relpath: relativeMaildirPath(root, address, filePath),
+          folder: folder.folder,
+          flags,
+          keywords,
+          read: flags.some((flag) => flag.toLowerCase() === '\\seen'),
+          size: Number(fileStat.size),
+          mtimeMs: Math.max(0, Math.trunc(fileStat.mtimeMs)),
+          receivedAt: fileStat.mtime.toISOString(),
+          baseName
+        };
+      });
+      messages.push(...scanned);
+    }
+  }
+  return messages.sort((left, right) => left.relpath.localeCompare(right.relpath));
+}
+
+export async function listMaildirFolders({ root, address }) {
+  return (await listMaildirFolderDirectories(root, address)).map((folder) => folder.folder);
+}
+
+export async function readMaildirMessage(entry) {
+  const bytes = await readFile(entry.filePath);
+  return {
+    bytes,
+    sha256: crypto.createHash('sha256').update(bytes).digest('hex')
+  };
+}
+
+export async function flushMaildirFiles(filePaths, { root = '', directories = [] } = {}) {
+  const cleanRoot = root ? path.resolve(String(root)) : '';
+  const files = [...new Set((filePaths || [])
+    .map((filePath) => String(filePath || '').trim())
+    .filter(Boolean)
+    .map((filePath) => path.resolve(filePath)))];
+  if (cleanRoot && files.some((filePath) => !isPathInside(cleanRoot, filePath))) {
+    throw new Error('Maildir 刷盘路径不正确。');
+  }
+  await mapWithConcurrency(files, 16, async (filePath) => {
+    const handle = await open(filePath, 'r');
+    try {
+      await handle.sync();
+    } finally {
+      await handle.close();
+    }
+  });
+  const directorySet = new Set([
+    ...files.map((filePath) => path.dirname(filePath)),
+    ...(directories || []).map((directory) => path.resolve(String(directory || '')))
+  ]);
+  for (const directory of [...directorySet]) {
+    if (cleanRoot && !isPathInside(cleanRoot, directory, { allowRoot: true })) {
+      throw new Error('Maildir 刷盘目录不正确。');
+    }
+    if (cleanRoot) addDirectoryChain(directorySet, directory, cleanRoot);
+  }
+  const orderedDirectories = [...directorySet].sort((left, right) => right.length - left.length);
+  for (const directory of orderedDirectories) await syncDirectory(directory);
+}
+
+export async function setMaildirMessageSeen({ root, address, storageKey, relpath = '', seen = true }) {
+  const cleanStorageKey = normalizeStorageKey(storageKey);
+  const entries = await scanMaildirMailbox({ root, address });
+  const entry = entries.find((candidate) => (
+    candidate.storageKey === cleanStorageKey
+    && (!relpath || candidate.relpath === relpath)
+  )) || entries.find((candidate) => candidate.storageKey === cleanStorageKey);
+  if (!entry) return null;
+  const rawFlags = new Set(parseRawMaildirFlags(path.basename(entry.filePath)).split(''));
+  if (seen) rawFlags.add('S');
+  else rawFlags.delete('S');
+  const flags = [...rawFlags].sort().join('');
+  const baseName = path.basename(entry.filePath).replace(/:2,[^/]*$/, '');
+  const folderPath = maildirFolderPath(root, address, entry.folder);
+  const nextPath = path.join(folderPath, 'cur', `${baseName}:2,${flags}`);
+  if (nextPath !== entry.filePath) {
+    const sourceDirectory = path.dirname(entry.filePath);
+    await mkdir(path.dirname(nextPath), { recursive: true, mode: 0o700 });
+    await rename(entry.filePath, nextPath);
+    await syncDirectoryChain(path.dirname(nextPath), path.resolve(root));
+    if (sourceDirectory !== path.dirname(nextPath)) {
+      await syncDirectoryChain(sourceDirectory, path.resolve(root));
+    }
+  }
+  const fileStat = await stat(nextPath);
+  const bytes = await readFile(nextPath);
+  return storageMetadata({
+    root,
+    address,
+    filePath: nextPath,
+    storageKey: cleanStorageKey,
+    bytes,
+    timestamp: fileStat.mtime
+  });
+}
+
+export function newMaildirStorageKey(origin = 'smtp') {
+  const cleanOrigin = String(origin || 'smtp').toLowerCase().replace(/[^a-z0-9]/g, '') || 'smtp';
+  return `mh${cleanOrigin}-${crypto.randomBytes(16).toString('hex')}`;
+}
+
+export function sqliteMaildirStorageKey(messageId) {
+  const id = Number(messageId);
+  if (!Number.isSafeInteger(id) || id <= 0) throw new Error('邮件 ID 不正确。');
+  return `mhdb-${id}`;
+}
+
+function normalizeStorageAddress(value) {
+  const address = String(value || '').trim().toLowerCase();
+  if (
+    address.length > 320
+    || !/^[^\s@/\\]+@[^\s@/\\]+\.[^\s@/\\]+$/.test(address)
+    || /[\r\n\u0000]/.test(address)
+  ) throw new Error('Maildir 邮箱地址不正确。');
+  return address;
+}
+
+function normalizeStorageKey(value) {
+  const key = String(value || '').trim().toLowerCase();
+  if (!storageKeyPattern.test(key)) throw new Error('Maildir 存储标识不正确。');
+  return key;
+}
+
+function normalizeFolder(value) {
+  const raw = String(value || 'INBOX').trim().replace(/\\/g, '/');
+  if (!raw || /[\r\n\u0000]/.test(raw)) throw new Error('Maildir 文件夹名称不正确。');
+  if (raw.toUpperCase() === 'INBOX') return 'INBOX';
+  const standard = standardFolders.find((folder) => folder.toLowerCase() === raw.toLowerCase());
+  if (standard) return standard;
+  const parts = raw.split('/').map((part) => part.trim()).filter(Boolean);
+  if (!parts.length || parts.some((part) => part === '.' || part === '..')) {
+    throw new Error('Maildir 文件夹名称不正确。');
+  }
+  return parts.join('/');
+}
+
+function decodeMaildirFolderName(value) {
+  return String(value || '')
+    .split('.')
+    .map((segment) => decodeModifiedUtf7(decodeMaildirStorageEscapes(segment)))
+    .join('/');
+}
+
+function encodeMaildirFolderSegment(value, firstPart) {
+  const raw = encodeModifiedUtf7(value);
+  let encoded = '';
+  for (let index = 0; index < raw.length; index += 1) {
+    const character = raw[index];
+    if (
+      character === '.'
+      || character === '^'
+      || character === '/'
+      || (firstPart && index === 0 && character === '~')
+    ) {
+      encoded += `^${character.charCodeAt(0).toString(16).padStart(2, '0')}`;
+    } else {
+      encoded += character;
+    }
+  }
+  return encoded;
+}
+
+function decodeMaildirStorageEscapes(value) {
+  return String(value || '').replace(/\^([0-9a-fA-F]{2})/g, (_match, hex) => (
+    String.fromCharCode(Number.parseInt(hex, 16))
+  ));
+}
+
+async function listMaildirFolderDirectories(root, address) {
+  const mailRoot = path.join(maildirHomePath(root, address), 'mail');
+  let rootEntries = [];
+  try {
+    rootEntries = await readdir(mailRoot, { withFileTypes: true });
+  } catch (error) {
+    if (error?.code === 'ENOENT') return [];
+    throw error;
+  }
+  const folders = [];
+  if (hasMaildirBuckets(rootEntries)) folders.push({ folder: 'INBOX', path: mailRoot });
+  for (const entry of rootEntries) {
+    if (!entry.isDirectory() || !entry.name.startsWith('.') || entry.name.length < 2) continue;
+    const folderPath = path.join(mailRoot, entry.name);
+    let folderEntries;
+    try {
+      folderEntries = await readdir(folderPath, { withFileTypes: true });
+    } catch (error) {
+      if (error?.code === 'ENOENT') continue;
+      throw error;
+    }
+    if (!hasMaildirBuckets(folderEntries)) continue;
+    folders.push({
+      folder: decodeMaildirFolderName(entry.name.slice(1)),
+      path: folderPath
+    });
+  }
+  return folders;
+}
+
+function hasMaildirBuckets(entries) {
+  const directories = new Set(entries
+    .filter((entry) => entry.isDirectory())
+    .map((entry) => entry.name));
+  return ['cur', 'new', 'tmp'].every((bucket) => directories.has(bucket));
+}
+
+function encodeMaildirFlags(flags, read) {
+  const output = new Set();
+  for (const flag of Array.isArray(flags) ? flags : [flags]) {
+    const maildirFlag = maildirFlagByImapFlag.get(String(flag || '').trim().toLowerCase());
+    if (maildirFlag) output.add(maildirFlag);
+  }
+  if (read === true) output.add('S');
+  if (read === false) output.delete('S');
+  return [...output].sort().join('');
+}
+
+function parseRawMaildirFlags(fileName) {
+  return String(fileName || '').match(/:2,([A-Za-z]*)/)?.[1] || '';
+}
+
+function decodeMaildirFlags(rawFlags, keywordMap) {
+  const flags = [];
+  const keywords = [];
+  for (const flag of String(rawFlags || '')) {
+    // Maildir system flags are uppercase. Dovecot reserves lowercase a-z for
+    // keyword slots, so normalizing case would turn keyword "d" into \Draft.
+    const imapFlag = imapFlagByMaildirFlag.get(flag);
+    if (imapFlag) flags.push(imapFlag);
+    if (/[a-z]/.test(flag) && keywordMap.has(flag.charCodeAt(0) - 97)) {
+      keywords.push(keywordMap.get(flag.charCodeAt(0) - 97));
+    }
+  }
+  return {
+    flags: [...new Set(flags)],
+    keywords: [...new Set(keywords)]
+  };
+}
+
+async function readDovecotKeywords(folderPath) {
+  const output = new Map();
+  try {
+    const content = await readFile(path.join(folderPath, 'dovecot-keywords'), 'utf8');
+    for (const line of content.split(/\r?\n/)) {
+      const match = line.match(/^([0-9]|1[0-9]|2[0-5])\s+(.+)$/);
+      if (match) output.set(Number(match[1]), match[2].trim());
+    }
+  } catch (error) {
+    if (error?.code !== 'ENOENT') throw error;
+  }
+  return output;
+}
+
+async function ensureDovecotKeywords(folderPath, keywords) {
+  const cleanKeywords = [...new Set((Array.isArray(keywords) ? keywords : [keywords])
+    .map((keyword) => String(keyword || '').trim())
+    .filter((keyword) => keyword && keyword.length <= 255 && !/[\r\n\u0000]/.test(keyword)))];
+  if (!cleanKeywords.length) return '';
+  const existing = await readDovecotKeywords(folderPath);
+  const indexByKeyword = new Map([...existing].map(([index, keyword]) => [keyword, index]));
+  let changed = false;
+  for (const keyword of cleanKeywords) {
+    if (indexByKeyword.has(keyword)) continue;
+    const freeIndex = Array.from({ length: 26 }, (_, index) => index)
+      .find((index) => !existing.has(index));
+    if (freeIndex === undefined) continue;
+    existing.set(freeIndex, keyword);
+    indexByKeyword.set(keyword, freeIndex);
+    changed = true;
+  }
+  if (changed) {
+    const content = [...existing]
+      .sort(([left], [right]) => left - right)
+      .map(([index, keyword]) => `${index} ${keyword}\n`)
+      .join('');
+    const target = path.join(folderPath, 'dovecot-keywords');
+    const temporary = path.join(folderPath, `.dovecot-keywords.${crypto.randomBytes(8).toString('hex')}`);
+    const handle = await open(temporary, 'wx', 0o600);
+    try {
+      await handle.writeFile(content, 'utf8');
+      await handle.sync();
+    } finally {
+      await handle.close();
+    }
+    await rename(temporary, target);
+    await syncDirectory(folderPath);
+  }
+  return cleanKeywords
+    .map((keyword) => indexByKeyword.get(keyword))
+    .filter((index) => Number.isInteger(index))
+    .map((index) => String.fromCharCode(97 + index))
+    .join('');
+}
+
+function storageKeyFromFilename(baseName) {
+  const managed = String(baseName || '').match(managedStorageKeyPattern)?.[1];
+  if (managed) return managed;
+  return `mhext-${crypto.createHash('sha256').update(String(baseName || '')).digest('hex').slice(0, 40)}`;
+}
+
+function createMaildirFilename(storageKey) {
+  // The key is already cryptographically unique for new mail and deterministic
+  // for migrations. Keeping the basename stable lets Dovecot move or rename
+  // flags without changing MailHub's storage identity.
+  return `${storageKey}.mailhub`;
+}
+
+function normalizeDate(value) {
+  const timestamp = value instanceof Date ? value : new Date(value);
+  if (!Number.isFinite(timestamp.getTime())) throw new Error('邮件时间不正确。');
+  return timestamp;
+}
+
+function relativeMaildirPath(root, address, filePath) {
+  const home = maildirHomePath(root, address);
+  const relative = path.relative(home, filePath).split(path.sep).join('/');
+  if (!relative || relative.startsWith('../') || path.isAbsolute(relative)) {
+    throw new Error('Maildir 存储路径不正确。');
+  }
+  return relative;
+}
+
+function storageMetadata({ root, address, filePath, storageKey, bytes, timestamp }) {
+  return {
+    backend: 'maildir',
+    key: storageKey,
+    relpath: relativeMaildirPath(root, address, filePath),
+    sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
+    size: bytes.length,
+    mtimeMs: Math.max(0, Math.trunc(timestamp.getTime())),
+    indexedAt: new Date().toISOString()
+  };
+}
+
+async function syncDirectory(directory) {
+  let handle;
+  try {
+    handle = await open(directory, 'r');
+    await handle.sync();
+  } catch (error) {
+    if (!['EINVAL', 'ENOTSUP', 'EISDIR', 'EPERM'].includes(error?.code)) throw error;
+  } finally {
+    await handle?.close();
+  }
+}
+
+async function syncDirectoryChain(directory, root) {
+  const directories = new Set();
+  addDirectoryChain(directories, path.resolve(directory), path.resolve(root));
+  for (const current of [...directories].sort((left, right) => right.length - left.length)) {
+    await syncDirectory(current);
+  }
+}
+
+function addDirectoryChain(output, directory, root) {
+  let current = path.resolve(directory);
+  const cleanRoot = path.resolve(root);
+  if (!isPathInside(cleanRoot, current, { allowRoot: true })) {
+    throw new Error('Maildir 目录不正确。');
+  }
+  while (true) {
+    output.add(current);
+    if (current === cleanRoot) return;
+    current = path.dirname(current);
+  }
+}
+
+function isPathInside(root, candidate, { allowRoot = false } = {}) {
+  const relative = path.relative(root, candidate);
+  if (!relative) return allowRoot;
+  return !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
+}
+
+async function mapWithConcurrency(values, concurrency, mapper) {
+  const output = new Array(values.length);
+  let cursor = 0;
+  const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => {
+    while (cursor < values.length) {
+      const index = cursor;
+      cursor += 1;
+      output[index] = await mapper(values[index], index);
+    }
+  });
+  await Promise.all(workers);
+  return output;
+}

+ 357 - 0
src/maildir-sync.js

@@ -0,0 +1,357 @@
+import crypto from 'node:crypto';
+import { unlink } from 'node:fs/promises';
+import path from 'node:path';
+
+import {
+  clearInboundMaildirMigrationStaging,
+  commitInboundMaildirMigrationStaging,
+  createImportedInboundMessage,
+  createImportedInboundMessageWithWebhook,
+  listInboundFolders,
+  listInboundMailboxesForStorage,
+  listInboundMaildirIndex,
+  listInboundMaildirMigrationCandidates,
+  listInboundMaildirMigrationStaging,
+  markMissingInboundMaildirMessages,
+  stageInboundMessageMaildirStorageBatch,
+  syncInboundMaildirFolders,
+  syncInboundMessageMaildirMetadata
+} from './db.js';
+import { parseInboundMessage } from './inbound-mail.js';
+import {
+  ensureMaildirMailbox,
+  flushMaildirFiles,
+  listMaildirFolders,
+  maildirHomePath,
+  readMaildirMessage,
+  scanMaildirMailbox,
+  sqliteMaildirStorageKey,
+  writeMaildirMessage
+} from './maildir-store.js';
+
+export async function migrateInboundMessagesToMaildir({
+  root,
+  batchSize = 250,
+  onProgress = null
+} = {}) {
+  const cleanBatchSize = Math.min(1000, Math.max(1, Number(batchSize) || 250));
+  let lastId = 0;
+  let processed = 0;
+  let written = 0;
+  let reused = 0;
+  let looseFiles = [];
+  let looseDirectories = [];
+
+  try {
+    await cleanupMaildirMigrationStaging({ root, batchSize: cleanBatchSize });
+    for (const mailbox of listInboundMailboxesForStorage()) {
+      await ensureMaildirMailbox(root, mailbox.address, listInboundFolders(mailbox), { durable: true });
+      const entries = new Map(
+        (await scanMaildirMailbox({ root, address: mailbox.address }))
+          .map((entry) => [entry.storageKey, entry])
+      );
+      let mailboxAfterId = 0;
+      while (true) {
+        const candidates = listInboundMaildirMigrationCandidates(
+          mailboxAfterId,
+          cleanBatchSize,
+          { mailboxId: mailbox.id }
+        );
+        if (!candidates.length) break;
+        const batchRecords = [];
+        const batchFiles = [];
+        const batchDirectories = [];
+        looseFiles = batchFiles;
+        looseDirectories = batchDirectories;
+        for (const candidate of candidates) {
+          mailboxAfterId = candidate.id;
+          lastId = Math.max(lastId, candidate.id);
+          const storageKey = validStorageKey(candidate.storageKey)
+            ? candidate.storageKey
+            : sqliteMaildirStorageKey(candidate.id);
+          const existing = entries.get(storageKey);
+          if (existing) {
+            const { bytes } = await readMaildirMessage(existing);
+            if (!bytes.equals(Buffer.from(candidate.rawMessageBytes))) {
+              throw new Error(`邮件 ${candidate.id} 的 Maildir 存储标识已被其他内容占用。`);
+            }
+            batchDirectories.push(path.dirname(existing.filePath));
+            await unlink(existing.filePath);
+            entries.delete(storageKey);
+            reused += 1;
+          }
+          const storage = await writeMaildirMessage({
+            root,
+            address: candidate.mailboxAddress,
+            rawMessageBytes: candidate.rawMessageBytes,
+            folder: candidate.folder,
+            flags: candidate.flags,
+            keywords: candidate.keywords,
+            read: candidate.read,
+            receivedAt: candidate.receivedAt,
+            storageKey,
+            durable: false
+          });
+          const filePath = maildirFilePath(root, candidate.mailboxAddress, storage.relpath);
+          entries.set(storageKey, {
+            storageKey,
+            filePath,
+            relpath: storage.relpath,
+            size: storage.size,
+            mtimeMs: storage.mtimeMs,
+            folder: candidate.folder,
+            flags: candidate.flags,
+            keywords: candidate.keywords,
+            read: candidate.read
+          });
+          written += 1;
+          batchFiles.push(filePath);
+          batchRecords.push({ id: candidate.id, storage });
+          processed += 1;
+          await onProgress?.({ processed, written, reused, lastId: candidate.id });
+        }
+        stageInboundMessageMaildirStorageBatch(batchRecords);
+        await flushMaildirFiles(batchFiles, { root, directories: batchDirectories });
+        looseFiles = [];
+        looseDirectories = [];
+      }
+    }
+    const switched = commitInboundMaildirMigrationStaging(processed);
+    if (switched !== processed) throw new Error('Maildir 批量切换未完整提交。');
+  } catch (error) {
+    await removeMaildirFiles(looseFiles);
+    await flushMaildirFiles([], { root, directories: looseDirectories }).catch(() => null);
+    await cleanupMaildirMigrationStaging({ root, batchSize: cleanBatchSize }).catch(() => null);
+    throw error;
+  }
+  return { processed, written, reused, lastId };
+}
+
+export async function reconcileMaildirMailbox({ root, mailbox, missingCounts = new Map() }) {
+  await ensureMaildirMailbox(root, mailbox.address);
+  const folderReport = syncInboundMaildirFolders(
+    mailbox,
+    await listMaildirFolders({ root, address: mailbox.address })
+  );
+  const index = listInboundMaildirIndex(mailbox.id);
+  const entries = assignStorageKeys(
+    await scanMaildirMailbox({ root, address: mailbox.address }),
+    index
+  );
+  const indexByKey = new Map(index.map((message) => [message.storageKey, message]));
+  const present = new Set();
+  let created = 0;
+  let updated = 0;
+
+  for (const entry of entries) {
+    present.add(entry.storageKey);
+    missingCounts.delete(missingCounterKey(mailbox.id, entry.storageKey));
+    const existing = indexByKey.get(entry.storageKey);
+    const storage = storageFromEntry(entry);
+    if (existing) {
+      if (
+        !existing.deleted
+        && existing.storageRelpath === entry.relpath
+        && existing.storageSize === entry.size
+        && existing.storageMtimeMs === entry.mtimeMs
+        && existing.folder === entry.folder
+        && existing.read === entry.read
+        && sameStringSet(existing.flags, entry.flags)
+        && sameStringSet(existing.keywords, entry.keywords)
+      ) continue;
+      if (syncInboundMessageMaildirMetadata(mailbox, storage, entry)) updated += 1;
+      continue;
+    }
+
+    const { bytes, sha256 } = await readMaildirMessage(entry);
+    let parsed = {};
+    try {
+      parsed = await parseInboundMessage(bytes, [mailbox.address]);
+    } catch {
+      // Dovecot must still expose malformed historic messages verbatim. The
+      // management index falls back to minimal metadata instead of dropping it.
+    }
+    const createIndex = entry.folder === 'INBOX' && entry.storageKey.startsWith('mhsmtp-')
+      ? createImportedInboundMessageWithWebhook
+      : createImportedInboundMessage;
+    const result = createIndex(mailbox, {
+      ...parsed,
+      importSource: 'maildir-index',
+      sourceKey: `${mailbox.id}:${entry.storageKey}`,
+      folder: entry.folder,
+      flags: entry.flags,
+      keywords: entry.keywords,
+      read: entry.read,
+      receivedAt: entry.receivedAt,
+      rawMessageBytes: bytes,
+      recipients: parsed.recipients?.length ? parsed.recipients : [mailbox.address],
+      storage: storageFromEntry(entry, { sha256, size: bytes.length })
+    });
+    if (result.created) {
+      created += 1;
+    }
+    else if (syncInboundMessageMaildirMetadata(mailbox, storage, entry)) updated += 1;
+  }
+
+  const protectedKeys = new Set(present);
+  for (const message of index) {
+    if (present.has(message.storageKey) || message.deleted) continue;
+    const counterKey = missingCounterKey(mailbox.id, message.storageKey);
+    const count = (missingCounts.get(counterKey) || 0) + 1;
+    missingCounts.set(counterKey, count);
+    if (count < 2) protectedKeys.add(message.storageKey);
+  }
+  const deleted = markMissingInboundMaildirMessages(mailbox.id, protectedKeys);
+  return {
+    mailboxId: mailbox.id,
+    files: entries.length,
+    foldersCreatedOrRestored: folderReport.createdOrRestored,
+    foldersDeleted: folderReport.deleted,
+    created,
+    updated,
+    deleted
+  };
+}
+
+export async function reconcileAllMaildirs({ root, missingCounts = new Map() } = {}) {
+  const reports = [];
+  for (const mailbox of listInboundMailboxesForStorage()) {
+    reports.push(await reconcileMaildirMailbox({ root, mailbox, missingCounts }));
+  }
+  return reports;
+}
+
+export function startMaildirReconciler({
+  root,
+  enabled = true,
+  intervalMs = 5_000,
+  logger = console
+} = {}) {
+  if (!enabled) return { stop() {} };
+  const delay = Math.max(1_000, Number(intervalMs) || 5_000);
+  const missingCounts = new Map();
+  let stopped = false;
+  let timer = null;
+
+  const schedule = () => {
+    if (stopped) return;
+    timer = setTimeout(run, delay);
+    timer.unref?.();
+  };
+  const run = async () => {
+    try {
+      await reconcileAllMaildirs({ root, missingCounts });
+    } catch (error) {
+      logger.warn?.(`Maildir index synchronization failed: ${error.message || error}`);
+    } finally {
+      schedule();
+    }
+  };
+  schedule();
+  return {
+    async runNow() {
+      return reconcileAllMaildirs({ root, missingCounts });
+    },
+    stop() {
+      stopped = true;
+      clearTimeout(timer);
+    }
+  };
+}
+
+function assignStorageKeys(entries, index) {
+  const indexByRelpath = new Map(index.map((message) => [message.storageRelpath, message]));
+  const used = new Set();
+  const pending = [];
+  const output = [];
+
+  for (const entry of entries) {
+    const existing = indexByRelpath.get(entry.relpath);
+    if (existing && !used.has(existing.storageKey)) {
+      used.add(existing.storageKey);
+      output.push({ ...entry, storageKey: existing.storageKey });
+    } else {
+      pending.push(entry);
+    }
+  }
+  for (const entry of pending) {
+    let storageKey = entry.storageKey;
+    if (used.has(storageKey)) {
+      storageKey = `mhcopy-${crypto.createHash('sha256')
+        .update(`${entry.storageKey}\0${entry.relpath}`)
+        .digest('hex')
+        .slice(0, 40)}`;
+    }
+    while (used.has(storageKey)) {
+      storageKey = `mhcopy-${crypto.createHash('sha256')
+        .update(`${storageKey}\0${entry.relpath}`)
+        .digest('hex')
+        .slice(0, 40)}`;
+    }
+    used.add(storageKey);
+    output.push({ ...entry, storageKey });
+  }
+  return output.sort((left, right) => left.relpath.localeCompare(right.relpath));
+}
+
+function storageFromEntry(entry, overrides = {}) {
+  return {
+    backend: 'maildir',
+    key: entry.storageKey,
+    relpath: entry.relpath,
+    sha256: overrides.sha256 || '',
+    size: overrides.size ?? entry.size,
+    mtimeMs: entry.mtimeMs,
+    indexedAt: new Date().toISOString()
+  };
+}
+
+function validStorageKey(value) {
+  return /^[a-z0-9][a-z0-9._-]{0,191}$/.test(String(value || ''));
+}
+
+function sameStringSet(left, right) {
+  return [...new Set(left || [])].sort().join('\0') === [...new Set(right || [])].sort().join('\0');
+}
+
+function missingCounterKey(mailboxId, storageKey) {
+  return `${mailboxId}:${storageKey}`;
+}
+
+async function cleanupMaildirMigrationStaging({ root, batchSize }) {
+  let afterMessageId = 0;
+  while (true) {
+    const staged = listInboundMaildirMigrationStaging(afterMessageId, batchSize);
+    if (!staged.length) break;
+    const files = staged.map((entry) => (
+      maildirFilePath(root, entry.mailboxAddress, entry.storageRelpath)
+    ));
+    await removeMaildirFiles(files);
+    await flushMaildirFiles([], {
+      root,
+      directories: files.map((filePath) => path.dirname(filePath))
+    });
+    afterMessageId = staged.at(-1).messageId;
+  }
+  clearInboundMaildirMigrationStaging();
+}
+
+async function removeMaildirFiles(filePaths) {
+  const files = [...new Set(filePaths || [])];
+  for (let offset = 0; offset < files.length; offset += 32) {
+    await Promise.all(files.slice(offset, offset + 32).map(async (filePath) => {
+      try {
+        await unlink(filePath);
+      } catch (error) {
+        if (error?.code !== 'ENOENT') throw error;
+      }
+    }));
+  }
+}
+
+function maildirFilePath(root, address, relpath) {
+  const home = maildirHomePath(root, address);
+  const filePath = path.resolve(home, ...String(relpath || '').split('/'));
+  if (!filePath.startsWith(`${home}${path.sep}`)) throw new Error('Maildir 存储路径不正确。');
+  return filePath;
+}

+ 14 - 4
src/mailer.js

@@ -191,7 +191,9 @@ export async function sendViaSmtp({ host, port, secure, username, password, helo
       phase: 'data',
       direction: 'client',
       message: 'Message content transmitted',
-      messageBytes: Buffer.byteLength(rawMessage || '', 'utf8'),
+      messageBytes: Buffer.isBuffer(rawMessage)
+        ? rawMessage.length
+        : Buffer.byteLength(rawMessage || '', 'utf8'),
       ok: true
     });
     await client.writeData(dotStuff(rawMessage));
@@ -393,8 +395,11 @@ function stripHtml(value) {
 }
 
 function dotStuff(rawMessage) {
-  const normalized = rawMessage.replace(/\r?\n/g, '\r\n');
-  return `${normalized.replace(/^\./gm, '..')}\r\n.`;
+  const binary = Buffer.isBuffer(rawMessage) || rawMessage instanceof Uint8Array;
+  const source = binary ? Buffer.from(rawMessage).toString('latin1') : String(rawMessage || '');
+  const normalized = source.replace(/\r?\n/g, '\r\n');
+  const stuffed = `${normalized.replace(/^\./gm, '..')}\r\n.`;
+  return binary ? Buffer.from(stuffed, 'latin1') : stuffed;
 }
 
 class SmtpClient {
@@ -428,7 +433,12 @@ class SmtpClient {
   }
 
   writeData(data) {
-    this.socket.write(`${data}\r\n`);
+    if (Buffer.isBuffer(data) || data instanceof Uint8Array) {
+      this.socket.write(data);
+      this.socket.write('\r\n');
+    } else {
+      this.socket.write(`${data}\r\n`);
+    }
     return Promise.resolve();
   }
 

+ 9 - 0
src/password-hash.js

@@ -1,6 +1,10 @@
 import crypto from 'node:crypto';
 
 const MD5_CRYPT_ALPHABET = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
+const dummyScryptSalt = 'mailhub-dummy-auth-v1';
+const dummyScryptHash = `scrypt$${dummyScryptSalt}$${crypto
+  .scryptSync('mailhub-dummy-password', dummyScryptSalt, 64)
+  .toString('hex')}`;
 
 export function hashPassword(password) {
   const salt = crypto.randomBytes(16).toString('hex');
@@ -22,6 +26,11 @@ export function verifyScryptPassword(password, stored) {
   return safeEqual(actual, Buffer.from(expectedHex, 'hex'));
 }
 
+export function consumeDummyPasswordVerification(password) {
+  verifyScryptPassword(password, dummyScryptHash);
+  return false;
+}
+
 export function isLegacyPasswordHash(stored) {
   return Boolean(parseVestaPasswordHash(stored));
 }

+ 68 - 15
src/server.js

@@ -30,6 +30,7 @@ import {
   getDomainByName,
   getApiToken,
   getInboundMessage,
+  getInboundMessageMaildirStorage,
   getSendEvent,
   getSendAnalytics,
   getSettings,
@@ -57,6 +58,7 @@ import {
   logAudit,
   logSendEvent,
   markInboundMessageRead,
+  recordInboundMessageMaildirStorage,
   markUserEmailVerified,
   previewUserMerge,
   replayWebhookDelivery,
@@ -110,7 +112,10 @@ import {
   publicMailboxAccessListeners,
   startMailboxAccessServers
 } from './mail-access.js';
+import { startDovecotAuthServer } from './dovecot-auth-server.js';
 import { repairStoredInboundMime } from './inbound-mime-repair.js';
+import { maildirRootFromEnvironment, setMaildirMessageSeen } from './maildir-store.js';
+import { startMaildirReconciler } from './maildir-sync.js';
 import {
   parseSubmissionListeners,
   publicSubmissionListeners,
@@ -139,6 +144,8 @@ const fallbackSecret = crypto
   .createHash('sha256')
   .update(`${process.env.ADMIN_PASSWORD || 'change-this-admin-password'}:${process.env.API_TOKEN || ''}`)
   .digest('hex');
+const mailAccessBackend = String(process.env.MAIL_ACCESS_BACKEND || 'legacy').trim().toLowerCase();
+const dovecotMailAccess = mailAccessBackend === 'dovecot';
 
 const envConfig = {
   port: Number(process.env.PORT || 3000),
@@ -173,12 +180,25 @@ const envConfig = {
   submissionTlsCert: process.env.SUBMISSION_TLS_CERT || '',
   submissionTlsKey: process.env.SUBMISSION_TLS_KEY || '',
   submissionMaxMessageBytes: Number(process.env.SUBMISSION_MAX_MESSAGE_BYTES || 50 * 1024 * 1024),
-  imapEnabled: String(process.env.IMAP_ENABLED || 'true').toLowerCase() !== 'false',
-  imapListeners: parseMailboxAccessListeners(process.env.IMAP_PORTS, '143:imap,993:imaps'),
-  pop3Enabled: String(process.env.POP3_ENABLED || 'true').toLowerCase() !== 'false',
-  pop3Listeners: parseMailboxAccessListeners(process.env.POP3_PORTS, '110:pop3,995:pop3s'),
-  mailboxAccessAllowInsecureAuth:
+  mailAccessBackend,
+  imapEnabled: dovecotMailAccess || String(process.env.IMAP_ENABLED || 'true').toLowerCase() !== 'false',
+  imapListeners: parseMailboxAccessListeners(
+    dovecotMailAccess ? '143:imap,993:imaps' : process.env.IMAP_PORTS,
+    '143:imap,993:imaps'
+  ),
+  pop3Enabled: dovecotMailAccess || String(process.env.POP3_ENABLED || 'true').toLowerCase() !== 'false',
+  pop3Listeners: parseMailboxAccessListeners(
+    dovecotMailAccess ? '110:pop3,995:pop3s' : process.env.POP3_PORTS,
+    '110:pop3,995:pop3s'
+  ),
+  mailboxAccessAllowInsecureAuth: !dovecotMailAccess &&
     String(process.env.MAIL_ACCESS_ALLOW_INSECURE_AUTH || process.env.SUBMISSION_ALLOW_INSECURE_AUTH || '').toLowerCase() === 'true',
+  dovecotAuthEnabled: String(process.env.DOVECOT_AUTH_ENABLED || '').toLowerCase() === 'true',
+  dovecotAuthHost: process.env.DOVECOT_AUTH_HOST || '0.0.0.0',
+  dovecotAuthPort: Number(process.env.DOVECOT_AUTH_PORT || 3001),
+  dovecotAuthSecretFile: process.env.DOVECOT_AUTH_SECRET_FILE || '',
+  maildirRoot: maildirRootFromEnvironment(process.env),
+  maildirSyncIntervalMs: Number(process.env.MAILDIR_SYNC_INTERVAL_MS || 5000),
   inboundEnabled: String(process.env.INBOUND_ENABLED || 'true').toLowerCase() !== 'false',
   sessionSecret: process.env.SESSION_SECRET || fallbackSecret,
   trackingSecret: process.env.TRACKING_SECRET || process.env.SESSION_SECRET || fallbackSecret,
@@ -249,6 +269,12 @@ startWebhookWorker({
   batchSize: envConfig.webhookWorkerBatchSize
 });
 
+startMaildirReconciler({
+  root: envConfig.maildirRoot,
+  enabled: envConfig.mailAccessBackend === 'dovecot',
+  intervalMs: envConfig.maildirSyncIntervalMs
+});
+
 startTrackingRetentionWorker({
   days: envConfig.trackingRetentionDays
 });
@@ -307,12 +333,25 @@ server.listen(envConfig.port, '0.0.0.0', () => {
   console.log(`MailHub listening on 0.0.0.0:${envConfig.port}`);
 });
 
+if (envConfig.dovecotAuthEnabled) {
+  startDovecotAuthServer({
+    host: envConfig.dovecotAuthHost,
+    port: envConfig.dovecotAuthPort,
+    secretFile: envConfig.dovecotAuthSecretFile,
+    onListening() {
+      console.log(`MailHub Dovecot authentication bridge listening on ${envConfig.dovecotAuthHost}:${envConfig.dovecotAuthPort}`);
+    }
+  });
+}
+
 startSubmissionServer({
   enabled: envConfig.submissionEnabled,
   listeners: envConfig.submissionListeners,
   hostname: envConfig.submissionHost,
   allowInsecureAuth: envConfig.submissionAllowInsecureAuth,
   inboundEnabled: envConfig.inboundEnabled,
+  maildirEnabled: envConfig.mailAccessBackend === 'dovecot',
+  maildirRoot: envConfig.maildirRoot,
   maxMessageBytes: envConfig.submissionMaxMessageBytes,
   tlsCertPath: envConfig.submissionTlsCert,
   tlsKeyPath: envConfig.submissionTlsKey,
@@ -338,16 +377,18 @@ startSubmissionServer({
   }
 });
 
-startMailboxAccessServers({
-  hostname: envConfig.submissionHost,
-  imapEnabled: envConfig.imapEnabled,
-  imapListeners: envConfig.imapListeners,
-  pop3Enabled: envConfig.pop3Enabled,
-  pop3Listeners: envConfig.pop3Listeners,
-  allowInsecureAuth: envConfig.mailboxAccessAllowInsecureAuth,
-  tlsCertPath: envConfig.submissionTlsCert,
-  tlsKeyPath: envConfig.submissionTlsKey
-});
+if (envConfig.mailAccessBackend !== 'dovecot') {
+  startMailboxAccessServers({
+    hostname: envConfig.submissionHost,
+    imapEnabled: envConfig.imapEnabled,
+    imapListeners: envConfig.imapListeners,
+    pop3Enabled: envConfig.pop3Enabled,
+    pop3Listeners: envConfig.pop3Listeners,
+    allowInsecureAuth: envConfig.mailboxAccessAllowInsecureAuth,
+    tlsCertPath: envConfig.submissionTlsCert,
+    tlsKeyPath: envConfig.submissionTlsKey
+  });
+}
 
 async function handleApi(req, res, url, user) {
   const method = req.method || 'GET';
@@ -479,6 +520,18 @@ async function handleApi(req, res, url, user) {
     }
     if (method === 'PATCH') {
       const body = await readJson(req);
+      const storage = getInboundMessageMaildirStorage(user.id, id);
+      if (storage?.backend === 'maildir') {
+        const updatedStorage = await setMaildirMessageSeen({
+          root: envConfig.maildirRoot,
+          address: storage.mailboxAddress,
+          storageKey: storage.key,
+          relpath: storage.relpath,
+          seen: body.read !== false
+        });
+        if (!updatedStorage) return sendJson(res, 409, { error: '邮件存储已发生变化,请刷新后重试。' });
+        recordInboundMessageMaildirStorage(id, updatedStorage);
+      }
       const message = markInboundMessageRead(user.id, id, body.read !== false);
       return sendJson(res, message ? 200 : 404, { message });
     }

+ 46 - 16
src/submission.js

@@ -3,9 +3,8 @@ import tls from 'node:tls';
 import { readFileSync } from 'node:fs';
 import {
   createSendEvent,
-  createInboundMessage,
+  createInboundMessageWithWebhook,
   createTrackingLink,
-  enqueueInboundWebhookDeliveries,
   finalizeSendEvent,
   getDomainByName,
   logSendEvent,
@@ -13,6 +12,7 @@ import {
   verifySmtpCredential
 } from './db.js';
 import { parseInboundMessage } from './inbound-mail.js';
+import { newMaildirStorageKey, writeMaildirMessage } from './maildir-store.js';
 import {
   addHeadersToRawMessage,
   buildDeliverabilityHeaders,
@@ -230,7 +230,10 @@ class SubmissionSession {
     this.remoteAddress = socket.remoteAddress || '';
     this.onDataBound = (chunk) => this.onData(chunk);
     this.queue = Promise.resolve();
-    socket.setEncoding('utf8');
+    // latin1 gives us a reversible one-byte string for SMTP framing while
+    // command parsing remains ASCII-compatible. DATA is converted back to a
+    // Buffer before MIME parsing or Maildir delivery.
+    socket.setEncoding('latin1');
     socket.on('data', this.onDataBound);
     socket.on('error', () => null);
     this.write(220, `${config.hostname} MailHub SMTP ready`);
@@ -255,7 +258,7 @@ class SubmissionSession {
     if (this.dataMode) {
       if (line === '.') return await this.finishData();
       const dataLine = line.startsWith('..') ? line.slice(1) : line;
-      const nextBytes = this.dataBytes + Buffer.byteLength(`${dataLine}\r\n`, 'utf8');
+      const nextBytes = this.dataBytes + Buffer.byteLength(`${dataLine}\r\n`, 'latin1');
       if (nextBytes > this.maxMessageBytes()) {
         this.dataTooLarge = true;
         this.dataBytes = nextBytes;
@@ -299,7 +302,9 @@ class SubmissionSession {
     if (this.canAuthenticate()) {
       this.socket.write('250-AUTH PLAIN LOGIN\r\n');
     }
-    this.socket.write('250 SMTPUTF8\r\n');
+    // Commands are intentionally ASCII-only; 8BITMIME applies to DATA bytes,
+    // but advertising SMTPUTF8 would claim unsupported Unicode envelopes.
+    this.socket.write('250 HELP\r\n');
   }
 
   startTls() {
@@ -322,7 +327,10 @@ class SubmissionSession {
       tlsActive: true,
       startTlsAvailable: false
     };
-    secureSocket.setEncoding('utf8');
+    // SMTP commands are ASCII-compatible, while DATA may contain arbitrary
+    // 8BITMIME bytes. Keep the one-byte mapping used before STARTTLS so the
+    // TLS upgrade cannot rewrite message content.
+    secureSocket.setEncoding('latin1');
     secureSocket.on('data', this.onDataBound);
     secureSocket.on('error', () => null);
   }
@@ -436,8 +444,9 @@ class SubmissionSession {
       this.resetEnvelope(false);
       return this.write(552, 'Message size exceeds fixed maximum message size');
     }
-    const rawMessage = `${this.dataLines.join('\r\n')}\r\n`;
-    if (!this.authenticated) return await this.finishInboundData(rawMessage);
+    const rawMessageBytes = Buffer.from(`${this.dataLines.join('\r\n')}\r\n`, 'latin1');
+    if (!this.authenticated) return await this.finishInboundData(rawMessageBytes);
+    const rawMessage = rawMessageBytes.toString('utf8');
     const headerFrom = extractHeader(rawMessage, 'from');
     const subject = decodeHeader(extractHeader(rawMessage, 'subject')) || '(no subject)';
     const sender = extractAddress(headerFrom) || this.mailFrom;
@@ -557,11 +566,12 @@ class SubmissionSession {
     }
   }
 
-  async finishInboundData(rawMessage) {
+  async finishInboundData(rawMessageBytes) {
     if (!this.config.inboundEnabled) return this.write(530, 'Authentication required');
     if (!this.inboundRoutes.length) return this.write(550, 'Recipient is not a local MailHub mailbox');
     try {
-      const parsedMessage = await parseInboundMessage(rawMessage, this.recipients);
+      const parsedMessage = await parseInboundMessage(rawMessageBytes, this.recipients);
+      const rawMessage = rawMessageBytes.toString('utf8');
       let storedCount = 0;
       let forwardedCount = 0;
       const forwardErrors = [];
@@ -571,22 +581,42 @@ class SubmissionSession {
         const forwardTo = route.forwardTo || [];
         const shouldStore = route.mailbox && (route.keepForwarded || !forwardTo.length);
         if (shouldStore) {
-          const inboundMessage = createInboundMessage(route.mailbox, {
+          const message = {
             ...parsedMessage,
+            rawMessage,
+            rawMessageBytes,
             recipients: [route.recipient],
             sender: parsedMessage.sender || this.mailFrom
-          });
+          };
+          let storage = null;
+          if (this.config.maildirEnabled) {
+            storage = await writeMaildirMessage({
+              root: this.config.maildirRoot,
+              address: route.mailbox.address,
+              rawMessageBytes: message.rawMessageBytes,
+              folder: 'INBOX',
+              read: false,
+              receivedAt: new Date(),
+              storageKey: newMaildirStorageKey('smtp')
+            });
+          }
           try {
-            enqueueInboundWebhookDeliveries(inboundMessage);
+            createInboundMessageWithWebhook(route.mailbox, {
+              ...message,
+              ...(storage ? { storage } : {})
+            });
           } catch (error) {
-            // The message is already durable; webhook retries must not turn receipt into an SMTP failure.
-            console.error(`Inbound webhook enqueue failed for ${route.recipient}: ${error.message}`);
+            if (!storage) throw error;
+            // Maildir is the durable source. The reconciler will rebuild a
+            // transiently failed SQLite index and webhook outbox atomically
+            // without asking the sender to retry.
+            console.error(`Inbound Maildir index enqueue failed for ${route.recipient}: ${error.message}`);
           }
           storedCount += 1;
         }
         if (forwardTo.length) {
           try {
-            await this.forwardInboundMessage(rawMessage, parsedMessage, route, forwardTo);
+            await this.forwardInboundMessage(rawMessageBytes, parsedMessage, route, forwardTo);
             forwardedCount += forwardTo.length;
           } catch (error) {
             forwardErrors.push({ route, error });

+ 10 - 0
test/cert-sync-script.test.js

@@ -16,12 +16,19 @@ import { spawnSync } from 'node:child_process';
 import { test } from 'node:test';
 
 const scriptPath = fileURLToPath(new URL('../scripts/sync-tls-certificate.sh', import.meta.url));
+const scriptSource = readFileSync(scriptPath, 'utf8');
 const opensslLookup = process.platform === 'win32'
   ? { status: 1, stdout: '' }
   : spawnSync('sh', ['-c', 'command -v openssl'], { encoding: 'utf8' });
 const opensslPath = opensslLookup.status === 0 ? opensslLookup.stdout.trim() : '';
 const canRun = process.platform !== 'win32' && Boolean(opensslPath);
 
+test('uses protocol-specific STARTTLS probes for explicit TLS upgrade ports', () => {
+  assert.match(scriptSource, /25\|587\|2525\) protocol_args=\(-starttls smtp\)/);
+  assert.match(scriptSource, /110\).*protocol_args=\(-starttls pop3\)/);
+  assert.match(scriptSource, /143\).*protocol_args=\(-starttls imap\)/);
+});
+
 test('atomically synchronizes a valid wildcard certificate and preserves safe permissions', { skip: !canRun }, (t) => {
   const fixture = createFixture(t);
   generateCertificate(fixture.sourceDir, 'example.test');
@@ -354,6 +361,9 @@ set -euo pipefail
 if [[ "$*" == *"tls.createSecureContext"* ]]; then
   exit 0
 fi
+if [[ "$*" == *"test -r"* ]]; then
+  exit 0
+fi
 if [[ "\${1:-}" == "compose" && "\${2:-}" == "restart" ]]; then
   exit 0
 fi

+ 102 - 5
test/deploy-remote-script.test.js

@@ -14,8 +14,13 @@ import { test } from 'node:test';
 import { fileURLToPath } from 'node:url';
 
 const scriptPath = fileURLToPath(new URL('../scripts/deploy-remote.sh', import.meta.url));
+const scriptSource = readFileSync(scriptPath, 'utf8');
 const canRun = process.platform !== 'win32';
 
+test('checks Maildir access with Dovecot mail worker uid instead of container root', () => {
+  assert.match(scriptSource, /compose exec -T --user 1000:1000 dovecot/);
+});
+
 test('waits for app and postfix health before and after certificate synchronization', { skip: !canRun }, (t) => {
   const fixture = createFixture(t);
   const result = runDeploy(fixture);
@@ -25,13 +30,33 @@ test('waits for app and postfix health before and after certificate synchronizat
   assert.deepEqual(
     events.filter((event) => event.startsWith('health:') || event.startsWith('sync:')),
     [
+      'sync:0',
       'health:app-container',
       'health:postfix-container',
+      'health:dovecot-container',
       'sync:1',
       'health:app-container',
-      'health:postfix-container'
+      'health:postfix-container',
+      'health:dovecot-container'
     ]
   );
+  assert.equal(events.filter((event) => event === 'runtime:app').length, 2);
+  assert.equal(events.filter((event) => event === 'runtime:dovecot').length, 2);
+});
+
+test('prepares the certificate and Dovecot image before entering the maintenance window', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  const result = runDeploy(fixture);
+
+  assert.equal(result.status, 0, result.stderr);
+  const events = readEvents(fixture.logFile);
+  const offlineSync = events.indexOf('sync:0');
+  const pull = events.indexOf('pull:dovecot');
+  const stop = events.indexOf('stop:app,dovecot');
+  const up = events.indexOf('up');
+  assert.ok(offlineSync >= 0 && offlineSync < stop, events.join('\n'));
+  assert.ok(pull >= 0 && pull < stop, events.join('\n'));
+  assert.ok(stop < up, events.join('\n'));
 });
 
 test('reports the previous revision when deployment fails', { skip: !canRun }, (t) => {
@@ -41,12 +66,42 @@ test('reports the previous revision when deployment fails', { skip: !canRun }, (
   assert.equal(result.status, 19);
   assert.match(
     result.stderr,
-    /Deployment failed\. Previous revision was previous-revision; inspect the running containers before rollback\./
+    /Deployment failed\. Previous revision was previous-revision; inspect the running containers before recovery\./
   );
+  assert.match(result.stderr, /Maildir cutover is already committed; legacy mail services will not be restarted/);
   const events = readEvents(fixture.logFile);
+  assert.equal(events.filter((event) => event === 'sync:0').length, 1);
   assert.equal(events.filter((event) => event === 'sync:1').length, 1);
   assert.equal(events.filter((event) => event === 'health:app-container').length, 1);
   assert.equal(events.filter((event) => event === 'health:postfix-container').length, 1);
+  assert.equal(events.filter((event) => event === 'health:dovecot-container').length, 1);
+});
+
+test('stops the app for migration and restarts the previous container if migration fails', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  const result = runDeploy(fixture, { migrationStatus: '23' });
+
+  assert.equal(result.status, 23);
+  assert.match(result.stderr, /Restarting the pre-migration MailHub mail services\./);
+  assert.deepEqual(readEvents(fixture.logFile), [
+    'sync:0',
+    'pull:dovecot',
+    'stop:app,dovecot',
+    'migrate',
+    'restart:app-container',
+    'restart:dovecot-container'
+  ]);
+});
+
+test('keeps the maintenance window explicit after cutover health checks fail', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  const result = runDeploy(fixture, { runtimeStatus: '29' });
+
+  assert.notEqual(result.status, 0);
+  assert.match(result.stderr, /Maildir cutover is already committed; legacy mail services will not be restarted/);
+  assert.match(result.stderr, /maintenance window remains active/);
+  assert.equal(readEvents(fixture.logFile).some((event) => event.startsWith('restart:')), false);
+  assert.equal(readEvents(fixture.logFile).filter((event) => event === 'stop:app,dovecot').length, 2);
 });
 
 function createFixture(t) {
@@ -93,6 +148,40 @@ if [ "$1" = "compose" ] && [ "$2" = "ps" ] && [ "\${3:-}" = "--all" ]; then
   printf '%s-container\\n' "$5"
   exit 0
 fi
+if [ "$1" = "compose" ] && [ "$2" = "stop" ]; then
+  printf 'stop:%s,%s\\n' "$3" "$4" >> "$MAILHUB_DEPLOY_TEST_LOG"
+  exit 0
+fi
+if [ "$1" = "compose" ] && [ "$2" = "run" ]; then
+  printf '%s\\n' 'migrate' >> "$MAILHUB_DEPLOY_TEST_LOG"
+  exit "\${MAILHUB_DEPLOY_TEST_MIGRATION_STATUS:-0}"
+fi
+if [ "$1" = "compose" ] && [ "$2" = "pull" ]; then
+  printf 'pull:%s\\n' "$3" >> "$MAILHUB_DEPLOY_TEST_LOG"
+  exit 0
+fi
+if [ "$1" = "compose" ] && [ "$2" = "up" ]; then
+  printf '%s\\n' 'up' >> "$MAILHUB_DEPLOY_TEST_LOG"
+  exit 0
+fi
+if [ "$1" = "compose" ] && [ "$2" = "exec" ]; then
+  service=""
+  for argument in "$@"; do
+    if [ "$argument" = "app" ] || [ "$argument" = "dovecot" ]; then
+      service="$argument"
+      break
+    fi
+  done
+  printf 'runtime:%s\\n' "$service" >> "$MAILHUB_DEPLOY_TEST_LOG"
+  if [ "$service" = "app" ] && [ "\${MAILHUB_DEPLOY_TEST_RUNTIME_STATUS:-0}" != "0" ]; then
+    exit "$MAILHUB_DEPLOY_TEST_RUNTIME_STATUS"
+  fi
+  exit 0
+fi
+if [ "$1" = "start" ]; then
+  printf 'restart:%s\\n' "$2" >> "$MAILHUB_DEPLOY_TEST_LOG"
+  exit 0
+fi
 if [ "$1" = "inspect" ]; then
   container=''
   for argument in "$@"; do
@@ -105,14 +194,20 @@ fi
 
   writeExecutable(path.join(remoteScriptsDir, 'sync-tls-certificate.sh'), `#!/bin/sh
 printf 'sync:%s\\n' "\${MAILHUB_CERT_RESTART:-}" >> "$MAILHUB_DEPLOY_TEST_LOG"
-exit "\${MAILHUB_DEPLOY_TEST_SYNC_STATUS:-0}"
+if [ "\${MAILHUB_CERT_RESTART:-0}" = "1" ]; then
+  exit "\${MAILHUB_DEPLOY_TEST_SYNC_STATUS:-0}"
+fi
+exit 0
+`);
+  writeExecutable(path.join(remoteScriptsDir, 'prepare-dovecot.sh'), `#!/bin/sh
+exit 0
 `);
 
   t.after(() => rmSync(root, { recursive: true, force: true }));
   return { root, fakeBin, remoteDir, logFile };
 }
 
-function runDeploy(fixture, { syncStatus = '0' } = {}) {
+function runDeploy(fixture, { syncStatus = '0', migrationStatus = '0', runtimeStatus = '0' } = {}) {
   return spawnSync('bash', [scriptPath], {
     cwd: fixture.root,
     encoding: 'utf8',
@@ -125,7 +220,9 @@ function runDeploy(fixture, { syncStatus = '0' } = {}) {
       MAILHUB_DEPLOY_GIT_URL: 'ssh://git.example.test/mailhub.git',
       MAILHUB_DEPLOY_TEST_LOG: fixture.logFile,
       MAILHUB_DEPLOY_TEST_REMOTE_DIR: fixture.remoteDir,
-      MAILHUB_DEPLOY_TEST_SYNC_STATUS: syncStatus
+      MAILHUB_DEPLOY_TEST_SYNC_STATUS: syncStatus,
+      MAILHUB_DEPLOY_TEST_MIGRATION_STATUS: migrationStatus,
+      MAILHUB_DEPLOY_TEST_RUNTIME_STATUS: runtimeStatus
     }
   });
 }

+ 259 - 0
test/dovecot-auth-server.test.js

@@ -0,0 +1,259 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync, writeFileSync } from 'node:fs';
+import http from 'node:http';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import { AuthenticationRateLimiter } from '../src/auth-rate-limit.js';
+import { createDovecotAuthServer } from '../src/dovecot-auth-server.js';
+
+const sharedSecret = 'mailhub-dovecot-auth-test-secret-0123456789';
+
+test('Dovecot authentication bridge requires a strong file-backed secret', () => {
+  assert.throws(
+    () => createDovecotAuthServer({ secret: sharedSecret }),
+    /secret file is required/
+  );
+  assert.throws(
+    () => createDovecotAuthServer({ secretFile: writeSecret('short') }),
+    /32-512 byte token/
+  );
+});
+
+test('Dovecot authentication bridge validates transport and returns fixed DTOs', async () => {
+  const verifierCalls = [];
+  const errors = [];
+  const server = createDovecotAuthServer({
+    secretFile: writeSecret(sharedSecret),
+    verifyCredential(username, password) {
+      verifierCalls.push({ username, password });
+      if (password === 'throw-error') throw new Error(`sensitive ${password}`);
+      if (password !== 'correct-password') return null;
+      return {
+        user: { id: 42, role: 'admin' },
+        mailbox: {
+          id: 7,
+          address: 'Alice@Example.com',
+          passwordHash: 'must-not-leak',
+          forwardTo: ['private@example.net']
+        }
+      };
+    },
+    logger: {
+      error(message) {
+        errors.push(message);
+      }
+    }
+  });
+  await listen(server);
+
+  try {
+    const unauthorized = await request(server, { secret: 'wrong-secret' });
+    assert.equal(unauthorized.status, 401);
+    assert.equal(unauthorized.headers['cache-control'], 'no-store');
+    assert.deepEqual(unauthorized.json, { error: 'Unauthorized.' });
+    assert.equal(verifierCalls.length, 0);
+
+    const wrongMethod = await request(server, { method: 'GET', body: undefined });
+    assert.equal(wrongMethod.status, 405);
+    assert.equal(wrongMethod.headers.allow, 'POST');
+
+    const wrongContentType = await request(server, { contentType: 'text/plain' });
+    assert.equal(wrongContentType.status, 415);
+
+    const invalidIp = await request(server, { body: authBody({ remoteIp: 'not-an-ip' }) });
+    assert.equal(invalidIp.status, 400);
+    assert.equal(verifierCalls.length, 0);
+
+    const malformed = await request(server, { rawBody: '{"username":' });
+    assert.equal(malformed.status, 400);
+    assert.equal(verifierCalls.length, 0);
+
+    const oversized = await request(server, {
+      body: authBody({ password: 'x'.repeat(9 * 1024) })
+    });
+    assert.equal(oversized.status, 413);
+    assert.equal(verifierCalls.length, 0);
+
+    const streamedOversized = await request(server, {
+      body: authBody({ password: 'x'.repeat(9 * 1024) }),
+      includeContentLength: false
+    });
+    assert.equal(streamedOversized.status, 413);
+    assert.equal(verifierCalls.length, 0);
+
+    const failed = await request(server, { body: authBody({ password: 'wrong-password' }) });
+    assert.equal(failed.status, 200);
+    assert.deepEqual(failed.json, { authenticated: false });
+    assert.equal(failed.headers['cache-control'], 'no-store');
+
+    const succeeded = await request(server, { body: authBody({ password: 'correct-password' }) });
+    assert.equal(succeeded.status, 200);
+    assert.deepEqual(succeeded.json, {
+      authenticated: true,
+      user: 'alice@example.com'
+    });
+    assert.equal(JSON.stringify(succeeded.json).includes('must-not-leak'), false);
+    assert.equal(JSON.stringify(succeeded.json).includes('private@example.net'), false);
+
+    const pop3Succeeded = await request(server, {
+      body: authBody({ password: 'correct-password', service: 'pop3' })
+    });
+    assert.equal(pop3Succeeded.status, 200);
+    assert.deepEqual(pop3Succeeded.json, {
+      authenticated: true,
+      user: 'alice@example.com'
+    });
+
+    const unavailable = await request(server, { body: authBody({ password: 'throw-error' }) });
+    assert.equal(unavailable.status, 503);
+    assert.deepEqual(unavailable.json, { error: 'Service unavailable.' });
+    assert.deepEqual(errors, ['Dovecot authentication bridge request failed.']);
+    assert.equal(errors.join(' ').includes('throw-error'), false);
+    assert.equal(errors.join(' ').includes(sharedSecret), false);
+  } finally {
+    await close(server);
+  }
+});
+
+test('Dovecot authentication bridge applies the shared limiter to the supplied remote IP', async () => {
+  let verifierCalls = 0;
+  const limiter = new AuthenticationRateLimiter({
+    combinationLimit: 1,
+    accountLimit: 10,
+    ipLimit: 10
+  });
+  const server = createDovecotAuthServer({
+    secretFile: writeSecret(sharedSecret),
+    authRateLimiter: limiter,
+    verifyCredential(_username, password) {
+      verifierCalls += 1;
+      return password === 'correct-password'
+        ? { mailbox: { address: 'user@example.com' } }
+        : null;
+    }
+  });
+  await listen(server);
+
+  try {
+    const failure = await request(server, {
+      body: authBody({ password: 'wrong-password', remoteIp: '203.0.113.10' })
+    });
+    assert.deepEqual(failure.json, { authenticated: false });
+
+    const blocked = await request(server, {
+      body: authBody({ password: 'correct-password', remoteIp: '203.0.113.10' })
+    });
+    assert.deepEqual(blocked.json, { authenticated: false });
+    assert.equal(verifierCalls, 1);
+
+    const otherIp = await request(server, {
+      body: authBody({ password: 'correct-password', remoteIp: '203.0.113.11' })
+    });
+    assert.deepEqual(otherIp.json, { authenticated: true, user: 'user@example.com' });
+    assert.equal(verifierCalls, 2);
+  } finally {
+    await close(server);
+  }
+});
+
+test('Dovecot authentication bridge rejects mailbox addresses that could escape a home path', async () => {
+  const unsafeAddresses = [
+    '../escape@example.com',
+    'escape\\child@example.com',
+    'nul\u0000byte@example.com',
+    ' leading@example.com',
+    'space user@example.com'
+  ];
+
+  for (const address of unsafeAddresses) {
+    const server = createDovecotAuthServer({
+      secretFile: writeSecret(sharedSecret),
+      verifyCredential() {
+        return { mailbox: { address } };
+      },
+      logger: { error() {} }
+    });
+    await listen(server);
+    try {
+      const response = await request(server, {
+        body: authBody({ password: 'correct-password' })
+      });
+      assert.equal(response.status, 503);
+      assert.deepEqual(response.json, { error: 'Service unavailable.' });
+    } finally {
+      await close(server);
+    }
+  }
+});
+
+function writeSecret(value) {
+  const directory = mkdtempSync(path.join(tmpdir(), 'mailhub-dovecot-auth-'));
+  const file = path.join(directory, 'secret');
+  writeFileSync(file, `${value}\n`, { mode: 0o600 });
+  return file;
+}
+
+function authBody(patch = {}) {
+  return {
+    username: 'alice@example.com',
+    password: 'wrong-password',
+    service: 'imap',
+    remoteIp: '203.0.113.10',
+    ...patch
+  };
+}
+
+function listen(server) {
+  server.listen(0, '127.0.0.1');
+  return new Promise((resolve, reject) => {
+    server.once('listening', resolve);
+    server.once('error', reject);
+  });
+}
+
+function close(server) {
+  return new Promise((resolve, reject) => {
+    server.close((error) => error ? reject(error) : resolve());
+  });
+}
+
+function request(server, {
+  method = 'POST',
+  requestPath = '/internal/dovecot/auth',
+  secret = sharedSecret,
+  contentType = 'application/json',
+  body = authBody(),
+  rawBody: suppliedRawBody,
+  includeContentLength = true
+} = {}) {
+  const rawBody = suppliedRawBody ?? (body === undefined ? '' : JSON.stringify(body));
+  return new Promise((resolve, reject) => {
+    const headers = {
+      Authorization: `Bearer ${secret}`,
+      'Content-Type': contentType
+    };
+    if (includeContentLength) headers['Content-Length'] = String(Buffer.byteLength(rawBody));
+    const req = http.request({
+      host: '127.0.0.1',
+      port: server.address().port,
+      path: requestPath,
+      method,
+      headers
+    }, (res) => {
+      const chunks = [];
+      res.on('data', (chunk) => chunks.push(chunk));
+      res.on('end', () => {
+        const raw = Buffer.concat(chunks).toString('utf8');
+        resolve({
+          status: res.statusCode,
+          headers: res.headers,
+          json: raw ? JSON.parse(raw) : null
+        });
+      });
+    });
+    req.once('error', reject);
+    req.end(rawBody);
+  });
+}

+ 83 - 0
test/dovecot-config.test.js

@@ -0,0 +1,83 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { test } from 'node:test';
+
+const compose = readFileSync(new URL('../docker-compose.yml', import.meta.url), 'utf8');
+const authConfig = readFileSync(new URL('../docker/dovecot/auth.conf', import.meta.url), 'utf8');
+const mailConfig = readFileSync(new URL('../docker/dovecot/mailhub.conf', import.meta.url), 'utf8');
+const sslConfig = readFileSync(new URL('../docker/dovecot/ssl.conf', import.meta.url), 'utf8');
+const authLua = readFileSync(new URL('../docker/dovecot/auth.lua', import.meta.url), 'utf8');
+
+test('Compose delegates public IMAP and POP3 ports to rootless Dovecot', () => {
+  assert.match(compose, /image: dovecot\/dovecot:2\.4\.4/);
+  for (const mapping of ['143:31143', '993:31993', '110:31110', '995:31995']) {
+    assert.ok(compose.includes(mapping), `missing Dovecot port mapping ${mapping}`);
+  }
+  for (const oldMapping of ['143:143', '993:993', '110:110', '995:995']) {
+    assert.equal(compose.includes(oldMapping), false, `app still owns ${oldMapping}`);
+  }
+  assert.match(compose, /dovecot_internal:\n\s+internal: true/);
+  assert.match(compose, /file: \.\/data\/secrets\/dovecot_auth_secret/);
+  assert.match(compose, /MAIL_ACCESS_BACKEND: dovecot/);
+  assert.match(compose, /MAILDIR_ROOT: \/data\/maildir/);
+  assert.match(compose, /test -r \/run\/secrets\/dovecot_auth_secret/);
+  assert.match(compose, /test -s \/run\/secrets\/dovecot_auth_secret/);
+  assert.match(compose, /test -w \/srv\/vmail/);
+  assert.match(compose, /doveadm service status imap-login pop3-login/);
+});
+
+test('Dovecot uses Lua passdb, a static rootless userdb, and Maildir storage', () => {
+  assert.match(authConfig, /passdb lua \{/);
+  assert.match(authConfig, /SUBMISSION_TLS_CERT = %\{env:SUBMISSION_TLS_CERT\}/);
+  assert.match(authConfig, /SUBMISSION_TLS_KEY = %\{env:SUBMISSION_TLS_KEY\}/);
+  assert.match(authConfig, /lua_file = \/etc\/dovecot\/auth\.lua/);
+  assert.match(authConfig, /userdb static \{/);
+  assert.match(authConfig, /userdb static \{[\s\S]*allow_all_users = yes/);
+  assert.match(authConfig, /uid = 1000/);
+  assert.match(authConfig, /gid = 1000/);
+  assert.match(authConfig, /home = \/srv\/vmail\/%\{user \| lower\}/);
+
+  assert.match(mailConfig, /^protocols = imap pop3$/m);
+  assert.match(mailConfig, /^mail_driver = maildir$/m);
+  assert.match(mailConfig, /^mail_path = ~\/mail$/m);
+  assert.match(mailConfig, /^mailbox_list_layout = maildir\+\+$/m);
+  assert.match(mailConfig, /^mailbox_list_storage_escape_char = \^$/m);
+  assert.match(mailConfig, /^mailbox_list_utf8 = no$/m);
+  for (const [mailbox, specialUse] of [
+    ['Archive', 'Archive'],
+    ['Drafts', 'Drafts'],
+    ['Junk', 'Junk'],
+    ['Sent', 'Sent'],
+    ['Trash', 'Trash']
+  ]) {
+    assert.match(
+      mailConfig,
+      new RegExp(`mailbox ${mailbox} \\{[\\s\\S]*?auto = subscribe[\\s\\S]*?special_use = \\\\${specialUse}`)
+    );
+  }
+  assert.match(mailConfig, /service imap-login \{[\s\S]*chroot =/);
+  assert.match(mailConfig, /service imap-login \{[\s\S]*inet_listener imaps \{[\s\S]*ssl = yes/);
+  assert.match(mailConfig, /service pop3-login \{[\s\S]*inet_listener pop3s \{[\s\S]*ssl = yes/);
+  assert.match(sslConfig, /^ssl_server_cert_file = \$ENV:SUBMISSION_TLS_CERT$/m);
+  assert.match(sslConfig, /^ssl_server_key_file = \$ENV:SUBMISSION_TLS_KEY$/m);
+});
+
+test('Lua passdb sends both IMAP and POP3 to the private auth bridge', () => {
+  assert.match(authLua, /http:\/\/app:3001\/internal\/dovecot\/auth/);
+  assert.match(authLua, /\/run\/secrets\/dovecot_auth_secret/);
+  assert.match(authLua, /first_nonempty_string\(request\.protocol, request\.service\)/);
+  assert.match(authLua, /request\.remote_ip,[\s\S]*request\.real_remote_ip/);
+  assert.match(authLua, /protocol ~= "imap" and protocol ~= "pop3"/);
+  assert.match(authLua, /request_max_attempts = 1/);
+  assert.match(authLua, /auto_retry = "no"/);
+  assert.match(authLua, /request_absolute_timeout = "2s"/);
+  assert.match(authLua, /status ~= 200[\s\S]*PASSDB_RESULT_INTERNAL_FAILURE/);
+  assert.doesNotMatch(authLua, /status == (?:401|403|404)/);
+  assert.match(
+    authLua,
+    /payload\.authenticated == false[\s\S]*PASSDB_RESULT_PASSWORD_MISMATCH[\s\S]*payload\.authenticated ~= true[\s\S]*PASSDB_RESULT_INTERNAL_FAILURE/
+  );
+  assert.match(authLua, /valid_user\(payload\.user\)/);
+  assert.match(authLua, /PASSDB_RESULT_OK, \{ user = string\.lower\(payload\.user\) \}/);
+  assert.doesNotMatch(authLua, /log_(?:debug|info|warning|error).*password/i);
+});

+ 26 - 1
test/mail-access.test.js

@@ -120,8 +120,17 @@ test('IMAP exposes imported Maildir flags and Dovecot keywords', async () => {
 
 test('IMAP SEARCH filters seen state and rejects invalid contexts or criteria', async () => {
   initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-search-test-')), 'mail-access-secret');
+  const { mailbox: offsetMailbox } = createMailboxFixture('search-offset.example', 'search-offset-user');
+  createInboundMessage(offsetMailbox, {
+    sender: 'offset@example.net',
+    recipients: ['admin@search-offset.example'],
+    subject: 'UID offset',
+    messageId: '<offset@search-offset.example>',
+    rawMessage: 'From: offset@example.net\r\nTo: admin@search-offset.example\r\nSubject: UID offset\r\n\r\nOffset body.',
+    textBody: 'Offset body.'
+  });
   const { mailbox } = createMailboxFixture('search.example', 'search-user');
-  createImportedInboundMessage(mailbox, {
+  const { message: seenMessage } = createImportedInboundMessage(mailbox, {
     importSource: 'imap-search-test',
     sourceKey: 'seen-message',
     sender: 'seen@example.net',
@@ -167,6 +176,14 @@ test('IMAP SEARCH filters seen state and rejects invalid contexts or criteria',
     const allUnseen = await client.command('A8 SEARCH ALL UNSEEN', /A8 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
     const uidUnseen = await client.command('A9 UID SEARCH UNSEEN', /A9 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
     const uidCharsetUnseen = await client.command('A10 UID SEARCH CHARSET UTF-8 UNSEEN', /A10 (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const sequenceNumber = await client.command('A10A SEARCH 2', /A10A (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const sequenceRangeUnseen = await client.command('A10B SEARCH 1:* UNSEEN', /A10B (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const uidSequenceNumber = await client.command('A10C UID SEARCH 2', /A10C (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const uidSequenceOutOfRange = await client.command('A10D UID SEARCH 16', /A10D (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const uidRangeUnseen = await client.command('A10E UID SEARCH 1:2 UNSEEN', /A10E (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const uidList = await client.command('A10F UID SEARCH 1,2', /A10F (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const uidCriterion = await client.command(`A10G UID SEARCH UID ${unseenMessage.id}`, /A10G (?:OK|NO|BAD)[^\r\n]*\r\n$/);
+    const sequenceUidCriterion = await client.command(`A10H SEARCH UID ${unseenMessage.id}`, /A10H (?:OK|NO|BAD)[^\r\n]*\r\n$/);
 
     const stored = await client.command(
       `A11 UID STORE ${unseenMessage.id} +FLAGS.SILENT (\\Seen)`,
@@ -189,6 +206,14 @@ test('IMAP SEARCH filters seen state and rejects invalid contexts or criteria',
     assertImapSearchResult(allUnseen, [2]);
     assertImapSearchResult(uidUnseen, [unseenMessage.id]);
     assertImapSearchResult(uidCharsetUnseen, [unseenMessage.id]);
+    assertImapSearchResult(sequenceNumber, [2]);
+    assertImapSearchResult(sequenceRangeUnseen, [2]);
+    assertImapSearchResult(uidSequenceNumber, [unseenMessage.id]);
+    assertImapSearchResult(uidSequenceOutOfRange, []);
+    assertImapSearchResult(uidRangeUnseen, [unseenMessage.id]);
+    assertImapSearchResult(uidList, [seenMessage.id, unseenMessage.id]);
+    assertImapSearchResult(uidCriterion, [unseenMessage.id]);
+    assertImapSearchResult(sequenceUidCriterion, [2]);
     assert.match(stored, /^A11 OK STORE completed\r?$/m);
     assert.doesNotMatch(stored, /^\* \d+ FETCH/m);
     assertImapSearchResult(unseenAfterStore, []);

+ 2 - 2
test/mail-auth-rate-limit.test.js

@@ -77,7 +77,7 @@ test('IMAP, POP3 and SMTP share generic authentication throttling by IP and acco
     const smtp = await connectClient(smtpServer.address().port);
     clients.push(smtp);
     await smtp.readUntil(/^220 .* ready\r\n/m);
-    await smtp.command('EHLO client.example', /250 SMTPUTF8\r\n/);
+    await smtp.command('EHLO client.example', /250 HELP\r\n/);
     const wrongAuth = Buffer.from(`\u0000${mailbox.address}\u0000wrong-smtp`).toString('base64');
     assert.match(
       await smtp.command(`AUTH PLAIN ${wrongAuth}`, /535 /),
@@ -87,7 +87,7 @@ test('IMAP, POP3 and SMTP share generic authentication throttling by IP and acco
     const blocked = await connectClient(smtpServer.address().port);
     clients.push(blocked);
     await blocked.readUntil(/^220 .* ready\r\n/m);
-    await blocked.command('EHLO client.example', /250 SMTPUTF8\r\n/);
+    await blocked.command('EHLO client.example', /250 HELP\r\n/);
     const correctAuth = Buffer.from(`\u0000${mailbox.address}\u0000correct-password`).toString('base64');
     assert.match(
       await blocked.command(`AUTH PLAIN ${correctAuth}`, /535 /),

+ 166 - 0
test/maildir-store.test.js

@@ -0,0 +1,166 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync } from 'node:fs';
+import { mkdir, readFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+  listMaildirFolders,
+  maildirFolderPath,
+  maildirHomePath,
+  readMaildirMessage,
+  scanMaildirMailbox,
+  setMaildirMessageSeen,
+  sqliteMaildirStorageKey,
+  writeMaildirMessage
+} from '../src/maildir-store.js';
+
+test('Maildir store writes original bytes atomically and exposes folder flags', async () => {
+  const root = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-store-'));
+  const address = 'User@Example.com';
+  const rawMessageBytes = Buffer.from([
+    ...Buffer.from('From: sender@example.net\r\nSubject: Latin1\r\n\r\n', 'ascii'),
+    0x48, 0xe9, 0x6c, 0x6c, 0x6f, 0x0d, 0x0a
+  ]);
+  const storage = await writeMaildirMessage({
+    root,
+    address,
+    rawMessageBytes,
+    folder: '归档/2026',
+    flags: ['\\Flagged'],
+    keywords: ['$Label1'],
+    read: false,
+    receivedAt: '2026-07-16T12:00:00.000Z',
+    storageKey: 'mhdb-42'
+  });
+
+  assert.equal(storage.backend, 'maildir');
+  assert.equal(storage.key, 'mhdb-42');
+  assert.match(storage.relpath, /^mail\/\./);
+  assert.match(storage.relpath, /\/cur\//);
+  assert.equal(storage.size, rawMessageBytes.length);
+  assert.equal(maildirHomePath(root, address), path.join(root, 'user@example.com'));
+  assert.match(maildirFolderPath(root, address, '归档/2026'), /mail\/\.&/);
+
+  const [entry] = await scanMaildirMailbox({ root, address });
+  assert.equal(entry.storageKey, 'mhdb-42');
+  assert.equal(entry.folder, '归档/2026');
+  assert.deepEqual(entry.flags, ['\\Flagged']);
+  assert.deepEqual(entry.keywords, ['$Label1']);
+  assert.equal(entry.read, false);
+  const stored = await readMaildirMessage(entry);
+  assert.deepEqual(stored.bytes, rawMessageBytes);
+  assert.deepEqual(await readFile(entry.filePath), rawMessageBytes);
+
+  const updated = await setMaildirMessageSeen({
+    root,
+    address,
+    storageKey: storage.key,
+    relpath: storage.relpath,
+    seen: true
+  });
+  assert.match(updated.relpath, /\/cur\//);
+  assert.match(updated.relpath, /:2,FSa$/);
+  const [seenEntry] = await scanMaildirMailbox({ root, address });
+  assert.deepEqual(seenEntry.flags, ['\\Flagged', '\\Seen']);
+  assert.deepEqual(seenEntry.keywords, ['$Label1']);
+  assert.equal(seenEntry.read, true);
+});
+
+test('Maildir store rejects unsafe addresses and never overwrites a storage key', async () => {
+  const root = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-safety-'));
+  assert.throws(() => maildirHomePath(root, '../escape@example.com'), /地址不正确/);
+  assert.equal(sqliteMaildirStorageKey(123), 'mhdb-123');
+  assert.throws(() => sqliteMaildirStorageKey(0), /ID 不正确/);
+
+  const first = Buffer.from('Subject: first\r\n\r\nfirst');
+  const storage = await writeMaildirMessage({
+    root,
+    address: 'safe@example.com',
+    rawMessageBytes: first,
+    storageKey: 'mhdb-1'
+  });
+  assert.match(storage.relpath, /\/new\//);
+  assert.doesNotMatch(storage.relpath, /:2,/);
+  await assert.rejects(
+    writeMaildirMessage({
+      root,
+      address: 'safe@example.com',
+      rawMessageBytes: Buffer.from('Subject: second\r\n\r\nsecond'),
+      storageKey: 'mhdb-1'
+    }),
+    /存储标识冲突/
+  );
+  const [entry] = await scanMaildirMailbox({ root, address: 'safe@example.com' });
+  assert.deepEqual((await readMaildirMessage(entry)).bytes, first);
+});
+
+test('Maildir keyword letters never become uppercase system flags', async () => {
+  const root = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-keywords-'));
+  const keywords = ['$Label1', '$Label2', '$Label3', '$Label4'];
+  await writeMaildirMessage({
+    root,
+    address: 'keywords@example.com',
+    rawMessageBytes: Buffer.from('Subject: keyword flags\r\n\r\nBody'),
+    keywords,
+    storageKey: 'mhdb-7'
+  });
+
+  const [entry] = await scanMaildirMailbox({ root, address: 'keywords@example.com' });
+  assert.deepEqual(entry.flags, []);
+  assert.deepEqual(entry.keywords, keywords);
+});
+
+test('Maildir folder encoding keeps literal dots distinct from hierarchy separators', async () => {
+  const root = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-folder-dots-'));
+  const address = 'folders@example.com';
+  const dottedPath = maildirFolderPath(root, address, 'team.ops');
+  const nestedPath = maildirFolderPath(root, address, 'team/ops');
+  assert.notEqual(dottedPath, nestedPath);
+  assert.match(dottedPath, /\^2e/);
+  assert.notEqual(
+    maildirFolderPath(root, address, 'team^2eops'),
+    dottedPath
+  );
+  assert.match(maildirFolderPath(root, address, '~archive'), /\.\^7earchive$/);
+
+  await writeMaildirMessage({
+    root,
+    address,
+    folder: 'team.ops',
+    rawMessageBytes: Buffer.from('Subject: dotted\r\n\r\nDotted'),
+    storageKey: 'mhdb-8'
+  });
+  await writeMaildirMessage({
+    root,
+    address,
+    folder: 'team/ops',
+    rawMessageBytes: Buffer.from('Subject: nested\r\n\r\nNested'),
+    storageKey: 'mhdb-9'
+  });
+
+  const entries = await scanMaildirMailbox({ root, address });
+  assert.deepEqual(entries.map((entry) => entry.folder).sort(), ['team.ops', 'team/ops']);
+});
+
+test('Maildir folder inventory ignores Dovecot internal and incomplete dot directories', async () => {
+  const root = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-folder-inventory-'));
+  const address = 'inventory@example.com';
+  await writeMaildirMessage({
+    root,
+    address,
+    folder: 'Projects',
+    rawMessageBytes: Buffer.from('Subject: project\r\n\r\nBody'),
+    storageKey: 'mhdb-10'
+  });
+
+  const mailRoot = path.join(maildirHomePath(root, address), 'mail');
+  await mkdir(path.join(mailRoot, '.dovecot-internal'), { recursive: true });
+  await mkdir(path.join(mailRoot, '.incomplete', 'cur'), { recursive: true });
+
+  assert.deepEqual(
+    (await listMaildirFolders({ root, address })).sort(),
+    ['INBOX', 'Projects']
+  );
+});

+ 320 - 0
test/maildir-sync.test.js

@@ -0,0 +1,320 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync } from 'node:fs';
+import { mkdir, rename, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+  commitInboundMaildirMigrationStaging,
+  createDomain,
+  createInboundMailbox,
+  createInboundMessage,
+  createUser,
+  createWebhook,
+  getInboundMessage,
+  initDatabase,
+  listInboundMailboxFolders,
+  listInboundMaildirIndex,
+  listInboundMaildirMigrationStaging,
+  listInboundMessages,
+  listWebhookDeliveries,
+  stageInboundMessageMaildirStorageBatch
+} from '../src/db.js';
+import {
+  ensureMaildirMailbox,
+  maildirFolderPath,
+  scanMaildirMailbox,
+  writeMaildirMessage
+} from '../src/maildir-store.js';
+import {
+  migrateInboundMessagesToMaildir,
+  reconcileMaildirMailbox
+} from '../src/maildir-sync.js';
+
+test('SQLite migration is resumable and Maildir changes reconcile into the management index', async () => {
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-sync-db-'));
+  const maildirRoot = path.join(dataDir, 'maildir');
+  initDatabase(dataDir, 'maildir-sync-secret');
+  const user = createUser({
+    username: 'maildir-sync-user',
+    email: 'maildir-sync-user@example.com',
+    password: 'password123'
+  });
+  createDomain(user.id, {
+    domain: 'maildir-sync.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.maildir-sync.example',
+    sendingIp: '192.0.2.70',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const mailbox = createInboundMailbox(user.id, {
+    address: 'box@maildir-sync.example',
+    password: 'mailbox-pass-123'
+  });
+  const originalRaw = 'From: alice@example.net\r\nSubject: Existing message\r\n\r\nBody';
+  const original = createInboundMessage(mailbox, {
+    sender: 'alice@example.net',
+    recipients: [mailbox.address],
+    subject: 'Existing message',
+    rawMessage: originalRaw,
+    textBody: 'Body'
+  });
+
+  const interruptedStorage = await writeMaildirMessage({
+    root: maildirRoot,
+    address: mailbox.address,
+    rawMessageBytes: Buffer.from(originalRaw, 'utf8'),
+    storageKey: `mhdb-${original.id}`,
+    durable: false
+  });
+  stageInboundMessageMaildirStorageBatch([{ id: original.id, storage: interruptedStorage }]);
+
+  const first = await migrateInboundMessagesToMaildir({ root: maildirRoot });
+  assert.deepEqual(first, { processed: 1, written: 1, reused: 0, lastId: original.id });
+  const migratedFiles = await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address });
+  assert.equal(migratedFiles.length, 1);
+  assert.equal(migratedFiles[0].storageKey, `mhdb-${original.id}`);
+  assert.equal(listInboundMaildirIndex(mailbox.id)[0].storageKey, `mhdb-${original.id}`);
+
+  const second = await migrateInboundMessagesToMaildir({ root: maildirRoot });
+  assert.deepEqual(second, { processed: 0, written: 0, reused: 0, lastId: 0 });
+  assert.equal((await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address })).length, 1);
+
+  const archiveCur = path.join(maildirFolderPath(maildirRoot, mailbox.address, 'Archive'), 'cur');
+  await mkdir(archiveCur, { recursive: true });
+  const archivedPath = path.join(
+    archiveCur,
+    `${migratedFiles[0].baseName}:2,S`
+  );
+  await rename(migratedFiles[0].filePath, archivedPath);
+  const missingCounts = new Map();
+  const moved = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
+  assert.equal(moved.updated, 1);
+  assert.equal(getInboundMessage(user.id, original.id).folder, 'Archive');
+  assert.equal(getInboundMessage(user.id, original.id).read, true);
+
+  await writeMaildirMessage({
+    root: maildirRoot,
+    address: mailbox.address,
+    rawMessageBytes: Buffer.from('From: bob@example.net\r\nSubject: Appended message\r\n\r\nNew body'),
+    folder: 'Sent',
+    read: true,
+    storageKey: 'mhappend-client-1'
+  });
+  const appended = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
+  assert.equal(appended.created, 1);
+  const messages = listInboundMessages(user.id, { folder: 'Sent' });
+  assert.equal(messages.length, 1);
+  assert.equal(messages[0].subject, 'Appended message');
+
+  await ensureMaildirMailbox(maildirRoot, mailbox.address, ['team.ops']);
+  const folderAdded = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
+  assert.equal(folderAdded.foldersCreatedOrRestored, 1);
+  assert.equal(
+    listInboundMailboxFolders(user.id, mailbox.id).some((folder) => folder.name === 'team.ops'),
+    true
+  );
+  await rm(maildirFolderPath(maildirRoot, mailbox.address, 'team.ops'), { recursive: true });
+  const folderRemoved = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
+  assert.equal(folderRemoved.foldersDeleted, 1);
+  assert.equal(
+    listInboundMailboxFolders(user.id, mailbox.id).some((folder) => folder.name === 'team.ops'),
+    false
+  );
+
+  const archiveEntry = (await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address }))
+    .find((entry) => entry.storageKey === `mhdb-${original.id}`);
+  await rm(archiveEntry.filePath);
+  const firstMissing = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
+  assert.equal(firstMissing.deleted, 0);
+  const secondMissing = await reconcileMaildirMailbox({ root: maildirRoot, mailbox, missingCounts });
+  assert.equal(secondMissing.deleted, 1);
+  assert.equal(getInboundMessage(user.id, original.id), null);
+});
+
+test('SQLite migration stops when a resumable Maildir key contains different bytes', async () => {
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-conflict-db-'));
+  const maildirRoot = path.join(dataDir, 'maildir');
+  initDatabase(dataDir, 'maildir-conflict-secret');
+  const user = createUser({
+    username: 'maildir-conflict-user',
+    email: 'maildir-conflict-user@example.com',
+    password: 'password123'
+  });
+  createDomain(user.id, {
+    domain: 'maildir-conflict.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.maildir-conflict.example',
+    sendingIp: '192.0.2.71',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const mailbox = createInboundMailbox(user.id, {
+    address: 'box@maildir-conflict.example',
+    password: 'mailbox-pass-123'
+  });
+  const preparedBeforeFailure = createInboundMessage(mailbox, {
+    sender: 'first@example.net',
+    recipients: [mailbox.address],
+    subject: 'Prepared before conflict',
+    rawMessage: 'From: first@example.net\r\nSubject: Prepared before conflict\r\n\r\nFirst'
+  });
+  const original = createInboundMessage(mailbox, {
+    sender: 'alice@example.net',
+    recipients: [mailbox.address],
+    subject: 'Original message',
+    rawMessage: 'From: alice@example.net\r\nSubject: Original message\r\n\r\nOriginal'
+  });
+  await writeMaildirMessage({
+    root: maildirRoot,
+    address: mailbox.address,
+    rawMessageBytes: Buffer.from('From: mallory@example.net\r\nSubject: Conflict\r\n\r\nConflict'),
+    storageKey: `mhdb-${original.id}`
+  });
+
+  await assert.rejects(
+    migrateInboundMessagesToMaildir({ root: maildirRoot }),
+    /存储标识已被其他内容占用/
+  );
+  assert.equal(listInboundMaildirIndex(mailbox.id).length, 0);
+  const remaining = await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address });
+  assert.equal(remaining.some((entry) => entry.storageKey === `mhdb-${preparedBeforeFailure.id}`), false);
+  assert.equal(remaining.some((entry) => entry.storageKey === `mhdb-${original.id}`), true);
+});
+
+test('Maildir cutover rejects a staging count mismatch before changing the message index', () => {
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-count-db-'));
+  initDatabase(dataDir, 'maildir-count-secret');
+  const user = createUser({
+    username: 'maildir-count-user',
+    email: 'maildir-count-user@example.com',
+    password: 'password123'
+  });
+  createDomain(user.id, {
+    domain: 'maildir-count.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.maildir-count.example',
+    sendingIp: '192.0.2.72',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const mailbox = createInboundMailbox(user.id, {
+    address: 'box@maildir-count.example',
+    password: 'mailbox-pass-123'
+  });
+  const message = createInboundMessage(mailbox, {
+    sender: 'alice@example.net',
+    recipients: [mailbox.address],
+    subject: 'Count guard',
+    rawMessage: 'From: alice@example.net\r\nSubject: Count guard\r\n\r\nBody'
+  });
+  stageInboundMessageMaildirStorageBatch([{
+    id: message.id,
+    storage: {
+      backend: 'maildir',
+      key: `mhdb-${message.id}`,
+      relpath: `mail/new/mhdb-${message.id}.mailhub`,
+      sha256: 'a'.repeat(64),
+      size: 4,
+      mtimeMs: Date.now(),
+      indexedAt: new Date().toISOString()
+    }
+  }]);
+
+  assert.throws(
+    () => commitInboundMaildirMigrationStaging(2),
+    /Maildir 迁移暂存数量不一致/
+  );
+  assert.equal(listInboundMaildirIndex(mailbox.id).length, 0);
+  assert.equal(listInboundMaildirMigrationStaging().length, 1);
+});
+
+test('Maildir reconciliation restores a missing SMTP index and queues its receipt webhook once', async () => {
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-webhook-db-'));
+  const maildirRoot = path.join(dataDir, 'maildir');
+  const database = initDatabase(dataDir, 'maildir-webhook-secret');
+  const user = createUser({
+    username: 'maildir-webhook-user',
+    email: 'maildir-webhook-user@example.com',
+    password: 'password123'
+  });
+  createDomain(user.id, {
+    domain: 'maildir-webhook.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.maildir-webhook.example',
+    sendingIp: '192.0.2.73',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const mailbox = createInboundMailbox(user.id, {
+    address: 'box@maildir-webhook.example',
+    password: 'mailbox-pass-123'
+  });
+  createWebhook(user.id, {
+    name: 'Recovered SMTP receipt',
+    url: 'https://hooks.example.com/recovered-smtp',
+    events: ['received'],
+    mailboxId: mailbox.id
+  });
+  await writeMaildirMessage({
+    root: maildirRoot,
+    address: mailbox.address,
+    rawMessageBytes: Buffer.from([
+      'Message-ID: <recovered-smtp@example.net>',
+      'From: sender@example.net',
+      `To: ${mailbox.address}`,
+      'Subject: Recovered SMTP receipt',
+      '',
+      'Recovered body'
+    ].join('\r\n')),
+    storageKey: 'mhsmtp-recovered-1'
+  });
+
+  database.exec(`
+    CREATE TRIGGER reject_recovered_smtp_webhook
+    BEFORE INSERT ON webhook_deliveries
+    WHEN NEW.inbound_message_id IS NOT NULL
+    BEGIN
+      SELECT RAISE(ABORT, 'forced webhook failure');
+    END;
+  `);
+  await assert.rejects(
+    reconcileMaildirMailbox({ root: maildirRoot, mailbox }),
+    /forced webhook failure/
+  );
+  assert.equal(listInboundMessages(user.id, { mailboxId: mailbox.id }).length, 0);
+  assert.equal(listWebhookDeliveries(user.id, { eventType: 'received' }).length, 0);
+  database.exec('DROP TRIGGER reject_recovered_smtp_webhook;');
+
+  const first = await reconcileMaildirMailbox({ root: maildirRoot, mailbox });
+  assert.equal(first.created, 1);
+  const [message] = listInboundMessages(user.id, { mailboxId: mailbox.id });
+  assert.equal(message.subject, 'Recovered SMTP receipt');
+  let deliveries = listWebhookDeliveries(user.id, { eventType: 'received' });
+  assert.equal(deliveries.length, 1);
+  assert.equal(deliveries[0].inboundMessageId, message.id);
+  assert.equal(JSON.parse(deliveries[0].payloadJson).type, 'email.received');
+
+  const second = await reconcileMaildirMailbox({ root: maildirRoot, mailbox });
+  assert.equal(second.created, 0);
+  deliveries = listWebhookDeliveries(user.id, { eventType: 'received' });
+  assert.equal(deliveries.length, 1);
+});

+ 35 - 0
test/prepare-dovecot-script.test.js

@@ -0,0 +1,35 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync, statSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { test } from 'node:test';
+
+test('Dovecot preparation creates a private secret and Maildir root', () => {
+  const projectDir = mkdtempSync(path.join(tmpdir(), 'mailhub-dovecot-prepare-'));
+  const result = spawnSync('bash', ['scripts/prepare-dovecot.sh'], {
+    cwd: path.resolve(import.meta.dirname, '..'),
+    env: {
+      ...process.env,
+      MAILHUB_PROJECT_DIR: projectDir
+    },
+    encoding: 'utf8'
+  });
+  const unsupportedLinuxUid = process.platform === 'linux'
+    && typeof process.getuid === 'function'
+    && ![0, 1000].includes(process.getuid());
+  if (unsupportedLinuxUid) {
+    assert.notEqual(result.status, 0);
+    assert.match(result.stderr, /must run as root or host uid 1000/);
+    return;
+  }
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+
+  const secretPath = path.join(projectDir, 'data', 'secrets', 'dovecot_auth_secret');
+  const maildirRoot = path.join(projectDir, 'data', 'maildir');
+  const secretStat = statSync(secretPath);
+  assert.equal(secretStat.isFile(), true);
+  assert.equal(secretStat.mode & 0o777, 0o400);
+  assert.equal(statSync(maildirRoot).isDirectory(), true);
+  assert.equal(statSync(maildirRoot).mode & 0o777, 0o700);
+});

+ 20 - 6
test/submission-inbound.test.js

@@ -16,10 +16,13 @@ import {
   updateDomain
 } from '../src/db.js';
 import { sendViaSmtp } from '../src/mailer.js';
+import { readMaildirMessage, scanMaildirMailbox } from '../src/maildir-store.js';
 import { startSubmissionServer } from '../src/submission.js';
 
 test('SMTP accepts unauthenticated inbound mail for local mailboxes', async () => {
-  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-')), 'inbound-secret');
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-'));
+  const maildirRoot = path.join(dataDir, 'maildir');
+  initDatabase(dataDir, 'inbound-secret');
   const user = createUser({ username: 'inbound-smtp', email: 'inbound-smtp@example.com', password: 'password123' });
   createDomain(user.id, {
     domain: 'inbound.example',
@@ -33,13 +36,15 @@ test('SMTP accepts unauthenticated inbound mail for local mailboxes', async () =
     dmarcPolicy: 'none',
     dmarcRua: ''
   });
-  createInboundMailbox(user.id, { address: 'support@inbound.example', displayName: 'Support' });
+  const mailbox = createInboundMailbox(user.id, { address: 'support@inbound.example', displayName: 'Support' });
   const [server] = startSubmissionServer({
     enabled: true,
     listeners: [{ port: 0, protocol: 'smtp' }],
     hostname: 'mx.inbound.example',
     allowInsecureAuth: true,
     inboundEnabled: true,
+    maildirEnabled: true,
+    maildirRoot,
     relayHost: '',
     relayPort: 25,
     relaySecure: false,
@@ -50,16 +55,19 @@ test('SMTP accepts unauthenticated inbound mail for local mailboxes', async () =
   await waitForListening(server);
 
   try {
-    const rawMessage = [
+    const rawHeaders = [
       'From: Alice <alice@example.net>',
       'To: Support <support@inbound.example>',
       'Subject: Hello inbound SMTP',
       'Message-ID: <hello-inbound@example.net>',
-      'Content-Type: text/plain; charset=UTF-8',
+      'Content-Type: text/plain; charset=ISO-8859-1',
       '',
-      'Hello through SMTP.',
       ''
     ].join('\r\n');
+    const rawMessage = Buffer.concat([
+      Buffer.from(rawHeaders, 'ascii'),
+      Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x63, 0x61, 0x66, 0xe9, 0x2e, 0x0d, 0x0a])
+    ]);
     const response = await sendViaSmtp({
       host: '127.0.0.1',
       port: server.address().port,
@@ -77,7 +85,13 @@ test('SMTP accepts unauthenticated inbound mail for local mailboxes', async () =
     assert.equal(message.sender, 'alice@example.net');
     assert.deepEqual(message.recipients, ['support@inbound.example']);
     assert.equal(message.subject, 'Hello inbound SMTP');
-    assert.equal(message.preview, 'Hello through SMTP.');
+    assert.equal(message.preview, 'Hello café.');
+    const [maildirMessage] = await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address });
+    assert.ok(maildirMessage.storageKey.startsWith('mhsmtp-'));
+    assert.equal(maildirMessage.folder, 'INBOX');
+    const maildirBytes = (await readMaildirMessage(maildirMessage)).bytes;
+    assert.equal(maildirBytes.includes(Buffer.from([0x63, 0x61, 0x66, 0xe9, 0x2e])), true);
+    assert.equal(maildirBytes.includes(Buffer.from('café', 'utf8')), false);
   } finally {
     await closeServer(server);
   }

+ 157 - 0
test/submission-tls-sniff.test.js

@@ -6,6 +6,13 @@ import path from 'node:path';
 import tls from 'node:tls';
 import { test } from 'node:test';
 
+import {
+  createDomain,
+  createInboundMailbox,
+  createUser,
+  initDatabase
+} from '../src/db.js';
+import { readMaildirMessage, scanMaildirMailbox } from '../src/maildir-store.js';
 import { resolveSubmissionTracking, startSubmissionServer } from '../src/submission.js';
 
 const testCert = `-----BEGIN CERTIFICATE-----
@@ -85,6 +92,72 @@ test('smtp listeners accept both plain SMTP and implicit TLS clients', async ()
   }
 });
 
+test('SMTP STARTTLS preserves non-UTF-8 inbound message bytes', async () => {
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-submission-starttls-'));
+  const maildirRoot = path.join(dataDir, 'maildir');
+  const certPath = path.join(dataDir, 'cert.pem');
+  const keyPath = path.join(dataDir, 'key.pem');
+  writeFileSync(certPath, testCert);
+  writeFileSync(keyPath, testKey);
+  initDatabase(dataDir, 'submission-starttls-secret');
+  const user = createUser({
+    username: 'submission-starttls',
+    email: 'submission-starttls@example.com',
+    password: 'password123'
+  });
+  createDomain(user.id, {
+    domain: 'starttls.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.starttls.example',
+    sendingIp: '192.0.2.72',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const mailbox = createInboundMailbox(user.id, {
+    address: 'inbox@starttls.example',
+    password: 'mailbox-password'
+  });
+  const [server] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'localhost',
+    allowInsecureAuth: false,
+    inboundEnabled: true,
+    maildirEnabled: true,
+    maildirRoot,
+    tlsCertPath: certPath,
+    tlsKeyPath: keyPath
+  });
+  await waitForListening(server);
+
+  const rawMessage = Buffer.concat([
+    Buffer.from([
+      'From: sender@example.net',
+      `To: ${mailbox.address}`,
+      'Subject: STARTTLS bytes',
+      'Content-Type: text/plain; charset=ISO-8859-1',
+      '',
+      'caf'
+    ].join('\r\n'), 'ascii'),
+    Buffer.from([0xe9, 0x0d, 0x0a])
+  ]);
+
+  try {
+    await sendStartTlsMessage(server.address().port, mailbox.address, rawMessage);
+    const [entry] = await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address });
+    assert.ok(entry);
+    const stored = (await readMaildirMessage(entry)).bytes;
+    assert.equal(stored.includes(Buffer.from([0x63, 0x61, 0x66, 0xe9])), true);
+    assert.equal(stored.includes(Buffer.from('café', 'utf8')), false);
+  } finally {
+    await closeServer(server);
+  }
+});
+
 test('submission tracking honors defaults and explicit control headers', () => {
   assert.deepEqual(resolveSubmissionTracking('Subject: A\r\n\r\nBody', false), {
     enabled: false,
@@ -155,3 +228,87 @@ function readImplicitTlsBanner(port) {
     socket.once('timeout', () => reject(new Error('implicit TLS SMTP banner timed out')));
   });
 }
+
+async function sendStartTlsMessage(port, recipient, rawMessage) {
+  const plainSocket = net.createConnection({ host: '127.0.0.1', port });
+  plainSocket.setTimeout(3000);
+  await waitForSocketEvent(plainSocket, 'connect');
+  await readSmtpResponse(plainSocket, 220);
+  await smtpCommand(plainSocket, 'EHLO sender.example.net', 250);
+  await smtpCommand(plainSocket, 'STARTTLS', 220);
+
+  const secureSocket = tls.connect({
+    socket: plainSocket,
+    rejectUnauthorized: false,
+    servername: 'localhost'
+  });
+  secureSocket.setTimeout(3000);
+  await waitForSocketEvent(secureSocket, 'secureConnect');
+  try {
+    await smtpCommand(secureSocket, 'EHLO sender.example.net', 250);
+    await smtpCommand(secureSocket, 'MAIL FROM:<sender@example.net>', 250);
+    await smtpCommand(secureSocket, `RCPT TO:<${recipient}>`, 250);
+    await smtpCommand(secureSocket, 'DATA', 354);
+    secureSocket.write(rawMessage);
+    if (!rawMessage.subarray(-2).equals(Buffer.from('\r\n'))) secureSocket.write('\r\n');
+    secureSocket.write('.\r\n');
+    await readSmtpResponse(secureSocket, 250);
+    await smtpCommand(secureSocket, 'QUIT', 221);
+  } finally {
+    secureSocket.destroy();
+  }
+}
+
+async function smtpCommand(socket, command, expectedCode) {
+  socket.write(`${command}\r\n`);
+  return readSmtpResponse(socket, expectedCode);
+}
+
+function readSmtpResponse(socket, expectedCode) {
+  return new Promise((resolve, reject) => {
+    let buffer = '';
+    const cleanup = () => {
+      socket.off('data', onData);
+      socket.off('error', onError);
+      socket.off('timeout', onTimeout);
+    };
+    const onError = (error) => {
+      cleanup();
+      reject(error);
+    };
+    const onTimeout = () => onError(new Error('SMTP response timed out'));
+    const onData = (chunk) => {
+      buffer += Buffer.from(chunk).toString('latin1');
+      const lines = buffer.split(/\r?\n/);
+      const terminal = lines.find((line) => new RegExp(`^${expectedCode} `).test(line));
+      if (!terminal) return;
+      cleanup();
+      resolve(buffer);
+    };
+    socket.on('data', onData);
+    socket.once('error', onError);
+    socket.once('timeout', onTimeout);
+  });
+}
+
+function waitForSocketEvent(socket, event) {
+  return new Promise((resolve, reject) => {
+    const cleanup = () => {
+      socket.off(event, onEvent);
+      socket.off('error', onError);
+      socket.off('timeout', onTimeout);
+    };
+    const onEvent = () => {
+      cleanup();
+      resolve();
+    };
+    const onError = (error) => {
+      cleanup();
+      reject(error);
+    };
+    const onTimeout = () => onError(new Error(`${event} timed out`));
+    socket.once(event, onEvent);
+    socket.once('error', onError);
+    socket.once('timeout', onTimeout);
+  });
+}

+ 1 - 1
test/vesta-legacy-auth.test.js

@@ -112,7 +112,7 @@ test('Vesta legacy mailbox password authenticates over IMAP, POP3 and SMTP then
 
     smtpClient = await connectClient(smtpServer.address().port);
     await smtpClient.readUntil(/^220 .* ready\r\n/m);
-    const ehlo = await smtpClient.command('EHLO client.example', /250 SMTPUTF8\r\n/);
+    const ehlo = await smtpClient.command('EHLO client.example', /250 HELP\r\n/);
     assert.match(ehlo, /250-AUTH PLAIN LOGIN/);
     const auth = Buffer.from(`\u0000${mailbox.address}\u0000${legacyPassword}`).toString('base64');
     assert.match(

+ 43 - 0
test/webhook-db.test.js

@@ -12,6 +12,7 @@ import {
   createDomain,
   createInboundMailbox,
   createInboundMessage,
+  createInboundMessageWithWebhook,
   createUser,
   createWebhook,
   deleteWebhook,
@@ -298,6 +299,48 @@ test('mailbox webhooks only enqueue idempotent receipt callbacks for their store
   assert.equal(enqueueInboundWebhookDeliveries(disabledMessage).length, 0);
 });
 
+test('inbound message index and receipt webhook outbox commit atomically', () => {
+  const database = initDatabase(tempDataDir(), 'test-secret');
+  const user = createUser({
+    username: 'atomic-inbound',
+    email: 'atomic-inbound@example.com',
+    password: 'password123'
+  });
+  createDomain(user.id, domainFixture('atomic-inbound.example'));
+  const mailbox = createInboundMailbox(user.id, { address: 'box@atomic-inbound.example' });
+  createWebhook(user.id, {
+    name: 'Atomic receipt',
+    url: 'https://hooks.example.com/atomic-receipt',
+    events: ['received'],
+    mailboxId: mailbox.id
+  });
+  database.exec(`
+    CREATE TRIGGER reject_atomic_receipt
+    BEFORE INSERT ON webhook_deliveries
+    WHEN NEW.inbound_message_id IS NOT NULL
+    BEGIN
+      SELECT RAISE(ABORT, 'forced receipt outbox failure');
+    END;
+  `);
+
+  const message = {
+    sender: 'sender@example.net',
+    recipients: [mailbox.address],
+    subject: 'Atomic receipt'
+  };
+  assert.throws(
+    () => createInboundMessageWithWebhook(mailbox, message),
+    /forced receipt outbox failure/
+  );
+  assert.equal(database.prepare('SELECT COUNT(*) AS total FROM inbound_messages').get().total, 0);
+  assert.equal(listWebhookDeliveries(user.id).length, 0);
+
+  database.exec('DROP TRIGGER reject_atomic_receipt;');
+  const created = createInboundMessageWithWebhook(mailbox, message);
+  const [delivery] = listWebhookDeliveries(user.id, { eventType: 'received' });
+  assert.equal(delivery.inboundMessageId, created.id);
+});
+
 test('domain override skips account webhooks for that event', () => {
   initDatabase(tempDataDir(), 'test-secret');
   const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });