C12.2: read and write MIME by the tree
The reader walks the MIME tree with its container context: alternative picks versions, related finds its root by start, attached multiparts stay whole, digest children are messages. Nesting deeper than 64 levels, counting enclosed messages, is refused, and over-deep parse results are taken apart iteratively so dropping them cannot overflow the stack. Composite parts with a Content-ID are cid targets. The writer keeps attachment parameters and Content-IDs, writes multipart and message attachments unencoded, and makes its own Message-ID. SMTP adds BODY=8BITMIME for 8-bit bodies and SMTPUTF8 when any header block at any depth is 8-bit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9f47b94714
commit
fe90221c11
23 files changed
+1672
-136
No files matched your search
Generated
+31
@@ -2135,8 +2135,11 @@ version = "0.1.8"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"ely-platform",
|
||||
"getrandom 0.4.3",
|
||||
"imap-proto",
|
||||
"log",
|
||||
"mail-builder",
|
||||
"mail-parser",
|
||||
"mail-send",
|
||||
"nom 8.0.0",
|
||||
"rcgen",
|
||||
@@ -3595,6 +3598,18 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashify"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.9.3"
|
||||
@@ -4556,6 +4571,22 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mail-builder"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccbc27cb02a58a311a404769a22f5abf63fe3173b290d80be7c534f9c7023151"
|
||||
|
||||
[[package]]
|
||||
name = "mail-parser"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ec00bda90c6e645a54506c630c2820cd6b1890cfd2b0a169b50f74b2b8c7c86"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hashify",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mail-send"
|
||||
version = "0.6.2"
|
||||
|
||||
@@ -58,6 +58,8 @@ security-framework = "3.7"
|
||||
objc2-pdf-kit = { version = "0.3", default-features = false, features = ["PDFDocument", "PDFPage", "objc2-app-kit"] }
|
||||
imap-proto = "0.17"
|
||||
nom = "8"
|
||||
mail-builder = { version = "1.0", default-features = false }
|
||||
mail-parser = { version = "0.11", features = ["full_encoding"] }
|
||||
mail-send = { version = "0.6", default-features = false, features = ["ring", "tls12"] }
|
||||
rcgen = "0.14"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "tls12", "std"] }
|
||||
|
||||
@@ -33,7 +33,7 @@ assets/ icons/ 是八个应用的图标母版(SVG)与渲染出的 icns
|
||||
dist/ 打包产物,不入库
|
||||
docs/ 兼容清单、无障碍、帮助、许可、性能记录;engineering-risks.md、qa.md 等内部笔记只在本机,不入库
|
||||
scripts/ fetch-references.sh 取上游 doctor.sh 查工具 review.sh 开分栏审查 build-*.sh 构建引擎
|
||||
tests/ scripts 与 engines 下是脚本测试;engines 缺二进制时报 SKIP;fixtures 是测试共用的样本(media/clip.mp4:1 秒 H.264/AAC;pdf/aes128.pdf:AES-128 加密,打开密码 user、所有者密码 owner;pdf/signer.p12 与 signer.der:自签名测试证书,密码 ely)
|
||||
tests/ scripts 与 engines 下是脚本测试;engines 缺二进制时报 SKIP;fixtures 是测试共用的样本(media/clip.mp4:1 秒 H.264/AAC;pdf/aes128.pdf:AES-128 加密,打开密码 user、所有者密码 owner;pdf/signer.p12 与 signer.der:自签名测试证书,密码 ely;mail/sample.eml:带编码主题、分组抄送、纯文本加 HTML、cid 图片与附件的邮件,LF 行尾,测试换成 CRLF)
|
||||
tasks/ TASKS.md 进度真相
|
||||
site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木;中英两种文案在 src/text.ts);site/video 是发布视频的分镜、配乐、录屏与合成脚本,产物在 site/video/out(不入库)
|
||||
```
|
||||
@@ -320,4 +320,4 @@ 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 不给首行的点加点。
|
||||
- 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 按每一层的头块判定。
|
||||
@@ -7,9 +7,12 @@ license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
imap-proto.workspace = true
|
||||
base64.workspace = true
|
||||
getrandom.workspace = true
|
||||
imap-proto.workspace = true
|
||||
log.workspace = true
|
||||
mail-builder.workspace = true
|
||||
mail-parser.workspace = true
|
||||
mail-send.workspace = true
|
||||
nom.workspace = true
|
||||
rustls.workspace = true
|
||||
|
||||
@@ -19,6 +19,8 @@ pub enum Error {
|
||||
Timeout(&'static str),
|
||||
/// An address or login the protocol cannot carry.
|
||||
Invalid(String),
|
||||
/// A message that cannot be read or written.
|
||||
Mime(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
@@ -31,6 +33,7 @@ impl fmt::Display for Error {
|
||||
Self::Protocol(text) => write!(f, "unreadable reply: {text}"),
|
||||
Self::Timeout(step) => write!(f, "no answer while {step}"),
|
||||
Self::Invalid(text) => write!(f, "cannot be sent: {text}"),
|
||||
Self::Mime(text) => write!(f, "unreadable message: {text}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod error;
|
||||
mod imap;
|
||||
mod mime;
|
||||
mod net;
|
||||
mod pop;
|
||||
mod smtp;
|
||||
@@ -9,6 +10,7 @@ mod wire;
|
||||
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
use mail_builder::{
|
||||
MessageBuilder,
|
||||
headers::{address::Address as Out, content_type::ContentType, message_id::MessageId},
|
||||
mime::MimePart,
|
||||
};
|
||||
use mail_parser::{Address, HeaderValue, MessageParser};
|
||||
|
||||
use crate::{Error, Result, smtp::mailbox};
|
||||
|
||||
mod walk;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Mailbox {
|
||||
pub name: Option<String>,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// An attachment or a `cid:` target.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Part {
|
||||
pub name: Option<String>,
|
||||
pub content_type: String,
|
||||
/// Content-Type parameters, such as charset.
|
||||
pub params: Vec<(String, String)>,
|
||||
pub content_id: Option<String>,
|
||||
/// Transfer-decoded; a multipart keeps its headers.
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A message as read; bodies are the sender's own parts.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Mail {
|
||||
pub subject: Option<String>,
|
||||
pub from: Vec<Mailbox>,
|
||||
pub to: Vec<Mailbox>,
|
||||
pub cc: Vec<Mailbox>,
|
||||
/// Seconds since the Unix epoch.
|
||||
pub date: Option<i64>,
|
||||
pub message_id: Option<String>,
|
||||
pub in_reply_to: Vec<String>,
|
||||
pub references: Vec<String>,
|
||||
pub text: Option<String>,
|
||||
pub html: Option<String>,
|
||||
pub inline: Vec<Part>,
|
||||
pub attachments: Vec<Part>,
|
||||
/// The header block as it arrived.
|
||||
pub headers: String,
|
||||
}
|
||||
|
||||
/// A message to write; Bcc stays in the SMTP envelope.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Draft {
|
||||
pub from: Mailbox,
|
||||
pub to: Vec<Mailbox>,
|
||||
pub cc: Vec<Mailbox>,
|
||||
pub subject: String,
|
||||
pub text: String,
|
||||
pub html: Option<String>,
|
||||
pub attachments: Vec<Part>,
|
||||
pub in_reply_to: Option<String>,
|
||||
pub references: Vec<String>,
|
||||
/// Seconds since the Unix epoch.
|
||||
pub date: i64,
|
||||
}
|
||||
|
||||
pub struct Built {
|
||||
/// Without angle brackets, as `Mail::message_id` reads it.
|
||||
pub message_id: String,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn read(raw: &[u8]) -> Result<Mail> {
|
||||
let message = MessageParser::default()
|
||||
.parse(raw)
|
||||
.ok_or_else(|| Error::Mime("not a mail message".into()))?;
|
||||
let problems = message
|
||||
.parts
|
||||
.iter()
|
||||
.filter(|part| part.is_encoding_problem)
|
||||
.count();
|
||||
if problems > 0 {
|
||||
log::warn!("mail: {problems} MIME parts did not decode cleanly");
|
||||
}
|
||||
if let Err(err) = walk::depth(&message) {
|
||||
walk::dispose(message);
|
||||
return Err(err);
|
||||
}
|
||||
let header_end = message.root_part().offset_body as usize;
|
||||
let shape = walk::shape(&message);
|
||||
Ok(Mail {
|
||||
subject: message.subject().map(str::to_owned),
|
||||
from: mailboxes(message.from().into_iter()),
|
||||
to: mailboxes(message.all_to()),
|
||||
cc: mailboxes(message.all_cc()),
|
||||
date: message.date().map(|date| date.to_timestamp()),
|
||||
message_id: message.message_id().map(str::to_owned),
|
||||
in_reply_to: ids(message.in_reply_to()),
|
||||
references: ids(message.references()),
|
||||
text: joined(shape.text),
|
||||
html: joined(shape.html),
|
||||
inline: shape.inline,
|
||||
attachments: shape.attachments,
|
||||
headers: String::from_utf8_lossy(&raw[..header_end.min(raw.len())]).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether any header block needs SMTPUTF8.
|
||||
pub(crate) fn utf8_headers(raw: &[u8]) -> Result<bool> {
|
||||
let message = MessageParser::default()
|
||||
.parse(raw)
|
||||
.ok_or_else(|| Error::Mime("not a mail message".into()))?;
|
||||
let checked = walk::depth(&message).map(|()| walk::utf8_headers(&message));
|
||||
walk::dispose(message);
|
||||
checked
|
||||
}
|
||||
|
||||
pub fn build(draft: &Draft) -> Result<Built> {
|
||||
let people = [&draft.from].into_iter().chain(&draft.to).chain(&draft.cc);
|
||||
for person in people {
|
||||
mailbox(&person.email)?;
|
||||
line("a name", person.name.as_deref().unwrap_or_default())?;
|
||||
}
|
||||
line("the subject", &draft.subject)?;
|
||||
for id in draft.in_reply_to.iter().chain(&draft.references) {
|
||||
line("a message ID", id)?;
|
||||
}
|
||||
let message_id = message_id(&draft.from.email)?;
|
||||
let mut builder = MessageBuilder::new()
|
||||
.message_id(message_id.as_str())
|
||||
.date(draft.date)
|
||||
.from(address(&draft.from))
|
||||
.subject(draft.subject.as_str())
|
||||
.body(body(draft)?);
|
||||
if !draft.to.is_empty() {
|
||||
builder = builder.to(Out::new_list(draft.to.iter().map(address).collect()));
|
||||
}
|
||||
if !draft.cc.is_empty() {
|
||||
builder = builder.cc(Out::new_list(draft.cc.iter().map(address).collect()));
|
||||
}
|
||||
if let Some(id) = &draft.in_reply_to {
|
||||
builder = builder.in_reply_to(id.as_str());
|
||||
}
|
||||
if !draft.references.is_empty() {
|
||||
let ids = draft.references.iter().map(String::as_str);
|
||||
builder = builder.references(MessageId::new_list(ids));
|
||||
}
|
||||
let bytes = builder
|
||||
.write_to_vec()
|
||||
.map_err(|err| Error::Mime(err.to_string()))?;
|
||||
Ok(Built { message_id, bytes })
|
||||
}
|
||||
|
||||
/// Mixed of the alternative bodies and the attachments.
|
||||
fn body(draft: &Draft) -> Result<MimePart<'_>> {
|
||||
let text = MimePart::new("text/plain", draft.text.as_str());
|
||||
let body = match &draft.html {
|
||||
Some(html) => {
|
||||
let html = MimePart::new("text/html", html.as_str());
|
||||
MimePart::new("multipart/alternative", vec![text, html])
|
||||
}
|
||||
None => text,
|
||||
};
|
||||
if draft.attachments.is_empty() {
|
||||
return Ok(body);
|
||||
}
|
||||
let mut parts = vec![body];
|
||||
for part in &draft.attachments {
|
||||
parts.push(attachment(part)?);
|
||||
}
|
||||
Ok(MimePart::new("multipart/mixed", parts))
|
||||
}
|
||||
|
||||
fn attachment(part: &Part) -> Result<MimePart<'_>> {
|
||||
let name = part
|
||||
.name
|
||||
.as_deref()
|
||||
.ok_or_else(|| Error::Invalid("an attachment needs a file name".into()))?;
|
||||
line("a file name", name)?;
|
||||
line("a content type", &part.content_type)?;
|
||||
let mut kind = ContentType::new(part.content_type.as_str());
|
||||
for (key, value) in &part.params {
|
||||
line("a content type parameter", key)?;
|
||||
line("a content type parameter", value)?;
|
||||
kind = kind.attribute(key.as_str(), value.as_str());
|
||||
}
|
||||
let composite = ["multipart/", "message/"]
|
||||
.iter()
|
||||
.any(|prefix| part.content_type.starts_with(prefix));
|
||||
let mut attachment = if composite {
|
||||
// RFC 2045 §6.4: composites stay unencoded.
|
||||
let body = composite_body(part)?;
|
||||
let encoding = if body.is_ascii() { "7bit" } else { "8bit" };
|
||||
MimePart::new(kind, body).transfer_encoding(encoding)
|
||||
} else {
|
||||
MimePart::new(kind, part.bytes.as_slice())
|
||||
}
|
||||
.attachment(name);
|
||||
if let Some(id) = &part.content_id {
|
||||
line("a content ID", id)?;
|
||||
attachment = attachment.cid(id.as_str());
|
||||
}
|
||||
Ok(attachment)
|
||||
}
|
||||
|
||||
/// A multipart as read carries its headers.
|
||||
fn composite_body(part: &Part) -> Result<&[u8]> {
|
||||
if !part.content_type.starts_with("multipart/") {
|
||||
return Ok(&part.bytes);
|
||||
}
|
||||
if !part.params.iter().any(|(key, _)| key == "boundary") {
|
||||
return Err(Error::Invalid(
|
||||
"a multipart attachment has no boundary".into(),
|
||||
));
|
||||
}
|
||||
if let Some(body) = part.bytes.strip_prefix(b"\r\n") {
|
||||
return Ok(body);
|
||||
}
|
||||
part.bytes
|
||||
.windows(4)
|
||||
.position(|end| end == b"\r\n\r\n")
|
||||
.map(|at| &part.bytes[at + 4..])
|
||||
.ok_or_else(|| Error::Invalid("a multipart attachment has no header end".into()))
|
||||
}
|
||||
|
||||
/// A random left side at the sender's domain.
|
||||
fn message_id(from: &str) -> Result<String> {
|
||||
let (_, domain) = from
|
||||
.rsplit_once('@')
|
||||
.ok_or_else(|| Error::Invalid(format!("not a mail address: {from:?}")))?;
|
||||
let mut random = [0u8; 16];
|
||||
getrandom::fill(&mut random).map_err(|err| Error::Mime(err.to_string()))?;
|
||||
let left: String = random.iter().map(|byte| format!("{byte:02x}")).collect();
|
||||
Ok(format!("{left}@{domain}"))
|
||||
}
|
||||
|
||||
/// A line break would start a new header.
|
||||
fn line(what: &str, text: &str) -> Result<()> {
|
||||
if text.contains(['\r', '\n']) {
|
||||
return Err(Error::Invalid(format!("{what} has a line break")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn address(mailbox: &Mailbox) -> Out<'_> {
|
||||
Out::new_address(mailbox.name.as_deref(), mailbox.email.as_str())
|
||||
}
|
||||
|
||||
fn mailboxes<'a>(lists: impl Iterator<Item = &'a Address<'a>>) -> Vec<Mailbox> {
|
||||
lists
|
||||
.flat_map(Address::iter)
|
||||
.filter_map(|addr| {
|
||||
let Some(email) = addr.address.as_deref() else {
|
||||
log::warn!("mail: an address without an email was skipped");
|
||||
return None;
|
||||
};
|
||||
Some(Mailbox {
|
||||
name: addr.name.as_deref().map(str::to_owned),
|
||||
email: email.to_owned(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ids(value: &HeaderValue) -> Vec<String> {
|
||||
match value {
|
||||
HeaderValue::Text(id) => vec![id.to_string()],
|
||||
HeaderValue::TextList(ids) => ids.iter().map(ToString::to_string).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn joined(parts: Vec<String>) -> Option<String> {
|
||||
(!parts.is_empty()).then(|| parts.join("\n"))
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use mail_parser::{
|
||||
Encoding, Message, MessagePart, MimeHeaders, PartType,
|
||||
decoders::{base64::base64_decode, quoted_printable::quoted_printable_decode},
|
||||
};
|
||||
|
||||
use super::Part;
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Deeper trees are refused before the walk.
|
||||
const DEPTH: usize = 64;
|
||||
|
||||
/// What a MIME tree shows: bodies, `cid:` targets, attachments.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Shape {
|
||||
pub text: Vec<String>,
|
||||
pub html: Vec<String>,
|
||||
pub inline: Vec<Part>,
|
||||
pub attachments: Vec<Part>,
|
||||
}
|
||||
|
||||
pub(super) fn shape(message: &Message) -> Shape {
|
||||
let mut shape = Shape::default();
|
||||
Walk { message }.body(0, &mut shape);
|
||||
shape
|
||||
}
|
||||
|
||||
/// Iterative, so no tree can exhaust the stack.
|
||||
pub(super) fn depth(message: &Message) -> Result<()> {
|
||||
let mut stack = vec![(message, 0, 1)];
|
||||
while let Some((message, id, level)) = stack.pop() {
|
||||
if level > DEPTH {
|
||||
return Err(Error::Mime(format!("MIME parts nest deeper than {DEPTH}")));
|
||||
}
|
||||
match &message.parts[id as usize].body {
|
||||
PartType::Multipart(children) => {
|
||||
stack.extend(children.iter().map(|&child| (message, child, level + 1)));
|
||||
}
|
||||
PartType::Message(inner) => stack.push((inner, 0, level + 1)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drops a deep tree without recursion.
|
||||
pub(super) fn dispose(message: Message) {
|
||||
let mut stack = vec![message];
|
||||
while let Some(mut message) = stack.pop() {
|
||||
for part in &mut message.parts {
|
||||
if let PartType::Message(inner) = std::mem::take(&mut part.body) {
|
||||
stack.push(inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 6531 §3.4: 8-bit header bytes at any depth.
|
||||
pub(super) fn utf8_headers(message: &Message) -> bool {
|
||||
let mut stack = vec![message];
|
||||
while let Some(message) = stack.pop() {
|
||||
// Part offsets index the whole buffer.
|
||||
let raw = message.raw_message.as_ref();
|
||||
for part in &message.parts {
|
||||
let header = &raw[part.offset_header as usize..part.offset_body as usize];
|
||||
if !header.is_ascii() {
|
||||
return true;
|
||||
}
|
||||
// Encoded parts carry no 8-bit bytes.
|
||||
match &part.body {
|
||||
PartType::Message(inner) if part.encoding == Encoding::None => stack.push(inner),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
struct Walk<'a, 'x> {
|
||||
message: &'a Message<'x>,
|
||||
}
|
||||
|
||||
impl Walk<'_, '_> {
|
||||
fn part(&self, id: u32) -> &MessagePart<'_> {
|
||||
&self.message.parts[id as usize]
|
||||
}
|
||||
|
||||
fn body(&self, id: u32, out: &mut Shape) {
|
||||
let part = self.part(id);
|
||||
// RFC 2183 §2.8, §2.9: the whole entity.
|
||||
if !inline(part) {
|
||||
out.attachments.push(self.leaf(part));
|
||||
return;
|
||||
}
|
||||
self.content(id, out, true);
|
||||
}
|
||||
|
||||
/// A related root is no `cid:` target.
|
||||
fn content(&self, id: u32, out: &mut Shape, target: bool) {
|
||||
let part = self.part(id);
|
||||
match &part.body {
|
||||
PartType::Multipart(children) => {
|
||||
self.target(part, target, out);
|
||||
match multipart(part).as_str() {
|
||||
"alternative" => self.alternative(children, out),
|
||||
"related" => self.related(part, children, out),
|
||||
_ => children.iter().for_each(|&child| self.body(child, out)),
|
||||
}
|
||||
}
|
||||
PartType::Text(text) if plain(part) => {
|
||||
out.text.push(text.to_string());
|
||||
self.target(part, target, out);
|
||||
}
|
||||
PartType::Html(html) => {
|
||||
out.html.push(html.to_string());
|
||||
self.target(part, target, out);
|
||||
}
|
||||
_ => {
|
||||
let leaf = self.leaf(part);
|
||||
let message = matches!(part.body, PartType::Message(_));
|
||||
match leaf.content_id {
|
||||
Some(_) if !message => out.inline.push(leaf),
|
||||
_ => out.attachments.push(leaf),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 2392: any part with a Content-ID.
|
||||
fn target(&self, part: &MessagePart, target: bool, out: &mut Shape) {
|
||||
if target && part.content_id().is_some() {
|
||||
out.inline.push(self.leaf(part));
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 2046 §5.1.4: the last supported version wins.
|
||||
fn alternative(&self, children: &[u32], out: &mut Shape) {
|
||||
let versions: Vec<Shape> = children
|
||||
.iter()
|
||||
.map(|&child| {
|
||||
let mut version = Shape::default();
|
||||
self.body(child, &mut version);
|
||||
version
|
||||
})
|
||||
.collect();
|
||||
let text = versions.iter().rposition(|v| !v.text.is_empty());
|
||||
let html = versions.iter().rposition(|v| !v.html.is_empty());
|
||||
for (at, version) in versions.into_iter().enumerate() {
|
||||
let bodiless = version.text.is_empty() && version.html.is_empty();
|
||||
if Some(at) != text && Some(at) != html && !bodiless {
|
||||
continue;
|
||||
}
|
||||
if Some(at) == text {
|
||||
out.text.extend(version.text);
|
||||
}
|
||||
if Some(at) == html {
|
||||
out.html.extend(version.html);
|
||||
}
|
||||
out.inline.extend(version.inline);
|
||||
out.attachments.extend(version.attachments);
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 2387: `start` names the root; §4 voids dispositions.
|
||||
fn related(&self, part: &MessagePart, children: &[u32], out: &mut Shape) {
|
||||
let start = part
|
||||
.content_type()
|
||||
.and_then(|kind| kind.attribute("start"))
|
||||
.map(|start| start.trim().trim_start_matches('<').trim_end_matches('>'));
|
||||
let root = match start {
|
||||
None => 0,
|
||||
Some(start) => children
|
||||
.iter()
|
||||
.position(|&child| self.part(child).content_id() == Some(start))
|
||||
.unwrap_or_else(|| {
|
||||
log::warn!("mail: related start names no part; first part is the root");
|
||||
0
|
||||
}),
|
||||
};
|
||||
for (at, &child) in children.iter().enumerate() {
|
||||
if at == root {
|
||||
self.content(child, out, false);
|
||||
continue;
|
||||
}
|
||||
// RFC 2387 §6.1: a resource is one entity.
|
||||
let leaf = self.leaf(self.part(child));
|
||||
match leaf.content_id {
|
||||
Some(_) => out.inline.push(leaf),
|
||||
None => out.attachments.push(leaf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn leaf(&self, part: &MessagePart) -> Part {
|
||||
let kind = part.content_type();
|
||||
let content_type = kind.map_or_else(
|
||||
// RFC 2046 §5.1.5: digest children are messages.
|
||||
|| match part.body {
|
||||
PartType::Message(_) => "message/rfc822".to_owned(),
|
||||
_ => "text/plain".to_owned(),
|
||||
},
|
||||
|kind| match &kind.c_subtype {
|
||||
Some(subtype) => format!("{}/{subtype}", kind.c_type),
|
||||
None => kind.c_type.to_string(),
|
||||
},
|
||||
);
|
||||
let params = kind
|
||||
.and_then(|kind| kind.attributes())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|attr| !attr.name.eq_ignore_ascii_case("name"))
|
||||
.map(|attr| (attr.name.to_ascii_lowercase(), attr.value.to_string()))
|
||||
.collect();
|
||||
Part {
|
||||
name: part.attachment_name().map(str::to_owned),
|
||||
content_type: content_type.to_ascii_lowercase(),
|
||||
params,
|
||||
content_id: part.content_id().map(str::to_owned),
|
||||
bytes: self.octets(part),
|
||||
}
|
||||
}
|
||||
|
||||
/// Transfer-decoded bytes; text keeps its own charset.
|
||||
fn octets(&self, part: &MessagePart) -> Vec<u8> {
|
||||
let raw = self.message.raw_message();
|
||||
let body = &raw[part.offset_body as usize..part.offset_end as usize];
|
||||
match &part.body {
|
||||
PartType::Binary(bytes) | PartType::InlineBinary(bytes) => bytes.to_vec(),
|
||||
PartType::Message(message) => message.raw_message().to_vec(),
|
||||
PartType::Multipart(_) => {
|
||||
raw[part.offset_header as usize..part.offset_end as usize].to_vec()
|
||||
}
|
||||
PartType::Text(_) | PartType::Html(_) => {
|
||||
let decoded = match part.encoding {
|
||||
Encoding::None => Some(body.to_vec()),
|
||||
Encoding::QuotedPrintable => quoted_printable_decode(body),
|
||||
Encoding::Base64 => base64_decode(body),
|
||||
};
|
||||
decoded.unwrap_or_else(|| {
|
||||
log::warn!("mail: a text attachment kept its encoded bytes");
|
||||
body.to_vec()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 2183 §2.8: unknown types are attachments.
|
||||
fn inline(part: &MessagePart) -> bool {
|
||||
part.content_disposition()
|
||||
.is_none_or(|disposition| disposition.c_type.eq_ignore_ascii_case("inline"))
|
||||
}
|
||||
|
||||
fn multipart(part: &MessagePart) -> String {
|
||||
part.content_type()
|
||||
.and_then(|kind| kind.c_subtype.as_deref())
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// RFC 2045 §5.2: no Content-Type is text/plain.
|
||||
fn plain(part: &MessagePart) -> bool {
|
||||
part.content_type().is_none_or(|kind| {
|
||||
kind.c_subtype
|
||||
.as_deref()
|
||||
.is_none_or(|subtype| subtype.eq_ignore_ascii_case("plain"))
|
||||
})
|
||||
}
|
||||
@@ -22,6 +22,8 @@ pub struct Smtp {
|
||||
client: SmtpClient<Stream>,
|
||||
/// The server takes UTF-8 addresses (SMTPUTF8).
|
||||
utf8: bool,
|
||||
/// The server takes 8-bit bodies (8BITMIME).
|
||||
eight: bool,
|
||||
}
|
||||
|
||||
impl Smtp {
|
||||
@@ -69,7 +71,12 @@ impl Smtp {
|
||||
}
|
||||
log::info!("mail: SMTP login on {}", server.host);
|
||||
let utf8 = hello.has_capability(smtp_proto::EXT_SMTP_UTF8);
|
||||
Ok(Self { client, utf8 })
|
||||
let eight = hello.has_capability(smtp_proto::EXT_8BIT_MIME);
|
||||
Ok(Self {
|
||||
client,
|
||||
utf8,
|
||||
eight,
|
||||
})
|
||||
}
|
||||
|
||||
/// Hands one message to the server for these recipients.
|
||||
@@ -81,12 +88,21 @@ impl Smtp {
|
||||
mailbox(email)?;
|
||||
}
|
||||
let data = data(message)?;
|
||||
let headers = crate::mime::utf8_headers(message)?;
|
||||
let mut params = Parameters::new();
|
||||
let header = message
|
||||
let body = message
|
||||
.windows(4)
|
||||
.position(|end| end == b"\r\n\r\n")
|
||||
.map_or(message, |at| &message[..at]);
|
||||
if !from.is_ascii() || to.iter().any(|to| !to.is_ascii()) || !header.is_ascii() {
|
||||
.map_or(&[][..], |at| &message[at..]);
|
||||
if !body.is_ascii() {
|
||||
if !self.eight {
|
||||
return Err(Error::Invalid(
|
||||
"the server does not take 8-bit message bodies".into(),
|
||||
));
|
||||
}
|
||||
params.add(("BODY", "8BITMIME"));
|
||||
}
|
||||
if !from.is_ascii() || to.iter().any(|to| !to.is_ascii()) || headers {
|
||||
if !self.utf8 {
|
||||
return Err(Error::Invalid(
|
||||
"the server does not take non-ASCII addresses or headers".into(),
|
||||
@@ -185,7 +201,7 @@ fn data(message: &[u8]) -> Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// RFC 5321 §4.1.2 Mailbox, with RFC 6531 UTF-8.
|
||||
fn mailbox(email: &str) -> Result<()> {
|
||||
pub(crate) fn mailbox(email: &str) -> Result<()> {
|
||||
let readable = email.rsplit_once('@').is_some_and(|(local, domain)| {
|
||||
let local = match local
|
||||
.strip_prefix('"')
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! A scripted mail server over real TLS, for protocol tests.
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod smtp;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use ely_mail_engine::{Login, Secret, Security, Server, TlsConnector};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use ely_mail_engine::{Secret, Security, Smtp};
|
||||
|
||||
use super::{Step, certs, expect, login, send, serve};
|
||||
|
||||
pub async fn logged_in(
|
||||
ehlo: &str,
|
||||
steps: Vec<Step>,
|
||||
) -> (Smtp, tokio::task::JoinHandle<Result<(), String>>) {
|
||||
let certs = certs();
|
||||
let mut script = vec![
|
||||
send("220 mx ready\n"),
|
||||
expect("EHLO [127.0.0.1]"),
|
||||
send(ehlo),
|
||||
expect(&format!(
|
||||
"AUTH PLAIN {}",
|
||||
STANDARD.encode("\0ann@example.com\0pw")
|
||||
)),
|
||||
send("235 ok\n"),
|
||||
];
|
||||
script.extend(steps);
|
||||
let (server, script) = serve(&certs, Security::Tls, script).await;
|
||||
let smtp = Smtp::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(smtp, script)
|
||||
}
|
||||
|
||||
/// MAIL, RCPT and DATA with the server agreeing.
|
||||
pub fn transaction(mail: &str, rcpt: &str, data: &str) -> Vec<Step> {
|
||||
vec![
|
||||
expect(mail),
|
||||
send("250 ok\n"),
|
||||
expect(rcpt),
|
||||
send("250 ok\n"),
|
||||
expect("DATA"),
|
||||
send("354 go\n"),
|
||||
Step::Data(data.to_owned()),
|
||||
send("250 queued\n"),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use ely_mail_engine::{Draft, Error, Mail, Mailbox, Part, build, read};
|
||||
|
||||
/// The sample as a server sends it: CRLF lines.
|
||||
fn sample() -> Vec<u8> {
|
||||
let path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../tests/fixtures/mail/sample.eml"
|
||||
);
|
||||
let text = std::fs::read_to_string(path).unwrap();
|
||||
text.replace("\r\n", "\n")
|
||||
.replace('\n', "\r\n")
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn mailbox(name: Option<&str>, email: &str) -> Mailbox {
|
||||
Mailbox {
|
||||
name: name.map(str::to_owned),
|
||||
email: email.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sample_reads_whole() {
|
||||
let mail = read(&sample()).unwrap();
|
||||
assert_eq!(mail.subject.as_deref(), Some("Café report"));
|
||||
assert_eq!(mail.from, [mailbox(Some("张三"), "zhang@example.com")]);
|
||||
assert_eq!(
|
||||
mail.to,
|
||||
[
|
||||
mailbox(Some("Bo Smith"), "bo@example.com"),
|
||||
mailbox(None, "cy@example.org")
|
||||
]
|
||||
);
|
||||
assert_eq!(mail.cc, [mailbox(None, "dee@example.net")]);
|
||||
assert_eq!(mail.date, Some(1_792_391_400));
|
||||
assert_eq!(mail.message_id.as_deref(), Some("m1@example.com"));
|
||||
assert_eq!(mail.in_reply_to, ["m0@example.com"]);
|
||||
assert_eq!(mail.references, ["r1@example.com", "m0@example.com"]);
|
||||
assert_eq!(mail.text.as_deref(), Some("Café numbers attached."));
|
||||
assert_eq!(
|
||||
mail.html.as_deref(),
|
||||
Some(r#"<p>Café <img src="cid:logo@example.com"></p>"#)
|
||||
);
|
||||
assert_eq!(
|
||||
mail.inline,
|
||||
[Part {
|
||||
name: None,
|
||||
content_type: "image/png".into(),
|
||||
params: Vec::new(),
|
||||
content_id: Some("logo@example.com".into()),
|
||||
bytes: b"\x89PNG\r\n\x1a\n".to_vec(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
mail.attachments,
|
||||
[Part {
|
||||
name: Some("report.pdf".into()),
|
||||
content_type: "application/pdf".into(),
|
||||
params: Vec::new(),
|
||||
content_id: Some("att@example.com".into()),
|
||||
bytes: b"%PDF-1.4\n".to_vec(),
|
||||
}]
|
||||
);
|
||||
assert!(
|
||||
mail.headers
|
||||
.starts_with("Return-Path: <zhang@example.com>\r\n")
|
||||
);
|
||||
assert!(mail.headers.contains("X-Custom: kept as sent\r\n"));
|
||||
assert!(!mail.headers.contains("--mix"));
|
||||
}
|
||||
|
||||
fn reply_to(mail: &Mail) -> Draft {
|
||||
let every_byte: Vec<u8> = (0..=255).collect();
|
||||
let mut attachments = mail.attachments.clone();
|
||||
attachments.push(Part {
|
||||
name: Some("bytes.bin".into()),
|
||||
content_type: "application/octet-stream".into(),
|
||||
params: Vec::new(),
|
||||
content_id: None,
|
||||
bytes: every_byte,
|
||||
});
|
||||
Draft {
|
||||
from: mail.from[0].clone(),
|
||||
to: mail.to.clone(),
|
||||
cc: mail.cc.clone(),
|
||||
subject: format!("Re: {}", mail.subject.as_deref().unwrap()),
|
||||
text: mail.text.clone().unwrap(),
|
||||
html: mail.html.clone(),
|
||||
attachments,
|
||||
in_reply_to: mail.message_id.clone(),
|
||||
references: mail.references.clone(),
|
||||
date: 1_792_400_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_built_from_the_sample_reads_back_the_same() {
|
||||
let mail = read(&sample()).unwrap();
|
||||
let draft = reply_to(&mail);
|
||||
let built = build(&draft).unwrap();
|
||||
let back = read(&built.bytes).unwrap();
|
||||
assert_eq!(back.subject.as_deref(), Some("Re: Café report"));
|
||||
assert_eq!(back.from, std::slice::from_ref(&draft.from));
|
||||
assert_eq!(back.to, draft.to);
|
||||
assert_eq!(back.cc, draft.cc);
|
||||
assert_eq!(back.date, Some(draft.date));
|
||||
assert_eq!(back.text, Some(draft.text.clone()));
|
||||
assert_eq!(back.html, draft.html);
|
||||
assert_eq!(back.message_id.as_deref(), Some(built.message_id.as_str()));
|
||||
assert!(
|
||||
built.message_id.ends_with("@example.com"),
|
||||
"{}",
|
||||
built.message_id
|
||||
);
|
||||
assert_eq!(back.in_reply_to, ["m1@example.com"]);
|
||||
assert_eq!(back.references, ["r1@example.com", "m0@example.com"]);
|
||||
assert_eq!(back.attachments, draft.attachments);
|
||||
let bcc = |line: &str| line.to_ascii_lowercase().starts_with("bcc:");
|
||||
assert!(!back.headers.lines().any(bcc), "{}", back.headers);
|
||||
let again = build(&draft).unwrap();
|
||||
assert_ne!(again.message_id, built.message_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bodies_are_only_what_the_sender_wrote() {
|
||||
let html = read(b"Content-Type: text/html\r\n\r\n<p>only html</p>").unwrap();
|
||||
assert_eq!(html.text, None);
|
||||
assert_eq!(html.html.as_deref(), Some("<p>only html</p>"));
|
||||
|
||||
let mut draft = reply_to(&read(&sample()).unwrap());
|
||||
draft.html = None;
|
||||
let plain = read(&build(&draft).unwrap().bytes).unwrap();
|
||||
assert_eq!(plain.html, None);
|
||||
assert_eq!(plain.text.as_deref(), Some("Café numbers attached."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_break_in_a_header_never_leaves() {
|
||||
let base = reply_to(&read(&sample()).unwrap());
|
||||
let mut subject = base.clone();
|
||||
subject.subject = "Hi\r\nBcc: hidden@example.com".into();
|
||||
let mut name = base.clone();
|
||||
name.to[0].name = Some("Bo\nBcc: hidden@example.com".into());
|
||||
let mut file = base.clone();
|
||||
file.attachments[0].name = Some("a.pdf\r\nX-Evil: 1".into());
|
||||
let mut email = base.clone();
|
||||
email.cc[0].email = "dee@example.net>\r\nBcc: <hidden@example.com".into();
|
||||
let mut nameless = base.clone();
|
||||
nameless.attachments[0].name = None;
|
||||
let mut cid = base.clone();
|
||||
cid.attachments[0].content_id = Some("a@b>\r\nX-Evil: 1".into());
|
||||
for draft in [subject, name, file, email, nameless, cid] {
|
||||
let err = build(&draft).err().unwrap();
|
||||
assert!(matches!(err, Error::Invalid(_)), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_that_are_no_message_are_an_error() {
|
||||
let err = read(b"").unwrap_err();
|
||||
assert!(matches!(err, Error::Mime(_)), "{err}");
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use ely_mail_engine::{Error, read};
|
||||
|
||||
/// `levels` messages, each enclosing the next.
|
||||
fn enclosed(levels: usize) -> Vec<u8> {
|
||||
let mut mail = b"Subject: core\r\n\r\nhi\r\n".to_vec();
|
||||
for _ in 0..levels {
|
||||
let mut outer = b"Content-Type: message/rfc822\r\n\r\n".to_vec();
|
||||
outer.extend_from_slice(&mail);
|
||||
mail = outer;
|
||||
}
|
||||
mail
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosed_messages_count_toward_the_depth() {
|
||||
let err = read(&enclosed(128)).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Mime(text) if text.contains("64")),
|
||||
"{err}"
|
||||
);
|
||||
let shallow = read(&enclosed(8)).unwrap();
|
||||
assert_eq!(shallow.attachments.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_very_deep_enclosure_is_refused_without_a_crash() {
|
||||
let err = read(&enclosed(8192)).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Mime(text) if text.contains("64")),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attached_message_is_its_own_bytes() {
|
||||
let inner = "Subject: inner\r\nContent-Type: text/plain\r\n\r\nhello";
|
||||
let raw = format!(
|
||||
"Subject: outer\r\nContent-Type: multipart/mixed; boundary=b\r\n\r\n\
|
||||
--b\r\nContent-Type: text/plain\r\n\r\nbody\r\n\
|
||||
--b\r\nContent-Type: message/rfc822\r\n\r\n{inner}\r\n--b--\r\n"
|
||||
);
|
||||
let mail = read(raw.as_bytes()).unwrap();
|
||||
let [message] = mail.attachments.as_slice() else {
|
||||
panic!("{:?}", mail.attachments)
|
||||
};
|
||||
assert_eq!(message.bytes, inner.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multipart_with_a_content_id_is_a_cid_target() {
|
||||
let raw = "Content-Type: multipart/mixed; boundary=m\r\n\r\n\
|
||||
--m\r\nContent-Type: text/html\r\n\r\n<a href=\"cid:compound@example.com\">x</a>\r\n\
|
||||
--m\r\nContent-Type: multipart/alternative; boundary=a\r\n\
|
||||
Content-ID: <compound@example.com>\r\n\
|
||||
Content-Disposition: inline; filename=compound.mime\r\n\r\n\
|
||||
--a\r\nContent-Type: text/plain\r\n\r\ninner\r\n--a--\r\n--m--\r\n";
|
||||
let mail = read(raw.as_bytes()).unwrap();
|
||||
let [target] = mail.inline.as_slice() else {
|
||||
panic!("{:?}", mail.inline)
|
||||
};
|
||||
assert_eq!(target.content_id.as_deref(), Some("compound@example.com"));
|
||||
assert_eq!(target.content_type, "multipart/alternative");
|
||||
assert_eq!(target.name.as_deref(), Some("compound.mime"));
|
||||
assert!(
|
||||
target
|
||||
.bytes
|
||||
.starts_with(b"Content-Type: multipart/alternative")
|
||||
);
|
||||
assert!(target.bytes.ends_with(b"--a--"), "{:?}", target.bytes);
|
||||
assert!(mail.attachments.is_empty(), "{:?}", mail.attachments);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
use ely_mail_engine::{Error, read};
|
||||
|
||||
/// Lines joined with CRLF, as servers send them.
|
||||
fn crlf(lines: &[&str]) -> Vec<u8> {
|
||||
lines.join("\r\n").into_bytes()
|
||||
}
|
||||
|
||||
/// `levels` multipart/mixed containers around one text part.
|
||||
fn nested(levels: usize) -> Vec<u8> {
|
||||
let mut lines = Vec::new();
|
||||
for level in 0..levels {
|
||||
lines.push(format!("Content-Type: multipart/mixed; boundary=b{level}"));
|
||||
lines.push(String::new());
|
||||
lines.push(format!("--b{level}"));
|
||||
}
|
||||
lines.push("Content-Type: text/plain".into());
|
||||
lines.push(String::new());
|
||||
lines.push("deep".into());
|
||||
for level in (0..levels).rev() {
|
||||
lines.push(format!("--b{level}--"));
|
||||
}
|
||||
lines.push(String::new());
|
||||
lines.join("\r\n").into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_is_an_error_not_a_crash() {
|
||||
let err = read(&nested(2048)).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Mime(text) if text.contains("deeper than 64")),
|
||||
"{err}"
|
||||
);
|
||||
let mail = read(&nested(63)).unwrap();
|
||||
assert_eq!(mail.text.as_deref(), Some("deep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_charsets_decode() {
|
||||
let mail = read(&crlf(&[
|
||||
"Subject: =?GB2312?B?1tDOxA==?=",
|
||||
"Content-Type: text/plain; charset=gb2312",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
"1tDOxA==",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(mail.subject.as_deref(), Some("中文"));
|
||||
assert_eq!(mail.text.as_deref(), Some("中文"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_related_root_marked_attachment_is_still_the_body() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/related; boundary=r; type=\"text/html\"",
|
||||
"",
|
||||
"--r",
|
||||
"Content-Type: text/html",
|
||||
"Content-Disposition: attachment; filename=body.html",
|
||||
"",
|
||||
"<p>root</p>",
|
||||
"--r",
|
||||
"Content-Type: image/png",
|
||||
"Content-ID: <img@example.com>",
|
||||
"",
|
||||
"png",
|
||||
"--r--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(mail.html.as_deref(), Some("<p>root</p>"));
|
||||
assert!(mail.attachments.is_empty());
|
||||
let ids: Vec<_> = mail
|
||||
.inline
|
||||
.iter()
|
||||
.map(|p| p.content_id.as_deref())
|
||||
.collect();
|
||||
assert_eq!(ids, [Some("img@example.com")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_composite_related_resource_keeps_its_entity() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/related; boundary=r; type=\"text/html\"",
|
||||
"",
|
||||
"--r",
|
||||
"Content-Type: text/html",
|
||||
"",
|
||||
"<p>root</p>",
|
||||
"--r",
|
||||
"Content-Type: multipart/alternative; boundary=a",
|
||||
"Content-ID: <entity@example.com>",
|
||||
"Content-Disposition: inline; filename=entity.mime",
|
||||
"",
|
||||
"--a",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"inner",
|
||||
"--a--",
|
||||
"--r--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(mail.html.as_deref(), Some("<p>root</p>"));
|
||||
assert!(mail.attachments.is_empty());
|
||||
let [entity] = mail.inline.as_slice() else {
|
||||
panic!("{:?}", mail.inline)
|
||||
};
|
||||
assert_eq!(entity.content_id.as_deref(), Some("entity@example.com"));
|
||||
assert_eq!(entity.name.as_deref(), Some("entity.mime"));
|
||||
assert_eq!(entity.content_type, "multipart/alternative");
|
||||
let bytes = String::from_utf8(entity.bytes.clone()).unwrap();
|
||||
assert!(
|
||||
bytes.starts_with("Content-Type: multipart/alternative"),
|
||||
"{bytes}"
|
||||
);
|
||||
assert!(bytes.contains("inner"), "{bytes}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_part_with_a_content_id_is_a_cid_target() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/mixed; boundary=b",
|
||||
"",
|
||||
"--b",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"body",
|
||||
"--b",
|
||||
"Content-Type: text/html; charset=utf-8",
|
||||
"Content-ID: <frame@example.com>",
|
||||
"Content-Disposition: inline",
|
||||
"",
|
||||
"<p>frame</p>",
|
||||
"--b--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(mail.text.as_deref(), Some("body"));
|
||||
let [frame] = mail.inline.as_slice() else {
|
||||
panic!("{:?}", mail.inline)
|
||||
};
|
||||
assert_eq!(frame.content_id.as_deref(), Some("frame@example.com"));
|
||||
assert_eq!(frame.content_type, "text/html");
|
||||
assert_eq!(frame.params, [("charset".to_owned(), "utf-8".to_owned())]);
|
||||
assert_eq!(frame.bytes, b"<p>frame</p>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_disposition_is_an_attachment() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/mixed; boundary=b",
|
||||
"",
|
||||
"--b",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"body",
|
||||
"--b",
|
||||
"Content-Type: text/plain",
|
||||
"Content-Disposition: x-file; filename=file.txt",
|
||||
"",
|
||||
"private attachment",
|
||||
"--b--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(mail.text.as_deref(), Some("body"));
|
||||
let [file] = mail.attachments.as_slice() else {
|
||||
panic!("{:?}", mail.attachments)
|
||||
};
|
||||
assert_eq!(file.name.as_deref(), Some("file.txt"));
|
||||
assert_eq!(file.bytes, b"private attachment");
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
use ely_mail_engine::{Draft, Mail, Mailbox, build, read};
|
||||
use mail_parser::{MessageParser, MimeHeaders, PartType};
|
||||
|
||||
/// Lines joined with CRLF, as servers send them.
|
||||
fn crlf(lines: &[&str]) -> Vec<u8> {
|
||||
lines.join("\r\n").into_bytes()
|
||||
}
|
||||
|
||||
/// A new message carrying `mail`'s attachments.
|
||||
fn forward(mail: &Mail) -> Vec<u8> {
|
||||
let draft = Draft {
|
||||
from: Mailbox {
|
||||
name: None,
|
||||
email: "a@example.com".into(),
|
||||
},
|
||||
to: Vec::new(),
|
||||
cc: Vec::new(),
|
||||
subject: "fwd".into(),
|
||||
text: "see attached".into(),
|
||||
html: None,
|
||||
attachments: mail.attachments.clone(),
|
||||
in_reply_to: None,
|
||||
references: Vec::new(),
|
||||
date: 0,
|
||||
};
|
||||
build(&draft).unwrap().bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forwarded_text_attachment_keeps_its_charset() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/mixed; boundary=b",
|
||||
"",
|
||||
"--b",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"body",
|
||||
"--b",
|
||||
"Content-Type: text/plain; charset=iso-8859-1",
|
||||
"Content-Transfer-Encoding: quoted-printable",
|
||||
"Content-Disposition: attachment; filename=cafe.txt",
|
||||
"",
|
||||
"Caf=E9",
|
||||
"--b--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
let sent = forward(&mail);
|
||||
let message = MessageParser::default().parse(&sent).unwrap();
|
||||
let file = message.attachment(0).unwrap();
|
||||
assert_eq!(file.text_contents(), Some("Café"));
|
||||
assert_eq!(read(&sent).unwrap().attachments, mail.attachments);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forwarded_multipart_stays_a_multipart() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/mixed; boundary=b",
|
||||
"",
|
||||
"--b",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"body",
|
||||
"--b",
|
||||
"Content-Type: multipart/alternative; boundary=a",
|
||||
"Content-Disposition: attachment; filename=alt.mime",
|
||||
"",
|
||||
"--a",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"alt plain",
|
||||
"--a",
|
||||
"Content-Type: text/html",
|
||||
"",
|
||||
"<p>alt html</p>",
|
||||
"--a--",
|
||||
"--b--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
let sent = forward(&mail);
|
||||
let message = MessageParser::default().parse(&sent).unwrap();
|
||||
let alt = message
|
||||
.parts
|
||||
.iter()
|
||||
.find(|part| part.content_type().and_then(|t| t.subtype()) == Some("alternative"))
|
||||
.unwrap();
|
||||
assert_eq!(alt.attachment_name(), Some("alt.mime"));
|
||||
assert_eq!(alt.content_transfer_encoding(), Some("7bit"));
|
||||
let PartType::Multipart(children) = &alt.body else {
|
||||
panic!("{:?}", alt.body)
|
||||
};
|
||||
let texts: Vec<_> = children
|
||||
.iter()
|
||||
.map(|&id| message.parts[id as usize].text_contents())
|
||||
.collect();
|
||||
assert_eq!(texts, [Some("alt plain"), Some("<p>alt html</p>")]);
|
||||
|
||||
let back = read(&sent).unwrap();
|
||||
assert_eq!(back.text.as_deref(), Some("see attached"));
|
||||
let [file] = back.attachments.as_slice() else {
|
||||
panic!("{:?}", back.attachments)
|
||||
};
|
||||
assert_eq!(file.content_type, "multipart/alternative");
|
||||
assert_eq!(file.params, mail.attachments[0].params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forwarded_message_stays_unencoded() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/mixed; boundary=b",
|
||||
"",
|
||||
"--b",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"body",
|
||||
"--b",
|
||||
"Content-Type: message/rfc822",
|
||||
"Content-Disposition: attachment; filename=old.eml",
|
||||
"",
|
||||
"Subject: old",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"",
|
||||
"您好",
|
||||
"--b--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
let sent = forward(&mail);
|
||||
let message = MessageParser::default().parse(&sent).unwrap();
|
||||
let old = message
|
||||
.parts
|
||||
.iter()
|
||||
.find(|part| matches!(part.body, PartType::Message(_)))
|
||||
.unwrap();
|
||||
assert_eq!(old.content_transfer_encoding(), Some("8bit"));
|
||||
let PartType::Message(inner) = &old.body else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(inner.subject(), Some("old"));
|
||||
assert_eq!(inner.body_text(0).as_deref(), Some("您好"));
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use ely_mail_engine::{Draft, Mailbox, Part, build, read};
|
||||
|
||||
/// Lines joined with CRLF, as servers send them.
|
||||
fn crlf(lines: &[&str]) -> Vec<u8> {
|
||||
lines.join("\r\n").into_bytes()
|
||||
}
|
||||
|
||||
fn part(name: Option<&str>, kind: &str, id: Option<&str>, bytes: &[u8]) -> Part {
|
||||
Part {
|
||||
name: name.map(str::to_owned),
|
||||
content_type: kind.to_owned(),
|
||||
params: Vec::new(),
|
||||
content_id: id.map(str::to_owned),
|
||||
bytes: bytes.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_attachments_keep_their_own_bytes() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/mixed; boundary=b",
|
||||
"",
|
||||
"--b",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"body",
|
||||
"--b",
|
||||
"Content-Type: text/plain; charset=iso-8859-1",
|
||||
"Content-Transfer-Encoding: quoted-printable",
|
||||
"Content-Disposition: attachment; filename=qp.txt",
|
||||
"",
|
||||
"Caf=E9",
|
||||
"--b",
|
||||
"Content-Type: text/plain; charset=iso-8859-1",
|
||||
"Content-Disposition: attachment; filename=raw.txt",
|
||||
"",
|
||||
"Caf\u{e9}",
|
||||
"--b",
|
||||
"Content-Type: text/plain; charset=iso-8859-1",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"Content-Disposition: attachment; filename=b64.txt",
|
||||
"",
|
||||
"Q2Fm6Q==",
|
||||
"--b--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(mail.text.as_deref(), Some("body"));
|
||||
let bytes: Vec<&[u8]> = mail
|
||||
.attachments
|
||||
.iter()
|
||||
.map(|p| p.bytes.as_slice())
|
||||
.collect();
|
||||
assert_eq!(bytes, [b"Caf\xe9", "Caf\u{e9}".as_bytes(), b"Caf\xe9"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_built_latin1_text_attachment_reads_back_as_built() {
|
||||
let latin1 = part(Some("cafe.txt"), "text/plain", None, b"Caf\xe9\r\n");
|
||||
let draft = Draft {
|
||||
from: Mailbox {
|
||||
name: None,
|
||||
email: "a@example.com".into(),
|
||||
},
|
||||
to: Vec::new(),
|
||||
cc: Vec::new(),
|
||||
subject: "s".into(),
|
||||
text: "t".into(),
|
||||
html: None,
|
||||
attachments: vec![latin1.clone()],
|
||||
in_reply_to: None,
|
||||
references: Vec::new(),
|
||||
date: 0,
|
||||
};
|
||||
let back = read(&build(&draft).unwrap().bytes).unwrap();
|
||||
assert_eq!(back.attachments, [latin1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_related_root_named_by_start_can_come_after_its_image() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/related; boundary=r; type=\"text/html\"; start=\"<root@example.com>\"",
|
||||
"",
|
||||
"--r",
|
||||
"Content-Type: image/png",
|
||||
"Content-ID: <img@example.com>",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
"iVBORw==",
|
||||
"--r",
|
||||
"Content-Type: text/html",
|
||||
"Content-ID: <root@example.com>",
|
||||
"",
|
||||
"<img src=\"cid:img@example.com\">",
|
||||
"--r--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
mail.html.as_deref(),
|
||||
Some("<img src=\"cid:img@example.com\">")
|
||||
);
|
||||
assert_eq!(
|
||||
mail.inline,
|
||||
[part(None, "image/png", Some("img@example.com"), b"\x89PNG")]
|
||||
);
|
||||
assert!(mail.attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_related_image_marked_attachment_is_still_a_cid_target() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/related; boundary=r",
|
||||
"",
|
||||
"--r",
|
||||
"Content-Type: text/html",
|
||||
"",
|
||||
"<img src=\"cid:img@example.com\">",
|
||||
"--r",
|
||||
"Content-Type: image/png",
|
||||
"Content-ID: <img@example.com>",
|
||||
"Content-Disposition: attachment; filename=img.png",
|
||||
"Content-Transfer-Encoding: base64",
|
||||
"",
|
||||
"iVBORw==",
|
||||
"--r--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
mail.inline,
|
||||
[part(
|
||||
Some("img.png"),
|
||||
"image/png",
|
||||
Some("img@example.com"),
|
||||
b"\x89PNG"
|
||||
)]
|
||||
);
|
||||
assert!(mail.attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attached_multipart_is_one_attachment_and_no_body() {
|
||||
let entity = [
|
||||
"Content-Type: multipart/alternative; boundary=in",
|
||||
"Content-Disposition: attachment; filename=alternatives.mime",
|
||||
"",
|
||||
"--in",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"private attachment",
|
||||
"--in--",
|
||||
];
|
||||
let mut lines = vec![
|
||||
"Content-Type: multipart/mixed; boundary=out",
|
||||
"",
|
||||
"--out",
|
||||
"Content-Type: text/plain",
|
||||
"",
|
||||
"body",
|
||||
"--out",
|
||||
];
|
||||
lines.extend(entity);
|
||||
lines.extend(["--out--", ""]);
|
||||
let mail = read(&crlf(&lines)).unwrap();
|
||||
assert_eq!(mail.text.as_deref(), Some("body"));
|
||||
assert_eq!(mail.attachments.len(), 1);
|
||||
let attached = &mail.attachments[0];
|
||||
assert_eq!(attached.name.as_deref(), Some("alternatives.mime"));
|
||||
assert_eq!(attached.content_type, "multipart/alternative");
|
||||
assert_eq!(attached.bytes, crlf(&entity));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternatives_of_one_type_are_chosen_not_joined() {
|
||||
let mail = read(&crlf(&[
|
||||
"Content-Type: multipart/alternative; boundary=a",
|
||||
"",
|
||||
"--a",
|
||||
"Content-Type: text/plain; charset=us-ascii",
|
||||
"",
|
||||
"Cafe",
|
||||
"--a",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"",
|
||||
"Café",
|
||||
"--a",
|
||||
"Content-Type: text/calendar",
|
||||
"",
|
||||
"BEGIN:VCALENDAR",
|
||||
"--a--",
|
||||
"",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(mail.text.as_deref(), Some("Café"));
|
||||
assert_eq!(
|
||||
mail.attachments,
|
||||
[part(None, "text/calendar", None, b"BEGIN:VCALENDAR")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_digest_child_without_a_type_is_a_message() {
|
||||
let enclosed = "From: x@example.com\r\nSubject: inner\r\n\r\nhello";
|
||||
let mut lines = vec!["Content-Type: multipart/digest; boundary=d", "", "--d", ""];
|
||||
lines.push(enclosed);
|
||||
lines.extend(["--d--", ""]);
|
||||
let mail = read(&crlf(&lines)).unwrap();
|
||||
assert_eq!(mail.attachments.len(), 1);
|
||||
assert_eq!(mail.attachments[0].content_type, "message/rfc822");
|
||||
let inner = read(&mail.attachments[0].bytes).unwrap();
|
||||
assert_eq!(inner.subject.as_deref(), Some("inner"));
|
||||
}
|
||||
@@ -2,7 +2,10 @@ mod fake;
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use ely_mail_engine::{Error, Secret, Security, Smtp};
|
||||
use fake::{Step, certs, expect, login, send, serve};
|
||||
use fake::{
|
||||
Step, certs, expect, login, send, serve,
|
||||
smtp::{logged_in, transaction},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn starttls_password_login_sends_one_message() {
|
||||
@@ -170,7 +173,7 @@ async fn a_refused_recipient_keeps_the_server_words() {
|
||||
send("250 ok\n"),
|
||||
expect("DATA"),
|
||||
send("354 go\n"),
|
||||
Step::Data("x".into()),
|
||||
Step::Data("Subject: x\n\nx".into()),
|
||||
send("250 queued\n"),
|
||||
],
|
||||
)
|
||||
@@ -186,7 +189,7 @@ async fn a_refused_recipient_keeps_the_server_words() {
|
||||
.send(
|
||||
"ann@example.com",
|
||||
&["nobody@example.com".to_owned()],
|
||||
b"x\r\n",
|
||||
b"Subject: x\r\n\r\nx\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
@@ -194,9 +197,13 @@ async fn a_refused_recipient_keeps_the_server_words() {
|
||||
matches!(&err, Error::Server(text) if text.contains("550") && text.contains("No such user")),
|
||||
"{err}"
|
||||
);
|
||||
smtp.send("ann@example.com", &["bo@example.com".to_owned()], b"x\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
smtp.send(
|
||||
"ann@example.com",
|
||||
&["bo@example.com".to_owned()],
|
||||
b"Subject: x\r\n\r\nx\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
@@ -280,7 +287,7 @@ async fn an_envelope_with_a_line_break_or_bracket_never_leaves() {
|
||||
];
|
||||
for (from, to) in bad {
|
||||
let err = smtp
|
||||
.send(from, &[to.to_owned()], b"x\r\n")
|
||||
.send(from, &[to.to_owned()], b"Subject: x\r\n\r\nx\r\n")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(&err, Error::Invalid(_)), "{err}");
|
||||
@@ -301,47 +308,6 @@ async fn an_envelope_with_a_line_break_or_bracket_never_leaves() {
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
async fn logged_in(
|
||||
ehlo: &str,
|
||||
steps: Vec<Step>,
|
||||
) -> (Smtp, tokio::task::JoinHandle<Result<(), String>>) {
|
||||
let certs = certs();
|
||||
let mut script = vec![
|
||||
send("220 mx ready\n"),
|
||||
expect("EHLO [127.0.0.1]"),
|
||||
send(ehlo),
|
||||
expect(&format!(
|
||||
"AUTH PLAIN {}",
|
||||
STANDARD.encode("\0ann@example.com\0pw")
|
||||
)),
|
||||
send("235 ok\n"),
|
||||
];
|
||||
script.extend(steps);
|
||||
let (server, script) = serve(&certs, Security::Tls, script).await;
|
||||
let smtp = Smtp::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(smtp, script)
|
||||
}
|
||||
|
||||
/// MAIL, RCPT and DATA with the server agreeing.
|
||||
fn transaction(mail: &str, rcpt: &str, data: &str) -> Vec<Step> {
|
||||
vec![
|
||||
expect(mail),
|
||||
send("250 ok\n"),
|
||||
expect(rcpt),
|
||||
send("250 ok\n"),
|
||||
expect("DATA"),
|
||||
send("354 go\n"),
|
||||
Step::Data(data.to_owned()),
|
||||
send("250 queued\n"),
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_quoted_local_part_is_a_mailbox() {
|
||||
let from = r#""ann \"a\" lee"@example.com"#;
|
||||
@@ -351,11 +317,13 @@ async fn a_quoted_local_part_is_a_mailbox() {
|
||||
transaction(
|
||||
&format!("MAIL FROM:<{from}>"),
|
||||
&format!("RCPT TO:<{to}>"),
|
||||
"x",
|
||||
"Subject: x\n\nx",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
smtp.send(from, &[to.to_owned()], b"x\r\n").await.unwrap();
|
||||
smtp.send(from, &[to.to_owned()], b"Subject: x\r\n\r\nx\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
@@ -379,80 +347,3 @@ async fn a_dot_opening_the_first_line_is_stuffed() {
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_utf8_header_needs_smtputf8() {
|
||||
let message = "Subject: 您好\r\n\r\nhi\r\n";
|
||||
let (mut smtp, script) = logged_in(
|
||||
"250-mx\n250-SMTPUTF8\n250 AUTH PLAIN\n",
|
||||
transaction(
|
||||
"MAIL FROM:<ann@example.com> SMTPUTF8",
|
||||
"RCPT TO:<bo@example.com>",
|
||||
"Subject: 您好\n\nhi",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let to = ["bo@example.com".to_owned()];
|
||||
smtp.send("ann@example.com", &to, message.as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
|
||||
let (mut smtp, script) = logged_in("250-mx\n250 AUTH PLAIN\n", vec![]).await;
|
||||
let err = smtp
|
||||
.send("ann@example.com", &to, message.as_bytes())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Invalid(text) if text.contains("headers")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
|
||||
let (mut smtp, script) = logged_in(
|
||||
"250-mx\n250 AUTH PLAIN\n",
|
||||
transaction(
|
||||
"MAIL FROM:<ann@example.com>",
|
||||
"RCPT TO:<bo@example.com>",
|
||||
"Subject: hi\n\n您好",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
smtp.send(
|
||||
"ann@example.com",
|
||||
&to,
|
||||
"Subject: hi\r\n\r\n您好\r\n".as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_utf8_address_needs_smtputf8() {
|
||||
let to = "用户@例子.广告";
|
||||
let (mut smtp, script) = logged_in(
|
||||
"250-mx\n250-SMTPUTF8\n250 AUTH PLAIN\n",
|
||||
transaction(
|
||||
"MAIL FROM:<ann@example.com> SMTPUTF8",
|
||||
&format!("RCPT TO:<{to}>"),
|
||||
"x",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
smtp.send("ann@example.com", &[to.to_owned()], b"x\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
|
||||
let (mut smtp, script) = logged_in("250-mx\n250 AUTH PLAIN\n", vec![]).await;
|
||||
let err = smtp
|
||||
.send("ann@example.com", &[to.to_owned()], b"x\r\n")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Invalid(text) if text.contains("non-ASCII")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
mod fake;
|
||||
|
||||
use ely_mail_engine::{Draft, Error, Mailbox, Part, build};
|
||||
use fake::smtp::{logged_in, transaction};
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_utf8_header_needs_smtputf8() {
|
||||
let message = "Subject: 您好\r\n\r\nhi\r\n";
|
||||
let (mut smtp, script) = logged_in(
|
||||
"250-mx\n250-SMTPUTF8\n250 AUTH PLAIN\n",
|
||||
transaction(
|
||||
"MAIL FROM:<ann@example.com> SMTPUTF8",
|
||||
"RCPT TO:<bo@example.com>",
|
||||
"Subject: 您好\n\nhi",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let to = ["bo@example.com".to_owned()];
|
||||
smtp.send("ann@example.com", &to, message.as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
|
||||
let (mut smtp, script) = logged_in("250-mx\n250 AUTH PLAIN\n", vec![]).await;
|
||||
let err = smtp
|
||||
.send("ann@example.com", &to, message.as_bytes())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Invalid(text) if text.contains("headers")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_8bit_body_needs_8bitmime() {
|
||||
let message = "Subject: hi\r\n\r\n您好\r\n".as_bytes();
|
||||
let to = ["bo@example.com".to_owned()];
|
||||
let (mut smtp, script) = logged_in(
|
||||
"250-mx\n250-8BITMIME\n250 AUTH PLAIN\n",
|
||||
transaction(
|
||||
"MAIL FROM:<ann@example.com> BODY=8BITMIME",
|
||||
"RCPT TO:<bo@example.com>",
|
||||
"Subject: hi\n\n您好",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
smtp.send("ann@example.com", &to, message).await.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
|
||||
let (mut smtp, script) = logged_in("250-mx\n250 AUTH PLAIN\n", vec![]).await;
|
||||
let err = smtp
|
||||
.send("ann@example.com", &to, message)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Invalid(text) if text.contains("8-bit")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_utf8_address_needs_smtputf8() {
|
||||
let to = "用户@例子.广告";
|
||||
let (mut smtp, script) = logged_in(
|
||||
"250-mx\n250-SMTPUTF8\n250 AUTH PLAIN\n",
|
||||
transaction(
|
||||
"MAIL FROM:<ann@example.com> SMTPUTF8",
|
||||
&format!("RCPT TO:<{to}>"),
|
||||
"Subject: x\n\nx",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
smtp.send(
|
||||
"ann@example.com",
|
||||
&[to.to_owned()],
|
||||
b"Subject: x\r\n\r\nx\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
|
||||
let (mut smtp, script) = logged_in("250-mx\n250 AUTH PLAIN\n", vec![]).await;
|
||||
let err = smtp
|
||||
.send(
|
||||
"ann@example.com",
|
||||
&[to.to_owned()],
|
||||
b"Subject: x\r\n\r\nx\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Invalid(text) if text.contains("non-ASCII")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
/// A forwarded message/global under ASCII outer headers.
|
||||
fn forwarded() -> Vec<u8> {
|
||||
let enclosed = Part {
|
||||
name: Some("old.eml".into()),
|
||||
content_type: "message/global".into(),
|
||||
params: Vec::new(),
|
||||
content_id: None,
|
||||
bytes: "Subject: 您好\r\n\r\nhi\r\n".as_bytes().to_vec(),
|
||||
};
|
||||
let draft = Draft {
|
||||
from: Mailbox {
|
||||
name: None,
|
||||
email: "ann@example.com".into(),
|
||||
},
|
||||
to: Vec::new(),
|
||||
cc: Vec::new(),
|
||||
subject: "fwd".into(),
|
||||
text: "see attached".into(),
|
||||
html: None,
|
||||
attachments: vec![enclosed],
|
||||
in_reply_to: None,
|
||||
references: Vec::new(),
|
||||
date: 0,
|
||||
};
|
||||
build(&draft).unwrap().bytes
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_enclosed_utf8_header_needs_smtputf8() {
|
||||
let to = ["bo@example.com".to_owned()];
|
||||
let built = forwarded();
|
||||
let (mut smtp, script) = logged_in("250-mx\n250-8BITMIME\n250 AUTH PLAIN\n", vec![]).await;
|
||||
let err = smtp.send("ann@example.com", &to, &built).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Invalid(text) if text.contains("headers")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
|
||||
let message = "Content-Type: multipart/mixed; boundary=b\r\n\r\n--b\r\n\
|
||||
Content-Type: message/global\r\n\r\nSubject: 您好\r\n\r\nhi\r\n--b--\r\n";
|
||||
let (mut smtp, script) = logged_in(
|
||||
"250-mx\n250-8BITMIME\n250-SMTPUTF8\n250 AUTH PLAIN\n",
|
||||
transaction(
|
||||
"MAIL FROM:<ann@example.com> BODY=8BITMIME SMTPUTF8",
|
||||
"RCPT TO:<bo@example.com>",
|
||||
message.replace("\r\n", "\n").trim_end(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
smtp.send("ann@example.com", &to, message.as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
+1
-1
@@ -135,7 +135,7 @@
|
||||
|
||||
- [x] C12.0 选型修订:PRD §1.1、§12.1、§17.3、§21.2、§23 与 `agents.md` 改为上述选型;删去 MailCore 2 与 libetpan 的取码、构建与探测脚本;`oauth.env` 不入库;`references.sh` 随之(并修上游钉与工作区不一致的组件库钉) — R1 ok
|
||||
- [x] C12.1 `crates/ely-mail-engine` 与连通(MAIL-01 前半):IMAP、POP3、SMTP 经 TLS(rustls,系统根证书)或 STARTTLS 连接,密码与 XOAUTH2 认证,失败带服务器原话;替身服务器测三协议的登录、拒绝、列邮箱、取信、发信。已做:`Imap`、`Pop`、`Smtp` 三个会话,`Server`(主机、端口、`Security::Tls` 或 `StartTls`,无明文)、`Login`(密码或 OAuth 令牌);系统信任库经 rustls-platform-verifier,测试换入只信测试证书的连接器;每步 60 秒超时;`Error` 分连接、TLS、凭据被拒(服务器原话)、命令被拒、读不懂、超时、发不出(信封地址或登录带换行等)。IMAP 由引擎自写,以 imap-proto 解析(`wire.rs` 收一个个完整回复,含字面量),换下 async-imap(它的 LIST、FETCH 不看结尾的 NO 与 BAD,拒绝成了空结果与“无此 UID”;登录与命令中途的 `* BYE` 被丢掉,只剩断线;trace 日志写出密码与邮件):每条命令看结尾的状态,NO 与 BAD 带原话报出(登录为凭据被拒,其余为命令被拒);问候或任何时候收到 `* BYE`,此后断线即带其原话;非 ASCII 或带换行的参数以字面量发出,等服务器的 `+`;STARTTLS 之后若已有明文字节即拒绝;LIST 里带引号的邮箱名去掉转义(imap-proto 照原样留着),字面量照原样,列出的名字可直接打开;日志只记标签与命令名。POP3 由引擎自写(RFC 1939:问候、STLS、USER/PASS、AUTH XOAUTH2、UIDL、RETR、QUIT),换下 async-pop(它留着点填充、丢末尾 CRLF、吞掉登录后 CAPA 的连接错误、不答 XOAUTH2 的错误挑战、在 trace 日志里写出密码与邮件);STLS 之后若已有明文字节即拒绝;用户名与密码带换行即拒绝。SMTP 的 XOAUTH2 与事务由引擎自走(mail-send 遇错误挑战会重发令牌;它的 DATA 不给首行的点加点,且自加末尾 CRLF):信封地址照 RFC 5321 的 Mailbox 核对(点串或带引号的本地部分,域名或地址字面量;地址或信头有 UTF-8 时,服务器报 SMTPUTF8 才发出并带此参数,否则拒绝(RFC 6531)),不合即拒绝、不发任何命令;DATA 每行开头的点(含首行)加一个点,裸 CR 或 LF 即拒绝。服务器拒绝事务中的一步后发 RSET,会话可再发信。三协议的 XOAUTH2 被拒都以空行答错误挑战、留下服务器最后的原话。34 个测试(`cargo test -p ely-mail-engine`,含以真日志器在 trace 下跑三协议、日志里无密码、令牌与正文),真服务器未连。审查三轮:第 3 轮的两处(带引号的邮箱名、信头的 UTF-8)已修并以测试核对,未再审查(每条至多三轮)
|
||||
- [ ] C12.2 MIME(MAIL-04、05 的基础):mail-parser 读出头、纯文本、HTML、cid 内嵌图片、附件与原始头;mail-builder 写出多部分邮件、附件、Message-ID、In-Reply-To 与 References;样本 EML 往返测试
|
||||
- [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 个测试
|
||||
- [ ] C12.3 本地存储(§12.3、§18.1、§18.3):SQLite 索引(账号、邮箱、消息身份为账号加邮箱加 UIDVALIDITY 加 UID、标记、头字段)、原文按内容存进内容目录、FTS5 全文索引(可重建)、结构版本与升级前备份、升级失败只读;各类大小可查
|
||||
- [ ] C12.4 凭据与 OAuth(MAIL-02,§18.2):密码与刷新令牌只进系统凭据库(不可用即只留到本次退出并写明);Gmail 与 Microsoft 的授权码加 PKCE、本机回环、换令牌与刷新;客户端 ID 编进构建,缺 ID 即写明不可用;刷新被拒转为“需重新登录”;替身授权端点测试
|
||||
- [ ] C12.5 账号(MAIL-01、02):ElyMail 主窗口与添加账号对话框:Gmail、Microsoft、iCloud(应用专用密码向导)、其他 IMAP/SMTP、POP3/SMTP;服务器、端口、TLS 方式、认证方式;连通性检查逐项报结果;多账号;删除账号先写明删去的本地缓存、草稿与凭据,不动服务端
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
FAIL
|
||||
|
||||
- crates/ely-mail-engine/src/mime.rs:198 — [P1] Text attachments lose their original file bytes. `MessagePart::contents()` returns charset-decoded UTF-8 for `text/*`, rather than just transfer-decoded octets. A `text/plain; charset=iso-8859-1` attachment containing quoted-printable `Caf=E9` becomes `[67,97,102,195,169]` instead of `[67,97,102,233]`. Building and reading a `text/plain` attachment with those original bytes instead produces `[67,97,102,239,191,189]`. Saving or forwarding therefore changes the file silently. Preserve attachment octets after transfer decoding; apply charset conversion separately to displayed bodies. Add non-UTF-8 text attachment tests; the existing every-byte test only covers `application/octet-stream`. Both failures are recorded in `.work/C12.2-r1/probes.log`.
|
||||
|
||||
- crates/ely-mail-engine/src/mime.rs:104 — [P2] HTML body selection ignores `multipart/related`'s `start` parameter. With `type="text/html"; start="<root@example.com>"`, an image first and the HTML part with that Content-ID second, `read` returns `html: None` and puts both parts in `inline`. The actual message body is thus unavailable despite its explicit root declaration. Resolve the related root from `start` before choosing body parts, using the first child only when `start` is absent, as specified by [RFC 2387 §3.2](https://www.rfc-editor.org/rfc/rfc2387.html#section-3.2). Add this ordering to the MIME tests. Reproduced by `a_related_html_root_can_come_after_its_image` in the probe log.
|
||||
|
||||
- crates/ely-mail-engine/src/mime.rs:81 — [P2] An attached multipart entity disappears as an attachment and its contents become the parent message's body. For an outer `multipart/mixed` containing plain `body` followed by `multipart/alternative` with `Content-Disposition: attachment; filename=alternatives.mime`, whose text child contains `private attachment`, `read` returns `text: Some("body\nprivate attachment")` and zero attachments. The parent's disposition applies to the whole multipart entity ([RFC 2183 §2.9](https://www.rfc-editor.org/rfc/rfc2183.html#section-2.9)). Respect that disposition while traversing the MIME tree, retain the attached entity, and exclude its descendants from the main bodies. Add a regression for this structure; `multipart_attachment_is_not_displayed_as_parent_body` reproduces both incorrect outputs.
|
||||
|
||||
- crates/ely-mail-engine/src/mime.rs:228 — [P2] Joining every body part concatenates mutually exclusive alternatives. A `multipart/alternative` with an ASCII plain-text version `Cafe` followed by its UTF-8 plain-text version `Café` yields `Some("Cafe\nCafé")`. The reader cannot subsequently select a representation because the alternatives and their container relationship have already been discarded. Select the supported alternative within each alternative container instead of joining its siblings; concatenation can remain for independent mixed body segments. [RFC 2046 §5.1.4](https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.4) specifies alternative selection. Add a same-type alternative regression; `alternatives_of_the_same_type_are_not_concatenated` fails in the probe log.
|
||||
|
||||
- crates/ely-mail-engine/src/mime.rs:86 — [P2] A related image's `attachment` disposition prevents it from being exposed as a CID target. With an HTML root referencing `cid:image@example.com` and a related image carrying that Content-ID plus `Content-Disposition: attachment`, `read` returns an empty `inline` list and places the image only in `attachments`. Within a related object, disposition does not override the object's relationships ([RFC 2387 §4](https://www.rfc-editor.org/rfc/rfc2387.html#section-4)). Determine CID resources using their MIME container context so this image remains available to the HTML body. Add coverage for a related resource with this disposition; `related_cid_image_is_available_even_when_disposition_is_attachment` reproduces the failure.
|
||||
|
||||
- crates/ely-mail-engine/src/mime.rs:154 — [P2] Building attachments silently discards the supplied `Part.content_id`. An image attachment with `content_id: Some("image@example.com")` is written without any Content-ID header and reads back as `None`; HTML referencing that ID cannot resolve it. The supplied sample's PDF Content-ID is likewise lost, while the roundtrip test compares only name, type and bytes, concealing the loss behind the task's field-preservation claim. Serialize and validate the supplied Content-ID, and compare complete attachment metadata in the roundtrip test. Reproduced by `supplied_attachment_content_id_survives_build` in the probe log.
|
||||
|
||||
- crates/ely-mail-engine/src/mime.rs:186 — [P2] The missing Content-Type default is wrong for children of `multipart/digest`. A digest containing an enclosed message with no explicit part Content-Type is parsed as a message by mail-parser, but `leaf` labels its bytes `text/plain`. Consumers therefore receive the wrong attachment type, and rebuilding from that metadata emits a text attachment instead of an attached message. The default in this container is `message/rfc822` ([RFC 2046 §5.1.5](https://www.rfc-editor.org/rfc/rfc2046.html#section-5.1.5)). Derive the effective type from the parent context or the parsed message variant, retaining `text/plain` for ordinary untyped parts. Add a digest regression; `a_digest_child_defaults_to_message_rfc822` reproduces the mismatch.
|
||||
@@ -0,0 +1,17 @@
|
||||
FAIL
|
||||
|
||||
- crates/ely-mail-engine/src/mime/walk.rs:43 — [P1] Unbounded MIME traversal can abort the application. A valid 138,017-byte message containing 2,048 nested multipart/mixed containers parses into 2,049 parts successfully, but `read` then overflows the stack and terminates the Windows test process with `0xc00000fd`. This is a process abort, not a recoverable `Error::Mime`, and the mail engine runs inside the application. Replace recursive traversal with an explicit stack, or enforce a checked nesting limit that returns an error before exhausting the stack. Add a regression covering deeply nested input. Reproduced in `.work/C12.2-r2/depth.log`; the existing 46-test mail-engine suite passes.
|
||||
|
||||
- Cargo.toml:62 — [P2] The mail-parser dependency omits `full_encoding`, whose default is disabled. Its multibyte charset decoders consequently use lossy UTF-8 conversion. A valid `Subject: =?GB2312?B?1tDOxA==?=` and a text/plain body declaring `charset=gb2312` with base64 `1tDOxA==` both read as four replacement characters instead of `中文`, without a decoding warning. Enable the dependency's charset support and test multibyte encoded headers and bodies. The same isolated probe passes when only `mail-parser/full_encoding` is enabled; see `.work/C12.2-r2/encoding-before.log` and `encoding-feature.log`.
|
||||
|
||||
- crates/ely-mail-engine/src/mime/walk.rs:133 — [P2] Preserving text attachment octets now loses the charset needed to interpret those octets. `leaf` discards all Content-Type parameters. Reading a `text/plain; charset=iso-8859-1` attachment containing `Caf=E9`, copying its returned `Part` into `Draft.attachments`, and building it emits `Content-Type: text/plain` with the Latin-1 bytes unchanged. Parsing the forwarded attachment yields `Caf�`. Without the charset, the declared default is US-ASCII ([RFC 2046 §4.1.2](https://www.rfc-editor.org/rfc/rfc2046.html#section-4.1.2)). Preserve and serialize the parameters required to interpret attachment bytes, and test the recipient's decoded attachment text after forwarding. The existing byte-equality regression cannot detect this loss. Reproduced in `.work/C12.2-r2/probes.log`.
|
||||
|
||||
- crates/ely-mail-engine/src/mime.rs:159 — [P2] Multipart attachments returned by `read` cannot be forwarded as valid MIME. Their `bytes` include the entity headers, but `build` treats those bytes as a binary leaf: an attached multipart/alternative becomes `Content-Type: multipart/alternative` without a boundary, followed by a base64 encoding of its original headers and body. The recipient parser sees a binary leaf instead of the original multipart structure. Base64 transfer encoding is prohibited on multipart entities ([RFC 2045 §6.4](https://www.rfc-editor.org/rfc/rfc2045.html#section-6.4)). Serialize these attachments as complete MIME entities with their boundary and children and a permitted transfer encoding. Add a read/build/parse regression for the attached multipart covered by round 1. Reproduced in `.work/C12.2-r2/probes.log`.
|
||||
|
||||
- crates/ely-mail-engine/src/mime/walk.rs:104 — [P2] The round 1 related-disposition fix still excludes the related root. A multipart/related with `type="text/html"` whose first child is text/html with `Content-Disposition: attachment; filename=body.html` returns `html: None` and treats the root as an attachment, because `related` sends it through `body`'s attachment guard. Related processing must ignore the disposition type for the root as well as its sibling resources ([RFC 2387 §4](https://www.rfc-editor.org/rfc/rfc2387.html#section-4)). Carry that container context into root selection, while preserving the rules for the root's descendants, and add this root-disposition regression. Reproduced in `.work/C12.2-r2/related.log`.
|
||||
|
||||
- crates/ely-mail-engine/src/mime/walk.rs:114 — [P2] A multipart related resource loses its own entity, Content-ID and filename. With an HTML root and a sibling multipart/alternative carrying `Content-ID: <entity@example.com>` and `filename=entity.mime`, `resource` recursively flattens the sibling before inspecting its metadata. The result has no inline target for `entity@example.com`; only an unnamed text/plain descendant remains in attachments. The compound resource can neither be resolved by its CID nor saved with its original contents. Related processing must retain the correspondence between an entity, its headers and its Content-ID ([RFC 2387 §6.1](https://www.rfc-editor.org/rfc/rfc2387.html#section-6.1)). Preserve the resource entity and metadata when traversing composite resources, and add this regression. Reproduced in `.work/C12.2-r2/related.log`.
|
||||
|
||||
- crates/ely-mail-engine/src/mime/walk.rs:45 — [P2] The claimed non-related CID handling is bypassed for text/plain and text/html parts. In multipart/mixed, a plain body followed by a text/html resource with `Content-ID: <frame@example.com>` and inline disposition returns that resource as `Mail.html`, with an empty `inline` list. Its Content-ID and original part bytes are unavailable to callers, so a CID link to it cannot be resolved. This contradicts C12.2's explicit claim that non-attachment CID parts outside related containers become CID targets. Classify text resources using their container context before consuming them as body strings, and test text CID targets as well as binary images. Reproduced in `.work/C12.2-r2/related.log`.
|
||||
|
||||
- crates/ely-mail-engine/src/mime/walk.rs:170 — [P2] Unrecognized disposition types are silently treated as inline body text. A multipart/mixed containing plain `body` followed by text/plain with `Content-Disposition: x-file; filename=file.txt` and `private attachment` reads as `text: Some("body\nprivate attachment")` with zero attachments. Unrecognized disposition types should be handled as attachments, retaining their filename ([RFC 2183 §2.8](https://www.rfc-editor.org/rfc/rfc2183.html#section-2.8)). Apply that classification outside the related-container override and add an unknown-disposition regression. Reproduced in `.work/C12.2-r2/probes.log`.
|
||||
@@ -0,0 +1,7 @@
|
||||
FAIL
|
||||
|
||||
- crates/ely-mail-engine/src/mime/walk.rs:35 — [P1] The round 2 nesting fix still permits a process-aborting stack overflow through embedded messages. `sub_parts()` visits only multipart children, so 128 nested `message/rfc822` entities return `Ok` despite the claimed 64-level limit. With 8,192 such entities (262,188 bytes), `read` terminates the Windows process with `0xc00000fd`. The parser itself completes; dropping its recursively nested `PartType::Message` result overflows the stack. Count embedded-message nesting as well as multipart nesting, and ensure over-limit parser results can be rejected and disposed of without recursive stack exhaustion. Add an embedded-message regression; the current depth test covers only multipart containers. Reproduced in `.work/C12.2-r3/depth-128.log`, `depth-8192.log`, `depth-parse-forget.log`, and `depth-parse-drop.log`.
|
||||
|
||||
- crates/ely-mail-engine/src/mime/walk.rs:65 — [P2] Non-related multipart CID targets still disappear. In a `multipart/mixed` containing an HTML reference to `cid:compound@example.com` and an inline `multipart/alternative` sibling with that Content-ID and `filename=compound.mime`, `read` returns empty `inline` and `attachments` lists; only the sibling's descendant text survives. The multipart branch descends without retaining the containing entity, so its ID, filename and original bytes are unavailable. This leaves round 2's CID fix incomplete for composite parts and contradicts C12.2's non-attachment CID handling. Retain the composite entity as a CID target before traversing its contents, respecting the related-root exception, and test this structure. [RFC 2392 §2](https://www.rfc-editor.org/rfc/rfc2392.html#section-2) permits CID references to MIME entities. Reproduced in `.work/C12.2-r3/cid.log`.
|
||||
|
||||
- crates/ely-mail-engine/src/smtp.rs:104 — [P2] The new unencoded composite-forwarding path can send internationalized headers without SMTPUTF8. Build a draft with ASCII envelope and outer headers and a `message/global` attachment containing `Subject: 您好`. The attachment is emitted as 8bit, but `send` inspects only the outer header block for SMTPUTF8: against a TLS test server advertising 8BITMIME without SMTPUTF8, it sends the complete message with only `BODY=8BITMIME` and returns `Ok(())`. Such headers require SMTPUTF8 at any MIME depth ([RFC 6531 §3.2 and §3.4](https://www.rfc-editor.org/rfc/rfc6531.html#section-3.2)); 8BITMIME alone does not authorize them. Carry or determine the complete MIME message's SMTPUTF8 requirement before sending, reject unsupported servers, and include the parameter when supported. Add a built-message/SMTP regression covering embedded UTF-8 headers. Reproduced in `.work/C12.2-r3/embedded-header.log`.
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
Return-Path: <zhang@example.com>
|
||||
X-Custom: kept as sent
|
||||
From: =?UTF-8?B?5byg5LiJ?= <zhang@example.com>
|
||||
To: Bo Smith <bo@example.com>, cy@example.org
|
||||
Cc: Team: dee@example.net;
|
||||
Subject: =?UTF-8?Q?Caf=C3=A9_report?=
|
||||
Date: Mon, 19 Oct 2026 08:30:00 +0200
|
||||
Message-ID: <m1@example.com>
|
||||
In-Reply-To: <m0@example.com>
|
||||
References: <r1@example.com> <m0@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/mixed; boundary="mix"
|
||||
|
||||
--mix
|
||||
Content-Type: multipart/related; boundary="rel"
|
||||
|
||||
--rel
|
||||
Content-Type: multipart/alternative; boundary="alt"
|
||||
|
||||
--alt
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
Caf=C3=A9 numbers attached.
|
||||
--alt
|
||||
Content-Type: text/html; charset=utf-8
|
||||
|
||||
<p>Café <img src="cid:logo@example.com"></p>
|
||||
--alt--
|
||||
--rel
|
||||
Content-Type: image/png
|
||||
Content-ID: <logo@example.com>
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
iVBORw0KGgo=
|
||||
--rel--
|
||||
--mix
|
||||
Content-Type: application/pdf; name="report.pdf"
|
||||
Content-Disposition: attachment; filename="report.pdf"
|
||||
Content-ID: <att@example.com>
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
JVBERi0xLjQK
|
||||
--mix--
|
||||
Reference in new issue
Block a user