← 返回博客

Actix Web / Rust 发送邮箱验证码(2026):用 HTTP API 发 OTP

在 Actix Web 里做注册/登录邮箱验证码:OsRng、HMAC 落 DashMap/Redis/SQL、限流、用 reqwest 调 POST /emails,以及 Idempotency-Key——优先 HTTP 而非 lettre SMTP;Rovela 只负责投递。

更新于 2026年11月4日
Actix Web / Rust 发送邮箱验证码(2026):用 HTTP API 发 OTP

为什么用 Actix Web + 邮箱验证码(而不是魔法链接 SDK)

Actix Web 本来就适合做注册/登录:类型化 handler、extractor、限流中间件,以及服务端存储(DashMap、Redis 或 SQL)。你不需要 Rovela 的 Rust SDK、「OTP 产品」接口,或把邮件 API 当成身份提供商的中间件。

你需要的是文档里的配方——邮箱验证码:应用用 rand::rngs::OsRng(或 getrandom)生成六位码,只存 HMAC(永不落明文),设过期与尝试次数,再用 reqwest 调 POST /emails,同时带上 html 与 text。Rovela 负责入队、DKIM、重试与事件。它不会生成验证码、校验验证码,也不会建会话。

Rovela 不提供 OTP 功能。 Actix 服务负责密码学、存储条目、重发节流、核销逻辑以及 session cookie / JWT。Rovela 只负责投递。

本文面向 Actix Web(Actix Web 4 + Tokio):「发码」与「验码」handler、带 Idempotency-Key 的薄封装 reqwest 客户端,以及内存或 Redis 的 HMAC 存储,避免超时重试入队两封不一致的码。这不是魔法链接教程——魔法链接见 Next.js、NestJS、FastAPI。同款 OTP 姊妹篇:Go(最接近的系统对标)、Spring Boot、Express.js、Phoenix。数字码 vs 链接的产品选择见 邮箱验证:验证码 vs 链接。语言无关指南见 用邮件发 OTP。通用 Rust 发送(reqwest 批量、lettre SMTP、Axum 接线、webhook)——不是 OTP 归属——见 Rust 发邮件;本文不重复信号量扇出或 lettre 中继。

你负责什么,Rovela 发什么

Actix 服务负责:

  • 操作系统 CSPRNG → 六位、左侧补零(密钥材料不要用非密码学 PRNG)
  • 用 hmac + sha2 对 email + ":" + code 做 HMAC-SHA256,密钥是服务端 pepper(CODE_PEPPER)
  • 过期(文档示例约 10 分钟)、尝试计数、以及「同一地址一分钟只发一次」的重发闸
  • 按规范化邮箱 upsert,新发会使旧码失效(DashMap、Redis 或 SQL)
  • 核销时用 subtle::ConstantTimeEq 常量时间比较;成功后 delete / 失效
  • 是否暴露账号存在性由产品决定,但公开响应宜保持一致
  • 对 /auth/otp/send 与 /auth/otp/verify 做限流

Rovela 负责:

  • POST https://api.rovela.dev/emails,Authorization: Bearer + sending_access 密钥(环境变量里的 ROVELA_API_KEY——勿提交、勿放进 WASM/前端包、勿打印到日志)
  • 入队、签名、重试、退信/投诉处理

发送接口返回 200 表示已接受并入队,不等于「已进收件箱」。

配置与薄封装客户端(reqwest + Idempotency-Key)

# Cargo.toml(节选)
[dependencies]
actix-web = "4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] }
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
rand = "0.8"
subtle = "2"
dashmap = "6"
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
thiserror = "1"
// src/config.rs
use std::env;
use std::time::Duration;

#[derive(Clone)]
pub struct Config {
    pub api_key: String,
    pub code_pepper: Vec<u8>,
    pub from: String,
    pub ttl: Duration,
    pub resend_cooldown: Duration,
    pub max_attempts: u32,
}

