C12.9: three-pane mailbox view, filters, virtualized list; ids never reused

This commit is contained in:
雷电芽衣 committed 2026-10-20 00:25:57 -04:00
1 parent 7f70ab6df7
commit ba358c7de6
29 files changed
+1274 -75

No files matched your search

Generated
+1 -1
View File
@@ -2048,7 +2048,7 @@ dependencies = [
[[package]]
name = "ely-gpui-component"
version = "0.1.1"
source = "git+https://github.com/ELY-Open-Source/Ely-GPUI-Components?rev=de961eb164c1c88797ea35f0860aba9d18c95e4f#de961eb164c1c88797ea35f0860aba9d18c95e4f"
source = "git+https://github.com/ELY-Open-Source/Ely-GPUI-Components?rev=cd365e92ea0c4467c439b8253963461ba0f6d155#cd365e92ea0c4467c439b8253963461ba0f6d155"
dependencies = [
"alacritty_terminal",
"anyhow",
+1 -1
View File
@@ -19,7 +19,7 @@ jiff = "0.2"
anyhow = "1"
directories = "6"
dunce = "1"
ely-gpui-component = { git = "https://github.com/ELY-Open-Source/Ely-GPUI-Components", rev = "de961eb164c1c88797ea35f0860aba9d18c95e4f" }
ely-gpui-component = { git = "https://github.com/ELY-Open-Source/Ely-GPUI-Components", rev = "cd365e92ea0c4467c439b8253963461ba0f6d155" }
env_logger = "0.11"
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] }
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] }
+2 -2
View File
@@ -40,7 +40,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
## 组件映射
依赖 `ely-gpui-component`(pin de961eb,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
依赖 `ely-gpui-component`(pin cd365e9,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
| 需要 | 用 |
| --- | --- |
@@ -320,7 +320,7 @@ 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 标签、链接地址与引用标签、图片、脚注引用,`<code>`、`<kbd>` 之间(含嵌套;交叉闭合照可视的读法关闭与重开)的文字照原文;`\\(` 不换。公式里与段落里另有的 `$`(含 `\$`)解析时暂写成文本里没有的控制字节,解析后还原,不与换上的 `$` 配对;相邻的两个公式之间暂插一个空格,解析后去掉,仍是两个公式。公式节点的范围是整对分隔符,值是写下的 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` 核对分隔符的各种情形(相邻、`\$`、链接地址、`<code>`、跨块),`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 不同于所记时只删别的 ULine truncated
- 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 不同于所记时只删别的 ULine truncated
- 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` 的 `<slug>.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 <引擎许可>`。
- 2026-10-19(GitHub #2,R9.1):Impress 在母版视图里 `getParts` 数母版、`getPartName` 给母版名,`getPartInfo` 却读同一序号的幻灯片,母版多于幻灯片时信息里没有 `visible`,ElyShow 每次重读部件都报错;补丁 `0052-lok-impress-master-view-part-info.patch` 让母版视图里取母版页(`client/tests/show.rs` 核对)。`ELY_SHARE` 下发布流程不重建内核:新补丁须先在三台机器的共享引擎上各自重建。GitHub PR #1(R9.2,用户定合并):选区画在图块之上,不透明的选区色盖住文字,`LokView` 画选区时不透明度至多 0.3。
+204
View File
@@ -0,0 +1,204 @@
//! What the window has open, and its list.
use std::{collections::HashMap, rc::Rc};
use ely_gpui_component::mail::Mail as Row;
use ely_mail_engine::{AccountId, Filter, MailboxId, Role, Scope, Summary};
use gpui::{Context, SharedString};
use crate::mail::{Mail, text};
pub type Unread = HashMap<MailboxId, u32>;
/// What the middle of the window shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Open {
Inboxes,
Mailbox(AccountId, MailboxId),
/// The account's settings and sync.
Account(AccountId),
}
impl Open {
pub fn key(self) -> SharedString {
match self {
Open::Inboxes => "inboxes".into(),
Open::Mailbox(_, mailbox) => format!("box-{}", mailbox.0).into(),
Open::Account(id) => format!("account-{}", id.0).into(),
}
}
pub fn account(self) -> Option<AccountId> {
match self {
Open::Inboxes => None,
Open::Mailbox(id, _) | Open::Account(id) => Some(id),
}
}
fn scope(self) -> Option<Scope> {
match self {
Open::Inboxes => Some(Scope::Inboxes),
Open::Mailbox(_, mailbox) => Some(Scope::Mailbox(mailbox)),
Open::Account(_) => None,
}
}
}
/// Rows read, and where each key sits.
pub struct Indexed {
rows: Vec<Row>,
at: HashMap<SharedString, usize>,
}
/// Off the UI thread: a long list takes a while.
pub fn index(rows: Vec<Row>) -> Indexed {
let at = rows
.iter()
.enumerate()
.map(|(ix, row)| (row.key.clone(), ix))
.collect();
Indexed { rows, at }
}
/// The open list and the messages picked in it.
#[derive(Default)]
pub struct Listing {
/// Numbers reads; an older one is dropped.
pub read: u64,
/// None until read, or when nothing lists mail.
pub mails: Option<Result<Rc<Vec<Row>>, String>>,
at: HashMap<SharedString, usize>,
pub selected: Vec<SharedString>,
/// The one message picked, for the reading pane.
pub chosen: Option<Row>,
}
impl Listing {
pub fn show(&mut self, mails: Option<Result<Indexed, String>>) {
self.at = HashMap::new();
self.mails = mails.map(|read| {
read.map(|indexed| {
self.at = indexed.at;
Rc::new(indexed.rows)
})
});
}
/// Keeps the picks still listed.
fn pick(&mut self, keys: Vec<SharedString>) {
let Some(Ok(mails)) = &self.mails else {
self.selected.clear();
self.chosen = None;
return;
};
self.selected = keys
.into_iter()
.filter(|key| self.at.contains_key(key))
.collect();
self.chosen = match &self.selected[..] {
[key] => Some(mails[self.at[key]].clone()),
_ => None,
};
}
}
impl Mail {
pub fn open_box(&mut self, open: Open, cx: &mut Context<Self>) {
log::info!("mail: open {}", open.key());
if open != self.open {
self.open = open;
self.listing.show(None);
self.listing.pick(Vec::new());
}
self.read_mail(cx);
self.notify(cx);
}
pub fn set_filter(&mut self, filter: Filter, cx: &mut Context<Self>) {
log::info!("mail: filter {filter:?}");
self.filter = filter;
self.read_mail(cx);
cx.notify();
}
pub fn select(&mut self, keys: &[SharedString], cx: &mut Context<Self>) {
self.listing.pick(keys.to_vec());
cx.notify();
}
/// Unread counts, and the open list when it lists mail.
pub fn read_mail(&mut self, cx: &mut Context<Self>) {
self.listing.read += 1;
let (read, scope, filter) = (self.listing.read, self.open.scope(), self.filter);
let none = (
text(cx, "mail.no_sender", &[]),
text(cx, "mail.no_subject", &[]),
);
self.with_store(
cx,
move |store| {
let rows = match scope {
Some(scope) => {
let list = store.list(scope, filter)?;
Some(index(
list.into_iter().map(|each| row(each, &none)).collect(),
))
}
None => None,
};
Ok((rows, store.unread()?))
},
move |mail, read_now, _| {
if mail.listing.read != read {
return log::info!("mail: list read {read} ended after a newer one");
}
match read_now {
Ok((rows, unread)) => {
mail.unread = unread;
mail.listing.show(rows.map(Ok));
}
Err(error) => {
log::warn!("mail: list read {read} failed: {error}");
mail.listing.show(scope.map(|_| Err(error)));
}
}
let keys = std::mem::take(&mut mail.listing.selected);
mail.listing.pick(keys);
},
);
}
/// Unread mail in every account's inbox.
pub fn inboxes_unread(&self) -> u32 {
self.syncs
.values()
.flat_map(|syncing| &syncing.folders)
.filter(|folder| folder.role == Some(Role::Inbox))
.filter_map(|folder| self.unread.get(&folder.id))
.sum()
}
}
/// `none` names a missing sender, then subject.
fn row(summary: Summary, none: &(SharedString, SharedString)) -> Row {
let key = summary.id.0.to_string();
let from = summary.from.first().map(|from| match &from.name {
Some(name) if !name.trim().is_empty() => name.clone(),
_ => from.email.clone(),
});
let from: SharedString = from.map_or_else(|| none.0.clone(), Into::into);
let subject: SharedString = summary.subject.map_or_else(|| none.1.clone(), Into::into);
let row = match summary.date.map(jiff::Timestamp::from_second) {
Some(Ok(at)) => Row::new(key, from, subject, at),
Some(Err(error)) => {
log::warn!("mail: message {} date unshown: {error}", summary.id.0);
Row::undated(key, from, subject)
}
None => Row::undated(key, from, subject),
};
let row = row.unread(!summary.seen).starred(summary.flagged);
match summary.attachments {
Some(count) => row.attachments(count as usize),
None => row,
}
}
+1 -1
View File
@@ -251,7 +251,7 @@ impl Mail {
if let Some(Ok(accounts)) = &mut mail.accounts {
accounts.push((id, account));
}
mail.picked = Some(id);
mail.open_box(crate::boxes::Open::Account(id), cx);
mail.sync(id, cx);
if mail.awaiting(asked).is_some() {
mail.adding = None;
+21 -13
View File
@@ -7,13 +7,19 @@ use std::{
};
use ely_gpui_component::primitives::Severity;
use ely_mail_engine::{Account, AccountId, Store, TlsConnector, Vault};
use ely_mail_engine::{Account, AccountId, Filter, Store, TlsConnector, Vault};
use ely_platform::AppId;
use ely_ui::Root;
use gpui::{App, AppContext as _, Context, Entity, FocusHandle, SharedString, WeakEntity, Window};
use tokio::runtime::Runtime;
use crate::{adding::Adding, removing::Removing, side::Side, syncing::Syncs};
use crate::{
adding::Adding,
boxes::{Listing, Open, Unread},
removing::Removing,
side::Side,
syncing::Syncs,
};
/// What the engine runs on and keeps to.
pub struct Parts {
@@ -40,7 +46,11 @@ pub struct Mail {
runtime: Arc<Runtime>,
/// None until read.
pub accounts: Option<Result<Vec<(AccountId, Account)>, String>>,
pub picked: Option<AccountId>,
/// Remove acts on its account.
pub open: Open,
pub filter: Filter,
pub listing: Listing,
pub unread: Unread,
pub adding: Option<Adding>,
pub removing: Option<Removing>,
pub syncs: Syncs,
@@ -102,7 +112,10 @@ impl Mail {
tls: parts.tls,
runtime: Arc::new(runtime),
accounts: None,
picked: None,
open: Open::Inboxes,
filter: Filter::default(),
listing: Listing::default(),
unread: Unread::new(),
adding: None,
removing: None,
syncs: Syncs::new(),
@@ -126,15 +139,15 @@ impl Mail {
let first = mail.accounts.is_none();
if let Ok(accounts) = &read {
let still = |id| accounts.iter().any(|(each, _)| *each == id);
mail.picked = mail
.picked
.filter(|id| still(*id))
.or_else(|| accounts.first().map(|(id, _)| *id));
if mail.open.account().is_some_and(|id| !still(id)) {
mail.open = Open::Inboxes;
}
}
mail.accounts = Some(read);
if first {
mail.sync_all(cx);
}
mail.read_mail(cx);
},
);
}
@@ -146,11 +159,6 @@ impl Mail {
}
}
pub fn pick(&mut self, id: AccountId, cx: &mut Context<Self>) {
self.picked = Some(id);
self.notify(cx);
}
/// Redraws the window and its account list.
pub fn notify(&self, cx: &mut Context<Self>) {
self.side.update(cx, |_, cx| cx.notify());
+2
View File
@@ -3,11 +3,13 @@
mod add_view;
mod adding;
mod boxes;
mod controls;
mod flow;
mod kinds;
mod mail;
mod menu;
mod panes;
mod removing;
mod side;
mod syncing;
+121
View File
@@ -0,0 +1,121 @@
//! The message list and the reading pane (C12.9).
use ely_gpui_component::{
buttons::{ToggleGroup, ToggleItem},
data_display::DescriptionList,
feedback::{EmptyState, ErrorView},
layout::SplitPane,
mail::MailList,
primitives::IconName,
};
use ely_mail_engine::Filter;
use gpui::{
AnyElement, Axis, Context, IntoElement, ParentElement as _, SharedString, Styled as _, div, px,
};
use crate::{
mail::{Mail, text},
view::local_time,
};
type Filtered = fn(Filter) -> bool;
const FILTERS: [(&str, &str, IconName, Filtered); 3] = [
("unread", "mail.filter.unread", IconName::Mail, |f| f.unread),
("flagged", "mail.filter.flagged", IconName::Star, |f| {
f.flagged
}),
(
"attachments",
"mail.filter.attachments",
IconName::Paperclip,
|f| f.attachments,
),
];
impl Mail {
pub fn panes(&self, cx: &mut Context<Self>) -> AnyElement {
SplitPane::new("mail-panes", Axis::Horizontal, px(240.))
.pane(self.list_pane(cx))
.pane(self.reader(cx))
.into_any_element()
}
fn list_pane(&self, cx: &mut Context<Self>) -> AnyElement {
let on: Vec<&str> = FILTERS
.iter()
.filter(|(_, _, _, on)| on(self.filter))
.map(|(key, _, _, _)| *key)
.collect();
let toggles = FILTERS.iter().fold(
ToggleGroup::new("mail-filters").multiple().selected(on),
|group, (key, label, icon, _)| {
let words = text(cx, label, &[]);
group.item(ToggleItem::new(*key).icon(*icon).label(words))
},
);
let this = cx.weak_entity();
let toggles = toggles.on_change(move |keys, _, cx| {
let has = |key: &str| keys.iter().any(|each| each == key);
let filter = Filter {
unread: has("unread"),
flagged: has("flagged"),
attachments: has("attachments"),
};
this.update(cx, |mail, cx| mail.set_filter(filter, cx)).ok();
});
let list = match &self.listing.mails {
None => div().into_any_element(),
Some(Err(detail)) => ErrorView::new(
"mail-list-error",
IconName::TriangleAlert,
text(cx, "mail.list.unread", &[]),
)
.detail(detail.clone())
.into_any_element(),
Some(Ok(mails)) => {
let this = cx.weak_entity();
MailList::new("mail-list", mails.clone())
.selected(self.listing.selected.clone())
.on_select(move |keys, _, cx| {
this.update(cx, |mail, cx| mail.select(keys, cx)).ok();
})
.into_any_element()
}
};
div()
.size_full()
.flex()
.flex_col()
.child(div().p_2().child(toggles))
.child(div().flex_1().min_h_0().child(list))
.into_any_element()
}
fn reader(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(row) = &self.listing.chosen else {
return EmptyState::new(
"mail-reader-empty",
IconName::Mail,
text(cx, "mail.reader.none", &[]),
)
.into_any_element();
};
let label = |key: &str| text(cx, key, &[]);
let date: SharedString = match row.at {
Some(at) => local_time(at.as_second()).into(),
None => label("mail.reader.no_date"),
};
let details = DescriptionList::new()
.text(label("mail.reader.from"), row.from.clone())
.text(label("mail.reader.date"), date);
div()
.p_6()
.flex()
.flex_col()
.gap_4()
.child(div().text_xl().child(row.subject.clone()))
.child(details)
.into_any_element()
}
}
+4 -1
View File
@@ -16,7 +16,10 @@ pub struct Removing {
impl Mail {
/// Measures what goes, then asks.
pub fn ask_remove(&mut self, cx: &mut Context<Self>) {
let Some(id) = self.picked else { return };
let Some(id) = self.open.account() else {
let words = text(cx, "mail.remove.none", &[]);
return self.toast(words, Severity::Info, cx);
};
self.with_store(
cx,
move |store| store.cached(id),
+100 -25
View File
@@ -1,14 +1,19 @@
//! The account list down the left.
//! Every inbox, then each account's folders, down the left.
use ely_gpui_component::lists::{ListItem, SelectableList};
use ely_mail_engine::AccountId;
use ely_gpui_component::{
layout::ScrollArea,
mail::{Mailbox, MailboxList},
primitives::IconName,
};
use ely_mail_engine::{LocalFolder, Role};
use gpui::{
Context, IntoElement, ParentElement as _, Render, Styled as _, WeakEntity, Window, div,
App, Context, IntoElement, ParentElement as _, Render, SharedString, Styled as _, WeakEntity,
Window, div,
};
use crate::{
boxes::Open,
mail::{Mail, text},
view::protocol,
};
pub struct Side {
@@ -24,28 +29,98 @@ impl Side {
impl Render for Side {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(mail) = self.mail.upgrade() else {
return div();
return div().into_any_element();
};
let mail = mail.read(cx);
let list = mail.accounts().iter().fold(
SelectableList::new("mail-accounts").label(text(cx, "mail.accounts", &[])),
|list, (id, account)| {
let item = ListItem::new(("mail-account", id.0 as u64), account.address.clone())
.description(protocol(account, cx));
list.row(id.0.to_string(), item)
},
);
let pick = self.mail.clone();
let list = list
.selected(mail.picked.map(|id| id.0.to_string()))
.on_change(move |keys, _, cx| {
let Some(id) = keys.first().and_then(|key| key.parse().ok()) else {
return log::error!("mail: the account list sent no account");
};
pick.update(cx, |mail, cx| mail.pick(AccountId(id), cx))
.ok();
let inboxes = Mailbox::new(
Open::Inboxes.key(),
text(cx, "mail.boxes.all", &[]),
IconName::Inbox,
)
.unread(mail.inboxes_unread());
let mut list =
MailboxList::new("mail-boxes").section(text(cx, "mail.boxes.unified", &[]), [inboxes]);
for (id, account) in mail.accounts() {
let folders = mail.syncs.get(id).map_or(&[][..], |each| &each.folders);
let mut sorted: Vec<&LocalFolder> = folders.iter().collect();
sorted.sort_by(|a, b| rank(a.role).cmp(&rank(b.role)).then(a.name.cmp(&b.name)));
let boxes = sorted.into_iter().map(|folder| {
let open = Open::Mailbox(*id, folder.id);
// Boxes with nothing unread are absent.
let unread = mail.unread.get(&folder.id).copied().unwrap_or(0);
Mailbox::new(open.key(), name(folder, cx), icon(folder.role)).unread(unread)
});
let settings = Mailbox::new(
Open::Account(*id).key(),
text(cx, "mail.boxes.settings", &[]),
IconName::Settings,
);
list = list.section(account.address.clone(), boxes.chain([settings]));
}
let this = self.mail.clone();
let list = list.open(mail.open.key()).on_open(move |key, _, cx| {
this.update(cx, |mail, cx| match mail.opened(key) {
Some(open) => mail.open_box(open, cx),
None => log::error!("mail: the mailbox list sent an unknown box"),
})
.size_full();
div().size_full().p_2().child(list)
.ok();
});
ScrollArea::new("mail-side-scroll")
.vertical()
.size_full()
.child(div().p_2().child(list))
.into_any_element()
}
}
impl Mail {
/// The box a side key names, if still here.
fn opened(&self, key: &SharedString) -> Option<Open> {
if *key == Open::Inboxes.key() {
return Some(Open::Inboxes);
}
self.accounts().iter().find_map(|(id, _)| {
let folders = self.syncs.get(id).map_or(&[][..], |each| &each.folders);
let boxes = folders.iter().map(|folder| Open::Mailbox(*id, folder.id));
boxes
.chain([Open::Account(*id)])
.find(|open| open.key() == *key)
})
}
}
/// Inbox in the reader's words; others as named.
fn name(folder: &LocalFolder, cx: &App) -> SharedString {
match folder.role {
Some(Role::Inbox) => text(cx, "mail.boxes.inbox", &[]),
_ => folder.name.clone().into(),
}
}
fn rank(role: Option<Role>) -> u8 {
match role {
Some(Role::Inbox) => 0,
Some(Role::Flagged) => 1,
Some(Role::Drafts) => 2,
Some(Role::Sent) => 3,
Some(Role::Archive) => 4,
Some(Role::All) => 5,
Some(Role::Junk) => 6,
Some(Role::Trash) => 7,
None => 8,
}
}
fn icon(role: Option<Role>) -> IconName {
match role {
Some(Role::Inbox) => IconName::Inbox,
Some(Role::Flagged) => IconName::Star,
Some(Role::Drafts) => IconName::File,
Some(Role::Sent) => IconName::Send,
Some(Role::Archive) => IconName::Archive,
Some(Role::All) => IconName::Mail,
Some(Role::Junk) => IconName::TriangleAlert,
Some(Role::Trash) => IconName::Trash2,
None => IconName::Folder,
}
}
+1
View File
@@ -177,6 +177,7 @@ impl Mail {
(Ok((synced, folders)), Some(syncing)) => {
syncing.synced = Some(synced);
syncing.folders = folders;
mail.read_mail(cx);
}
(Err(error), Some(_)) => mail.failed("mail.sync.unread", &error, cx),
(_, None) => {}
+1
View File
@@ -23,6 +23,7 @@ use crate::{
mod controls;
mod flows;
mod panes;
mod syncs;
/// A credential store that keeps or refuses.
+22 -1
View File
@@ -10,7 +10,7 @@ use gpui::{
};
use super::{Keys, account, settle, window};
use crate::{mail::Mail, syncing::EVERY, view::imap_range};
use crate::{boxes::Open, mail::Mail, syncing::EVERY, view::imap_range};
/// An account on a server that never answers.
fn silent(dir: &std::path::Path, port: u16) -> AccountId {
@@ -252,6 +252,7 @@ fn the_account_page_scrolls_to_its_last_folder() {
.unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(Open::Account(ann), cx)));
cx.simulate_resize(size(px(1100.), px(800.)));
settle(cx, |app| {
mail.read(app)
@@ -315,3 +316,23 @@ fn the_range_names_the_folders_kept_whole() {
);
});
}
#[test]
fn remove_needs_an_open_account() {
let dir = tempfile::tempdir().unwrap();
Store::open(dir.path())
.unwrap()
.add_account(&account("ann@example.com"))
.unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
settle(cx, |app| mail.read(app).accounts().len() == 1);
cx.update(|_, app| assert_eq!(mail.read(app).open, Open::Inboxes));
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
cx.run_until_parked();
cx.update(|_, app| {
let mail = mail.read(app);
assert!(mail.removing.is_none());
assert_eq!(mail.accounts().len(), 1);
});
}
+16 -6
View File
@@ -4,7 +4,7 @@ use ely_mail_engine::{Auth, Error, Fetched, Key, Protocol, Security, Store, oaut
use gpui::{Entity, TestAppContext, VisualTestContext};
use super::{Keys, account, adding, closed_port, passed, settle, step_words, type_in, window};
use crate::{adding::Step, kinds::Kind, mail::Mail};
use crate::{adding::Step, boxes::Open, kinds::Kind, mail::Mail};
#[test]
fn an_imap_form_says_what_is_missing() {
@@ -188,7 +188,7 @@ fn a_refused_keychain_keeps_the_account_and_says_why() {
cx.update(|_, app| {
let mail = mail.read(app);
assert_eq!(mail.accounts().len(), 1);
assert_eq!(mail.picked, Some(mail.accounts()[0].0));
assert_eq!(mail.open, Open::Account(mail.accounts()[0].0));
assert_eq!(
mail.vault
.get("account:ann@example.com")
@@ -245,6 +245,7 @@ fn an_account_is_kept_and_removed_with_its_secret() {
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
cx.update(|_, app| assert_eq!(mail.read(app).accounts()[0].1, account("ann@example.com")));
open_first(&mail, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
settle(cx, |app| mail.read(app).removing.is_some());
cx.update(|_, app| {
@@ -272,6 +273,7 @@ fn a_refused_secret_removal_keeps_the_account() {
};
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
open_first(&mail, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
settle(cx, |app| mail.read(app).removing.is_some());
press_remove(&mail, cx);
@@ -320,6 +322,7 @@ still here
.unwrap()
.file_name();
std::fs::create_dir(shard.join(format!(".{}.gone", name.to_str().unwrap()))).unwrap();
open_first(&mail, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
settle(cx, |app| mail.read(app).removing.is_some());
press_remove(&mail, cx);
@@ -386,7 +389,7 @@ fn two_saves_in_flight_add_an_address_once() {
}
#[test]
fn the_keyboard_picks_another_account() {
fn the_keyboard_opens_another_accounts_settings() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(dir.path()).unwrap();
store.add_account(&account("ann@example.com")).unwrap();
@@ -401,13 +404,13 @@ fn the_keyboard_picks_another_account() {
cx.run_until_parked();
}
for _ in 0..8 {
cx.simulate_keystrokes("tab down");
cx.simulate_keystrokes("tab space");
cx.run_until_parked();
if cx.update(|_, app| mail.read(app).picked) == Some(bo) {
if cx.update(|_, app| mail.read(app).open) == Open::Account(bo) {
return;
}
}
panic!("no Tab stop let Down reach the second account");
panic!("no Tab stop let Space open the second account");
}
/// Through the dialog: back to its last stop, Remove.
@@ -423,6 +426,13 @@ fn press_remove(mail: &Entity<Mail>, cx: &mut VisualTestContext) {
settle(cx, |app| mail.read(app).removing.is_none());
}
/// Opens the first account's page, as a user would.
fn open_first(mail: &Entity<Mail>, cx: &mut VisualTestContext) {
settle(cx, |app| !mail.read(app).accounts().is_empty());
let first = cx.update(|_, app| mail.read(app).accounts()[0].0);
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(Open::Account(first), cx)));
}
/// The save lands after a second dialog opened.
fn save_then_reopen(fail: bool) {
let dir = tempfile::tempdir().unwrap();
+258
View File
@@ -0,0 +1,258 @@
use std::time::{Duration, Instant};
use ely_gpui_component::mail::Mail as Row;
use ely_mail_engine::{AccountId, Fetched, Filter, Key, MailboxId, Role, Store};
use gpui::{
Entity, ScrollDelta, ScrollWheelEvent, SharedString, TestAppContext, VisualTestContext, point,
px, size,
};
use super::{Keys, account, settle, window};
use crate::{
boxes::{self, Open},
mail::Mail,
};
fn message(subject: &str, date: &str) -> Vec<u8> {
format!(
"From: Ann <ann@example.com>\r\nSubject: {subject}\r\nDate: {date}\r\n\
Content-Type: text/plain\r\n\r\nhi\r\n"
)
.into_bytes()
}
fn put(store: &mut Store, mailbox: MailboxId, uid: u32, flags: &[&str], subject: &str, day: u8) {
let key = Key::Imap {
uidvalidity: 1,
uid,
};
let bytes = message(subject, &format!("{day} Jan 2024 10:00:00 +0000"));
store
.put(mailbox, &key, flags, Fetched::Whole(&bytes))
.unwrap();
}
/// Ann has an inbox and an archive, Bob an inbox.
fn seeded(dir: &std::path::Path) -> (AccountId, MailboxId) {
let mut store = Store::open(dir).unwrap();
let ann = store.add_account(&account("ann@example.com")).unwrap();
let bob = store.add_account(&account("bob@example.com")).unwrap();
let [ann_inbox, archive] = store
.set_folders(
ann,
&[
("INBOX", Some(Role::Inbox)),
("Archive", Some(Role::Archive)),
],
)
.unwrap()
.try_into()
.unwrap();
let [bob_inbox] = store
.set_folders(bob, &[("INBOX", Some(Role::Inbox))])
.unwrap()
.try_into()
.unwrap();
put(&mut store, ann_inbox, 1, &["\\Seen"], "old", 1);
put(&mut store, ann_inbox, 2, &[], "new", 3);
put(&mut store, archive, 3, &[], "kept", 2);
put(&mut store, bob_inbox, 4, &["\\Flagged"], "bob", 2);
(ann, archive)
}
/// The listed subjects, once a list is read.
fn listed(mail: &Entity<Mail>, cx: &mut VisualTestContext) -> Option<Vec<String>> {
cx.update(|_, app| match &mail.read(app).listing.mails {
Some(Ok(rows)) => Some(rows.iter().map(|row| row.subject.to_string()).collect()),
Some(Err(error)) => panic!("the list failed: {error}"),
None => None,
})
}
fn shows(mail: &Entity<Mail>, cx: &mut VisualTestContext, subjects: &[&str]) {
let want: Vec<String> = subjects.iter().map(|each| each.to_string()).collect();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
while listed(mail, cx).as_ref() != Some(&want) {
assert!(
std::time::Instant::now() < deadline,
"listed {:?}, not {want:?}",
listed(mail, cx)
);
cx.run_until_parked();
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
fn key_of(mail: &Entity<Mail>, cx: &mut VisualTestContext, subject: &str) -> SharedString {
cx.update(|_, app| {
let mails = mail.read(app).listing.mails.clone();
let rows = mails.unwrap().unwrap();
rows.iter()
.find(|row| row.subject == subject)
.unwrap()
.key
.clone()
})
}
#[test]
fn every_inbox_is_one_list_newest_first() {
let dir = tempfile::tempdir().unwrap();
seeded(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
cx.update(|_, app| assert_eq!(mail.read(app).open, Open::Inboxes));
shows(&mail, cx, &["new", "bob", "old"]);
settle(cx, |app| mail.read(app).inboxes_unread() == 2);
}
#[test]
fn filters_narrow_the_list() {
let dir = tempfile::tempdir().unwrap();
seeded(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
shows(&mail, cx, &["new", "bob", "old"]);
let unread = Filter {
unread: true,
..Filter::default()
};
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_filter(unread, cx)));
shows(&mail, cx, &["new", "bob"]);
let flagged = Filter {
unread: true,
flagged: true,
..Filter::default()
};
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_filter(flagged, cx)));
shows(&mail, cx, &["bob"]);
}
#[test]
fn a_folder_lists_its_own_mail() {
let dir = tempfile::tempdir().unwrap();
let (ann, archive) = seeded(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
shows(&mail, cx, &["new", "bob", "old"]);
let open = Open::Mailbox(ann, archive);
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(open, cx)));
shows(&mail, cx, &["kept"]);
}
#[test]
fn one_pick_fills_the_reader() {
let dir = tempfile::tempdir().unwrap();
let (ann, archive) = seeded(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
shows(&mail, cx, &["new", "bob", "old"]);
let (new, bob) = (key_of(&mail, cx, "new"), key_of(&mail, cx, "bob"));
let chosen = |cx: &mut VisualTestContext| {
cx.update(|_, app| {
let chosen = mail.read(app).listing.chosen.as_ref();
chosen.map(|row| row.subject.to_string())
})
};
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(std::slice::from_ref(&new), cx)));
assert_eq!(chosen(cx).as_deref(), Some("new"));
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&[new, bob], cx)));
assert_eq!(chosen(cx), None);
let open = Open::Mailbox(ann, archive);
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(open, cx)));
cx.update(|_, app| assert!(mail.read(app).listing.selected.is_empty()));
}
#[test]
fn a_rebuilt_mailbox_drops_the_pick() {
let dir = tempfile::tempdir().unwrap();
let (ann, archive) = seeded(dir.path());
// The newest row, whose id SQLite would hand out again.
let mut store = Store::open(dir.path()).unwrap();
put(&mut store, archive, 9, &[], "picked", 4);
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
let open = Open::Mailbox(ann, archive);
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(open, cx)));
shows(&mail, cx, &["picked", "kept"]);
let picked = key_of(&mail, cx, "picked");
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&[picked], cx)));
store.open_mailbox(archive, 2).unwrap();
let key = Key::Imap {
uidvalidity: 2,
uid: 3,
};
let bytes = message("other", "2 Jan 2024 10:00:00 +0000");
store
.put(archive, &key, &[], Fetched::Whole(&bytes))
.unwrap();
drop(store);
cx.update(|_, app| mail.update(app, |mail, cx| mail.read_mail(cx)));
shows(&mail, cx, &["other"]);
cx.update(|_, app| {
let listing = &mail.read(app).listing;
assert!(listing.selected.is_empty(), "kept {:?}", listing.selected);
assert!(listing.chosen.is_none());
});
}
#[test]
fn picking_every_one_of_many_messages_is_quick() {
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
shows(&mail, cx, &[]);
let rows: Vec<Row> = (0..100_000)
.map(|n| Row::undated(n.to_string(), "Ann", "hi"))
.collect();
let keys: Vec<SharedString> = rows.iter().map(|row| row.key.clone()).collect();
let listed = boxes::index(rows);
cx.update(|_, app| mail.update(app, |mail, _| mail.listing.show(Some(Ok(listed)))));
let started = Instant::now();
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&keys, cx)));
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&keys, cx)));
let took = started.elapsed();
assert!(took < Duration::from_secs(1), "picking took {took:?}");
cx.update(|_, app| assert_eq!(mail.read(app).listing.selected.len(), 100_000));
}
#[test]
fn the_side_scrolls_past_many_folders_to_the_next_account() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path()).unwrap();
let ann = store.add_account(&account("ann@example.com")).unwrap();
let bob = store.add_account(&account("bob@example.com")).unwrap();
let names: Vec<String> = (0..40).map(|n| format!("Folder {n:02}")).collect();
let folders: Vec<_> = names.iter().map(|name| (name.as_str(), None)).collect();
store.set_folders(ann, &folders).unwrap();
store
.set_folders(bob, &[("INBOX", Some(Role::Inbox))])
.unwrap();
drop(store);
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(Open::Account(bob), cx)));
cx.simulate_resize(size(px(1100.), px(800.)));
settle(cx, |app| {
let syncs = &mail.read(app).syncs;
syncs.get(&ann).is_some_and(|each| each.folders.len() == 40) && syncs.contains_key(&bob)
});
let selector = "item-selected-Account settings";
let height = cx.update(|window, _| window.viewport_size().height);
let before = cx.debug_bounds(selector).expect("Bob's settings row");
assert!(before.bottom() > height, "the side fits the window");
let at = point(px(100.), 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("Bob's settings row");
assert!(
after.bottom() <= height,
"Bob's settings are out of reach: {after:?}"
);
}
+11 -13
View File
@@ -9,7 +9,7 @@ use ely_gpui_component::{
primitives::{IconName, Severity},
typography::format,
};
use ely_mail_engine::{Account, AccountId, Auth, LocalFolder, Protocol, Security, Server, sync};
use ely_mail_engine::{AccountId, Auth, LocalFolder, Protocol, Security, Server, sync};
use ely_platform::AppId;
use ely_ui::Shell;
use gpui::{
@@ -18,20 +18,11 @@ use gpui::{
};
use crate::{
boxes::Open,
mail::{Mail, text},
menu,
};
/// "IMAP · imap.example.com"
pub fn protocol(account: &Account, cx: &App) -> SharedString {
let name = protocol_name(account.protocol);
text(
cx,
"mail.account.line",
&[("protocol", name), ("host", &account.incoming.host)],
)
}
fn server(server: &Server, cx: &App) -> SharedString {
let security = match server.security {
Security::Tls => "TLS",
@@ -78,10 +69,17 @@ impl Mail {
Some(Err(detail)) => return error("mail.store.unread", detail, cx),
Some(Ok(accounts)) => accounts,
};
let Some((id, account)) = accounts.iter().find(|(id, _)| Some(*id) == self.picked) else {
if accounts.is_empty() {
return EmptyState::new("mail-empty", IconName::Mail, text(cx, "mail.empty", &[]))
.action(self.add_button(cx))
.into_any_element();
}
let Open::Account(open) = self.open else {
return self.panes(cx);
};
let Some((id, account)) = accounts.iter().find(|(id, _)| *id == open) else {
log::error!("mail: account {} open but not listed", open.0);
return error("mail.store.unread", "", cx);
};
let label = |key: &str| text(cx, key, &[]);
let incoming = format!(
@@ -272,7 +270,7 @@ impl Render for Mail {
}
/// Local time to the minute.
fn local_time(at: i64) -> String {
pub fn local_time(at: i64) -> String {
match jiff::Timestamp::from_second(at) {
Ok(at) => at
.to_zoned(jiff::tz::TimeZone::system())
+2 -1
View File
@@ -21,7 +21,8 @@ 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, LocalFolder, MailboxId, MessageId, Sizes, Store, Stored, Synced,
AccountId, Cached, Fetched, Filter, Held, Key, LocalFolder, MailboxId, MessageId, Scope, Sizes,
Store, Stored, Summary, Synced,
};
pub use tokio_rustls::TlsConnector;
pub use vault::{Backend, Kept, Keyring, Vault};
+9 -2
View File
@@ -14,6 +14,7 @@ mod accounts;
mod aside;
mod folders;
mod html;
mod listing;
mod messages;
mod schema;
mod search;
@@ -22,6 +23,7 @@ mod tests;
pub use accounts::Cached;
pub use folders::{Held, LocalFolder, Synced};
pub use listing::{Filter, Scope, Summary};
pub use messages::{Fetched, Key, Stored};
const DB: &str = "mail.db";
@@ -175,13 +177,18 @@ fn upgrade(
Ok(())
}
fn migrate(conn: &mut Connection, version: usize, steps: &[&str]) -> rusqlite::Result<()> {
fn migrate(conn: &mut Connection, version: usize, steps: &[&str]) -> Result<()> {
// A rebuilt parent would cascade into its children.
conn.pragma_update(None, "foreign_keys", false)?;
let tx = conn.transaction()?;
for step in &steps[version..] {
tx.execute_batch(step)?;
}
if tx.prepare("PRAGMA foreign_key_check")?.exists([])? {
return Err(Error::Store("rows point at missing rows".into()));
}
tx.pragma_update(None, "user_version", steps.len() as i64)?;
tx.commit()
Ok(tx.commit()?)
}
fn backup(conn: &Connection, dir: &Path, version: usize) -> Result<()> {
+111
View File
@@ -0,0 +1,111 @@
//! Message lists: one mailbox or every inbox, newest first.
use std::collections::HashMap;
use rusqlite::ToSql;
use super::{MailboxId, MessageId, Store};
use crate::{Mailbox, Result};
/// Which messages a list holds.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Scope {
Mailbox(MailboxId),
/// Every account's inbox together.
Inboxes,
}
/// Each set field narrows the list.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Filter {
pub unread: bool,
pub flagged: bool,
pub attachments: bool,
}
/// A message as a list shows it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Summary {
pub id: MessageId,
pub from: Vec<Mailbox>,
pub subject: Option<String>,
pub date: Option<i64>,
pub seen: bool,
pub flagged: bool,
/// None until the body is here.
pub attachments: Option<u32>,
}
// RFC 3501 §2.3.2: system flags match in any case.
const SEEN: &str = "EXISTS (SELECT 1 FROM flags f WHERE f.message = m.id \
AND f.flag = '\\Seen' COLLATE NOCASE)";
const FLAGGED: &str = "EXISTS (SELECT 1 FROM flags f WHERE f.message = m.id \
AND f.flag = '\\Flagged' COLLATE NOCASE)";
impl Store {
/// Newest first; SQLite sorts undated last.
pub fn list(&self, scope: Scope, filter: Filter) -> Result<Vec<Summary>> {
let mut clauses = vec![match scope {
Scope::Mailbox(_) => "m.mailbox = ?1".to_owned(),
Scope::Inboxes => "b.role = 'inbox'".to_owned(),
}];
if filter.unread {
clauses.push(format!("NOT {SEEN}"));
}
if filter.flagged {
clauses.push(FLAGGED.to_owned());
}
if filter.attachments {
clauses.push("m.attachments > 0".to_owned());
}
let sql = format!(
"SELECT m.id, m.sender, m.subject, m.date, m.attachments, {SEEN}, {FLAGGED}
FROM messages m JOIN mailboxes b ON b.id = m.mailbox
WHERE {}
ORDER BY m.date DESC, m.id DESC",
clauses.join(" AND ")
);
let id;
let values: Vec<&dyn ToSql> = match scope {
Scope::Mailbox(mailbox) => {
id = mailbox.0;
vec![&id]
}
Scope::Inboxes => Vec::new(),
};
let mut query = self.conn.prepare(&sql)?;
let rows = query.query_map(values.as_slice(), |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
))
})?;
rows.map(|row| {
let (id, from, subject, date, attachments, seen, flagged) = row?;
Ok(Summary {
id: MessageId(id),
from: serde_json::from_str(&from)?,
subject,
date,
seen,
flagged,
attachments,
})
})
.collect()
}
/// Unread messages per mailbox; mailboxes with none are left out.
pub fn unread(&self) -> Result<HashMap<MailboxId, u32>> {
let mut query = self.conn.prepare(&format!(
"SELECT m.mailbox, COUNT(*) FROM messages m WHERE NOT {SEEN} GROUP BY m.mailbox"
))?;
let rows = query.query_map([], |row| Ok((MailboxId(row.get(0)?), row.get(1)?)))?;
Ok(rows.collect::<rusqlite::Result<_>>()?)
}
}
+62 -1
View File
@@ -1,5 +1,5 @@
/// Step `n` upgrades version `n`.
pub(super) const STEPS: &[&str] = &[V1, V2, V3];
pub(super) const STEPS: &[&str] = &[V1, V2, V3, V4];
const V1: &str = "
CREATE TABLE accounts (
@@ -63,3 +63,64 @@ ALTER TABLE messages ADD COLUMN missing TEXT NOT NULL DEFAULT '[]';
const V3: &str = "
ALTER TABLE mailboxes ADD COLUMN offline INTEGER NOT NULL DEFAULT 0;
";
/// Ids never reused (C12.9); foreign keys are off here.
const V4: &str = "
CREATE TABLE accounts_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
address TEXT NOT NULL UNIQUE COLLATE NOCASE,
settings TEXT NOT NULL,
synced INTEGER
);
CREATE TABLE mailboxes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account INTEGER NOT NULL REFERENCES accounts ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT,
uidvalidity INTEGER,
offline INTEGER NOT NULL DEFAULT 0,
UNIQUE (account, name)
);
CREATE TABLE messages_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mailbox INTEGER NOT NULL REFERENCES mailboxes ON DELETE CASCADE,
uidvalidity INTEGER,
uid INTEGER,
uidl TEXT,
message_id TEXT,
in_reply_to TEXT NOT NULL,
refs TEXT NOT NULL,
subject TEXT,
sender TEXT NOT NULL,
recipients TEXT NOT NULL,
copies TEXT NOT NULL,
date INTEGER,
attachments INTEGER,
content TEXT,
missing TEXT NOT NULL DEFAULT '[]',
CHECK ((uid IS NULL) = (uidvalidity IS NULL) AND (uid IS NULL) <> (uidl IS NULL))
);
INSERT INTO accounts_new (id, address, settings, synced)
SELECT id, address, settings, synced FROM accounts;
INSERT INTO mailboxes_new (id, account, name, role, uidvalidity, offline)
SELECT id, account, name, role, uidvalidity, offline FROM mailboxes;
INSERT INTO messages_new (id, mailbox, uidvalidity, uid, uidl, message_id, in_reply_to,
refs, subject, sender, recipients, copies, date, attachments, content, missing)
SELECT id, mailbox, uidvalidity, uid, uidl, message_id, in_reply_to,
refs, subject, sender, recipients, copies, date, attachments, content, missing
FROM messages;
DROP TABLE messages;
DROP TABLE mailboxes;
DROP TABLE accounts;
ALTER TABLE accounts_new RENAME TO accounts;
ALTER TABLE mailboxes_new RENAME TO mailboxes;
ALTER TABLE messages_new RENAME TO messages;
CREATE UNIQUE INDEX messages_imap ON messages (mailbox, uidvalidity, uid)
WHERE uid IS NOT NULL;
CREATE UNIQUE INDEX messages_pop ON messages (mailbox, uidl) WHERE uidl IS NOT NULL;
CREATE INDEX messages_thread ON messages (message_id);
CREATE INDEX messages_content ON messages (content);
CREATE TRIGGER messages_gone AFTER DELETE ON messages BEGIN
DELETE FROM search WHERE rowid = old.id;
END;
";
+25 -1
View File
@@ -22,6 +22,9 @@ fn account(address: &str) -> Account {
}
}
mod ids;
mod listing;
const ADD: &str = "ALTER TABLE accounts ADD COLUMN nick TEXT";
const BROKEN: &str = "ALTER TABLE nowhere ADD COLUMN nick TEXT";
@@ -144,7 +147,10 @@ fn a_v1_store_gains_sync_and_keeps_its_mail() {
.set_folders(ann, &[("INBOX", Some(Role::Inbox))])
.unwrap();
let [inbox] = store.folders(ann).unwrap().try_into().unwrap();
assert_eq!((inbox.name.as_str(), inbox.role), ("INBOX", Some(Role::Inbox)));
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::<Vec<_>>(), [1]);
@@ -153,3 +159,21 @@ fn a_v1_store_gains_sync_and_keeps_its_mail() {
assert_eq!(store.synced(ann).unwrap().at, Some(9));
assert_eq!(store.synced(ann).unwrap().messages, 1);
}
#[test]
fn an_upgrade_leaving_a_row_without_its_parent_fails() {
let dir = tempfile::tempdir().unwrap();
let old = open(dir.path(), &[STEPS[0]]).unwrap();
old.conn
.execute_batch(
"PRAGMA foreign_keys = OFF;
INSERT INTO mailboxes (account, name) VALUES (99, 'INBOX');",
)
.unwrap();
drop(old);
let store = open(dir.path(), STEPS).unwrap();
let reason = store.read_only().unwrap();
assert!(reason.contains("missing rows"), "{reason}");
drop(store);
assert_eq!(version(&dir.path().join(DB)), 1);
}
@@ -0,0 +1,74 @@
//! Ids are never reused, so a stale one names nothing.
use super::{STEPS, account, open};
use crate::{Fetched, Key, Role, Store};
const MAIL: &[u8] = b"From: a@example.com\r\nSubject: hi\r\n\r\nhi\r\n";
fn imap(uidvalidity: u32) -> Key {
Key::Imap {
uidvalidity,
uid: 1,
}
}
#[test]
fn a_removed_account_folder_or_message_leaves_its_id_unused() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path()).unwrap();
let ann = store.add_account(&account("ann@example.com")).unwrap();
let bob = store.add_account(&account("bob@example.com")).unwrap();
store.remove_account(bob).unwrap();
let cy = store.add_account(&account("cy@example.com")).unwrap();
assert!(cy.0 > bob.0, "account {} came back", bob.0);
let [_, old] = store
.set_folders(ann, &[("INBOX", Some(Role::Inbox)), ("Old", None)])
.unwrap()
.try_into()
.unwrap();
store
.set_folders(ann, &[("INBOX", Some(Role::Inbox))])
.unwrap();
let [inbox, new] = store
.set_folders(ann, &[("INBOX", Some(Role::Inbox)), ("New", None)])
.unwrap()
.try_into()
.unwrap();
assert!(new.0 > old.0, "folder {} came back", old.0);
store.open_mailbox(inbox, 1).unwrap();
let first = store
.put(inbox, &imap(1), &[], Fetched::Whole(MAIL))
.unwrap();
store.open_mailbox(inbox, 2).unwrap();
let second = store
.put(inbox, &imap(2), &[], Fetched::Whole(MAIL))
.unwrap();
assert!(second.0 > first.0, "message {} came back", first.0);
}
#[test]
fn a_v3_store_keeps_its_ids_and_stops_reusing_them() {
let dir = tempfile::tempdir().unwrap();
let mut old = open(dir.path(), &STEPS[..3]).unwrap();
let ann = old.add_account(&account("ann@example.com")).unwrap();
let [inbox] = old
.set_folders(ann, &[("INBOX", Some(Role::Inbox))])
.unwrap()
.try_into()
.unwrap();
old.open_mailbox(inbox, 1).unwrap();
let first = old
.put(inbox, &imap(1), &["\\Seen"], Fetched::Whole(MAIL))
.unwrap();
drop(old);
let mut store = open(dir.path(), STEPS).unwrap();
assert_eq!(store.read_only(), None);
assert_eq!(store.accounts().unwrap()[0].0, ann);
let held = store.open_mailbox(inbox, 1).unwrap();
assert_eq!(held[&1].id, first);
store.open_mailbox(inbox, 2).unwrap();
let second = store
.put(inbox, &imap(2), &[], Fetched::Whole(MAIL))
.unwrap();
assert!(second.0 > first.0, "message {} came back", first.0);
}
@@ -0,0 +1,181 @@
use super::account;
use crate::{Fetched, Filter, Key, MailboxId, Role, Scope, Store};
fn mail(subject: &str, date: Option<&str>, attachment: bool) -> Vec<u8> {
let mut head = format!("From: Ann <ann@example.com>\r\nSubject: {subject}\r\n");
if let Some(date) = date {
head.push_str(&format!("Date: {date}\r\n"));
}
if attachment {
format!(
"{head}Content-Type: multipart/mixed; boundary=b\r\n\r\n--b\r\n\
Content-Type: text/plain\r\n\r\nhi\r\n--b\r\n\
Content-Type: application/pdf\r\nContent-Disposition: attachment; filename=a.pdf\r\n\r\n\
%PDF\r\n--b--\r\n"
)
.into_bytes()
} else {
format!("{head}Content-Type: text/plain\r\n\r\nhi\r\n").into_bytes()
}
}
fn put(store: &mut Store, mailbox: MailboxId, uid: u32, flags: &[&str], bytes: &[u8]) {
let key = Key::Imap {
uidvalidity: 1,
uid,
};
store
.put(mailbox, &key, flags, Fetched::Whole(bytes))
.unwrap();
}
fn subjects(store: &Store, scope: Scope, filter: Filter) -> Vec<String> {
let listed = store.list(scope, filter).unwrap();
listed.into_iter().map(|s| s.subject.unwrap()).collect()
}
/// Two accounts: inbox and archive, inbox only.
fn two_accounts() -> (tempfile::TempDir, Store, [MailboxId; 3]) {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path()).unwrap();
let ann = store.add_account(&account("ann@example.com")).unwrap();
let bob = store.add_account(&account("bob@example.com")).unwrap();
let [ann_inbox, archive] = store
.set_folders(
ann,
&[
("INBOX", Some(Role::Inbox)),
("Archive", Some(Role::Archive)),
],
)
.unwrap()
.try_into()
.unwrap();
let [bob_inbox] = store
.set_folders(bob, &[("INBOX", Some(Role::Inbox))])
.unwrap()
.try_into()
.unwrap();
(dir, store, [ann_inbox, archive, bob_inbox])
}
#[test]
fn every_inbox_lists_newest_first_and_undated_last() {
let (_dir, mut store, [ann, archive, bob]) = two_accounts();
put(
&mut store,
ann,
1,
&[],
&mail("old", Some("Mon, 1 Jan 2024 10:00:00 +0000"), false),
);
put(
&mut store,
bob,
1,
&[],
&mail("new", Some("Tue, 2 Jan 2024 10:00:00 +0000"), false),
);
put(&mut store, ann, 2, &[], &mail("undated", None, false));
put(
&mut store,
archive,
1,
&[],
&mail("filed", Some("Wed, 3 Jan 2024 10:00:00 +0000"), false),
);
assert_eq!(
subjects(&store, Scope::Inboxes, Filter::default()),
["new", "old", "undated"]
);
assert_eq!(
subjects(&store, Scope::Mailbox(archive), Filter::default()),
["filed"]
);
assert_eq!(
subjects(&store, Scope::Mailbox(ann), Filter::default()),
["old", "undated"]
);
let first = &store.list(Scope::Inboxes, Filter::default()).unwrap()[0];
assert_eq!(first.from[0].email, "ann@example.com");
assert_eq!(first.date, Some(1_704_189_600));
}
#[test]
fn filters_narrow_together() {
let (_dir, mut store, [ann, ..]) = two_accounts();
let date = Some("Mon, 1 Jan 2024 10:00:00 +0000");
put(&mut store, ann, 1, &["\\Seen"], &mail("read", date, false));
put(
&mut store,
ann,
2,
&["\\SEEN", "\\flagged"],
&mail("read flagged", date, true),
);
put(
&mut store,
ann,
3,
&["\\Flagged"],
&mail("unread flagged", date, true),
);
put(&mut store, ann, 4, &[], &mail("unread", date, false));
let scope = Scope::Mailbox(ann);
let unread = Filter {
unread: true,
..Filter::default()
};
let flagged = Filter {
flagged: true,
..Filter::default()
};
let attached = Filter {
attachments: true,
..Filter::default()
};
let all = Filter {
unread: true,
flagged: true,
attachments: true,
};
assert_eq!(
subjects(&store, scope, unread),
["unread", "unread flagged"]
);
assert_eq!(
subjects(&store, scope, flagged),
["unread flagged", "read flagged"]
);
assert_eq!(
subjects(&store, scope, attached),
["unread flagged", "read flagged"]
);
assert_eq!(subjects(&store, scope, all), ["unread flagged"]);
let listed = store.list(scope, Filter::default()).unwrap();
let read_flagged = listed
.iter()
.find(|s| s.subject.as_deref() == Some("read flagged"))
.unwrap();
assert!(read_flagged.seen && read_flagged.flagged);
assert_eq!(read_flagged.attachments, Some(1));
}
#[test]
fn unread_counts_by_mailbox() {
let (_dir, mut store, [ann, archive, bob]) = two_accounts();
put(&mut store, ann, 1, &[], &mail("a", None, false));
put(&mut store, ann, 2, &["\\seen"], &mail("b", None, false));
put(&mut store, ann, 3, &[], &mail("c", None, false));
put(&mut store, archive, 1, &["\\Seen"], &mail("d", None, false));
put(&mut store, bob, 1, &[], &mail("e", None, false));
let counts = store.unread().unwrap();
assert_eq!(counts.len(), 2);
assert_eq!((counts[&ann], counts[&bob]), (2, 1));
assert!(!counts.contains_key(&archive));
}
+33 -4
View File
@@ -6,7 +6,24 @@ pub(super) const EN: &[(&str, &str)] = &[
("mail.remove.open", "Remove Account…"),
("mail.cancel", "Cancel"),
("mail.empty", "No mail accounts yet"),
("mail.accounts", "Accounts"),
("mail.boxes.unified", "Unified"),
(
"mail.remove.none",
"Open an account or one of its folders to remove it",
),
("mail.boxes.all", "All inboxes"),
("mail.boxes.inbox", "Inbox"),
("mail.boxes.settings", "Account settings"),
("mail.filter.unread", "Unread"),
("mail.filter.flagged", "Flagged"),
("mail.filter.attachments", "Attachments"),
("mail.list.unread", "The message list could not be read"),
("mail.no_sender", "(no sender)"),
("mail.no_subject", "(no subject)"),
("mail.reader.none", "Pick a message to read it"),
("mail.reader.from", "From"),
("mail.reader.date", "Date"),
("mail.reader.no_date", "No date"),
("mail.add.title", "Add Account"),
("mail.kind.gmail", "Gmail"),
("mail.kind.microsoft", "Microsoft"),
@@ -93,7 +110,6 @@ pub(super) const EN: &[(&str, &str)] = &[
"mail.store.read_only",
"The mail store is read-only, so nothing can be added or removed: {error}",
),
("mail.account.line", "{protocol} · {host}"),
("mail.account.server", "{host}:{port}, {security}"),
("mail.account.address", "Address"),
("mail.account.user", "User name"),
@@ -178,7 +194,21 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("mail.remove.open", "删除账号…"),
("mail.cancel", "取消"),
("mail.empty", "还没有邮件账号"),
("mail.accounts", "账号"),
("mail.boxes.unified", "统一"),
("mail.remove.none", "先打开一个账号或其文件夹,再删除"),
("mail.boxes.all", "全部收件箱"),
("mail.boxes.inbox", "收件箱"),
("mail.boxes.settings", "账号设置"),
("mail.filter.unread", "未读"),
("mail.filter.flagged", "已加旗标"),
("mail.filter.attachments", "有附件"),
("mail.list.unread", "邮件列表读不出"),
("mail.no_sender", "(无发件人)"),
("mail.no_subject", "(无主题)"),
("mail.reader.none", "选一封邮件来读"),
("mail.reader.from", "发件人"),
("mail.reader.date", "日期"),
("mail.reader.no_date", "无日期"),
("mail.add.title", "添加账号"),
("mail.kind.gmail", "Gmail"),
("mail.kind.microsoft", "Microsoft"),
@@ -253,7 +283,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
"mail.store.read_only",
"邮件存储为只读,不能添加或删除:{error}",
),
("mail.account.line", "{protocol} · {host}"),
("mail.account.server", "{host}:{port},{security}"),
("mail.account.address", "地址"),
("mail.account.user", "用户名"),
+1 -1
View File
@@ -36,7 +36,7 @@ fetch() { # name url rev [sparse paths...]
echo "fetch $name $rev"
}
fetch Ely-GPUI-Components https://github.com/ELY-Open-Source/Ely-GPUI-Components.git de961eb164c1c88797ea35f0860aba9d18c95e4f
fetch Ely-GPUI-Components https://github.com/ELY-Open-Source/Ely-GPUI-Components.git cd365e92ea0c4467c439b8253963461ba0f6d155
fetch zed https://github.com/zed-industries/zed.git 1a28cff4b409169bac058bca40dfbfeb7621d19b
fetch libreoffice-core https://github.com/LibreOffice/core.git 8221e31b3ac356a1623c672912a3d2b492f7e3d1 \
include/LibreOfficeKit libreofficekit desktop/source/lib desktop/inc \
+1 -1
View File
@@ -142,7 +142,7 @@
- [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 轮通过
- [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 万封滚动不掉帧
- [x] C12.9 邮箱视图(MAIL-03):账号与文件夹、邮件列表、阅读区三栏;统一收件箱与账号视图;未读、标记、带附件筛选;列表只画可见行,10 万封滚动不掉帧。已做:组件库 be00886:`MailList` 收 `Rc<Vec<Mail>>`,`uniform_list` 只画视口内的行,选中按集合核对(原按切片逐个查,10 万封全选时近 10¹⁰ 步),点按、Shift、Cmd、上下键、Home、End、空格、Cmd+A,空列表文字、星标提示与日期随读者的语言(中文为“周一”“10月19日”);cd365e9:`Mail::undated`,没有 Date 头的邮件不画日期,不编造。引擎 `store/listing.rs`:`Scope`(全部收件箱或一个邮箱)、`Filter`(未读、已标记、带附件,同时选即同时满足)、`Store::list`(新的在前,无日期的在后)、`Store::unread`(各邮箱的未读数)。应用:左栏 `MailboxList`,“全部收件箱”(各账号收件箱的未读合计)与每个账号一节(“账号设置”加同步来的文件夹,收件箱照界面语言、其余照服务器的名字);中栏三个筛选开关与邮件列表;右栏阅读区写出所选邮件的主题、发件人与时间(正文随 C12.10),多选或未选写明;列表在后台读,每次读有序号,迟到的旧读取即弃,换文件夹清掉选中,重读后只留仍在列表里的选中;账号页从左栏“账号设置”打开,删账号须先开着某个账号(`picked` 去掉)。测试:组件库 `mail/tests/many.rs`(10 万封只画视口内的行、End 到最后一封、Shift 扩选与全选、无日期的行不画日期);引擎 `store/tests/listing.rs` 3 个(全部收件箱新的在前无日期在后、筛选同时满足、未读按邮箱计);应用 `tests/panes.rs` 4 个(全部收件箱合成一个列表与未读合计、筛选收窄、文件夹只列自己的邮件、单选填入阅读区而多选与换文件夹清掉),`remove_needs_an_open_account`;去掉各规则即失败。未验证:10 万封时的实际帧时间(只核对了只画视口内的行)、真实服务器;列表里的星标还不能切换(写回服务端随 C12.15)。第 1 轮审查后:列表读取时连同按键到行的索引(`boxes::index`,在后台建),选中按索引核对,10 万封全选两次在 1 秒内(`picking_every_one_of_many_messages_is_quick`,原逐个扫全列表 224 秒);左栏整栏可纵向滚动(`the_side_scrolls_past_many_folders_to_the_next_account`)。第 2 轮审查后:结构 v4 以 AUTOINCREMENT 重建账号、邮箱、邮件三表,编号永不复用(原 SQLite 把删去的最大编号交给新行,邮箱重建后旧的选中落到另一封信上:`a_rebuilt_mailbox_drops_the_pick`;`store/tests/ids.rs` 2 个);升级在关闭外键下进行(自带的 SQLite 默认开外键,重建父表会级联删去邮件),提交前以 `foreign_key_check` 核对,有行失去父行即拒绝、库保持原样(`an_upgrade_leaving_a_row_without_its_parent_fails`);去掉各修复即失败。第 3 轮通过。
- [ ] C12.10 阅读(MAIL-04,§12.4):纯文本;HTML 经 html5ever 解析、净化后按块绘制;远程图片与跟踪资源默认拦截,可对当前邮件或发件人放行;cid 内嵌图片;附件列表;原始邮件头
- [ ] C12.11 会话线程(MAIL-04,§12.3):按 Message-ID、In-Reply-To、References 组织,不按主题合并
- [ ] C12.12 写信(MAIL-05):收件人、抄送、密送;回复、回复全部、转发;签名;附件;纯文本或 Markdown(发出纯文本与 HTML);可在独立窗口打开
+5
View File
@@ -0,0 +1,5 @@
FAIL
- apps/ely-mail/src/boxes.rs:69 — [P1] `Listing::pick` scans the entire message list for each selected key, undoing the component's selection optimization. Selecting all 100,000 messages requires about 5 billion comparisons on the UI thread; the isolated Windows probe invoking the unchanged `Mail::select` took 107.545 seconds. Refreshing that selection repeats the same work, freezing interaction and scrolling. Use a keyed membership set to retain selections in linear time, and cover the application's actual selection callback with a large-list regression test. Evidence: `.work/c12.9-r1/probes.log`.
- apps/ely-mail/src/side.rs:67 — [P2] The complete mailbox list has no height-bounded vertical scroll container, so overflowing account sections are clipped by the shell. With 40 folders in the first account and a second account in an 1100×800 window, the second account's settings row stays at y=1592–1624 after 20 downward wheel events; later folders and accounts cannot be reached by scrolling. Put the complete sidebar list in a bounded vertical `ScrollArea` and test reaching later account sections with many folders. The existing scrolling test covers the account settings body, not this sidebar. Evidence: `.work/c12.9-r1/sidebar.log`.
+3
View File
@@ -0,0 +1,3 @@
FAIL
- apps/ely-mail/src/boxes.rs:182 — [P2] List keys use recyclable SQLite message row IDs, so refresh can transfer a selection to a different message. With one selected message (row id 1, UIDVALIDITY 1), rebuild its mailbox with UIDVALIDITY 2 and insert a different message: SQLite reuses id 1, and `Listing::pick` retains `["1"]` and fills the reader with the replacement instead of clearing the vanished selection. This breaks C12.9 selection retention and PRD §12.3 message identity. Use identifiers that cannot be reused, or key selections by complete message identity, and add a rebuild/replacement regression. Reproduced twice using the actual `Store::open_mailbox`, `Store::put`, and `Mail::read_mail`; evidence: `.work/c12.9-r2/identity.log`.
+1
View File
@@ -0,0 +1 @@
PASS