「その②まででBounce処理もしっかりできたね😊」
「スパム配信するなよな!(ではSESでサーバレスメール送信ライフを楽しんでください。)」
お疲れ様〜!
もくじ
実は全然だめだった
Bounceだけでなく、Complaintも処理しなくてはいけないのだ!にゃーん🐱
バックナンバー
- Laravel6 AWS SES連携 + Bounce, Complaint対応 その①
- Laravel6 AWS SES連携 + Bounce, Complaint対応 その②
- Laravel6 AWS SES連携 + Bounce, Complaint対応 その③
会員認証やSES バウンス周りやったった。1月中旬までの仕事をがしがし終わらせるのだ🐱https://t.co/XgQdAfsHvW
— 優さん🌷個人開発 (@yuu13n6) January 2, 2020
AWS SESのコンフィグ設定
Bounceだけでなく、Complaintも検知してSNSに通知を出すように設定します。
【Notifications】→ 【Edit Configuration】をクリックします。
ComplaintsにSNSのトピック名を設定します!
Laravelの設定
マイグレーションファイル変更
database/migration/xxxx_create_email_problems_table.php
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\DB; class CreateEmailProblemsTable extends Migration { /** * Run the migrations. * * @return void */ public function up() { Schema::create('email_problems', function (Blueprint $table) { $table->bigIncrements('email_problem_id')->comment('問題のあったメールアドレステーブル 主キー'); $table->string('message_type')->comment('メッセージのタイプ'); $table->string('sqs_message_id')->comment('SQSメッセージid'); $table->string('email')->comment('問題のあったメールアドレス'); $table->timestamps(); $table->index('sqs_message_id'); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('email_problems'); } }
- bounceだけでなく、complaintsをタイプ分けしたいから”message_type”カラムを追加
- プライマリティキーをemail_problem_idに変更
モデルの変更
app/Http/Models/EmailProblem.php
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; class EmailProblem extends Model { /** * @var string プライマリーキー名 */ protected $primaryKey = 'email_problem_id'; }
主キー名を変更したことに対応します。
分岐処理をさせよう
app/Http/Console/Commands/registerEmailProblemFromSQS
<?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\EmailProblem; /** * AWS SQSからBounce or Complaint(未到達, 苦情)とマークされたメールアドレスを取得してDBに登録 * * @return void */ class RegisterEmailProblemFromSQS extends Command { private $client; private $queue_url; private $notificationType; private $sqs_messageId; /** * The name and signature of the console command. * * @var string */ protected $signature = 'command:registerEmailProblemFromSQS'; /** * The console command description. * * @var string */ protected $description = 'AWS SQSからBounce or Complaint(未到達, 苦情)とマークされたメールアドレスを取得してDBに登録します。'; /** * Create a new command instance. * * @return void */ public function __construct() { parent::__construct(); } /** * SQSからBounce, Complaint(未到達, 苦情))マークされたメールアドレスを取得してDBに登録してキューを削除 * - 既に登録されたメッセージidの場合は登録せずにそのまま削除 (SQSは複数同じメッセージを送信する場合がある為) * * @return void */ public function handle(): void { try { // SQSに接続してQUEUEを取得してBouce, Complaintな問題のあるEmailアドレスをDBに登録 $prefix = config('queue.connections.sqs.prefix'); $queue = config('queue.connections.sqs.queue'); $this->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')); $this->client = new SqsClient([ 'credentials' => $credentials, // AWS IAMユーザの認証情報 'region' => $region, // AWS SQSのリージョン 'version' => $version, // AWS SQSのバージョン ]); $receive = [ 'AttributeNames' => ['All'], // 取得するメタ情報を指定 'MessageAttributeNames' => ['All'], // 取得する属性を指定 'MaxNumberOfMessages' => 10, // 一度に受信するメッセージの最大数 'QueueUrl' => $this->queue_url, // SQSのURL 'WaitTimeSeconds' => 20, // 0秒以上を指定でロングポーリングを有効化 - @see https://docs.aws.amazon.com/ja_jp/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-short-and-long-polling.html#sqs-long-polling 'VisibilityTimeout' => 60, // 可視性タイムアウト - @see https://docs.aws.amazon.com/ja_jp/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html ]; // キューを監視してキューがあれば受信しDBにバウンスメールを登録し、キューを削除します。 while (true) { $result = $this->client->receiveMessage($receive); $data = $result->get('Messages'); if ($data) { foreach ($data as $item) { $json_Message_Body = json_decode($item['Body'], true); $this->sqs_messageId = $json_Message_Body['MessageId']; $json_Message = json_decode($json_Message_Body['Message'], true); $this->notificationType = $json_Message['notificationType']; switch ($this->notificationType) { // 未到達処理 case "Bounce": $bouncedRecipients = $json_Message["bounce"]["bouncedRecipients"]; $this->dequeue($this->sqs_messageId, $bouncedRecipients, $item); break; // 苦情処理 case "Complaint": $complainedRecipients = $json_Message["complaint"]["complainedRecipients"]; $this->dequeue($this->sqs_messageId, $complainedRecipients, $item); break; default: // 想定外の値が与えられた時の例外処理 Log::error('API POST /users/me/ - Class ' . get_class() . ' - Exception: Unexpected notificationType From SQS ... 想定外の$notificationTypeの値が与えられた'); } } } } } catch (AwsException $e) { // 例外処理 Log::error('Command - Class ' . get_class() . ' - ' . $e->getMessage()); } } /** * Bounce, Complaintと判別してDBに登録し、キューを削除 * * @param string $sqs_messageId - SQSメッセージid * @param string $email_address - Eメールアドレス * @param array $item - SNSのトピックデータ * * @return void */ private function dequeue($sqs_messageId, $Recipients, $item): void { // 既に処理したキューではないか?SQSのメッセージidを確認してから処理する $is_exist_sqs_messageId = EmailProblem::where('sqs_message_id', $sqs_messageId) ->exists(); if (!$is_exist_sqs_messageId) { // バウンスメールアドレスを登録 foreach ($Recipients as $Recipient) { $EmailProblem = new EmailProblem(); $EmailProblem->sqs_message_id = $sqs_messageId; // SQSメッセージid $EmailProblem->message_type = $this->notificationType; // 問題タイプ $EmailProblem->email = $Recipient["emailAddress"]; // メールアドレス $EmailProblem->save(); } } // キューの削除 $this->client->deleteMessage([ 'QueueUrl' => $this->queue_url, 'ReceiptHandle' => $item['ReceiptHandle'], 'VisibilityTimeout' => 1000, ]); } }
switch case構文で分岐処理させています。
app/Http/Controllers/Api/V1_0/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 Illuminate\Http\JsonResponse; use App\Models\UserRole; use App\Models\Device; use App\Models\EmailProblem; use Illuminate\Support\Facades\Config; class RegisterController extends Controller { private $code; private $userGeneralRoleId; private $now; private $activation; public function __construct() { $userRole = new UserRole(); $this->userGeneralRoleId = $userRole->getGeneralRoleId(); $this->now = Carbon::now()->format('Y-m-d H:i:s'); } /** * 登録リクエストを受付 * * @param App\Http\Requests\Api\V1_0\RegistUserRequest * @return Illuminate\Http\JsonResponse */ public function register(RegistUserRequest $request): JsonResponse { if (!$this->isProblemEmail($request)) { $this->createActivation($request); return response()->json([ 'message' => config('mail.message.send_verify_mail') ]); } else { switch ($this->getDetailOfProblem($request)) { case "Bounce": // 登録希望のメールアドレスが既に問題のあるメールリストに登録されている return response()->json([ 'error' => Config::get('error.alreadyRegisteredAsBounceEmailAddress') ]); break; case "Complaint": return response()->json([ 'error' => Config::get('error.alreadyRegisteredAsComplaintEmailAddress') ]); break; default: // 想定外の値が与えれた時の例外処理 return response()->json([ 'error' => Config::get('error.problemEmailAddress') ]); } } } /** * メールアドレスが問題のあるメールとして登録されているか、否かを確認 * * @param App\Http\Requests\Api\V1_0\RegistUserRequest * @return bool */ public function isProblemEmail(RegistUserRequest $request): bool { return EmailProblem::where('email', $request->email)->exists(); } /** * 問題のあったメールアドレスのmessage_typeを取得 * * @param Illuminate\Http\Request * @return string */ public function getDetailOfProblem($request) :string { $item = EmailProblem::where('email', $request->email)->first(); return $item["message_type"]; } /** * アクティベーションコードを生成して認証コードをメールで送信 * * @param App\Http\Requests\Api\V1_0\RegistUserRequest * @return void */ private function createActivation(RegistUserRequest $request): void { $hour = Config('mail.activation_invalid_hours'); // 有効日時を設定する為に加算する時間 $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->expired_at = Carbon::now()->addHours($hour); // 有効期限= メール認証コード発行日時+$hours $activation->save(); Mail::to($activation->email)->send(new ActivationCreated($activation)); } /** * メール認証コードを検証してユーザ情報の登録 * * @param Illuminate\Http\Request */ public function verify(Request $request): string { $this->code = $request->code; //メール認証コード // 認証確認 if (!$this->checkCode($this->code)) { // 認証確認エラー処理 return response()->json([ 'error' => Config::get('error.mailActivationError') ]); } else { // ユーザ情報, デバイス情報の登録 try { $retries = (int)3; // トランザクションリトライ回数 DB::beginTransaction(null, $retries); $this->activation = Activation::where('code', $this->code)->first(); $user = new User(); $user->user_name = $this->activation->user_name; $user->email = $this->activation->email; $user->password = $this->activation->password; $user->user_role_id = $this->userGeneralRoleId; $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(); $message = Config::get('mail.message.add_user_success'); return $message; } 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([ 'error' => Config::get('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([ 'error' => Config::get('error.databaseSomethingWentWrongError') ]); } } } /** * メール認証コードの検証 * * 1. 与えられた認証コードがActivations.codeに存在するか? * 2. users.emailが存在しユーザ登録が既に完了したメールアドレスかどうか? * 3. 認証コード発行後4時間以内に発行された認証コードであるか? * * @param string $code - メール認証のURLパラメータから取得する認証コード * @return boolean */ private function checkCode(string $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(); $now = Carbon::now(); $expired_at = Carbon::parse($activation->expired_at); return $code === $latest->code && !$user && $now->lt($expired_at); } }
switch case構文でmessage_type別にエラーメッセージを変更させたいので分岐処理させています。
コンフィグ設定
エラーメッセージの定義をしておきます。
config/error.php
return [ ・・・ 'mailActivationError' => [ 'code' => 401009, 'message' => 'メール認証キーが無効、または既に認証が完了しています。' ], 'alreadyRegisteredAsBounceEmailAddress' => [ 'code' => 401010, 'message' => '送信に失敗しました。メールアドレスをご確認ください。' ], 'alreadyRegisteredAsComplaintEmailAddress' => [ 'code' => 401011, 'message' => '送信に失敗しました。メールアドレスをご確認ください。' ], 'problemEmailAddress' => [ 'code' => 401012, 'message' => '送信に失敗しました。メールアドレスをご確認ください。' ], ・・・ ];
config/mail.php
<?php return [ ・・・ /* |-------------------------------------------------------------------------- | message |-------------------------------------------------------------------------- | */ 'message' => [ 'send_verify_mail' => 'ご指定頂いたメールアドレスに確認メールを送信しました!ご確認頂き登録を完了してください。', 'add_user_success' => '認証が完了しました!●●アプリをご利用ください', ], /* |-------------------------------------------------------------------------- | activation |-------------------------------------------------------------------------- | */ // activations メール認証コード発行有効期限を設定する為の時間 // 有効期限 = メール認証コード発行日時 + activation_invalid_hours // activation_invalid_hours = 6 → メール認証コード発行日時から+6時間後に有効期限となる 'activation_invalid_hours' => 6, ];
コンフィグ反映
$ php artisan config:clear $ php artisan config:cache
Dockerfileの修正
docker-files/php-ses-problem-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"]
docker-compose.yml
--- version: "3.7" services: ・・・ php-ses-problem-queue-listener: build: ./docker-files/php-ses-problem-queue-listener volumes: - ./src:/usr/share/nginx:cached working_dir: "/usr/share/nginx" restart: always ・・・
起動
$ docker-compose up -d
動作確認しよう
RegistController@registerに向けてPOSTする
{ "udid": "udid_string2", "device_os": "ios", "device_token": "device_token_string", "name": "yuu", "email": "bounce@simulator.amazonses.com", "password": "password" } または、 { "udid": "udid_string2", "device_os": "ios", "device_token": "device_token_string", "name": "yuu", "email": "complaint@simulator.amazonses.com", "password": "password" }
宜しいな!
SESへの申請(SES Sending Limits)
解除は申請からまるっと1日程度です。
Bounce対策が終わったら最後に制限解除申請を行います。
本番利用の前に余裕を持って申請しましょうね😊