From 4d0d64ff54e68583096a463230df8048d41812e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 19 Oct 2026 21:40:25 -0400 Subject: [PATCH] C12.7: POP3 fetch by UIDL --- agents.md | 2 +- apps/ely-mail/src/add_view.rs | 17 +- apps/ely-mail/src/adding.rs | 13 +- apps/ely-mail/src/kinds.rs | 9 +- apps/ely-mail/src/syncing.rs | 10 +- apps/ely-mail/src/tests/syncs.rs | 55 ++++-- apps/ely-mail/src/view.rs | 29 ++- crates/ely-mail-engine/src/account.rs | 7 +- crates/ely-mail-engine/src/pop.rs | 9 + crates/ely-mail-engine/src/store/folders.rs | 11 +- crates/ely-mail-engine/src/sync.rs | 54 +++++- crates/ely-mail-engine/tests/accounts.rs | 2 +- crates/ely-mail-engine/tests/pop_sync.rs | 194 ++++++++++++++++++++ crates/ely-mail-engine/tests/sync.rs | 3 +- crates/ely-ui/src/i18n/mail.rs | 20 +- tasks/TASKS.md | 2 +- tasks/reviews/C12.7-r1.md | 3 + tasks/reviews/C12.7-r2.md | 1 + 18 files changed, 394 insertions(+), 47 deletions(-) create mode 100644 crates/ely-mail-engine/tests/pop_sync.rs create mode 100644 tasks/reviews/C12.7-r1.md create mode 100644 tasks/reviews/C12.7-r2.md diff --git a/agents.md b/agents.md index 7f65211..02a6942 100644 --- a/agents.md +++ b/agents.md @@ -320,6 +320,6 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-18:Word 公式(R8.2,Gitea #9)。附件 math.docx 的六类公式逐类核对(本机无 Word,以文件所写为准):键盘输入、OMML(内核转为公式对象)、MathType(OLE,显示随带的 WMF 预览)、上下标、图片原本就对;差别在两处根因。其一,运行的 `w:position`(升降)原先只给文字,图片与 OLE 公式所在的运行失去它(25 张图片与一个 ½ 浮在行上):补丁 `0049-writer-inline-objects-move-with-their-run.patch` 把它作为固定升降写到对象的锚字符上(按锚字符自己的字号换算百分比,不扩展到其后的文字),版面让“作为字符”的对象随锚字符的固定升降移动(vertAlign 的自动上下标不动对象,同原样);各导出本就写出锚字符的升降,DOCX 往返仍为 `w:position`。其二,EQ 域原先只认 `\o`、`\s\do` 与注音,其余(如分数 `\f`)整个消失:补丁 `0050-writer-eq-field-parser.patch`(读法)与 `0051-writer-eq-fields-as-formulas.patch`(导入)把 EQ 域转为公式对象:`\f` 分数、`\r` 根式、`\s` 上下标、`\b` 括号、`\l` 列表、`\a` 阵列、`\i` 积分与求和求积;`\x` 与 `\o` 只留内容(无框、无叠印),`\d` 只前移,`\ai`、`\di` 只是行距(不升降),开关不分大小写;每个命令字符保留其运行的上下标(Word 照此画),同一段上下标里字样变了仍是一个上下标;斜体字母写成标识符(StarMath 与 OMML 都画成斜体,DOCX 往返不丢),斜体的数字与符号写成 `ital` 文字,存为 DOCX 后成正体(OMML 无此写法,已知限制);没有底的上下标以空文字为底(空的底在 OMML 里是占位符,重开成 ``);公式取域的字号;读不通的命令以其域代码显示为文字,不消失。只有整个域是 `\s\do N(文字)` 时照原样成为下移的文字,其余都经读法(原先小写的 `\s\do` 读到第一个括号即止,其后丢失)。`client/tests/word.rs` 以 `tests/fixtures/office/word.docx`(由 `.work/r8/gen/eq.py` 生成,不入库)核对公式文本(含开头的空格、大写开关、读不通的域、字样变化的下标、`\ai`、`\di`、小写 `\s\do` 的尾部与嵌套)、DOCX 往返后的斜体、下标与无占位符、锚字符的升降与图块里图片低于文字(去掉版面的升降即失败)。审查三轮:第 3 轮的四处已修并以上述测试核对,未再审查(每条至多三轮)。仍与 Word 不同:编辑区里公式与图片四周有内核的辅助框线(`ShowBoundaries`,视图辅助,不印出);DOCX 另存后重开行距变宽,因内核导出改写文档默认字体与段距,与公式无关。 - 2026-10-18:工作表过多时标签条(R8.3,Gitea #8)。组件库 `navigation::Tabs` 横排时可横向滚动(滚轮同样),标签可窄于自然宽度;所选标签在所选或标签条宽度变了时滚入视口,在绘制时以刚排好的宽度判定(原先只在首帧滚一次,其后右栏打开、标签条变窄,所选标签被挤出视口);手动滚动保留到二者之一再变;横排的标签至多与标签条同宽,名字过长以省略号结尾(组件库 e56a343、3a68d1b、7d704d9,同批带入上游列外壳拖动的四个提交)。ElyTable 的标签条随之收缩,添加按钮留在其旁;标签条的状态按工作簿各记一份(元素 id 带标签号),切到另一工作簿时其所选标签照样滚入。`navigation/tests/tabs.rs` 以 30 个标签核对:首帧滚到所选、滚轮回到第一个、新选的滚入、变窄后所选仍在末端(去掉按宽度判定即失败)、长名的所选标签整个在标签条内(去掉宽度上限即失败);ElyTable 的 `tests/strip.rs` 以 `sheets::strip` 画两个各有 30 张表、都选第 30 张的工作簿,滚回开头后来回切换,所选标签每次都在标签条内(两个工作簿共用一个 id 即失败)。Windows 真窗口(`.work/r8/strip3.png`):30 张表、第 30 张为当前,1100 宽时标签条末端为它。 - 2026-10-18:ElyMark 公式(R8.4,用户定两项都做)。行内公式照块公式由 RaTeX 排版(原只显示着色的 TeX 原文):gpui 的文本里放不进图片,组件库 `render/math_text.rs` 把每个行内公式换成等宽的不断行空格(U+00A0,按字体量出个数),排好的公式居中画在其上,行高随最高的公式撑开;空格串与词一样整个折到下一行,画在其所在行(折行处的序号 gpui 读作上一行末);按下与右键所在的字节经换算读回公式的原文;排不出的公式照旧显示 TeX 原文,其后以危险色写出 RaTeX 给的原因与位置,记警告(不含公式)。`\(…\)` 读作行内公式,`\[…\]` 读作块公式(ChatGPT 等常写的分隔符;GitHub 不认,Typora 默认不认):解析前逐字节把成对的分隔符换成 `$`(长度不变,位置照旧对得上)。成对只在同一段落、标题或单元格里(跨列表项、CR 空行不成对);不跨代码、公式、HTML 标签、链接地址与引用标签、图片、脚注引用,``、`` 之间(含嵌套;交叉闭合照可视的读法关闭与重开)的文字照原文;`\\(` 不换。公式里与段落里另有的 `$`(含 `\$`)解析时暂写成文本里没有的控制字节,解析后还原,不与换上的 `$` 配对;相邻的两个公式之间暂插一个空格,解析后去掉,仍是两个公式。公式节点的范围是整对分隔符,值是写下的 TeX 原文。独占一段的 `\[…\]` 为块公式(段首可有一至三个空格)。组件库公开 `documents::markdown_tree`,ElyMark 的 HTML、PDF、纯文本导出都经它解析(`markdown_options` 不再公开),纯文本里独占一段的公式照块公式写其 TeX。同时查出(组件库 e095f06):RaTeX 的 SVG 以 pt 定尺寸,usvg 按 px 量墨迹,两者差 4/3,每个公式的画面右侧与下方多出约四分之一的空白(块公式因此偏左)、导出的公式比正文大 4/3;现生成时即把根改为 px。`markdown/tex/tests.rs` 与 `tree/tests.rs` 核对分隔符的各种情形(相邻、`\$`、链接地址、``、跨块),`math_text.rs` 的窗口测试核对公式排进并读回原文、折到第二行的公式画在第二行首(改回按起点定位即失败)、排不出的公式写出原因(组件库 a20e712、5fba7b3、de961eb),`math/tests.rs` 核对画面与公式同宽(改回 pt 即失败),ElyMark 的 `visual.rs` 核对导出与纯文本。Windows 真窗口(`.work/r8/mark/visual2.png`):`$…$`、`\(…\)` 排在行内,`\[…\]` 居中成块,行内代码与转义的 `\\(` 照原文。 -- 2026-10-19(用户定):ElyMail 的后端改为 Rust crate,取代 MailCore 2(其 Windows 构建是 VS 2013 工程加 2015 年前后的预编译依赖,POP 无 OAuth2):mail-send、mail-parser、mail-builder,在应用进程的后台任务里运行,不另起 worker;MailCore 2 与 libetpan 的取码、构建与探测随之删去。正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制,不当作完整页面;写信用 Markdown 文档组件,发出纯文本与 HTML。Gmail 与 Microsoft 走 OAuth(授权码加 PKCE,本机回环),客户端 ID 在构建时注入:`ELY_GOOGLE_CLIENT_ID`、`ELY_GOOGLE_CLIENT_SECRET`、`ELY_MICROSOFT_CLIENT_ID`,本机写在项目根的 `oauth.env`(不入库),CI 为仓库机密;缺 ID 的构建里该登录写明不可用,不退回别的方式。iCloud 不给第三方 OAuth,走应用专用密码向导(`imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)。注册由用户做(Google Desktop 客户端,Entra 公共客户端,委托权限 IMAP、POP、SMTP 与 offline_access)。POP3(C12.1 审查后)由引擎自写,不用 async-pop:它留着点填充、丢末尾 CRLF、吞掉登录后的连接错误、不答 XOAUTH2 的错误挑战、在 trace 日志里写出密码。IMAP(第 2 轮审查后)同样自写,以 imap-proto 解析回复,不用 async-imap:它的 LIST、FETCH 等流式命令不看结尾的 NO 与 BAD(拒绝成了空结果),登录与命令中途的 `* BYE` 被丢掉(只剩断线),trace 日志写出密码与邮件。SMTP 的事务(MAIL、RCPT、DATA)也由引擎自走:mail-send 不给首行的点加点;正文有 8 位字节即带 `BODY=8BITMIME`,服务器不支持即拒绝。读信按 MIME 树与所在容器(C12.2),嵌套(多部分与内嵌邮件都算)深于 64 层即报错,超深的解析结果以迭代拆开再丢(直接丢弃会递归、栈溢出);mail-parser 开 `full_encoding`;SMTPUTF8 按每一层的头块判定。本地存储(C12.3):rusqlite 自带的 SQLite,`mail.db` 加内容目录(原文按 SHA-256),FTS5(trigram)可由索引与原文重建;`user_version` 记结构版本,升级前备份,失败即只读;原文文件在删去或替换邮件的事务提交前删去,删不掉即回滚。只有 HTML 的邮件经 html5ever 的树构建器取文字入索引,元素深过 512 层即停读(树构建器的开放元素栈扫描随深度平方增长)。凭据(C12.4):密码与刷新令牌只进系统凭据库(keyring),被拒即只留在内存到退出;OAuth 换令牌与刷新经 ureq,回跳在 127.0.0.1 随机端口;Google 授权带 `access_type=offline` 与 `prompt=consent`(每次同意都发刷新令牌),Microsoft 另要 `profile`(无 `email` 时以 `preferred_username` 为地址),换码没有刷新令牌即报错;客户端 ID 由 `crates/ely-mail-engine/build.rs` 取环境变量或 `oauth.env`,该文件不存在即未配置,读不出或写错即构建失败。ureq 在 trace 级逐字节记下请求与回复(令牌、客户端密钥),`ely_platform::logging` 在任何 `ELY_LOG` 下都不记 `ureq`、`ureq_proto` 低于 info 的日志。账号(C12.5):`ely_mail_engine::Account` 存在 accounts 表的 settings 列(JSON),凭据库名 `account:地址`;删账号先删凭据再删本地数据,本地删除失败即放回凭据,凭据库删不掉时内存里的凭据照留,不动服务端;地址在库里唯一,不分 ASCII 大小写(`Error::Taken`)。应用的库读写在后台、锁后进行,网络任务在应用自带的两线程 tokio 运行时上,结果交回 gpui。iCloud 收发都以完整地址为用户名。删去原文先改名为 `.散列.gone` 留在原处,事务提交后才删,失败即改回;打开库时按库里是否仍有邮件指向它放回或删去(C12.5 第 1 轮后)。对话框的请求(检查、登录、保存)在整个窗口里编号,回来时只作用于仍在等它的那个对话框;保存的结果照样更新账号列表,失败而对话框已换即以提示写明。端口记下上次自动填入的值,与之不同的即为键入,换安全方式不覆盖。IMAP 同步(C12.6):文件夹按 LIST 存为邮箱(角色按 RFC 6154,INBOX 按名、库里不分大小写;有 LIST-EXTENDED 与 SPECIAL-USE 即以 `RETURN (SPECIAL-USE)` 取角色),不再列出的连同邮件删去;EXAMINE 缺 UIDVALIDITY 或 EXISTS 即报错;UIDVALIDITY 变了即清空重建;同一 UID 的 FETCH 回复合并;邮件头全取,正文只取 90 天内(`sync::BODY_DAYS`):先读 BODYSTRUCTURE,无附件即取整封,有附件只取头与正文部分、按原结构重组,附件内容为空、部分号记在 `missing`,附件按需(C12.17),处置为 attachment 的多部分整个按需,related 里不看处置(RFC 2387);新列由结构 v2 升级加上,UIDVALIDITY 不同于所记时只删别的 UIDVALIDITY 下的邮件;`start` 指向多部分(BODYSTRUCTURE 无其 Content-ID)时各多部分子项照正文读;任何一步失败即停,成功才记同步时间;打开窗口与添加账号后各同步一次;本机 TLS 初始化失败记为该账号的同步失败。 +- 2026-10-19(用户定):ElyMail 的后端改为 Rust crate,取代 MailCore 2(其 Windows 构建是 VS 2013 工程加 2015 年前后的预编译依赖,POP 无 OAuth2):mail-send、mail-parser、mail-builder,在应用进程的后台任务里运行,不另起 worker;MailCore 2 与 libetpan 的取码、构建与探测随之删去。正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制,不当作完整页面;写信用 Markdown 文档组件,发出纯文本与 HTML。Gmail 与 Microsoft 走 OAuth(授权码加 PKCE,本机回环),客户端 ID 在构建时注入:`ELY_GOOGLE_CLIENT_ID`、`ELY_GOOGLE_CLIENT_SECRET`、`ELY_MICROSOFT_CLIENT_ID`,本机写在项目根的 `oauth.env`(不入库),CI 为仓库机密;缺 ID 的构建里该登录写明不可用,不退回别的方式。iCloud 不给第三方 OAuth,走应用专用密码向导(`imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)。注册由用户做(Google Desktop 客户端,Entra 公共客户端,委托权限 IMAP、POP、SMTP 与 offline_access)。POP3(C12.1 审查后)由引擎自写,不用 async-pop:它留着点填充、丢末尾 CRLF、吞掉登录后的连接错误、不答 XOAUTH2 的错误挑战、在 trace 日志里写出密码。IMAP(第 2 轮审查后)同样自写,以 imap-proto 解析回复,不用 async-imap:它的 LIST、FETCH 等流式命令不看结尾的 NO 与 BAD(拒绝成了空结果),登录与命令中途的 `* BYE` 被丢掉(只剩断线),trace 日志写出密码与邮件。SMTP 的事务(MAIL、RCPT、DATA)也由引擎自走:mail-send 不给首行的点加点;正文有 8 位字节即带 `BODY=8BITMIME`,服务器不支持即拒绝。读信按 MIME 树与所在容器(C12.2),嵌套(多部分与内嵌邮件都算)深于 64 层即报错,超深的解析结果以迭代拆开再丢(直接丢弃会递归、栈溢出);mail-parser 开 `full_encoding`;SMTPUTF8 按每一层的头块判定。本地存储(C12.3):rusqlite 自带的 SQLite,`mail.db` 加内容目录(原文按 SHA-256),FTS5(trigram)可由索引与原文重建;`user_version` 记结构版本,升级前备份,失败即只读;原文文件在删去或替换邮件的事务提交前删去,删不掉即回滚。只有 HTML 的邮件经 html5ever 的树构建器取文字入索引,元素深过 512 层即停读(树构建器的开放元素栈扫描随深度平方增长)。凭据(C12.4):密码与刷新令牌只进系统凭据库(keyring),被拒即只留在内存到退出;OAuth 换令牌与刷新经 ureq,回跳在 127.0.0.1 随机端口;Google 授权带 `access_type=offline` 与 `prompt=consent`(每次同意都发刷新令牌),Microsoft 另要 `profile`(无 `email` 时以 `preferred_username` 为地址),换码没有刷新令牌即报错;客户端 ID 由 `crates/ely-mail-engine/build.rs` 取环境变量或 `oauth.env`,该文件不存在即未配置,读不出或写错即构建失败。ureq 在 trace 级逐字节记下请求与回复(令牌、客户端密钥),`ely_platform::logging` 在任何 `ELY_LOG` 下都不记 `ureq`、`ureq_proto` 低于 info 的日志。账号(C12.5):`ely_mail_engine::Account` 存在 accounts 表的 settings 列(JSON),凭据库名 `account:地址`;删账号先删凭据再删本地数据,本地删除失败即放回凭据,凭据库删不掉时内存里的凭据照留,不动服务端;地址在库里唯一,不分 ASCII 大小写(`Error::Taken`)。应用的库读写在后台、锁后进行,网络任务在应用自带的两线程 tokio 运行时上,结果交回 gpui。iCloud 收发都以完整地址为用户名。删去原文先改名为 `.散列.gone` 留在原处,事务提交后才删,失败即改回;打开库时按库里是否仍有邮件指向它放回或删去(C12.5 第 1 轮后)。对话框的请求(检查、登录、保存)在整个窗口里编号,回来时只作用于仍在等它的那个对话框;保存的结果照样更新账号列表,失败而对话框已换即以提示写明。端口记下上次自动填入的值,与之不同的即为键入,换安全方式不覆盖。IMAP 同步(C12.6):文件夹按 LIST 存为邮箱(角色按 RFC 6154,INBOX 按名、库里不分大小写;有 LIST-EXTENDED 与 SPECIAL-USE 即以 `RETURN (SPECIAL-USE)` 取角色),不再列出的连同邮件删去;EXAMINE 缺 UIDVALIDITY 或 EXISTS 即报错;UIDVALIDITY 变了即清空重建;同一 UID 的 FETCH 回复合并;邮件头全取,正文只取 90 天内(`sync::BODY_DAYS`):先读 BODYSTRUCTURE,无附件即取整封,有附件只取头与正文部分、按原结构重组,附件内容为空、部分号记在 `missing`,附件按需(C12.17),处置为 attachment 的多部分整个按需,related 里不看处置(RFC 2387);新列由结构 v2 升级加上,UIDVALIDITY 不同于所记时只删别的 UIDVALIDITY 下的邮件;`start` 指向多部分(BODYSTRUCTURE 无其 Content-ID)时各多部分子项照正文读;任何一步失败即停,成功才记同步时间;打开窗口与添加账号后各同步一次;本机 TLS 初始化失败记为该账号的同步失败。POP3 收取(C12.7):`Protocol::Pop { keep }`,一个 INBOX,按 UIDL 整封取一次,不保留副本时存下之后才 DELE,服务端删去的本地照留。 - 2026-10-19(用户定):代码与发布从 Gitea 搬到 GitHub 组织 ELY-Open-Source(`ELY-Open-Source/ElyOffice`;组件库 `ELY-Open-Source/Ely-GPUI-Components`,Cargo 的 git 依赖与 `fetch-references.sh` 随之改址,钉的提交不变;Kigi CLI 同在此组织)。历史、标签与发布已迁过,Gitea 停用,GitHub 是唯一的家。发布由 `.github/workflows/release.yml` 在推送 `v*` 标签时运行(只此一种触发,拉取请求到不了自托管机器):macOS 在 `[self-hosted, macos-signing]`(Mac mini 的图形会话,Developer ID 签名并公证),Windows 与 Linux(WSL)在 `[self-hosted, ely-windows]`;各机的常驻克隆 `$HOME/ely-ci/ElyOffice` 从 GitHub 拉取。`scripts/ci.sh` 经 GitHub API 按标签取发布、没有就建(`GITHUB_TOKEN`,`contents: write`),同名文件先删再传:重跑只换本平台的文件。更新检查读 `api.github.com/repos/ELY-Open-Source/ElyOffice/releases/latest` 的 `.json`,清单的下载地址为 `github.com/ELY-Open-Source/ElyOffice/releases/download/v<版本>/<文件>`;0.1.8 及以前装好的应用仍查 Gitea,需手动装一次新版。官网 worker 读 GitHub API(可选机密 `GITHUB_TOKEN` 避开共享出口地址的限流),下载与源码链接指向 GitHub(GitHub 标志);macOS 版已公证,去掉“未经公证”的提示。发布视频的源文件与成片里仍是 Gitea 地址,未改未重渲染。 - 2026-10-19(用户定):自有代码由 AGPL-3.0-or-later 改为 MIT(`LICENSE`,Copyright (c) 2026 Yang Zhang;工作区 `Cargo.toml`、Linux 包的许可字段、`docs/licenses.md`、官网)。第三方照旧:LibreOffice 与 `patches/libreoffice` 为 MPL-2.0 等,Git 为 GPL-2.0,MuPDF 为 AGPL-3.0;ElyPDF 链接 MuPDF,其发行包整体仍须按 AGPL 履行,Linux 包的许可字段据此写为 `MIT AND <引擎许可>`。 diff --git a/apps/ely-mail/src/add_view.rs b/apps/ely-mail/src/add_view.rs index 1c178c1..7708fa3 100644 --- a/apps/ely-mail/src/add_view.rs +++ b/apps/ely-mail/src/add_view.rs @@ -4,7 +4,7 @@ use ely_gpui_component::{ buttons::{Button, ButtonVariant, SegmentedControl}, data_display::DescriptionList, feedback::{Banner, InlineMessage}, - forms::{Input, PasswordInput}, + forms::{Input, PasswordInput, Switch}, overlays::Dialog, primitives::Severity, }; @@ -207,6 +207,21 @@ impl Mail { .item(label("mail.add.user"), field(&fields.user)) .item(label("mail.add.password"), password); } + if adding.kind == Kind::Pop { + let this = this.clone(); + let keep = Switch::new("mail-add-keep", adding.keep) + .name(label("mail.add.keep")) + .on_change(move |on, _, cx| { + this.update(cx, |mail, cx| { + if let Some(adding) = &mut mail.adding { + adding.set_keep(on); + cx.notify(); + } + }) + .ok(); + }); + rows = rows.item(label("mail.add.keep"), keep); + } rows.item(label("mail.add.incoming"), field(&fields.incoming)) .item(label("mail.add.port"), field(&fields.incoming_port)) .item( diff --git a/apps/ely-mail/src/adding.rs b/apps/ely-mail/src/adding.rs index 06abee9..a874542 100644 --- a/apps/ely-mail/src/adding.rs +++ b/apps/ely-mail/src/adding.rs @@ -42,6 +42,8 @@ pub struct Adding { pub outgoing_security: Security, /// Typed-in accounts choose; providers fix it. pub auth: Auth, + /// POP3 leaves a copy on the server (§12.3). + pub keep: bool, pub step: Step, /// The ports last filled in; any other was typed. filled: [String; 2], @@ -108,6 +110,7 @@ impl Adding { incoming_security: Security::Tls, outgoing_security: Security::StartTls, auth: Auth::Password, + keep: true, step: Step::Editing, filled: Default::default(), asked: None, @@ -144,10 +147,16 @@ impl Adding { self.set_ports(cx); } + pub fn set_keep(&mut self, keep: bool) { + self.stop(); + self.step = Step::Editing; + self.keep = keep; + } + /// The usual ports, unless one was typed. fn set_ports(&mut self, cx: &mut App) { let ports = [ - kinds::port(Some(self.kind.protocol()), self.incoming_security), + kinds::port(Some(self.kind.protocol(self.keep)), self.incoming_security), kinds::port(None, self.outgoing_security), ]; let fields = [&self.fields.incoming_port, &self.fields.outgoing_port]; @@ -229,7 +238,7 @@ impl Adding { address, user, auth: self.auth(), - protocol: self.kind.protocol(), + protocol: self.kind.protocol(self.keep), incoming, outgoing, }; diff --git a/apps/ely-mail/src/kinds.rs b/apps/ely-mail/src/kinds.rs index e1af3d0..5a9894e 100644 --- a/apps/ely-mail/src/kinds.rs +++ b/apps/ely-mail/src/kinds.rs @@ -37,9 +37,10 @@ impl Kind { KINDS.into_iter().find(|kind| kind.key() == key) } - pub fn protocol(self) -> Protocol { + /// `keep` is POP3's server copy. + pub fn protocol(self, keep: bool) -> Protocol { match self { - Kind::Pop => Protocol::Pop, + Kind::Pop => Protocol::Pop { keep }, _ => Protocol::Imap, } } @@ -78,8 +79,8 @@ pub fn port(protocol: Option, security: Security) -> u16 { match (protocol, security) { (Some(Protocol::Imap), Security::Tls) => 993, (Some(Protocol::Imap), Security::StartTls) => 143, - (Some(Protocol::Pop), Security::Tls) => 995, - (Some(Protocol::Pop), Security::StartTls) => 110, + (Some(Protocol::Pop { .. }), Security::Tls) => 995, + (Some(Protocol::Pop { .. }), Security::StartTls) => 110, (None, Security::Tls) => 465, (None, Security::StartTls) => 587, } diff --git a/apps/ely-mail/src/syncing.rs b/apps/ely-mail/src/syncing.rs index 6991532..7676ebb 100644 --- a/apps/ely-mail/src/syncing.rs +++ b/apps/ely-mail/src/syncing.rs @@ -1,4 +1,4 @@ -//! IMAP accounts sync on open and once added. +//! Accounts sync on open and once added. use std::{ collections::HashMap, @@ -26,9 +26,6 @@ impl Mail { let Some((_, account)) = self.accounts().iter().find(|(each, _)| *each == id) else { return log::info!("mail: sync asked of a gone account"); }; - if account.protocol != Protocol::Imap { - return; - } if self.syncs.get(&id).is_some_and(|each| each.task.is_some()) { return log::info!("mail: account {} is already syncing", id.0); } @@ -50,7 +47,10 @@ impl Mail { .map_err(|err| ely_mail_engine::Error::Store(format!("the clock: {err}")))? .as_secs() as i64; let secret = sync::secret(&account, &vault).await?; - sync::imap(&account, id, secret, &tls, &store, now).await + match account.protocol { + Protocol::Imap => sync::imap(&account, id, secret, &tls, &store, now).await, + Protocol::Pop { .. } => sync::pop(&account, id, secret, &tls, &store, now).await, + } }; let task = self.on_runtime(cx, work, move |mail, done, cx| { let failed = match done { diff --git a/apps/ely-mail/src/tests/syncs.rs b/apps/ely-mail/src/tests/syncs.rs index c7d2d64..2b58bfd 100644 --- a/apps/ely-mail/src/tests/syncs.rs +++ b/apps/ely-mail/src/tests/syncs.rs @@ -1,41 +1,45 @@ use ely_mail_engine::{Protocol, Store}; use gpui::TestAppContext; -use super::{Keys, account, closed_port, settle, window, window_with}; +use super::{Keys, account, adding, closed_port, settle, type_in, window, window_with}; +use crate::kinds::Kind; #[test] -fn an_imap_account_syncs_on_open_and_says_why_it_failed() { +fn every_account_syncs_on_open_and_says_why_it_failed() { let dir = tempfile::tempdir().unwrap(); let store = Store::open(dir.path()).unwrap(); let mut ann = account("ann@example.com"); ann.incoming.port = closed_port(); let ann = store.add_account(&ann).unwrap(); - let pop = ely_mail_engine::Account { - protocol: Protocol::Pop, + let mut pop = ely_mail_engine::Account { + protocol: Protocol::Pop { keep: true }, ..account("bo@example.com") }; + pop.incoming.port = closed_port(); let bo = store.add_account(&pop).unwrap(); drop(store); let keys = Keys::default(); keys.kept .lock() .unwrap() - .insert("account:ann@example.com".into(), "pw".into()); + .extend(["ann", "bo"].map(|name| (format!("account:{name}@example.com"), "pw".into()))); let mut cx = TestAppContext::single(); let (mail, cx) = window(&mut cx, dir.path(), keys); settle(cx, |app| { - mail.read(app) - .syncs - .get(&ann) - .is_some_and(|each| each.task.is_none() && each.synced.is_some()) + [ann, bo].iter().all(|id| { + mail.read(app) + .syncs + .get(id) + .is_some_and(|each| each.task.is_none() && each.synced.is_some()) + }) }); cx.update(|_, app| { - let mail = mail.read(app); - let syncing = &mail.syncs[&ann]; - let failed = syncing.failed.as_deref().unwrap(); - assert!(failed.starts_with("cannot connect"), "{failed}"); - assert_eq!(syncing.synced.unwrap().at, None); - assert!(!mail.syncs.contains_key(&bo), "POP3 is collected apart"); + for id in [ann, bo] { + let syncing = &mail.read(app).syncs[&id]; + let failed = syncing.failed.as_deref().unwrap(); + assert!(failed.starts_with("cannot connect"), "{failed}"); + assert_eq!(syncing.synced.unwrap().at, None); + } }); } @@ -88,3 +92,24 @@ fn a_missing_tls_is_the_sync_failure() { assert!(syncing.task.is_none()); }); } + +#[test] +fn a_pop_account_keeps_a_server_copy_unless_told() { + let dir = tempfile::tempdir().unwrap(); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), Keys::default()); + adding(&mail, Kind::Pop, cx); + type_in(&mail, cx, |f| &f.address, "bo@example.com"); + type_in(&mail, cx, |f| &f.user, "bo"); + type_in(&mail, cx, |f| &f.incoming, "pop.example.com"); + type_in(&mail, cx, |f| &f.outgoing, "smtp.example.com"); + let protocol = |cx: &mut gpui::VisualTestContext| { + cx.update(|_, app| { + let adding = mail.read(app).adding.as_ref().unwrap(); + adding.account(None, app).unwrap().protocol + }) + }; + assert_eq!(protocol(cx), Protocol::Pop { keep: true }); + cx.update(|_, app| mail.update(app, |mail, _| mail.adding.as_mut().unwrap().set_keep(false))); + assert_eq!(protocol(cx), Protocol::Pop { keep: false }); +} diff --git a/apps/ely-mail/src/view.rs b/apps/ely-mail/src/view.rs index 97908c9..04fabd0 100644 --- a/apps/ely-mail/src/view.rs +++ b/apps/ely-mail/src/view.rs @@ -97,7 +97,7 @@ impl Mail { server(&account.outgoing, cx), ) .text(label("mail.account.auth"), auth(account.auth, cx)); - let (details, failed) = self.sync_rows(*id, details, cx); + let (details, failed) = self.sync_rows(*id, account.protocol, details, cx); let remove = Button::new("mail-remove", label("mail.remove.open")) .on_click(|_, window, cx| window.dispatch_action(Box::new(menu::RemoveAccount), cx)); div() @@ -116,6 +116,7 @@ impl Mail { fn sync_rows( &self, id: AccountId, + protocol: Protocol, details: DescriptionList, cx: &App, ) -> (DescriptionList, Option) { @@ -128,12 +129,24 @@ impl Mail { (None, Some(at)) => local_time(at).into(), (None, None) => label("mail.sync.never"), }; - let days = sync::BODY_DAYS.to_string(); - let mut details = details.text(label("mail.sync.last"), last).text( - label("mail.sync.range"), - text(cx, "mail.sync.range_value", &[("days", &days)]), - ); - if let Some(synced) = syncing.synced { + let mut details = details.text(label("mail.sync.last"), last); + if let Protocol::Pop { keep } = protocol { + let server = label(if keep { + "mail.sync.kept" + } else { + "mail.sync.deleted" + }); + details = details + .text(label("mail.sync.range"), label("mail.sync.range_pop")) + .text(label("mail.sync.server"), server); + } else { + let days = sync::BODY_DAYS.to_string(); + details = details.text( + label("mail.sync.range"), + text(cx, "mail.sync.range_value", &[("days", &days)]), + ); + } + if let (Some(synced), Protocol::Imap) = (syncing.synced, protocol) { for (key, n) in [ ("mail.sync.uncached", synced.uncached), ("mail.sync.partial", synced.partial), @@ -196,7 +209,7 @@ impl Mail { pub fn protocol_name(protocol: Protocol) -> &'static str { match protocol { Protocol::Imap => "IMAP", - Protocol::Pop => "POP3", + Protocol::Pop { .. } => "POP3", } } diff --git a/crates/ely-mail-engine/src/account.rs b/crates/ely-mail-engine/src/account.rs index 3daa2da..4eda45f 100644 --- a/crates/ely-mail-engine/src/account.rs +++ b/crates/ely-mail-engine/src/account.rs @@ -8,7 +8,10 @@ use crate::{Error, Imap, Login, Pop, Result, Secret, Server, Smtp, TlsConnector, #[serde(rename_all = "lowercase")] pub enum Protocol { Imap, - Pop, + /// `keep` leaves each message on the server (§12.3). + Pop { + keep: bool, + }, } /// A password, or OAuth with one provider. @@ -106,7 +109,7 @@ impl Account { .logout() .await } - Protocol::Pop => Pop::connect(&self.incoming, login, tls).await?.quit().await, + Protocol::Pop { .. } => Pop::connect(&self.incoming, login, tls).await?.quit().await, } } } diff --git a/crates/ely-mail-engine/src/pop.rs b/crates/ely-mail-engine/src/pop.rs index 463a4ab..0cd2c3e 100644 --- a/crates/ely-mail-engine/src/pop.rs +++ b/crates/ely-mail-engine/src/pop.rs @@ -67,6 +67,15 @@ impl Pop { .await? } + /// Marks it deleted; QUIT carries that out. + pub async fn delete(&mut self, number: usize) -> Result<()> { + within( + "deleting the message", + ask(&mut self.io, &format!("DELE {number}")), + ) + .await? + } + pub async fn quit(mut self) -> Result<()> { within("logging out", ask(&mut self.io, "QUIT")).await??; Ok(()) diff --git a/crates/ely-mail-engine/src/store/folders.rs b/crates/ely-mail-engine/src/store/folders.rs index acaac97..4919a6e 100644 --- a/crates/ely-mail-engine/src/store/folders.rs +++ b/crates/ely-mail-engine/src/store/folders.rs @@ -1,6 +1,6 @@ //! IMAP folders as mailboxes, and how far they are synced. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use rusqlite::{Transaction, params}; @@ -150,6 +150,15 @@ impl Store { Ok(held) } + /// The UIDLs a POP3 mailbox holds. + pub fn uidls(&self, mailbox: MailboxId) -> Result> { + let mut query = self + .conn + .prepare("SELECT uidl FROM messages WHERE mailbox = ?1 AND uidl IS NOT NULL")?; + let rows = query.query_map([mailbox.0], |row| row.get(0))?; + Ok(rows.collect::>()?) + } + pub fn set_synced(&self, account: AccountId, at: i64) -> Result<()> { self.writable()?; let changed = self.conn.execute( diff --git a/crates/ely-mail-engine/src/sync.rs b/crates/ely-mail-engine/src/sync.rs index 5c2f99e..671f6bd 100644 --- a/crates/ely-mail-engine/src/sync.rs +++ b/crates/ely-mail-engine/src/sync.rs @@ -1,4 +1,4 @@ -//! IMAP sync: folders, flags, deletions and the 90-day window (§12.3). +//! IMAP and POP3 sync (§12.3). use std::{ collections::{HashMap, HashSet}, @@ -6,8 +6,8 @@ use std::{ }; use crate::{ - Account, AccountId, Error, Fetched, Got, Held, Imap, Key, MailboxId, Result, Secret, Store, - TlsConnector, Vault, oauth, + Account, AccountId, Error, Fetched, Got, Held, Imap, Key, MailboxId, Pop, Protocol, Result, + Role, Secret, Store, TlsConnector, Vault, oauth, }; /// Bodies come down for mail this recent. @@ -23,6 +23,8 @@ pub struct Report { pub headers: usize, pub bodies: usize, pub removed: usize, + /// POP3 messages deleted from the server. + pub deleted: usize, } /// The password, or an access token from the refresh token. @@ -83,6 +85,52 @@ pub async fn imap( Ok(report) } +/// New mail by UIDL; no folders, local copies stay. +pub async fn pop( + account: &Account, + id: AccountId, + secret: Secret, + tls: &TlsConnector, + store: &Mutex, + now: i64, +) -> Result { + let Protocol::Pop { keep } = account.protocol else { + return Err(Error::Invalid("a POP3 sync of an IMAP account".into())); + }; + let mut pop = Pop::connect(&account.incoming, &account.login(secret), tls).await?; + let inbox = match lock(store)?.set_folders(id, &[("INBOX", Some(Role::Inbox))])?[..] { + [inbox] => inbox, + _ => return Err(Error::Store("a POP3 account has one inbox".into())), + }; + let mut held = lock(store)?.uidls(inbox)?; + let mut report = Report { + folders: 1, + ..Report::default() + }; + for (number, uidl) in pop.uids().await? { + if !held.contains(&uidl) { + let bytes = pop.fetch(number).await?; + let key = Key::Pop { uidl: uidl.clone() }; + lock(store)?.put(inbox, &key, &[], Fetched::Whole(&bytes))?; + held.insert(uidl); + report.bodies += 1; + } + if !keep { + pop.delete(number).await?; + report.deleted += 1; + } + } + pop.quit().await?; + lock(store)?.set_synced(id, now)?; + log::info!( + "mail: account {} synced: {} new, {} deleted on the server", + id.0, + report.bodies, + report.deleted + ); + Ok(report) +} + async fn one( imap: &mut Imap, store: &Mutex, diff --git a/crates/ely-mail-engine/tests/accounts.rs b/crates/ely-mail-engine/tests/accounts.rs index 8574332..e810024 100644 --- a/crates/ely-mail-engine/tests/accounts.rs +++ b/crates/ely-mail-engine/tests/accounts.rs @@ -11,7 +11,7 @@ fn pop_oauth(address: &str) -> Account { address: address.into(), user: "bo".into(), auth: Auth::Microsoft, - protocol: Protocol::Pop, + protocol: Protocol::Pop { keep: true }, incoming: Server { host: "pop.example.com".into(), port: 995, diff --git a/crates/ely-mail-engine/tests/pop_sync.rs b/crates/ely-mail-engine/tests/pop_sync.rs new file mode 100644 index 0000000..3f2d502 --- /dev/null +++ b/crates/ely-mail-engine/tests/pop_sync.rs @@ -0,0 +1,194 @@ +mod fake; + +use std::sync::Mutex; + +use ely_mail_engine::{ + Account, AccountId, Error, Key, Protocol, Role, Secret, Security, Store, sync, +}; +use fake::{account, certs, expect, send, serve}; + +const NOW: i64 = 1_792_411_200; + +fn opened(keep: bool) -> (tempfile::TempDir, Mutex, Account, AccountId) { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open(dir.path()).unwrap(); + let account = Account { + protocol: Protocol::Pop { keep }, + ..account("ann@example.com") + }; + let id = store.add_account(&account).unwrap(); + (dir, Mutex::new(store), account, id) +} + +/// A login, then `steps`. +async fn run( + store: &Mutex, + account: &Account, + id: AccountId, + steps: Vec, +) -> Result { + let certs = certs(); + let mut script = vec![ + send("+OK ready\n"), + expect("USER ann@example.com"), + send("+OK\n"), + expect("PASS pw"), + send("+OK in\n"), + ]; + script.extend(steps); + let (incoming, task) = serve(&certs, Security::Tls, script).await; + let account = Account { + incoming, + ..account.clone() + }; + let secret = Secret::Password("pw".into()); + let done = sync::pop(&account, id, secret, &certs.trusted, store, NOW).await; + if done.is_ok() { + task.await.unwrap().unwrap(); + } + done +} + +fn subjects(store: &Mutex, id: AccountId) -> Vec<(String, String)> { + let store = store.lock().unwrap(); + let [(inbox, name, role)] = store.folders(id).unwrap().try_into().unwrap(); + assert_eq!((name.as_str(), role), ("INBOX", Some(Role::Inbox))); + let mut out: Vec<_> = store + .uidls(inbox) + .unwrap() + .into_iter() + .map(|uidl| { + let key = Key::Pop { uidl: uidl.clone() }; + let found = store.find(inbox, &key).unwrap().unwrap(); + let stored = store.get(found).unwrap(); + assert!(stored.body); + (uidl, stored.subject.unwrap()) + }) + .collect(); + out.sort(); + out +} + +fn quit() -> [fake::Step; 2] { + [expect("QUIT"), send("+OK bye\n")] +} + +#[tokio::test] +async fn new_mail_comes_down_once_and_stays_on_the_server() { + let (_dir, store, account, id) = opened(true); + let mut first = vec![ + expect("UIDL"), + send("+OK\n1 aa1\n2 bb2\n.\n"), + expect("RETR 1"), + send("+OK\nSubject: One\n\nhi\n.\n"), + expect("RETR 2"), + send("+OK\nSubject: Two\n\nhi\n.\n"), + ]; + first.extend(quit()); + let report = run(&store, &account, id, first).await.unwrap(); + assert_eq!((report.bodies, report.deleted), (2, 0)); + assert_eq!(store.lock().unwrap().synced(id).unwrap().at, Some(NOW)); + let mut second = vec![ + expect("UIDL"), + send("+OK\n1 aa1\n2 bb2\n3 cc3\n.\n"), + expect("RETR 3"), + send("+OK\nSubject: Three\n\nhi\n.\n"), + ]; + second.extend(quit()); + let report = run(&store, &account, id, second).await.unwrap(); + assert_eq!((report.bodies, report.deleted), (1, 0)); + let held = subjects(&store, id); + assert_eq!( + held, + [("aa1", "One"), ("bb2", "Two"), ("cc3", "Three")] + .map(|(uidl, subject)| (uidl.to_owned(), subject.to_owned())) + ); +} + +#[tokio::test] +async fn without_keep_each_stored_message_is_deleted() { + let (_dir, store, account, id) = opened(false); + let mut first = vec![ + expect("UIDL"), + send("+OK\n1 aa1\n.\n"), + expect("RETR 1"), + send("+OK\nSubject: One\n\nhi\n.\n"), + expect("DELE 1"), + send("+OK deleted\n"), + ]; + first.extend(quit()); + run(&store, &account, id, first).await.unwrap(); + // A held message the server still lists goes too. + let mut second = vec![ + expect("UIDL"), + send("+OK\n1 aa1\n2 bb2\n.\n"), + expect("DELE 1"), + send("+OK deleted\n"), + expect("RETR 2"), + send("+OK\nSubject: Two\n\nhi\n.\n"), + expect("DELE 2"), + send("+OK deleted\n"), + ]; + second.extend(quit()); + let report = run(&store, &account, id, second).await.unwrap(); + assert_eq!((report.bodies, report.deleted), (1, 2)); + assert_eq!(subjects(&store, id).len(), 2); +} + +#[tokio::test] +async fn a_uidl_listed_twice_comes_down_once() { + for keep in [true, false] { + let (_dir, store, account, id) = opened(keep); + let mut steps = vec![ + expect("UIDL"), + send("+OK\n1 aa1\n2 aa1\n.\n"), + expect("RETR 1"), + send("+OK\nSubject: One\n\nhi\n.\n"), + ]; + if !keep { + steps.extend([ + expect("DELE 1"), + send("+OK deleted\n"), + expect("DELE 2"), + send("+OK deleted\n"), + ]); + } + steps.extend(quit()); + let report = run(&store, &account, id, steps).await.unwrap(); + let deleted = if keep { 0 } else { 2 }; + assert_eq!((report.bodies, report.deleted), (1, deleted)); + assert_eq!(subjects(&store, id).len(), 1); + } +} + +#[tokio::test] +async fn a_refused_message_stops_the_sync_before_any_delete() { + let (_dir, store, account, id) = opened(false); + let steps = vec![ + expect("UIDL"), + send("+OK\n1 aa1\n.\n"), + expect("RETR 1"), + send("-ERR gone\n"), + ]; + let error = run(&store, &account, id, steps).await.unwrap_err(); + assert!( + matches!(error, Error::Server(ref text) if text == "gone"), + "{error}" + ); + assert_eq!(store.lock().unwrap().synced(id).unwrap().at, None); + assert!(subjects(&store, id).is_empty()); +} + +#[tokio::test] +async fn an_imap_account_is_refused() { + let (_dir, store, account, id) = opened(true); + let account = Account { + protocol: Protocol::Imap, + ..account + }; + let secret = Secret::Password("pw".into()); + let error = sync::pop(&account, id, secret, &certs().trusted, &store, NOW) + .await + .unwrap_err(); + assert!(matches!(error, Error::Invalid(_)), "{error}"); +} diff --git a/crates/ely-mail-engine/tests/sync.rs b/crates/ely-mail-engine/tests/sync.rs index 88c5590..9e8a7bf 100644 --- a/crates/ely-mail-engine/tests/sync.rs +++ b/crates/ely-mail-engine/tests/sync.rs @@ -118,7 +118,8 @@ async fn a_first_sync_takes_headers_and_recent_bodies() { folders: 2, headers: 1, bodies: 1, - removed: 0 + removed: 0, + deleted: 0 } ); let store = store.lock().unwrap(); diff --git a/crates/ely-ui/src/i18n/mail.rs b/crates/ely-ui/src/i18n/mail.rs index 9bc5096..e815fb5 100644 --- a/crates/ely-ui/src/i18n/mail.rs +++ b/crates/ely-ui/src/i18n/mail.rs @@ -21,6 +21,7 @@ pub(super) const EN: &[(&str, &str)] = &[ ("mail.add.outgoing", "Outgoing server (SMTP)"), ("mail.add.port", "Port"), ("mail.add.security", "Security"), + ("mail.add.keep", "Keep a copy on the server"), ("mail.security.tls", "TLS"), ("mail.security.starttls", "STARTTLS"), ("mail.auth.title", "Sign-in"), @@ -115,6 +116,10 @@ pub(super) const EN: &[(&str, &str)] = &[ ("mail.sync.running", "Syncing…"), ("mail.sync.never", "Not yet"), ("mail.sync.range", "Downloaded"), + ("mail.sync.range_pop", "Every new message, whole"), + ("mail.sync.server", "On the server"), + ("mail.sync.kept", "A copy stays"), + ("mail.sync.deleted", "Deleted once downloaded"), ( "mail.sync.range_value", "Headers of all mail; text from the last {days} days; attachments when asked", @@ -138,7 +143,10 @@ pub(super) const EN: &[(&str, &str)] = &[ "1 message has attachments not downloaded", ), ("mail.sync.failed", "The last sync failed: {error}"), - ("mail.sync.unread", "The sync state could not be read: {error}"), + ( + "mail.sync.unread", + "The sync state could not be read: {error}", + ), ]; pub(super) const ZH_CN: &[(&str, &str)] = &[ @@ -162,6 +170,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("mail.add.outgoing", "发信服务器(SMTP)"), ("mail.add.port", "端口"), ("mail.add.security", "加密"), + ("mail.add.keep", "在服务器上保留副本"), ("mail.security.tls", "TLS"), ("mail.security.starttls", "STARTTLS"), ("mail.auth.title", "登录方式"), @@ -244,7 +253,14 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("mail.sync.running", "正在同步…"), ("mail.sync.never", "尚未同步"), ("mail.sync.range", "已下载"), - ("mail.sync.range_value", "全部邮件的邮件头;最近 {days} 天的正文;附件按需下载"), + ("mail.sync.range_pop", "每封新邮件整封下载"), + ("mail.sync.server", "服务器上"), + ("mail.sync.kept", "保留副本"), + ("mail.sync.deleted", "下载后删除"), + ( + "mail.sync.range_value", + "全部邮件的邮件头;最近 {days} 天的正文;附件按需下载", + ), ("mail.sync.uncached", "未下载"), ("mail.sync.uncached_value", "{n} 封邮件只有邮件头"), ("mail.sync.uncached_value.one", "1 封邮件只有邮件头"), diff --git a/tasks/TASKS.md b/tasks/TASKS.md index ca30505..54a1460 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -140,7 +140,7 @@ - [x] C12.4 凭据与 OAuth(MAIL-02,§18.2):密码与刷新令牌只进系统凭据库(不可用即只留到本次退出并写明);Gmail 与 Microsoft 的授权码加 PKCE、本机回环、换令牌与刷新;客户端 ID 编进构建,缺 ID 即写明不可用;刷新被拒转为“需重新登录”;替身授权端点测试。已做:`Vault`(keyring:钥匙串、Windows 凭据、Secret Service;拒绝时只在内存,`Kept::Session` 带原因);`oauth::SignIn` 在 127.0.0.1 随机端口等回跳(Google 写 127.0.0.1,Microsoft 写 localhost),state 与 S256 PKCE 各 32 随机字节,回跳的 state 不符或带 error 即报错,其余请求答 404;换令牌与刷新经 ureq(后台线程,30 秒),刷新答 invalid_grant 或 interaction_required 即 `SignInAgain`,其余为 `SignIn`;邮箱取 ID 令牌的 email,Microsoft 无则 preferred_username;客户端 ID 由 build.rs 读环境变量或项目根 `oauth.env`(坏行、未知键即构建失败),缺即 `Unavailable`;`tests/oauth.rs` 以替身令牌端点与替身浏览器核对,`tests/vault.rs` 以替身凭据库核对。审查三轮:第 1 轮四处(Google 授权带 `access_type=offline` 与 `prompt=consent`、换码无刷新令牌即报错;Microsoft 另要 `profile`;`oauth.env` 读不出即构建失败、不存在为未配置;超长注释)、第 2 轮一处(ureq 在 trace 级写出令牌与客户端密钥,`ely_platform::logging` 不记 `ureq`、`ureq_proto` 低于 info 的日志;`tests/private_oauth_logs.rs` 以真日志器在 trace 下核对,改回即失败)已修,第 3 轮通过 - [x] C12.5 账号(MAIL-01、02):ElyMail 主窗口与添加账号对话框:Gmail、Microsoft、iCloud(应用专用密码向导)、其他 IMAP/SMTP、POP3/SMTP;服务器、端口、TLS 方式、认证方式;连通性检查逐项报结果;多账号;删除账号先写明删去的本地缓存、草稿与凭据,不动服务端。已做:引擎 `Account`(地址、用户名、`Auth` 为密码或 Google、Microsoft 的 OAuth,`Protocol` 为 IMAP 或 POP3,收发两个 `Server`),以 JSON 存在 accounts 表的 settings 列(结构 v1 未发布,直接改,不加升级步);`valid` 照 SMTP 的 Mailbox 核对地址,用户名不空不带换行,主机不空不带空白,端口非零;`check` 同时登录收发两个服务器再退出,各自报结果;`Store::cached` 给该账号的邮件数与只属它的原文字节,`remove_account` 在一个事务里删账号(邮箱、邮件、标记随外键删去)与只属它的原文,原文在提交前删、删不掉即回滚,与别的账号共用的原文留着;凭据库名为 `account:地址`。应用:主窗口左栏列出账号(地址与协议、收信主机),中间为所选账号的地址、用户名、收发服务器、登录方式与“删除账号…”,无账号时为空状态与“添加账号…”;“账号”菜单两项。添加对话框分 Gmail、Microsoft、iCloud、IMAP、POP3:Gmail 与 Microsoft 填可选的地址作登录提示,“以 Google/Microsoft 登录”开浏览器,回来后以 ID 令牌的地址与访问令牌检查两台服务器,构建缺客户端 ID 即提示条写明并禁用按钮;iCloud 写明生成应用专用密码的步骤并可开 Apple 账户,服务器固定(imap.mail.me.com:993 TLS,smtp.mail.me.com:587 STARTTLS,两者用户名都用完整地址,Apple 写明可用);IMAP 与 POP3 填地址、登录方式(密码或两种 OAuth)、用户名、密码、收发主机、端口与加密,改加密时端口随之换成惯用的,手填的端口不动。“检查”逐项报收信与发信服务器(连不上、TLS 失败、拒绝登录及服务器原话等,按界面语言),通过才可“添加账号”,之后改任一字段即回到编辑;后到的检查与登录按编号丢弃,关对话框即中止网络任务;同一地址(不分大小写)不重复添加。保存先写库再存凭据,凭据库拒绝即提示只保留到退出。删除先在后台量出缓存,对话框写明删去的邮件数与大小、草稿与保存的密码或登录、服务器上的邮件不动;先删凭据,删不掉即不删账号并报错。库与网络都在后台(库在锁后,网络在两线程的 tokio 运行时),提示文字不进日志(可能含地址)。测试:引擎 `tests/accounts.rs` 6 个(往返、地址唯一、删去时留下共用原文与量缓存、两台服务器都登录、各自报结果、不合的设置),应用窗口测试 8 个(缺项逐个写明、端口随加密、检查逐项报连不上且改字段回到编辑、iCloud 的服务器、缺客户端 ID、凭据库拒绝仍保存并留在本次、保存与重开读回再删去且凭据随之删、凭据删不掉即留账号),去掉各自的规则即失败;真窗口冒烟(`ELY_SMOKE=1`)画出一帧。未验证:真实的 Gmail、Microsoft、iCloud 与其他服务器(须用户的客户端 ID 与账号)。第 1 轮审查后:删去或替换的原文先改名留在原处,提交后才删、失败即改回,打开库时收拾中断留下的(引擎 `tests/aside.rs`:第二个原文删不掉时两个都还读得到,重开后放回或删去);删除确认按钮带着所删的账号(原先关闭回调先清掉状态,按了什么也不删),测试改按真的按钮;端口以上次自动填入的值判断是否键入(原先把惯用端口当未键入,IMAP 收信填 995 会被冲掉);请求在整个窗口编号,保存的结果只作用于仍在等它的对话框(原先会关掉或改写之后新开的对话框),测试 `a_late_save…`、`a_late_failed_save…`;去掉各修复即失败。第 2 轮审查后:本地删除失败即把读出的凭据放回(凭据库拒绝时只放回本次并写明),账号仍可登录(`a_failed_removal_puts_the_secret_back`:原文的停放处被占,删除失败后凭据、账号与原文都在);地址唯一由库保证(`address` 不分 ASCII 大小写,`add_account` 遇冲突报 `Error::Taken`,对话框写明已添加),两个在途的保存不再都成功(`two_saves_in_flight_add_an_address_once`);左栏账号列表改用组件库 `SelectableList`(一个 Tab 停靠点,上下键选账号,`the_keyboard_picks_another_account`);去掉各修复即失败。第 3 轮审查后:凭据库删不掉时内存里的凭据照留(`Vault::remove` 先删凭据库,成功才清内存),凭据库不可用时添加的账号删除失败后仍能取回凭据(`a_refused_keychain…` 接着删除,引擎 `tests/vault.rs` 同);去掉修复即失败;三轮已满,未再审查 - [x] C12.6 IMAP 同步(MAIL-14,§12.3,QA-18):文件夹列表与特殊用途;邮件头全部、正文只取 90 天内;UIDVALIDITY 变化即重建该邮箱;服务端删除与标记变化随之;后台任务执行,界面显示最后同步时间、下载范围、未缓存的数量与失败原因。已做:引擎 `Imap::folders` 先问 CAPABILITY,有 LIST-EXTENDED 与 SPECIAL-USE 即以 `LIST "" * RETURN (SPECIAL-USE)` 取角色,否则照常 LIST(INBOX 按名,其余按 RFC 6154 的属性;`\Noselect`、`\NonExistent` 不可选);`examine` 读 UIDVALIDITY 与 EXISTS(缺任一即报错,不当作空邮箱);`flags`、`headers`、`bodies`、`structures`、`text` 按 UID 取,同一 UID 的多条 FETCH 回复合并(别的客户端改标记时插来的标记行照收),命令结束后才查所要的段(`\Recent` 属本次会话,不存);`since` 以 UID SEARCH SINCE 找 90 天内的邮件。`sync::imap` 一轮:列出的可选文件夹存为邮箱(带角色),不再列出的连同邮件删去,库里 INBOX 不分大小写(`Store::mailbox`、`set_folders`);每个邮箱 UIDVALIDITY 变了即清空重建;服务端已无的 UID 删去,标记不同即改;更早的邮件只取邮件头;90 天内的先读 BODYSTRUCTURE,无附件即取整封,有附件只取邮件头、各部分的 MIME 头与正文部分(文字,related 里的内嵌图片),按原结构重组,附件内容为空,部分号记在 `missing`(`Fetched::Text`、`Stored::missing`),附件按需(C12.17);已有邮件头的在进入 90 天时补正文;每次 FETCH 至多 100 个 UID;任何一步失败即停下报错,上次同步时间不变,成功才记下;`sync::secret` 取密码,OAuth 以刷新令牌换访问令牌(新的刷新令牌随即存下)。应用:窗口打开时与添加账号后各同步一次 IMAP 账号(POP3 随 C12.7),在应用的 tokio 运行时上;删除账号先中止其同步;账号详情写明上次同步时间(或正在同步、尚未同步)、下载范围、只有邮件头的邮件数与附件未下载的邮件数,失败以提示条写出原因。测试:引擎 `tests/sync.rs` 9 个(首次同步取邮件头与近期正文并记角色与时间;删除、标记与不再列出的文件夹随服务端;新 UIDVALIDITY 重建;失败不记时间;缺 EXISTS 即失败且邮件留着;取信中插来的标记行;INBOX 换大小写不删邮件;扩展 LIST 取角色;有附件的只存正文、附件为空且记部分号),应用 `tests/syncs.rs` 3 个(打开即同步并写明连不上、POP3 不同步;凭据库里没有即写明);去掉各规则即失败。未验证:真实服务器;令牌失效(`SignInAgain`)后重新登录的界面未做,现只写明原因。第 2 轮审查后:结构 v1 照已提交的原样,同步所需的列由 v2 升级加上(`schema::V2`:accounts.synced、mailboxes.role 与 uidvalidity、messages.missing),已存的 v1 库升级后邮件照留并能同步(`a_v1_store_gains_sync_and_keeps_its_mail`);处置为 attachment 的多部分整个是一个按需的实体,部分号记在 `missing`,不再逐个子部分判(`an_attached_multipart_waits_whole`);related 里不看处置:根照常读,带 Content-ID 的其余部分即正文资源,标为 attachment 的内嵌图片照样取下(`a_related_image_marked_attachment_comes_down`、`a_related_start_root_ignores_its_disposition`);本机 TLS 初始化失败即为该账号的同步失败,提示条写出原因(`a_missing_tls_is_the_sync_failure`);部分选择单测在 `imap/parts/tests.rs`(5 个,以真实的 BODYSTRUCTURE 解析后重组再经 MIME 读回);去掉各修复即失败。第 3 轮审查后:邮箱的 UIDVALIDITY 不同于所记(含 v2 升级后尚未记下)时只删去别的 UIDVALIDITY 下的邮件,同一 UIDVALIDITY 的照留,升级后的首次同步不再删去已存的正文(`a_v1_store_gains_sync_and_keeps_its_mail` 接着打开邮箱);related 的 `start` 在 BODYSTRUCTURE 里找不到(多部分不带 Content-ID)而有多部分子项时,各多部分子项照正文读,根的文字与 HTML 照样取下(`a_related_start_may_name_a_multipart`);去掉各修复即失败;三轮已满,未再审查 -- [ ] C12.7 POP3 收取(MAIL-01,§12.3):按 UIDL 去重下载到本地收件箱,“保留服务端副本”默认开,不做伪文件夹同步 +- [x] C12.7 POP3 收取(MAIL-01,§12.3):按 UIDL 去重下载到本地收件箱,“保留服务端副本”默认开,不做伪文件夹同步。已做:`Protocol::Pop { keep }` 记在账号里(结构未发布,旧的开发库里的 POP3 账号读不出即报错);`Pop::delete` 发 DELE(QUIT 才执行);`Store::uidls` 给收件箱已有的 UIDL;`sync::pop` 一轮:账号只有一个 INBOX 邮箱(角色收件箱),UIDL 不在库里的整封 RETR 存下(`Key::Pop`,无标记),不保留副本时每封在本地存下之后才 DELE(已存下而服务端仍列着的也删),服务端不再列出的本地照留;任何一步失败即停,QUIT 前断开即服务端什么也不删,成功才记同步时间;IMAP 账号拒绝。应用:POP3 账号同 IMAP 一样打开窗口与添加后同步;添加对话框的 POP3 有“在服务器上保留副本”开关(默认开,改动回到编辑);账号详情写明下载方式(每封新邮件整封下载)与服务器上保留副本或下载后删除。测试:引擎 `tests/pop_sync.rs` 4 个(新邮件只取一次、副本留在服务器;不保留时存下即删、已存下的也删;取信被拒即停且不删、不记时间;IMAP 账号拒绝),应用 `tests/syncs.rs`(POP3 账号打开即同步并写明连不上;开关默认开、可关);去掉各规则即失败。未验证:真实的 POP3 服务器;用户在本地删去的邮件(C12.15)下次会再取下,届时须记下删去的 UIDL。第 1 轮审查后:同一次列表里重复的 UIDL(RFC 1939 §7 许可同样的两封)只取一次,存下即记入本轮的集合,不保留副本时每个编号照样 DELE(`a_uidl_listed_twice_comes_down_once`,两种模式;去掉修复即失败);第 2 轮通过 - [ ] C12.8 同步控制(MAIL-14):手动刷新、运行期间定时同步、暂停、按文件夹完整离线下载 - [ ] C12.9 邮箱视图(MAIL-03):账号与文件夹、邮件列表、阅读区三栏;统一收件箱与账号视图;未读、标记、带附件筛选;列表只画可见行,10 万封滚动不掉帧 - [ ] C12.10 阅读(MAIL-04,§12.4):纯文本;HTML 经 html5ever 解析、净化后按块绘制;远程图片与跟踪资源默认拦截,可对当前邮件或发件人放行;cid 内嵌图片;附件列表;原始邮件头 diff --git a/tasks/reviews/C12.7-r1.md b/tasks/reviews/C12.7-r1.md new file mode 100644 index 0000000..d37b957 --- /dev/null +++ b/tasks/reviews/C12.7-r1.md @@ -0,0 +1,3 @@ +FAIL + +- crates/ely-mail-engine/src/sync.rs:111 — UIDL deduplication uses only the pre-sync `held` snapshot; successfully downloaded UIDLs are never added. A server can legitimately list `1 aa1` and `2 aa1` for identical copies ([RFC 1939 §7](https://www.rfc-editor.org/rfc/rfc1939.html#section-7)). Starting with an empty inbox, both messages are retrieved and written, although they have one UIDL and produce one local message. The isolated regression reproduced two RETR commands and `report.bodies == 2` instead of 1 (`.work/c12.7-r1-probe.log`). This violates C12.7's download-once requirement and can unnecessarily fail synchronization if the redundant transfer fails. Add each UIDL to the current session's set after its local write succeeds; still issue DELE for every server message number when `keep` is false. Cover duplicate UIDLs within one listing in both retention modes. diff --git a/tasks/reviews/C12.7-r2.md b/tasks/reviews/C12.7-r2.md new file mode 100644 index 0000000..7ef22e9 --- /dev/null +++ b/tasks/reviews/C12.7-r2.md @@ -0,0 +1 @@ +PASS