Files
..

WpywMail.Native v2

自建中文邮件服务(Windows / .NET 8,单文件进程,零外部依赖)。 本目录是 v2 重写版本;v1 原样保留在 ../server-native,部署现场备份在 C:\Program Files\WpywMail.v1-backup。


1. 它做什么

                     ┌──────────────── WpywMail.Native.exe ────────────────┐
   公网 25 ─────────►│ SmtpServer  收信(只收本地收件人,绝不中继)          │
   客户端 587 ──────►│ SmtpServer  发信(STARTTLS + AUTH 后提交)           │
   Webmail/客户端 ──►│ ApiServer   REST + 长轮询(默认仅监听 127.0.0.1)     │
                     │ DeliveryQueue  出站队列:DKIM 签名 → MX 直投/中继    │
                     │ FileStore  users/messages/queue/sessions + raw/     │
                     └────────────────────────────────────────────────────┘
文件 职责
Program.cs 入口、配置校验、启动/停止、--selftest / --check-config / --migrate
Models.cs 配置与数据模型
Mime.cs MIME 组装与解析(RFC 5322 / 2047 / 2231、base64、QP、multipart、附件、字符集)
Dkim.cs DKIM 签名(RFC 6376,rsa-sha256,relaxed/relaxed,自动生成密钥并打印 DNS 记录)
SmtpServer.cs 25 收信 / 587 提交服务端
SmtpReader.cs 字节级 SMTP 行读取器(命令与 DATA 共用缓冲区,保证 8bit 正文不被破坏)
SmtpDataEncoder.cs DATA 段编码/还原(行尾规范化、dot-stuffing),与 DKIM 签名顺序严格配合
DirectSmtpDelivery.cs 出站投递(MX 直投 / relay 中继)+ 极简 DNS MX 解析
DeliveryQueue.cs 出站队列:重试、退避、退信通知
FileStore.cs 文件存储与全部状态变更
ApiServer.cs REST API(Webmail 与桌面客户端共用)
Migration.cs 从 v1 数据一次性迁移/修复

2. v1 的问题与本版修法

# v1 问题(位置) 后果 v2 处理
1 DirectSmtpDelivery.cs:192 用 Encoding.ASCII 的 StreamWriter 写 DATA 所有非 ASCII 变 ?,中文彻底不可用 按字节写出;SmtpDataEncoder 只做 dot-stuffing
2 Mime.cs:28 写死 Message-ID: <[email protected]> 声明域 ≠ 发信域,垃圾邮件特征 取配置 Domain
3 Mime.Parse 不解码 RFC 2047、不解 base64/QP 正文 收件显示 =?utf-8?b?...?=、正文乱码 完整解码(含量化可打印、软换行合并)
4 头部按 Latin-1 解码 裸 UTF-8 主题变 [æµè¯] UTF-8 优先、失败回退 Latin-1
5 SmtpServer.ReadData 用 StreamReader 读文本再拼回 8bit 内容/行尾被破坏、O(n²) 长度统计、无上限 字节级读取 + 大小上限 + dot-unstuffing
6 自签名证书时不广告 STARTTLS,但 AUTH 又要求加密 587 完全死锁(538) 只要加载到证书就广告 STARTTLS;AUTH 在 TLS 后提供
7 无 DKIM 进垃圾箱 新增 DKIM 签名(含密钥生成与 DNS 记录输出)
8 4xx/5xx 一律按 8 次封顶,无退信 封锁类 5xx 直接丢信且无通知 可配置重试策略 + 彻底失败发退信
9 无 multipart / 无附件 前端已在读 attachments 但后端没有 完整 multipart 收/发 + 附件存储与下载
10 会话仅存内存 每次重启把客户端踢下线 会话落盘 sessions.json
11 无文件夹/星标/删除/队列接口 客户端只能读收件箱 补齐(见第 6 节)
12 签名后才改行尾 DKIM 正文哈希对不上,签名失效 先规范化 → 再签名 → 传输不改字节(自检覆盖)

2.1 v2.0.0 自身的 DKIM 缺陷(2026-09-13 由外部验证器揪出,v2.0.1 修复)

