| Files | Last commit | Last update |
|---|---|---|
feat(auth): 单用户模式登录认证 未登录时所有操作一律跳转登录页。默认账号 admin / adminadmin,首次登录后强制 修改密码——在此之前除改密码外的所有请求服务端一律拒绝。 访问控制实现为「默认封闭」的中间件而非逐个端点标注,这样新增端点时不会因为忘记 标注而漏出接口。这个判断很快得到了验证:/oauth2/callback 不在 /api/ 前缀下,但 同样会创建邮箱账号,按前缀放行就会把它漏在外面。 包含: - 设置页可修改用户名与密码,改密码需验证旧密码 - 登录频率限制:15 分钟 5 次失败,按用户名 + IP 双维度计数 - CSRF:逐写请求校验 X-CSRF-Token - 前端不提供找回密码,改由命令行 --set-password 处理。不需要旧密码:能操作这台 机器本身已是最高权限,再要一次旧密码只是为难人 实现中发现并修正的三个缺陷: - SignInAsync 只写 Set-Cookie 而不回填 HttpContext.User,而防伪造令牌是与用户身份 绑定的,于是签到后立即生成的令牌仍绑在匿名身份上,下一次请求就校验失败。 表现为「登录成功,之后点什么都报 400」。 - Minimal API 会丢弃「只有 HttpContext 参数、返回 Task<IResult>」这类委托的返回值, 客户端收到的是默认的 200 + 空 body。logout 正好是这个签名;用 299 验证过并非 204 的特殊性,确实是返回值被丢弃。 - EF 查询缺 OrderBy 会告警,且多行时取到哪一行不可预测。 前端对 OAuth2 切换指令的处理也随本 commit 落地(服务端部分见上一 commit)。 已知取舍:限流计数在内存中,重启即清零——能重启进程的人已经拥有本机最高权限; Data Protection 密钥在 Linux 上明文落盘,README「已知限制」已记录。 | 30 days ago | |
| 24 days ago | ||
fix(settings): 密钥丢失时 GetAsync 不再 500,视为未设置返回 容器重建后 keys 目录未持久化(v0.2.2 之前默认在 ~/.mailclient/keys, 很可能只挂载了 mmail.db 单文件)时,数据库里所有加密字段都解不开, 整个设置页 GET 500,连"重新填"的入口都没有——用户得直接 SQL 改库。 把 Unprotect 用 try/catch 包住,捕获 CryptographicException 后记一条 警告日志并返回 null。调用方按"未设置"回落:用户看到空字段,重新保存 会用新密钥加密覆盖旧密文,恢复链就此接通。 邮箱账户的密码不在这次改动范围——EncryptedSecret 解不开时 ImapSync / SmtpSend / OAuth2 仍会按原方式抛异常。这是故意的:让用户清楚看到 "这个账户的密码需要重新录",而不是悄悄用空密码去尝试登录。 | 25 days ago | |
feat(version): 产物注入构建元数据,并支持 --version 查看 原先产物只有 InformationalVersion(版本号+commit),排查「这个实例跑的 是哪次构建」时信息不够用。补充构建时间、目标 RID、距上一个 tag 的提交数、 tag 名四项,由 publish.sh 通过 MSBuild 属性注入,csproj 的 AssemblyAttribute ItemGroup 展开为 AssemblyMetadataAttribute。 新增 --version / -v:打印完整版本信息后退出,供人工查看与脚本探测版本用。 /api/version 同步返回这些字段,并改为公开可读——版本号不是敏感数据(git tag 本身就是公开的),公开便于外部工具查询。 两处踩坑: - .NET 10 不把 SourceRevisionId 单独生成成 AssemblyMetadataAttribute, 所以 commit 从 InformationalVersion 的 + 后缀拆取,不依赖单独的 metadata - SessionRequiredMiddleware 按路径白名单判断,不认 ASP.NET 的 AllowAnonymous(), 所以 /api/version 需显式加进白名单(先加的 AllowAnonymous 实测无效) | 28 days ago | |
feat(mail): 邮件列表显示入站安全校验徽标 列表每封邮件的主题行首加一个圆形徽标,四档: 已验证 ✓ / 存疑 ! / 未通过 ✕ / 未验证 ? 悬停用原生 tooltip 显示原因,如「SPF 通过 · DKIM 通过 · DMARC 未通过」。 结论来自收件服务器写在 Authentication-Results 头里的判断(微软、谷歌在收信 那一刻做的),本程序自己不做验证——SPF 的输入是发信 IP,客户端拿不到这个 信息,只有接收方能在 SMTP 会话里看到。 为什么必须先解析:原始头最长 2000 字符,50 封就是 100KB,不能跟着列表一起 返回。所以同步时就解析成 AuthVerdict(枚举)+ AuthSummary(中文摘要), 列表只返回这两个小字段。 取值规则(AuthResultsParser): - 只认 spf / dkim / dmarc。compauth= 等各家私有字段没有公开语义,跳过—— 微软那类代发邮件 compauth=fail 但三项全过,照样是已验证 - 一封信常有多个 DKIM 签名,任意一个通过即算通过,全部 fail 才算失败 - 明确 fail 优先;其次 DMARC 通过(它要求 SPF 或 DKIM 至少一项对齐); 再次 softfail / neutral / policy 归存疑;temperror / permerror 归未验证 - 拿不准一律归未验证。宁可少给结论,也不给错误的安全感 SPF 软失败但 DKIM+DMARC 通过时算已验证:这是代发的正常形态,发送平台用 自己的 IP 发信,SPF 本就对不上,业界也照常投递。软失败这一细节仍留在摘要 里,悬停可见。没有 DMARC 兜底时才算存疑。 历史邮件必须补算:同步只处理新邮件,不补算的话升级前那些邮件的徽标会永远 停在「未验证」,看上去像功能没生效。启动时在后台分批补算,筛选条件是 AuthSummary 为空,补完自然收敛,重启也能接着上次的进度继续。 生成迁移时踩的坑,记在这里:ef 工具不读 appsettings 的 Database:Provider, 默认按 Sqlite 生成。第一次产出 126 个 AlterColumn,把 varchar(64) 改成 TEXT、tinyint(1) 改成 INTEGER,应用到生产 MySQL 库就是灾难。最后用一个 独立的临时启动项目给 ef 提供钉住 MySql 的工厂才得到正确结果(只有 2 个 AddColumn)。以后再生成迁移要沿用这个办法。 验证: - 解析器按真实格式跑 16 组:微软(两重 DKIM)、谷歌、SPF+DMARC 失败、 DKIM 签名验证失败、软失败(有/无 DMARC)、各项均为 none、头不存在、 空串、无法识别的内容、compauth=fail、两重 DKIM 一过一败、只有一项通过、 临时/永久错误,全部相符 - 端到端:起实库,插入 4 封「结论字段留空」的邮件模拟升级前的历史数据, 启动后由后台补算,列表接口返回的三档结论与摘要全部正确 - app.js 过 node --check,编译无错。浏览器里的徽标观感未验证 | 23 days ago | |
fix(sync): 通讯录批内重复导致整批邮件提交失败 同步改成攒批提交(每 20 封一次)后,同一批里第二次遇到同一邮箱时, 前一条还只是 Added 状态、尚未落库;ContactService.UpsertAsync 只查库, 于是认为不存在又 Add 一条,SaveChanges 时撞唯一索引——MySQL 报 「Duplicate entry '…' for key 'Contacts.IX_Contacts_EmailAddress'」。 发件箱/已发送里同一收件人反复出现,所以第一轮同步就必然踩到。 这是上一轮攒批改动引入的回归。 改动两处: 1. UpsertAsync 先查 DbSet.Local(含 Added/Unchanged/Modified)再查库, 补上「本批已 Add 但还没提交」这个盲区。比较用 OrdinalIgnoreCase, 对齐 MySQL 的 utf8mb4_unicode_ci——它把仅大小写不同的邮箱视为同一条, 若按 SQLite/PostgreSQL 的大小写敏感放过,换到 MySQL 又会炸。 2. SavePendingAsync 整批失败时,先剥离本批通讯录变更再重试一次。 邮件正文已从 IMAP 下到磁盘,整批丢弃等于下一轮全部重下;通讯录丢几条 无妨,下一轮遇到同一邮箱还会再沉淀。仍失败则把变更集 detach 干净, 避免失败状态黏到下一批反复触发。 验证:临时 console 项目直接调 ContactService,同一邮箱批内 Upsert 三次。 修复前报 UNIQUE constraint failed(与线上 Duplicate entry 同源), 修复后「批内重复」与「大小写不同」两个场景均成功且只落一条。 | 25 days ago | |
feat: 单用户多账号 Web 邮件客户端初始实现 后端 .NET 10 + ASP.NET Core Minimal API,前端零依赖原生 JS。 - 邮件:MailKit/MimeKit 实现 IMAP 定时拉取与 SMTP 发送。 遵循「用完即断」:同步时临时建连、拉完即断,不使用 IDLE、不做连接池; 按 LastUid 增量同步,UIDVALIDITY 变化时重置基线,插入前查 (FolderId, Uid) 保证幂等。 - 数据:SQLite + EF Core。模型层严格 Provider 无关(禁用 HasColumnType、 索引列一律限长),运行时按 Database:Provider 切换;因迁移产物绑定方言, 切换需通过 tools/switch-db.sh 重新生成。 - 安全:凭据经 Data Protection 加密落库,正文经 HtmlSanitizer 消毒, 远程图片降级为 data-src 默认不加载,附件强制 attachment 下载, 入站安全结论直接读 Authentication-Results 头。 - 通讯录:收发件人自动沉淀 + 手动增删改。 | 1 month ago | |
fix(imap): 向服务器自报身份,修复网易系 EXAMINE 被拒 网易系(163 / 126 / 188)的 IMAP 会拒绝「不报身份」的客户端: 登录能过,一到打开文件夹就返回 EXAMINE Unsafe Login. Please contact kefu@188.com for help 结果是这些账号的收件箱与 Archive 全部同步失败,等于收不到邮件。 IMAP 的 ID 命令(RFC 2971)就是用来声明客户端身份的,Foxmail、 Thunderbird 这些都会发。MailKit 默认不发,补上这一次即可。 实现上三点: 1. 时机是认证之后、打开文件夹之前。ID 要求连接处于已认证状态, 而网易正是在 EXAMINE 那一步检查身份的。 2. contact 这一项只能塞进 Properties:ImapImplementation 没有对应的 属性,而网易要求的 name / version / vendor / contact 四项里就差它。 实测过 Properties 构造后恒非空,往里写是有效的。 3. 整段包在 try 里,只记 Debug 日志。ID 是可选扩展,服务器不实现是 常态;这一步失败只是没报上身份,不该连累后面。真正打不开文件夹的 错误照常抛出,不会被这里挡住。 四处连接点都补上:同步的两处(整账号 / 单文件夹),以及发信后回写 「已发送」与存草稿时的 IMAP 连接——它们同样要 Open,在网易上现在 也是坏的。 验证:起一个假 IMAP 服务器,直接调用项目自己的 ImapIdentify: 服务器声明支持 ID → 收到 ("name" "MMail" "version" "1.0.0" "vendor" "MMail" "contact" "u@x.com"), 且位于 EXAMINE 之前 服务器不声明 ID → 异常被吞掉,EXAMINE 照样成功 后者说明加了 try 不会让同步出问题:最坏情况就是维持现状。 局限:没有网易账号,无法实测网易服务器是否就此放行。 若升级后仍报同样的错,说明不只是 ID 的问题。 | 23 days ago | |
fix(imap): 向服务器自报身份,修复网易系 EXAMINE 被拒 网易系(163 / 126 / 188)的 IMAP 会拒绝「不报身份」的客户端: 登录能过,一到打开文件夹就返回 EXAMINE Unsafe Login. Please contact kefu@188.com for help 结果是这些账号的收件箱与 Archive 全部同步失败,等于收不到邮件。 IMAP 的 ID 命令(RFC 2971)就是用来声明客户端身份的,Foxmail、 Thunderbird 这些都会发。MailKit 默认不发,补上这一次即可。 实现上三点: 1. 时机是认证之后、打开文件夹之前。ID 要求连接处于已认证状态, 而网易正是在 EXAMINE 那一步检查身份的。 2. contact 这一项只能塞进 Properties:ImapImplementation 没有对应的 属性,而网易要求的 name / version / vendor / contact 四项里就差它。 实测过 Properties 构造后恒非空,往里写是有效的。 3. 整段包在 try 里,只记 Debug 日志。ID 是可选扩展,服务器不实现是 常态;这一步失败只是没报上身份,不该连累后面。真正打不开文件夹的 错误照常抛出,不会被这里挡住。 四处连接点都补上:同步的两处(整账号 / 单文件夹),以及发信后回写 「已发送」与存草稿时的 IMAP 连接——它们同样要 Open,在网易上现在 也是坏的。 验证:起一个假 IMAP 服务器,直接调用项目自己的 ImapIdentify: 服务器声明支持 ID → 收到 ("name" "MMail" "version" "1.0.0" "vendor" "MMail" "contact" "u@x.com"), 且位于 EXAMINE 之前 服务器不声明 ID → 异常被吞掉,EXAMINE 照样成功 后者说明加了 try 不会让同步出问题:最坏情况就是维持现状。 局限:没有网易账号,无法实测网易服务器是否就此放行。 若升级后仍报同样的错,说明不只是 ID 的问题。 | 23 days ago | |
fix(data-dir): 数据目录默认到程序二进制同级的 data/ 容器 / 镜像里 HOME 不可靠:非 root 镜像下 /root 不存在或不可写, HOME 也未必是 /root,结果 Program.cs 里 PersistKeysToFileSystem 和 SQLite 默认路径(~/.mailclient/)直接创建失败或落到不可写位置。 改成默认与可执行文件同级 data/——容器里这就是工作目录, 权限由部署者自己控制(通常就是 image 的 WORKDIR)。 - 默认值:Path.GetDirectoryName(Environment.ProcessPath) / "data" - 用 Environment.ProcessPath 而非 AppContext.BaseDirectory: 单文件发布下后者指向临时解包目录,写入会丢 - 仍支持 MMAIL_DATA_DIR 环境变量整体覆盖(开发用 dotnet run 时 也建议设这个,因为此时 ProcessPath 是 dotnet 主机的路径) - appsettings.json 里的 ConnectionStrings.Default / Storage.Root 一并删除:用代码默认即可,部署者无路径可填 - PathHelper.Expand / Home 字段同步删除:与新逻辑无关 实测:publish/data/ 下正确生成 keys/ + mmail.db + WAL, /root/.mailclient 不再被创建。 | 25 days ago | |
feat(mail): 草稿箱邮件改为「编辑」,原稿在发送或保存后被删掉 草稿还没发出去,对它该做的是继续写,不是回信: - 阅读区右上角与列表右键菜单都按所在文件夹类型切换,草稿箱出「编辑」, 打开写信卡片并回填账号、收件人、主题与正文(列表右键现取一次详情, 因为列表项只有预览片段); - 写信卡片记住正在编辑的草稿,发送成功、或存草稿成功之后删掉原稿。 删除走新增的 DELETE /api/messages/{id}:IMAP 上打 \Deleted 再 EXPUNGE, 随后清本地记录与磁盘文件。服务器删除失败就不动本地——删了没法重试, 留着至少还看得见。端点只允许删草稿箱里的邮件:其余文件夹各家服务商的 删除语义并不一致(Gmail 的 EXPUNGE 是移入回收站),先不开放。 顺序统一为「先立新的、再拆旧的」:新稿已落定才动原稿, 否则一旦发送/存入失败就两边都没了。 | 18 days ago | |
feat(mail): 补齐正文渲染——内嵌图片、<style> 样式与远程图片开关 用户反馈正文缺图片少样式。排查下来消毒这块砍得比该砍的多: - 远程图片 src 一律降级成 data-src 等确认(防追踪像素,这层保留), 但「显示远程图片」按钮从未被渲染出来,等于是条死路径——现在有图 待确认时给按钮,点开才加载,文案写明代价; - cid: 内嵌图片整个被剥掉。新增 GET /api/messages/{id}/inline/{cid}, 原始 .eml 就在磁盘上,现场按 ContentId 找出那一份按 inline 出图, 不必为 ContentId 改表;cid/data 都不出网,无需等确认直接显示; - <style> 块被白名单剥掉,商业邮件排版几乎全在里面。放开标签本身, 块内 CSS 仍走 HtmlSanitizer 的 CSS 白名单。 放开 <style> 的前提是把正文装进 sandbox iframe(无 allow-scripts,消毒之外 的双保险;allow-same-origin 仅为读内容高度撑起 iframe):否则一封带 body{display:none} 的邮件就能把整个界面抹掉。 | 3 days ago | |
| 24 days ago | ||
fix(imap): 向服务器自报身份,修复网易系 EXAMINE 被拒 网易系(163 / 126 / 188)的 IMAP 会拒绝「不报身份」的客户端: 登录能过,一到打开文件夹就返回 EXAMINE Unsafe Login. Please contact kefu@188.com for help 结果是这些账号的收件箱与 Archive 全部同步失败,等于收不到邮件。 IMAP 的 ID 命令(RFC 2971)就是用来声明客户端身份的,Foxmail、 Thunderbird 这些都会发。MailKit 默认不发,补上这一次即可。 实现上三点: 1. 时机是认证之后、打开文件夹之前。ID 要求连接处于已认证状态, 而网易正是在 EXAMINE 那一步检查身份的。 2. contact 这一项只能塞进 Properties:ImapImplementation 没有对应的 属性,而网易要求的 name / version / vendor / contact 四项里就差它。 实测过 Properties 构造后恒非空,往里写是有效的。 3. 整段包在 try 里,只记 Debug 日志。ID 是可选扩展,服务器不实现是 常态;这一步失败只是没报上身份,不该连累后面。真正打不开文件夹的 错误照常抛出,不会被这里挡住。 四处连接点都补上:同步的两处(整账号 / 单文件夹),以及发信后回写 「已发送」与存草稿时的 IMAP 连接——它们同样要 Open,在网易上现在 也是坏的。 验证:起一个假 IMAP 服务器,直接调用项目自己的 ImapIdentify: 服务器声明支持 ID → 收到 ("name" "MMail" "version" "1.0.0" "vendor" "MMail" "contact" "u@x.com"), 且位于 EXAMINE 之前 服务器不声明 ID → 异常被吞掉,EXAMINE 照样成功 后者说明加了 try 不会让同步出问题:最坏情况就是维持现状。 局限:没有网易账号,无法实测网易服务器是否就此放行。 若升级后仍报同样的错,说明不只是 ID 的问题。 | 23 days ago | |
fix(ui): 磁盘占用改为后台统计、存库,页面只读结果 在请求里现算行不通:遍历成千上万个 .eml 与附件要几分钟,HTTP 请求等不起, 用户对着设置页只能一直看到「统计中」。 改为 StorageUsageService 后台统计:启动 5 秒后来一趟,之后每 30 分钟一趟, 结果写进 Setting 表(不新建表,省得为三个数据库各写一份迁移)。页面只读库里 那个数,并把统计时间放进 tooltip——数字是半小时前的,得让人知道。 两处细节:门闩保证同一时刻只统计一趟,页面催的和定时那趟不会撞在一起重跑; 还没有任何结果时(刚启动,或首次那趟没跑完)端点顺手催一趟、先回「未统计」, 前端补取一次就不再轮询——它可能要好几分钟,反复问没有意义。 | 16 days ago |
| Files | Last commit | Last update |
|---|---|---|
| 30 days ago | ||
| 24 days ago | ||
| 25 days ago | ||
| 28 days ago | ||
| 23 days ago | ||
| 25 days ago | ||
| 1 month ago | ||
| 23 days ago | ||
| 23 days ago | ||
| 25 days ago | ||
| 18 days ago | ||
| 3 days ago | ||
| 24 days ago | ||
| 23 days ago | ||
| 16 days ago |