その①でLaravel + SESのメール認証までできました😊
もくじ
バックナンバー
- Laravel6 AWS SES連携 + Bounce, Complaint対応 その①
- Laravel6 AWS SES連携 + Bounce, Complaint対応 その②
- Laravel6 AWS SES連携 + Bounce, Complaint対応 その③
その②ではAWS SESを利用する上で必須となる、Bounce対策を行っていきます。
バウンス用のアドレスにメールを送る
- バウンス
bounce@simulator.amazonses.com - 苦情
complaint@simulator.amazonses.com
SNSトピックの作成
https://console.aws.amazon.com/sns/v3/home?region=us-east-1#/topics
【トピックの作成】をクリックします。
名前:SES-Bounce-Notify
と入力します。
【トピックの作成】をクリックします。
トピックが作成できました😆
ARNの値をメモしてください、SQSの設定で利用します。
SQSの作成
- キュー名:SESBouceMailQueue
- キューのタイプ:標準キュー
【キューのクイック作成】をクリックします。
【アクセス許可】のタブをクリックして、【アクセス許可の追加】をクリックします。
- プリシンパルの項目:全員にチェック
- アクション:SendMessageにチェック
【条件の追加(オプション)】をクリックします。
aws:SourceArn: <SNSで作成したトピックのarn名>
SNSのARNを入力して、【アクセス許可の追加】をクリックします。
SESのNotification設定
【Domains】から設定したいドメインをクリックします。
【Notifications】をクリックします。
【Edit Configuration】をクリックします。
BounceにSNSで作成したトピック名を指定し、【Save Config】をクリックします。
SNSのサブスクリプション設定
SES-Bounce-Notifyトピックにて、【サブスクリプションの作成】をクリックします。
- プロトコル:Amazon SQS
- エンドポイント:SQSのARN名
【サブスクリプションの作成】をクリックします。
Bounceメールのテスト
ユーザ登録APIを叩いてSESにメール送信します。
指定しているメールアドレスはSESのBounceシミュレータであるメールアドレスを指定しるので確実にバウンスされます。
SQSを確認する
利用可能なメッセージに1になっています。連携に成功しています。
QUEUEの隙を見逃すな!
Laravelから無防備を晒しているSQSのキューの隙をついてキューを首を叩き落とせ。
キューに保存されているバウンスメールアドレス情報をデータベースに登録しろ!
SQSに入っているJSONデータ
array(2) { [0]=> array(5) { ["MessageId"]=> string(36) "7e976ba9-c57b-40fc-8d9f-d54480e6f99f" ["ReceiptHandle"]=> string(412) "AAAAAAQEBAeNxxJmY0I8Dmsq3P3hi+DLPgPRFGY5yrjRGlRzkstyXR3c15RTxl+N/nt76wA0hbiTGytazg3tBs1h2BmHACic1CEWUTBYk+wdfI1XLN+sTYSdSIoQ6vd7p+lLVBJbOkeDmZX3S//mHTOmb5VKDOqrAIrefBS8JBrXt9xfHNifsw45dWxIkAyfpLfyMTNF+mbAExB/EMW3RYBHaBqrPg0S0y0zUdxqtT6rTpCt7QqH2cdREdSr8CndRgYIx3lVeNaKRf0p00YhwfOZxD+o8AE8SSL+URrNl/XAsMPl772EVQfqlMe08EsyxVceZd83CjwN01siD5mhzRvdqKu+YtgwNIMXGQsUIVeSKi8ivTeZz45OH4PzDVTGhY7H3l2OcgDyRA0DAvSpVqxMrpKlg==" ["MD5OfBody"]=> string(32) "e2963132982d2e477a12f3a101959d0a" ["Body"]=> string(1772) "{ "Type" : "Notification", "MessageId" : "de249a72-c82f-5db8-a6cf-ccf4f08ca5b7", "TopicArn" : "arn:aws:sns:us-east-1:xxxxx:SES-Bounce-Notify", "Message" : "{ \"notificationType\":\"Bounce\", \"bounce\":{ \"bounceType\":\"Permanent\", \"bounceSubType\":\"General\", \"bouncedRecipients\":[{ \"emailAddress\":\"bounce@simulator.amazonses.com\", \"action\":\"failed\",\"status\":\"5.1.1\", \"diagnosticCode\":\"smtp; 550 5.1.1 user unknown\" } ], \"timestamp\":\"2020-01-01T06:04:19.046Z\", \"feedbackId\":\"0100016f5fb472a7-84ab6e74-AAAAAA-452e-8dc9-d3095016b195-000000\", \"remoteMtaIp\":\"18.xxx.yyy.12\",\"reportingMTA\":\"dsn; a48-37.smtp-out.amazonses.com\"}, \"mail\":{\"timestamp\":\"2020-01-01T06:04:18.000Z\", \"source\":\"info@yuutest1.work\", \"sourceArn\":\"arn:aws:ses:us-east-1:xxxxx:identity/yuutest1.work\", \"sourceIp\":\"14.8.39.32\", \"sendingAccountId\":\"925948485307\", \"messageId\":\"0100016f5fb47099-3bc5ad88-5102-4544-9d2d-61e70a65b4c1-000000\", \"destination\":[ \"bounce@simulator.amazonses.com\" ] } }", "Timestamp" : "2020-01-01T06:04:19.072Z", "SignatureVersion" : "1", "Signature" : "AAAAAsEygUsmhI13LnAgaAjey134Wen484UD7LZsfQ1iru+RfLlUC5Z4QI3LunRv0ZMOIVbuAYK18n7DL6a5Rl0DvxoDniIIcklvJZAl9347t1oB+heP33uX0ovQesD8/OZBnV7VaCXvDbss1TOugmRj2/EINFDb9Sga8VcLRgwKGh7Y6I6Gd5dTemQsuoT/0V9XUnZfFxmQdFQxepXpDWBFHCRZRCpVBo3k9egqGY4VhgPUPyhMSW4F5DcWk6f5V/OPHq5MCct3v3qJezhE/xx3CisfzOVeER1P+fPc6amn3g3XZK54eYToNMONU6AJeF5P/VlYFn2zMeQ+oSw==", "SigningCertURL" : "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-6aad65c2f9911b05cd53efda11f913f9.pem", "UnsubscribeURL" : "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:xxxxx:SES-Bounce-Notify:25e4fc31-a73d-4c8d-b183-d5ca732e68e8" }" ["Attributes"]=> array(4) { ["SenderId"]=> string(21) "AIDAIT2UOQQY3AUExxxxx" ["ApproximateFirstReceiveTimestamp"]=> string(13) "1577858659116" ["ApproximateReceiveCount"]=> string(1) "8" ["SentTimestamp"]=> string(13) "1577858659116" } } [1]=> array(5) { ["MessageId"]=> string(36) "fa56f0a6-9a9d-44aa-bc17-0f67d928479b" ・・・
これをうまいこと料理しよう。
コンフィグの設定
標準キューを利用
.env
QUEUE_CONNECTION=sqs SQS_KEY=<AWS アクセスキーID> SQS_SECRET=<AWS アクセスキーシークレット> SQS_PREFIX=https://sqs.ap-northeast-1.amazonaws.com/xxxxx/MyAppMailQueue.fifo SQS_QUEUE=MyAppMailQueue.fifo SQS_REGION=ap-northeast-1 SQS_VERSION=2012-11-05
- QUEUE_CONNECTION=sqsになっています。
- SQS_PREFIX + SQS_QUEUE = “SQSのキューURL”になるようにしてください😆
config/queue.php
<?php return [ 'connections' => [ ・・・ 'sqs' => [ 'driver' => 'sqs', 'key' => env('SQS_KEY'), 'secret' => env('SQS_SECRET'), 'prefix' => env('SQS_PREFIX'), 'queue' => env('SQS_QUEUE'), 'region' => env('SQS_REGION'), 'version' => env('SQS_VERSION', '2012-11-05'), ], ・・・
コンフィグの設定反映
$ php artisan config:clear $ php artisan config:cache
※FIFOキューを利用する場合
一応書いておきます。
.env
QUEUE_CONNECTION=sqs-fifo SQS_KEY=<AWS キーID> SQS_SECRET=<AWS アクセスキーシークレット> SQS_PREFIX=https://sqs.ap-northeast-1.amazonaws.com/xxxxx/ SQS_QUEUE=MyAppMailQueue.fifo SQS_REGION=ap-northeast-1
config/queue.php
'sqs-fifo' => [ 'driver' => 'sqs-fifo', 'key' => env('SQS_KEY'), 'secret' => env('SQS_SECRET'), 'queue' => env('SQS_PREFIX') . env('SQS_QUEUE'), 'region' => env('SQS_REGION'), 'group' => 'default', 'deduplicator' => 'unique', ],
コンフィグの設定反映
$ php artisan config:clear $ php artisan config:cache
コマンドを作る
$ php artisan make:command registerBounceEmailAddressFromSQS
app/Http/Console/Commands/registerBounceEmailAddressFromSQS.php
<?php namespace App\Console\Commands; use Illuminate\Console\Command; use Aws\Sqs\SqsClient; use Aws\Exception\AwsException; use Aws\Credentials\Credentials; use Illuminate\Support\Facades\Log; class registerBounceEmailAddressFromSQS extends Command { /** * The name and signature of the console command. * * @var string */ protected $signature = 'command:registerBounceEmailAddressFromSQS'; /** * The console command description. * * @var string */ protected $description = 'AWS SQSからBounceされたメールアドレスを取得してDBに登録します。'; /** * Create a new command instance. * * @return void */ public function __construct() { parent::__construct(); } /** * Execute the console command. * * @return mixed */ public function handle() { try{ // SQSに接続してQUEUEを取得してBouceEmailアドレスをDBに登録 $prefix = config('queue.connections.sqs.prefix'); $queue = config('queue.connections.sqs.queue'); $queue_url = $prefix . $queue; $region = config('queue.connections.sqs.region'); $version = config('queue.connections.sqs.version'); $credentials = new Credentials(config('queue.connections.sqs.key'), config('queue.connections.sqs.secret')); $client = new SqsClient([ 'credentials' => $credentials, 'region' => $region, 'version' => $version, // AWS SQSのバージョン ]); $receive = [ 'AttributeNames' => ['All'], 'MessageAttributeNames' => ['All'], 'MaxNumberOfMessages' => 10, 'QueueUrl' => $queue_url, 'WaitTimeSeconds' => 20, 'VisibilityTimeout' => 60, ]; // キューを監視してキューがあれば受信しキューを削除します。 while(true){ $result = $client->receiveMessage($receive); $data = $result->get('Messages'); if($data){ foreach($data as $item){ // キューの削除 $client->deleteMessage([ 'QueueUrl' => $queue_url, 'ReceiptHandle' => $item['ReceiptHandle'], 'VisibilityTimeout' => 1000, ]); } } } } catch(AwsException $e){ // 例外処理 Log::error('Command - Class ' . get_class() . ' - ' . $e->getMessage()); } } }
コマンドの実行
# php artisan command:registerBounceEmailAddressFromSQ
SQSのキューは0に変わってるな!
DBにも登録されてるな!
はい、できました!😊
お疲れ様でした。
そんなんじゃねぇだろ?
忘れるな!😤
- バウンスキューをDBに登録すること
- メール送信時にバウンスされたメールアドレスではないか?確認してからSESでメール送信すること。
これだ。
ぼぅっと生きてんじゃねぇ!🐱
バウンスされたメールアドレスを保存するテーブル bounce_emailsを作成
$ php artisan make:migration create_bounceEmails_table
database/migrations/xxxxx_create_bounceEmails_table
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\DB; class CreateBounceEmailsTable extends Migration { /** * Run the migrations. * * @return void */ public function up() { Schema::create('bounce_emails', function (Blueprint $table) { $table->bigIncrements('bounce_email_id')->comment('バウンスメールアドレステーブル 主キー'); $table->string('email')->comment('バウンスされたメールアドレス'); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('bounce_emails'); DB::statement('ALTER TABLE `bounce_emails` auto_increment = 1;'); } }
app/Http/Models/BounceEmail.php
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; class BounceEmail extends Model { /** * @var string プライマリーキー名 */ protected $primaryKey = 'bounce_email_id'; }
マイグレーションの実行
$ php artisan migrate:fresh
キューの受信処理
app/Http/Console/Commands/registerBounceEmailAddressFromSQS.php
<?php namespace App\Console\Commands; use Illuminate\Console\Command; use Aws\Sqs\SqsClient; use Aws\Exception\AwsException; use Aws\Credentials\Credentials; use Illuminate\Support\Facades\Log; use App\Models\BounceEmail; class registerBounceEmailAddressFromSQS extends Command { /** * The name and signature of the console command. * * @var string */ protected $signature = 'command:registerBounceEmailAddressFromSQS'; /** * The console command description. * * @var string */ protected $description = 'AWS SQSからBounceされたメールアドレスを取得してDBに登録します。'; /** * Create a new command instance. * * @return void */ public function __construct() { parent::__construct(); } /** * SQSからBounceされたメールアドレスを取得してDBに登録してキューを削除 * - 既に登録されたメッセージidの場合は登録せずにそのまま削除 (SQSは複数同じメッセージを送信する場合がある為) * * @return void */ public function handle() :void { try{ // SQSに接続してQUEUEを取得してBouceEmailアドレスをDBに登録 $prefix = config('queue.connections.sqs.prefix'); $queue = config('queue.connections.sqs.queue'); $queue_url = $prefix . $queue; $region = config('queue.connections.sqs.region'); $version = config('queue.connections.sqs.version'); $credentials = new Credentials(config('queue.connections.sqs.key'), config('queue.connections.sqs.secret')); $client = new SqsClient([ 'credentials' => $credentials, 'region' => $region, 'version' => $version, ]); $receive = [ 'AttributeNames' => ['All'], 'MessageAttributeNames' => ['All'], 'MaxNumberOfMessages' => 10, 'QueueUrl' => $queue_url, 'WaitTimeSeconds' => 20, 'VisibilityTimeout' => 60, ]; // キューを監視してキューがあれば受信しDBにバウンスメールを登録し、キューを削除します。 while(true) { $result = $client->receiveMessage($receive); $data = $result->get('Messages'); if($data) { foreach($data as $item){ $json_Message_Body = json_decode($item['Body'], true); $sqs_messageId = $json_Message_Body['MessageId']; $json_Message_Body_Message = json_decode($json_Message_Body['Message'], true); $bounceEmailAddress = $json_Message_Body_Message["bounce"]["bouncedRecipients"][0]["emailAddress"]; // 既に処理したキューではないか?SQSのメッセージidを確認してから処理する $is_exist_sqs_messageId = BounceEmail::where('sqs_message_id', $sqs_messageId) ->exists(); if(!$is_exist_sqs_messageId) { // バウンスメールアドレスを登録 $bounceEmail = new BounceEmail(); $bounceEmail->sqs_message_id = $sqs_messageId; // SQSメッセージid $bounceEmail->email = $bounceEmailAddress; // バウンスメールアドレス $bounceEmail->save(); } // キューの削除 $client->deleteMessage([ 'QueueUrl' => $queue_url, 'ReceiptHandle' => $item['ReceiptHandle'], 'VisibilityTimeout' => 1000, ]); } } } } catch(AwsException $e) { // 例外処理 Log::error('Command - Class ' . get_class() . ' - ' . $e->getMessage()); } } }
バウンスメールアドレスに送信を行い、キューを貯めよう。
そして…
コマンドの実行
# php artisan command:registerBounceEmailAddressFromSQ
ちゃんとデータベースに登録されていますか?
確認しよう。
メール送信処理
app/Http/Console/Commands/RegisterController.php
<?php namespace App\Http\Controllers\Api\V1_0; use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1_0\RegistUserRequest; use App\Models\User; use App\Models\Activation; use Illuminate\Http\Request; use Ramsey\Uuid\Uuid; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use App\Mail\ActivationCreated; use App\Models\UserRole; use App\Models\Device; use App\Models\BounceEmail; class RegisterController extends Controller { private $code; private $userGuestRoleId; private $now; private $activation; public function __construct() { $userRole = new UserRole(); $this->userGuestRoleId = $userRole->getGuestRoleId(); $this->now = Carbon::now()->format('Y-m-d H:i:s'); } /** * 登録リクエストを受付 * * @param App\Http\Requests\Api\V1_0\RegistUserRequest * @return void */ public function register(RegistUserRequest $request): string { if(!$this->is_bounce($request)) { $this->createActivation($request); return response()->json([ 'message' => config('mail.message.send_verify_mail') ]); } else { // エラー処理 // 登録希望のメールアドレスが既にバウンスメールリストに登録されている return response()->json([ 'code' => config('error.alreadyRegisteredAsBounceEmailAddress.code'), 'message' => config('error.alreadyRegisteredAsBounceEmailAddress.message') ]); } } /** * メールアドレスがバウンスメールとして登録されているか、否かを確認 * * @param App\Http\Requests\Api\V1_0\RegistUserRequest * @return bool */ public function is_bounce(RegistUserRequest $request): bool { return BounceEmail::where('email', $request->email)->exists(); } /** * アクティベーションコードを生成して認証コードをメールで送信 * * @param App\Http\Requests\Api\V1_0\RegistUserRequest * @return void */ private function createActivation(RegistUserRequest $request): void { $activation = new Activation; $activation->user_name = $request->name; $activation->email = $request->email; $activation->password = bcrypt($request->password); $activation->udid = $request->udid; $activation->device_os = $request->device_os; $activation->device_token = $request->device_token; $activation->code = Uuid::uuid4(); $activation->save(); Mail::to($activation->email)->send(new ActivationCreated($activation)); } /** * メール認証コードを検証してユーザ情報の登録 * * @param Illuminate\Http\Request * @return string */ public function verify(Request $request) :string { $this->code = $request->code; // 認証確認 if (!$this->checkCode($this->code)) { // 認証確認エラー処理 return response()->json(config('error.mailActivationError')); } else { // ユーザ情報, デバイス情報の登録 try { $retries = (int)3; // トランザクションリトライ回数 DB::beginTransaction(function() {}, $retries); $this->activation = Activation::where('code',$this->code)->first(); $generalRoleId = $this->userGuestRoleId; $user = new User(); $user->user_name = $this->activation->user_name; $user->email = $this->activation->email; $user->password = $this->activation->password; $user->user_role_id = $generalRoleId; $user->save(); Activation::where('code', $this->code)->update(['email_verified_at' => Carbon::now()]); $user_id = $user->user_id; $udid = $this->activation->udid; $device_os = $this->activation->device_os; $device_token = $this->activation->device_token; Device::create([ 'udid' => $udid, 'device_os' => $device_os, 'device_token' => $device_token ]); $user->devices()->attach( ['user_id' => $user_id], ['udid' => $udid], ['created_at' => $this->now], ['updated_at' => $this->now] ); DB::commit(); return response()->json(config('mail.message.add_user_success')); } catch (\Illuminate\Database\QueryException $e) { // トランザクションでのエラー処理 DB::rollback(); Log::error('WEB /users/me/verify - Class ' . get_class() . ' - PDOException Error. Rollback was executed.' . $e->getMessage()); return response()->json(config('error.databaseTransactionRollback')); } catch (\Exception $e) { // その他のエラー処理 DB::rollback(); Log::error('WEB /users/me/verify - Class ' . get_class() . ' - something went wrong elsewhere.' . $e->getMessage()); return response()->json(config('error.databaseSomethingWentWrongError')); } } } /** * メール認証コードの検証 * * 1. 与えられた認証コードがActivations.codeに存在するか? * 2. users.emailが存在しユーザ登録が既に完了したメールアドレスかどうか? * 3. 認証コード発行後1日以内に発行された認証コードであるか? * * @param string $code - メール認証のURLパラメータから取得する認証コード * @return boolean */ private function checkCode($code): bool { $activation = Activation::where('code',$code)->first(); if (!$activation) { return false; } $activation_email = $activation->email; $latest = Activation::where('email',$activation_email)->orderBy('created_at', 'desc') ->first(); $user = User::where('email',$activation_email)->first(); $activation_created_at = Carbon::parse($activation->created_at); $expire_at = $activation_created_at->addDay(1); $now = Carbon::now(); return $code === $latest->code && !$user && $now->lt($expire_at); } }
バウンスメールの登録と送信処理がうまくできていたら終わりです😊
お疲れ様でした〜。
生殺与奪の権を他人に握らせるな!!
コマンド状態でのキューのポーリングでは作成したコマンドの実行を止めたら、キューの処理が止まってしまう。
そんなことで守ったつもりか?🐱
Dockerfileを作成する
このphp artisan command:ほにゃららコマンドを実行し続けるワーカーとなるコンテナをこしらえるのだ!
# php artisan command:registerBounceEmailAddressFromSQ
Dockerは嫌なんだよな…って人はsupervisordを利用しよう。EC2を1台で動かすならsupervisordでしょう。
私が今回DockerでやるのはAWS ECS Fargateで動かすことが前提で作っているからなのだ🐱
docker-files/php-ses-bounce-queue-listener/Dockerfile
FROM composer:1.9.0 RUN docker-php-ext-install pdo_mysql WORKDIR /usr/share/nginx/ RUN docker-php-ext-install pdo_mysql RUN mkdir -p storage/framework/cache/data RUN chmod -R 755 storage RUN chown -R www-data:www-data storage CMD ["php", "artisan", "command:registerEmailProblemFromSQS"]
cat docker-compose.yml
--- version: "3.7" services: 略 php-ses-bounce-queue-listener: build: ./docker-files/php-ses-bounce-queue-listener volumes: - ./src:/usr/share/nginx:cached working_dir: "/usr/share/nginx" restart: always 略
$ docker-compose up -d
これでSQSのキューを処理するリスナーが自動起動します😊
その③へ