# 问题(位置) 后果 修法
13 Dkim.cs 构造签名输入时,把 DKIM-Signature 头放在最前面且多带一个结尾 CRLF 违反 RFC 6376 §3.7 第 2 步 → Gmail / Outlook / port25 等所有合规验证器一律判 dkim=fail,等于白签 改为:各被签名头按 h= 顺序、每个后跟一个 CRLF;DKIM-Signature 头放最后且结尾不带 CRLF

这条为什么差点被漏掉(重要教训):v2.0.0 的 SelfTest.VerifyDkim 与本机 Python 验签脚本 当初都是照着签名端的实现写的,两边犯了同一个错,于是自检 38/38、加上手写验签脚本全部"通过"—— 属于典型的假通过。真正把它暴露出来的是外部独立验证器(把信发给 [email protected],报告里明确写着 dkim=fail reason="signature doesn't verify", 并且它打印的「Canonicalized Headers」里签名头的顺序与我们对不上,一眼看出顺序错误)。

因此本版把「验签」当成独立实现来写,并加了两道防线:

  1. SelfTest 新增反向对照用例:故意用非规范顺序(签名头在前 + 带结尾 CRLF)重建签名输入, 断言它必须验不过。若哪天验签器又被写成与签名端"同错",这项会立刻失败。
  2. tools/verify_published_dkim.py:只吃 DNS 上已发布的 TXT、不接触私钥, 对真实投递报文按 RFC 6376 顺序验签 —— 直接复现收件方会看到的结果。

修好之后的实测证据(同一把 DNS 公钥、同一个验签器):

  • v2.0.0 签的报文(14:09 落盘)→ 失败(DigestInfo 不匹配)
  • v2.0.1 签的报文(14:24 落盘)→ 通过
  • port25 外部验证器第三次报告 → DKIM check: pass,对方写入 Authentication-Results: ... dkim=pass (matches From: [email protected]) header.d=wpy.email

3. 配置(appsettings.json)

{
  "Domain": "wpy.email",
  "Hostname": "mail.example.com",
  "HttpPrefix": "http://127.0.0.1:8787/",
  "SmtpPort": 25,
  "SubmissionPort": 587,
  "DataDirectory": "C:\\WpywMailData",
  "AdminEmail": "[email protected]",
  "AdminPassword": "至少12位;会同步为该账号的登录密码",
  "TlsCertificatePath": "C:\\WpywMailData\\certs\\mail.example.com.pfx",
  "TlsCertificatePassword": "...",
  "DeliveryMode": "direct",              // direct=MX直投 | relay=上游中继

  "Storage": {
    "Provider": "sqlite",                // sqlite(默认,推荐)| json(旧实现,可一键回滚)
    "DatabasePath": "",                  // 留空 = DataDirectory\\wpywmail.db
    "WalAutoCheckpointPages": 0,         // 0 = 用 SQLite 默认(约 1000 页)
    "FullTextSearch": false              // 见第 10 节:打开后搜索快约 20 倍,但索引要占正文量级的空间
  },

  "DirectDelivery": {
    "ConnectionTimeoutSeconds": 30,
    "CommandTimeoutSeconds": 30,
    "DnsTimeoutSeconds": 5,
    "OpportunisticStartTls": true,       // 对方支持就加密,握手失败自动回退明文重连
    "RequireStartTls": false,
    "DnsServer": "",                     // 留空用系统 DNS
    "HeloName": ""                       // 留空用 Hostname
  },

  "Relay": { "Host": "", "Port": 587, "User": "", "Password": "", "EnableSsl": true },

  "Retry": {
    "MaxAttempts": 12,
    "InitialDelaySeconds": 60,
    "MaxDelaySeconds": 3600,
    "RetryOnPermanentFailure": true,     // 5xx 也重试(封锁/策略类常是临时的)
    "MaxAttemptsForPermanent": 3,
    "SendBounceNotification": true       // 彻底失败给发件人发退信
  },

  "Dkim": {
    "Enabled": true,
    "Selector": "mail",                  // DNS 名:mail._domainkey.<Domain>
    "SigningDomain": "",                 // 留空用 Domain
    "PrivateKeyPath": "",                // 留空 = DataDirectory/dkim/<selector>.private.pem
    "Headers": ["From","To","Subject","Date","Message-ID","MIME-Version","Content-Type","Content-Transfer-Encoding"]
  },

  "Api": { "SessionDays": 30, "CorsOrigin": "*", "LongPollSeconds": 25 },

  "Accounts": {
    "Registration": "invite",            // open=自助注册 | invite=需要邀请码 | closed=关闭注册
    "InviteCode": "换成一串只有你知道的随机串",
    "AllowedDomains": ["wpy.email"],     // 允许注册的域名;留空 = 只允许本机域
    "RequireEmailVerification": true,    // ⚠ 本机托管的域名会自动跳过,见第 6.2.2 节
    "MinPasswordLength": 12,
    "CodeMinutes": 30,                   // 验证码有效期
    "MaxCodeAttempts": 5,
    "MaxLoginFailures": 8,               // 连续失败多少次后临时锁定
    "LockoutMinutes": 15,
    "RegisterPerHourPerIp": 5,           // 严格配额只算「真的建出的账号」
    "ResendPerHourPerEmail": 5,
    "AuditLimit": 2000                   // 单账号保留的审计条数
  },

  "InboundAuth": {
    "Enabled": true,                     // 收信时做 SPF/DKIM/DMARC 校验
    "AddAuthenticationResults": true,    // 把结论写进报文的 Authentication-Results 头(标准做法)
    "SpamFolderOnFail": true,            // 判定为垃圾 → 投 spam 文件夹(可逆)
    "RejectOnDmarcReject": false,        // DMARC p=reject 且失败时直接 550 拒收(默认关:拒收不可逆)
    "VerifyDkim": true,                  // DKIM 验签(要查发件域 DNS 公钥;公钥发布成 CNAME 也会跟)
    "SpamScoreThreshold": 3,             // 判垃圾的分数阈值(DMARC 失败固定 +4)
    "DnsTimeoutSeconds": 5,
    "MaxSpfLookups": 10                  // RFC 7208 规定 10
  },

  "Smtp": {
    "MaxMessageBytes": 26214400,
    "AdvertiseStartTls": true,
    "AllowAuthOnInbound": false,         // 25 端口默认不允许认证
    "AddReceivedHeader": true,
    "AuthFailuresBeforeBan": 8,
    "BanMinutes": 15,
    "EnforceSenderMatch": true           // 已认证用户必须用自己的地址发件
  }
}

