← 返回博客

用 Perl 通过 Rovela API 发送邮件

介绍如何用 Perl 的 HTTP::Tiny、LWP::UserAgent 或 Mojo::UserAgent 发送事务邮件,涵盖幂等键重试、Parallel::ForkManager 并发批量发送,以及 Mojolicious 和原生 PSGI 两种 Webhook 验签写法。

更新于 2026年11月3日
用 Perl 通过 Rovela API 发送邮件

Perl 至今仍在承担大量邮件基础设施,用它发事务邮件也只需要几个模块。本文涵盖 HTTP::Tiny、LWP::UserAgent 和 Mojo::UserAgent 三种发送方式,幂等键重试、Parallel::ForkManager 并发批量发送,以及 Mojolicious 和原生 PSGI 的 Webhook 验签。

前提条件

将 API 密钥设置为环境变量:

export ROVELA_API_KEY="re_..."

HTTP::Tiny、JSON::PP 和 Digest::SHA 随 Perl 一起发布。其余模块用 cpanm 安装:

cpanm LWP::UserAgent Mojolicious Parallel::ForkManager Plack

用 HTTP::Tiny 发送

HTTP::Tiny 从 Perl 5.14 起就是核心模块,所以这个例子完全不需要 CPAN:

use strict;
use warnings;
use HTTP::Tiny;
use JSON::PP;

my $http = HTTP::Tiny->new(timeout => 10);

my $res = $http->post('https://api.rovela.dev/emails', {
    headers => {
        'Authorization' => 'Bearer ' . $ENV{ROVELA_API_KEY},
        'Content-Type'  => 'application/json',
    },
    content => JSON::PP::encode_json({
        from    => 'Acme <noreply@yourdomain.com>',
        to      => 'user@example.com',
        subject => 'Welcome aboard',
        html    => '<p>Thanks for signing up!</p>',
    }),
});

die "HTTP $res->{status}: $res->{content}\n" unless $res->{success};

my $body = JSON::PP::decode_json($res->{content});
print $body->{id}, "\n";

HTTP::Tiny 会把 $res->{headers} 的键名统一转成小写,后面读 retry-after 时要注意这一点。

用 LWP::UserAgent 发送

LWP::UserAgent 仍然是 Perl 里部署最广的 HTTP 客户端:

use strict;
use warnings;
use LWP::UserAgent;
use HTTP::Request::Common qw(POST);
use JSON::PP;

my $ua = LWP::UserAgent->new(timeout => 10);

my $req = POST 'https://api.rovela.dev/emails',
    'Authorization' => 'Bearer ' . $ENV{ROVELA_API_KEY},
    'Content-Type'  => 'application/json',
    Content         => JSON::PP::encode_json({
        from    => 'Acme <noreply@yourdomain.com>',
        to      => 'user@example.com',
        subject => 'Welcome aboard',
        html    => '<p>Thanks for signing up!</p>',
    });

my $res = $ua->request($req);
die $res->status_line unless $res->is_success;

print JSON::PP::decode_json($res->content)->{id}, "\n";

用 Mojo::UserAgent 发送

如果项目里已经有 Mojolicious,它的 user agent 会替你完成 JSON 编码:

use strict;
use warnings;
use Mojo::UserAgent;

my $ua = Mojo::UserAgent->new;

my $tx = $ua->post('https://api.rovela.dev/emails' => {
    'Authorization' => 'Bearer ' . $ENV{ROVELA_API_KEY},
} => json => {
    from    => 'Acme <noreply@yourdomain.com>',
    to      => 'user@example.com',
    subject => 'Welcome aboard',
    html    => '<p>Thanks for signing up!</p>',
});

die $tx->res->message unless $tx->res->is_success;

print $tx->res->json->{id}, "\n";

重试与幂等键

在循环之前生成一次幂等键,每次重试都复用同一个键,防止服务器已经接受请求后连接断开导致重复发送。只对 429 重试,其余状态码一律交给调用方:

use strict;
use warnings;
use HTTP::Tiny;
use JSON::PP;
use Time::HiRes qw(sleep);

sub idempotency_key {
    open my $fh, '<:raw', '/dev/urandom' or die "urandom: $!";
    my $buf;
    read($fh, $buf, 16) == 16 or die "short read on /dev/urandom";
    close $fh;
    return unpack 'H*', $buf;    # 32 个十六进制字符
}

my $MAX_RETRIES = 4;
my $http = HTTP::Tiny->new(timeout => 10);
my $payload = JSON::PP::encode_json({
    from    => 'Acme <noreply@yourdomain.com>',
    to      => 'user@example.com',
    subject => 'Welcome aboard',
    html    => '<p>Thanks for signing up!</p>',
});

my $key = idempotency_key();     # 循环前生成一次,重试时复用
my $body;

for my $attempt (0 .. $MAX_RETRIES - 1) {
    my $res = $http->post('https://api.rovela.dev/emails', {
        headers => {
            'Authorization'   => 'Bearer ' . $ENV{ROVELA_API_KEY},
            'Content-Type'    => 'application/json',
            'Idempotency-Key' => $key,
        },
        content => $payload,
    });

    if ($res->{status} == 429) {
        sleep $res->{headers}{'retry-after'} // (2 ** $attempt);
        next;
    }

    die "HTTP $res->{status}: $res->{content}\n" unless $res->{success};

    $body = JSON::PP::decode_json($res->{content});
    last;
}

