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

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 计算的必须是发送方签名时那一串完全相同的字节——把结构重新编码一遍,几乎不可能还原出同样的字节串。