4. 构建与部署

# 构建 + 自检(202 项;含 DKIM 可验证性、传输一致性、存储双后端契约、账号体系全套)
dotnet build -c Release
.\bin\Release\net8.0\win-x64\WpywMail.Native.exe --selftest

# 发布自包含版本(目标机无需安装 .NET)
dotnet publish -c Release -r win-x64 --self-contained true -o ..\work\publish-v2

增量部署(只换主程序集,不动配置与数据):

# ⚠ 顺序不能变:Stop-ScheduledTask 会触发任务的失败重启策略,把服务又拉起来并锁住 DLL
Disable-ScheduledTask -TaskName WpywMail
Stop-ScheduledTask    -TaskName WpywMail
Get-Process -Name 'WpywMail.Native' -ErrorAction SilentlyContinue | Stop-Process -Force
Copy-Item .\bin\Release\net8.0\win-x64\WpywMail.Native.dll 'C:\Program Files\WpywMail\WpywMail.Native.dll' -Force
Enable-ScheduledTask  -TaskName WpywMail
Start-ScheduledTask   -TaskName WpywMail

部署(计划任务名 WpywMail,动作指向 C:\Program Files\WpywMail\WpywMail.Native.exe):

Stop-ScheduledTask -TaskName WpywMail
Copy-Item ..\work\publish-v2\* 'C:\Program Files\WpywMail\' -Recurse -Force   # 运行时首次需全量,之后只需 *.dll/*.exe
Start-ScheduledTask -TaskName WpywMail

回滚:删掉 C:\Program Files\WpywMail,把 C:\Program Files\WpywMail.v1-backup 改回来,重启计划任务。

维护命令:

WpywMail.Native.exe --version         # 版本
WpywMail.Native.exe --check-config    # 校验配置并打印关键项(含账号策略与「本机域免验证」提醒)
WpywMail.Native.exe --selftest        # 202 项自检(中文编解码、附件、DKIM、传输一致性、存储双后端契约、账号体系)
WpywMail.Native.exe --migrate         # 从 v1 数据迁移:重解析 raw/*.eml 修正主题/正文/附件、改写旧域名归属

