C12.5: mail accounts, add and remove

ElyMail's window lists accounts and adds Gmail, Microsoft, iCloud
(app-specific password), IMAP and POP3 ones; a check logs in to both
servers and reports each. Accounts live in the store as JSON with a
case-insensitive unique address; secrets go to the keychain. Removal
names the cache it deletes, takes the secret first and puts it back
when local removal fails. Originals are set aside until commit and
restored after a crash. Requests are numbered so late results stay
with their own dialog. The account list is keyboard-navigable.
This commit is contained in:
雷电芽衣 committed 2026-10-19 20:17:19 -04:00
1 parent 5068bc07ca
commit 1c225f6812
39 files changed
+3152 -105

No files matched your search

Generated
+6
View File
@@ -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]]
+1 -1
View File
@@ -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 标签、链接地址与引用标签、图片、脚注引用,`<code>`、`<kbd>` 之间(含嵌套;交叉闭合照可视的读法关闭与重开)的文字照原文;`\\(` 不换。公式里与段落里另有的 `$`(含 `\$`)解析时暂写成文本里没有的控制字节,解析后还原,不与换上的 `$` 配对;相邻的两个公式之间暂插一个空格,解析后去掉,仍是两个公式。公式节点的范围是整对分隔符,值是写下的 TeX 原文。独占一段的 `\[…\]` 为块公式(段首可有一至三个空格)。组件库公开 `documents::markdown_tree`,ElyMark 的 HTML、PDF、纯文本导出都经它解析(`markdown_options` 不再公开),纯文本里独占一段的公式照块公式写其 TeX。同时查出(组件库 e095f06):RaTeX 的 SVG 以 pt 定尺寸,usvg 按 px 量墨迹,两者差 4/3,每个公式的画面右侧与下方多出约四分之一的空白(块公式因此偏左)、导出的公式比正文大 4/3;现生成时即把根改为 px。`markdown/tex/tests.rs` 与 `tree/tests.rs` 核对分隔符的各种情形(相邻、`\$`、链接地址、`<code>`、跨块),`math_text.rs` 的窗口测试核对公式排进并读回原文、折到第二行的公式画在第二行首(改回按起点定位即失败)、排不出的公式写出原因(组件库 a20e712、5fba7b3、de961eb),`math/tests.rs` 核对画面与公式同宽(改回 pt 即失败),ElyMark 的 `visual.rs` 核对导出与纯文本。Windows 真窗口(`.work/r8/mark/visual2.png`):`$…$`、`\(…\)` 排在行内,`\[…\]` 居中成块,行内代码与转义的 `\\(` 照原文。
- 2026-10-19(用户定):ElyMail 的后端改为 Rust crate,取代 MailCore 2(其 Windows 构建是 VS 2013 工程加 2015 年前后的预编译依赖,POP 无 OAuth2):mail-send、mail-parser、mail-builder,在应用进程的后台任务里运行,不另起 worker;MailCore 2 与 libetpan 的取码、构建与探测随之删去。正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制,不当作完整页面;写信用 Markdown 文档组件,发出纯文本与 HTML。Gmail 与 Microsoft 走 OAuth(授权码加 PKCE,本机回环),客户端 ID 在构建时注入:`ELY_GOOGLE_CLIENT_ID`、`ELY_GOOGLE_CLIENT_SECRET`、`ELY_MICROSOFT_CLIENT_ID`,本机写在项目根的 `oauth.env`(不入库),CI 为仓库机密;缺 ID 的构建里该登录写明不可用,不退回别的方式。iCloud 不给第三方 OAuth,走应用专用密码向导(`imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)。注册由用户做(Google Desktop 客户端,Entra 公共客户端,委托权限 IMAP、POP、SMTP 与 offline_access)。POP3(C12.1 审查后)由引擎自写,不用 async-pop:它留着点填充、丢末尾 CRLF、吞掉登录后的连接错误、不答 XOAUTH2 的错误挑战、在 trace 日志里写出密码。IMAP(第 2 轮审查后)同样自写,以 imap-proto 解析回复,不用 async-imap:它的 LIST、FETCH 等流式命令不看结尾的 NO 与 BAD(拒绝成了空结果),登录与命令中途的 `* BYE` 被丢掉(只剩断线),trace 日志写出密码与邮件。SMTP 的事务(MAIL、RCPT、DATA)也由引擎自走:mail-send 不给首行的点加点;正文有 8 位字节即带 `BODY=8BITMIME`,服务器不支持即拒绝。读信按 MIME 树与所在容器(C12.2),嵌套(多部分与内嵌邮件都算)深于 64 层即报错,超深的解析结果以迭代拆开再丢(直接丢弃会递归、栈溢出);mail-parser 开 `full_encoding`;SMTPUTF8 按每一层的头块判定。本地存储(C12.3):rusqlite 自带的 SQLite,`mail.db` 加内容目录(原文按 SHA-256),FTS5(trigram)可由索引与原文重建;`user_version` 记结构版本,升级前备份,失败即只读;原文文件在删去或替换邮件的事务提交前删去,删不掉即回滚。只有 HTML 的邮件经 html5ever 的树构建器取文字入索引,元素深过 512 层即停读(树构建器的开放元素栈扫描随深度平方增长)。凭据(C12.4):密码与刷新令牌只进系统凭据库(keyring),被拒即只留在内存到退出;OAuth 换令牌与刷新经 ureq,回跳在 127.0.0.1 随机端口;Google 授权带 `access_type=offline` 与 `prompt=consent`(每次同意都发刷新令牌),Microsoft 另要 `profile`(无 `email` 时以 `preferred_username` 为地址),换码没有刷新令牌即报错;客户端 ID 由 `crates/ely-mail-engine/build.rs` 取环境变量或 `oauth.env`,该文件不存在即未配置,读不出或写错即构建失败。ureq 在 trace 级逐字节记下请求与回复(令牌、客户端密钥),`ely_platform::logging` 在任何 `ELY_LOG` 下都不记 `ureq`、`ureq_proto` 低于 info 的日志。
- 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` 的 `<slug>.json`,清单的下载地址为 `github.com/ELY-Open-Source/ElyOffice/releases/download/v<版本>/<文件>`;0.1.8 及以前装好的应用仍查 Gitea,需手动装一次新版。官网 worker 读 GitHub API(可选机密 `GITHUB_TOKEN` 避开共享出口地址的限流),下载与源码链接指向 GitHub(GitHub 标志);macOS 版已公证,去掉“未经公证”的提示。发布视频的源文件与成片里仍是 Gitea 地址,未改未重渲染。
- 2026-10-19(用户定):自有代码由 AGPL-3.0-or-later 改为 MIT(`LICENSE`,Copyright (c) 2026 Yang Zhang;工作区 `Cargo.toml`、Linux 包的许可字段、`docs/licenses.md`、官网)。第三方照旧:LibreOffice 与 `patches/libreoffice` 为 MPL-2.0 等,Git 为 GPL-2.0,MuPDF 为 AGPL-3.0;ElyPDF 链接 MuPDF,其发行包整体仍须按 AGPL 履行,Linux 包的许可字段据此写为 `MIT AND <引擎许可>`。
+11
View File
@@ -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
+270
View File
@@ -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<Mail>,
id: &'static str,
picked: &str,
options: impl IntoIterator<Item = (&'static str, SharedString)>,
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<Self>) -> Option<AnyElement> {
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<Mail>)) = 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<Mail>, 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<AnyElement> {
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)])
}
+253
View File
@@ -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<TextInput>,
pub user: Entity<TextInput>,
pub password: Entity<TextInput>,
pub incoming: Entity<TextInput>,
pub incoming_port: Entity<TextInput>,
pub outgoing: Entity<TextInput>,
pub outgoing_port: Entity<TextInput>,
}
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<u64>,
pub task: Option<AbortHandle>,
_edits: Vec<Subscription>,
}
impl Drop for Adding {
fn drop(&mut self) {
self.stop();
}
}
impl Adding {
pub fn new(kind: Kind, window: &mut Window, cx: &mut Context<Mail>) -> Self {
let mut field = |key: &str, masked: bool, cx: &mut Context<Mail>| {
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<TextInput>, 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<TextInput>, port: &Entity<TextInput>, 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<Account, SharedString> {
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<String, SharedString> {
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)
}
}
+288
View File
@@ -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<Self>) {
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>) {
self.adding = None;
cx.notify();
}
/// A field changed: an earlier check no longer holds.
pub fn edited(&mut self, cx: &mut Context<Self>) {
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<u64> {
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<Self>) {
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<Self>) -> Option<SharedString> {
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<Self>) {
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<Self>) {
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<Checked, String>,
cx: &mut Context<Self>,
) {
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<Self>) {
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<Self>) {
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<Self>) {
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())],
),
}
}
+86
View File
@@ -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<Kind> {
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<Protocol>, 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,
}
}
+215
View File
@@ -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<Store, String>,
pub vault: Vault,
pub tls: Result<TlsConnector, String>,
}
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<Arc<Mutex<Store>>, String>,
pub read_only: Option<String>,
pub vault: Arc<Vault>,
pub tls: Result<TlsConnector, String>,
runtime: Arc<Runtime>,
/// None until read.
pub accounts: Option<Result<Vec<(AccountId, Account)>, String>>,
pub picked: Option<AccountId>,
pub adding: Option<Adding>,
pub removing: Option<Removing>,
/// Numbers every request across dialogs.
pub asked: u64,
pub focus: FocusHandle,
pub root: WeakEntity<Root>,
pub side: Entity<Side>,
}
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<Root> {
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>) -> 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>) {
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>) {
self.picked = Some(id);
self.notify(cx);
}
/// Redraws the window and its account list.
pub fn notify(&self, cx: &mut Context<Self>) {
self.side.update(cx, |_, cx| cx.notify());
cx.notify();
}
/// Store work off the window's thread.
pub fn with_store<R: Send + 'static>(
&self,
cx: &mut Context<Self>,
work: impl FnOnce(&mut Store) -> ely_mail_engine::Result<R> + Send + 'static,
then: impl FnOnce(&mut Mail, Result<R, String>, &mut Context<Mail>) + '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<R: Send + 'static>(
&self,
cx: &mut Context<Self>,
work: impl Future<Output = R> + Send + 'static,
then: impl FnOnce(&mut Mail, Result<R, String>, &mut Context<Mail>) + '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);
}
}
+14 -1
View File
@@ -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);
}
+16
View File
@@ -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<Menu> {
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),
])]
}
+90
View File
@@ -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<Self>) {
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<Self>) {
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();
}
}
+51
View File
@@ -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<Mail>,
}
impl Side {
pub fn new(mail: WeakEntity<Mail>) -> Self {
Self { mail }
}
}
impl Render for Side {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(mail) = self.mail.upgrade() else {
return div();
};
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)
}
}
+176
View File
@@ -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<Mutex<HashMap<String, String>>>,
pub refuse: Option<&'static str>,
pub deletes: Arc<AtomicUsize>,
}
impl Backend for Keys {
fn get(&self, name: &str) -> Result<Option<String>, 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<Mail>, &'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<Mail>, 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<Mail>,
cx: &mut VisualTestContext,
pick: fn(&crate::adding::Fields) -> &Entity<ely_gpui_component::forms::TextInput>,
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<Mail>,
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<Mail>, 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(),
},
)
}
+470
View File
@@ -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<Mail>, 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);
}
+186
View File
@@ -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<Self>) -> 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<Self>) -> Option<AnyElement> {
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<Self>) -> 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))
}
}
+123
View File
@@ -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<Result<oauth::Provider>> {
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",
}
}
+3
View File
@@ -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"),
+3 -1
View File
@@ -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};
+4 -2
View File
@@ -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<TcpStream>;
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,
+4 -15
View File
@@ -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<AccountId> {
self.writable()?;
self.conn
.execute("INSERT INTO accounts (address) VALUES (?1)", [address])?;
Ok(AccountId(self.conn.last_insert_rowid()))
}
pub fn accounts(&self) -> Result<Vec<(AccountId, String)>> {
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::<rusqlite::Result<_>>()?)
}
/// The mailbox by name, made on first use.
pub fn mailbox(&self, account: AccountId, name: &str) -> Result<MailboxId> {
let find = "SELECT id FROM mailboxes WHERE account = ?1 AND name = ?2";
@@ -155,6 +143,7 @@ fn open(dir: &Path, steps: &[&str]) -> Result<Store> {
}
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,
@@ -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<AccountId> {
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<Vec<(AccountId, Account)>> {
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<Cached> {
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<Vec<String>> {
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::<rusqlite::Result<_>>()?)
}
}
+139
View File
@@ -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<String>,
}
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<T>(self, done: Result<T>) -> Result<T> {
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(())
}
+56 -69
View File
@@ -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<String> = 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<MessageId> {
let mut old: Option<String> = 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<Option<String>> {
@@ -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 {
+2 -1
View File
@@ -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,
+21 -5
View File
@@ -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);
+4 -2
View File
@@ -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<String, String>> {
+200
View File
@@ -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<tokio::task::JoinHandle<Result<(), String>>>) {
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}");
}
}
+73
View File
@@ -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);
}
+18 -1
View File
@@ -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(),
+5 -1
View File
@@ -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)
}
@@ -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)
}
+5 -1
View File
@@ -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<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)
}
+4 -3
View File
@@ -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"
);
}
+3
View File
@@ -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)
+215
View File
@@ -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}"),
];
+1 -1
View File
@@ -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_Line truncated
- [ ] C12.6 IMAP 同步(MAIL-14,§12.3,QA-18):文件夹列表与特殊用途;邮件头全部、正文只取 90 天内;UIDVALIDITY 变化即重建该邮箱;服务端删除与标记变化随之;后台任务执行,界面显示最后同步时间、下载范围、未缓存的数量与失败原因
- [ ] C12.7 POP3 收取(MAIL-01,§12.3):按 UIDL 去重下载到本地收件箱,“保留服务端副本”默认开,不做伪文件夹同步
- [ ] C12.8 同步控制(MAIL-14):手动刷新、运行期间定时同步、暂停、按文件夹完整离线下载
+11
View File
@@ -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`。
+7
View File
@@ -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`。
+3
View File
@@ -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`。