- CLI
【免费下载链接】himalaya
CLI to manage emails
导读
本文围绕 himalaya 的message.send.save-copy配置项展开,讲解如何在发送命令(message send,以及带--send的message compose、message reply、message forward)未显式传入--save时,将已发送邮件的副本自动归档到指定邮箱,并深入剖析其底层实现(Account::resolve_save的解析优先级、handler::apply的“先发送后保存”顺序),帮助你按账号配置发送副本策略,避免 SMTP 发信后无处留存的问题。
背景:为什么需要“发送即保存副本”
himalaya v2 在重构时移除了 v1 中的message.send.save-copy配置。结果就是:通过 SMTP 发送的邮件,除非每次调用命令都显式传--save,否则发送成功后不会有任何副本留存。而不同的邮件服务商行为并不一致:
- Gmail 和 Microsoft Graph:服务端会自动把已发送邮件归档进 Sent 文件夹,无需客户端保存副本;
- SMTP(如自建服务器、Fastmail/Posteo 的 SMTP 通道):服务端不会保存副本,发送后本地无痕。
按账号来配置这个需求尤为关键:同一个 himalaya 实例可能同时管理 Gmail 与 SMTP 账号,前端(himalaya-emacs、himalaya-vim、himalaya-tui)无法预知每个账号走的是哪种后端。与其在每个前端各自添加保存选项,不如在核心配置层为每个账号指定“发件副本去哪”。
另外,旧行为还有一个隐患:--save是在发送之前追加副本的。如果发送失败,就会留下一个“从未真正发出”的邮件副本。新实现将保存顺序调整为“先发送、后保存”,彻底规避了这个问题。
配置项:message.send.save-copy
取值类型
message.send.save-copy支持两种取值形式,由 src/config.rs 中的SaveCopyConfig枚举通过#[serde(untagged)]反序列化解析:
| 写法 | 含义 | 示例 |
|---|---|---|
布尔值true | 表示“sent”角色邮箱(即 v1 的语义) | message.send.save-copy = true |
布尔值false | 不保存副本(v1 语义) | message.send.save-copy = false |
| 字符串 | 邮箱名、别名(alias)或角色(role) | message.send.save-copy = "Sent Items"、message.send.save-copy = "sent" |
字符串解析与--save完全一致:可以是邮箱的实际名称(如"Sent Items")、配置在mailbox.alias中的别名,或者角色名(如sent、inbox、archive)。
全局与账号级覆盖
该配置可以写在全局[message.send]区域,也可以写在账号的[accounts.<name>]中,账号级覆盖全局。这一“全局 + 每账号覆盖”的合并逻辑由 Account::merge 实现:save_copy: other.save_copy.or(self.save_copy),即账号配置存在时优先采用账号值,否则回退到全局值。
参考 config.sample.toml 中的官方注释示例:
# 全局配置:所有账号默认将已发送副本存到 sent 邮箱 [message.send] save-copy = "sent" [accounts.example] email = "alice@example.org" # 账号级覆盖:该账号发送副本存入 "Sent Items" #message.send.save-copy = "Sent Items"来自 config.sample.toml 与 config.sample.toml 的说明还特别提醒:Gmail 和 Microsoft Graph 账号应当保持该项不设置,因为这两个后端自己会归档已发送邮件,重复保存反而多此一举。
v1 兼容:message表开放未知键
himalaya v1 的[message]表包含read、write、delete、send.backend等大量键。为保证 v1 配置在 v2 中继续加载,MessageConfig 特意不再使用deny_unknown_fields:
#[serde(rename_all = "kebab-case")] pub struct MessageConfig { #[serde(default)] pub send: MessageSendConfig, }也就是说,message表接受未知键,v1 的[message]表其余内容照常解析,不会因出现新版本不认识(或已移除)的键而整体报错。这一点由单元测试 save_copy_reads_a_v1_boolean_beside_v1_keys 直接验证:同时携带message.send.save-copy = true、message.send.backend = "smtp"、message.read.format = "plain"的 v1 风格配置可以完整反序列化,且save-copy = true被解析为sent角色。
命令行:--save与--no-save
新增--no-save
为了让用户在一次调用中跳过配置好的副本,四个发送命令都新增了--no-save标志:
message sendmessage compose(配合--send发送时)message reply(配合--send发送时)message forward(配合--send发送时)
以 send.rs 中的MessageSendCommand为例:
/// Append a copy of the sent message to this mailbox name, alias or /// role, overriding `message.send.save-copy`. #[arg(long, value_name = "MAILBOX")] pub save: Option<String>, /// Skip the copy `message.send.save-copy` configures. #[arg(long, conflicts_with = "save")] pub no_save: bool,注意#[arg(long, conflicts_with = "save")]:--no-save与--save互相冲突,同时传入会直接报错退出,避免语义歧义。compose、reply、forward三个命令的 CLI 定义与此完全同构(见 compose.rs、reply.rs、forward.rs)。
优先级
实际生效的保存目标由 Account::resolve_save 统一裁决,优先级从高到低:
--save <mailbox>显式指定:永远优先,覆盖一切配置;--no-save:本次发送跳过副本;message.send.save-copy配置:作为兜底;- 两者皆无且未发送:不保存。
核心逻辑:
pub fn resolve_save<'a>( &'a self, over: Option<&'a str>, no_save: bool, send: bool, ) -> Option<&'a str> { if over.is_some() || no_save || !send { return over; } match self.save_copy.as_ref()? { SaveCopyConfig::Enabled(true) => Some(MailboxRole::Sent.as_str()), SaveCopyConfig::Enabled(false) => None, SaveCopyConfig::Mailbox(mailbox) => Some(mailbox), } }这段代码还揭示了一个重要语义:配置的副本只在“发送”时生效。即使用户配置了save-copy,如果只是执行不带--send的message compose(仅撰写不发送),resolve_save(..., send = false)也会返回None,不会把草稿写进副本邮箱。保存草稿仍需显式--save。这一点由测试 resolve_save_falls_back_on_the_copy_only_when_sending 锁定:
assert_eq!(account.resolve_save(None, false, true), Some("Sent Items")); // 发送时回退到配置 assert_eq!(account.resolve_save(None, false, false), None); // 不发送时忽略配置 assert_eq!(account.resolve_save(Some("Archive"), false, true), Some("Archive")); // --save 覆盖 assert_eq!(account.resolve_save(None, true, true), None); // --no-save 跳过底层实现:先发送、后保存
handler::apply的执行顺序
无论是--save与发送命令组合,还是message add --send,保存与发送的统一执行入口都是 handler::apply。其顺序保证为:
- 先发送:
client.send_message(sent, raw.clone())?,发送失败则整个命令以错误退出,不留下任何副本; - 后保存:发送成功后再
client.add_message(mailbox, flags, raw)追加副本; - 保存失败:发送已成功、保存失败时,错误上下文明确告知“邮件已发送但副本保存失败”。
对应关键代码:
// NOTE: a deferred send is filed under the saved copy's mailbox, else // under the one the account names as sent. let queued = match send { true => { let sent = mailbox.or_else(|| account.mailbox_alias.get("sent").map(String::as_str)); client.send_message(sent, raw.clone())? } false => None, }; let saved_id = match mailbox { Some(mailbox) if send => Some( client .add_message(mailbox, flags, raw) .with_context(|| format!("Message sent, but saving a copy to {mailbox} failed"))?, ), ... };当发送失败时,send_message返回错误,后续的add_message根本不会执行——这正是“失败发送不产生副本”的保证来源。
相关细节
- 副本附加
\Seen标志:handler::route调用apply时传入&[Flag::from_iana(IanaFlag::Seen)],即保存的副本会被标记为已读(见 handler.rs)。 - 副本邮箱经别名解析:
apply中的let mailbox = save.map(|name| account.resolve_mailbox(name))会先经过mailbox.alias别名解析,再落到后端(见 handler.rs)。 - 输出提示:发送并保存成功输出
Message successfully saved and sent;仅保存输出Message successfully saved;仅发送输出相应提示(见 handler.rs)。 - 发送回退邮箱:发送时若无副本邮箱,
send_message会回退到mailbox.alias中名为sent的别名,保证 pimdir 等延迟发送(queued send)场景下发送记录能落到正确邮箱。
各命令用法速查
以下命令均适用于配置了save-copy的账号:
# 发送原始消息文件,并自动把副本存到 save-copy 配置的邮箱 himalaya message send --file ./message.eml # 发送并用 --save 临时覆盖配置,存到 Archive himalaya message send --file ./message.eml --save Archive # 本次发送不保存任何副本 himalaya message send --file ./message.eml --no-save # 撰写并发送,自动保存副本 himalaya message compose --send # 回复并发送,自动保存副本 himalaya message reply --send 42 # 转发并发送,自动保存副本 himalaya message forward --send 42要点总结:
--no-save与--save冲突,不能同时使用;--save优先级高于配置文件;- 副本只在“发送”动作发生时自动保存,纯撰写不会触发。
已知限制与后续规划
依据 proposal.md 的说明,本变更落地时明确了两个未覆盖的边界:
- 向导(wizard)不会写入该配置:
himalaya wizard生成的配置不含save-copy,因为 IMAP 后端要等imap-special-use-aliases(LISTRETURN (SPECIAL-USE))能力落地后才能可靠解析sent角色;在此之前,向导生成一个save-copy = "sent"会在每次 IMAP 发送副本时失败。 - IMAP 的
sent角色依赖:在 SPECIAL-USE 支持到位前,IMAP 账号需手动配置mailbox.alias.sent或用真实邮箱名作为save-copy值,副本保存才能指向正确位置。
验证记录与源码导读
该变更已随 himalaya 落地(cairn 状态status: landed,2026-10-01),并完成了完整的端到端验证(见 cairn/log/2026-10-01-message-send-save-copy.md):使用 Maildir 账号加脚本化 SMTP 服务器,覆盖了“配置副本生效”“--no-save跳过”“--save覆盖配置”“发送被拒不留副本”“副本保存失败时报告已发送”“compose --send组合”六类场景。
如需深入源码,建议按以下路径阅读:
- 配置解析:src/config.rs(
MessageConfig/MessageSendConfig/SaveCopyConfig) - 账号合并与解析:src/account/context.rs(
Account::resolve_save)及同文件测试段 src/account/context.rs - 命令定义:src/shared/message/send.rs、compose.rs、reply.rs、forward.rs
- 发送/保存统一入口:src/shared/message/handler.rs
- 样例配置:config.sample.toml
通过message.send.save-copy,himalaya 把“发件副本去哪”的决定权收敛到了每个账号的配置文件里,配合--save/--no-save的临时覆盖,以及“先发送后保存”的顺序保证,为多后端、多账号场景下的发件留档提供了统一且可靠的方案。
- CLI
【免费下载链接】himalaya
CLI to manage emails
相关推荐
himalaya v2 配置指南:用 `message.send.save-copy` 让每次发送都自动归档副本
himalaya v2 配置指南:用 message.send.save copy 让每次发送都自动归档副本 message.send.save copy 是
CLIhimalaya 邮件发送副本保存:`message.send.save-copy` 配置解析与"先发送后保存"语义
himalaya 邮件发送副本保存: message.send.save copy 配置解析与"先发送后保存"语义 本文围绕 himalaya v2 新增的 m
CLIInstatic 多语言支持:一个字段到整站多语言的最快路径
Instatic 多语言支持:一个字段到整站多语言的最快路径 给站点加个日语版,你大概打算把整站页面手动复制一遍。复制了二十页卡住了,导航还指着英文版。Inst
CLI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考