求基于Laravel 5.4的Amazon SES批量发件及统计展示教程
嘿,我来帮你一步步搞定Laravel 5.4里用Amazon SES发批量邮件,还能跟踪软退信、硬退信、打开和点击数据,最后在管理面板展示出来。之前我也折腾过类似的需求,下面是亲测有效的步骤:
一、前期准备
先把基础工作做好:
- 确保你有Amazon SES账号,并且已经完成域名/邮箱验证(不然没法发批量邮件)
- 创建一个IAM用户,给它分配SES发送权限和SNS相关权限(后面要用来接收SES的事件通知)
- 你的Laravel 5.4项目里已经安装了
guzzlehttp/guzzle(Laravel 5.4默认自带,但还是确认下更稳妥)
二、配置Laravel用SES发送批量邮件
安装Laravel的SES扩展包:
运行这个Composer命令安装官方适配包:composer require aws/aws-sdk-php-laravel:3.*这个版本是专门适配Laravel 5.4的,别选错了。
配置环境变量:
在.env文件里添加SES相关配置:MAIL_DRIVER=ses AWS_ACCESS_KEY_ID=你的IAM访问密钥ID AWS_SECRET_ACCESS_KEY=你的IAM秘密访问密钥 AWS_DEFAULT_REGION=你的SES所在区域(比如us-east-1)确认邮件驱动配置:
打开config/mail.php,确保驱动设置是读取环境变量的:'driver' => env('MAIL_DRIVER', 'ses'),测试批量发送功能:
在控制器里写个简单的方法,用来发送批量邮件,记得加自定义头部跟踪每封邮件:public function sendBulkEmail() { // 这里替换成你的收件人列表,也可以从数据库读取 $recipients = [ 'user1@example.com', 'user2@example.com', // 更多收件人... ]; foreach ($recipients as $email) { Mail::send('emails.bulk', [], function ($message) use ($email) { $message->to($email) ->subject('批量测试邮件'); // 关键:添加自定义头部,用来关联后续的统计数据 $message->getHeaders()->addTextHeader('X-Mail-Track-Id', uniqid()); }); } return response()->json(['status' => '批量邮件已开始发送']); }这里的
X-Mail-Track-Id是用来把SES返回的事件和具体邮件/用户绑定的核心标识,一定要加。
三、设置SES事件通知,获取统计数据
SES不会主动把退信、打开这些数据推给你,得通过SNS订阅来接收:
创建SNS主题:
登录AWS控制台,找到SNS服务,创建一个主题(比如叫SES-Email-Events),区域要和你的SES一致。配置SES事件通知:
- 进入SES控制台,找到「配置集」,创建一个新的配置集(比如
EmailTrackingConfig) - 在配置集里添加「事件目标」,选择刚才创建的SNS主题,勾选你需要跟踪的事件:硬退信、软退信、打开、点击
- 保存配置,这样SES就会把这些事件推送到SNS主题了
- 进入SES控制台,找到「配置集」,创建一个新的配置集(比如
搭建Laravel Webhook接收SNS通知:
- 先在
routes/web.php里加个路由:Route::post('/ses-webhook', 'SesWebhookController@handle'); - 生成控制器:
php artisan make:controller SesWebhookController - 编写控制器逻辑,处理SNS的订阅确认和事件数据:
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use App\Models\EmailTracking; class SesWebhookController extends Controller { public function handle(Request $request) { $payload = json_decode($request->getContent(), true); // 处理SNS订阅确认请求 if (isset($payload['Type']) && $payload['Type'] === 'SubscriptionConfirmation') { // 访问订阅URL完成确认 $client = new \GuzzleHttp\Client(); $client->get($payload['SubscribeURL']); return response('', 200); } // 处理实际的事件通知 if (isset($payload['Type']) && $payload['Type'] === 'Notification') { $message = json_decode($payload['Message'], true); foreach ($message['Records'] as $record) { $eventType = $record['eventType']; $mail = $record['mail']; // 提取我们之前加的自定义跟踪ID $trackId = collect($mail['headers'])->firstWhere('name', 'X-Mail-Track-Id')['value'] ?? null; if (!$trackId) { Log::warning('找不到邮件跟踪ID', $record); continue; } // 根据事件类型存储数据 switch ($eventType) { case 'HardBounce': $this->saveTrackingData($trackId, 'hard_bounce', $record['bounce']); break; case 'SoftBounce': $this->saveTrackingData($trackId, 'soft_bounce', $record['bounce']); break; case 'Open': $this->saveTrackingData($trackId, 'open', $record['open']); break; case 'Click': $this->saveTrackingData($trackId, 'click', $record['click']); break; } } return response('', 200); } return response('无效请求', 400); } private function saveTrackingData($trackId, $eventType, $data) { // 用updateOrCreate避免重复存储同一事件 EmailTracking::updateOrCreate( ['track_id' => $trackId, 'event_type' => $eventType], [ 'payload' => json_encode($data), 'occurred_at' => now(), ] ); } } - 注意:这个Webhook的URL必须是公网可访问的,测试阶段可以用ngrok临时暴露本地端口,正式环境要部署到服务器。
- 先在
四、创建数据库存储统计数据
- 生成迁移文件:
php artisan make:migration create_email_trackings_table - 编辑迁移文件,定义存储结构:
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; class CreateEmailTrackingsTable extends Migration { public function up() { Schema::create('email_trackings', function (Blueprint $table) { $table->id(); $table->string('track_id'); // 关联邮件跟踪ID $table->enum('event_type', ['hard_bounce', 'soft_bounce', 'open', 'click']); $table->text('payload'); // 存储原始事件数据 $table->timestamp('occurred_at'); $table->timestamps(); $table->index(['track_id', 'event_type']); }); } public function down() { Schema::dropIfExists('email_trackings'); } } - 运行迁移:
php artisan migrate - 生成对应的模型:
模型里可以加一些字段转换,方便后续操作:php artisan make:model EmailTracking<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; class EmailTracking extends Model { protected $fillable = ['track_id', 'event_type', 'payload', 'occurred_at']; protected $casts = [ 'payload' => 'array', 'occurred_at' => 'datetime', ]; }
五、在管理面板展示统计数据
创建统计控制器:
php artisan make:controller Admin/AdminStatsController编写控制器逻辑,获取统计数据:
<?php namespace App\Http\Controllers\Admin; use Illuminate\Http\Request; use App\Http\Controllers\Controller; use App\Models\EmailTracking; use Carbon\Carbon; class AdminStatsController extends Controller { public function index() { // 这里可以自定义统计时间范围,比如最近7天 $startDate = Carbon::now()->subDays(7); $stats = [ 'total_unique_emails' => EmailTracking::distinct('track_id')->count(), 'hard_bounces' => EmailTracking::where('event_type', 'hard_bounce')->where('occurred_at', '>=', $startDate)->count(), 'soft_bounces' => EmailTracking::where('event_type', 'soft_bounce')->where('occurred_at', '>=', $startDate)->count(), 'opens' => EmailTracking::where('event_type', 'open')->where('occurred_at', '>=', $startDate)->count(), 'clicks' => EmailTracking::where('event_type', 'click')->where('occurred_at', '>=', $startDate)->count(), ]; // 获取最近的事件列表,用于表格展示 $recentEvents = EmailTracking::orderBy('occurred_at', 'desc')->paginate(20); return view('admin.stats.index', compact('stats', 'recentEvents')); } }创建统计视图:
在resources/views/admin/stats/index.blade.php里写一个直观的展示页面,用卡片和表格呈现数据:@extends('admin.layouts.app') @section('content') <div class="row mb-4"> <div class="col-md-3"> <div class="card"> <div class="card-body text-center"> <h5 class="card-title">总邮件数</h5> <p class="card-text display-4">{{ $stats['total_unique_emails'] }}</p> </div> </div> </div> <div class="col-md-3"> <div class="card bg-danger text-white"> <div class="card-body text-center"> <h5 class="card-title">硬退信</h5> <p class="card-text display-4">{{ $stats['hard_bounces'] }}</p> </div> </div> </div> <div class="col-md-3"> <div class="card bg-warning text-white"> <div class="card-body text-center"> <h5 class="card-title">软退信</h5> <p class="card-text display-4">{{ $stats['soft_bounces'] }}</p> </div> </div> </div> <div class="col-md-3"> <div class="card bg-success text-white"> <div class="card-body text-center"> <h5 class="card-title">打开量</h5> <p class="card-text display-4">{{ $stats['opens'] }}</p> </div> </div> </div> <div class="col-md-3 mt-3"> <div class="card bg-info text-white"> <div class="card-body text-center"> <h5 class="card-title">点击量</h5> <p class="card-text display-4">{{ $stats['clicks'] }}</p> </div> </div> </div> </div> <div class="card"> <div class="card-header"> <h5>最近事件记录</h5> </div> <div class="card-body"> <table class="table table-striped"> <thead> <tr> <th>跟踪ID</th> <th>事件类型</th> <th>发生时间</th> <th>操作</th> </tr> </thead> <tbody> @foreach($recentEvents as $event) <tr> <td>{{ $event->track_id }}</td> <td> @switch($event->event_type) @case('hard_bounce') <span class="badge badge-danger">硬退信</span> @break @case('soft_bounce') <span class="badge badge-warning">软退信</span> @break @case('open') <span class="badge badge-success">打开</span> @break @case('click') <span class="badge badge-info">点击</span> @break @endswitch </td> <td>{{ $event->occurred_at->format('Y-m-d H:i:s') }}</td> <td> <button class="btn btn-sm btn-outline-primary" data-toggle="modal" data-target="#eventModal{{ $event->id }}">查看详情</button> </td> </tr> <!-- 详情模态框 --> <div class="modal fade" id="eventModal{{ $event->id }}" tabindex="-1" role="dialog"> <div class="modal-dialog" role="document"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">事件详情</h5> <button type="button" class="close" data-dismiss="modal"> <span>×</span> </button> </div> <div class="modal-body"> <pre>{{ json_encode($event->payload, JSON_PRETTY_PRINT) }}</pre> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-dismiss="modal">关闭</button> </div> </div> </div> </div> @endforeach </tbody> </table> {{ $recentEvents->links() }} </div> </div> @endsection添加管理员路由:
在routes/web.php里添加管理员专属路由(记得加权限中间件):Route::group(['prefix' => 'admin', 'middleware' => 'auth:admin'], function () { Route::get('/email-stats', 'Admin\AdminStatsController@index')->name('admin.email.stats'); });
六、一些重要的注意事项
- SES发送限制:新的SES账号默认发送速率是每小时200封,如果需要发更多,得去AWS控制台申请提额
- 打开/点击跟踪:SES的打开依赖自动添加的跟踪像素,点击跟踪需要所有链接经过SES重定向,所以邮件里的链接一定要用绝对路径
- SNS订阅确认:必须处理SNS的订阅确认请求,不然SNS不会推送事件数据
- 日志记录:建议把Webhook里的错误日志记录下来,方便后续排查问题
内容的提问来源于stack exchange,提问作者user3886556
相关产品推荐
相关产品推荐