die "超过最大重试次数 $MAX_RETRIES\n" unless $body;

print $body->{id}, "\n";

Windows 上没有 /dev/urandom,改用 CPAN 的 Crypt::URandom::random_bytes(16)。

Parallel::ForkManager 并发批量发送

Parallel::ForkManager 用来限制同时运行的子进程数量。fork 出来的子进程无法把返回值交给父进程,所以每个子进程各自上报结果并以退出码表示成败:

use strict;
use warnings;
use HTTP::Tiny;
use JSON::PP;
use Parallel::ForkManager;

sub send_email {
    my %r = @_;
    my $http = HTTP::Tiny->new(timeout => 10);
    my $res  = $http->post('https://api.rovela.dev/emails', {
        headers => {
            'Authorization' => 'Bearer ' . $ENV{ROVELA_API_KEY},
            'Content-Type'  => 'application/json',
        },
        content => JSON::PP::encode_json({
            from    => 'Acme <noreply@yourdomain.com>',
            to      => $r{to},
            subject => $r{subject},
            html    => $r{html},
        }),
    });
    die "HTTP $res->{status}: $res->{content}\n" unless $res->{success};
    return JSON::PP::decode_json($res->{content});
}

my @recipients = (
    { to => 'ada@example.com',   subject => 'Hello Ada',   html => '<p>Hi Ada</p>' },
    { to => 'grace@example.com', subject => 'Hello Grace', html => '<p>Hi Grace</p>' },
);

my $pm = Parallel::ForkManager->new(5);    # 最多 5 个并发请求

$pm->run_on_finish(sub {
    my ($pid, $exit_code, $ident) = @_;
    printf "%s: %s\n", $ident, $exit_code == 0 ? 'sent' : 'failed';
});

for my $r (@recipients) {
    $pm->start($r->{to}) and next;         # 父进程:入队后继续

    my $ok = eval { send_email(%$r); 1 };  # 子进程:执行发送
    warn $@ unless $ok;
    $pm->finish($ok ? 0 : 1);              # 子进程:退出
}

$pm->wait_all_children;

start 在子进程里返回 0,所以 and next 只会让父进程回到循环。

Mojolicious Webhook 验签

Rovela 用 HMAC-SHA256 对每个事件签名。签名在 x-resend-signature 请求头中,格式为 t=<时间戳>,v1=<十六进制签名>。对 <时间戳>.<原始 body> 用 Webhook 密钥计算 HMAC,再常量时间比对。

use Mojolicious::Lite;
use Digest::SHA qw(hmac_sha256_hex);
use Mojo::JSON qw(decode_json);
use Mojo::Util qw(secure_compare);

post '/webhooks/rovela' => sub {
    my $c = shift;

    my $secret = $ENV{ROVELA_WEBHOOK_SECRET};
    my $raw    = $c->req->body->slurp;                                  # 原始字节,不是解析后的 JSON
    my $header = $c->req->headers->header('x-resend-signature') // '';

    my %parts = map { split /=/, $_, 2 } grep { /=/ } split /,/, $header;
    return $c->render(status => 400, text => 'missing signature fields')
        unless defined $parts{t} && defined $parts{v1};

    my $expected = hmac_sha256_hex("$parts{t}.$raw", $secret);
    return $c->render(status => 400, text => 'invalid signature')
        unless secure_compare($expected, $parts{v1});

    my $event = decode_json($raw);
    if ($event->{type} eq 'email.delivered') {
        # 标记为已投递
    }
    elsif ($event->{type} eq 'email.bounced') {
        # 标记该地址退信
    }

    $c->render(text => 'ok');
};

app->start;

Mojo::Util 的 secure_compare 会逐字节比较,长度不同直接返回假,因此比对耗时不会泄露签名匹配了多少。

原生 PSGI Webhook 验签

不用框架时,Plack::Request 同样能拿到原始 body 和请求头。常量时间比较本身只有几行,可以直接写:

use strict;
use warnings;
use Plack::Request;
use Digest::SHA qw(hmac_sha256_hex);

sub constant_time_eq {
    my ($a, $b) = @_;
    return 0 if length($a) != length($b);
    my $diff = 0;
    $diff |= ord(substr($a, $_, 1)) ^ ord(substr($b, $_, 1))
        for 0 .. length($a) - 1;
    return $diff == 0;
}

my $app = sub {
    my $env = shift;
    my $req = Plack::Request->new($env);

    my $secret = $ENV{ROVELA_WEBHOOK_SECRET};
    my $raw    = $req->raw_body;
    my $header = $req->header('x-resend-signature') // '';

    my %parts = map { split /=/, $_, 2 } grep { /=/ } split /,/, $header;
    return [400, [], ['missing signature fields']]
        unless defined $parts{t} && defined $parts{v1};

    my $expected = hmac_sha256_hex("$parts{t}.$raw", $secret);
    return [400, [], ['invalid signature']]
        unless constant_time_eq($expected, $parts{v1});

    # 解析 $raw 并处理事件类型
    return [200, [], ['ok']];
};

一定要在解析之前读原始 body。hmac_sha256_hex 计算的必须是发送方签名时那一串完全相同的字节——把结构重新编码一遍,几乎不可能还原出同样的字节串。