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」里签名头的顺序与我们对不上,一眼看出顺序错误)。
因此本版把「验签」当成独立实现来写,并加了两道防线:
SelfTest新增反向对照用例:故意用非规范顺序(签名头在前 + 带结尾 CRLF)重建签名输入, 断言它必须验不过。若哪天验签器又被写成与签名端"同错",这项会立刻失败。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 # 两套后端的基准对比
迁移与回滚
- 停服务(先
Disable-ScheduledTask WpywMail,否则任务的失败重启策略会把它拉起来、锁住 DLL); - 备份
C:\WpywMailData; --migrate-to-sqlite --delete-source:迁移会逐封比对 SHA256,任何一封不一致就中止并保留源文件;- 改
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 节)。