
その①で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のキューを処理するリスナーが自動起動します😊
その③へ





