From 22d913d45eb41dc52c5b8d486da9e91db9379706 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 22:29:27 -0400 Subject: [PATCH] C12.8: sync now, timed sync, pause, offline folders --- agents.md | 2 +- apps/ely-mail/src/controls.rs | 88 ++++++ apps/ely-mail/src/mail.rs | 12 +- apps/ely-mail/src/main.rs | 1 + apps/ely-mail/src/menu.rs | 16 +- apps/ely-mail/src/syncing.rs | 103 ++++++- apps/ely-mail/src/tests.rs | 1 + apps/ely-mail/src/tests/controls.rs | 317 ++++++++++++++++++++ apps/ely-mail/src/tests/flows.rs | 32 +- apps/ely-mail/src/view.rs | 48 ++- crates/ely-mail-engine/src/lib.rs | 2 +- crates/ely-mail-engine/src/store.rs | 2 +- crates/ely-mail-engine/src/store/folders.rs | 62 +++- crates/ely-mail-engine/src/store/schema.rs | 7 +- crates/ely-mail-engine/src/store/tests.rs | 7 +- crates/ely-mail-engine/src/sync.rs | 24 +- crates/ely-mail-engine/tests/pop_sync.rs | 8 +- crates/ely-mail-engine/tests/sync.rs | 45 ++- crates/ely-ui/src/i18n/mail.rs | 37 +++ tasks/TASKS.md | 2 +- tasks/reviews/C12.8-r1.md | 9 + tasks/reviews/C12.8-r2.md | 3 + tasks/reviews/C12.8-r3.md | 1 + 23 files changed, 763 insertions(+), 66 deletions(-) create mode 100644 apps/ely-mail/src/controls.rs create mode 100644 apps/ely-mail/src/tests/controls.rs create mode 100644 tasks/reviews/C12.8-r1.md create mode 100644 tasks/reviews/C12.8-r2.md create mode 100644 tasks/reviews/C12.8-r3.md diff --git a/agents.md b/agents.md index 02a6942..52f4575 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 初始化失败记为该账号的同步失败。POP3 收取(C12.7):`Protocol::Pop { keep }`,一个 INBOX,按 UIDL 整封取一次,不保留副本时存下之后才 DELE,服务端删去的本地照留。 +- 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,服务端删去的本地照留。同步控制(C12.8):窗口开着时每 10 分钟同步全部账号,“立即同步”可手动;暂停中止进行中的同步、手动与定时都不再同步,恢复即同步全部;暂停只在内存里,不跨重启;邮箱的 `offline`(结构 v3)打开时该文件夹不限 90 天取每封的整封(连同附件),同步进行中打开的在该次结束后再同步一次;每次同步有全窗口唯一的序号(账号条目删去重建也不重来),迟到的结束不碰更新的同步。 - 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/controls.rs b/apps/ely-mail/src/controls.rs new file mode 100644 index 0000000..f7d9a5e --- /dev/null +++ b/apps/ely-mail/src/controls.rs @@ -0,0 +1,88 @@ +//! Sync now, pause, and folders kept whole. + +use ely_gpui_component::{buttons::Button, feedback::Banner, forms::Switch, primitives::Severity}; +use ely_mail_engine::{AccountId, Protocol}; +use gpui::{ + AnyElement, Context, InteractiveElement as _, IntoElement, ParentElement as _, Styled as _, div, +}; + +use crate::mail::{Mail, text}; + +impl Mail { + pub fn sync_buttons(&self, id: AccountId, cx: &mut Context) -> AnyElement { + let running = self.syncs.get(&id).is_some_and(|each| each.task.is_some()); + let this = cx.weak_entity(); + let now = Button::new("mail-sync-now", text(cx, "mail.sync.now", &[])) + .disabled(running || self.paused) + .on_click({ + let this = this.clone(); + move |_, _, cx| { + this.update(cx, |mail, cx| mail.sync(id, cx)).ok(); + } + }); + let key = if self.paused { + "mail.sync.resume" + } else { + "mail.sync.pause" + }; + let pause = Button::new("mail-sync-pause", text(cx, key, &[])).on_click(move |_, _, cx| { + this.update(cx, |mail, cx| mail.set_paused(!mail.paused, cx)) + .ok(); + }); + div() + .flex() + .gap_2() + .child(now) + .child(pause) + .into_any_element() + } + + pub fn paused_banner(&self, cx: &Context) -> Option { + self.paused.then(|| { + Banner::new( + "mail-sync-paused", + Severity::Info, + text(cx, "mail.sync.paused", &[]), + ) + }) + } + + /// One switch per IMAP folder. + pub fn offline_switches( + &self, + id: AccountId, + protocol: Protocol, + cx: &mut Context, + ) -> Option { + if protocol != Protocol::Imap { + return None; + } + let folders = &self.syncs.get(&id)?.folders; + if folders.is_empty() { + return None; + } + let this = cx.weak_entity(); + let switches = folders.iter().map(|folder| { + let mailbox = folder.id; + let this = this.clone(); + let label = text(cx, "mail.sync.offline", &[("name", &folder.name)]); + let switch = Switch::new(("mail-offline", mailbox.0 as u64), folder.offline) + .label(label) + .on_change(move |on, _, cx| { + this.update(cx, |mail, cx| mail.set_offline(id, mailbox, on, cx)) + .ok(); + }); + div() + .debug_selector(move || format!("mail-offline-{}", mailbox.0)) + .child(switch) + }); + Some( + div() + .flex() + .flex_col() + .gap_2() + .children(switches) + .into_any_element(), + ) + } +} diff --git a/apps/ely-mail/src/mail.rs b/apps/ely-mail/src/mail.rs index 5ed464e..61116a3 100644 --- a/apps/ely-mail/src/mail.rs +++ b/apps/ely-mail/src/mail.rs @@ -44,6 +44,10 @@ pub struct Mail { pub adding: Option, pub removing: Option, pub syncs: Syncs, + /// Numbers every sync, even across forgotten accounts. + pub runs: u64, + /// Until resumed, no account syncs. + pub paused: bool, /// Numbers every request across dialogs. pub asked: u64, pub focus: FocusHandle, @@ -102,12 +106,15 @@ impl Mail { adding: None, removing: None, syncs: Syncs::new(), + runs: 0, + paused: false, asked: 0, focus: cx.focus_handle(), root: WeakEntity::new_invalid(), side: cx.new(|_| Side::new(this)), }; mail.read_accounts(cx); + Self::sync_on_a_timer(cx); mail } @@ -126,10 +133,7 @@ impl Mail { } mail.accounts = Some(read); if first { - let ids: Vec<_> = mail.accounts().iter().map(|(id, _)| *id).collect(); - for id in ids { - mail.sync(id, cx); - } + mail.sync_all(cx); } }, ); diff --git a/apps/ely-mail/src/main.rs b/apps/ely-mail/src/main.rs index b679f2c..48657c1 100644 --- a/apps/ely-mail/src/main.rs +++ b/apps/ely-mail/src/main.rs @@ -3,6 +3,7 @@ mod add_view; mod adding; +mod controls; mod flow; mod kinds; mod mail; diff --git a/apps/ely-mail/src/menu.rs b/apps/ely-mail/src/menu.rs index 6746e84..a965228 100644 --- a/apps/ely-mail/src/menu.rs +++ b/apps/ely-mail/src/menu.rs @@ -1,16 +1,20 @@ -//! The Account menu. +//! The Account and Sync menus. use gpui::{App, Menu, MenuItem, actions}; use crate::mail::text; -actions!(mail, [AddAccount, RemoveAccount]); +actions!(mail, [AddAccount, RemoveAccount, SyncAll]); pub const CONTEXT: &str = "mail"; pub fn menus(cx: &App) -> Vec { - vec![Menu::new(text(cx, "mail.menu.account", &[])).items([ - MenuItem::action(text(cx, "mail.add.open", &[]), AddAccount), - MenuItem::action(text(cx, "mail.remove.open", &[]), RemoveAccount), - ])] + vec![ + Menu::new(text(cx, "mail.menu.account", &[])).items([ + MenuItem::action(text(cx, "mail.add.open", &[]), AddAccount), + MenuItem::action(text(cx, "mail.remove.open", &[]), RemoveAccount), + ]), + Menu::new(text(cx, "mail.menu.sync", &[])) + .items([MenuItem::action(text(cx, "mail.sync.now", &[]), SyncAll)]), + ] } diff --git a/apps/ely-mail/src/syncing.rs b/apps/ely-mail/src/syncing.rs index 7676ebb..16cd71f 100644 --- a/apps/ely-mail/src/syncing.rs +++ b/apps/ely-mail/src/syncing.rs @@ -1,28 +1,39 @@ -//! Accounts sync on open and once added. +//! Accounts sync on open, on demand and on a timer. use std::{ collections::HashMap, - time::{SystemTime, UNIX_EPOCH}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; -use ely_mail_engine::{AccountId, Protocol, Synced, sync}; +use ely_mail_engine::{AccountId, LocalFolder, MailboxId, Protocol, Synced, sync}; use gpui::Context; use tokio::task::AbortHandle; use crate::mail::Mail; +/// How often every account syncs while the window is open. +pub const EVERY: Duration = Duration::from_secs(10 * 60); + /// One account's sync as the window shows it. #[derive(Default)] pub struct Syncing { pub task: Option, + /// Which sync `task` is; older completions are stale. + pub run: u64, + /// A folder changed mid-sync: sync once more. + pub again: bool, pub synced: Option, pub failed: Option, + pub folders: Vec, } pub type Syncs = HashMap; impl Mail { pub fn sync(&mut self, id: AccountId, cx: &mut Context) { + if self.paused { + return log::info!("mail: account {} not synced: paused", id.0); + } let Some((_, account)) = self.accounts().iter().find(|(each, _)| *each == id) else { return log::info!("mail: sync asked of a gone account"); }; @@ -41,6 +52,9 @@ impl Mail { } }; let (account, vault) = (account.clone(), self.vault.clone()); + self.runs += 1; + let run = self.runs; + self.syncs.entry(id).or_default().run = run; let work = async move { let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -64,14 +78,90 @@ impl Mail { let Some(syncing) = mail.syncs.get_mut(&id) else { return log::info!("mail: a sync ended for a removed account"); }; + if syncing.run != run { + return log::info!("mail: account {} sync {run} ended after a newer one", id.0); + } syncing.task = None; syncing.failed = failed; + let again = std::mem::take(&mut syncing.again); mail.read_synced(id, cx); + if again { + log::info!("mail: account {} syncs again for a changed folder", id.0); + mail.sync(id, cx); + } }); self.syncs.entry(id).or_default().task = Some(task); self.read_synced(id, cx); } + pub fn sync_all(&mut self, cx: &mut Context) { + if self.paused { + return log::info!("mail: sync is paused"); + } + let ids: Vec<_> = self.accounts().iter().map(|(id, _)| *id).collect(); + for id in ids { + self.sync(id, cx); + } + } + + /// Pausing stops running syncs; resuming syncs every account. + pub fn set_paused(&mut self, paused: bool, cx: &mut Context) { + log::info!("mail: sync paused: {paused}"); + self.paused = paused; + if paused { + for syncing in self.syncs.values_mut() { + syncing.again = false; + if let Some(task) = syncing.task.take() { + task.abort(); + } + } + } else { + self.sync_all(cx); + } + self.notify(cx); + } + + /// Syncs every account each `EVERY` until the window goes. + pub fn sync_on_a_timer(cx: &mut Context) { + cx.spawn(async move |this, cx| { + loop { + cx.background_executor().timer(EVERY).await; + if this.update(cx, |mail, cx| mail.sync_all(cx)).is_err() { + break; + } + } + }) + .detach(); + } + + /// Takes the folder's mail whole, then syncs to fetch it. + pub fn set_offline( + &mut self, + id: AccountId, + mailbox: MailboxId, + offline: bool, + cx: &mut Context, + ) { + self.with_store( + cx, + move |store| store.set_offline(mailbox, offline), + move |mail, done, cx| match done { + Ok(()) => { + mail.read_synced(id, cx); + match mail.syncs.get_mut(&id) { + Some(syncing) if offline && syncing.task.is_some() => { + log::info!("mail: account {} syncs again after this one", id.0); + syncing.again = true; + } + _ if offline => mail.sync(id, cx), + _ => {} + } + } + Err(error) => mail.failed("mail.sync.offline_failed", &error, cx), + }, + ); + } + /// Stops the account's sync and forgets it. pub fn stop_sync(&mut self, id: AccountId) { if let Some(task) = self.syncs.remove(&id).and_then(|each| each.task) { @@ -82,9 +172,12 @@ impl Mail { fn read_synced(&mut self, id: AccountId, cx: &mut Context) { self.with_store( cx, - move |store| store.synced(id), + move |store| Ok((store.synced(id)?, store.folders(id)?)), move |mail, read, cx| match (read, mail.syncs.get_mut(&id)) { - (Ok(synced), Some(syncing)) => syncing.synced = Some(synced), + (Ok((synced, folders)), Some(syncing)) => { + syncing.synced = Some(synced); + syncing.folders = folders; + } (Err(error), Some(_)) => mail.failed("mail.sync.unread", &error, cx), (_, None) => {} }, diff --git a/apps/ely-mail/src/tests.rs b/apps/ely-mail/src/tests.rs index 233d7f1..572eea6 100644 --- a/apps/ely-mail/src/tests.rs +++ b/apps/ely-mail/src/tests.rs @@ -21,6 +21,7 @@ use crate::{ mail::{Mail, Parts}, }; +mod controls; mod flows; mod syncs; diff --git a/apps/ely-mail/src/tests/controls.rs b/apps/ely-mail/src/tests/controls.rs new file mode 100644 index 0000000..1a259f0 --- /dev/null +++ b/apps/ely-mail/src/tests/controls.rs @@ -0,0 +1,317 @@ +use std::{ + net::TcpListener, + sync::mpsc, + time::{Duration, Instant}, +}; + +use ely_mail_engine::{AccountId, LocalFolder, MailboxId, Role, Store}; +use gpui::{ + Entity, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point, px, size, +}; + +use super::{Keys, account, settle, window}; +use crate::{mail::Mail, syncing::EVERY, view::imap_range}; + +/// An account on a server that never answers. +fn silent(dir: &std::path::Path, port: u16) -> AccountId { + let mut ann = account("ann@example.com"); + ann.incoming.port = port; + let mut store = Store::open(dir).unwrap(); + let id = store.add_account(&ann).unwrap(); + store + .set_folders(id, &[("INBOX", Some(Role::Inbox))]) + .unwrap(); + id +} + +fn with_secret() -> Keys { + let keys = Keys::default(); + let secret = ("account:ann@example.com".to_owned(), "pw".to_owned()); + keys.kept.lock().unwrap().extend([secret]); + keys +} + +/// Waits on the network task alone, not the window. +fn finished(mail: &Entity, id: AccountId, cx: &mut VisualTestContext) { + let deadline = Instant::now() + Duration::from_secs(20); + let done = |cx: &mut VisualTestContext| { + cx.update(|_, app| { + let task = mail.read(app).syncs[&id].task.as_ref(); + task.unwrap().is_finished() + }) + }; + while !done(cx) { + assert!(Instant::now() < deadline, "the sync never ended"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// An account whose sync fails at once: no secret saved. +fn failing(dir: &std::path::Path) -> AccountId { + let mut store = Store::open(dir).unwrap(); + let ann = store.add_account(&account("ann@example.com")).unwrap(); + store + .set_folders(ann, &[("INBOX", Some(Role::Inbox))]) + .unwrap(); + ann +} + +fn failed(mail: &Entity, id: AccountId, cx: &mut VisualTestContext) -> bool { + cx.update(|_, app| mail.read(app).syncs[&id].failed.is_some()) +} + +fn forget(mail: &Entity, id: AccountId, cx: &mut VisualTestContext) { + cx.update(|_, app| { + mail.update(app, |mail, _| { + mail.syncs.get_mut(&id).unwrap().failed = None + }) + }); +} + +fn settled_failure(mail: &Entity, id: AccountId, cx: &mut VisualTestContext) { + settle(cx, |app| { + mail.read(app) + .syncs + .get(&id) + .is_some_and(|each| each.failed.is_some() && each.task.is_none()) + }); +} + +#[test] +fn every_account_syncs_again_on_the_timer() { + let dir = tempfile::tempdir().unwrap(); + let ann = failing(dir.path()); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), Keys::default()); + settled_failure(&mail, ann, cx); + forget(&mail, ann, cx); + cx.executor().advance_clock(EVERY); + settled_failure(&mail, ann, cx); +} + +#[test] +fn a_paused_sync_waits_until_resumed() { + let dir = tempfile::tempdir().unwrap(); + let ann = failing(dir.path()); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), Keys::default()); + settled_failure(&mail, ann, cx); + forget(&mail, ann, cx); + cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx))); + cx.update(|_, app| mail.update(app, |mail, cx| mail.sync(ann, cx))); + cx.executor().advance_clock(EVERY); + cx.run_until_parked(); + assert!(!failed(&mail, ann, cx), "a paused account synced"); + cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(false, cx))); + settled_failure(&mail, ann, cx); +} + +#[test] +fn a_folder_kept_offline_is_saved_and_synced() { + let dir = tempfile::tempdir().unwrap(); + let ann = failing(dir.path()); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), Keys::default()); + settle(cx, |app| { + mail.read(app) + .syncs + .get(&ann) + .is_some_and(|each| each.failed.is_some() && !each.folders.is_empty()) + }); + let inbox = cx.update(|_, app| mail.read(app).syncs[&ann].folders[0].clone()); + assert!(!inbox.offline); + forget(&mail, ann, cx); + cx.update(|_, app| mail.update(app, |mail, cx| mail.set_offline(ann, inbox.id, true, cx))); + settle(cx, |app| { + let syncing = &mail.read(app).syncs[&ann]; + syncing.folders[0].offline && syncing.failed.is_some() && syncing.task.is_none() + }); + assert!(Store::open(dir.path()).unwrap().offline(inbox.id).unwrap()); +} + +#[test] +fn an_old_end_queued_across_a_resume_keeps_the_new_sync() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let dir = tempfile::tempdir().unwrap(); + let ann = silent(dir.path(), listener.local_addr().unwrap().port()); + let keys = Keys::default(); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), keys.clone()); + settled_failure(&mail, ann, cx); + cx.update(|_, app| mail.update(app, |mail, cx| mail.sync(ann, cx))); + finished(&mail, ann, cx); + keys.kept + .lock() + .unwrap() + .insert("account:ann@example.com".into(), "pw".into()); + let resumed = cx.update(|_, app| { + mail.update(app, |mail, cx| { + mail.set_paused(true, cx); + mail.set_paused(false, cx); + mail.syncs[&ann].task.clone().unwrap() + }) + }); + cx.run_until_parked(); + assert!( + cx.update(|_, app| mail.read(app).syncs[&ann].task.is_some()), + "an old end cleared the new sync" + ); + cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx))); + let deadline = Instant::now() + Duration::from_secs(5); + while !resumed.is_finished() { + assert!(Instant::now() < deadline, "pausing left the sync running"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn an_old_end_after_a_failed_removal_keeps_the_new_sync() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let dir = tempfile::tempdir().unwrap(); + let ann = silent(dir.path(), listener.local_addr().unwrap().port()); + let keys = Keys::default(); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), keys.clone()); + settled_failure(&mail, ann, cx); + cx.update(|_, app| { + mail.update(app, |mail, cx| { + mail.stop_sync(ann); + mail.sync(ann, cx); + }) + }); + finished(&mail, ann, cx); + keys.kept + .lock() + .unwrap() + .insert("account:ann@example.com".into(), "pw".into()); + let restarted = cx.update(|_, app| { + mail.update(app, |mail, cx| { + mail.stop_sync(ann); + mail.sync(ann, cx); + mail.syncs[&ann].task.clone().unwrap() + }) + }); + cx.run_until_parked(); + assert!( + cx.update(|_, app| mail.read(app).syncs[&ann].task.is_some()), + "an old end cleared the restarted sync" + ); + cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx))); + let deadline = Instant::now() + Duration::from_secs(5); + while !restarted.is_finished() { + assert!(Instant::now() < deadline, "pausing left the sync running"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn a_folder_kept_offline_mid_sync_syncs_once_more() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let (hang_up, hung) = mpsc::channel::<()>(); + let server = std::thread::spawn(move || { + let held = listener.accept().unwrap(); + hung.recv().unwrap(); + drop((held, listener)); + }); + let dir = tempfile::tempdir().unwrap(); + let ann = silent(dir.path(), port); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), with_secret()); + settle(cx, |app| { + mail.read(app) + .syncs + .get(&ann) + .is_some_and(|each| each.task.is_some() && !each.folders.is_empty()) + }); + let (inbox, run) = cx.update(|_, app| { + let syncing = &mail.read(app).syncs[&ann]; + (syncing.folders[0].id, syncing.run) + }); + cx.update(|_, app| mail.update(app, |mail, cx| mail.set_offline(ann, inbox, true, cx))); + settle(cx, |app| mail.read(app).syncs[&ann].again); + hang_up.send(()).unwrap(); + server.join().unwrap(); + settle(cx, |app| { + let syncing = &mail.read(app).syncs[&ann]; + syncing.run == run + 1 && syncing.task.is_none() && syncing.failed.is_some() + }); +} + +#[test] +fn the_account_page_scrolls_to_its_last_folder() { + let dir = tempfile::tempdir().unwrap(); + let ann = failing(dir.path()); + let names: Vec = (0..40).map(|n| format!("Folder {n:02}")).collect(); + let folders: Vec<_> = names.iter().map(|name| (name.as_str(), None)).collect(); + let last = *Store::open(dir.path()) + .unwrap() + .set_folders(ann, &folders) + .unwrap() + .last() + .unwrap(); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), Keys::default()); + cx.simulate_resize(size(px(1100.), px(800.))); + settle(cx, |app| { + mail.read(app) + .syncs + .get(&ann) + .is_some_and(|each| each.folders.len() == 40) + }); + let selector: &'static str = format!("mail-offline-{}", last.0).leak(); + let height = cx.update(|window, _| window.viewport_size().height); + let before = cx + .debug_bounds(selector) + .expect("the last switch is laid out"); + assert!(before.bottom() > height, "the folders fit the window"); + let at = point(px(400.), height / 2.0); + for _ in 0..20 { + cx.simulate_event(ScrollWheelEvent { + position: at, + delta: ScrollDelta::Pixels(point(px(0.), px(-1000.))), + ..Default::default() + }); + cx.run_until_parked(); + } + let after = cx + .debug_bounds(selector) + .expect("the last switch is laid out"); + assert!( + after.bottom() <= height, + "the last switch is out of reach: {after:?}" + ); +} + +#[test] +fn the_range_names_the_folders_kept_whole() { + let folder = |id, name: &str, offline| LocalFolder { + id: MailboxId(id), + name: name.into(), + role: None, + offline, + }; + let cx = TestAppContext::single(); + cx.update(|app| { + ely_ui::i18n::install_as("en-US", app); + let rows = |folders: &[LocalFolder]| -> Vec { + imap_range(folders, app) + .into_iter() + .map(|(key, value)| format!("{key}: {value}")) + .collect() + }; + let both = [folder(1, "INBOX", true), folder(2, "Sent", false), folder(3, "Work", true)]; + assert_eq!( + rows(&both), + [ + "Downloaded: Other folders: headers of all mail; text from the last 90 days; attachments when asked", + "Kept whole: Every message in INBOX, Work, with attachments", + ] + ); + assert_eq!(rows(&[folder(1, "INBOX", true)]), ["Kept whole: Every message in INBOX, with attachments"]); + assert_eq!( + rows(&[folder(2, "Sent", false)]), + ["Downloaded: Headers of all mail; text from the last 90 days; attachments when asked"] + ); + }); +} diff --git a/apps/ely-mail/src/tests/flows.rs b/apps/ely-mail/src/tests/flows.rs index a960ff2..994c808 100644 --- a/apps/ely-mail/src/tests/flows.rs +++ b/apps/ely-mail/src/tests/flows.rs @@ -357,23 +357,31 @@ fn two_saves_in_flight_add_an_address_once() { }); passed(&mail, account("ANN@example.com"), "pw2", cx); cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx))); + // Either save may reach the store first. settle(cx, |app| { - matches!( - mail.read(app).adding.as_ref().map(|adding| &adding.step), - Some(Step::Failed(_)) - ) + let mail = mail.read(app); + mail.accounts().len() == 1 + && !matches!( + mail.adding.as_ref().map(|adding| &adding.step), + Some(Step::Saving) + ) }); - assert_eq!(step_words(&mail, cx), "ANN@example.com is already added."); - cx.update(|_, app| assert_eq!(mail.read(app).accounts().len(), 1)); - assert_eq!( - Store::open(dir.path()).unwrap().accounts().unwrap()[0].1, - account("ann@example.com") - ); + let won = Store::open(dir.path()).unwrap().accounts().unwrap(); + assert_eq!(won.len(), 1); + let (address, secret) = if won[0].1 == account("ann@example.com") { + assert_eq!(step_words(&mail, cx), "ANN@example.com is already added."); + ("ann", "pw") + } else { + assert_eq!(won[0].1, account("ANN@example.com")); + cx.update(|_, app| assert!(mail.read(app).adding.is_none())); + ("ANN", "pw2") + }; let kept = keys.kept.lock().unwrap(); assert_eq!(kept.len(), 1); assert_eq!( - kept.get("account:ann@example.com").map(String::as_str), - Some("pw") + kept.get(&format!("account:{address}@example.com")) + .map(String::as_str), + Some(secret) ); } diff --git a/apps/ely-mail/src/view.rs b/apps/ely-mail/src/view.rs index 04fabd0..55f4d92 100644 --- a/apps/ely-mail/src/view.rs +++ b/apps/ely-mail/src/view.rs @@ -4,11 +4,12 @@ use ely_gpui_component::{ buttons::{Button, ButtonVariant}, data_display::DescriptionList, feedback::{Banner, EmptyState, ErrorView}, + layout::ScrollArea, overlays::ConfirmDialog, primitives::{IconName, Severity}, typography::format, }; -use ely_mail_engine::{Account, AccountId, Auth, Protocol, Security, Server, sync}; +use ely_mail_engine::{Account, AccountId, Auth, LocalFolder, Protocol, Security, Server, sync}; use ely_platform::AppId; use ely_ui::Shell; use gpui::{ @@ -97,18 +98,26 @@ impl Mail { server(&account.outgoing, cx), ) .text(label("mail.account.auth"), auth(account.auth, cx)); - let (details, failed) = self.sync_rows(*id, account.protocol, details, cx); + let (id, protocol) = (*id, account.protocol); + let (details, failed) = self.sync_rows(id, 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() + let page = div() .p_6() .flex() .flex_col() .gap_4() .max_w_128() + .children(self.paused_banner(cx)) .children(failed) .child(details) - .child(div().child(remove)) + .child(self.sync_buttons(id, cx)) + .children(self.offline_switches(id, protocol, cx)) + .child(div().child(remove)); + ScrollArea::new("mail-account-scroll") + .vertical() + .size_full() + .child(page) .into_any_element() } @@ -140,11 +149,9 @@ impl Mail { .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)]), - ); + for (key, value) in imap_range(&syncing.folders, cx) { + details = details.text(key, value); + } } if let (Some(synced), Protocol::Imap) = (syncing.synced, protocol) { for (key, n) in [ @@ -206,6 +213,28 @@ impl Mail { } } +/// What the rest download, then folders kept whole. +pub fn imap_range(folders: &[LocalFolder], cx: &App) -> Vec<(SharedString, SharedString)> { + let (whole, rest): (Vec<_>, Vec<_>) = folders.iter().partition(|folder| folder.offline); + let label = |key: &str| text(cx, key, &[]); + let days = sync::BODY_DAYS.to_string(); + let mut rows = Vec::new(); + if whole.is_empty() || !rest.is_empty() { + let key = match whole.is_empty() { + true => "mail.sync.range_value", + false => "mail.sync.range_rest", + }; + rows.push((label("mail.sync.range"), text(cx, key, &[("days", &days)]))); + } + if !whole.is_empty() { + let sep = label("mail.sync.whole_sep"); + let names: Vec<&str> = whole.iter().map(|folder| folder.name.as_str()).collect(); + let value = text(cx, "mail.sync.whole_value", &[("names", &names.join(&sep))]); + rows.push((label("mail.sync.whole"), value)); + } + rows +} + pub fn protocol_name(protocol: Protocol) -> &'static str { match protocol { Protocol::Imap => "IMAP", @@ -235,6 +264,7 @@ impl Render for Mail { cx.listener(|mail, _: &menu::AddAccount, window, cx| mail.start_adding(window, cx)), ) .on_action(cx.listener(|mail, _: &menu::RemoveAccount, _, cx| mail.ask_remove(cx))) + .on_action(cx.listener(|mail, _: &menu::SyncAll, _, cx| mail.sync_all(cx))) .child(shell) .children(self.add_dialog(cx)) .children(self.remove_dialog(cx)) diff --git a/crates/ely-mail-engine/src/lib.rs b/crates/ely-mail-engine/src/lib.rs index f0ab4e3..3f64bfa 100644 --- a/crates/ely-mail-engine/src/lib.rs +++ b/crates/ely-mail-engine/src/lib.rs @@ -21,7 +21,7 @@ pub use net::{Login, Secret, Security, Server, system_tls}; pub use pop::Pop; pub use smtp::Smtp; pub use store::{ - AccountId, Cached, Fetched, Held, Key, MailboxId, MessageId, Sizes, Store, Stored, Synced, + AccountId, Cached, Fetched, Held, Key, LocalFolder, MailboxId, MessageId, Sizes, Store, Stored, Synced, }; pub use tokio_rustls::TlsConnector; pub use vault::{Backend, Kept, Keyring, Vault}; diff --git a/crates/ely-mail-engine/src/store.rs b/crates/ely-mail-engine/src/store.rs index 4f4d858..69b846a 100644 --- a/crates/ely-mail-engine/src/store.rs +++ b/crates/ely-mail-engine/src/store.rs @@ -21,7 +21,7 @@ mod search; mod tests; pub use accounts::Cached; -pub use folders::{Held, Synced}; +pub use folders::{Held, LocalFolder, Synced}; pub use messages::{Fetched, Key, Stored}; const DB: &str = "mail.db"; diff --git a/crates/ely-mail-engine/src/store/folders.rs b/crates/ely-mail-engine/src/store/folders.rs index 4919a6e..c99a14d 100644 --- a/crates/ely-mail-engine/src/store/folders.rs +++ b/crates/ely-mail-engine/src/store/folders.rs @@ -13,10 +13,22 @@ pub struct Held { pub id: MessageId, /// The body is here, perhaps without attachments. pub body: bool, + /// Every part is here. + pub whole: bool, /// Sorted. pub flags: Vec, } +/// A local mailbox. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LocalFolder { + pub id: MailboxId, + pub name: String, + pub role: Option, + /// Every message comes down whole. + pub offline: bool, +} + /// An account's sync, as the window shows it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Synced { @@ -67,30 +79,58 @@ impl Store { } /// The account's mailboxes in name order. - pub fn folders(&self, account: AccountId) -> Result)>> { - let mut query = self - .conn - .prepare("SELECT id, name, role FROM mailboxes WHERE account = ?1 ORDER BY name")?; + pub fn folders(&self, account: AccountId) -> Result> { + let mut query = self.conn.prepare( + "SELECT id, name, role, offline FROM mailboxes WHERE account = ?1 ORDER BY name", + )?; let rows = query.query_map([account.0], |row| { Ok(( MailboxId(row.get(0)?), row.get(1)?, row.get::<_, Option>(2)?, + row.get(3)?, )) })?; rows.map(|row| { - let (id, name, role) = row?; + let (id, name, role, offline) = row?; let role = role .map(|role| { Role::from_name(&role) .ok_or_else(|| Error::Store(format!("an unknown folder role {role:?}"))) }) .transpose()?; - Ok((id, name, role)) + Ok(LocalFolder { + id, + name, + role, + offline, + }) }) .collect() } + /// Whether the mailbox's mail comes down whole. + pub fn offline(&self, mailbox: MailboxId) -> Result { + Ok(self.conn.query_row( + "SELECT offline FROM mailboxes WHERE id = ?1", + [mailbox.0], + |row| row.get(0), + )?) + } + + pub fn set_offline(&self, mailbox: MailboxId, offline: bool) -> Result<()> { + self.writable()?; + let changed = self.conn.execute( + "UPDATE mailboxes SET offline = ?1 WHERE id = ?2", + params![offline, mailbox.0], + )?; + if changed != 1 { + return Err(Error::Store(format!("no mailbox {}", mailbox.0))); + } + log::info!("mail: mailbox {} offline: {offline}", mailbox.0); + Ok(()) + } + /// Held messages by UID; mail under another UIDVALIDITY goes. pub fn open_mailbox( &mut self, @@ -116,19 +156,21 @@ impl Store { } let mut held: HashMap = HashMap::new(); let mut query = self.conn.prepare( - "SELECT id, uid, content IS NOT NULL FROM messages - WHERE mailbox = ?1 AND uidvalidity = ?2", + "SELECT id, uid, content IS NOT NULL, content IS NOT NULL AND missing = '[]' + FROM messages WHERE mailbox = ?1 AND uidvalidity = ?2", )?; let rows = query.query_map(params![mailbox.0, uidvalidity], |row| { - Ok((row.get::<_, u32>(1)?, MessageId(row.get(0)?), row.get(2)?)) + let id = MessageId(row.get(0)?); + Ok((row.get::<_, u32>(1)?, id, row.get(2)?, row.get(3)?)) })?; for row in rows { - let (uid, id, body) = row?; + let (uid, id, body, whole) = row?; held.insert( uid, Held { id, body, + whole, flags: Vec::new(), }, ); diff --git a/crates/ely-mail-engine/src/store/schema.rs b/crates/ely-mail-engine/src/store/schema.rs index dcd502d..eab9c50 100644 --- a/crates/ely-mail-engine/src/store/schema.rs +++ b/crates/ely-mail-engine/src/store/schema.rs @@ -1,5 +1,5 @@ /// Step `n` upgrades version `n`. -pub(super) const STEPS: &[&str] = &[V1, V2]; +pub(super) const STEPS: &[&str] = &[V1, V2, V3]; const V1: &str = " CREATE TABLE accounts ( @@ -58,3 +58,8 @@ ALTER TABLE mailboxes ADD COLUMN role TEXT; ALTER TABLE mailboxes ADD COLUMN uidvalidity INTEGER; ALTER TABLE messages ADD COLUMN missing TEXT NOT NULL DEFAULT '[]'; "; + +/// Folders kept whole (C12.8). +const V3: &str = " +ALTER TABLE mailboxes ADD COLUMN offline INTEGER NOT NULL DEFAULT 0; +"; diff --git a/crates/ely-mail-engine/src/store/tests.rs b/crates/ely-mail-engine/src/store/tests.rs index f6f80ae..713a291 100644 --- a/crates/ely-mail-engine/src/store/tests.rs +++ b/crates/ely-mail-engine/src/store/tests.rs @@ -143,9 +143,10 @@ fn a_v1_store_gains_sync_and_keeps_its_mail() { store .set_folders(ann, &[("INBOX", Some(Role::Inbox))]) .unwrap(); - let [(inbox, name, role)] = store.folders(ann).unwrap().try_into().unwrap(); - assert_eq!((name.as_str(), role), ("INBOX", Some(Role::Inbox))); - let held = store.open_mailbox(inbox, 7).unwrap(); + let [inbox] = store.folders(ann).unwrap().try_into().unwrap(); + assert_eq!((inbox.name.as_str(), inbox.role), ("INBOX", Some(Role::Inbox))); + assert!(!inbox.offline); + let held = store.open_mailbox(inbox.id, 7).unwrap(); assert_eq!(held.keys().copied().collect::>(), [1]); assert!(held[&1].body); store.set_synced(ann, 9).unwrap(); diff --git a/crates/ely-mail-engine/src/sync.rs b/crates/ely-mail-engine/src/sync.rs index 671f6bd..dd66186 100644 --- a/crates/ely-mail-engine/src/sync.rs +++ b/crates/ely-mail-engine/src/sync.rs @@ -158,6 +158,26 @@ async fn one( _ => {} } } + let key = |uid| Key::Imap { + uidvalidity: opened.uidvalidity, + uid, + }; + if lock(store)?.offline(mailbox)? { + let mut whole: Vec = server + .keys() + .filter(|uid| !held.get(uid).is_some_and(|held| held.whole)) + .copied() + .collect(); + whole.sort_unstable(); + for batch in whole.chunks(BATCH) { + for got in imap.bodies(batch).await? { + let fetched = Fetched::Whole(&got.bytes); + put(store, mailbox, key(got.uid), &got, fetched)?; + report.bodies += 1; + } + } + return Ok(()); + } let recent: HashSet = imap.since(day).await?.into_iter().collect(); let mut headers = Vec::new(); let mut bodies = Vec::new(); @@ -170,10 +190,6 @@ async fn one( } headers.sort_unstable(); bodies.sort_unstable(); - let key = |uid| Key::Imap { - uidvalidity: opened.uidvalidity, - uid, - }; for batch in headers.chunks(BATCH) { for got in imap.headers(batch).await? { put( diff --git a/crates/ely-mail-engine/tests/pop_sync.rs b/crates/ely-mail-engine/tests/pop_sync.rs index 3f2d502..d36f4f3 100644 --- a/crates/ely-mail-engine/tests/pop_sync.rs +++ b/crates/ely-mail-engine/tests/pop_sync.rs @@ -51,15 +51,15 @@ async fn run( 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 [inbox] = store.folders(id).unwrap().try_into().unwrap(); + assert_eq!((inbox.name.as_str(), inbox.role), ("INBOX", Some(Role::Inbox))); let mut out: Vec<_> = store - .uidls(inbox) + .uidls(inbox.id) .unwrap() .into_iter() .map(|uidl| { let key = Key::Pop { uidl: uidl.clone() }; - let found = store.find(inbox, &key).unwrap().unwrap(); + let found = store.find(inbox.id, &key).unwrap().unwrap(); let stored = store.get(found).unwrap(); assert!(stored.body); (uidl, stored.subject.unwrap()) diff --git a/crates/ely-mail-engine/tests/sync.rs b/crates/ely-mail-engine/tests/sync.rs index 9e8a7bf..255a25e 100644 --- a/crates/ely-mail-engine/tests/sync.rs +++ b/crates/ely-mail-engine/tests/sync.rs @@ -98,7 +98,7 @@ fn key(uidvalidity: u32, uid: u32) -> Key { } fn inbox(store: &Store, id: AccountId) -> MailboxId { - store.folders(id).unwrap()[0].0 + store.folders(id).unwrap()[0].id } fn opened() -> (tempfile::TempDir, Mutex, AccountId) { @@ -127,7 +127,7 @@ async fn a_first_sync_takes_headers_and_recent_bodies() { .folders(id) .unwrap() .into_iter() - .map(|(_, name, role)| (name, role)) + .map(|folder| (folder.name, folder.role)) .collect(); assert_eq!( folders, @@ -319,7 +319,10 @@ async fn inbox_casing_does_not_destroy_cached_mail() { .unwrap(); let folders = store.folders(id).unwrap(); assert_eq!(folders.len(), 1); - assert_eq!((kept[0], folders[0].1.as_str()), (folders[0].0, "INBOX")); + assert_eq!( + (kept[0], folders[0].name.as_str()), + (folders[0].id, "INBOX") + ); let new = store.find(kept[0], &key(7, 2)).unwrap().unwrap(); assert_eq!( store.original(new).unwrap().unwrap(), @@ -345,7 +348,7 @@ async fn roles_are_asked_for_when_the_server_can_list_them() { .await .unwrap(); let folders = store.lock().unwrap().folders(id).unwrap(); - assert_eq!(folders[0].2, Some(Role::Sent)); + assert_eq!(folders[0].role, Some(Role::Sent)); } /// One FETCH line carrying each section as a literal. @@ -408,3 +411,37 @@ async fn a_recent_body_sync_leaves_attachments_on_the_server() { assert!(mail.attachments[0].bytes.is_empty()); assert_eq!(store.synced(id).unwrap().partial, 1); } + +#[tokio::test] +async fn an_offline_folder_takes_every_message_whole() { + let (_dir, store, id) = opened(); + run(&store, id, first_sync()).await.unwrap(); + let mailbox = inbox(&store.lock().unwrap(), id); + store.lock().unwrap().set_offline(mailbox, true).unwrap(); + let steps = vec![ + expect("{tag} CAPABILITY"), + send("* CAPABILITY IMAP4rev1\n{tag} OK caps\n"), + expect("{tag} LIST \"\" *"), + send("* LIST (\\HasNoChildren) \"/\" INBOX\n"), + send("* LIST (\\HasNoChildren \\Sent) \"/\" Sent\n{tag} OK listed\n"), + expect("{tag} EXAMINE \"INBOX\""), + send("* 2 EXISTS\n* OK [UIDVALIDITY 7] v\n{tag} OK in\n"), + expect("{tag} UID FETCH 1:* (UID FLAGS)"), + send("* 1 FETCH (UID 1 FLAGS (\\Seen))\n* 2 FETCH (UID 2 FLAGS ())\n"), + send("{tag} OK flags\n"), + expect("{tag} UID FETCH 1 (UID FLAGS BODY.PEEK[])"), + send(&literal(1, "\\Seen", "BODY[]", &format!("{OLD}kept\n"))), + send("{tag} OK bodies\n"), + expect("{tag} EXAMINE \"Sent\""), + send("* 0 EXISTS\n* OK [UIDVALIDITY 3] v\n{tag} OK in\n"), + ]; + let report = run(&store, id, steps).await.unwrap(); + assert_eq!((report.headers, report.bodies), (0, 1)); + let mut store = store.lock().unwrap(); + assert!(store.folders(id).unwrap()[0].offline); + let held = store.open_mailbox(mailbox, 7).unwrap(); + assert!(held[&1].whole && held[&2].whole); + let old = store.find(mailbox, &key(7, 1)).unwrap().unwrap(); + let bytes = store.original(old).unwrap().unwrap(); + assert!(String::from_utf8(bytes).unwrap().ends_with("kept\r\n")); +} diff --git a/crates/ely-ui/src/i18n/mail.rs b/crates/ely-ui/src/i18n/mail.rs index e815fb5..119e694 100644 --- a/crates/ely-ui/src/i18n/mail.rs +++ b/crates/ely-ui/src/i18n/mail.rs @@ -124,6 +124,16 @@ pub(super) const EN: &[(&str, &str)] = &[ "mail.sync.range_value", "Headers of all mail; text from the last {days} days; attachments when asked", ), + ( + "mail.sync.range_rest", + "Other folders: headers of all mail; text from the last {days} days; attachments when asked", + ), + ("mail.sync.whole", "Kept whole"), + ( + "mail.sync.whole_value", + "Every message in {names}, with attachments", + ), + ("mail.sync.whole_sep", ", "), ("mail.sync.uncached", "Not downloaded"), ( "mail.sync.uncached_value", @@ -147,6 +157,19 @@ pub(super) const EN: &[(&str, &str)] = &[ "mail.sync.unread", "The sync state could not be read: {error}", ), + ("mail.menu.sync", "Sync"), + ("mail.sync.now", "Sync Now"), + ("mail.sync.pause", "Pause Sync"), + ("mail.sync.resume", "Resume Sync"), + ( + "mail.sync.paused", + "Sync is paused. Nothing downloads until you resume.", + ), + ("mail.sync.offline", "Keep {name} whole offline"), + ( + "mail.sync.offline_failed", + "The folder setting was not saved: {error}", + ), ]; pub(super) const ZH_CN: &[(&str, &str)] = &[ @@ -261,6 +284,13 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ "mail.sync.range_value", "全部邮件的邮件头;最近 {days} 天的正文;附件按需下载", ), + ( + "mail.sync.range_rest", + "其他文件夹:全部邮件的邮件头;最近 {days} 天的正文;附件按需下载", + ), + ("mail.sync.whole", "完整保留"), + ("mail.sync.whole_value", "{names} 的每封邮件,连同附件"), + ("mail.sync.whole_sep", "、"), ("mail.sync.uncached", "未下载"), ("mail.sync.uncached_value", "{n} 封邮件只有邮件头"), ("mail.sync.uncached_value.one", "1 封邮件只有邮件头"), @@ -269,4 +299,11 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("mail.sync.partial_value.one", "1 封邮件的附件未下载"), ("mail.sync.failed", "上次同步失败:{error}"), ("mail.sync.unread", "读不出同步状态:{error}"), + ("mail.menu.sync", "同步"), + ("mail.sync.now", "立即同步"), + ("mail.sync.pause", "暂停同步"), + ("mail.sync.resume", "恢复同步"), + ("mail.sync.paused", "同步已暂停。恢复之前不下载任何内容。"), + ("mail.sync.offline", "离线完整保留 {name}"), + ("mail.sync.offline_failed", "文件夹设置未存下:{error}"), ]; diff --git a/tasks/TASKS.md b/tasks/TASKS.md index 54a1460..498f06d 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -141,7 +141,7 @@ - [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`);去掉各修复即失败;三轮已满,未再审查 - [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):手动刷新、运行期间定时同步、暂停、按文件夹完整离线下载 +- [x] C12.8 同步控制(MAIL-14):手动刷新、运行期间定时同步、暂停、按文件夹完整离线下载。已做:结构 v3 给邮箱加 `offline`(默认关),`Store::set_offline`、`folders` 带此开关;`sync::imap` 对离线的文件夹取每封邮件的整封(不限 90 天,连同附件),只有邮件头或缺附件的照补。应用:“邮件›立即同步”同步全部账号,账号页有“立即同步”(同步中或暂停时禁用)与“暂停同步”/“恢复同步”;暂停即中止进行中的同步、此后手动与定时都不同步(记日志),恢复即同步全部;窗口开着时每 10 分钟同步全部(`syncing::EVERY`);暂停时账号页上方以提示条写明;IMAP 账号页每个文件夹一个“离线保留”开关,打开即存下并同步,写不进即提示;同步进行中打开的,该次结束后再同步一次(`Syncing::again`,暂停即清掉);每次同步有序号(`Mail::runs` 全窗口递增,`Syncing::run` 记当前那次;第 2 轮后:原按账号的条目计数,删账号失败后条目重建、序号从 1 重来,迟到的结束会清掉新的同步),暂停后恢复、删账号失败重新同步时上一次迟到的结束都不碰新的同步;账号页可纵向滚动;“已下载”在有离线文件夹时写明其余文件夹的范围,另有一行“完整保留”列出离线文件夹(连同附件)。测试:引擎 `tests/sync.rs` 的离线文件夹取 90 天以前的整封;应用 `tests/controls.rs` 8 个(定时再同步、暂停期间手动与定时都不同步而恢复即同步、开关存下并触发同步、迟到的结束不清掉恢复后的同步且再暂停能中止它、条目忘掉重建后迟到的结束同样不清掉新的同步、同步中打开开关即再同步一次、40 个文件夹时滚得到最后一个开关、范围按离线文件夹写);去掉各规则即失败。`two_saves_in_flight_add_an_address_once` 原假定先发的保存先到库,定时任务改变了测试执行器的任务次序后另一个先到:两种次序都是正确行为,测试改为认任一胜者(库里一个账号、一个凭据、败者写明已添加)。未验证:真实服务器。 - [ ] C12.9 邮箱视图(MAIL-03):账号与文件夹、邮件列表、阅读区三栏;统一收件箱与账号视图;未读、标记、带附件筛选;列表只画可见行,10 万封滚动不掉帧 - [ ] C12.10 阅读(MAIL-04,§12.4):纯文本;HTML 经 html5ever 解析、净化后按块绘制;远程图片与跟踪资源默认拦截,可对当前邮件或发件人放行;cid 内嵌图片;附件列表;原始邮件头 - [ ] C12.11 会话线程(MAIL-04,§12.3):按 Message-ID、In-Reply-To、References 组织,不按主题合并 diff --git a/tasks/reviews/C12.8-r1.md b/tasks/reviews/C12.8-r1.md new file mode 100644 index 0000000..b404f79 --- /dev/null +++ b/tasks/reviews/C12.8-r1.md @@ -0,0 +1,9 @@ +FAIL + +- [P1] apps/ely-mail/src/syncing.rs:98 — Pause/resume can lose the resumed sync's abort handle. If the old Tokio task has finished but its GPUI completion callback is still queued, pausing takes its handle and resuming starts another task. The old callback then unconditionally sets `syncing.task = None` at line 74, clearing the new task's handle. A subsequent pause leaves that network task running despite the paused banner. The probe reproduced `resumed handle retained=false; paused task cancelled=false` (.work/C12.8-r1-probes.log). Associate completion callbacks with their particular sync invocation and ignore obsolete completions; add a regression covering completion queued across pause/resume. The existing pause test waits for the first sync to finish before pausing and cannot detect this failure. + +- [P2] apps/ely-mail/src/syncing.rs:135 — Enabling offline retention during a running sync can discard the required download. `set_offline` calls `sync`, which returns at line 36 whenever that account already has a task. If INBOX has already been processed and the task is waiting on Sent, enabling INBOX offline saves the flag but never revisits INBOX. A real TLS scripted-server probe finished the sync with `offline=true body_cached=false`: the old message still had only headers (.work/C12.8-r1-probes.log). Keep a pending resync request or safely restart the account so the changed folder policy is applied before reporting completion; test enabling the flag after that folder has been visited. + +- [P2] apps/ely-mail/src/controls.rs:75 — The unbounded folder switch column has no scrolling container, and neither the account body nor Shell supplies one. With 40 folders in a 1100×800 window, the rendered folder list starts at y=630 and ends at y=1782; Remove Account is at y=1798. The later offline switches cannot be reached on screen, preventing per-folder offline configuration (.work/C12.8-r1-probes.log, layout selectors added only to the probe copies). Constrain and scroll the folder list or account content, and verify that later folders remain reachable in a bounded window. + +- [P2] apps/ely-mail/src/view.rs:150 — The download-range row always says that bodies cover the last 90 days and attachments download only when requested, even when a folder has offline retention enabled. After successfully downloading an offline folder, this contradicts both its older cached bodies and `sync::imap`'s whole-message fetches, which also include attachments. PRD §12.3 requires the displayed download range to reflect the policy. Account for offline folders in this row and test the displayed range after toggling one. Correct the same inaccurate attachment claim in tasks/TASKS.md:144, which currently says offline folders still leave attachments on demand. diff --git a/tasks/reviews/C12.8-r2.md b/tasks/reviews/C12.8-r2.md new file mode 100644 index 0000000..513e822 --- /dev/null +++ b/tasks/reviews/C12.8-r2.md @@ -0,0 +1,3 @@ +FAIL + +- [P1] apps/ely-mail/src/syncing.rs:56 — Sync invocation numbers repeat after `stop_sync` removes the entry. During an account's first sync, a synchronous credential read can block; removing the account aborts that task and discards its `Syncing`, but a refused credential deletion restarts syncing with `run = 1` again. If the original credential read subsequently returns an error, its already-running poll completes normally despite the abort. Its completion passes the generation check, clears the replacement task's handle, and makes Pause leave that network task running. A probe through the actual `remove` failure path reproduced `retained=false, cancelled=false` (.work/C12.8-r2-removal-probe.log); allocating invocation numbers across entry recreation made the same probe pass. Keep invocation numbers unique across `stop_sync` and entry recreation, and add a regression covering a late completion after failed removal. The new pause/resume test retains the same entry and misses this case. diff --git a/tasks/reviews/C12.8-r3.md b/tasks/reviews/C12.8-r3.md new file mode 100644 index 0000000..0cdd613 --- /dev/null +++ b/tasks/reviews/C12.8-r3.md @@ -0,0 +1 @@ +PASS