diff --git a/Cargo.lock b/Cargo.lock index 0d639f5..b0c9e1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2124,9 +2124,15 @@ dependencies = [ name = "ely-mail" version = "0.1.8" dependencies = [ + "ely-gpui-component", + "ely-mail-engine", "ely-platform", "ely-ui", "embed-resource", + "gpui-pre", + "log", + "tempfile", + "tokio", ] [[package]] diff --git a/agents.md b/agents.md index 867c45f..7cf8f8c 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 的日志。 +- 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 轮后)。对话框的请求(检查、登录、保存)在整个窗口里编号,回来时只作用于仍在等它的那个对话框;保存的结果照样更新账号列表,失败而对话框已换即以提示写明。端口记下上次自动填入的值,与之不同的即为键入,换安全方式不覆盖。 - 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/Cargo.toml b/apps/ely-mail/Cargo.toml index 45e1505..d88011c 100644 --- a/apps/ely-mail/Cargo.toml +++ b/apps/ely-mail/Cargo.toml @@ -11,8 +11,19 @@ build = "../../packaging/windows/icon.rs" embed-resource.workspace = true [dependencies] +ely-gpui-component.workspace = true +ely-mail-engine.workspace = true ely-platform.workspace = true ely-ui.workspace = true +gpui.workspace = true +log.workspace = true +tokio = { workspace = true, features = ["rt-multi-thread"] } + +[dev-dependencies] +ely-ui = { workspace = true, features = ["test-support"] } +ely-gpui-component = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +tempfile.workspace = true [lints] workspace = true diff --git a/apps/ely-mail/src/add_view.rs b/apps/ely-mail/src/add_view.rs new file mode 100644 index 0000000..1c178c1 --- /dev/null +++ b/apps/ely-mail/src/add_view.rs @@ -0,0 +1,270 @@ +//! The add-account dialog (MAIL-01, 02). + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + data_display::DescriptionList, + feedback::{Banner, InlineMessage}, + forms::{Input, PasswordInput}, + overlays::Dialog, + primitives::Severity, +}; +use ely_mail_engine::{Auth, Error, Security}; +use gpui::{ + AnyElement, App, Context, IntoElement, ParentElement as _, SharedString, Styled as _, + WeakEntity, Window, div, +}; + +use crate::{ + adding::{Adding, Step}, + kinds::{APPLE_ACCOUNT, KINDS, Kind}, + mail::{Mail, text}, + view::auth, +}; + +type Edit = fn(&mut Adding, &str, &mut App); + +/// A segmented choice whose change edits the dialog. +fn choice( + this: &WeakEntity, + id: &'static str, + picked: &str, + options: impl IntoIterator, + edit: Edit, +) -> SegmentedControl { + let this = this.clone(); + options + .into_iter() + .fold( + SegmentedControl::new(id, picked.to_owned()), + |control, (key, label)| control.segment(key, label, None), + ) + .on_change(move |key, _, cx| { + this.update(cx, |mail, cx| { + if let Some(adding) = &mut mail.adding { + edit(adding, key, cx); + cx.notify(); + } + }) + .ok(); + }) +} + +fn security_key(security: Security) -> &'static str { + match security { + Security::Tls => "tls", + Security::StartTls => "starttls", + } +} + +fn security_of(key: &str) -> Security { + match key { + "tls" => Security::Tls, + _ => Security::StartTls, + } +} + +fn auth_key(auth: Auth) -> &'static str { + match auth { + Auth::Password => "password", + Auth::Google => "google", + Auth::Microsoft => "microsoft", + } +} + +impl Mail { + pub fn add_dialog(&self, cx: &mut Context) -> Option { + let adding = self.adding.as_ref()?; + let this = cx.weak_entity(); + let dismiss = { + let this = this.clone(); + move |_: &mut Window, cx: &mut App| { + this.update(cx, |mail, cx| mail.close_adding(cx)).ok(); + } + }; + let kinds = KINDS.map(|kind| { + ( + kind.key(), + text(cx, &format!("mail.kind.{}", kind.key()), &[]), + ) + }); + let kinds = choice( + &this, + "mail-add-kind", + adding.kind.key(), + kinds, + |adding, key, cx| { + if let Some(kind) = Kind::from_key(key) { + adding.set_kind(kind, cx); + } + }, + ); + let oauth = adding.auth() != Auth::Password; + let unavailable = match adding.auth().provider() { + Some(Err(Error::Unavailable(name))) => { + Some(text(cx, &format!("mail.add.unavailable.{name}"), &[])) + } + _ => None, + }; + let cancel = text(cx, "mail.cancel", &[]); + let (go_key, go): (&str, fn(&mut Mail, &mut Context)) = match &adding.step { + Step::Checked { checked, .. } if checked.passed() => ("mail.add.save", Mail::save), + _ if oauth => ("mail.add.sign_in", Mail::sign_in), + _ => ("mail.add.check", Mail::check), + }; + let go_label = text(cx, go_key, &[("method", &auth(adding.auth(), cx))]); + let busy = adding.busy(); + let blocked = busy || (unavailable.is_some() && oauth); + let dialog = Dialog::new("mail-add", text(cx, "mail.add.title", &[]), dismiss) + .child(kinds) + .children( + unavailable + .map(|words| Banner::new("mail-add-unavailable", Severity::Danger, words)), + ) + .child(self.add_fields(adding, &this, cx)) + .children(status(adding, cx)) + .action(move |close| { + Button::new("mail-add-cancel", cancel) + .on_click(move |_, window, cx| close(window, cx)) + }) + .action(move |_| { + Button::new("mail-add-go", go_label) + .variant(ButtonVariant::Primary) + .loading(busy) + .disabled(blocked) + .on_click(move |_, _, cx| { + this.update(cx, go).ok(); + }) + }); + Some(dialog.into_any_element()) + } + + fn add_fields(&self, adding: &Adding, this: &WeakEntity, cx: &App) -> AnyElement { + let label = |key: &str| text(cx, key, &[]); + let field = |input| div().w_full().child(Input::new(input)); + let fields = &adding.fields; + let password = div().w_full().child(PasswordInput::new(&fields.password)); + match adding.kind { + Kind::Gmail | Kind::Microsoft => DescriptionList::new() + .item(label("mail.add.address_hint"), field(&fields.address)) + .into_any_element(), + Kind::ICloud => { + let open = Button::new("mail-add-apple", label("mail.icloud.open")) + .on_click(|_, _, cx| cx.open_url(APPLE_ACCOUNT)); + let rows = DescriptionList::new() + .item(label("mail.add.address"), field(&fields.address)) + .item(label("mail.icloud.password"), password); + div() + .flex() + .flex_col() + .gap_3() + .child(label("mail.icloud.steps")) + .child(div().child(open)) + .child(rows) + .into_any_element() + } + Kind::Imap | Kind::Pop => { + let auths = [Auth::Password, Auth::Google, Auth::Microsoft] + .map(|each| (auth_key(each), auth(each, cx))); + let methods = choice( + this, + "mail-add-auth", + auth_key(adding.auth), + auths, + |adding, key, _| { + adding.stop(); + adding.step = Step::Editing; + adding.auth = match key { + "google" => Auth::Google, + "microsoft" => Auth::Microsoft, + _ => Auth::Password, + }; + }, + ); + let securities = |outgoing: bool, picked: Security| { + let options = [Security::Tls, Security::StartTls].map(|each| { + ( + security_key(each), + label(&format!("mail.security.{}", security_key(each))), + ) + }); + let id = if outgoing { + "mail-add-out-security" + } else { + "mail-add-in-security" + }; + let edit: Edit = if outgoing { + |adding, key, cx| adding.set_security(true, security_of(key), cx) + } else { + |adding, key, cx| adding.set_security(false, security_of(key), cx) + }; + choice(this, id, security_key(picked), options, edit) + }; + let mut rows = DescriptionList::new() + .item(label("mail.add.address"), field(&fields.address)) + .item(label("mail.auth.title"), methods); + if adding.auth == Auth::Password { + rows = rows + .item(label("mail.add.user"), field(&fields.user)) + .item(label("mail.add.password"), password); + } + rows.item(label("mail.add.incoming"), field(&fields.incoming)) + .item(label("mail.add.port"), field(&fields.incoming_port)) + .item( + label("mail.add.security"), + securities(false, adding.incoming_security), + ) + .item(label("mail.add.outgoing"), field(&fields.outgoing)) + .item(label("mail.add.port"), field(&fields.outgoing_port)) + .item( + label("mail.add.security"), + securities(true, adding.outgoing_security), + ) + .into_any_element() + } + } + } +} + +/// Each server's answer, or what is under way. +fn status(adding: &Adding, cx: &App) -> Option { + let line = + |severity, words: SharedString| InlineMessage::new(severity, words).into_any_element(); + let said = |key: &str, result: &ely_mail_engine::Result<()>| match result { + Ok(()) => line( + Severity::Success, + text(cx, key, &[("result", &text(cx, "mail.check.passed", &[]))]), + ), + Err(error) => line( + Severity::Danger, + text(cx, key, &[("result", &failure(error, cx))]), + ), + }; + Some(match &adding.step { + Step::Editing => return None, + Step::Signing => line(Severity::Info, text(cx, "mail.add.signing", &[])), + Step::Checking => line(Severity::Info, text(cx, "mail.add.checking", &[])), + Step::Saving => line(Severity::Info, text(cx, "mail.add.saving", &[])), + Step::Failed(words) => line(Severity::Danger, words.clone()), + Step::Checked { checked, .. } => div() + .flex() + .flex_col() + .gap_1() + .child(said("mail.check.incoming", &checked.incoming)) + .child(said("mail.check.outgoing", &checked.outgoing)) + .into_any_element(), + }) +} + +/// A server's refusal in the reader's language, its words kept. +pub fn failure(error: &Error, cx: &App) -> SharedString { + let (key, detail) = match error { + Error::Connect(err) => ("mail.error.connect", err.to_string()), + Error::Tls(detail) => ("mail.error.tls_failed", detail.clone()), + Error::Rejected(detail) => ("mail.error.rejected", detail.clone()), + Error::Server(detail) => ("mail.error.server", detail.clone()), + Error::Protocol(detail) => ("mail.error.protocol", detail.clone()), + Error::Timeout(step) => ("mail.error.timeout", (*step).to_owned()), + other => ("mail.error.other", other.to_string()), + }; + text(cx, key, &[("detail", &detail)]) +} diff --git a/apps/ely-mail/src/adding.rs b/apps/ely-mail/src/adding.rs new file mode 100644 index 0000000..06abee9 --- /dev/null +++ b/apps/ely-mail/src/adding.rs @@ -0,0 +1,253 @@ +//! The add-account dialog's fields and steps (MAIL-01, 02). + +use ely_gpui_component::forms::{InputEvent, TextInput}; +use ely_mail_engine::{Account, Auth, Checked, Security, Server}; +use gpui::{App, AppContext as _, Context, Entity, SharedString, Subscription, Window}; +use tokio::task::AbortHandle; + +use crate::{ + kinds::{self, Kind}, + mail::{Mail, text}, +}; + +pub struct Fields { + pub address: Entity, + pub user: Entity, + pub password: Entity, + pub incoming: Entity, + pub incoming_port: Entity, + pub outgoing: Entity, + pub outgoing_port: Entity, +} + +pub enum Step { + Editing, + /// The browser holds the provider's page. + Signing, + Checking, + /// `kept` goes to the vault: a password or refresh token. + Checked { + account: Account, + kept: String, + checked: Checked, + }, + Failed(SharedString), + Saving, +} + +pub struct Adding { + pub kind: Kind, + pub fields: Fields, + pub incoming_security: Security, + pub outgoing_security: Security, + /// Typed-in accounts choose; providers fix it. + pub auth: Auth, + pub step: Step, + /// The ports last filled in; any other was typed. + filled: [String; 2], + /// The request awaited; any other answer is dropped. + pub asked: Option, + pub task: Option, + _edits: Vec, +} + +impl Drop for Adding { + fn drop(&mut self) { + self.stop(); + } +} + +impl Adding { + pub fn new(kind: Kind, window: &mut Window, cx: &mut Context) -> Self { + let mut field = |key: &str, masked: bool, cx: &mut Context| { + let words = text(cx, key, &[]); + cx.new(|cx| { + let input = TextInput::new(window, cx) + .placeholder(words.clone()) + .label(words); + if masked { input.masked() } else { input } + }) + }; + let address = field("mail.add.address", false, cx); + let user = field("mail.add.user", false, cx); + let password = field("mail.add.password", true, cx); + let incoming = field("mail.add.incoming", false, cx); + let incoming_port = field("mail.add.port", false, cx); + let outgoing = field("mail.add.outgoing", false, cx); + let outgoing_port = field("mail.add.port", false, cx); + let fields = Fields { + address, + user, + password, + incoming, + incoming_port, + outgoing, + outgoing_port, + }; + let edits = [ + &fields.address, + &fields.user, + &fields.password, + &fields.incoming, + &fields.incoming_port, + &fields.outgoing, + &fields.outgoing_port, + ] + .into_iter() + .map(|field| { + cx.subscribe(field, |mail: &mut Mail, _, event: &InputEvent, cx| { + if *event == InputEvent::Changed { + mail.edited(cx); + } + }) + }) + .collect(); + let mut adding = Adding { + kind, + fields, + incoming_security: Security::Tls, + outgoing_security: Security::StartTls, + auth: Auth::Password, + step: Step::Editing, + filled: Default::default(), + asked: None, + task: None, + _edits: edits, + }; + adding.set_ports(cx); + adding + } + + pub fn stop(&mut self) { + if let Some(task) = self.task.take() { + task.abort(); + } + self.asked = None; + } + + /// A new kind keeps what was typed. + pub fn set_kind(&mut self, kind: Kind, cx: &mut App) { + self.stop(); + self.kind = kind; + self.step = Step::Editing; + self.set_ports(cx); + } + + pub fn set_security(&mut self, outgoing: bool, security: Security, cx: &mut App) { + self.stop(); + self.step = Step::Editing; + if outgoing { + self.outgoing_security = security; + } else { + self.incoming_security = security; + } + self.set_ports(cx); + } + + /// The usual ports, unless one was typed. + fn set_ports(&mut self, cx: &mut App) { + let ports = [ + kinds::port(Some(self.kind.protocol()), self.incoming_security), + kinds::port(None, self.outgoing_security), + ]; + let fields = [&self.fields.incoming_port, &self.fields.outgoing_port]; + for ((field, port), filled) in fields.into_iter().zip(ports).zip(&mut self.filled) { + let typed = field.read(cx).text().trim().to_owned(); + if typed.is_empty() || typed == *filled { + *filled = port.to_string(); + field.update(cx, |field, cx| field.set_text(filled.clone(), cx)); + } + } + } + + /// The sign-in this kind uses. + pub fn auth(&self) -> Auth { + match self.kind.known() { + Some((auth, ..)) => auth, + None => self.auth, + } + } + + fn read(&self, field: &Entity, cx: &App) -> String { + field.read(cx).text().trim().to_owned() + } + + /// The servers as known or typed. + pub fn servers(&self, cx: &App) -> Result<(Server, Server), SharedString> { + if let Some((_, incoming, outgoing)) = self.kind.known() { + return Ok((incoming, outgoing)); + } + let server = |host: &Entity, port: &Entity, security| { + let host = self.read(host, cx); + if host.is_empty() { + return Err(text(cx, "mail.add.need_host", &[])); + } + let port = self.read(port, cx); + let port = port + .parse() + .ok() + .filter(|port| *port > 0) + .ok_or_else(|| text(cx, "mail.add.bad_port", &[("port", &port)]))?; + Ok(Server { + host, + port, + security, + }) + }; + Ok(( + server( + &self.fields.incoming, + &self.fields.incoming_port, + self.incoming_security, + )?, + server( + &self.fields.outgoing, + &self.fields.outgoing_port, + self.outgoing_security, + )?, + )) + } + + /// The account the fields name; `signed` is an OAuth address. + pub fn account(&self, signed: Option<&str>, cx: &App) -> Result { + let address = match signed { + Some(address) => address.to_owned(), + None => self.read(&self.fields.address, cx), + }; + if address.is_empty() { + return Err(text(cx, "mail.add.need_address", &[])); + } + let user = match (signed, self.kind) { + (None, Kind::Imap | Kind::Pop) => self.read(&self.fields.user, cx), + _ => address.clone(), + }; + if user.is_empty() { + return Err(text(cx, "mail.add.need_user", &[])); + } + let (incoming, outgoing) = self.servers(cx)?; + let account = Account { + address, + user, + auth: self.auth(), + protocol: self.kind.protocol(), + incoming, + outgoing, + }; + account + .valid() + .map_err(|err| text(cx, "mail.add.invalid", &[("error", &err.to_string())]))?; + Ok(account) + } + + pub fn password(&self, cx: &App) -> Result { + let password = self.fields.password.read(cx).text().to_owned(); + if password.is_empty() { + return Err(text(cx, "mail.add.need_password", &[])); + } + Ok(password) + } + + pub fn busy(&self) -> bool { + matches!(self.step, Step::Signing | Step::Checking | Step::Saving) + } +} diff --git a/apps/ely-mail/src/flow.rs b/apps/ely-mail/src/flow.rs new file mode 100644 index 0000000..a8aa903 --- /dev/null +++ b/apps/ely-mail/src/flow.rs @@ -0,0 +1,288 @@ +//! Checking, signing in and adding an account (MAIL-01, 02). + +use ely_gpui_component::primitives::Severity; +use ely_mail_engine::{Account, Checked, Error, Kept, Secret, oauth}; +use gpui::{Context, SharedString, Window}; + +use crate::{ + adding::{Adding, Step}, + kinds::Kind, + mail::{Mail, text}, +}; + +impl Mail { + pub fn start_adding(&mut self, window: &mut Window, cx: &mut Context) { + if self.adding.is_none() && self.removing.is_none() { + self.adding = Some(Adding::new(Kind::Gmail, window, cx)); + } + cx.notify(); + } + + pub fn close_adding(&mut self, cx: &mut Context) { + self.adding = None; + cx.notify(); + } + + /// A field changed: an earlier check no longer holds. + pub fn edited(&mut self, cx: &mut Context) { + let Some(adding) = &mut self.adding else { + return; + }; + if !matches!(adding.step, Step::Editing | Step::Saving) { + adding.stop(); + adding.step = Step::Editing; + cx.notify(); + } + } + + /// Starts a request for the open dialog. + fn ask(&mut self) -> Option { + let adding = self.adding.as_mut()?; + adding.stop(); + self.asked += 1; + adding.asked = Some(self.asked); + Some(self.asked) + } + + /// The open dialog, if it awaits `asked`. + fn awaiting(&mut self, asked: u64) -> Option<&mut Adding> { + self.adding + .as_mut() + .filter(|adding| adding.asked == Some(asked)) + } + + fn halt(&mut self, words: SharedString, cx: &mut Context) { + if let Some(adding) = &mut self.adding { + adding.stop(); + adding.step = Step::Failed(words); + } + cx.notify(); + } + + /// The words when the address is already here. + fn taken(&self, address: &str, cx: &Context) -> Option { + self.accounts() + .iter() + .any(|(_, account)| account.address.eq_ignore_ascii_case(address)) + .then(|| text(cx, "mail.add.taken", &[("address", address)])) + } + + /// Checks a typed-in password with both servers. + pub fn check(&mut self, cx: &mut Context) { + let Some(adding) = &self.adding else { return }; + let ready = adding + .account(None, cx) + .and_then(|account| Ok((account, adding.password(cx)?))); + match ready { + Ok((account, password)) => { + self.probe(account, password.clone(), Secret::Password(password), cx) + } + Err(words) => self.halt(words, cx), + } + } + + /// Logs in with `secret`; `kept` goes to the vault after. + fn probe(&mut self, account: Account, kept: String, secret: Secret, cx: &mut Context) { + if let Some(words) = self.taken(&account.address, cx) { + return self.halt(words, cx); + } + let tls = match &self.tls { + Ok(tls) => tls.clone(), + Err(error) => return self.halt(text(cx, "mail.error.tls", &[("error", error)]), cx), + }; + let Some(asked) = self.ask() else { return }; + if let Some(adding) = &mut self.adding { + adding.step = Step::Checking; + } + log::info!("mail: checking a {:?} account", account.protocol); + let probe = account.clone(); + let work = async move { probe.check(secret, &tls).await }; + let task = self.on_runtime(cx, work, move |mail, done, cx| { + mail.checked(asked, account, kept, done, cx) + }); + if let Some(adding) = &mut self.adding { + adding.task = Some(task); + } + cx.notify(); + } + + fn checked( + &mut self, + asked: u64, + account: Account, + kept: String, + done: Result, + cx: &mut Context, + ) { + let Some(adding) = self.awaiting(asked) else { + return log::info!("mail: a stale check dropped"); + }; + adding.task = None; + adding.step = match done { + Ok(checked) => Step::Checked { + account, + kept, + checked, + }, + Err(error) => Step::Failed(text(cx, "mail.add.check_failed", &[("error", &error)])), + }; + } + + /// Opens the provider's page, then checks what it grants. + pub fn sign_in(&mut self, cx: &mut Context) { + let Some(adding) = &self.adding else { return }; + let provider = match adding.auth().provider() { + Some(Ok(provider)) => provider, + Some(Err(error)) => return self.halt(unsigned(&error, cx), cx), + None => return log::error!("mail: sign-in asked of a password account"), + }; + if let Err(words) = adding.servers(cx) { + return self.halt(words, cx); + } + let hint = adding.fields.address.read(cx).text().trim().to_owned(); + let Some(asked) = self.ask() else { return }; + if let Some(adding) = &mut self.adding { + adding.step = Step::Signing; + } + let work = async move { oauth::SignIn::start(provider, &hint).await }; + let task = self.on_runtime(cx, work, move |mail, started, cx| { + if mail.awaiting(asked).is_none() { + return log::info!("mail: a stale sign-in dropped"); + } + match started.map_err(|error| text(cx, "mail.add.check_failed", &[("error", &error)])) { + Ok(Ok(sign_in)) => mail.wait_for_browser(asked, sign_in, cx), + Ok(Err(error)) => mail.halt(unsigned(&error, cx), cx), + Err(words) => mail.halt(words, cx), + } + }); + if let Some(adding) = &mut self.adding { + adding.task = Some(task); + } + cx.notify(); + } + + fn wait_for_browser(&mut self, asked: u64, sign_in: oauth::SignIn, cx: &mut Context) { + cx.open_url(sign_in.url()); + log::info!("mail: the browser has the sign-in page"); + let work = async move { + let tokens = sign_in.finish().await?; + let email = tokens + .email + .clone() + .ok_or_else(|| Error::SignIn("the provider named no address".into()))?; + let refresh = tokens + .refresh + .clone() + .ok_or_else(|| Error::SignIn("no refresh token granted".into()))?; + Ok::<_, Error>((tokens.access, refresh, email)) + }; + let task = self.on_runtime(cx, work, move |mail, done, cx| { + if mail.awaiting(asked).is_none() { + return log::info!("mail: a stale sign-in dropped"); + } + let account = match done { + Ok(Ok((access, refresh, email))) => mail.adding.as_ref().map(|adding| { + adding + .account(Some(&email), cx) + .map(|account| (account, access, refresh)) + }), + Ok(Err(error)) => Some(Err(unsigned(&error, cx))), + Err(error) => Some(Err(text(cx, "mail.add.check_failed", &[("error", &error)]))), + }; + match account { + Some(Ok((account, access, refresh))) => { + mail.probe(account, refresh, Secret::Token(access), cx) + } + Some(Err(words)) => mail.halt(words, cx), + None => {} + } + }); + if let Some(adding) = &mut self.adding { + adding.task = Some(task); + } + } + + /// Stores the checked account, then its secret. + pub fn save(&mut self, cx: &mut Context) { + let Some(Adding { + step: + Step::Checked { + account, + kept, + checked, + }, + .. + }) = &self.adding + else { + return; + }; + if !checked.passed() { + return; + } + if let Some(words) = self.taken(&account.address, cx) { + return self.halt(words, cx); + } + let (account, kept) = (account.clone(), kept.clone()); + let Some(asked) = self.ask() else { return }; + if let Some(adding) = &mut self.adding { + adding.step = Step::Saving; + } + let vault = self.vault.clone(); + let work = move |store: &mut ely_mail_engine::Store| { + let id = match store.add_account(&account) { + Ok(id) => id, + Err(Error::Taken(address)) => return Ok(Err(address)), + Err(error) => return Err(error), + }; + let held = vault.put(&account.secret_name(), &kept); + Ok(Ok((id, account, held))) + }; + self.with_store(cx, work, move |mail, saved, cx| match saved { + Ok(Err(address)) => { + let words = text(cx, "mail.add.taken", &[("address", &address)]); + match mail.awaiting(asked) { + Some(_) => mail.halt(words, cx), + None => mail.toast(words, Severity::Danger, cx), + } + } + Ok(Ok((id, account, held))) => { + log::info!("mail: account {} added", id.0); + let words = text(cx, "mail.add.added", &[("address", &account.address)]); + if let Some(Ok(accounts)) = &mut mail.accounts { + accounts.push((id, account)); + } + mail.picked = Some(id); + if mail.awaiting(asked).is_some() { + mail.adding = None; + } + mail.toast(words, Severity::Success, cx); + if let Kept::Session(reason) = held { + mail.toast( + text(cx, "mail.add.session_only", &[("error", &reason)]), + Severity::Warning, + cx, + ); + } + } + Err(error) => { + let words = text(cx, "mail.add.save_failed", &[("error", &error)]); + match mail.awaiting(asked) { + Some(_) => mail.halt(words, cx), + None => mail.toast(words, Severity::Danger, cx), + } + } + }); + } +} + +/// Sign-in failures, with this build's missing client named. +fn unsigned(error: &Error, cx: &gpui::App) -> SharedString { + match error { + Error::Unavailable(name) => text(cx, &format!("mail.add.unavailable.{name}"), &[]), + other => text( + cx, + "mail.add.sign_in_failed", + &[("error", &other.to_string())], + ), + } +} diff --git a/apps/ely-mail/src/kinds.rs b/apps/ely-mail/src/kinds.rs new file mode 100644 index 0000000..e1af3d0 --- /dev/null +++ b/apps/ely-mail/src/kinds.rs @@ -0,0 +1,86 @@ +//! The kinds of account and their known servers. + +use ely_mail_engine::{Auth, Protocol, Security, Server}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Gmail, + Microsoft, + ICloud, + Imap, + Pop, +} + +pub const KINDS: [Kind; 5] = [ + Kind::Gmail, + Kind::Microsoft, + Kind::ICloud, + Kind::Imap, + Kind::Pop, +]; + +/// Where iCloud makes app-specific passwords. +pub const APPLE_ACCOUNT: &str = "https://account.apple.com"; + +impl Kind { + pub fn key(self) -> &'static str { + match self { + Kind::Gmail => "gmail", + Kind::Microsoft => "microsoft", + Kind::ICloud => "icloud", + Kind::Imap => "imap", + Kind::Pop => "pop", + } + } + + pub fn from_key(key: &str) -> Option { + KINDS.into_iter().find(|kind| kind.key() == key) + } + + pub fn protocol(self) -> Protocol { + match self { + Kind::Pop => Protocol::Pop, + _ => Protocol::Imap, + } + } + + /// The provider's servers and sign-in; None when typed in. + pub fn known(self) -> Option<(Auth, Server, Server)> { + let server = |host: &str, port, security| Server { + host: host.into(), + port, + security, + }; + match self { + Kind::Gmail => Some(( + Auth::Google, + server("imap.gmail.com", 993, Security::Tls), + server("smtp.gmail.com", 465, Security::Tls), + )), + Kind::Microsoft => Some(( + Auth::Microsoft, + server("outlook.office365.com", 993, Security::Tls), + server("smtp.office365.com", 587, Security::StartTls), + )), + // Apple: IMAP takes the full address too. + Kind::ICloud => Some(( + Auth::Password, + server("imap.mail.me.com", 993, Security::Tls), + server("smtp.mail.me.com", 587, Security::StartTls), + )), + Kind::Imap | Kind::Pop => None, + } + } +} + +/// The usual port for a protocol under a security. +pub fn port(protocol: Option, security: Security) -> u16 { + match (protocol, security) { + (Some(Protocol::Imap), Security::Tls) => 993, + (Some(Protocol::Imap), Security::StartTls) => 143, + (Some(Protocol::Pop), Security::Tls) => 995, + (Some(Protocol::Pop), Security::StartTls) => 110, + (None, Security::Tls) => 465, + (None, Security::StartTls) => 587, + } +} diff --git a/apps/ely-mail/src/mail.rs b/apps/ely-mail/src/mail.rs new file mode 100644 index 0000000..4b7b772 --- /dev/null +++ b/apps/ely-mail/src/mail.rs @@ -0,0 +1,215 @@ +//! The window: accounts and the dialogs that change them. + +use std::{ + future::Future, + path::Path, + sync::{Arc, Mutex}, +}; + +use ely_gpui_component::primitives::Severity; +use ely_mail_engine::{Account, AccountId, 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}; + +/// What the engine runs on and keeps to. +pub struct Parts { + pub store: Result, + pub vault: Vault, + pub tls: Result, +} + +impl Parts { + pub fn system(dir: &Path) -> Self { + Self { + store: Store::open(dir).map_err(|err| err.to_string()), + vault: Vault::system(), + tls: ely_mail_engine::system_tls().map_err(|err| err.to_string()), + } + } +} + +pub struct Mail { + pub store: Result>, String>, + pub read_only: Option, + pub vault: Arc, + pub tls: Result, + runtime: Arc, + /// None until read. + pub accounts: Option, String>>, + pub picked: Option, + pub adding: Option, + pub removing: Option, + /// Numbers every request across dialogs. + pub asked: u64, + pub focus: FocusHandle, + pub root: WeakEntity, + pub side: Entity, +} + +pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString { + ely_ui::i18n::text(cx, key, args) +} + +pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { + let dir = match ely_platform::data_dir(app) { + Ok(dir) => dir, + Err(error) => ely_platform::startup::fail(app, &format!("{error:#}")), + }; + let parts = Parts::system(&dir); + let mail = cx.new(|cx| Mail::new(parts, cx)); + let root = ely_ui::tool_root(app, mail.clone().into(), crate::menu::menus(cx), window, cx); + mail.update(cx, |mail, cx| { + mail.root = root.downgrade(); + window.focus(&mail.focus, cx); + }); + root +} + +impl Mail { + pub fn new(parts: Parts, cx: &mut Context) -> Self { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_name("mail") + .enable_all() + .build() + .unwrap_or_else(|error| { + ely_platform::startup::fail(AppId::Mail, &format!("no mail runtime: {error}")) + }); + let read_only = match &parts.store { + Ok(store) => store.read_only().map(str::to_owned), + Err(error) => { + log::error!("mail: the store did not open: {error}"); + None + } + }; + if let Err(error) = &parts.tls { + log::error!("mail: no system TLS: {error}"); + } + let this = cx.weak_entity(); + let mut mail = Mail { + store: parts.store.map(|store| Arc::new(Mutex::new(store))), + read_only, + vault: Arc::new(parts.vault), + tls: parts.tls, + runtime: Arc::new(runtime), + accounts: None, + picked: None, + adding: None, + removing: None, + asked: 0, + focus: cx.focus_handle(), + root: WeakEntity::new_invalid(), + side: cx.new(|_| Side::new(this)), + }; + mail.read_accounts(cx); + mail + } + + pub fn read_accounts(&mut self, cx: &mut Context) { + self.with_store( + cx, + |store| store.accounts(), + |mail, read, _| { + 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)); + } + mail.accounts = Some(read); + }, + ); + } + + pub fn accounts(&self) -> &[(AccountId, Account)] { + match &self.accounts { + Some(Ok(accounts)) => accounts, + _ => &[], + } + } + + pub fn pick(&mut self, id: AccountId, cx: &mut Context) { + self.picked = Some(id); + self.notify(cx); + } + + /// Redraws the window and its account list. + pub fn notify(&self, cx: &mut Context) { + self.side.update(cx, |_, cx| cx.notify()); + cx.notify(); + } + + /// Store work off the window's thread. + pub fn with_store( + &self, + cx: &mut Context, + work: impl FnOnce(&mut Store) -> ely_mail_engine::Result + Send + 'static, + then: impl FnOnce(&mut Mail, Result, &mut Context) + 'static, + ) { + let store = match &self.store { + Ok(store) => store.clone(), + Err(error) => { + self.failed("mail.error.store", error, cx); + return; + } + }; + let read = cx.background_spawn(async move { + let mut store = store + .lock() + .map_err(|_| "a store task panicked".to_owned())?; + work(&mut store).map_err(|err| err.to_string()) + }); + cx.spawn(async move |this, cx| { + let read = read.await; + this.update(cx, |mail, cx| { + then(mail, read, cx); + mail.notify(cx); + }) + .ok(); + }) + .detach(); + } + + /// Network work on the mail runtime; abort stops it. + pub fn on_runtime( + &self, + cx: &mut Context, + work: impl Future + Send + 'static, + then: impl FnOnce(&mut Mail, Result, &mut Context) + 'static, + ) -> tokio::task::AbortHandle { + let task = self.runtime.spawn(work); + let abort = task.abort_handle(); + cx.spawn(async move |this, cx| { + let done = task.await; + if done.as_ref().is_err_and(|err| err.is_cancelled()) { + return; + } + let done = done.map_err(|err| format!("a mail task failed: {err}")); + this.update(cx, |mail, cx| { + then(mail, done, cx); + mail.notify(cx); + }) + .ok(); + }) + .detach(); + abort + } + + /// Not logged: the words may name an address. + pub fn toast(&self, text: SharedString, severity: Severity, cx: &mut App) { + if let Some(root) = self.root.upgrade() { + root.update(cx, |root, cx| root.toast(text, severity, cx)); + } + } + + pub fn failed(&self, key: &str, error: &str, cx: &mut App) { + log::warn!("mail: {key}"); + let words = text(cx, key, &[("error", error)]); + self.toast(words, Severity::Danger, cx); + } +} diff --git a/apps/ely-mail/src/main.rs b/apps/ely-mail/src/main.rs index b78bc99..b23c8da 100644 --- a/apps/ely-mail/src/main.rs +++ b/apps/ely-mail/src/main.rs @@ -1,8 +1,21 @@ // Release builds open no console on Windows. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod add_view; +mod adding; +mod flow; +mod kinds; +mod mail; +mod menu; +mod removing; +mod side; +mod view; + +#[cfg(test)] +mod tests; + use ely_platform::AppId; fn main() { - ely_ui::run(AppId::Mail, ely_ui::blank); + ely_ui::run(AppId::Mail, mail::open); } diff --git a/apps/ely-mail/src/menu.rs b/apps/ely-mail/src/menu.rs new file mode 100644 index 0000000..6746e84 --- /dev/null +++ b/apps/ely-mail/src/menu.rs @@ -0,0 +1,16 @@ +//! The Account menu. + +use gpui::{App, Menu, MenuItem, actions}; + +use crate::mail::text; + +actions!(mail, [AddAccount, RemoveAccount]); + +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), + ])] +} diff --git a/apps/ely-mail/src/removing.rs b/apps/ely-mail/src/removing.rs new file mode 100644 index 0000000..2f1a9ed --- /dev/null +++ b/apps/ely-mail/src/removing.rs @@ -0,0 +1,90 @@ +//! Removing an account: its local data and secret only. + +use ely_gpui_component::primitives::Severity; +use ely_mail_engine::{AccountId, Cached, Error, Kept}; +use gpui::Context; + +use crate::mail::{Mail, text}; + +#[derive(Clone)] +pub struct Removing { + pub id: AccountId, + pub address: String, + pub cached: Cached, +} + +impl Mail { + /// Measures what goes, then asks. + pub fn ask_remove(&mut self, cx: &mut Context) { + let Some(id) = self.picked else { return }; + self.with_store( + cx, + move |store| store.cached(id), + move |mail, cached, cx| { + let address = mail + .accounts() + .iter() + .find(|(each, _)| *each == id) + .map(|(_, account)| account.address.clone()); + match (cached, address) { + (Ok(cached), Some(address)) + if mail.adding.is_none() && mail.removing.is_none() => + { + mail.removing = Some(Removing { + id, + address, + cached, + }); + } + (Ok(_), _) => { + log::info!("mail: removal asked of a gone account or over a dialog") + } + (Err(error), _) => mail.failed("mail.remove.failed", &error, cx), + } + }, + ); + } + + /// The secret goes first, and back if the rest fails. + pub fn remove(&mut self, removing: Removing, cx: &mut Context) { + let Some((_, account)) = self + .accounts() + .iter() + .find(|(each, _)| *each == removing.id) + else { + return log::info!("mail: the account to remove is already gone"); + }; + let (id, name, vault) = (removing.id, account.secret_name(), self.vault.clone()); + let address = removing.address; + self.with_store( + cx, + move |store| { + let secret = vault.get(&name)?; + vault.remove(&name)?; + store.remove_account(id).map_err(|error| { + match secret.map(|secret| vault.put(&name, &secret)) { + Some(Kept::Session(reason)) => Error::Vault(format!( + "{error}; its secret is back for this session only: {reason}" + )), + _ => error, + } + }) + }, + move |mail, removed, cx| { + match removed { + Ok(()) => { + log::info!("mail: account {} removed", id.0); + mail.toast( + text(cx, "mail.remove.done", &[("address", &address)]), + Severity::Success, + cx, + ); + } + Err(error) => mail.failed("mail.remove.failed", &error, cx), + } + mail.read_accounts(cx); + }, + ); + cx.notify(); + } +} diff --git a/apps/ely-mail/src/side.rs b/apps/ely-mail/src/side.rs new file mode 100644 index 0000000..c48ce1b --- /dev/null +++ b/apps/ely-mail/src/side.rs @@ -0,0 +1,51 @@ +//! The account list down the left. + +use ely_gpui_component::lists::{ListItem, SelectableList}; +use ely_mail_engine::AccountId; +use gpui::{ + Context, IntoElement, ParentElement as _, Render, Styled as _, WeakEntity, Window, div, +}; + +use crate::{ + mail::{Mail, text}, + view::protocol, +}; + +pub struct Side { + mail: WeakEntity, +} + +impl Side { + pub fn new(mail: WeakEntity) -> Self { + Self { mail } + } +} + +impl Render for Side { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(mail) = self.mail.upgrade() else { + return div(); + }; + 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(); + }) + .size_full(); + div().size_full().p_2().child(list) + } +} diff --git a/apps/ely-mail/src/tests.rs b/apps/ely-mail/src/tests.rs new file mode 100644 index 0000000..1179d9f --- /dev/null +++ b/apps/ely-mail/src/tests.rs @@ -0,0 +1,176 @@ +//! Accounts in the window: add, check, keep, remove (C12.5). + +use std::{ + collections::HashMap, + path::Path, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::{Duration, Instant}, +}; + +use ely_mail_engine::{Account, Auth, Backend, Checked, Protocol, Security, Server, Store, Vault}; +use gpui::{App, Entity, TestAppContext, VisualTestContext}; + +use crate::{ + adding::Step, + kinds::Kind, + mail::{Mail, Parts}, +}; + +mod flows; + +/// A credential store that keeps or refuses. +#[derive(Clone, Default)] +pub(crate) struct Keys { + pub kept: Arc>>, + pub refuse: Option<&'static str>, + pub deletes: Arc, +} + +impl Backend for Keys { + fn get(&self, name: &str) -> Result, String> { + Ok(self.kept.lock().unwrap().get(name).cloned()) + } + + fn set(&self, name: &str, secret: &str) -> Result<(), String> { + if let Some(reason) = self.refuse { + return Err(reason.into()); + } + self.kept.lock().unwrap().insert(name.into(), secret.into()); + Ok(()) + } + + fn delete(&self, name: &str) -> Result<(), String> { + self.deletes.fetch_add(1, Ordering::SeqCst); + if let Some(reason) = self.refuse { + return Err(reason.into()); + } + self.kept.lock().unwrap().remove(name); + Ok(()) + } +} + +pub(crate) fn settle(cx: &mut VisualTestContext, done: impl Fn(&App) -> bool) { + let deadline = Instant::now() + Duration::from_secs(20); + while Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + if cx.update(|_, app| done(app)) { + return; + } + } + panic!("the state never settled"); +} + +pub(crate) fn window<'a>( + cx: &'a mut TestAppContext, + dir: &Path, + keys: Keys, +) -> (Entity, &'a mut VisualTestContext) { + cx.executor().allow_parking(); + cx.update(ely_gpui_component::init_for_tests); + cx.update(|cx| { + ely_ui::settings::install_defaults(cx); + ely_ui::i18n::install_as("en-US", cx); + // As tool_root does: menus before the window. + let menus = crate::menu::menus(cx); + let menus = ely_ui::commands::menus( + ely_platform::AppId::Mail, + &ely_ui::commands::OPENING, + false, + menus, + cx, + ); + cx.set_menus(menus); + }); + let parts = Parts { + store: Store::open(dir).map_err(|err| err.to_string()), + vault: Vault::new(Box::new(keys)), + tls: ely_mail_engine::system_tls().map_err(|err| err.to_string()), + }; + let (mail, cx) = cx.add_window_view(move |_, cx| Mail::new(parts, cx)); + settle(cx, |app| mail.read(app).accounts.is_some()); + (mail, cx) +} + +/// A port nothing listens on. +pub(crate) fn closed_port() -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() +} + +pub(crate) fn account(address: &str) -> Account { + let server = |host: &str, port| Server { + host: host.into(), + port, + security: Security::Tls, + }; + Account { + address: address.into(), + user: address.into(), + auth: Auth::Password, + protocol: Protocol::Imap, + incoming: server("imap.example.com", 993), + outgoing: server("smtp.example.com", 465), + } +} + +/// Opens the dialog on `kind`. +pub(crate) fn adding(mail: &Entity, kind: Kind, cx: &mut VisualTestContext) { + cx.update(|window, app| { + mail.update(app, |mail, cx| { + mail.start_adding(window, cx); + mail.adding.as_mut().unwrap().set_kind(kind, cx); + }) + }); +} + +pub(crate) fn type_in( + mail: &Entity, + cx: &mut VisualTestContext, + pick: fn(&crate::adding::Fields) -> &Entity, + words: &str, +) { + cx.update(|_, app| { + let field = pick(&mail.read(app).adding.as_ref().unwrap().fields).clone(); + field.update(app, |field, cx| field.set_text(words.to_owned(), cx)); + }); + cx.run_until_parked(); +} + +/// A passed check, as servers would answer. +pub(crate) fn passed( + mail: &Entity, + account: Account, + kept: &str, + cx: &mut VisualTestContext, +) { + cx.update(|_, app| { + mail.update(app, |mail, _| { + mail.adding.as_mut().unwrap().step = Step::Checked { + account, + kept: kept.into(), + checked: Checked { + incoming: Ok(()), + outgoing: Ok(()), + }, + }; + }) + }); +} + +pub(crate) fn step_words(mail: &Entity, cx: &mut VisualTestContext) -> String { + cx.update( + |_, app| match &mail.read(app).adding.as_ref().unwrap().step { + Step::Failed(words) => words.to_string(), + Step::Editing => "editing".into(), + Step::Signing => "signing".into(), + Step::Checking => "checking".into(), + Step::Saving => "saving".into(), + Step::Checked { .. } => "checked".into(), + }, + ) +} diff --git a/apps/ely-mail/src/tests/flows.rs b/apps/ely-mail/src/tests/flows.rs new file mode 100644 index 0000000..a960ff2 --- /dev/null +++ b/apps/ely-mail/src/tests/flows.rs @@ -0,0 +1,470 @@ +use std::sync::atomic::Ordering; + +use ely_mail_engine::{Auth, Error, Fetched, Key, Protocol, Security, Store, oauth}; +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}; + +#[test] +fn an_imap_form_says_what_is_missing() { + let dir = tempfile::tempdir().unwrap(); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), Keys::default()); + adding(&mail, Kind::Imap, cx); + let check = |cx: &mut gpui::VisualTestContext| { + cx.update(|_, app| mail.update(app, |mail, cx| mail.check(cx))) + }; + check(cx); + assert_eq!(step_words(&mail, cx), "Type the email address."); + type_in(&mail, cx, |f| &f.address, "ann@example.com"); + check(cx); + assert_eq!(step_words(&mail, cx), "Type the user name."); + type_in(&mail, cx, |f| &f.user, "ann"); + check(cx); + assert_eq!(step_words(&mail, cx), "Type both server names."); + type_in(&mail, cx, |f| &f.incoming, "imap.example.com"); + type_in(&mail, cx, |f| &f.outgoing, "smtp.example.com"); + type_in(&mail, cx, |f| &f.outgoing_port, "70000"); + check(cx); + assert_eq!( + step_words(&mail, cx), + "70000 is not a port; use 1 to 65535." + ); + type_in(&mail, cx, |f| &f.outgoing_port, "587"); + check(cx); + assert_eq!(step_words(&mail, cx), "Type the password."); +} + +#[test] +fn ports_follow_security_but_a_typed_one_stays() { + let dir = tempfile::tempdir().unwrap(); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), Keys::default()); + adding(&mail, Kind::Pop, cx); + let ports = |cx: &mut gpui::VisualTestContext| { + cx.update(|_, app| { + let fields = &mail.read(app).adding.as_ref().unwrap().fields; + ( + fields.incoming_port.read(app).text().to_owned(), + fields.outgoing_port.read(app).text().to_owned(), + ) + }) + }; + assert_eq!(ports(cx), ("995".into(), "587".into())); + let secure = |cx: &mut gpui::VisualTestContext, outgoing, security| { + cx.update(|_, app| { + mail.update(app, |mail, cx| { + mail.adding + .as_mut() + .unwrap() + .set_security(outgoing, security, cx) + }) + }); + }; + secure(cx, false, Security::StartTls); + assert_eq!(ports(cx), ("110".into(), "587".into())); + type_in(&mail, cx, |f| &f.outgoing_port, "2525"); + secure(cx, true, Security::Tls); + assert_eq!(ports(cx), ("110".into(), "2525".into())); + cx.update(|_, app| mail.update(app, |mail, cx| mail.close_adding(cx))); + adding(&mail, Kind::Imap, cx); + assert_eq!(ports(cx), ("993".into(), "587".into())); + type_in(&mail, cx, |f| &f.incoming_port, "995"); + secure(cx, true, Security::Tls); + assert_eq!(ports(cx), ("995".into(), "465".into())); + secure(cx, false, Security::StartTls); + assert_eq!(ports(cx), ("995".into(), "465".into())); +} + +#[test] +fn a_check_reports_each_server() { + let dir = tempfile::tempdir().unwrap(); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), Keys::default()); + adding(&mail, Kind::Imap, cx); + type_in(&mail, cx, |f| &f.address, "ann@example.com"); + type_in(&mail, cx, |f| &f.user, "ann"); + type_in(&mail, cx, |f| &f.password, "pw"); + type_in(&mail, cx, |f| &f.incoming, "127.0.0.1"); + type_in(&mail, cx, |f| &f.incoming_port, &closed_port().to_string()); + type_in(&mail, cx, |f| &f.outgoing, "127.0.0.1"); + type_in(&mail, cx, |f| &f.outgoing_port, &closed_port().to_string()); + cx.update(|_, app| mail.update(app, |mail, cx| mail.check(cx))); + assert_eq!(step_words(&mail, cx), "checking"); + settle(cx, |app| { + matches!( + mail.read(app).adding.as_ref().unwrap().step, + Step::Checked { .. } + ) + }); + cx.update(|_, app| { + let Step::Checked { + checked, + account, + kept, + } = &mail.read(app).adding.as_ref().unwrap().step + else { + unreachable!() + }; + assert!( + matches!(checked.incoming, Err(Error::Connect(_))), + "{:?}", + checked.incoming + ); + assert!( + matches!(checked.outgoing, Err(Error::Connect(_))), + "{:?}", + checked.outgoing + ); + assert_eq!( + (account.user.as_str(), account.protocol, kept.as_str()), + ("ann", Protocol::Imap, "pw") + ); + let said = crate::add_view::failure(checked.incoming.as_ref().unwrap_err(), app); + assert!(said.starts_with("cannot connect ("), "{said}"); + }); + type_in(&mail, cx, |f| &f.password, "pw2"); + assert_eq!(step_words(&mail, cx), "editing"); +} + +#[test] +fn an_icloud_account_takes_apples_servers() { + let dir = tempfile::tempdir().unwrap(); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), Keys::default()); + adding(&mail, Kind::ICloud, cx); + type_in(&mail, cx, |f| &f.address, "ann@icloud.com"); + let account = cx + .update(|_, app| mail.read(app).adding.as_ref().unwrap().account(None, app)) + .unwrap(); + assert_eq!( + (account.incoming.host.as_str(), account.incoming.port), + ("imap.mail.me.com", 993) + ); + assert_eq!( + ( + account.outgoing.host.as_str(), + account.outgoing.port, + account.outgoing.security + ), + ("smtp.mail.me.com", 587, Security::StartTls) + ); + assert_eq!( + (account.user.as_str(), account.auth), + ("ann@icloud.com", Auth::Password) + ); +} + +#[test] +fn a_build_without_a_client_id_says_so() { + if oauth::google().is_ok() { + return eprintln!("SKIP: this build has a Google client ID"); + } + let dir = tempfile::tempdir().unwrap(); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), Keys::default()); + adding(&mail, Kind::Gmail, cx); + cx.update(|_, app| mail.update(app, |mail, cx| mail.sign_in(cx))); + assert_eq!( + step_words(&mail, cx), + "Gmail sign-in is not in this build: it was built without a Google client ID." + ); +} + +#[test] +fn a_refused_keychain_keeps_the_account_and_says_why() { + let dir = tempfile::tempdir().unwrap(); + let keys = Keys { + refuse: Some("locked"), + ..Keys::default() + }; + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), keys.clone()); + adding(&mail, Kind::Imap, cx); + passed(&mail, account("ann@example.com"), "pw", cx); + cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx))); + settle(cx, |app| mail.read(app).adding.is_none()); + 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.vault + .get("account:ann@example.com") + .unwrap() + .as_deref(), + Some("pw") + ); + }); + assert!(keys.kept.lock().unwrap().is_empty()); + 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); + settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1); + let store = cx.update(|_, app| mail.read(app).store.clone().unwrap()); + settle(cx, |_| store.try_lock().is_ok()); + cx.run_until_parked(); + cx.update(|_, app| { + let mail = mail.read(app); + assert_eq!(mail.accounts().len(), 1); + assert_eq!( + mail.vault + .get("account:ann@example.com") + .unwrap() + .as_deref(), + Some("pw") + ); + }); +} + +#[test] +fn an_account_is_kept_and_removed_with_its_secret() { + let dir = tempfile::tempdir().unwrap(); + let keys = Keys::default(); + { + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), keys.clone()); + adding(&mail, Kind::Imap, cx); + passed(&mail, account("ann@example.com"), "pw", cx); + cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx))); + settle(cx, |app| mail.read(app).adding.is_none()); + adding(&mail, Kind::Imap, cx); + passed(&mail, account("ANN@example.com"), "pw", cx); + cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx))); + assert_eq!(step_words(&mail, cx), "ANN@example.com is already added."); + } + assert_eq!( + keys.kept + .lock() + .unwrap() + .get("account:ann@example.com") + .map(String::as_str), + Some("pw") + ); + 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"))); + cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx))); + settle(cx, |app| mail.read(app).removing.is_some()); + cx.update(|_, app| { + let removing = mail.read(app).removing.as_ref().unwrap(); + assert_eq!( + (removing.address.as_str(), removing.cached.messages), + ("ann@example.com", 0) + ); + }); + press_remove(&mail, cx); + settle(cx, |app| mail.read(app).accounts().is_empty()); + assert!(keys.kept.lock().unwrap().is_empty()); +} + +#[test] +fn a_refused_secret_removal_keeps_the_account() { + let dir = tempfile::tempdir().unwrap(); + Store::open(dir.path()) + .unwrap() + .add_account(&account("ann@example.com")) + .unwrap(); + let keys = Keys { + refuse: Some("locked"), + ..Keys::default() + }; + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), keys.clone()); + 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); + settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1); + settle(cx, |app| mail.read(app).accounts().len() == 1); + assert_eq!( + Store::open(dir.path()).unwrap().accounts().unwrap().len(), + 1 + ); +} + +#[test] +fn a_failed_removal_puts_the_secret_back() { + let dir = tempfile::tempdir().unwrap(); + let mut store = Store::open(dir.path()).unwrap(); + let ann = store.add_account(&account("ann@example.com")).unwrap(); + let inbox = store.mailbox(ann, "INBOX").unwrap(); + let raw: &[u8] = b"Subject: Kept + +still here +"; + let key = Key::Imap { + uidvalidity: 1, + uid: 1, + }; + let kept = store.put(inbox, &key, &[], Fetched::Whole(raw)).unwrap(); + drop(store); + let keys = Keys::default(); + keys.kept + .lock() + .unwrap() + .insert("account:ann@example.com".into(), "pw".into()); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), keys.clone()); + // A folder where the original would be set aside. + let shard = std::fs::read_dir(dir.path().join("content")) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + let name = std::fs::read_dir(&shard) + .unwrap() + .next() + .unwrap() + .unwrap() + .file_name(); + std::fs::create_dir(shard.join(format!(".{}.gone", name.to_str().unwrap()))).unwrap(); + 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); + settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1); + let store = cx.update(|_, app| mail.read(app).store.clone().unwrap()); + settle(cx, |_| store.try_lock().is_ok()); + cx.run_until_parked(); + assert_eq!( + keys.kept + .lock() + .unwrap() + .get("account:ann@example.com") + .map(String::as_str), + Some("pw") + ); + let store = store.lock().unwrap(); + assert_eq!(store.accounts().unwrap().len(), 1); + assert_eq!(store.original(kept).unwrap().unwrap(), raw); +} + +#[test] +fn two_saves_in_flight_add_an_address_once() { + let dir = tempfile::tempdir().unwrap(); + let keys = Keys::default(); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), keys.clone()); + adding(&mail, Kind::Imap, cx); + passed(&mail, account("ann@example.com"), "pw", cx); + cx.update(|window, app| { + mail.update(app, |mail, cx| { + mail.save(cx); + mail.close_adding(cx); + mail.start_adding(window, cx); + }) + }); + passed(&mail, account("ANN@example.com"), "pw2", cx); + cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx))); + settle(cx, |app| { + matches!( + mail.read(app).adding.as_ref().map(|adding| &adding.step), + Some(Step::Failed(_)) + ) + }); + 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 kept = keys.kept.lock().unwrap(); + assert_eq!(kept.len(), 1); + assert_eq!( + kept.get("account:ann@example.com").map(String::as_str), + Some("pw") + ); +} + +#[test] +fn the_keyboard_picks_another_account() { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open(dir.path()).unwrap(); + store.add_account(&account("ann@example.com")).unwrap(); + let bo = store.add_account(&account("bo@example.com")).unwrap(); + drop(store); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), Keys::default()); + // As `mail::open` does. + cx.update(|window, app| window.focus(&mail.read(app).focus.clone(), app)); + for _ in 0..2 { + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + } + for _ in 0..8 { + cx.simulate_keystrokes("tab down"); + cx.run_until_parked(); + if cx.update(|_, app| mail.read(app).picked) == Some(bo) { + return; + } + } + panic!("no Tab stop let Down reach the second account"); +} + +/// Through the dialog: back to its last stop, Remove. +fn press_remove(mail: &Entity, cx: &mut VisualTestContext) { + for _ in 0..2 { + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + } + cx.simulate_keystrokes("shift-tab enter"); + cx.simulate_event(gpui::KeyUpEvent { + keystroke: gpui::Keystroke::parse("enter").unwrap(), + }); + settle(cx, |app| mail.read(app).removing.is_none()); +} + +/// The save lands after a second dialog opened. +fn save_then_reopen(fail: bool) { + let dir = tempfile::tempdir().unwrap(); + let mut cx = TestAppContext::single(); + let (mail, cx) = window(&mut cx, dir.path(), Keys::default()); + adding(&mail, Kind::Imap, cx); + passed(&mail, account("ann@example.com"), "pw", cx); + if fail { + let store = cx.update(|_, app| mail.read(app).store.clone().unwrap()); + let added = store + .lock() + .unwrap() + .add_account(&account("ann@example.com")); + added.unwrap(); + } + cx.update(|window, app| { + mail.update(app, |mail, cx| { + mail.save(cx); + mail.close_adding(cx); + mail.start_adding(window, cx); + let adding = mail.adding.as_mut().unwrap(); + adding.set_kind(Kind::Pop, cx); + let address = adding.fields.address.clone(); + address.update(cx, |field, cx| { + field.set_text("bob@example.com".to_owned(), cx) + }); + }) + }); + if fail { + let store = cx.update(|_, app| mail.read(app).store.clone().unwrap()); + settle(cx, |_| store.try_lock().is_ok()); + cx.run_until_parked(); + cx.update(|_, app| assert!(mail.read(app).accounts().is_empty())); + } else { + settle(cx, |app| mail.read(app).accounts().len() == 1); + } + cx.update(|_, app| { + let adding = mail.read(app).adding.as_ref().unwrap(); + assert_eq!(adding.kind, Kind::Pop); + assert!(matches!(adding.step, Step::Editing)); + assert_eq!(adding.fields.address.read(app).text(), "bob@example.com"); + }); +} + +#[test] +fn a_late_save_leaves_a_newer_dialog_alone() { + save_then_reopen(false); +} + +#[test] +fn a_late_failed_save_leaves_a_newer_dialog_alone() { + save_then_reopen(true); +} diff --git a/apps/ely-mail/src/view.rs b/apps/ely-mail/src/view.rs new file mode 100644 index 0000000..d011146 --- /dev/null +++ b/apps/ely-mail/src/view.rs @@ -0,0 +1,186 @@ +//! What the window draws. + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + data_display::DescriptionList, + feedback::{Banner, EmptyState, ErrorView}, + overlays::ConfirmDialog, + primitives::{IconName, Severity}, + typography::format, +}; +use ely_mail_engine::{Account, Auth, Protocol, Security, Server}; +use ely_platform::AppId; +use ely_ui::Shell; +use gpui::{ + AnyElement, App, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, + SharedString, Styled as _, Window, div, +}; + +use crate::{ + 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", + Security::StartTls => "STARTTLS", + }; + text( + cx, + "mail.account.server", + &[ + ("host", &server.host), + ("port", &server.port.to_string()), + ("security", security), + ], + ) +} + +pub fn auth(auth: Auth, cx: &App) -> SharedString { + let key = match auth { + Auth::Password => "mail.auth.password", + Auth::Google => "mail.auth.google", + Auth::Microsoft => "mail.auth.microsoft", + }; + text(cx, key, &[]) +} + +impl Mail { + fn add_button(&self, cx: &App) -> Button { + Button::new("mail-add", text(cx, "mail.add.open", &[])) + .variant(ButtonVariant::Primary) + .on_click(|_, window, cx| window.dispatch_action(Box::new(menu::AddAccount), cx)) + } + + fn body(&self, cx: &mut Context) -> AnyElement { + let error = |key: &str, detail: &str, cx: &App| { + ErrorView::new("mail-error", IconName::TriangleAlert, text(cx, key, &[])) + .detail(detail.to_owned()) + .into_any_element() + }; + if let Err(detail) = &self.store { + return error("mail.store.unopened", detail, cx); + } + let accounts = match &self.accounts { + None => return div().into_any_element(), + Some(Err(detail)) => return error("mail.store.unread", detail, cx), + Some(Ok(accounts)) => accounts, + }; + let Some((_, account)) = accounts.iter().find(|(id, _)| Some(*id) == self.picked) else { + return EmptyState::new("mail-empty", IconName::Mail, text(cx, "mail.empty", &[])) + .action(self.add_button(cx)) + .into_any_element(); + }; + let label = |key: &str| text(cx, key, &[]); + let incoming = format!( + "{} · {}", + protocol_name(account.protocol), + server(&account.incoming, cx) + ); + let details = DescriptionList::new() + .text(label("mail.account.address"), account.address.clone()) + .text(label("mail.account.user"), account.user.clone()) + .text(label("mail.account.incoming"), incoming) + .text( + label("mail.account.outgoing"), + server(&account.outgoing, cx), + ) + .text(label("mail.account.auth"), auth(account.auth, cx)); + let remove = Button::new("mail-remove", label("mail.remove.open")) + .on_click(|_, window, cx| window.dispatch_action(Box::new(menu::RemoveAccount), cx)); + div() + .p_6() + .flex() + .flex_col() + .gap_4() + .max_w_128() + .child(details) + .child(div().child(remove)) + .into_any_element() + } + + fn remove_dialog(&self, cx: &mut Context) -> Option { + let removing = self.removing.as_ref()?; + let this = cx.weak_entity(); + let dismiss = { + let this = this.clone(); + move |_: &mut Window, cx: &mut App| { + this.update(cx, |mail, cx| { + mail.removing = None; + cx.notify(); + }) + .ok(); + } + }; + let size = format::file_size(removing.cached.bytes, false); + let count = removing.cached.messages; + let args = [ + ("address", removing.address.as_str()), + ("n", &count.to_string()), + ("size", &size), + ]; + let message = ely_ui::i18n::counted(cx, "mail.remove.message", count, &args); + let dialog = ConfirmDialog::new( + "mail-remove", + text(cx, "mail.remove.title", &args), + message, + dismiss, + ) + .confirm(text(cx, "mail.remove.go", &[])) + .destructive() + .on_confirm({ + let removing = removing.clone(); + move |_, cx| { + let removing = removing.clone(); + this.update(cx, |mail, cx| mail.remove(removing, cx)).ok(); + } + }); + Some(dialog.into_any_element()) + } +} + +pub fn protocol_name(protocol: Protocol) -> &'static str { + match protocol { + Protocol::Imap => "IMAP", + Protocol::Pop => "POP3", + } +} + +impl Render for Mail { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let left = (!self.accounts().is_empty()).then(|| self.side.clone().into()); + let read_only = self.read_only.as_ref().map(|reason| { + Banner::new( + "mail-read-only", + Severity::Warning, + text(cx, "mail.store.read_only", &[("error", reason)]), + ) + }); + let shell = Shell::new(AppId::Mail.name(), &self.focus) + .left(left) + .children(read_only) + .child(self.body(cx)); + div() + .size_full() + .key_context(menu::CONTEXT) + .track_focus(&self.focus) + .on_action( + cx.listener(|mail, _: &menu::AddAccount, window, cx| mail.start_adding(window, cx)), + ) + .on_action(cx.listener(|mail, _: &menu::RemoveAccount, _, cx| mail.ask_remove(cx))) + .child(shell) + .children(self.add_dialog(cx)) + .children(self.remove_dialog(cx)) + } +} diff --git a/crates/ely-mail-engine/src/account.rs b/crates/ely-mail-engine/src/account.rs new file mode 100644 index 0000000..3daa2da --- /dev/null +++ b/crates/ely-mail-engine/src/account.rs @@ -0,0 +1,123 @@ +//! An account's servers and how it signs in (MAIL-01, 02). + +use serde::{Deserialize, Serialize}; + +use crate::{Error, Imap, Login, Pop, Result, Secret, Server, Smtp, TlsConnector, oauth}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Protocol { + Imap, + Pop, +} + +/// A password, or OAuth with one provider. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Auth { + Password, + Google, + Microsoft, +} + +impl Auth { + /// The OAuth server; None for a password. + pub fn provider(self) -> Option> { + match self { + Auth::Password => None, + Auth::Google => Some(oauth::google()), + Auth::Microsoft => Some(oauth::microsoft()), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Account { + pub address: String, + pub user: String, + pub auth: Auth, + pub protocol: Protocol, + pub incoming: Server, + pub outgoing: Server, +} + +/// What each server answered. +pub struct Checked { + pub incoming: Result<()>, + pub outgoing: Result<()>, +} + +impl Checked { + pub fn passed(&self) -> bool { + self.incoming.is_ok() && self.outgoing.is_ok() + } +} + +impl Account { + /// The vault name of its password or refresh token. + pub fn secret_name(&self) -> String { + format!("account:{}", self.address) + } + + /// Refuses what no server could carry. + pub fn valid(&self) -> Result<()> { + crate::smtp::mailbox(&self.address)?; + if self.user.is_empty() || self.user.contains(['\r', '\n']) { + return Err(Error::Invalid("a user name on one line is needed".into())); + } + for server in [&self.incoming, &self.outgoing] { + if server.host.is_empty() || server.host.contains(char::is_whitespace) { + return Err(Error::Invalid(format!("a server host: {:?}", server.host))); + } + if server.port == 0 { + return Err(Error::Invalid("port 0".into())); + } + } + Ok(()) + } + + pub fn login(&self, secret: Secret) -> Login { + Login { + user: self.user.clone(), + secret, + } + } + + /// Logs in to both servers at once, then leaves. + pub async fn check(&self, secret: Secret, tls: &TlsConnector) -> Checked { + let login = self.login(secret); + let (incoming, outgoing) = tokio::join!( + self.check_incoming(&login, tls), + check_outgoing(&self.outgoing, &login, tls) + ); + log::info!( + "mail: check: incoming {}, outgoing {}", + outcome(&incoming), + outcome(&outgoing) + ); + Checked { incoming, outgoing } + } + + async fn check_incoming(&self, login: &Login, tls: &TlsConnector) -> Result<()> { + match self.protocol { + Protocol::Imap => { + Imap::connect(&self.incoming, login, tls) + .await? + .logout() + .await + } + Protocol::Pop => Pop::connect(&self.incoming, login, tls).await?.quit().await, + } + } +} + +async fn check_outgoing(server: &Server, login: &Login, tls: &TlsConnector) -> Result<()> { + Smtp::connect(server, login, tls).await?.quit().await +} + +fn outcome(result: &Result<()>) -> &'static str { + match result { + Ok(()) => "passed", + Err(_) => "failed", + } +} diff --git a/crates/ely-mail-engine/src/error.rs b/crates/ely-mail-engine/src/error.rs index 4767498..d6dee0f 100644 --- a/crates/ely-mail-engine/src/error.rs +++ b/crates/ely-mail-engine/src/error.rs @@ -23,6 +23,8 @@ pub enum Error { Mime(String), /// The local store failed: database, disk or damage. Store(String), + /// An account with this address is already kept. + Taken(String), /// The store refuses writes; the reason says why. ReadOnly(String), /// The system credential store failed. @@ -47,6 +49,7 @@ impl fmt::Display for Error { Self::Invalid(text) => write!(f, "cannot be sent: {text}"), Self::Mime(text) => write!(f, "unreadable message: {text}"), Self::Store(text) => write!(f, "local store failed: {text}"), + Self::Taken(address) => write!(f, "{address} is already added"), Self::ReadOnly(text) => write!(f, "local store is read-only: {text}"), Self::Vault(text) => write!(f, "credential store failed: {text}"), Self::Unavailable(name) => write!(f, "{name} sign-in is not in this build"), diff --git a/crates/ely-mail-engine/src/lib.rs b/crates/ely-mail-engine/src/lib.rs index ecc2cd8..146e6af 100644 --- a/crates/ely-mail-engine/src/lib.rs +++ b/crates/ely-mail-engine/src/lib.rs @@ -1,5 +1,6 @@ //! Mail protocols, sync and local storage for ElyMail. +mod account; mod error; mod imap; mod mime; @@ -11,12 +12,13 @@ mod store; mod vault; mod wire; +pub use account::{Account, Auth, Checked, Protocol}; pub use error::{Error, Result}; pub use imap::Imap; pub use mime::{Built, Draft, Mail, Mailbox, Part, build, read}; pub use net::{Login, Secret, Security, Server, system_tls}; pub use pop::Pop; pub use smtp::Smtp; -pub use store::{AccountId, Fetched, Key, MailboxId, MessageId, Sizes, Store, Stored}; +pub use store::{AccountId, Cached, Fetched, Key, MailboxId, MessageId, Sizes, Store, Stored}; pub use tokio_rustls::TlsConnector; pub use vault::{Backend, Kept, Keyring, Vault}; diff --git a/crates/ely-mail-engine/src/net.rs b/crates/ely-mail-engine/src/net.rs index 8a88740..a5bea2e 100644 --- a/crates/ely-mail-engine/src/net.rs +++ b/crates/ely-mail-engine/src/net.rs @@ -2,6 +2,7 @@ use std::{future::Future, sync::Arc, time::Duration}; use rustls::{ClientConfig, crypto::ring, pki_types::ServerName}; use rustls_platform_verifier::BuilderVerifierExt; +use serde::{Deserialize, Serialize}; use tokio::net::TcpStream; use tokio_rustls::{TlsConnector, client::TlsStream}; @@ -11,7 +12,7 @@ pub(crate) type Stream = TlsStream; const WAIT: Duration = Duration::from_secs(60); -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Server { pub host: String, pub port: u16, @@ -19,7 +20,8 @@ pub struct Server { } /// No plaintext: every mode ends in TLS. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum Security { Tls, StartTls, diff --git a/crates/ely-mail-engine/src/store.rs b/crates/ely-mail-engine/src/store.rs index 75e5414..dd02cc4 100644 --- a/crates/ely-mail-engine/src/store.rs +++ b/crates/ely-mail-engine/src/store.rs @@ -10,6 +10,8 @@ use rusqlite::{Connection, OpenFlags, OptionalExtension, params}; use crate::{Error, Result}; +mod accounts; +mod aside; mod html; mod messages; mod schema; @@ -17,6 +19,7 @@ mod search; #[cfg(test)] mod tests; +pub use accounts::Cached; pub use messages::{Fetched, Key, Stored}; const DB: &str = "mail.db"; @@ -63,21 +66,6 @@ impl Store { } } - pub fn add_account(&self, address: &str) -> Result { - self.writable()?; - self.conn - .execute("INSERT INTO accounts (address) VALUES (?1)", [address])?; - Ok(AccountId(self.conn.last_insert_rowid())) - } - - pub fn accounts(&self) -> Result> { - let mut query = self - .conn - .prepare("SELECT id, address FROM accounts ORDER BY id")?; - let rows = query.query_map([], |row| Ok((AccountId(row.get(0)?), row.get(1)?)))?; - Ok(rows.collect::>()?) - } - /// The mailbox by name, made on first use. pub fn mailbox(&self, account: AccountId, name: &str) -> Result { let find = "SELECT id FROM mailboxes WHERE account = ?1 AND name = ?2"; @@ -155,6 +143,7 @@ fn open(dir: &Path, steps: &[&str]) -> Result { } conn.pragma_update(None, "foreign_keys", true)?; conn.pragma_update(None, "journal_mode", "WAL")?; + aside::recover(&conn, dir)?; log::info!("mail: store opened at schema v{target}"); Ok(Store { conn, diff --git a/crates/ely-mail-engine/src/store/accounts.rs b/crates/ely-mail-engine/src/store/accounts.rs new file mode 100644 index 0000000..77d3b2a --- /dev/null +++ b/crates/ely-mail-engine/src/store/accounts.rs @@ -0,0 +1,110 @@ +//! Accounts: added, listed, and removed with their cache. + +use std::{fs, io::ErrorKind}; + +use rusqlite::params; + +use super::{AccountId, CONTENT, Store, aside::Aside, disk}; +use crate::{Account, Error, Result}; + +/// What removing an account deletes here. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Cached { + pub messages: u64, + /// Originals no other account shares. + pub bytes: u64, +} + +impl Store { + /// Addresses differ in more than ASCII case. + pub fn add_account(&self, account: &Account) -> Result { + self.writable()?; + let settings = serde_json::to_string(account)?; + let added = self.conn.execute( + "INSERT INTO accounts (address, settings) VALUES (?1, ?2)", + params![account.address, settings], + ); + match added { + Ok(_) => Ok(AccountId(self.conn.last_insert_rowid())), + Err(rusqlite::Error::SqliteFailure(err, _)) + if err.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE => + { + Err(Error::Taken(account.address.clone())) + } + Err(err) => Err(err.into()), + } + } + + pub fn accounts(&self) -> Result> { + let mut query = self + .conn + .prepare("SELECT id, settings FROM accounts ORDER BY id")?; + let rows = query.query_map([], |row| { + Ok((AccountId(row.get(0)?), row.get::<_, String>(1)?)) + })?; + rows.map(|row| { + let (id, settings) = row?; + Ok((id, serde_json::from_str(&settings)?)) + }) + .collect() + } + + pub fn cached(&self, account: AccountId) -> Result { + let messages: i64 = self.conn.query_row( + "SELECT count(*) FROM messages JOIN mailboxes ON messages.mailbox = mailboxes.id + WHERE mailboxes.account = ?1", + [account.0], + |row| row.get(0), + )?; + let mut bytes = 0; + for hash in self.only_in(account)? { + let path = self.dir.join(CONTENT).join(&hash[..2]).join(&hash); + bytes += match fs::metadata(path) { + Ok(meta) => meta.len(), + Err(err) if err.kind() == ErrorKind::NotFound => 0, + Err(err) => return Err(disk("measuring an original", err)), + }; + } + let messages = u64::try_from(messages) + .map_err(|_| Error::Store(format!("a negative count: {messages}")))?; + Ok(Cached { messages, bytes }) + } + + /// The account, its mailboxes, messages and unshared originals. + pub fn remove_account(&mut self, account: AccountId) -> Result<()> { + self.writable()?; + let hashes = self.only_in(account)?; + let tx = self.conn.transaction()?; + let mut aside = Aside::new(&self.dir); + let done = (|| -> Result<()> { + let gone = tx.execute("DELETE FROM accounts WHERE id = ?1", [account.0])?; + if gone != 1 { + return Err(Error::Store(format!("no account {}", account.0))); + } + for hash in &hashes { + aside.drop_unused(&tx, hash)?; + } + Ok(tx.commit()?) + })(); + aside.settle(done)?; + log::info!( + "mail: account {} removed with {} originals", + account.0, + hashes.len() + ); + Ok(()) + } + + fn only_in(&self, account: AccountId) -> Result> { + let mut query = self.conn.prepare( + "SELECT DISTINCT content FROM messages JOIN mailboxes ON messages.mailbox = mailboxes.id + WHERE mailboxes.account = ?1 AND content IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM messages AS other + JOIN mailboxes AS theirs ON other.mailbox = theirs.id + WHERE other.content = messages.content AND theirs.account <> ?1) + ORDER BY content", + )?; + let rows = query.query_map([account.0], |row| row.get(0))?; + Ok(rows.collect::>()?) + } +} diff --git a/crates/ely-mail-engine/src/store/aside.rs b/crates/ely-mail-engine/src/store/aside.rs new file mode 100644 index 0000000..43f67c3 --- /dev/null +++ b/crates/ely-mail-engine/src/store/aside.rs @@ -0,0 +1,139 @@ +//! Originals set aside until their transaction commits. + +use std::{ + fs, + io::ErrorKind, + path::{Path, PathBuf}, +}; + +use rusqlite::Connection; + +use super::{CONTENT, disk}; +use crate::{Error, Result}; + +const MARK: &str = ".gone"; + +fn original(dir: &Path, hash: &str) -> PathBuf { + dir.join(CONTENT).join(&hash[..2]).join(hash) +} + +fn parked(dir: &Path, hash: &str) -> PathBuf { + dir.join(CONTENT) + .join(&hash[..2]) + .join(format!(".{hash}{MARK}")) +} + +/// Renamed, not deleted, so a rollback can put them back. +pub(super) struct Aside { + dir: PathBuf, + moved: Vec, +} + +impl Aside { + pub fn new(dir: &Path) -> Self { + Self { + dir: dir.to_owned(), + moved: Vec::new(), + } + } + + /// Once no row names the hash; runs before commit. + pub fn drop_unused(&mut self, conn: &Connection, hash: &str) -> Result<()> { + let used: bool = conn.query_row( + "SELECT EXISTS (SELECT 1 FROM messages WHERE content = ?1)", + [hash], + |row| row.get(0), + )?; + if used { + return Ok(()); + } + match fs::rename(original(&self.dir, hash), parked(&self.dir, hash)) { + Ok(()) => { + self.moved.push(hash.to_owned()); + Ok(()) + } + Err(err) if err.kind() == ErrorKind::NotFound => { + log::warn!("mail: original {hash} was already gone"); + Ok(()) + } + Err(err) => Err(disk("deleting an original", err)), + } + } + + /// Deletes on success; puts every file back on failure. + pub fn settle(self, done: Result) -> Result { + match done { + Ok(value) => { + for hash in &self.moved { + if let Err(err) = fs::remove_file(parked(&self.dir, hash)) { + log::error!("mail: original {hash} stays set aside: {err}"); + } + } + Ok(value) + } + Err(err) => { + let mut stuck = Vec::new(); + for hash in self.moved.iter().rev() { + if let Err(back) = + fs::rename(parked(&self.dir, hash), original(&self.dir, hash)) + { + stuck.push(format!("{hash}: {back}")); + } + } + if stuck.is_empty() { + Err(err) + } else { + Err(Error::Store(format!( + "{err}; originals not put back (restored at next start): {}", + stuck.join("; ") + ))) + } + } + } + } +} + +/// After a crash: back if named, deleted if not. +pub(super) fn recover(conn: &Connection, dir: &Path) -> Result<()> { + let mut back = 0; + let mut gone = 0; + let shards = + fs::read_dir(dir.join(CONTENT)).map_err(|err| disk("reading the originals", err))?; + for shard in shards { + let shard = shard.map_err(|err| disk("reading the originals", err))?; + let files = fs::read_dir(shard.path()).map_err(|err| disk("reading the originals", err))?; + for file in files { + let name = file + .map_err(|err| disk("reading the originals", err))? + .file_name(); + let Some(hash) = name + .to_str() + .and_then(|name| name.strip_prefix('.')) + .and_then(|name| name.strip_suffix(MARK)) + .filter(|hash| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit())) + else { + continue; + }; + let named: bool = conn.query_row( + "SELECT EXISTS (SELECT 1 FROM messages WHERE content = ?1)", + [hash], + |row| row.get(0), + )?; + if named { + fs::rename(parked(dir, hash), original(dir, hash)) + .map_err(|err| disk("restoring an original", err))?; + back += 1; + } else { + fs::remove_file(parked(dir, hash)) + .map_err(|err| disk("deleting an original", err))?; + gone += 1; + } + } + } + if back + gone > 0 { + log::warn!( + "mail: {back} originals restored and {gone} deleted after an interrupted change" + ); + } + Ok(()) +} diff --git a/crates/ely-mail-engine/src/store/messages.rs b/crates/ely-mail-engine/src/store/messages.rs index e121730..ca584ec 100644 --- a/crates/ely-mail-engine/src/store/messages.rs +++ b/crates/ely-mail-engine/src/store/messages.rs @@ -1,9 +1,9 @@ use std::{fs, io::ErrorKind, path::Path}; -use rusqlite::{Connection, OptionalExtension, Row, ToSql, Transaction, params}; +use rusqlite::{OptionalExtension, Row, ToSql, Transaction, params}; use sha2::{Digest, Sha256}; -use super::{CONTENT, MailboxId, MessageId, Store, disk, search}; +use super::{CONTENT, MailboxId, MessageId, Store, aside::Aside, disk, search}; use crate::{Error, Mail, Mailbox, Result, read}; /// RFC 3501 §2.3.1.1: identity is mailbox, UIDVALIDITY and UID. @@ -89,11 +89,12 @@ impl Store { let mail = read(bytes)?; let (content, made) = self.keep(bytes)?; let written = self.write(mailbox, key, flags, &mail, Some(&content), existing); - if written.is_err() - && made - && let Err(gone) = drop_unused(&self.conn, &self.dir, &content) - { - log::error!("mail: an unused original stays after a failed write: {gone}"); + if written.is_err() && made { + let mut aside = Aside::new(&self.dir); + let gone = aside.drop_unused(&self.conn, &content); + if let Err(gone) = aside.settle(gone) { + log::error!("mail: an unused original stays after a failed write: {gone}"); + } } written } @@ -132,42 +133,46 @@ impl Store { &content, ]; let tx = self.conn.transaction()?; - let mut old: Option = None; - let id = match existing { - Some(id) => { - old = tx.query_row( - "SELECT content FROM messages WHERE id = ?1", - [id.0], - |row| row.get(0), - )?; - values.push(&id.0); - tx.execute( - "UPDATE messages SET message_id = ?1, in_reply_to = ?2, refs = ?3, - subject = ?4, sender = ?5, recipients = ?6, copies = ?7, date = ?8, - attachments = ?9, content = ?10 WHERE id = ?11", - values.as_slice(), - )?; - id + let mut aside = Aside::new(&self.dir); + let done = (|| -> Result { + let mut old: Option = None; + let id = match &existing { + Some(id) => { + old = tx.query_row( + "SELECT content FROM messages WHERE id = ?1", + [id.0], + |row| row.get(0), + )?; + values.push(&id.0); + tx.execute( + "UPDATE messages SET message_id = ?1, in_reply_to = ?2, refs = ?3, + subject = ?4, sender = ?5, recipients = ?6, copies = ?7, date = ?8, + attachments = ?9, content = ?10 WHERE id = ?11", + values.as_slice(), + )?; + *id + } + None => { + values.extend::<[&dyn ToSql; 4]>([&mailbox.0, &uidvalidity, &uid, &uidl]); + tx.execute( + "INSERT INTO messages (message_id, in_reply_to, refs, subject, sender, + recipients, copies, date, attachments, content, mailbox, uidvalidity, + uid, uidl) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, + ?13, ?14)", + values.as_slice(), + )?; + MessageId(tx.last_insert_rowid()) + } + }; + replace_flags(&tx, id, flags)?; + search::index(&tx, id, mail, content.is_some())?; + if let Some(old) = old.filter(|old| Some(old.as_str()) != content) { + aside.drop_unused(&tx, &old)?; } - None => { - values.extend::<[&dyn ToSql; 4]>([&mailbox.0, &uidvalidity, &uid, &uidl]); - tx.execute( - "INSERT INTO messages (message_id, in_reply_to, refs, subject, sender, - recipients, copies, date, attachments, content, mailbox, uidvalidity, - uid, uidl) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, - ?13, ?14)", - values.as_slice(), - )?; - MessageId(tx.last_insert_rowid()) - } - }; - replace_flags(&tx, id, flags)?; - search::index(&tx, id, mail, content.is_some())?; - if let Some(old) = old.filter(|old| Some(old.as_str()) != content) { - drop_unused(&tx, &self.dir, &old)?; - } - tx.commit()?; - Ok(id) + tx.commit()?; + Ok(id) + })(); + aside.settle(done) } pub fn set_flags(&mut self, id: MessageId, flags: &[&str]) -> Result<()> { @@ -205,12 +210,15 @@ impl Store { self.writable()?; let content = self.content(id)?; let tx = self.conn.transaction()?; - tx.execute("DELETE FROM messages WHERE id = ?1", [id.0])?; - if let Some(hash) = content { - drop_unused(&tx, &self.dir, &hash)?; - } - tx.commit()?; - Ok(()) + let mut aside = Aside::new(&self.dir); + let done = (|| -> Result<()> { + tx.execute("DELETE FROM messages WHERE id = ?1", [id.0])?; + if let Some(hash) = content { + aside.drop_unused(&tx, &hash)?; + } + Ok(tx.commit()?) + })(); + aside.settle(done) } fn content(&self, id: MessageId) -> Result> { @@ -253,27 +261,6 @@ impl Store { } } -/// Deletes the file once no row names it; runs before commit. -fn drop_unused(conn: &Connection, dir: &Path, hash: &str) -> Result<()> { - let used: bool = conn.query_row( - "SELECT EXISTS (SELECT 1 FROM messages WHERE content = ?1)", - [hash], - |row| row.get(0), - )?; - if used { - return Ok(()); - } - let path = dir.join(CONTENT).join(&hash[..2]).join(hash); - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(err) if err.kind() == ErrorKind::NotFound => { - log::warn!("mail: original {hash} was already gone"); - Ok(()) - } - Err(err) => Err(disk("deleting an original", err)), - } -} - fn replace_flags(tx: &Transaction, id: MessageId, flags: &[&str]) -> Result<()> { tx.execute("DELETE FROM flags WHERE message = ?1", [id.0])?; for flag in flags { diff --git a/crates/ely-mail-engine/src/store/schema.rs b/crates/ely-mail-engine/src/store/schema.rs index f1fb405..c7dcae0 100644 --- a/crates/ely-mail-engine/src/store/schema.rs +++ b/crates/ely-mail-engine/src/store/schema.rs @@ -4,7 +4,8 @@ pub(super) const STEPS: &[&str] = &[V1]; const V1: &str = " CREATE TABLE accounts ( id INTEGER PRIMARY KEY, - address TEXT NOT NULL UNIQUE + address TEXT NOT NULL UNIQUE COLLATE NOCASE, + settings TEXT NOT NULL ); CREATE TABLE mailboxes ( id INTEGER PRIMARY KEY, diff --git a/crates/ely-mail-engine/src/store/tests.rs b/crates/ely-mail-engine/src/store/tests.rs index 6a52dd8..7fa349d 100644 --- a/crates/ely-mail-engine/src/store/tests.rs +++ b/crates/ely-mail-engine/src/store/tests.rs @@ -3,7 +3,23 @@ use std::fs; use rusqlite::Connection; use super::{BACKUPS, DB, open, schema::STEPS}; -use crate::Error; +use crate::{Account, Auth, Error, Protocol, Security, Server}; + +fn account(address: &str) -> Account { + let server = Server { + host: "127.0.0.1".into(), + port: 993, + security: Security::Tls, + }; + Account { + address: address.into(), + user: address.into(), + auth: Auth::Password, + protocol: Protocol::Imap, + incoming: server.clone(), + outgoing: server, + } +} const ADD: &str = "ALTER TABLE accounts ADD COLUMN nick TEXT"; const BROKEN: &str = "ALTER TABLE nowhere ADD COLUMN nick TEXT"; @@ -36,7 +52,7 @@ fn an_upgrade_backs_up_first() { let dir = tempfile::tempdir().unwrap(); open(dir.path(), &[STEPS[0]]) .unwrap() - .add_account("ann@example.com") + .add_account(&account("ann@example.com")) .unwrap(); let store = open(dir.path(), &[STEPS[0], ADD]).unwrap(); assert_eq!(store.read_only(), None); @@ -55,13 +71,13 @@ fn a_failed_upgrade_keeps_the_data_and_refuses_writes() { let dir = tempfile::tempdir().unwrap(); open(dir.path(), &[STEPS[0]]) .unwrap() - .add_account("ann@example.com") + .add_account(&account("ann@example.com")) .unwrap(); let store = open(dir.path(), &[STEPS[0], ADD, BROKEN]).unwrap(); let reason = store.read_only().unwrap(); assert!(reason.contains("v1 to v3"), "{reason}"); - assert_eq!(store.accounts().unwrap()[0].1, "ann@example.com"); - let err = store.add_account("bo@example.com").unwrap_err(); + assert_eq!(store.accounts().unwrap()[0].1.address, "ann@example.com"); + let err = store.add_account(&account("bo@example.com")).unwrap_err(); assert!(matches!(err, Error::ReadOnly(_)), "{err}"); drop(store); assert_eq!(version(&dir.path().join(DB)), 1); diff --git a/crates/ely-mail-engine/src/vault.rs b/crates/ely-mail-engine/src/vault.rs index 8114a74..3b24b1c 100644 --- a/crates/ely-mail-engine/src/vault.rs +++ b/crates/ely-mail-engine/src/vault.rs @@ -93,11 +93,13 @@ impl Vault { .map_err(|reason| Error::Vault(format!("reading {name}: {reason}"))) } + /// A refused delete keeps the session copy too. pub fn remove(&self, name: &str) -> Result<()> { - self.session().remove(name); self.backend .delete(name) - .map_err(|reason| Error::Vault(format!("deleting {name}: {reason}"))) + .map_err(|reason| Error::Vault(format!("deleting {name}: {reason}")))?; + self.session().remove(name); + Ok(()) } fn session(&self) -> std::sync::MutexGuard<'_, HashMap> { diff --git a/crates/ely-mail-engine/tests/accounts.rs b/crates/ely-mail-engine/tests/accounts.rs new file mode 100644 index 0000000..8574332 --- /dev/null +++ b/crates/ely-mail-engine/tests/accounts.rs @@ -0,0 +1,200 @@ +mod fake; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use ely_mail_engine::{ + Account, Auth, Cached, Error, Fetched, Key, Protocol, Secret, Security, Server, Store, +}; +use fake::{Certs, account, certs, expect, send, serve}; + +fn pop_oauth(address: &str) -> Account { + Account { + address: address.into(), + user: "bo".into(), + auth: Auth::Microsoft, + protocol: Protocol::Pop, + incoming: Server { + host: "pop.example.com".into(), + port: 995, + security: Security::Tls, + }, + outgoing: Server { + host: "smtp.example.com".into(), + port: 587, + security: Security::StartTls, + }, + } +} + +#[test] +fn accounts_come_back_as_added() { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open(dir.path()).unwrap(); + let ann = account("ann@example.com"); + let bo = pop_oauth("bo@example.com"); + let first = store.add_account(&ann).unwrap(); + let second = store.add_account(&bo).unwrap(); + drop(store); + let store = Store::open(dir.path()).unwrap(); + assert_eq!(store.accounts().unwrap(), [(first, ann), (second, bo)]); +} + +#[test] +fn an_address_is_added_once() { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open(dir.path()).unwrap(); + store.add_account(&account("ann@example.com")).unwrap(); + let err = store + .add_account(&pop_oauth("ANN@Example.com")) + .unwrap_err(); + assert!( + matches!(&err, Error::Taken(address) if address == "ANN@Example.com"), + "{err}" + ); + assert_eq!(store.accounts().unwrap().len(), 1); +} + +#[test] +fn removing_an_account_keeps_what_another_shares() { + let dir = tempfile::tempdir().unwrap(); + let mut store = Store::open(dir.path()).unwrap(); + let ann = store.add_account(&account("ann@example.com")).unwrap(); + let bo = store.add_account(&account("bo@example.com")).unwrap(); + let (ann_inbox, bo_inbox) = ( + store.mailbox(ann, "INBOX").unwrap(), + store.mailbox(bo, "INBOX").unwrap(), + ); + let shared = b"Subject: Both\r\n\r\nshared\r\n"; + let own = b"Subject: Ann only\r\n\r\nmine and mine alone\r\n"; + let key = |uid| Key::Imap { + uidvalidity: 1, + uid, + }; + store + .put(ann_inbox, &key(1), &[], Fetched::Whole(shared)) + .unwrap(); + store + .put(ann_inbox, &key(2), &[], Fetched::Whole(own)) + .unwrap(); + let kept = store + .put(bo_inbox, &key(1), &[], Fetched::Whole(shared)) + .unwrap(); + assert_eq!( + store.cached(ann).unwrap(), + Cached { + messages: 2, + bytes: own.len() as u64 + } + ); + store.remove_account(ann).unwrap(); + assert_eq!(store.accounts().unwrap().len(), 1); + assert_eq!(store.original(kept).unwrap().unwrap(), shared); + assert_eq!(store.sizes().unwrap().originals, shared.len() as u64); + let err = store.remove_account(ann).unwrap_err(); + assert!(matches!(err, Error::Store(_)), "{err}"); +} + +/// IMAP and SMTP scripts that log in and leave. +async fn servers( + certs: &Certs, + imap_answer: &str, +) -> (Account, Vec>>) { + let mut imap = vec![ + send("* OK ready\n"), + expect("{tag} LOGIN \"ann@example.com\" \"pw\""), + send(imap_answer), + ]; + if imap_answer.contains(" OK ") { + imap.extend([expect("{tag} LOGOUT"), send("* BYE\n{tag} OK bye\n")]); + } + let (incoming, imap) = serve(certs, Security::Tls, imap).await; + let smtp = vec![ + send("220 mx ready\n"), + expect("EHLO [127.0.0.1]"), + send("250-mx\n250 AUTH PLAIN\n"), + expect(&format!( + "AUTH PLAIN {}", + STANDARD.encode("\0ann@example.com\0pw") + )), + send("235 ok\n"), + expect("QUIT"), + send("221 bye\n"), + ]; + let (outgoing, smtp) = serve(certs, Security::Tls, smtp).await; + let account = Account { + incoming, + outgoing, + ..account("ann@example.com") + }; + (account, vec![imap, smtp]) +} + +#[tokio::test] +async fn a_check_logs_in_to_both_servers() { + let certs = certs(); + let (account, scripts) = servers(&certs, "{tag} OK in\n").await; + let checked = account + .check(Secret::Password("pw".into()), &certs.trusted) + .await; + assert!(checked.passed()); + for script in scripts { + script.await.unwrap().unwrap(); + } +} + +#[tokio::test] +async fn each_server_answers_for_itself() { + let certs = certs(); + let (account, scripts) = + servers(&certs, "{tag} NO [AUTHENTICATIONFAILED] bad password\n").await; + let checked = account + .check(Secret::Password("pw".into()), &certs.trusted) + .await; + assert!(!checked.passed()); + match checked.incoming { + Err(Error::Rejected(text)) => assert!(text.contains("bad password"), "{text}"), + other => panic!("incoming: {:?}", other.err()), + } + checked.outgoing.unwrap(); + for script in scripts { + script.await.unwrap().unwrap(); + } +} + +#[test] +fn what_no_server_could_carry_is_refused() { + account("ann@example.com").valid().unwrap(); + let broken = [ + Account { + address: "ann".into(), + ..account("ann@example.com") + }, + Account { + user: String::new(), + ..account("ann@example.com") + }, + Account { + user: "ann\r\nRSET".into(), + ..account("ann@example.com") + }, + Account { + incoming: Server { + host: "imap example.com".into(), + port: 993, + security: Security::Tls, + }, + ..account("ann@example.com") + }, + Account { + outgoing: Server { + host: "smtp.example.com".into(), + port: 0, + security: Security::Tls, + }, + ..account("ann@example.com") + }, + ]; + for account in broken { + let err = account.valid().unwrap_err(); + assert!(matches!(err, Error::Invalid(_)), "{err}"); + } +} diff --git a/crates/ely-mail-engine/tests/aside.rs b/crates/ely-mail-engine/tests/aside.rs new file mode 100644 index 0000000..75da068 --- /dev/null +++ b/crates/ely-mail-engine/tests/aside.rs @@ -0,0 +1,73 @@ +mod fake; + +use std::{fs, path::Path}; + +use ely_mail_engine::{Fetched, Key, Store}; +use fake::account; +use sha2::{Digest, Sha256}; + +fn hash(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +fn parked(dir: &Path, hash: &str) -> std::path::PathBuf { + dir.join("content") + .join(&hash[..2]) + .join(format!(".{hash}.gone")) +} + +fn key(uid: u32) -> Key { + Key::Imap { + uidvalidity: 1, + uid, + } +} + +#[test] +fn a_removal_that_fails_midway_keeps_every_original() { + let dir = tempfile::tempdir().unwrap(); + let mut store = Store::open(dir.path()).unwrap(); + let ann = store.add_account(&account("ann@example.com")).unwrap(); + let inbox = store.mailbox(ann, "INBOX").unwrap(); + let one: &[u8] = b"Subject: One\r\n\r\nfirst\r\n"; + let two: &[u8] = b"Subject: Two\r\n\r\nsecond\r\n"; + let a = store.put(inbox, &key(1), &[], Fetched::Whole(one)).unwrap(); + let b = store.put(inbox, &key(2), &[], Fetched::Whole(two)).unwrap(); + let last = hash(one).max(hash(two)); + fs::create_dir(parked(dir.path(), &last)).unwrap(); + store.remove_account(ann).unwrap_err(); + assert_eq!(store.accounts().unwrap().len(), 1); + assert_eq!(store.original(a).unwrap().unwrap(), one); + assert_eq!(store.original(b).unwrap().unwrap(), two); + fs::remove_dir(parked(dir.path(), &last)).unwrap(); + store.remove_account(ann).unwrap(); + assert_eq!(store.sizes().unwrap().originals, 0); +} + +#[test] +fn a_restart_puts_back_what_a_crash_left_aside() { + let dir = tempfile::tempdir().unwrap(); + let mut store = Store::open(dir.path()).unwrap(); + let ann = store.add_account(&account("ann@example.com")).unwrap(); + let inbox = store.mailbox(ann, "INBOX").unwrap(); + let kept: &[u8] = b"Subject: Kept\r\n\r\nstill named\r\n"; + let id = store + .put(inbox, &key(1), &[], Fetched::Whole(kept)) + .unwrap(); + drop(store); + let named = hash(kept); + let original = dir.path().join("content").join(&named[..2]).join(&named); + fs::rename(&original, parked(dir.path(), &named)).unwrap(); + let orphan = hash(b"nobody names this"); + let shard = dir.path().join("content").join(&orphan[..2]); + fs::create_dir_all(&shard).unwrap(); + fs::write(parked(dir.path(), &orphan), b"nobody names this").unwrap(); + let store = Store::open(dir.path()).unwrap(); + assert_eq!(store.original(id).unwrap().unwrap(), kept); + assert!(!parked(dir.path(), &named).exists()); + assert!(!parked(dir.path(), &orphan).exists()); + assert_eq!(store.sizes().unwrap().originals, kept.len() as u64); +} diff --git a/crates/ely-mail-engine/tests/fake/mod.rs b/crates/ely-mail-engine/tests/fake/mod.rs index d1803a5..6b7f6c3 100644 --- a/crates/ely-mail-engine/tests/fake/mod.rs +++ b/crates/ely-mail-engine/tests/fake/mod.rs @@ -5,7 +5,7 @@ pub mod smtp; use std::sync::Arc; -use ely_mail_engine::{Login, Secret, Security, Server, TlsConnector}; +use ely_mail_engine::{Account, Auth, Login, Protocol, Secret, Security, Server, TlsConnector}; use rcgen::{CertificateParams, KeyPair}; use tokio::{ io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}, @@ -78,6 +78,23 @@ pub fn certs() -> Certs { } } +/// An IMAP account on the test address. +pub fn account(address: &str) -> Account { + let server = |port| Server { + host: "127.0.0.1".into(), + port, + security: Security::Tls, + }; + Account { + address: address.into(), + user: address.into(), + auth: Auth::Password, + protocol: Protocol::Imap, + incoming: server(993), + outgoing: server(465), + } +} + pub fn login(secret: Secret) -> Login { Login { user: "ann@example.com".into(), diff --git a/crates/ely-mail-engine/tests/store.rs b/crates/ely-mail-engine/tests/store.rs index d42a379..374af25 100644 --- a/crates/ely-mail-engine/tests/store.rs +++ b/crates/ely-mail-engine/tests/store.rs @@ -1,3 +1,5 @@ +mod fake; + use std::path::Path; use ely_mail_engine::{Error, Fetched, Key, MailboxId, MessageId, Store}; @@ -23,7 +25,9 @@ fn headers(raw: &[u8]) -> &[u8] { fn inbox(dir: &Path) -> (Store, MailboxId) { let store = Store::open(dir).unwrap(); - let account = store.add_account("ann@example.com").unwrap(); + let account = store + .add_account(&fake::account("ann@example.com")) + .unwrap(); let inbox = store.mailbox(account, "INBOX").unwrap(); (store, inbox) } diff --git a/crates/ely-mail-engine/tests/store_release.rs b/crates/ely-mail-engine/tests/store_release.rs index 699c8dd..5595d7f 100644 --- a/crates/ely-mail-engine/tests/store_release.rs +++ b/crates/ely-mail-engine/tests/store_release.rs @@ -1,5 +1,7 @@ #![cfg(windows)] +mod fake; + use std::{fs::File, os::windows::fs::OpenOptionsExt, path::Path}; use ely_mail_engine::{Fetched, Key, MailboxId, Store}; @@ -10,7 +12,9 @@ const TWO: &[u8] = b"Subject: two\r\n\r\nsecond body\r\n"; fn inbox(dir: &Path) -> (Store, MailboxId) { let store = Store::open(dir).unwrap(); - let account = store.add_account("ann@example.com").unwrap(); + let account = store + .add_account(&fake::account("ann@example.com")) + .unwrap(); let inbox = store.mailbox(account, "INBOX").unwrap(); (store, inbox) } diff --git a/crates/ely-mail-engine/tests/store_repair.rs b/crates/ely-mail-engine/tests/store_repair.rs index a8076dc..ed764c7 100644 --- a/crates/ely-mail-engine/tests/store_repair.rs +++ b/crates/ely-mail-engine/tests/store_repair.rs @@ -1,3 +1,5 @@ +mod fake; + use std::path::Path; use ely_mail_engine::{Error, Fetched, Key, MailboxId, MessageId, Store}; @@ -17,7 +19,9 @@ fn sample() -> Vec { fn inbox(dir: &Path) -> (Store, MailboxId) { let store = Store::open(dir).unwrap(); - let account = store.add_account("ann@example.com").unwrap(); + let account = store + .add_account(&fake::account("ann@example.com")) + .unwrap(); let inbox = store.mailbox(account, "INBOX").unwrap(); (store, inbox) } diff --git a/crates/ely-mail-engine/tests/vault.rs b/crates/ely-mail-engine/tests/vault.rs index da1b441..46f76f4 100644 --- a/crates/ely-mail-engine/tests/vault.rs +++ b/crates/ely-mail-engine/tests/vault.rs @@ -67,9 +67,10 @@ fn a_refused_store_keeps_the_secret_for_this_session() { assert!(matches!(err, Error::Vault(text) if text.contains("locked"))); let err = vault.remove("a").err().unwrap(); assert!(matches!(err, Error::Vault(_))); - assert!( - vault.get("a").is_err(), - "the session copy is gone after remove" + assert_eq!( + vault.get("a").unwrap().as_deref(), + Some("pw"), + "a failed remove keeps the session copy" ); } diff --git a/crates/ely-ui/src/i18n.rs b/crates/ely-ui/src/i18n.rs index 4abcd06..867cf18 100644 --- a/crates/ely-ui/src/i18n.rs +++ b/crates/ely-ui/src/i18n.rs @@ -4,6 +4,7 @@ mod analysis; mod calc; mod data; mod doc; +mod mail; mod mark; mod mark_menus; mod motion; @@ -165,6 +166,7 @@ fn catalogs() -> (Catalog, Catalog) { .chain(office::EN) .chain(doc::EN) .chain(refs::EN) + .chain(mail::EN) .chain(tools::EN) .chain(table::EN) .chain(calc::EN) @@ -189,6 +191,7 @@ fn catalogs() -> (Catalog, Catalog) { .chain(office::ZH_CN) .chain(doc::ZH_CN) .chain(refs::ZH_CN) + .chain(mail::ZH_CN) .chain(tools::ZH_CN) .chain(table::ZH_CN) .chain(calc::ZH_CN) diff --git a/crates/ely-ui/src/i18n/mail.rs b/crates/ely-ui/src/i18n/mail.rs new file mode 100644 index 0000000..cbe1a48 --- /dev/null +++ b/crates/ely-ui/src/i18n/mail.rs @@ -0,0 +1,215 @@ +//! ElyMail's accounts. + +pub(super) const EN: &[(&str, &str)] = &[ + ("mail.menu.account", "Account"), + ("mail.add.open", "Add Account…"), + ("mail.remove.open", "Remove Account…"), + ("mail.cancel", "Cancel"), + ("mail.empty", "No mail accounts yet"), + ("mail.accounts", "Accounts"), + ("mail.add.title", "Add Account"), + ("mail.kind.gmail", "Gmail"), + ("mail.kind.microsoft", "Microsoft"), + ("mail.kind.icloud", "iCloud"), + ("mail.kind.imap", "IMAP"), + ("mail.kind.pop", "POP3"), + ("mail.add.address", "Email address"), + ("mail.add.address_hint", "Email address (optional)"), + ("mail.add.user", "User name"), + ("mail.add.password", "Password"), + ("mail.add.incoming", "Incoming server"), + ("mail.add.outgoing", "Outgoing server (SMTP)"), + ("mail.add.port", "Port"), + ("mail.add.security", "Security"), + ("mail.security.tls", "TLS"), + ("mail.security.starttls", "STARTTLS"), + ("mail.auth.title", "Sign-in"), + ("mail.auth.password", "Password"), + ("mail.auth.google", "Google"), + ("mail.auth.microsoft", "Microsoft"), + ( + "mail.icloud.steps", + "iCloud needs an app-specific password: sign in to your Apple Account, open Sign-In and Security, choose App-Specific Passwords, make one and paste it below.", + ), + ("mail.icloud.open", "Open Apple Account"), + ("mail.icloud.password", "App-specific password"), + ("mail.add.check", "Check"), + ("mail.add.sign_in", "Sign In with {method}"), + ("mail.add.save", "Add Account"), + ("mail.add.signing", "Finish signing in in your browser."), + ("mail.add.checking", "Checking both servers…"), + ("mail.add.saving", "Saving…"), + ("mail.check.incoming", "Incoming server: {result}"), + ("mail.check.outgoing", "Outgoing server: {result}"), + ("mail.check.passed", "signed in"), + ("mail.add.need_address", "Type the email address."), + ("mail.add.need_user", "Type the user name."), + ("mail.add.need_password", "Type the password."), + ("mail.add.need_host", "Type both server names."), + ("mail.add.bad_port", "{port} is not a port; use 1 to 65535."), + ("mail.add.invalid", "These settings cannot be used: {error}"), + ("mail.add.taken", "{address} is already added."), + ( + "mail.add.unavailable.google", + "Gmail sign-in is not in this build: it was built without a Google client ID.", + ), + ( + "mail.add.unavailable.microsoft", + "Microsoft sign-in is not in this build: it was built without a Microsoft client ID.", + ), + ("mail.add.sign_in_failed", "Sign-in did not finish: {error}"), + ("mail.add.check_failed", "The check did not finish: {error}"), + ("mail.add.save_failed", "The account was not saved: {error}"), + ("mail.add.added", "{address} added."), + ( + "mail.add.session_only", + "The system keychain refused the password, so it is kept only until ElyMail quits: {error}", + ), + ("mail.error.connect", "cannot connect ({detail})"), + ( + "mail.error.tls_failed", + "the secure connection failed ({detail})", + ), + ( + "mail.error.rejected", + "the server refused the sign-in: {detail}", + ), + ("mail.error.server", "the server refused: {detail}"), + ( + "mail.error.protocol", + "the server's reply cannot be read: {detail}", + ), + ("mail.error.timeout", "no answer while {detail}"), + ("mail.error.other", "{detail}"), + ( + "mail.error.tls", + "Secure connections are not available: {error}", + ), + ("mail.error.store", "The mail store is not open: {error}"), + ("mail.store.unopened", "The mail store did not open"), + ("mail.store.unread", "The accounts could not be read"), + ( + "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"), + ("mail.account.incoming", "Incoming"), + ("mail.account.outgoing", "Outgoing"), + ("mail.account.auth", "Sign-in"), + ("mail.remove.title", "Remove {address}?"), + ( + "mail.remove.message", + "This removes from this computer the {n} messages kept for it ({size}), its drafts, and its saved password or sign-in. Mail on the server is not touched.", + ), + ( + "mail.remove.message.one", + "This removes from this computer the 1 message kept for it ({size}), its drafts, and its saved password or sign-in. Mail on the server is not touched.", + ), + ("mail.remove.go", "Remove Account"), + ("mail.remove.done", "{address} removed."), + ("mail.remove.failed", "The account was not removed: {error}"), +]; + +pub(super) const ZH_CN: &[(&str, &str)] = &[ + ("mail.menu.account", "账号"), + ("mail.add.open", "添加账号…"), + ("mail.remove.open", "删除账号…"), + ("mail.cancel", "取消"), + ("mail.empty", "还没有邮件账号"), + ("mail.accounts", "账号"), + ("mail.add.title", "添加账号"), + ("mail.kind.gmail", "Gmail"), + ("mail.kind.microsoft", "Microsoft"), + ("mail.kind.icloud", "iCloud"), + ("mail.kind.imap", "IMAP"), + ("mail.kind.pop", "POP3"), + ("mail.add.address", "邮箱地址"), + ("mail.add.address_hint", "邮箱地址(可不填)"), + ("mail.add.user", "用户名"), + ("mail.add.password", "密码"), + ("mail.add.incoming", "收信服务器"), + ("mail.add.outgoing", "发信服务器(SMTP)"), + ("mail.add.port", "端口"), + ("mail.add.security", "加密"), + ("mail.security.tls", "TLS"), + ("mail.security.starttls", "STARTTLS"), + ("mail.auth.title", "登录方式"), + ("mail.auth.password", "密码"), + ("mail.auth.google", "Google"), + ("mail.auth.microsoft", "Microsoft"), + ( + "mail.icloud.steps", + "iCloud 须用应用专用密码:登录 Apple 账户,打开“登录与安全”,选“应用专用密码”,生成一个,粘贴到下面。", + ), + ("mail.icloud.open", "打开 Apple 账户"), + ("mail.icloud.password", "应用专用密码"), + ("mail.add.check", "检查"), + ("mail.add.sign_in", "以 {method} 登录"), + ("mail.add.save", "添加账号"), + ("mail.add.signing", "请在浏览器中完成登录。"), + ("mail.add.checking", "正在检查收发两个服务器…"), + ("mail.add.saving", "正在保存…"), + ("mail.check.incoming", "收信服务器:{result}"), + ("mail.check.outgoing", "发信服务器:{result}"), + ("mail.check.passed", "已登录"), + ("mail.add.need_address", "请填写邮箱地址。"), + ("mail.add.need_user", "请填写用户名。"), + ("mail.add.need_password", "请填写密码。"), + ("mail.add.need_host", "请填写两个服务器名。"), + ("mail.add.bad_port", "{port} 不是端口,请填 1 至 65535。"), + ("mail.add.invalid", "这些设置用不了:{error}"), + ("mail.add.taken", "{address} 已经添加过。"), + ( + "mail.add.unavailable.google", + "本构建没有 Gmail 登录:构建时未带 Google 客户端 ID。", + ), + ( + "mail.add.unavailable.microsoft", + "本构建没有 Microsoft 登录:构建时未带 Microsoft 客户端 ID。", + ), + ("mail.add.sign_in_failed", "登录没有完成:{error}"), + ("mail.add.check_failed", "检查没有完成:{error}"), + ("mail.add.save_failed", "账号没有保存:{error}"), + ("mail.add.added", "已添加 {address}。"), + ( + "mail.add.session_only", + "系统钥匙串拒绝了密码,它只保留到 ElyMail 退出:{error}", + ), + ("mail.error.connect", "连不上({detail})"), + ("mail.error.tls_failed", "安全连接失败({detail})"), + ("mail.error.rejected", "服务器拒绝登录:{detail}"), + ("mail.error.server", "服务器拒绝:{detail}"), + ("mail.error.protocol", "读不懂服务器的回复:{detail}"), + ("mail.error.timeout", "{detail}时没有回应"), + ("mail.error.other", "{detail}"), + ("mail.error.tls", "无法建立安全连接:{error}"), + ("mail.error.store", "邮件存储没有打开:{error}"), + ("mail.store.unopened", "邮件存储打不开"), + ("mail.store.unread", "读不出账号"), + ( + "mail.store.read_only", + "邮件存储为只读,不能添加或删除:{error}", + ), + ("mail.account.line", "{protocol} · {host}"), + ("mail.account.server", "{host}:{port},{security}"), + ("mail.account.address", "地址"), + ("mail.account.user", "用户名"), + ("mail.account.incoming", "收信"), + ("mail.account.outgoing", "发信"), + ("mail.account.auth", "登录方式"), + ("mail.remove.title", "删除 {address}?"), + ( + "mail.remove.message", + "将从本机删去它的 {n} 封缓存邮件({size})、草稿与保存的密码或登录。服务器上的邮件不动。", + ), + ( + "mail.remove.message.one", + "将从本机删去它的 1 封缓存邮件({size})、草稿与保存的密码或登录。服务器上的邮件不动。", + ), + ("mail.remove.go", "删除账号"), + ("mail.remove.done", "已删除 {address}。"), + ("mail.remove.failed", "账号没有删除:{error}"), +]; diff --git a/tasks/TASKS.md b/tasks/TASKS.md index ead873f..c7530f2 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -138,7 +138,7 @@ - [x] C12.2 MIME(MAIL-04、05 的基础):mail-parser 读出头、纯文本、HTML、cid 内嵌图片、附件与原始头;mail-builder 写出多部分邮件、附件、Message-ID、In-Reply-To 与 References;样本 EML 往返测试。已做:`read` 得 `Mail`(主题、发件人、收件人、抄送(分组展开)、日期、Message-ID、In-Reply-To、References、纯文本、HTML、`inline`、`attachments`、原始头块);按 MIME 树与所在容器读(不用 mail-parser 摊平的列表):正文只取发件人写的部分(mail-parser 会把 HTML 转成文字或反之顶替缺的一种,不取);alternative 里纯文本与 HTML 各取最后一个(RFC 2046 §5.1.4),其余版本不并入,没有正文的版本(如日历)为附件;related 的根照 `start`,无 `start` 取首个(RFC 2387),其余带 Content-ID 的即 cid 目标,不论处置;mixed 等的部分依次并成正文;处置为 attachment 的实体整个是附件,多部分实体连同其头(RFC 2183 §2.9),子孙不进正文;附件字节只解传输编码,文字附件不按字符集转换;不在 related 里的部分,带 Content-ID 且不是 attachment 的为 cid 目标;无 Content-Type 照 RFC 2045 为 text/plain,digest 的子部分为 message/rfc822;解码有问题的部分与无地址的收件人记警告。`build` 由 `Draft` 写出(密送不进信头,留在 SMTP 信封):Message-ID 为 16 字节随机数加发件人的域(mail-builder 默认写本机主机名,不用),交回供发件记录;地址照 SMTP 的 Mailbox 核对,主题、姓名、Message-ID、文件名、类型带换行即拒绝;附件须有文件名,Content-ID 照写(带换行即拒绝);正文与附件自己组成 mixed 与 alternative。`tests/fixtures/mail/sample.eml` 读全;由它回复写出再读回,各字段与附件(名、类型、Content-ID、含 0 至 255 每个字节)相同;第 1 轮审查后另 7 个结构测试(`tests/mime_tree.rs`:Latin-1 文字附件、`start` 在图后、related 里标为附件的图、附件化的多部分、同型 alternative、digest),去掉各自的修复即失败;第 2 轮审查后:嵌套深于 64 层即报错(先以迭代算出深度,原递归在 2048 层时栈溢出、进程退出);mail-parser 开 `full_encoding`(原 GB2312 等多字节字符集读成替换字符);`Part::params` 带 Content-Type 的参数(不含 name),写出时照写,转发的 Latin-1 附件收件方读出 Café;多部分与 message 附件照原样写出(不编码,7bit 或 8bit,多部分带原分界,RFC 2045 §6.4),SMTP 遇 8 位正文即带 `BODY=8BITMIME`,服务器不支持即在发出前拒绝;related 的根不看处置,其余每个资源整个是一个实体(多部分资源带其 Content-ID 与文件名);带 Content-ID 的正文文字部分另为 cid 目标(related 的根除外);未知处置照附件(RFC 2183 §2.8)。另 9 个测试(`tests/mime_edges.rs`、`tests/mime_forward.rs`,SMTP 一个),去掉各自的修复即失败;第 3 轮审查(最后一轮,修完不再审查)后:深度也数内嵌的邮件(mail-parser 以迭代读出层层相套的 message/rfc822,丢弃时却递归,8192 层即栈溢出),超深的解析结果以迭代逐层拆开再丢;带 Content-ID 的多部分实体(related 的根除外)连同其头另为 cid 目标,子孙照常读;SMTP 按每一层的头块判定是否需 SMTPUTF8(转发的 message/global 里的中文主题),只深入未经传输编码的内嵌邮件;每次发送都先解析邮件,不是邮件即拒绝。另 5 个测试(`tests/mime_deep.rs` 四个、`tests/smtp_utf8.rs` 一个;SMTP 的编码测试与辅助函数移入 `tests/smtp_utf8.rs` 与 `tests/fake/smtp.rs`),去掉各自的修复即失败或栈溢出;原往返测试按子串查 bcc,随机的 Message-ID 约四十次一次含 bcc 而误报,改为查 `Bcc:` 头行;引擎共 61 个测试 - [x] C12.3 本地存储(§12.3、§18.1、§18.3):SQLite 索引(账号、邮箱、消息身份为账号加邮箱加 UIDVALIDITY 加 UID、标记、头字段)、原文按内容存进内容目录、FTS5 全文索引(可重建)、结构版本与升级前备份、升级失败只读;各类大小可查。已做:`Store`(rusqlite,自带 SQLite 3.53.2,FTS5 与 dbstat)在给定文件夹开 `mail.db`(WAL,外键开);表 accounts、mailboxes(账号加名字唯一)、messages(身份为邮箱加 UIDVALIDITY 加 UID,POP3 为邮箱加 UIDL,两者恰居其一,各有部分唯一索引;头字段:Message-ID、In-Reply-To、References、主题、发件人、收件人、抄送(JSON)、日期;附件数在整封到达前为空)、flags(每标记一行);原文按 SHA-256 存在 `content/前两位/散列`(暂存后改名,同内容只存一份,读时核对散列,不符即报“已损坏”,最后一封引用它的邮件删去时删文件);同一身份再来头部只改标记,整封到达即补原文、附件数与正文索引;FTS5 表 `search`(主题、人、正文、附件名,无内容副本,trigram 分词,删邮件的触发器同删索引行),HTML 独有的正文以 html5ever 分词器取文字(略去 script 与 style,标签处断词);`rebuild_search` 由索引行与原文重建;`PRAGMA user_version` 记结构版本,升级前以 `VACUUM INTO` 备份到 `backups/`,各步在一个事务里,备份或升级失败即以只读重开、写入报 `ReadOnly` 并带原因,更新的版本写的库同样只读;`sizes` 给索引、全文索引(dbstat)与原文三类字节数。测试:结构版本 5 个(新库、升级先备份、失败回滚且只读、更新的版本只读、无备份不升级),存储 6 个(整封、先头后整封、UIDVALIDITY 与 POP 身份、共用原文、损坏原文、HTML 正文与重建),去掉各自的规则即失败;引擎共 72 个测试。第 1 轮审查后:重建先删去整张全文表再按库里记的定义重建(损坏的段删不掉);存原文时已有的文件先核对散列,坏了即以本次收到的字节改写;写库失败时删去本次新建且无人引用的原文;`sizes` 的索引按 `mail.db`、`-wal`、`-shm` 的实际字节减去全文页;HTML 取文字照树构建器给分词器的状态(script 为脚本数据,style、iframe、noembed、noframes 为原始文本,title 与 textarea 为 RCDATA,xmp 与 plaintext 照显示,自闭合在 HTML 元素上无效;SVG 与 MathML 里自闭合有效、script 与 style 的文字略去);给不存在的邮件设标记即报错。`tests/store_repair.rs` 6 个,去掉各自的修复即失败;引擎共 78 个测试。第 2 轮审查后:HTML 取文字改走 html5ever 的整个树构建器(`store/html.rs`,自建的节点表作 `TreeSink`,脚本关闭),命名空间、整合点与插入模式全由它定,不再自仿分词器状态;不画的元素照 HTML 渲染规则(script、style、title、head、iframe、template 等)略去;树构建器每个开始标签都扫一遍开放元素栈,嵌套越深越慢(实测 release:十万层 div 28 秒),故按 4 KB 分块喂入,元素深过 512 层(同 Blink 的上限)即停,已读的文字照常入索引,警告写明哪封邮件;暂存原文写入或改名失败时删去暂存文件,删不掉即连同原因报错。`tests/store_repair.rs` 共 8 个;去掉清理、不画元素、MathML 整合点、深度上限即各自失败。第 3 轮审查后(最后一轮,修完不再审查):删除与替换邮件时,原文文件在同一事务提交之前删去,删不掉即整步回滚,邮件与原文都留着、可重试(原先先提交再删文件,失败后失去引用,文件永久残留);提交在删文件之后失败时,下次清理遇文件已不在只记警告;两处超长注释缩短。`tests/store_release.rs`(Windows,以只读共享占住文件)2 个,删去与替换各一,改回先提交即失败;引擎共 82 个测试 - [x] C12.4 凭据与 OAuth(MAIL-02,§18.2):密码与刷新令牌只进系统凭据库(不可用即只留到本次退出并写明);Gmail 与 Microsoft 的授权码加 PKCE、本机回环、换令牌与刷新;客户端 ID 编进构建,缺 ID 即写明不可用;刷新被拒转为“需重新登录”;替身授权端点测试。已做:`Vault`(keyring:钥匙串、Windows 凭据、Secret Service;拒绝时只在内存,`Kept::Session` 带原因);`oauth::SignIn` 在 127.0.0.1 随机端口等回跳(Google 写 127.0.0.1,Microsoft 写 localhost),state 与 S256 PKCE 各 32 随机字节,回跳的 state 不符或带 error 即报错,其余请求答 404;换令牌与刷新经 ureq(后台线程,30 秒),刷新答 invalid_grant 或 interaction_required 即 `SignInAgain`,其余为 `SignIn`;邮箱取 ID 令牌的 email,Microsoft 无则 preferred_username;客户端 ID 由 build.rs 读环境变量或项目根 `oauth.env`(坏行、未知键即构建失败),缺即 `Unavailable`;`tests/oauth.rs` 以替身令牌端点与替身浏览器核对,`tests/vault.rs` 以替身凭据库核对。审查三轮:第 1 轮四处(Google 授权带 `access_type=offline` 与 `prompt=consent`、换码无刷新令牌即报错;Microsoft 另要 `profile`;`oauth.env` 读不出即构建失败、不存在为未配置;超长注释)、第 2 轮一处(ureq 在 trace 级写出令牌与客户端密钥,`ely_platform::logging` 不记 `ureq`、`ureq_proto` 低于 info 的日志;`tests/private_oauth_logs.rs` 以真日志器在 trace 下核对,改回即失败)已修,第 3 轮通过 -- [ ] C12.5 账号(MAIL-01、02):ElyMail 主窗口与添加账号对话框:Gmail、Microsoft、iCloud(应用专用密码向导)、其他 IMAP/SMTP、POP3/SMTP;服务器、端口、TLS 方式、认证方式;连通性检查逐项报结果;多账号;删除账号先写明删去的本地缓存、草稿与凭据,不动服务端 +- [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` 同);去掉修复即失败;三轮已满,未再审查 - [ ] C12.6 IMAP 同步(MAIL-14,§12.3,QA-18):文件夹列表与特殊用途;邮件头全部、正文只取 90 天内;UIDVALIDITY 变化即重建该邮箱;服务端删除与标记变化随之;后台任务执行,界面显示最后同步时间、下载范围、未缓存的数量与失败原因 - [ ] C12.7 POP3 收取(MAIL-01,§12.3):按 UIDL 去重下载到本地收件箱,“保留服务端副本”默认开,不做伪文件夹同步 - [ ] C12.8 同步控制(MAIL-14):手动刷新、运行期间定时同步、暂停、按文件夹完整离线下载 diff --git a/tasks/reviews/C12.5-r1.md b/tasks/reviews/C12.5-r1.md new file mode 100644 index 0000000..8b9ca0b --- /dev/null +++ b/tasks/reviews/C12.5-r1.md @@ -0,0 +1,11 @@ +FAIL + +1. **[P1] crates/ely-mail-engine/src/store/accounts.rs:74** — 删除账号逐个永久删除原文,后续失败时只回滚 SQLite,已删除的文件无法恢复。Windows 探针给同一账号存两份不同原文,占住删除顺序中后一份文件;`remove_account` 报共享冲突,账号和两条消息仍在,但前一份原文已丢失,`original` 报文件不存在。这违背删除失败保留数据、可重试的行为。应让整批原文删除可恢复,任一文件操作或提交失败时恢复之前处理的原文,并补上多文件中途失败的回归测试。复现见 `.work/C12.5-r1/engine-probe.log`。 + +2. **[P1] apps/ely-mail/src/view.rs:120** — 删除确认按钮实际不删除任何账号。钉住的 `ConfirmDialog` 先调用关闭回调,再调用 `on_confirm`;这里关闭时先将 `mail.removing` 清空,随后 `Mail::remove` 在 `removing.rs:49` 直接返回。窗口探针按 Tab、Tab、Enter 确认,日志记录了 confirmed 和 closed,但账号数仍为 1,凭据也没有删除。现有测试直接调用 `Mail::remove`,绕过了此路径。应把待删目标捕获并传给确认操作,使其不依赖关闭时清掉的状态,并通过真实确认按钮测试。复现见 `.work/C12.5-r1/ui-probe.log`。 + +3. **[P2] apps/ely-mail/src/adding.rs:148** — 是否自动填写端口仅按数值是否属于六个惯用端口判断,且修改任一服务器的加密方式都会重写两个端口,无法保留手填值。探针在 IMAP 表单手填收信端口 995,再仅将 SMTP 从 STARTTLS 改成 TLS,收信端口即被静默改为 993。自定义端口的账号因此会检查错误的端点,也与 C12.5 所述“手填的端口不动”不符。应逐字段记录自动值与用户编辑状态,只更新相应服务器尚未手填的端口,并覆盖手填值恰为其他协议惯用端口的测试。复现见 `.work/C12.5-r1/ui-probe.log`。 + +4. **[P2] apps/ely-mail/src/flow.rs:239** — 保存结果未绑定发起保存的对话框,会清掉后来打开的另一份添加表单。保存账号 A 的后台库操作尚未结束时,用户仍可取消对话框、重新添加并填写账号 B;A 保存完成后无条件执行 `mail.adding = None`,B 的输入全部消失。探针用存储锁延迟 A 的保存,重开并填写 B 后释放锁,稳定复现。失败回调的 `halt` 同样会改写新对话框。应将保存回调绑定其所属对话框;账号列表照常更新,只对仍匹配的表单更新状态或关闭。复现见 `.work/C12.5-r1/ui-probe.log`。 + +5. **[P3] apps/ely-mail/src/mail.rs:1;apps/ely-mail/src/removing.rs:1** — 两条新增模块注释各有 11 个词,超过项目规定的十词上限。运行 `python tests/scripts/comments.py` 会将两处列为失败。应将它们缩短到十词以内。检查输出见 `.work/C12.5-r1/comments.log`。 diff --git a/tasks/reviews/C12.5-r2.md b/tasks/reviews/C12.5-r2.md new file mode 100644 index 0000000..79355bc --- /dev/null +++ b/tasks/reviews/C12.5-r2.md @@ -0,0 +1,7 @@ +FAIL + +1. **[P2] apps/ely-mail/src/removing.rs:62** — 凭据先被永久删除,随后本地账号删除失败时没有恢复。Windows 探针以只读共享占住原文,再通过真实确认按钮删除:文件操作失败后账号和原文仍在,但 `vault.get` 已为 `None`,留下无法继续登录的账号;界面只报账号未删除。应在存储失败时恢复原有凭据,保持失败后的账号可用,并覆盖文件删除失败与只读存储的回归路径。复现见 `.work/C12.5-r2/ui-probe.log`。 + +2. **[P2] apps/ely-mail/src/side.rs:28** — 账号行使用只有鼠标点击的 `ListItem`,没有 Tab 停靠或键盘选择。已有两个账号时,键盘用户无法切到第二个账号,也无法操作它的删除;窗口探针循环 Tab、方向键、Enter 后仍选中第一个账号。此处违反项目对可按压元素的焦点规则。应使用组件库带键盘导航的列表,把选择事件接到 `pick`,并添加键盘切换账号的窗口测试。复现见 `.work/C12.5-r2/ui-probe.log`。 + +3. **[P2] crates/ely-mail-engine/src/store/accounts.rs:23** — 存储层按原样插入地址,只防止大小写完全相同的重复;界面不分大小写的检查只看已返回的账号列表。第一次保存 `ann@example.com` 尚未完成时,取消并重新添加 `ANN@example.com`,两次检查都能通过,两个保存最终都成功。探针用存储锁延迟保存,确认重现两个重复账号,违背 C12.5 声称的同地址不重复添加。应在存储层原子地执行与界面一致的地址唯一约束,并覆盖两次保存尚未返回的情形。复现见 `.work/C12.5-r2/ui-probe.log`。 diff --git a/tasks/reviews/C12.5-r3.md b/tasks/reviews/C12.5-r3.md new file mode 100644 index 0000000..131bd30 --- /dev/null +++ b/tasks/reviews/C12.5-r3.md @@ -0,0 +1,3 @@ +FAIL + +1. **[P2] apps/ely-mail/src/removing.rs:63** — 凭据删除自身失败时仍会丢失本次会话的密码或刷新令牌。`Vault::remove` 先移除内存副本,再尝试系统凭据库;这里的 `?` 直接返回,绕过后面的凭据恢复。Windows 窗口探针让凭据库拒绝写入与删除,添加账号后确认密码仍在本次会话,再通过真实确认按钮删除:账号保留,但 `vault.get` 已为 `None`,未退出应用就失去登录凭据。第 2 轮修复只覆盖了本地数据库删除失败。应让凭据删除失败也保留或恢复先前读出的会话凭据,并补上凭据库不可用时添加账号、删除失败后仍能取回凭据的回归测试。复现见 `.work/C12.5-r3/ui-probe.log`。