aster_forge_mail
aster_forge_mail 提供产品无关的邮件基础机械层。它拥有 Aster 产品共享的邮件状态、模板 code 和投递控制流,但不拥有 SMTP 配置 key、产品模板 payload、用户上下文、审计记录或数据库实体。
当前这个 crate 主要覆盖各服务里重复出现的 outbox 投递机制:
- SMTP / sender / 模板配置值的产品无关规范化;
- SMTP runtime settings 模型、默认端口和 readiness 判定;
- 投递计数统计;
- 共享 Aster
MailTemplateCode、MailOutboxStatus和StoredMailPayload; - 重试延迟策略;
- 投递错误截断;
- SMTP 成功后对
mark_sent的最佳努力重试。 - 邮件 envelope / body 数据模型;
MailSendertrait、SMTP sender、memory sender;- 邮件模板注册、变量元数据和 placeholder 渲染。
适用场景
- 产品有自己的
mail_outbox表,希望复用 dispatch 统计。 - 产品希望在临时投递失败后采用统一的重试延迟。
- 产品希望对持久化错误做 UTF-8 安全截断。
- 产品希望在 SMTP 已接受邮件后,对
mark_sent做共享重试。 - 产品希望复用
MailRecipient/MailMessage这类不带发送语义的数据模型。 - 产品希望复用统一的 SMTP 发送实现和内存测试 sender。
- 产品希望用注册式方式维护模板变量,并复用 HTML escape、placeholder 替换和 text fallback 生成。
- 产品希望复用 SMTP host/port、sender address/name、mail security、模板 subject/body 的规范化规则。
- 产品希望复用统一的 SMTP runtime settings 结构和“是否可投递”判定。
不适合放在这里的内容:
- 产品模板 payload、渲染逻辑和本地化文案。
- 产品配置 key 和默认值。
- 审计动作。
- 用户 ID 或用户上下文。
- runtime 配置 key 和 runtime config 读取函数。
- 具体 SeaORM repository。
- 具体 payload enum、业务 URL 生成和本地化文案。
Config Normalization
模块:aster_forge_mail::config
主要类型和函数:
MailConfigErrorMailConfigResult<T>MailRuntimeSettingsparse_smtp_port(value)normalize_smtp_host_config_value(value)normalize_smtp_port_config_value(value)normalize_mail_address_config_value(value)normalize_mail_name_config_value(value)normalize_mail_security_config_value(value)normalize_mail_template_subject_config_value(key, value)normalize_mail_template_body_config_value(key, value)DEFAULT_MAIL_SMTP_PORTDEFAULT_MAIL_SECURITYMAIL_TEMPLATE_MAX_SUBJECT_LENMAIL_TEMPLATE_MAX_BODY_LEN
这些 helper 只处理产品无关的存储值规范化:
- SMTP host 会 trim、转小写,空值表示未配置,非空值不能包含空白字符。
- SMTP port 必须是
1..=65535的整数。 - sender address 会 trim、转小写,空值表示未配置,非空值必须符合 Aster 轻量 email 规则。
- sender name 会 trim,最长 128 字符。
- mail security 接受
true/false、1/0、yes/no、on/off,存储为true/false。 - template subject 会 trim,不能为空,不能包含换行,最长 255 字符。
- template body 会把 CRLF/CR 规范化成 LF,不能为空,最长 64 KiB。
MailRuntimeSettings 是发送前的产品无关 SMTP 设置快照:
is_configured()要求smtp_host和from_address非空;is_ready_for_delivery()在已配置基础上,要求smtp_username和smtp_password同时为空或同时非空(按 trim 后判定);transport 构造侧用同一 trim 口径决定是否挂凭据,纯空白用户名不会触发认证。
产品侧仍然负责:
- 配置 key、默认值、敏感标记和前端 schema;
- 从自己的 runtime config 读取并构造
MailRuntimeSettings; - 是否允许在业务流程中静默跳过未配置 SMTP;
MailConfigError到产品 API 错误码的映射。
示例:
pub fn normalize_mail_security_config_value(value: &str) -> Result<String> {
aster_forge_mail::normalize_mail_security_config_value(value)
.map_err(|error| AsterError::validation_error(error.to_string()))
}Cargo
[dependencies]
aster_forge_mail = { git = "https://github.com/AsterCommunity/AsterForge" }默认 feature 提供 sender、template、config normalizer 和产品无关 outbox dispatch 控制流。
如果产品要把 MailTemplateCode、MailOutboxStatus、StoredMailPayload 作为 SeaORM 持久化类型使用,开启:
aster_forge_mail = { git = "https://github.com/AsterCommunity/AsterForge", features = ["persistence"] }如果产品要接入 mail_outbox_component(...),开启:
aster_forge_mail = { git = "https://github.com/AsterCommunity/AsterForge", features = ["runtime-component"] }runtime-component 会启用 aster_forge_runtime 和 aster_forge_tasks/runtime-component,因为 mail outbox drain 的标准 shutdown 顺序依赖 background_tasks。
Sender
模块:aster_forge_mail::sender
主要类型和函数:
MailSenderMailDeliveryErrorMailSendResult<T>SmtpMailSenderMemoryMailSendersmtp_sender(settings_provider)memory_sender()memory_sender_ref(sender)send_rendered_with(sender, settings, to, rendered)DEFAULT_SMTP_SEND_TIMEOUT_SECS
SmtpMailSender 在每次投递前调用 settings_provider,因此产品侧可以继续从自己的 runtime config 快照读取最新 SMTP 设置:
pub fn runtime_sender(runtime_config: Arc<RuntimeConfig>) -> Arc<dyn aster_forge_mail::MailSender> {
aster_forge_mail::smtp_sender(move || runtime_mail_settings(&runtime_config))
}send_rendered_with 只负责把 RenderedMail 和 MailRuntimeSettings 中的 sender identity 组装成 MailMessage,再交给传入的 sender。产品侧仍然负责把 MailDeliveryError 映射成自己的错误类型:
fn map_mail_delivery_error(error: aster_forge_mail::MailDeliveryError) -> AsterError {
match error {
aster_forge_mail::MailDeliveryError::NotConfigured(message) => {
AsterError::mail_not_configured(message)
}
aster_forge_mail::MailDeliveryError::InvalidMessage(message) => {
AsterError::validation_error(message)
}
aster_forge_mail::MailDeliveryError::Config(message) => AsterError::config_error(message),
aster_forge_mail::MailDeliveryError::Delivery(message) => {
AsterError::mail_delivery_failed(message)
}
aster_forge_mail::MailDeliveryError::Internal(message) => AsterError::internal_error(message),
}
}Outbox
模块:aster_forge_mail::outbox
主要类型和函数:
DispatchStatsMailTemplateCodeMailOutboxStatusStoredMailPayloadMailOutboxDispatchConfigMailOutboxDispatchRowMailOutboxRetryPolicyMailOutboxDeliveryFailureDecisiondispatch_mail_outbox(config, callbacks...)drain_mail_outbox(config, dispatch)DEFAULT_ERROR_MAX_LENDEFAULT_MARK_SENT_RETRY_DELAYS_MSretry_delay_secs(attempt_count)truncate_error(error, max_len)retry_mark_sent(id, retry_delays_ms, mark_sent)
MailTemplateCode 只标准化当前 Aster 产品共享的持久化模板 code,例如 password_reset、external_auth_email_verification 和 user_invitation。产品仍然维护自己的 payload enum、模板文件、链接构造、变量列表和渲染分支。共享数据库 schema 给 template_code 预留 64 字节;新增共享模板 code 时要保持稳定、snake_case,并避免超过这个长度。
统计和策略
DispatchStats 是一个轻量计数器:
use aster_forge_mail::DispatchStats;
let mut total = DispatchStats::default();
total.merge(DispatchStats {
claimed: 1,
sent: 1,
retried: 0,
failed: 0,
});MailOutboxRetryPolicy 描述产品无关的重试决策。产品代码传入“本次投递后的 attempt count”和原始错误,Forge 返回一个明确的 MailOutboxDeliveryFailureDecision:
use aster_forge_mail::{
DEFAULT_ERROR_MAX_LEN, MailOutboxDeliveryFailureDecision, MailOutboxRetryPolicy,
};
let policy = MailOutboxRetryPolicy::new(6, DEFAULT_ERROR_MAX_LEN);
let decision = policy.delivery_failure_decision(attempt_count, delivery_error.to_string());
match decision {
MailOutboxDeliveryFailureDecision::PermanentFailure {
attempt_count,
error_message,
} => {
// 产品代码标记 failed,并写入自己的审计字段。
}
MailOutboxDeliveryFailureDecision::Retry {
attempt_count,
retry_delay_secs,
error_message,
} => {
// 产品代码用自己的时钟和数据库层写 next_attempt_at。
}
}Forge 只决定“永久失败还是重试”和“错误字符串如何截断”。数据库状态、审计、时间戳和事务仍然由产品侧处理。
Dispatch Loop
dispatch_mail_outbox 把 outbox 的控制流收进 Forge:
- 拉取可 claim 的 rows;
- 对每行按
id执行 claim; - 成功 claim 后把 row move 给产品传入的 deliver;
- deliver 成功后用
retry_mark_sent尽力标记 sent; - deliver 失败后按
MailOutboxRetryPolicy决定 retry / failed,并按id标记状态; - 调用产品传入的 audit callback,callback 收到轻量
MailOutboxDispatchContext; - 返回统一的
DispatchStats。
如果产品使用 aster_forge_db 的共享 mail_outbox 表,优先调用 MailOutboxDbStore::dispatch_due(...),不要直接拼这些 callback。直接接入 dispatch_mail_outbox(...) 主要留给非 Forge DB store 或定制 outbox persistence。 这类高级接入仍然由产品侧保留具体 entity/repository、claimable 查询条件、timestamp 计算、 模板渲染、audit 写入和产品错误类型。 dispatcher 不会 clone 完整 row;它只会在 deliver 前捕获 id、attempt、template code、收件人地址和可选收件人名称,避免复制可能较大的 payload JSON。
产品的 row model 只需要实现最小 metadata trait:
impl aster_forge_mail::MailOutboxDispatchRow for mail_outbox::Model {
fn id(&self) -> i64 { self.id }
fn attempt_count(&self) -> i32 { self.attempt_count }
fn template_code(&self) -> &str { self.template_code.as_str() }
fn to_address(&self) -> &str { &self.to_address }
fn to_name(&self) -> Option<&str> { self.to_name.as_deref() }
}drain_mail_outbox 负责多轮调用产品提供的 dispatch 函数,直到没有 row 被 claim,或者达到 drain_max_rounds。
Runtime Component
模块:aster_forge_mail::component
MAIL_OUTBOX_COMPONENT 是稳定组件名,默认 API 可用,方便 database、audit 等 crate 声明依赖而不启用 mail runtime 组件。
MAIL_OUTBOX_DRAIN_SHUTDOWN_PHASE 和 mail_outbox_component(...) 需要 Cargo feature:runtime-component。
如果产品已经使用 aster_forge_runtime::AsterRuntime component 模式,mail outbox 的 shutdown graph 不应该在产品侧重复写。Forge 提供标准组件:
MAIL_OUTBOX_COMPONENT:mail_outboxMAIL_OUTBOX_DRAIN_SHUTDOWN_PHASE:mail_outbox_drainmail_outbox_component(resources, drain)
标准依赖关系是:
background_tasks -> mail_outbox -> database也就是先停止后台 worker,再 drain 已 claim / 可 claim 的邮件 outbox,最后关闭数据库连接。产品仍然提供资源和 drain callback:
pub fn mail_outbox_component(
resources: MailOutboxRuntimeResources,
) -> RuntimeComponentBundleRegistration<
aster_forge_runtime::ShutdownResourceComponent<MailOutboxRuntimeResources>,
> {
aster_forge_mail::mail_outbox_component(resources, |resources| async move {
product_mail_outbox::drain_with(
&resources.db,
&resources.runtime_config,
&resources.mail_sender,
)
.await
.map(|_| ())
.map_err(|error| error.to_string())
})
}产品侧不要再手写 "mail_outbox"、"mail_outbox_drain" 和 background_tasks 依赖。需要声明数据库 shutdown 依赖时,使用 aster_forge_mail::MAIL_OUTBOX_COMPONENT。
mark_sent 重试
retry_mark_sent 缩小了一个典型窗口:SMTP 已成功,但数据库行仍然是 Processing。
use aster_forge_mail::{DEFAULT_MARK_SENT_RETRY_DELAYS_MS, retry_mark_sent};
let updated = retry_mark_sent(
outbox_id,
DEFAULT_MARK_SENT_RETRY_DELAYS_MS,
|id, _attempt| async move {
product_mail_outbox_repo::mark_sent(db, id, now()).await
},
)
.await?;产品回调负责时间戳、repository 调用、错误类型、事务和日志上下文。Forge 只负责重试循环和延迟调度。
Message Model
模块:aster_forge_mail::message
主要类型:
MailRecipientMailMessage
这两个类型只表达已经渲染好的邮件 envelope 和 body:
use aster_forge_mail::{MailMessage, MailRecipient};
let message = MailMessage {
from: MailRecipient {
address: "no-reply@example.com".to_string(),
display_name: Some("Aster".to_string()),
},
to: MailRecipient {
address: "user@example.com".to_string(),
display_name: None,
},
subject: "Welcome".to_string(),
text_body: "Welcome to Aster.".to_string(),
html_body: "<p>Welcome to Aster.</p>".to_string(),
};产品侧仍然负责错误映射、审计、metrics、outbox 状态和业务投递策略。
Template Registry
模块:aster_forge_mail::template
主要类型和函数:
MailTemplateDefinitionMailTemplateRegistryMailTemplateCatalogMailTemplateCatalogBuilderMailTemplateRegistrarMailTemplateRegistryErrorTemplateVariableSpecTemplateVariableGroupTemplateVariableItemTemplatePlaceholderSetRenderedMailrender_template(subject_template, html_template, placeholders)render_placeholders(template, values)(单遍扫描替换:值里出现的不会被二次展开,未知或未闭合的 token 保持字面)escape_html(value)html_to_text(html)(head/script/style/title 内容剔除,闭合标签弹到最近匹配项以容错误嵌套;HTML 实体单遍解码,&最后处理避免双重解码)
Forge 不定义任何产品模板 code,也不解析产品 payload。产品侧把自己的模板注册成静态定义:
use aster_forge_mail::{MailTemplateDefinition, MailTemplateRegistry, TemplateVariableSpec};
const USERNAME: TemplateVariableSpec = TemplateVariableSpec::new(
"username",
"settings_template_variable_username_label",
"settings_template_variable_username_desc",
);
const SITE_NAME: TemplateVariableSpec = TemplateVariableSpec::new(
"site_name",
"settings_template_variable_site_name_label",
"settings_template_variable_site_name_desc",
);
const WELCOME_VARIABLES: &[TemplateVariableSpec] = &[USERNAME, SITE_NAME];
const DEFINITIONS: &[MailTemplateDefinition] = &[MailTemplateDefinition::new(
"welcome",
"mail_template",
"settings_mail_template_group_welcome",
WELCOME_VARIABLES,
)];
let registry = MailTemplateRegistry::new(DEFINITIONS);
registry.validate()?;
let groups = registry.variable_groups();如果一个产品由多个子系统分别提供模板,可以用 MailTemplateCatalogBuilder 做组合注册。这样主程序只负责装配,子系统自己导出自己的静态 definitions:
use aster_forge_mail::{MailTemplateCatalog, MailTemplateDefinition};
static ACCOUNT_TEMPLATE: MailTemplateDefinition = MailTemplateDefinition::new(
"account_welcome",
"mail_template",
"settings_mail_template_group_account_welcome",
&[],
);
static BILLING_TEMPLATE: MailTemplateDefinition = MailTemplateDefinition::new(
"billing_invoice",
"mail_template",
"settings_mail_template_group_billing_invoice",
&[],
);
let mut builder = MailTemplateCatalog::builder();
builder.register(&ACCOUNT_TEMPLATE);
builder.register(&BILLING_TEMPLATE);
let catalog = builder.build()?;如果子系统导出注册函数,可以让 Forge 统一执行这些 registrar:
fn register_account_templates(builder: &mut MailTemplateCatalogBuilder) {
builder.register(&ACCOUNT_TEMPLATE);
}
fn register_billing_templates(builder: &mut MailTemplateCatalogBuilder) {
builder.register(&BILLING_TEMPLATE);
}
let catalog = MailTemplateCatalog::from_registrars(&[
register_account_templates,
register_billing_templates,
])?;build() 会校验:
- 模板 code 不能为空;
- 模板 code 不能重复;
- 同一个模板里的变量 key 不能为空;
- 同一个模板里的变量 key 不能重复。
静态 MailTemplateRegistry 适合一个模块集中维护模板列表;MailTemplateCatalogBuilder 适合以后 Forge/Yggdrasil/Drive 这种多子系统注册模板的接入方式。
产品侧仍然负责:
- 从 runtime config 读取 subject/html 模板。
- 把数据库里的 payload JSON 解码成自己的 payload 类型。
- 生成 verification/reset/invitation 等业务 URL。
- 决定哪些变量进入 text 渲染,哪些变量需要 HTML escape。
渲染时只把机械层交给 Forge:
use aster_forge_mail::{TemplatePlaceholderSet, escape_html, render_template};
let placeholders = TemplatePlaceholderSet::new(
vec![("username", "Alice".to_string())],
vec![("username", escape_html("Alice"))],
);
let rendered = render_template(subject_template, html_template, &placeholders);render_template 会使用 text values 渲染 subject,用 HTML values 渲染 HTML body,并从 HTML 生成 plain-text fallback。
错误边界
retry_mark_sent 返回产品回调的错误类型。这样数据库和 API 错误映射继续留在产品 crate。
测试
Forge 测试覆盖:
- dispatch 计数合并;
- 默认重试延迟策略;
- 投递失败 decision 分类;
- UTF-8 安全截断;
retry_mark_sent的重试成功和最终失败行为。- 模板 registry 的变量组生成;
- placeholder 渲染、HTML escape 和 text fallback。
产品测试仍然应该覆盖:
- repository claim fence;
- stale processing reclaim;
- 模板渲染;
- 模板 payload 编解码和业务 URL 生成;
- 审计记录;
- SMTP sender 配置;
- 事务行为。
参考项目
- AsterYggdrasil:模板 payload enum、模板渲染、业务 URL 和发信审计 hook 留在产品代码里;sender、dispatch stats、投递失败 decision、重试策略、截断、
mark_sent重试、模板 registry、placeholder 渲染和 HTML/text 转换走 Forge。使用 Forgemail_outbox表时,claim/retry/sent/failed 状态机通过aster_forge_db::MailOutboxDbStore::dispatch_due(...)接入。