impl Config {
    pub fn from_env() -> Self {
        Self {
            api_key: must_env("ROVELA_API_KEY"), // sending_access
            code_pepper: must_env("CODE_PEPPER").into_bytes(),
            from: env::var("OTP_FROM")
                .unwrap_or_else(|_| "Acme <security@mail.acme.com>".into()),
            ttl: Duration::from_secs(
                env::var("OTP_TTL_MINUTES").ok().and_then(|v| v.parse().ok()).unwrap_or(10) * 60,
            ),
            resend_cooldown: Duration::from_secs(
                env::var("OTP_RESEND_COOLDOWN_SEC")
                    .ok()
                    .and_then(|v| v.parse().ok())
                    .unwrap_or(60),
            ),
            max_attempts: env::var("OTP_MAX_ATTEMPTS")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(5),
        }
    }
}

fn must_env(k: &str) -> String {
    env::var(k).unwrap_or_else(|_| panic!("missing env {k}"))
}
// src/rovela.rs
use reqwest::Client;
use serde_json::{json, Value};
use thiserror::Error;

#[derive(Clone)]
pub struct MailClient {
    http: Client,
    api_key: String,
    from: String,
}

pub struct SendInput {
    pub to: String,
    pub subject: String,
    pub html: String,
    pub text: String,
    pub idempotency_key: String,
}

#[derive(Debug, Error)]
pub enum MailError {
    #[error("transport: {0}")]
    Transport(#[from] reqwest::Error),
    #[error("rovela send failed: {0} {1}")]
    Status(u16, String),
}

impl MailClient {
    pub fn new(api_key: String, from: String) -> Self {
        Self {
            http: Client::builder()
                .timeout(std::time::Duration::from_secs(15))
                .build()
                .expect("reqwest client"),
            api_key,
            from,
        }
    }

