← 返回博客

PHP 与 Laravel 发送邮件(2026):cURL、HTTP 客户端与 Webhook 验签

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

更新于 2026年11月3日
PHP 与 Laravel 发送邮件(2026):cURL、HTTP 客户端与 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 中,在控制台和部署里同步更换。