Rust 发送邮件(2026):reqwest、lettre 与 Webhook 验签
用 reqwest 调 Rovela 的 HTTP API 从 Rust 发交易邮件,用信号量做并发、用幂等键做重试、带附件、用 lettre 走 SMTP 中继,并用 HMAC 校验 Webhook 签名。

Rust 团队做交易类邮件,通常在两条路里选一条:HTTP API 或 SMTP 中继。本文以 Rovela 为发送服务,讲清楚两种方式——用 reqwest 调 HTTP API、用 lettre 走 SMTP 中继、带幂等键的重试、base64 附件,以及用 HMAC 校验 Webhook 签名。
HTTP API 与 SMTP 中继
HTTP API(reqwest) | SMTP 中继(lettre) | |
|---|---|---|
| 认证 | Authorization: Bearer re_... | SMTP AUTH LOGIN |
| 重试 | Idempotency-Key 幂等去重 | MTA 重投,无应用级去重 |
| 并发 | tokio::spawn + Semaphore | 连接池 |
| 附件 | JSON attachments 数组 | MIME multipart/mixed |
| Webhook | 带签名的投递事件 | 不可用 |
HTTP API 是主集成方式,SMTP 中继留给已经只会说 SMTP 的老系统。
前置条件
- 一个 Rovela 账号,且已验证发信域名。
from地址必须在已验证域名上;DKIM、SPF、DMARC 记录由系统自动生成。 - 一把前缀为
re_的 API key。在后台或通过POST /api-keys创建。 - Rust 1.75+,配合 Tokio。
用 reqwest 发送一封邮件
Cargo.toml:
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
thiserror = "1"
tokio = { version = "1", features = ["full"] }
use reqwest::Client;
use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum EmailError {
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("api rejected ({0}): {1}")]
Api(u16, String),
}
#[derive(Serialize)]
pub struct SendRequest<'a> {
pub from: &'a str,
pub to: &'a [&'a str],
pub subject: &'a str,
pub html: Option<&'a str>,
pub text: Option<&'a str>,
}
#[derive(Deserialize)]
struct SendResponse {
id: String,
}
pub async fn send_email(
client: &Client,
api_key: &str,
req: &SendRequest<'_>,
) -> Result<String, EmailError> {
let resp = client
.post("https://api.rovela.dev/emails")
.bearer_auth(api_key)
.json(req)
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(EmailError::Api(status.as_u16(), body));
}
let body: SendResponse = resp.json().await?;
Ok(body.id)
}
SendRequest 借用自己的字段,不需要 Clone。按引用传入、只序列化一次,不额外占用内存。
用信号量做并发发送
一次只发一封,吞吐受网络往返限制。tokio::join_all 配合 Semaphore 限制并发,既不会压垮 API,也不会让对端邮箱过载。
use std::sync::Arc;
use tokio::sync::Semaphore;
pub async fn send_many(
client: &Client,
api_key: &str,
requests: Vec<SendRequest<'_>>,
max_concurrent: usize,
) -> Vec<Result<String, EmailError>> {
let sem = Arc::new(Semaphore::new(max_concurrent));
let mut handles = Vec::with_capacity(requests.len());
for req in requests {
let permit = sem.clone().acquire_owned().await.unwrap();
let client = client.clone();
let api_key = api_key.to_string();
handles.push(tokio::spawn(async move {
let _permit = permit;
send_email(&client, &api_key, &req).await
}));
}
futures::future::join_all(handles)
.await
.into_iter()
.map(|r| r.unwrap())
.collect()
}
Cargo.toml 加 futures = "0.3"。每个任务持有一把信号量许可,任务结束许可释放,下一封再进来。
带幂等键的重试
网络调用会失败。盲目重试可能把同一封邮件发两遍。Rovela 支持 Idempotency-Key 请求头:24 小时内用同一把 key 重发,API 会返回第一次的响应,不会再发第二封。幂等键按 API key 隔离。
use std::time::Duration;
pub async fn send_with_retry(
client: &Client,
api_key: &str,
req: &SendRequest<'_>,
idempotency_key: &str,
max_attempts: u8,
) -> Result<String, EmailError> {
assert!(max_attempts > 0, "max_attempts must be at least 1");
let mut backoff = Duration::from_millis(500);
let mut last_err = None;
for _ in 0..max_attempts {
let resp = client
.post("https://api.rovela.dev/emails")
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.json(req)
.send()
.await?;
let status = resp.status();
if status.is_success() {
let body: SendResponse = resp.json().await?;
return Ok(body.id);
}
if status.as_u16() == 429 {
last_err = Some(EmailError::Api(429, "rate limited".into()));
tokio::time::sleep(backoff).await;
backoff *= 2;
continue;
}
let body = resp.text().await.unwrap_or_default();
return Err(EmailError::Api(status.as_u16(), body));
}
Err(last_err.unwrap_or_else(|| EmailError::Api(0, "exhausted retries".into())))
}
有了幂等键,在 429 或超时后重试是安全的——第二次要么拿到缓存响应,要么入一封新邮件,二者不会同时发生。
附件
attachments 字段是一个数组,每项含 filename、content(base64 编码)、content_type。批量发送(POST /emails/batch)不支持附件。
use base64::{engine::general_purpose, Engine as _};
#[derive(Serialize)]
pub struct Attachment<'a> {
pub filename: &'a str,
pub content: String,
pub content_type: &'a str,
}
impl<'a> Attachment<'a> {
pub fn new(filename: &'a str, bytes: &[u8], content_type: &'a str) -> Self {
Self {
filename,
content: general_purpose::STANDARD.encode(bytes),
content_type,
}
}
}
Cargo.toml 加 base64 = "0.22"。把数组作为 attachments: Option<&[Attachment]> 挂到 SendRequest 上。
在 Axum 应用里使用 Rovela
把一个 reqwest::Client 放进 AppState,全程复用。客户端内部带连接池,省掉每次发送的 TLS 握手。
use axum::extract::State;
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub http: reqwest::Client,
pub api_key: String,
}
pub async fn send_handler(
State(state): State<Arc<AppState>>,
) -> Result<String, EmailError> {
let req = SendRequest {
from: "orders@yourdomain.com",
to: &["customer@example.com"],
subject: "您的订单已发货",
html: Some("<p>运单号:1Z999</p>"),
text: Some("运单号:1Z999"),
};
send_email(&state.http, &state.api_key, &req).await
}
reqwest::Client 内部是 Arc,克隆开销几乎为零,跨 handler 共享不花成本。
用 lettre 走 SMTP 中继
如果你已经在跑一个 SMTP 中继,lettre 原生说 SMTP。Rovela 的 SMTP 中继接受 AUTH LOGIN,用 API key 当密码、发信地址当用户名。主机名和端口以后台配置为准——去后台查看对应的 SMTP 端点。
[dependencies]
lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-native-tls", "builder"] }
tokio = { version = "1", features = ["full"] }
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use lettre::message::header::ContentType;
use lettre::message::{MultiPart, SinglePart};
use lettre::transport::smtp::authentication::Credentials;
pub async fn send_via_smtp(
host: &str,
username: &str,
password: &str,
from: &str,
to: &str,
subject: &str,
html_body: &str,
) -> anyhow::Result<()> {
let creds = Credentials::new(username.to_string(), password.to_string());
let mailer = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(host)?
.credentials(creds)
.build();
let email = Message::builder()
.from(from.parse()?)
.to(to.parse()?)
.subject(subject)
.multipart(
MultiPart::mixed().singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html_body.to_string())?,
),
)?;
mailer.send(email).await?;
Ok(())
}
SMTP 中继不提供投递 Webhook 和幂等键——做交易类邮件,HTTP API 是更好的选择。
校验 Webhook 签名
当邮件被投递、退信或打开时,Rovela 会向你配置的 Webhook 端点发一个带签名的事件。校验签名能让你在信任请求体之前确认它确实来自 Rovela。
Rovela 发送 x-resend-signature 请求头,格式为 t=<时间戳>,v1=<十六进制签名>。被签名的字符串是 <时间戳>.<原始请求体>——时间戳、一个英文句点、再接原始请求体的字节。签名是用 Webhook 端点的签名密钥对这段字符串做 HMAC-SHA256。校验时用常量时间比较。
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
pub fn verify_signature(
secret: &str,
header: &str,
body: &[u8],
) -> Result<(), &'static str> {
let Some((ts_part, sig_part)) = header.split_once(',') else {
return Err("malformed signature header");
};
let ts = ts_part.strip_prefix("t=").ok_or("missing timestamp")?;
let sig_hex = sig_part.strip_prefix("v1=").ok_or("missing signature")?;
let expected = hex::decode(sig_hex).map_err(|_| "invalid hex")?;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| "bad key")?;
mac.update(ts.as_bytes());
mac.update(b".");
mac.update(body);
let computed = mac.finalize().into_bytes();
if computed.as_bytes().ct_eq(&expected).into() {
Ok(())
} else {
Err("signature mismatch")
}
}
Cargo.toml 加 hmac = "0.12"、sha2 = "0.10"、subtle = "2"、hex = "0.4"。
处理请求时,要把原始请求体当字节读出来——不要先解析成 JSON,否则你签名的字节和实际发出的不一致。从头部取出 t,若时间戳比当前晚几分钟以上则拒绝(防重放),然后再校验签名。
检查清单
- 验证一个域名,
from用该域名上的地址。 - 全程复用一个
reqwest::Client。 - 每次可能重试的发送都带上
Idempotency-Key。 - 限流返回
429,退避后用同一把幂等键重试。 - 附件在 JSON 体里做 base64;批量发送不支持附件。
- 信任请求体之前,用常量时间比较校验 Webhook 签名。
- 校验签名时读原始请求体字节,不要用重新序列化后的 JSON 副本。