You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

求基于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发送批量邮件
  1. 安装Laravel的SES扩展包:
    运行这个Composer命令安装官方适配包:

    composer require aws/aws-sdk-php-laravel:3.*
    

    这个版本是专门适配Laravel 5.4的,别选错了。

  2. 配置环境变量:
    在.env文件里添加SES相关配置:

    MAIL_DRIVER=ses
    AWS_ACCESS_KEY_ID=你的IAM访问密钥ID
    AWS_SECRET_ACCESS_KEY=你的IAM秘密访问密钥
    AWS_DEFAULT_REGION=你的SES所在区域(比如us-east-1)
    
  3. 确认邮件驱动配置:
    打开config/mail.php,确保驱动设置是读取环境变量的:

    'driver' => env('MAIL_DRIVER', 'ses'),
    
  4. 测试批量发送功能:
    在控制器里写个简单的方法,用来发送批量邮件,记得加自定义头部跟踪每封邮件:

    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订阅来接收:

  1. 创建SNS主题:
    登录AWS控制台,找到SNS服务,创建一个主题(比如叫SES-Email-Events),区域要和你的SES一致。

  2. 配置SES事件通知:

    • 进入SES控制台,找到「配置集」,创建一个新的配置集(比如EmailTrackingConfig)
    • 在配置集里添加「事件目标」,选择刚才创建的SNS主题,勾选你需要跟踪的事件:硬退信、软退信、打开、点击
    • 保存配置,这样SES就会把这些事件推送到SNS主题了
  3. 搭建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临时暴露本地端口,正式环境要部署到服务器。
四、创建数据库存储统计数据
  1. 生成迁移文件:
    php artisan make:migration create_email_trackings_table
    
  2. 编辑迁移文件,定义存储结构:
    <?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');
        }
    }
    
  3. 运行迁移:
    php artisan migrate
    
  4. 生成对应的模型:
    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',
        ];
    }
    
五、在管理面板展示统计数据
  1. 创建统计控制器:

    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'));
        }
    }
    
  2. 创建统计视图:
    在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>&times;</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
    
  3. 添加管理员路由:
    在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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.15 07:51:16