AWS

Laravel6 AWS SES連携 + Bounce, Complaint対応 その③

AWS

 

その②まででBounce処理もしっかりできたね😊」

「スパム配信するなよな!(ではSESでサーバレスメール送信ライフを楽しんでください。)」

 

お疲れ様〜!

 

 

 

 

実は全然だめだった


Bounceだけでなく、Complaintも処理しなくてはいけないのだ!にゃーん🐱

 

バックナンバー

 

 

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日程度です。

https://console.aws.amazon.com/support/home?region=us-east-1#/case/create?issueType=service-limit-increase&limitType=service-code-ses

Bounce対策が終わったら最後に制限解除申請を行います。

本番利用の前に余裕を持って申請しましょうね😊

 

関連

AWS SESのアラーム

Amazonおすすめ

iPad 9世代 2021年最新作

iPad 9世代出たから買い替え。安いぞ!🐱 初めてならiPad。Kindleを外で見るならiPad mini。ほとんどの人には通常のiPadをおすすめします><

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)