    pub async fn send(&self, in_: SendInput) -> Result<Value, MailError> {
        let body = json!({
            "from": self.from,
            "to": in_.to,
            "subject": in_.subject,
            "html": in_.html,
            "text": in_.text,
        });
        let res = self
            .http
            .post("https://api.rovela.dev/emails")
            .header("Authorization", format!("Bearer {}", self.api_key))
            .header("Idempotency-Key", in_.idempotency_key)
            .header("Accept", "application/json")
            .json(&body)
            .send()
            .await?;
        let status = res.status().as_u16();
        let text = res.text().await.unwrap_or_default();
        if !(200..300).contains(&status) {
            return Err(MailError::Status(status, text));
        }
        Ok(serde_json::from_str(&text).unwrap_or(json!({})))
    }
}

ROVELA_API_KEY 与 CODE_PEPPER 按普通服务端凭证接入(Authentication、Quick Start)。生产发 OTP 用 sending_access 密钥即可。不要把密钥放进可能被序列化进响应的 Actix web::Data,也不要通过公开 handler 暴露。

落库哈希:OsRng + HMAC,不要裸 SHA-256

六位码只有一百万种可能。泄露的裸哈希表几秒内就能反推。按文档:用永不离开服务器的 pepper 做 HMAC。

// src/crypto.rs
use hmac::{Hmac, Mac};
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::Sha256;
use subtle::ConstantTimeEq;

type HmacSha256 = Hmac<Sha256>;

pub fn normalize_email(raw: &str) -> String {
    raw.trim().to_ascii_lowercase()
}

pub fn mint_code() -> String {
    // CSPRNG — ThreadRng / 小型 PRNG 不适合密钥材料
    let n = OsRng.next_u32() % 1_000_000;
    format!("{n:06}")
}

pub fn hash_code(pepper: &[u8], email: &str, code: &str) -> String {
    let mut mac = HmacSha256::new_from_slice(pepper).expect("HMAC key");
    mac.update(format!("{email}:{code}").as_bytes());
    hex::encode(mac.finalize().into_bytes())
}

pub fn safe_equal_hex(a: &str, b: &str) -> bool {
    let (Ok(ab), Ok(bb)) = (hex::decode(a), hex::decode(b)) else {
        return false;
    };
    if ab.len() != bb.len() {
        return false;
    }
    ab.ct_eq(&bb).into()
}

建议的表结构(每个邮箱一条有效码),对齐 验证码示例:

CREATE TABLE verification_codes (
  email      TEXT PRIMARY KEY,
  code_hash  TEXT NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  attempts   INT NOT NULL DEFAULT 0,
  sent_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

演示用内存方案: 单进程可用按邮箱索引的 DashMap<String, Record>。多副本请用 Redis 或 Postgres。

服务:发码 + 验码(DashMap 版)

// src/service.rs
use crate::config::Config;
use crate::crypto::{hash_code, mint_code, normalize_email, safe_equal_hex};
use crate::rovela::{MailClient, SendInput};
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use std::sync::Arc;
use std::time::Duration;

#[derive(Clone)]
struct Record {
    code_hash: String,
    attempts: u32,
    expires_at: DateTime<Utc>,
    sent_at: DateTime<Utc>,
}

#[derive(Clone)]
pub struct OtpService {
    cfg: Config,
    mail: MailClient,
    store: Arc<DashMap<String, Record>>,
}

const SAFE_SEND_MSG: &str = "若该地址可收信,验证码已在路上。";

impl OtpService {
    pub fn new(cfg: Config, mail: MailClient) -> Self {
        Self {
            cfg,
            mail,
            store: Arc::new(DashMap::new()),
        }
    }

    pub async fn send(&self, raw_email: &str) -> Result<serde_json::Value, String> {
        let email = normalize_email(raw_email);
        let now = Utc::now();

        if let Some(rec) = self.store.get(&email) {
            if now.signed_duration_since(rec.sent_at)
                < chrono::Duration::from_std(self.cfg.resend_cooldown).unwrap()
            {
                return Ok(serde_json::json!({"ok": false, "error": "too_soon"}));
            }
        }

        let code = mint_code();
        let code_hash = hash_code(&self.cfg.code_pepper, &email, &code);
        self.store.insert(
            email.clone(),
            Record {
                code_hash: code_hash.clone(),
                attempts: 0,
                expires_at: now + chrono::Duration::from_std(self.cfg.ttl).unwrap(),
                sent_at: now,
            },
        );

        // 同一逻辑发送用稳定键:哈希前缀。新码 ⇒ 新键。
        let idem = format!("otp-{}", &code_hash[..32]);
        let ttl_min = self.cfg.ttl.as_secs() / 60;
        self.mail
            .send(SendInput {
                to: email,
                subject: format!("Your Acme code: {code}"),
                html: format!(
                    "<p>Your verification code is</p>\
                     <p style=\"font-size:28px;font-weight:600;letter-spacing:4px\">{code}</p>\
                     <p>It expires in {ttl_min} minutes. If you did not ask for it, ignore this email.</p>"
                ),
                text: format!(
                    "Your verification code is {code}. It expires in {ttl_min} minutes. \
                     If you did not ask for it, ignore this email."
                ),
                idempotency_key: idem,
            })
            .await
            .map_err(|e| e.to_string())?;

        Ok(serde_json::json!({"ok": true, "message": SAFE_SEND_MSG}))
    }

    pub fn verify(&self, raw_email: &str, raw_code: &str) -> serde_json::Value {
        let email = normalize_email(raw_email);
        let code = raw_code.trim();
        if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
            return serde_json::json!({"ok": false, "error": "invalid"});
        }

        let Some(mut entry) = self.store.get_mut(&email) else {
            return serde_json::json!({"ok": false, "error": "invalid"});
        };
        let now = Utc::now();
        if now > entry.expires_at {
            drop(entry);
            self.store.remove(&email);
            return serde_json::json!({"ok": false, "error": "expired"});
        }
        if entry.attempts >= self.cfg.max_attempts {
            return serde_json::json!({"ok": false, "error": "locked"});
        }

        let candidate = hash_code(&self.cfg.code_pepper, &email, code);
        if !safe_equal_hex(&candidate, &entry.code_hash) {
            entry.attempts += 1;
            return serde_json::json!({"ok": false, "error": "invalid"});
        }

        drop(entry);
        self.store.remove(&email);
        // 在此签发 session / JWT
        serde_json::json!({"ok": true})
    }
}

把码放进 subject 有利于锁屏预览——多数产品可接受,共享大屏场景可省略(验证码文档)。务必同时发送 html 与 text,并在 text 中带上数字(POST /emails)。

Redis:SET otp:{email} {json},TTL = 过期;另加 SET otp:cooldown:{email} 1 EX 60。SQL:INSERT … ON CONFLICT (email) DO UPDATE,事务里递增 attempts,成功后 DELETE。

Handler:Actix Web + 限流

// src/handlers.rs
use crate::service::OtpService;
use actix_web::{web, HttpResponse, Responder};
use serde::Deserialize;

#[derive(Deserialize)]
pub struct SendBody {
    email: String,
}

#[derive(Deserialize)]
pub struct VerifyBody {
    email: String,
    code: String,
}

pub async fn send_otp(
    svc: web::Data<OtpService>,
    body: web::Json<SendBody>,
) -> impl Responder {
    if body.email.trim().is_empty() {
        return HttpResponse::BadRequest().json(serde_json::json!({"error":"invalid_email"}));
    }
    match svc.send(&body.email).await {
        Ok(out) if out.get("ok") == Some(&serde_json::Value::Bool(false))
            && out.get("error") == Some(&serde_json::Value::String("too_soon".into())) =>
        {
            HttpResponse::TooManyRequests().json(serde_json::json!({"error":"too_soon"}))
        }
        Ok(out) => HttpResponse::Ok().json(serde_json::json!({"message": out["message"]})),
        Err(_) => HttpResponse::BadGateway().json(serde_json::json!({"error":"send_failed"})),
    }
}

pub async fn verify_otp(
    svc: web::Data<OtpService>,
    body: web::Json<VerifyBody>,
) -> impl Responder {
    let out = svc.verify(&body.email, &body.code);
    if out.get("ok") == Some(&serde_json::Value::Bool(true)) {
        return HttpResponse::Ok().json(serde_json::json!({"ok": true}));
    }
    let err = out.get("error").and_then(|v| v.as_str()).unwrap_or("invalid");
    let status = if matches!(err, "locked" | "too_soon") {
        actix_web::http::StatusCode::TOO_MANY_REQUESTS
    } else {
        actix_web::http::StatusCode::BAD_REQUEST
    };
    HttpResponse::build(status).json(serde_json::json!({"error": err}))
}
// src/main.rs
use actix_web::{web, App, HttpServer};
mod config;
mod crypto;
mod handlers;
mod rovela;
mod service;

use config::Config;
use handlers::{send_otp, verify_otp};
use rovela::MailClient;
use service::OtpService;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let cfg = Config::from_env();
    let mail = MailClient::new(cfg.api_key.clone(), cfg.from.clone());
    let svc = web::Data::new(OtpService::new(cfg, mail));

