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