适合个人用户自托管的轻量Web邮件客户端,支持多邮箱账号收发邮件与简单通讯录。基于ASP.NET Core Minimal APIs与原生JS构建,零前端框架零构建链,仅4个第三方依赖,支持邮箱自动配置与OAuth2登录,长期在线定时拉取,开箱即用。【此简介由AI生成】
| Files | Last commit | Last update |
|---|---|---|
| 28 days ago | ||
| 3 days ago | ||
| 1 month ago | ||
| 29 days ago | ||
| 24 days ago | ||
| 29 days ago | ||
| 28 days ago |
MMail
单用户、多邮箱账号的 Web 邮件客户端。只做收发邮件 + 简单通讯录,长期在线、定时拉取。
技术栈
| 层 | 选型 |
|---|---|
| 运行时 | .NET 10 LTS |
| Web | ASP.NET Core Minimal APIs + 零依赖前端(原生 JS,无构建链) |
| 邮件协议 | MailKit 4.18(IMAP / SMTP / XOAUTH2 / STARTTLS)+ MimeKit(MIME) |
| 数据库 | MySQL + EF Core 10(可切 SQLite / PostgreSQL) |
| 后台同步 | BackgroundService + PeriodicTimer |
| HTML 消毒 | HtmlSanitizer |
第三方依赖只有 4 个;Redis、消息队列、调度框架、前端框架一概没有。
运行
开发时从源码跑:
cd src/MMail.Client
dotnet run
# 打开 http://localhost:5080
# (必须用 localhost,见下方「OAuth2」一节)
部署到机器上则用 runMMail.sh(见下方「部署运行」一节):它会自行下载产物并
检查更新,不需要 clone 仓库,也不需要装 .NET SDK。
首次启动会自动建库、应用迁移,并初始化登录账号:
| 初始用户名 | admin |
| 初始密码 | adminadmin |
首次登录会被强制要求修改密码,在此之前除了「改密码」以外的一切操作都会被服务端拒绝。
原因是 admin/adminadmin 就写在本文档里,只要它还生效,这个实例就等于没上锁。
忘记密码无法从网页找回——这是单机邮件客户端,没有邮箱、也不该有邮箱来发重置链接。 找回的办法是在运行本程序的机器上执行:
cd src/MMail.Client dotnet run -- --set-password 新密码命令行改密码不需要旧密码:能在这台机器上执行命令本身就已是最高权限, 再要一次旧密码只会让「忘了密码」这种最常见的场景彻底没有出口。
数据目录默认是可执行文件同级的 data/(可用 MMAIL_DATA_DIR 覆盖):
data/
├── keys/ Data Protection 密钥(凭据加密用)
└── store/ 原始 .eml 与附件
mmail.db 只在 Provider 是 Sqlite 时出现;用 MySQL / PostgreSQL 时元数据进数据库,
不落在这个目录。keys/ 无论用哪个库都必须保留——它丢了,库里已存的
邮箱凭据会永久无法解密,即使数据完好也得全部重录。
部署运行
runMMail.sh 是部署时的入口:本地没有 MMail.Client 就下载最新版,
有则查远端 tag 决定是否覆盖,然后启动。升级时再跑一次同一条命令即可。
./runMMail.sh # 检查更新后启动
./runMMail.sh --no-update # 跳过检查,直接启动本地的
目标机需要已安装 .NET 10 运行时(产物不含运行环境)。长期运行挂到后台即可,
例如 nohup ./runMMail.sh > mail.log 2>&1 &。
目前不提供 systemd 服务单元与容器化部署。
配置(appsettings.json)
"Database": { "Provider": "MySql" }, // Sqlite | MySql | PostgreSql
// 必须显式填写:留空会静默回落到源码里的哨兵串(root/root@localhost:3306)
// charset=utf8mb4 是硬要求:MySQL 的 "utf8" 实为 utf8mb3,存不下 emoji
"ConnectionStrings": { "Default": "server=127.0.0.1;port=3306;database=mmail;user=mmail;password=…;charset=utf8mb4" },
"Sync": { "IntervalMinutes": 10, "InitialFetchCount": 50 },
"Storage": { "Root": "" }, // 留空 = 数据目录下的 store/
"Auth": { "AccessToken": "" }, // 可选的第二道锁,留空 = 不启用
InitialFetchCount:某个文件夹首次同步只取最近 N 封,避免一次拉下十年老邮件。Auth.AccessToken:可选。登录认证之外再加一道静态令牌,用于「挂到外网反向代理后面」 的场景:那种部署里多一层与用户身份无关的令牌,能在接口刚对外开放时挡掉一批无凭据扫描。 留空则不启用,本机直连时不必理会——用户名密码那一层已经足够。 启用后每个/api/请求都要带X-Access-Token头,仅/api/auth/session与/api/auth/login例外。注意/api/version不在此列——它虽不需要登录, 但启用令牌之后仍要带令牌。OAuth2:<服务商>:ClientId/ClientSecret/RedirectUri:一般都留空。 凭据请到运行时可达的「设置」页登记(存在数据库里)。 配置文件项保留是为了可以用环境变量注入,而不必写进配置文件。RedirectUri一旦填写就以它为准,不再自动推算。
切换数据库
模型层是 Provider 无关的(禁止 HasColumnType、索引列一律限长),
但 EF Core 的迁移产物是绑定 Provider 的:它会把 TEXT / varchar(128) /
character varying(128) 和各自的 Sqlite:Autoincrement /
Npgsql:ValueGenerationStrategy / MySQL:ValueGenerationStrategy 写死进迁移文件。
拿 SQLite 的迁移去跑 MySQL 会建出错误的列类型与主键。
所以切换 = 重建目标库 schema(切换时目标库本来就是空的):
./tools/switch-db.sh MySql # 或 PostgreSql / Sqlite
脚本会清掉 Migrations/、用目标 Provider 重新生成、并改写 Database:Provider。
之后手动填好 ConnectionStrings:Default 再启动即可,表会自动建好。
三个 Provider 的 NuGet 包已在 csproj 中全部引用,切换不需要改任何 C# 代码。 (若想减小发布体积,可删掉 csproj 中未使用的两个 Provider 包。)
添加账号:自动配置
用户只需填显示名 + 邮箱 + 密码,服务器地址、端口、加密方式全部自动识别, 流程对齐 Thunderbird Account Autoconfig:
https://autoconfig.<域名>/mail/config-v1.1.xmlhttps://<域名>/.well-known/autoconfig/mail/config-v1.1.xml- Thunderbird 中央数据库
autoconfig.thunderbird.net/v1.1/<域名>(ISPDB) - 查 MX 记录反推真实服务商后再查 ISPDB(自有域名托管场景,如 Workspace / 365)
- 按
imap./smtp./mail./mx.等常见主机名 × 常见端口逐个实测
每一步拿到的配置都会用真实连接 + 真实密码验证,验证不过就继续往下试, 全程不通才报错——不会出现「填了个配置但其实是错的」。 OAuth2 模式同样会在拿到令牌后真连一次 IMAP 与 SMTP,验证不过就不建账号。 出于安全考虑,探测只尝试 993/465(隐式 TLS)与 143/587(STARTTLS), 明文端口一律不试,避免密码裸奔。
三种失败结果会给出针对性的指引:
| 结果 | 含义 | 提示 |
|---|---|---|
RequiresOAuth2 |
该服务商只接受 OAuth2 | 提示去生成应用专用密码,或走 OAuth2 |
CredentialRejected |
已识别服务器,但登录被拒 | 提示两步验证账号要用应用专用密码 |
UnknownProvider |
完全识别不出 | 提示检查域名,或改用手动配置 |
OAuth2:登记一次应用凭据,之后只需登录
对比一下同为自托管 / 桌面的邮件客户端是怎么处理这件事的:
| 客户端 | 谁承担 OAuth 应用注册 | 回调地址 |
|---|---|---|
| Thunderbird / eM Client / Postbox | 厂商自有注册应用,内置进客户端 | .../oauth2/nativeclient、回环地址 |
| SnappyMail | 内置默认凭据,管理员可覆盖 | https 回调 |
Roundcube(oauth 插件)/ Nextcloud Mail |
管理员在配置里登记 | https 回调 |
本项目取「由部署者登记一次」这条路,但登记之后比上面几家都省事:
- 不需要 client secret:走 PKCE(RFC 7636)+ 桌面应用类型的客户端, 设置页只需要填 Client ID。这不是偷懒——公共客户端本来就无法保守密钥, 两家服务商都明确要求这类客户端不要使用密钥。
- 回调地址默认自动推算:回环访问时主机名取服务商认可的
localhost、 端口取真实监听端口,不再需要手工校对http://localhost与http://127.0.0.1、 端口、路径是否完全一致;远程访问则按实际访问的地址推算——协议取Request.Scheme(反向代理由X-Forwarded-Proto修正)、端口取外部端口、默认端口不写出。 推算值未必等于你想登记的那个(反代后面监听端口与外部端口往往不同), 这时在设置页填RedirectUri即可,填了就以它为准。 - 回调地址只能用纯 ASCII 域名:微软不支持国际化域名(IDN)作为重定向 URI
——中文域名不行,它的 punycode 形式(
xn--开头)同样不行,官方文档把 「Redirect URIs don't support Internationalized Domain Names」列为明确限制。 本机回环部署不受影响(localhost本身是 ASCII)。远程部署而访问地址是中文域名时, 必须另给一个 ASCII 域名专门走 OAuth2:回调地址登记它,发起授权时程序也会自动跳到 它——待授权 Cookie 只在回调所属的主机名下才会回传,跨域名收不回来。 日常收发邮件不受影响,令牌续期在服务端完成,不经浏览器。
本机部署时:主机名用 localhost,且必须用 localhost 打开本程序
两家服务商的回环回调都固定在 localhost 上,原因是实测得出的:
- Azure 门户拒收
http://127.0.0.1形式的回调地址(那只能通过修改应用 manifest 才能登记),而localhost可以直接填进文本框;微软在匹配 localhost 回调时还会忽略端口号。 - Google 桌面应用类型的回环地址
localhost与127.0.0.1都支持,所以统一取localhost。
于是请用 http://localhost:5080 打开本程序,不要用 127.0.0.1
(远程部署不适用:那种场合回调主机名取实际访问的地址,见上一条)。
这一点之所以重要,是因为「访问所用的主机名」与「登记在服务端的回调主机名」必须是同一个:
浏览器的待授权 Cookie 只在那个主机名下才会回传。两者不一致时商会回调到另一个主机名,
Cookie 收不到,最后只得到一句看不懂的「授权会话无效或已超时」。
程序已经把这层坑兜住了:发起授权时若发现访问主机不对,
会在写入 Cookie 之前返回一个「请先跳到 http://localhost:5080」的指令,
前端据此先把人送过去继续向导。所以即使你习惯了用 127.0.0.1,也只会多一次跳转,不会静默失败。
另注:在 Azure 门户登记回退地址时不要选「单页应用程序」类型—— 那样 refresh token 只有 24 小时有效期,账号会每天静默失效。 应选「移动和桌面应用程序」。
授权流程
- 前端
POST /api/accounts/probe—— 只判认证方式,不发起授权 (用户还没决定要走哪条路,生成 state 只是白白占着一次性会话)。 - 用户点「用服务商的账号登录」→
POST /api/oauth2/start: 生成 PKCE verifier、把待授权会话写进加密 Cookie、返回授权页地址。 - 服务商回调
GET|POST /oauth2/callback(微软用 form_post)——真正麻烦的部分在这里:- 校验
state与 Cookie(RFC 6749 §10.12 要求的 CSRF 防护) - 用授权码 +
code_verifier换 access token / refresh token - 校验
scope是否覆盖了必需的邮件权限 - 从
id_token读出实际授权的邮箱:用户在同意页上完全可以换成另一个账号, 光看表单填的那个无法知道令牌属于谁 - 用刚拿到的 access token 真连一次 IMAP 和 SMTP ——「授权成功」和「能用邮件客户端读邮件」是两回事
- 全部通过才落库,并把 refresh token 加密存起来
- 校验
任何一步失败都不会留下半成品账号,错误信息会给出可照做的指引。
令牌失效怎么办
令牌不会一直有效:改密码、用户主动吊销权限、多个客户端轮流刷新, 都可能让 refresh token 作废。处理办法:
ImapSyncService会在认证失败时丢掉缓存的 access token 再换一次 (一次偶发的过期错位不值得让账号整整一轮同步不出来)。- 仍然失败时错误会被记下来,账号页会显示「令牌已失效,需要重新授权」, 旁边就是「重新授权」按钮 —— 走同一套流程,定向更新既有账号。
- 「删除账号」会先向服务商吊销令牌再删本地记录;
也可以只用
POST /api/accounts/{id}/revoke吊销而保留邮件。
Google 会限制「每个客户端 × 用户」的 refresh token 数量,超出后旧的会失效。 因此本程序不会每次都带
prompt=consent(它本来也没必要: 桌面应用类型就一定会下发 refresh token)。
同步策略
- 用完即断:每次同步临时建 IMAP 连接,拉完立即
Disconnect。不使用 IDLE、不做连接池。 - 串行遍历账号:单用户少量账号,串行即可规避服务商的并发连接上限。
- 增量:按
folder.LastUid只拉更大的 UID;UIDVALIDITY变化说明服务端重建了 UID 空间,会自动重置基线重新索引。 - 幂等:插入前先查
(FolderId, Uid),重复同步不会进脏数据。 - 发送时临时建 SMTP 连接,发完断开,并 append 到「已发送」文件夹。
安全
登录与会话
单用户模式,所有操作都要先登录,未登录时服务端一律返回 401,由前端把界面切到登录页。
- 访问控制做在服务端的中间件里,按「除了显式放行的都要求登录」来判定,
而不是逐个端点标注——这样以后新增的端点默认是受保护的。
显式放行的 API 只有三个:
/api/auth/session、/api/auth/login、/api/version(版本号不是敏感数据,公开便于外部工具查询)。 静态资源(index.html/app.js/style.css)不受限,否则登录页自身也加载不出来; 它们里面没有任何用户数据,数据全在 API 后面。 - 密码用 PBKDF2-HMAC-SHA256 哈希(21 万次迭代,带随机盐),永不明文存储。
- 登录失败按「客户端 IP」与「用户名」两侧同时计数,15 分钟内超过 5 次即限流, 响应里带上还需等待的秒数。超限期间即便是正确密码也会被拦下—— 不这么做的话限流就形同虚设。等不了就走上面那条命令行。
- 所有写请求都要校验 CSRF 令牌(
/api/auth/session下发的X-CSRF-Token)。 会话 Cookie 是HttpOnly+SameSite=Lax;之所以是 Lax 而非 Strict, 是因为 OAuth2 授权结束后服务商会把浏览器跳回本站,那是一次跨站导航—— Strict 会让会话 Cookie 在这次跳转里不被发送,用户就会「授权成功了却发现自己没登录」。 - 「用户不存在」与「密码错误」返回完全一致的错误,并有等量的哈希延迟, 避免这个接口被当成枚举用户名的探针。
数据
- 邮箱凭据经 ASP.NET Core Data Protection 加密后落库,永不明文存储(冒烟测试已验证)。
- OAuth2 走 PKCE,不保存也不需要 client secret;待授权会话放在
HttpOnly的加密 Cookie 里,并校验state(符合 RFC 6749 §10.12 的 CSRF 防护)。 - 回调刻意不用查询串回传结果(会进浏览器历史和反代日志),
微软那一路还用了
response_mode=form_post,授权码不落在地址栏里。 - 邮件正文经 HtmlSanitizer 白名单消毒后才下发到浏览器。
- 远程图片默认被降级为
data-src不加载,避免追踪像素与信息泄露。 - 附件下载强制
Content-Disposition: attachment,防止text/html附件被浏览器渲染。 - 入站安全结论(SPF/DKIM/DMARC)直接读服务端的
Authentication-Results头做展示, 不自算——客户端拿不到连接 IP,自算没有意义。 - 传输层优先 STARTTLS / 隐式 TLS,不关闭证书校验。
发版
一条命令走完「提交 → 打 tag → 推送 → 出产物 → 传附件」:
./scripts/release.sh 0.3.0 # 工作区干净时
./scripts/release.sh 0.3.0 -m "提交说明" # 有未提交改动,顺带提交
版本号必须显式给出:major/minor/patch 跳哪一位,取决于这次改动是不是破坏性的 (数据库 schema 变了没、配置格式变了没),机器判断不了,脚本不替你猜。 实际执行前会先把发版计划列出来等你确认——tag 推上去之后再想撤很麻烦。
版本号不手写,由 git 推算
csproj 里没有版本属性,也不维护版本文件,publish.sh 一律从 git 推:
| 情形 | 推算出 |
|---|---|
| HEAD 正好在 tag 上 | 该 tag 的版本 0.3.0 |
| tag 之后还有 N 个提交 | 下一个 patch 的预发布 0.3.1-alpha.N+<hash> |
| 仓库还没有任何 tag | 0.0.1-alpha.<总提交数>+<hash> |
第二种情形取「下一个 patch」而不是「当前 tag + alpha」,是因为 0.3.0-alpha.5
在 SemVer 里小于 0.3.0,检查更新会得出「比已发布的版本还旧」,而它实际是
0.3.0 之后的代码。0.3.1-alpha.5 才表达「在 0.3.0 之后、尚未发布」。
这样「平台上的 tag」与「产物自称的版本」天然是同一个东西——部署实例上检查更新,
靠的就是这层对应。--release 会强制要求干净工作区且 HEAD 上有 tag:
发给他人的产物必须能对应到确定的 commit,这条不能靠自觉。
产物怎么传上去
平台没有「直接 POST 文件到 Release」的接口,是两段式:
GET /releases/:tag/upload_url?file_name=..换一个上传地址PUT <该地址>,body 是文件原始字节,带上返回里指定的x-obs-*头
底层是华为云 OBS,所以上传地址不在 gitcode 域名下——照着 GitHub 的
POST /releases/:id/assets 去找是找不到的(v5 的 release 响应里也没有数字 id)。
需要平台凭据:环境变量 ATOMGIT_TOKEN,或项目根放 atomgit_token
(已被 .gitignore 忽略)。没凭据不会让发版失败,只跳过上传并给出地址,
配好之后补传即可:
./scripts/upload-release.py v0.3.0
同名文件是覆盖而非新增,所以补传可以放心重跑,不会堆出重复附件。
上传的是 publish/ 下除 VERSION.txt 之外的文件。VERSION.txt 只留在本地——
它记的是构建时的 commit 与时间,而版本已经由 tag 表达了,对外发布反而可能与
tag 不一致。服务起不来时它在本地仍有用:不启动程序也能看清这个产物是什么。
查看当前版本
不用登录,两种方式:
./MMail.Client --version # 或 -v
curl http://localhost:5080/api/version
两者内容一致:版本号、commit、tag、距上一个 tag 的提交数、构建时间、目标 RID。
--version 适合手边有二进制时用(服务没起也能看),/api/version 适合确认远程
实例跑的是哪个版本。runMMail.sh 判断本地要不要更新,靠的就是前者的第一行。
不从二进制里 grep 版本号:里面混着一堆依赖的版本字符串(实测 5 个匹配), 分辨不出哪个是本体,所以改由产物自己报告。
检查更新
部署实例的设置页点「检查更新」,服务端查 appsettings.json 里的
UpdateCheck.TagsApi(v5 的 /tags 端点),拿远端最新 tag 与自身版本比较。
不要把地址改成
/api/v4/...:平台已禁用 v4,而且它不会报错, 只会永远显示「暂时无法检查」,看上去像网络问题而不是配置写错了。
已知限制
- 未实现:DKIM 出站签名(需托管域名私钥)、S/MIME、OpenPGP。
- 未实现:服务端删除/已读状态回写本地。本地已读状态由本应用维护, 在手机上读过的邮件,Web 端仍会显示为未读。
- Linux 上 Data Protection 密钥以明文形式落盘(无系统级密钥环)。
如需加密,请自行配置
ProtectKeysWithCertificate,或依赖磁盘级加密。 - 搜索使用
LIKE,三个 Provider 行为一致;邮件量上万后建议换成各库自己的全文索引。