PHP 与 Laravel 发送邮件(2026):cURL、HTTP 客户端与 Webhook 验签
用 PHP 8.2+ 的 cURL 和 Laravel 的 HTTP 客户端发送事务邮件:幂等重试、文件附件、Http::pool 并发批量发送,以及用 hash_equals 做 HMAC-SHA256 Webhook 验签。

PHP 承担着网络上大量的事务邮件发送,现代 PHP(8.2+)自带所需的一切:cURL 处理 HTTP、hash_hmac 计算 HMAC、hash_equals 做常量时间比较。本文先用纯 PHP 通过 Rovela API 发送邮件,再用 Laravel 的 HTTP 客户端实现一遍,覆盖幂等重试、文件附件、并发批量发送以及 Webhook 签名验证。
前置条件
- PHP 8.2 及以上
- Rovela 控制台里的 API Key
- Laravel 部分需要:Laravel 10 及以上
ext-curl、ext-json、ext-mbstring扩展(大多数发行版自带)
纯 PHP:发送单封邮件
用 cURL 向 https://api.rovela.dev/emails 发送 JSON。API Key 放在环境变量里,用 getenv 读取。
<?php
const ROVELA_URL = 'https://api.rovela.dev/emails';
class RovelaError extends Exception
{
public int $statusCode;
public string $name;
public function __construct(int $statusCode, string $name, string $message)
{
parent::__construct($message, $statusCode);
$this->statusCode = $statusCode;
$this->name = $name;
}
}
function sendEmail(string $apiKey, array $payload, string $idempotencyKey = ''): string
{
$body = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$ch = curl_init(ROVELA_URL);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => array_filter([
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
$idempotencyKey !== '' ? 'Idempotency-Key: ' . $idempotencyKey : null,
]),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$errno = curl_errno($ch);
curl_close($ch);
if ($errno !== 0) {
throw new Exception('HTTP 请求失败: ' . curl_strerror($errno));
}
$data = json_decode($response, true);
if ($status >= 400) {
throw new RovelaError(
$status,
$data['name'] ?? 'Unknown',
$data['message'] ?? '未知错误'
);
}
return $data['id'];
}
调用示例:
$apiKey = getenv('ROVELA_API_KEY');
$id = sendEmail($apiKey, [
'from' => 'hello@yourcompany.com',
'to' => ['customer@example.com'],
'subject' => '欢迎使用平台',
'html' => '<h1>您已成功注册。</h1><p>点击下方按钮开始使用。</p>',
]);
echo "已发送: {$id}
";
带幂等键的重试
Rovela API 在 POST /emails 上接受 Idempotency-Key 请求头。24 小时窗口内,相同的 key 返回缓存响应而不会重复发送。在循环之前生成一次 key,每次重试复用同一个值。只对 429 Too Many Requests 重试。
function sendWithRetry(string $apiKey, array $payload, int $maxAttempts = 3): string
{
// 生成一次,每次重试复用,保证幂等
$idempotencyKey = ($payload['to'][0] ?? 'send') . '-' . hrtime(true);
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
try {
return sendEmail($apiKey, $payload, $idempotencyKey);
} catch (RovelaError $e) {
if ($e->statusCode !== 429) {
throw $e;
}
// 429:退避后用同一个幂等键重试
usleep((1 << $attempt) * 1_000_000);
}
}
throw new Exception("超过最大重试次数 {$maxAttempts}");
}
文件附件
在 payload 里加入 attachments 数组。每个附件需要 filename、base64 编码的 content 和 content_type。附件不支持批量接口。
function buildInvoiceEmail(string $recipient, string $invoicePath): array
{
$data = file_get_contents($invoicePath);
return [
'from' => 'billing@yourcompany.com',
'to' => [$recipient],
'subject' => '您的发票',
'html' => '<p>请查收附件中的发票。</p>',
'attachments' => [
[
'filename' => 'invoice.pdf',
'content' => base64_encode($data),
'content_type' => 'application/pdf',
],
],
];
}
Laravel:使用 HTTP 客户端
Laravel 的 Http 门面封装了 Guzzle,提供流畅且可测试的 API。把基础 URL 和 API Key 放在 config/services.php 里:
// config/services.php
'rovela' => [
'url' => env('ROVELA_URL', 'https://api.rovela.dev'),
'key' => env('ROVELA_API_KEY'),
'secret' => env('ROVELA_WEBHOOK_SECRET'),
],
用一个小的服务类把发送逻辑集中在一处:
<?php
namespace App\Services;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
class RovelaService
{
public function __construct(
private string $baseUrl,
private string $apiKey,
) {}
public function send(array $payload, string $idempotencyKey = ''): string
{
$response = Http::withToken($this->apiKey)
->timeout(10)
->when($idempotencyKey !== '', fn ($r) => $r->withHeaders([
'Idempotency-Key' => $idempotencyKey,
]))
->post("{$this->baseUrl}/emails", $payload);
if ($response->failed()) {
throw new RovelaApiException(
$response->status(),
$response->json('name', 'Unknown'),
$response->json('message', 'Unknown error'),
);
}
return $response->json('id');
}
public function sendWithRetry(array $payload, int $maxAttempts = 3): string
{
$idempotencyKey = ($payload['to'][0] ?? 'send') . '-' . hrtime(true);
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
try {
return $this->send($payload, $idempotencyKey);
} catch (RovelaApiException $e) {
if ($e->statusCode !== 429) {
throw $e;
}
usleep((1 << $attempt) * 1_000_000);
}
}
throw new \Exception("超过最大重试次数 {$maxAttempts}");
}
}
在服务提供者里注册:
// AppServiceProvider::register()
$this->app->bind(RovelaService::class, function ($app) {
return new RovelaService(
baseUrl: config('services.rovela.url'),
apiKey: config('services.rovela.key'),
);
});
Laravel:并发批量发送
每个收件人需要个性化内容时,用 Http::pool 并发分发。池大小要控制在 Rovela 速率限制以内。在进入池之前为每个请求生成幂等键,这样 sendWithRetry 内部的重试仍然稳定。
use Illuminate\Support\Facades\Http;
function sendBatchConcurrent(RovelaService $rovela, array $payloads): array
{
$responses = Http::pool(fn (Pool $pool) => array_map(
fn (int $i, array $payload) => $pool
->as("send-{$i}")
->withToken(config('services.rovela.key'))
->timeout(10)
->post(config('services.rovela.url') . '/emails', $payload),
array_keys($payloads),
$payloads,
));
$ids = [];
foreach ($payloads as $i => $payload) {
$response = $responses["send-{$i}"];
if ($response->failed()) {
// 这里抛出第一个失败;生产环境应收集全部失败
throw new \Exception("第 {$i} 个发送失败: " . $response->body());
}
$ids[] = $response->json('id');
}
return $ids;
}
批量接口(POST /emails/batch)支持单次最多 100 个收件人,适合所有收件人共享同一正文的场景。只有 payload 因人而异时,才用上面的并发池。
Webhook 签名验证
Rovela 用 HMAC-SHA256 对 Webhook 投递签名。签名在 x-resend-signature 头中,格式为 t=<时间戳>,v1=<十六进制签名>。签名原文是时间戳、一个英文句号、原始请求体拼接而成:<时间戳>.<原始请求体>。
PHP 的 hash_hmac 计算 HMAC,hash_equals 做常量时间比较。读取原始请求体很关键:不要让框架在验签之前重新编码或裁剪它,因为签名覆盖的是发送时的确切字节。
纯 PHP
function verifyWebhook(string $rawBody, string $signatureHeader, string $secret): array
{
if ($signatureHeader === '' || !str_contains($signatureHeader, ',')) {
throw new Exception('缺少或格式错误的 x-resend-signature');
}
$parts = [];
foreach (explode(',', $signatureHeader) as $segment) {
[$k, $v] = explode('=', $segment, 2);
$parts[$k] = $v;
}
$timestamp = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if ($timestamp === '' || $v1 === '') {
throw new Exception('x-resend-signature 头格式错误');
}
// 签名原文 = "<时间戳>.<原始请求体>"
$signed = $timestamp . '.' . $rawBody;
$expected = hash_hmac('sha256', $signed, $secret);
if (!hash_equals($expected, $v1)) {
throw new Exception('签名不匹配');
}
return json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
}
在纯 PHP 前控制器里,用 php://input 读取原始请求体:
$rawBody = file_get_contents('php://input');
$secret = getenv('ROVELA_WEBHOOK_SECRET');
try {
$event = verifyWebhook($rawBody, $_SERVER['HTTP_X_RESEND_SIGNATURE'] ?? '', $secret);
} catch (Exception $e) {
http_response_code(401);
echo 'Unauthorized';
exit;
}
http_response_code(204);
Laravel 控制器
Laravel 在框架解析请求体之后重新读取时,会拿到解析后的结果,所以要用 getContent() 显式获取请求流的原始字节。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function __invoke(Request $request)
{
$secret = config('services.rovela.secret');
$rawBody = $request->getContent();
$header = $request->header('x-resend-signature', '');
try {
$event = $this->verify($rawBody, $header, $secret);
} catch (\Exception $e) {
return response('Unauthorized', 401);
}
// 将事件分发到队列
// event(new RovelaWebhookReceived($event));
return response()->noContent();
}
private function verify(string $rawBody, string $header, string $secret): array
{
if ($header === '' || !str_contains($header, ',')) {
throw new \Exception('签名头缺失或格式错误');
}
$parts = [];
foreach (explode(',', $header) as $segment) {
[$k, $v] = explode('=', $segment, 2);
$parts[$k] = $v;
}
$timestamp = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if ($timestamp === '' || $v1 === '') {
throw new \Exception('签名头格式错误');
}
$signed = $timestamp . '.' . $rawBody;
$expected = hash_hmac('sha256', $signed, $secret);
if (!hash_equals($expected, $v1)) {
throw new \Exception('签名不匹配');
}
return json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
}
}
在 routes/web.php 或 routes/api.php 里注册路由:
Route::post('/webhooks/rovela', WebhookController::class);
如果有中间件会重新读取或变换请求体(比如 JSON 解析或裁剪),要么在这条路由上禁用它,要么在它运行之前就读取原始请求体。签名覆盖的是 Rovela 发出的确切字节。
上线前检查清单
- API Key 放在环境变量里:从
ROVELA_API_KEY(或 Laravel 的config('services.rovela.key'))读取,不要硬编码,不要提交到版本控制。 - 幂等键在循环外生成:在
for循环之前生成一次,循环内复用同一个值。 - 只对 429 重试:API 在超限时返回
{"statusCode":429,"name":"Too Many Requests"};因为有幂等键防止重复,对 4xx 和 5xx 重试也是安全的。 - Webhook 用原始请求体:在任何框架变换之前用
php://input或$request->getContent()读取,签名覆盖的是发送时的确切字节。 - 使用常量时间比较:签名字节必须用
hash_equals,不能用===或==。 - 池大小低于速率限制:
Http::pool的并发数不要超过 Rovela 发送上限除以预期单封延迟的结果。 - Webhook 密钥放在环境变量里:存在
ROVELA_WEBHOOK_SECRET中,在控制台和部署里同步更换。