
「その②までで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対策が終わったら最後に制限解除申請を行います。
本番利用の前に余裕を持って申請しましょうね😊

