← 返回博客

Rust 发送邮件(2026):reqwest、lettre 与 Webhook 验签

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

更新于 2026年11月3日
Rust 发送邮件(2026):reqwest、lettre 与 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 副本。