    HttpServer::new(move || {
        App::new()
            .app_data(svc.clone())
            // 用你的 IP 限流中间件(governor / actix-limitation / 反向代理)包裹
            .route("/auth/otp/send", web::post().to(send_otp))
            .route("/auth/otp/verify", web::post().to(verify_otp))
    })
    .bind(("0.0.0.0", 8080))?
    .run()
    .await
}

用 governor / actix-limitation 做按 IP 限流,或前置 Cloudflare / nginx。服务内按邮箱冷却,与按 IP 限流解决不同问题——两者都要。加固清单见 邮箱 OTP 安全。

Actix vs Go OTP(同一契约,不同 crate)

Actix Web(本文)Go(姊妹篇)
HTTP 框架Actix handler + extractornet/http / chi
HTTP 客户端reqwest标准库 net/http
CSPRNGrand::rngs::OsRngcrypto/rand
HMAChmac + sha2crypto/hmac + crypto/sha256
进程内存储DashMapsync.Map
常量时间subtle::ConstantTimeEqhmac.Equal

两者都带 Idempotency-Key 调同一个 POST /emails。都不会让 Rovela 铸码或验码。

Idempotency-Key:重试不会发两封码

Worker、反向代理与 AI agent 都会重试。没有 Idempotency-Key,一次超时就可能让收件箱出现两封不一致的码。

  • Header ≤ 256 字符;Rovela 记住 24 小时,重放返回原始响应(API: emails)。
  • 由 code hash 派生(otp- + 前 32 位十六进制),使一次逻辑铸造对应一封入队邮件。
  • 冷却后的主动重发会铸造新码 → 新哈希 → 新键。不要用旧键发新码。
curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer $ROVELA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: otp-smoke-actix-2026-11-05" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "you@example.com",
    "subject": "Your Acme code: 482913",
    "html": "<p>Your verification code is <b>482913</b>. Expires in 10 minutes.</p>",
    "text": "Your verification code is 482913. Expires in 10 minutes."
  }'

为什么 OTP 不用 lettre / SMTP dialer

reqwest → POST /emails(本文)lettre SMTP
传输HTTPS 443465 / 587(25 常被拦)
幂等Idempotency-Key应用自己去重
反馈message id + webhook之后才有退信 DSN
容器 / Cloud Run443 通常开放SMTP 常受限
OTP 适配JSON html + text手写 MIME

已经依赖 SMTP 时可以继续用。新的注册/登录数字码请调 HTTP API。更广的 Rust 发送模式(含 Axum 接线与 lettre)见 Rust 发邮件。

认证数字码的投递注意(不是魔法 URL)

OTP 邮件短且敏感。优先用已验证子域,例如 security@mail.example.com,对齐 SPF/DKIM/DMARC,并保持 HTML 简洁。正文(以及常见的 subject)里的码不需要可点链接——点击追踪改写对魔法链接更关键——但打开/点击追踪仍应落在你控制的域名上。

若进垃圾箱,按 为什么 OTP / 魔法链接进垃圾箱 排查。产品该用数字码还是确认链接,见 验证码 vs 链接。

Actix 里:验证码 vs 魔法链接

六位 OTP(本文)魔法链接
用户动作输入数字点击 URL
秘密熵低 → 必须 HMAC + pepperURL token 熵高 → 通常对 token 做 SHA-256 即可
文档形态验证码密码重置 / 链接模式
Actix 表面POST /auth/otp/send + POST /auth/otp/verifyPOST /auth/magic/request + GET /auth/magic?token=
点击追踪可选关注安全发信域上建议关闭

两者都调同一个 POST /emails。存储分开——不要把 OTP 记录当成魔法链接 token 复用。无论哪种,都不要把 sending_access 打进浏览器或 WASM 客户端。

上线前清单

  1. OsRng / getrandom + HMAC pepper;永不存明文码;不用弱 PRNG 发 OTP。
  2. 约 10 分钟 TTL、尝试上限、按邮箱重发冷却、按 IP 限流。
  3. upsert / 覆盖会使旧码失效;验码成功后 delete。
  4. 同时有 html 和 text;可选把码放进 subject 方便预览。
  5. ROVELA_API_KEY(sending_access)只放服务端——不要进前端 / WASM。
  6. Idempotency-Key 对同一次逻辑发送保持稳定;新码 → 新键。
  7. 若隐藏账号存在性,公开文案保持一致。
  8. OTP 优先 HTTP API 而非 SMTP;发信域已验证;进垃圾箱则按清单排查。

相关阅读

  1. 文档:邮箱验证码 · POST /emails · Quick Start · Authentication。
  2. 产品无关 OTP:用邮件发 OTP · 邮箱 OTP 安全。
  3. UX 选择:邮箱验证:验证码 vs 链接。
  4. 投递:为什么 OTP / 魔法链接进垃圾箱。
  5. 系统对标:Go OTP · 其他 OTP 栈:Spring Boot · Express · Phoenix。
  6. 仅 Rust 发送(非 OTP):Rust 发邮件。
  7. 魔法链接姊妹篇:Next.js · NestJS · FastAPI。

一句话: 把注册/登录 OTP 放进 Actix handler——用 OsRng 铸码、HMAC 落库(DashMap / Redis / SQL)、限流发码/验码,再带 Idempotency-Key 调一次 reqwest POST 给 Rovela;优先于 lettre SMTP dialer,也永远不要把邮件 API 当成认证服务器。