4.1 真机验收脚本(账号体系)

tools/account-acceptance.ps1 —— 在邮件服务器本机上跑,打真实 HTTP API + 真实 IMAP(993):

# 上传后在服务器上执行(脚本必须存成「UTF-8 带 BOM」,否则 PowerShell 5.1 会把中文按 GBK 解)
powershell -NoProfile -ExecutionPolicy Bypass -File account-acceptance.ps1
# 退出码 = 失败项数;报告默认写到 C:\Windows\Temp\wpyw-acct-verify.txt(UTF-8)

覆盖 53 项:策略接口 → 邀请码/域名/弱密码拦截 → 注册即开通 → 会话/资料/审计 → 真实 IMAP 登录与收件箱计数 → 自己发信并被本地投递 → 忘记密码(真去邮箱里读验证码)→ 重置 → 改密踢其他会话 → 登录失败锁定(423 + retryAfterSeconds)→ 管理员视角 → 停用与「不能靠重新注册复活」。

⚠ 脚本一小时内重复跑会撞到 RegisterPerHourPerIp 配额:此时它会把自助注册那几项标成 [SKIP] 并改用管理员接口建号,其余用例照常跑完(断言的是产品行为,不是配额余量)。 测试会留下两个 selftest-*@ / locktest-*@ 账号,脚本结束前会停用它们。


5. DNS 配置(决定能否进收件箱)

以 wpy.email + IP <SERVER_IP> 为例:

类型 名称 值 说明
A mail <SERVER_IP> 必须灰云(DNS only),MX 指向的主机不能走代理
MX @ mail.example.com(优先级 10)
TXT @ v=spf1 ip4:<SERVER_IP> -all 注意是半角冒号;-all 比 ~all 严格
TXT mail._domainkey 服务启动日志里打印的 v=DKIM1; k=rsa; p=... 一字不能改,Cloudflare 会自动分段
TXT _dmarc v=DMARC1; p=none; rua=mailto:[email protected] 先 p=none 观察,再收紧
PTR 56.55.236.103(反向) mail.example.com 只能由 IDC 设置,无 PTR 时大厂几乎必判垃圾

