- 后端
- 前端
- 协同办公
- 即时通讯
- 企业应用
- CRM
- AI Agent
- 人工智能
【免费下载链接】macro
Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.
Macro 是一个为团队打造的统一工作空间,集成了邮件、聊天、文档、任务、Agent、通话与 CRM,并通过 @-链接和共享 AI 记忆把它们串在一起。其中的ses_clientcrate 承担了出站事务邮件的基础设施职责:在 dev/prod 环境走 AWS SES,在本地环境(设置了SMTP_HOST时)走本地 SMTP(Mailpit),并被authentication_service用于发送邀请邮件。读完本篇,你将理解它的双传输通道如何选择与驱动、两个发送路径的底层实现、邀请邮件模板的 HTML 转义与装配,以及它在测试中被 mock 的机制和设计边界。
一、ses_client 的定位与职责边界
ses_client是 Macro 共享基础设施的一部分,专门负责出站邮件的投递,而不是负责所有邮件文案的编写。它的职责边界非常清晰:
- 负责(Owns):选择并驱动邮件传输通道(SES 或本地 SMTP),以及邀请邮件模板的构建;
- 不负责(Not here):其他事务邮件模板。这些模板各自组装自己的 HTML,然后直接调用
send_email。
这一点很关键:ses_client提供的是一个“传输层”,上层业务只要把渲染好的 HTML 交给它即可,而不需要为每一类邮件新增一条传输分支。这正是它在 crates/ses_client/AGENTS.md 中反复强调的设计意图——“不要为每类邮件都加一条新的传输分支(don't add a new transport branch per email kind)”。
从依赖关系看,ses_client依赖macro_env_var来读取SMTP_HOST/SMTP_PORT这两个“逃生舱”环境变量;而它本身被authentication_service使用。这些依赖在 crates/ses_client/Cargo.toml 中可以得到印证:
[dependencies] anyhow = { workspace = true } aws-sdk-sesv2 = { workspace = true } html-escape = { workspace = true } # Local-mode SMTP delivery (Mailpit). rustls (no native-tls/openssl); the local # transport itself is plaintext via builder_dangerous. lettre = { version = "0.11", default-features = false, features = [ "builder", "smtp-transport", "tokio1-rustls-tls", ] } macro_env_var = { path = "../macro_env_var" } mockall = { workspace = true } tracing = { workspace = true }可以看到它同时引入了aws-sdk-sesv2(生产/开发环境)与lettre(本地 SMTP 投递),注释也明确说明了本地传输使用builder_dangerous以明文方式工作。
二、双传输通道架构:Transport枚举
整个 crate 的核心抽象是Transport枚举,它封装了“邮件到底怎么发出去”这一决策。定义在 crates/ses_client/src/lib.rs:
/// How outbound mail is delivered. SES in dev/prod; SMTP to a local Mailpit in /// local mode (selected by the `SMTP_HOST` env var). #[derive(Clone, Debug)] enum Transport { Ses(ses::Client), Smtp { host: String, port: u16 }, }Transport::Ses(ses::Client):走 AWS SESv2 SDK,用于 dev/prod;Transport::Smtp { host, port }:走本地 SMTP(Mailpit),由SMTP_HOST环境变量选中,用于本地开发。
而对外暴露的句柄是SesClient,它持有transport、invite_email(发件人地址)和environment三个字段:
#[derive(Clone, Debug)] pub struct SesClient { transport: Transport, invite_email: Option<String>, environment: String, }Ses这个名字通过#[cfg(test)]在测试与非测试之间切换指向不同的类型(见 crates/ses_client/src/lib.rs):
#[cfg(test)] pub use MockSesClient as Ses; #[cfg(not(test))] pub use SesClient as Ses;这意味着:上层业务代码统一使用ses_client::Ses这个类型别名,正式构建时它就是真正的SesClient,而一旦进入cargo test,它就自动变成了MockSesClient。这一技巧让业务方在集成测试里无需关心真实传输。
三、从环境装配客户端:from_env与SMTP_HOST逃生舱
客户端的构造入口是SesClient::from_env(见 crates/ses_client/src/lib.rs)。它的逻辑是:只要SMTP_HOST被设置且非空,就路由到本地 SMTP(Mailpit),否则回落到 SES:
/// Construct from the environment: route to local SMTP (Mailpit) when /// `SMTP_HOST` is set, otherwise SES. The SES client is still passed in (and /// kept for the SES path) so callers don't branch. pub fn from_env(inner: ses::Client, environment: &str) -> Self { let transport = match SmtpHost::new().and_then(|host| host.value().map(str::to_string)) { Some(host) if !host.is_empty() => { let port = SmtpPort::new() .and_then(|p| p.value().and_then(|p| p.parse().ok())) .unwrap_or(1025); Transport::Smtp { host, port } } _ => Transport::Ses(inner), }; Self { transport, invite_email: None, environment: environment.to_string(), } }这里有几个值得注意的实现细节:
SMTP_PORT默认值为1025:当SmtpPort未设置或解析失败时,端口回落到1025——这正是 Mailpit 默认监听的 SMTP 端口。- SES 客户端总是被传入并保留:即便最终走 SMTP 分支,
inner: ses::Client依然被传入from_env。注释解释了这样做是为了“调用方不用分支判断”,即让上层无需感知底层走的是哪条路径。 environment参数被记录下来,后续用于邀请邮件中区分prod/staging/dev的前缀。
SmtpHost/SmtpPort是通过macro_env_var的maybe_env_vars!宏声明的可选环境变量(见 crates/ses_client/src/lib.rs):
macro_env_var::maybe_env_vars! { struct SmtpHost; struct SmtpPort; }maybe_env_vars!生成的类型在变量缺失时返回None而非报错,因此“没设SMTP_HOST就走 SES”这一默认行为得以成立。这也符合 Macro 的配置规范:每个运行时的配置只有一个来源,缺失的值应当在启动时大声失败,而不是偷偷读取裸环境变量作为兜底(参见.agents/rules/config-and-service-urls.md)。
在authentication_service的启动流程中,Ses正是这样被装配的(见 services/authentication_service/src/server.rs):
// `from_env` routes to local SMTP (Mailpit) when SMTP_HOST is set, else SES. let ses_client = ses_client::Ses::from_env( aws_sdk_sesv2::Client::new(&aws_config), &config.environment.to_string(), );而在本地栈中,SMTP_HOST/SMTP_PORT被显式注入为mailpit/1025(见 tooling/xtask/crates/xtask_local/src/local/local_env.rs):
/// Mail: SES sends are routed to Mailpit SMTP (see the `ses_client` transport). struct MailEnv { smtp_host: &'static str, smtp_port: &'static str, sender_base_address: &'static str, } impl MailEnv { fn local() -> Self { MailEnv { smtp_host: "mailpit", smtp_port: "1025", sender_base_address: "macro.local", } } fn write(&self, env: &mut BTreeMap<String, String>) { env.insert("SMTP_HOST".into(), self.smtp_host.into()); env.insert("SMTP_PORT".into(), self.smtp_port.into()); env.insert("SENDER_BASE_ADDRESS".into(), self.sender_base_address.into()); } }这解释了本地开发时“邮件去了哪里”:所有本该发往 SES 的事务邮件,都被改投到 Mailpit 这个本地 SMTP 接收器,从而无需 SES 凭证即可在本地观察完整的邮件流。
四、SES 发送路径的实现
当Transport命中Ses分支时,deliver会调用 crates/ses_client/src/send_email.rs 中的send_email:
pub async fn send_email( client: &ses::Client, from_email: &str, to_email: &str, subject: &str, content: &str, ) -> anyhow::Result<()> { let mut dest: Destination = Destination::builder().build(); dest.to_addresses = Some(vec![to_email.to_string()]); let subject_content = Content::builder() .data(subject) .charset("UTF-8") .build() .context("building subject Content")?; let body_content = Content::builder() .data(content) .charset("UTF-8") .build() .context("building body Content")?; let body = Body::builder().html(body_content).build(); let msg = Message::builder() .subject(subject_content) .body(body) .build(); let email_content = EmailContent::builder().simple(msg).build(); client .send_email() .from_email_address(from_email) .destination(dest) .content(email_content) .send() .await?; Ok(()) }要点:
- 主题与正文都显式指定
UTF-8字符集,避免中文等非 ASCII 内容在邮件客户端中乱码; - 正文按 HTML 投递(
Body::builder().html(body_content)),因此content参数必须是渲染好的完整 HTML; - 只设置了单个
to_email,from_email通过from_email_address传入; - 错误通过
anyhow::Context附带上下文信息向上传递。
五、本地 SMTP(Mailpit)发送路径的实现
当Transport命中Smtp分支时,走 crates/ses_client/src/send_email_smtp.rs:
/// Send an HTML email over plaintext SMTP (no TLS/auth — Mailpit accepts any). pub async fn send_email_smtp( host: &str, port: u16, from_email: &str, to_email: &str, subject: &str, content: &str, ) -> Result<()> { let from = mailbox(from_email).context("parsing from address")?; let to = mailbox(to_email).context("parsing to address")?; let email = Message::builder() .from(from) .to(to) .subject(subject) .header(ContentType::TEXT_HTML) .body(content.to_string()) .context("building SMTP message")?; let mailer: AsyncSmtpTransport<Tokio1Executor> = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host) .port(port) .build(); mailer.send(email).await.context("sending via SMTP")?; Ok(()) } fn mailbox(email: &str) -> Result<Mailbox> { let address: Address = email .parse() .with_context(|| format!("invalid email '{email}'"))?; Ok(Mailbox::new(None, address)) }关键点:
builder_dangerous意味着明文、无 TLS、无鉴权——这是为了适配本地 Mailpit(它对任何发件方都接受)。文档特别警告:这条明文通道绝不能用在本地开发之外。- 通过
lettre的Message::builder()构造邮件,并显式声明ContentType::TEXT_HTML; mailbox辅助函数先把邮箱字符串解析成lettre::Address,再包装成Mailbox(无显示名),解析失败会带着具体邮箱值报出invalid email '...'上下文。cargo依赖里lettre使用tokio1-rustls-tls(rustls,非 native-tls/openssl),但本地这条传输本身是明文。
两条路径在deliver中做最终分派(见 crates/ses_client/src/lib.rs):
async fn deliver( &self, from_email: &str, to_email: &str, subject: &str, content: &str, ) -> anyhow::Result<()> { match &self.transport { Transport::Ses(client) => { send_email::send_email(client, from_email, to_email, subject, content).await } Transport::Smtp { host, port } => { send_email_smtp::send_email_smtp( host, *port, from_email, to_email, subject, content, ) .await } } }对外的两个高层方法——invite_user与send_email——都只是对deliver的薄封装,且都挂了#[tracing::instrument]便于追踪:
pub async fn send_email(&self, from_email, to_email, subject, content) -> anyhow::Result<()> { self.deliver(from_email, to_email, subject, content).await }六、邀请邮件模板与 HTML 转义
invite_user是SesClient提供的高层方法,它会先校验invite_email(发件人)已设置,再构建邀请 HTML 并投递(见 crates/ses_client/src/lib.rs):
pub async fn invite_user(&self, organization_name: &str, email: &str) -> anyhow::Result<()> { let Some(invite_email) = self.invite_email.clone() else { return Err(anyhow::anyhow!("invite_email is not set")); }; let html = invite_user::build_user_invite_message(organization_name, &self.environment); self.deliver( &invite_email, email, invite_user::INVITE_USER_SUBJECT, &html, ) .await }其中invite_email(发件人地址)通过链式构建器invite_email(&self, &str) -> Self设置(见 crates/ses_client/src/lib.rs)。
邀请邮件的正文由 crates/ses_client/src/invite_user.rs 构建,它把静态 HTML 模板 crates/ses_client/templates/invite_user.html 通过include_str!内联进来,然后替换两个占位符:
pub(crate) static INVITE_USER_SUBJECT: &str = "Invitation to Macro"; /// Builds the user invite message pub(crate) fn build_user_invite_message(org_name: &str, environment: &str) -> String { let prefix = match environment { "prod" => "".to_string(), _ => format!("{}.", environment), }; let result = include_str!("../templates/invite_user.html"); let result = result.replace("{PREFIX}", prefix.as_str()); result.replace("{ORG_NAME}", &html_escape::encode_text(org_name)) }这里有两个设计亮点:
- 环境前缀:
prod环境不添加前缀(即按钮直接指向https://macro.com/app/?login=true),而staging/dev等环境会在域名前加上staging./dev.,从而让邀请按钮指向对应环境的入口。 - HTML 转义:组织名
org_name会先经过html_escape::encode_text编码再替换进{ORG_NAME},防止组织名里的 HTML 标签(例如<img ... onerror=...>)被邮件客户端当作真实标签执行,避免 XSS 注入。
模板 crates/ses_client/templates/invite_user.html 是一份典型的“兼容邮件客户端”的表格布局 HTML:使用table布局、内联样式、<!--[if mso]>条件注释以兼容 Outlook,并带有响应式媒体查询。它引用了组织 Logo(通过 static-file-service 托管的固定 UUID 图片)与一个 CTA 按钮:
<a href="https://{PREFIX}macro.com/app/?login=true" ...>Accept invitation</a> ... You've been invited to join <strong>{ORG_NAME}</strong> on Macro.围绕这些行为,crate 自带了单元测试(见 crates/ses_client/src/invite_user/test.rs),验证了三个关键保证:
escapes_organization_html:组织名中的 HTML(如R&D <img src=x onerror=alert(1)>)必须被转义成R&D <img ...>,且原始未转义字符串不应出现在结果中;logo_keeps_the_png_aspect_ratio:Logo 图片必须保留width="54" height="36"的宽高比;preserves_organization_text_and_environment_links:对prod/staging/dev三种环境,组织名文本(含引号、破折号、日文字符東京)应原样保留,而链接前缀应正确切换为""/staging./dev.,且{ORG_NAME}/{PREFIX}占位符必须被完全替换掉。
这些测试为“转义 + 环境前缀 + 模板完整性”提供了可执行的回归保护。
七、测试中的 Mock 机制与一个“陷阱”
SesClient的impl块通过#[cfg_attr(test, automock)]在测试编译时自动生成MockSesClient(见 crates/ses_client/src/lib.rs):
#[cfg_attr(test, automock)] impl SesClient { pub fn from_env(inner: ses::Client, environment: &str) -> Self { ... } pub fn invite_email(mut self, invite_email: &str) -> Self { ... } pub async fn invite_user(&self, organization_name: &str, email: &str) -> anyhow::Result<()> { ... } pub async fn send_email(&self, ...) -> anyhow::Result<()> { ... } }配合前面提到的#[cfg(test)] pub use MockSesClient as Ses;,就产生了一个文档里明确点名的“陷阱(Gotcha)”:
#[cfg(test)] pub use MockSesClient as Ses意味着测试总能拿到 mock;在cargo test下没有任何途径去驱动真实的SesClient类型。
换言之,cargo test -p ses_client验证的是 mock 路径与纯函数(如邀请模板构建),而非真实的 SES/SMTP 网络调用。这是刻意的设计:把有副作用的传输隔离在 mock 之后,让单元测试保持无网络、可离线运行。运行验证命令在 crates/ses_client/AGENTS.md 中给出:
cargo test -p ses_client # 保持 SQLX_OFFLINE 未设置八、在authentication_service中的实际用法
ses_client::Ses被注入到authentication_service的 API 上下文里(见 services/authentication_service/src/api/context.rs):
pub ses_client: Arc<ses_client::Ses>,一个真实调用示例是“账户合并请求”流程(见 services/authentication_service/src/api/merge/create_merge_request.rs):业务先用自己的模板渲染出 HTML(MERGE_REQUEST_TEMPLATE),替换掉{{EMAIL}}与{{CODE}}占位符,然后直接调用通用的send_email:
let content = MERGE_REQUEST_TEMPLATE .replace("{{EMAIL}}", &user_profile.email) .replace("{{CODE}}", &code); if let Err(e) = ctx .ses_client .send_email( "auth@macro.com", // from email &req.email, // to email "Account Merge Request", // subject &content, // content ) .await { tracing::error!(error=?e, "failed to send email"); return Err((StatusCode::INTERNAL_SERVER_ERROR, ...).into_response()); }这恰好印证了文档的“Recipes”建议:要发一种新邮件,就自己构建 HTML,然后调用Ses::send_email(或参照invite_user作为模板)——不要为每类邮件新增一条传输分支。传输通道的选择(SES vs Mailpit)被完全封装在SesClient内部,业务方无感知。
九、设计原则小结与注意事项
综合文档与源码,ses_client遵循以下几条设计原则,值得在扩展时遵守:
- 单一传输决策点:SES 与本地 SMTP 的分派只发生在
from_env(构造时)与deliver(投递时)两处,新增邮件类型不应再引入新的传输分支; - 模板与传输解耦:邀请邮件模板(
invite_user)是本 crate 的资产,其他事务模板各自渲染 HTML 后复用send_email; - 配置只认单一来源:
SMTP_HOST/SMTP_PORT通过macro_env_var的maybe_env_vars!读取,符合 Macro “每个运行时配置一个来源”的规范; - 安全边界:本地 SMTP 是明文(
builder_dangerous),严禁在非本地开发环境启用; - 可测试性:测试编译下
Ses即MockSesClient,cargo test永远走 mock,无法触达真实SesClient。
运行与验证方面:在 dev/prod 下它使用 AWS SES 投递;在本地栈下,SMTP_HOST=mailpit、SMTP_PORT=1025会把邮件改投到 Mailpit,开发者可在本地 UI 中检视邮件流;单元测试通过cargo test -p ses_client执行(保持SQLX_OFFLINE未设置)。
相关文件索引:
- 核心入口与
Transport:crates/ses_client/src/lib.rs - SES 发送路径:crates/ses_client/src/send_email.rs
- 本地 SMTP 发送路径:crates/ses_client/src/send_email_smtp.rs
- 邀请邮件模板构建:crates/ses_client/src/invite_user.rs 与 crates/ses_client/templates/invite_user.html
- 模板单元测试:crates/ses_client/src/invite_user/test.rs
- 依赖声明:crates/ses_client/Cargo.toml
- 实际使用方:services/authentication_service/src/server.rs、services/authentication_service/src/api/context.rs
- 本地环境注入:tooling/xtask/crates/xtask_local/src/local/local_env.rs
- 配置规范:.agents/rules/config-and-service-urls.md
- 后端
- 前端
- 协同办公
- 即时通讯
- 企业应用
- CRM
- AI Agent
- 人工智能
【免费下载链接】macro
Macro is a unified workspace for teams: email, chat, docs, tasks, agents, calls, and CRM — @-linked together with shared AI memory.
相关推荐
freehire 邮件通知通道实现解析:用 AWS SES v2 + notify.Router 为筛选订阅投递摘要邮件
freehire 邮件通知通道实现解析:用 AWS SES v2 + notify.Router 为筛选订阅投递摘要邮件 本文以 freehire 仓库中「em
后端前端搜索引擎网页爬虫数据工程AI 应用可观测性Linux 内核通用通知机制(watch_queue)深度解析:基于管道的内核事件订阅与投递
Linux 内核通用通知机制(watch_queue)深度解析:基于管道的内核事件订阅与投递 通用通知机制(general notification mecha
操作系统驱动开发内核驱动freehire 邮件通知通道实战:基于 AWS SES 为订阅摘要增加第二种投递渠道
freehire 邮件通知通道实战:基于 AWS SES 为订阅摘要增加第二种投递渠道 本文以 freehire 的变更文档 proposal.md https
后端前端搜索引擎网页爬虫数据工程AI 应用可观测性
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考