6. REST API

  • 基址:config.HttpPrefix(默认 http://127.0.0.1:8787/,仅供反向代理/本机访问)
  • 认证:POST /api/login 换 token,之后所有请求带 Authorization: Bearer <token>
  • 编码:请求与响应均为 UTF-8 JSON;错误统一为 { "error": "说明" }
  • 会话有效期 Api.SessionDays 天,落盘保存,重启不掉线
  • 所有路径保持 v1 兼容(/api/login、/api/messages、/api/send、/api/config、/api/me、/api/logout、/api/account/password、/api/admin/users)

6.1 无需认证

方法 路径 说明
GET /api/health { ok, service, version, domain, hostname }
GET /api/version { version, domain, hostname, dkim }
POST /api/login 入参 { email, password } → { token, expiresAt, user }

6.2 账号

方法 路径 说明
GET /api/me { user, stats };stats 含 inbox/unread/starred/drafts/sent/trash/queue/failed
GET /api/config 域名、主机名、协议端口、特性开关(客户端据此决定是否显示附件/草稿入口)
POST /api/logout 使当前 token 失效
POST /api/account/password { password, currentPassword? }(新密码 ≥ 12 位);成功后吊销其他会话,返回 { ok, revokedSessions }
PATCH /api/account/profile { displayName }(≤64 字)→ { ok, user }
GET /api/account/sessions { sessions: [{ tokenPrefix, token, current, createdAt, expiresAt }] }
POST /api/account/sessions/revoke 空体/{} = 退出其他设备(保留当前);{ all: true } = 全部退出;{ token } = 指定会话
GET /api/account/audit?limit=50 { events: [{ at, email, ip, reason, success, detail, userAgent }] }(自己的登录/改密/注册轨迹)

6.2.1 自助注册与密码找回(v2.2 新增,无需认证)

方法 路径 说明
GET /api/auth/policy 注册策略:{ registration, inviteRequired, requireEmailVerification, minPasswordLength, allowedDomains, codeMinutes, maxLoginFailures, lockoutMinutes, selfHostedDomain, verificationNote }
POST /api/register { email, password, displayName?, inviteCode? } → 201 { ok, verificationRequired:false, session }(直接开通)或 202 { ok, verificationRequired:true, email, expiresInMinutes }(等验证码)
POST /api/register/verify { email, code } → 201 { ok, session }(验证通过即登录)
POST /api/register/resend { email, purpose:"register"|"reset" } → { ok }
POST /api/auth/forgot { email } → 恒返回 200 { ok, expiresInMinutes }(不暴露邮箱是否存在;命中则发验证码邮件)
POST /api/auth/reset { email, code, password } → { ok };成功后吊销该账号全部会话;若账号此前未激活(注册没验证完)顺带激活

登录失败的状态码(客户端要按码分支,别只看文案):

码 何时 响应
401 账号不存在 / 密码错 / 账号被停用 { error: "邮箱或密码不正确" }(一句话,不区分原因,防账号枚举)
403 账号存在但注册的邮箱验证没做完 { error: "...请用验证码完成验证,或用「忘记密码」重设密码", pendingVerification: true }
423 连续失败达到 maxLoginFailures 后的锁定期 { error, retryAfterSeconds }(客户端应显示倒计时并禁用提交)

6.2.2 两条必须知道的设计决策(踩过坑才定下来的)

① 本机托管的邮箱注册后免邮箱验证码。 理由是个死循环:验证码邮件只能投进「这个」信箱,而这个信箱在验证通过前不允许登录(IMAP / Webmail / API 全进不去), 用户永远拿不到码。所以域名等于本服务器自己的域(config.Domain / config.Hostname 的域)时, RequireEmailVerification 会被自动跳过,注册授权凭据是邀请码(管理员发放)。 GET /api/auth/policy 的 verificationNote 会把这件事讲给用户听;--check-config 也会打印同一提醒。 要让邮箱验证真正生效,得把 Accounts.AllowedDomains 换成托管在别处的域名(如 gmail.com)。 判定逻辑:AccountService.IsHostedDomain(email, Domain, Hostname)。

② 未激活的账号先建信箱行,但密码只存在验证码记录里。 注册时就把用户行建出来(active=0,密码是随机不可用值),否则本地投递看不到收件人、验证码邮件会被静默丢弃; 真正的密码哈希随验证码一起存进验证码记录的 Payload,验证码通过那一刻才写进用户行并激活。 于是「谁能读到验证码,谁才能决定这个账号的密码」—— 抢注者单独无法在别人的邮箱上留下自己的密码。 未激活的账号不能登录 IMAP/SMTP/API(各处 FindUser 只取 active=1),但能收信(投递用 FindUserAnyState)。

其它已实现的加固:注册/登录限流(严格配额只算真的建出的账号,填错表单不吃配额,另有宽松上限防邀请码爆破)、 验证码只存 PBKDF2 哈希 + 试错上限 + 过期、审计表(可按账号/IP/原因查询)、 管理员停用是权威状态(被停用的账号不能靠重新注册复活)、改密/重置后踢掉其他会话。

6.3 邮件

方法 路径 说明
GET /api/messages 查询:folder(inbox/sent/drafts/archive/trash/spam,留空=全部)、q、unread=1、starred=1、limit(≤500,默认 100)、offset → { total, offset, limit, messages[] }
GET /api/messages/{id} 详情;查询 markRead=false 可只看不改已读状态
PATCH /api/messages/{id} { unread?, read?, starred?, folder? }(folder 即移动/归档/删除到垃圾箱)
DELETE /api/messages/{id} 默认移入垃圾箱;?permanent=true 彻底删除(同时删除原始报文与附件文件)
GET /api/messages/{id}/raw 下载原始 .eml
GET /api/messages/{id}/attachments/{index} 下载附件(Content-Disposition 为 RFC 5987 编码,中文名正常)
POST /api/send { to, subject, text, html?, cc?, inReplyTo?, attachments?[] } → 202 { queued, messageId, recipients, cc }
POST /api/drafts { to, subject, text } → 201,存入 drafts

messages[] 摘要字段:id, folder, from, to, cc, subject, date, receivedAt, unread, starred, deliveryStatus, lastError, size, attachmentCount, hasAttachments, preview。

详情额外含:text, html, messageId, inReplyTo, references, dkimSigned, attachments[{ index, fileName, contentType, size, inline, url }], rawUrl。

attachments 元素格式:{ fileName, contentType, base64 }(单封上限见 Smtp.MaxMessageBytes)。

6.4 出站队列

方法 路径 说明
GET /api/queue { queue: [{ id, messageId, recipients, attempts, status, lastError, lastCode, nextAttempt, createdAt, lastAttemptAt }] }
POST /api/queue/{id}/retry 立即重试(重置 attempts 与退避)

status:pending / processing / retry / sent / failed。

6.5 新邮件推送(长轮询)

GET /api/watch?since=<version>
→ 挂起至多 Api.LongPollSeconds 秒,直到数据发生变化
→ { version, changed, stats }

客户端流程:启动时 version = (await /api/watch?since=0).version,之后循环 /api/watch?since=<上次的 version>;changed=true 时刷新列表,并把返回的 version 作为下次的 since。

6.6 管理员

方法 路径 说明
GET /api/admin/users 用户列表(含 active / createdAt / lastLoginAt)
POST /api/admin/users { email, password(≥12), displayName? } → 201
PATCH /api/admin/users/{email} { active? } 或 { password? }

7. 客户端接入要点

登录

POST /api/login  {"email":"[email protected]","password":"..."}
→ 200 {"token":"...","expiresAt":"...","user":{"email":"...","displayName":"...","role":"admin","domain":"wpy.email"}}

之后每个请求加 Authorization: Bearer <token>;收到 401 即视为需要重新登录(本地清 token)。

SMTP 客户端(手机/电脑上的邮件 App):587 + STARTTLS + 普通密码认证(AUTH LOGIN/PLAIN),发件人必须等于登录账号;25 端口只用于收信,不接受认证与中继。

WinUI 客户端建议:走 REST + /api/watch 长轮询即可,无需实现 IMAP/SMTP; 若要支持系统级邮件 App,则需另加 IMAP(v2 尚未实现,见第 8 节)。

错误处理:400 参数问题、401 未登录、403 权限/密码错误、404 对象不存在、405 方法不支持、500 服务端异常;响应体固定为 { error }。

并发:长轮询会占用一个连接,建议单个客户端同时最多 1 个 watch 请求。


8. 用标准邮件客户端接入(IMAP)

v2 内置 IMAP4rev1 服务端,Thunderbird / Outlook / Foxmail / 手机邮件 App 可直接连接。

项目 值
明文 + STARTTLS 端口 143
隐式 TLS 端口 993
用户名 完整邮箱地址,如 [email protected]
密码 appsettings.json 里的 AdminPassword
发信(SMTP) 587 + STARTTLS + 同一套账号密码
收信(SMTP) 无需配置(25 由公网直接投递)

文件夹映射(客户端里显示名 → 内部名): INBOX→inbox、Sent→sent、Drafts→drafts、Archive→archive、Trash→trash、Junk→spam。 中文名(收件箱/已发送/草稿/归档/垃圾箱/垃圾邮件)也可被识别。

已实现的命令:CAPABILITY、NOOP、LOGOUT、STARTTLS、LOGIN、AUTHENTICATE PLAIN、 LIST/LSUB、SELECT/EXAMINE、STATUS、CLOSE、UNSELECT、EXPUNGE、SEARCH、 FETCH/UID FETCH、STORE/UID STORE、COPY/UID COPY、APPEND、IDLE、 CREATE/DELETE/RENAME/SUBSCRIBE(接受但文件夹集合固定)。 未实现(不影响常规使用):SORT、THREAD、CONDSTORE、QRESYNC、ACL。

行为约定:

  • RequireTlsForLogin=true 时,必须先 STARTTLS 或用 993 才能 LOGIN;仅 PlaintextLoginAllowFrom 列表内的地址(默认本机)可明文登录。
  • SELECT 后 FETCH 的序号按 UID 升序(稳定顺序)。
  • 非 PEEK 的正文请求会把邮件标记为已读;\Flagged ↔ 星标;\Deleted 在 EXPUNGE 时生效(inbox 等移入垃圾箱,垃圾箱内则彻底删除)。
  • EXPUNGE 的序号会随删除动态变化(符合 RFC 3501)。
  • 自签名证书下客户端会提示证书不受信;换成受信任证书后即无提示。

配置示例(appsettings.json):

"Imap": {
  "Enabled": true,
  "Port": 143,
  "TlsPort": 993,
  "RequireTlsForLogin": true,
  "PlaintextLoginAllowFrom": ["127.0.0.1", "::1"]
}

9. 存储后端(SQLite / JSON)

为什么要有这一节

v2.0.x 只有一种存储:users.json / messages.json / queue.json / sessions.json 四个文件, 任何一次改动都会把全部邮件重新序列化并整文件重写。把一封邮件标记为已读也是 O(N), UID 分配、未读计数、统计、搜索全是全表扫描,而且全部邮件常驻内存。

v2.1 起默认使用 SQLite(元数据进库 + 索引 + 事务),原始报文与附件以 gzip 压缩 + 按内容 SHA256 去重的形式存进 blobs 表 —— 不再写 raw/ 文件。

基准数据(同一份负载,--bench-store)

指标 JSON(整文件重写) SQLite 结论
入库 每封 @400 封 3.74 ms 0.27 ms 快 14×
入库 每封 @2000 封 9.94 ms 0.36 ms 快 28×(JSON 随规模劣化)
标记已读 每次 @2000 16.50 ms 0.10 ms 快 167×
收件箱首页(50 条) 0.42 ms 0.97 ms 都在毫秒级
未读数 0.13 ms 0.06 ms 快 2×
统计 0.17 ms 0.18 ms 持平
搜索(LIKE 扫描) 6.27 ms 7.30 ms 持平;开 FTS5 后 0.35 ms(快 18×)
常驻内存增量 @2000 24.5 MB 7.5 MB 省 69%
磁盘 @2000 10.56 MB 7.03 MB 省 33%(raw 808 KB→库内 273 KB)

关键不是单点快多少,而是增长曲线:JSON 的单次改动成本随邮件数线性上涨(400→2000 封时 每封入库从 3.7 ms 涨到 9.9 ms),SQLite 基本恒定。

命令

WpywMail.Native.exe --storage-status      # 当前后端、各表占用、大对象压缩率、有无孤儿
WpywMail.Native.exe --migrate-to-sqlite    # JSON → SQLite(逐封 SHA256 校验,默认保留源文件)
WpywMail.Native.exe --migrate-to-sqlite --delete-source   # 校验通过后删除 raw/ 与 attachments/ 文件
WpywMail.Native.exe --migrate-to-json      # SQLite → JSON(回滚用,把 blobs 还原成文件)
WpywMail.Native.exe --compact              # 清理没有引用的大对象
WpywMail.Native.exe --vacuum               # 合并 WAL + 回收空闲页
WpywMail.Native.exe --bench-store 2000     # 两套后端的基准对比

迁移与回滚

  1. 停服务(先 Disable-ScheduledTask WpywMail,否则任务的失败重启策略会把它拉起来、锁住 DLL);
  2. 备份 C:\WpywMailData;
  3. --migrate-to-sqlite --delete-source:迁移会逐封比对 SHA256,任何一封不一致就中止并保留源文件;
  4. 改 Storage.Provider 为 sqlite(默认值即是),启服务。

回滚:把 Storage.Provider 改回 json 即可 —— 四个 JSON 索引文件在迁移时故意保留。 若源文件已被 --delete-source 删除导致 JSON 侧缺 raw/,先跑 --migrate-to-json 把 blobs 还原成文件,再切回 json。

⚠️ --vacuum 必须在停服时做

VACUUM 需要约 2 倍临时空间,且 WAL 无法在别的连接持有数据库时截断 —— 在服务运行时执行会把文件撑大而收不回来(实测 241 KB → 1.75 MB,停服重做后回到 274 KB)。

空间账(本机实测,66 封邮件)

raw/            0 B(0 个文件)      ← 报文字节已压缩进库
attachments/    0 B(0 个文件)
wpywmail.db     274,432 B(67 页 × 4096 B,空闲页 0)
  大对象 59 个:原始 88,917 B → 存储 49,986 B(57 个启用压缩)
  正文列合计 47,853 B(最大一封 8,908 B:port25 的 Authentication Report)

平均约 4.2 KB/封(含索引)。索引不是免费的:ux_messages_owner_folder_uid、 ix_messages_owner_folder_date、ix_messages_owner_unread、ix_messages_owner_starred、 ix_messages_owner_status 都对应真实查询;早期版本建过两个没人用的索引(message_id、raw_path), 已在建表语句里幂等 DROP 掉。


10. 全文检索开关(FTS5)

Storage.FullTextSearch: true 会建立 FTS5(trigram 分词器)索引,中文子串搜索走索引:

搜索每次 @2000 封 占用
false(默认) 7.30 ms(LIKE '%…%' 全表扫描) 7,028,736 B
true 0.35 ms 9,777,152 B(索引多占 2.7 MB ≈ 1.4 KB/封)

默认关闭是因为本机磁盘偏紧;按 12.5 GB 可用空间算,索引成本要到上百万封才会成为问题, 所以只要搜索体验优先,随时可以打开(改配置重启即可,首次启动会自动为历史邮件建索引)。


10.1 入站邮件身份校验(SPF / DKIM / DMARC)

在此之前谁都能用 From: [email protected] 给这台服务器发信,服务器照单全收进收件箱 —— 冒名邮件和正常邮件没有区别。 现在收信时依次做:

步骤 实现 说明
SPF Spf.EvaluateAsync(RFC 7208 常用子集) all / include / a / mx / ip4 / ip6 / exists + 限定符 + redirect + 宏;查询次数上限 10(超了 permerror);ptr 机制按 RFC 建议直接视为不匹配
DKIM DkimVerifier(RFC 6376) relaxed/simple 规范化、h= 重名从下往上取、l= 截断、x= 过期、p= 为空视为吊销;公钥发布成 CNAME 时会自动跟(outlook.com 就是这种)
DMARC Dmarc.EvaluateAsync(RFC 7489) p= / aspf= / adkim=;relaxed 对齐用「组织域」(内置 co.uk / com.cn 这类多段后缀表)

结论会写进报文:Authentication-Results:、X-Spam-Score:、X-Spam-Reason:(前置插入,不动原有字节, 所以发件人的 DKIM 签名不会被我们破坏 —— 自检里有这条断言)。 判定为垃圾(默认阈值 3;DMARC 失败固定 +4)则投进 spam 文件夹并把 DeliveryStatus 标成 received-spam, 默认不拒收:校验实现自身也可能有 bug,投垃圾箱可逆、拒收不可逆。

验签实现必须能被打假:自检里带了「改正文 / 改主题 / 只翻转一个字节 / 换公钥 / 公钥吊销」五类反向用例, 它们必须全部失败 —— 2026-09-13 的 DKIM 事故就是签名端和验签端犯了同一个错,导致自检「全通过」而外部判 fail。

维护命令:--verify-inbound [N] —— 对已落库的真实邮件走真实 DNS 验签(排查「这封信是不是伪造的」)。 实测最近 25 封里 8 封通过(含 126.com 这类第三方签名),1 封 163 转发的因正文被改写而判 fail(判定正确)。


11. 已知限制

  • 未实现 IMAP/POP3 → IMAP4rev1 已在 v2 实现(见第 8 节);POP3 仍未实现。
  • TLS 证书自签名 → 已换成 Let's Encrypt 受信任证书(DNS-01,含自动续期计划任务)。
  • 无账号体系 → v2.2 已有自助注册 / 登录加固 / 找回密码 / 会话与资料管理(见第 6.2 节); 仍缺:管理员删除账号(目前只能停用,PATCH /api/admin/users/{email} {"active":false})。
  • 明文凭据:appsettings.json 中的 AdminPassword 与证书口令是明文,注意文件权限。
  • 无病毒/垃圾过滤:收信不做内容扫描,也未做 SPF/DKIM 校验入站判定。
  • 默认无全文索引:搜索是 LIKE 扫描(数千封内毫秒级);需要更快请开 FullTextSearch。
  • 无配额:单用户磁盘占用不限制。
  • --vacuum 需停服执行(见第 9 节)。