【Laravel】メール送信に失敗したアドレスをバリデーションで拒否する

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、サイトの運営をしているとホントにいろいろなところで修正が必要な部分が出てくるとは思うのですが、この間困ったのが今回のテーマにした、

(実は)メールが送信されていなかった 😩

という不具合でした💦

というのも、Laravel側ではエラーも出ず送信できるんですが、送信先のセキュリティソフトなどが受取を拒否している場合があり、そんな場合はLaravel側では把握することができず、メールログを見てやっと内容がわかるというものでした。(gmailspfが必要になった、とかもですね)

そして、どうやらpostfixを使えば通知メッセージを自分に送信することはできるようですが、メール送信できない問題の根本解決できるわけではありません。

そこで❗

今回は、メールログを定期的にチェックして「送信に失敗したメールアドレス」をデータベースに貯めておき、次回はバリデーションでエラーを出すようにしてみることにしました。(つまり、本人に「前のメール送信失敗したからダメよ」と伝えるようにしたいわけです)

ぜひ何かの参考になりましたら嬉しいです。😊✨

ChatGPTがいい回答したときは
『ナイス!』
と言っておだてるようにしています😂」

開発環境: Laravel 10.x

実装する手順

まずは今回の実装をする手順をまとめておきます。

  1. メールログから最後の一部を切り取って Laravel 側のテキストに保存する(※)
  2. Laravel のコマンドを実行して問題のあるメールアドレスを取得
  3. それらを DB へ保存
  4. 保存したメールアドレスを使ってバリデーションを実施する

※ 環境によるかもですが、私の場合だとpostfixのメールログは管理者権限でないといけないので、一旦Laravelに移す作業をいれています

では、今回も楽しんでやっていきましょう❗

DB まわりをつくる

まずは、DBまわり(モデル&マイグレーション)です。
以下のコマンドを実行してください。

php artisan make:model FailedEmail -m

すると、モデルとマイグレーションの2ファイルが作成されるのでそれぞれ中身を次のようにします。

app/Models/FailedEmail.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class FailedEmail extends Model
{
    use HasFactory;

    protected $fillable = [
        'email',
        'status',
        'failed_at',
    ];
}

database/migrations/****_**_**_******_create_failed_emails_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('failed_emails', function (Blueprint $table) {
            $table->id();
            $table->string('email')->unique()->comment('メールアドレス');
            $table->string('status')->comment('ステータス'); // App\Enums\FailedEmailStatus
            $table->dateTime('failed_at')->comment('メール送信に失敗した日時');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('failed_emails');
    }
};

では、この状態でテーブルを追加してみましょう。
以下のコマンドを実行してください。

php artisan migrate

すると、実際のテーブルはこうなりました。

PostFix のステータスで Enum をつくる

postfixが送信したときにメールログが追加されますが、その中には「status=*****」という部分が含まれていて、これでメール送信の結果を知ることができるようです。

そこで、それら(今回は主要なものだけ)をEnumで「ステータス」の管理をしやすくしておきます。

app/Enums/FailedEmailStatus.php

<?php

namespace App\Enums;

enum FailedEmailStatus: string
{
    case sent = 'sent';         // 成功
    case deferred = 'deferred'; // 遅延
    case bounced = 'bounced';   // 失敗
    case rejected = 'rejected'; // 拒否

    public static function values()
    {
        return array_column(self::cases(), 'value');
    }
}

独自 Artisan コマンドをつくる

続いて、メールログから「問題のあるメールアドレス」を取得する部分です。これは、独自のArtisanコマンドで実行します。

以下のコマンドを実行してください。

php artisan make:command ExtractFailedEmailCommand

すると、ファイルが作成されるので中身を以下のようにします。

app/Console/Commands/ExtractFailedEmailCommand.php

<?php

namespace App\Console\Commands;

use App\Enums\FailedEmailStatus;
use App\Models\FailedEmail;
use Carbon\Carbon;
use Illuminate\Console\Command;

class ExtractFailedEmailCommand extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'extract:failed_email';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'メール送信に失敗したメールアドレスを抽出するコマンド';

    /**
     * Execute the console command.
     */
    public function handle()
    {
        $file_path = storage_path('logs/maillog_copy.txt'); // cronでコピーしたログファイルのパス
        $lines = file($file_path, FILE_IGNORE_NEW_LINES);

        foreach ($lines as $line) {

            $email = $this->parseToEmail($line);
            $status = $this->parseStatus($line);
            $failed_at = $this->parseDateTime($line);

            if (! is_null($email) && ! is_null($status) && $failed_at instanceof Carbon) {

                if (in_array($status, ['deferred', 'bounced', 'rejected'])) {

                    $failed_email = FailedEmail::firstOrNew(['email' => $email]);
                    $failed_email->email = $email;
                    $failed_email->status = $status;
                    $failed_email->failed_at = $failed_at;
                    $failed_email->save();

                    $this->warn('Saved! `'. $email .'`');

                } else if ($status === 'sent') {

                    FailedEmail::where('email', $email)->delete(); // 送信に成功したら削除

                    $this->info('Deleted! `'. $email .'`');

                }

            }

        }

    }

    private function parseToEmail(string $text): ?string
    {
        $pattern = '/to=<(.+?)>,/';
        preg_match($pattern, $text, $matches);

        return $matches[1] ?? null;
    }

    private function parseStatus(string $text): ?string
    {
        $failed_email_values = FailedEmailStatus::values();
        $pattern = '/status=('. implode('|', $failed_email_values) . ') /';
        preg_match($pattern, $text, $matches);

        return $matches[1] ?? null;
    }

    private function parseDateTime(string $text): ?Carbon
    {
        $pattern = '/^(\w{3} \d{1,2} \d{2}:\d{2}:\d{2}) /';

        if(preg_match($pattern, $text, $matches)) {

            return Carbon::createFromFormat('M d H:i:s', $matches[1]);

        }

        return null;
    }
}

これで、php artisan extract:failed_emailコマンドを実行するとメールログの中から「送信に失敗したメールアドレス」が取得&保存ができるようになりました。

独自バリデーション・ルールをつくる

失敗メールアドレス」を取得できるようになったので、次はバリデーション・ルールの作成です。

つまり、「データベースにメールアドレスが登録されていれば、失敗があったメールアドレス」ということなので、バリデーションを通過できなくなります。

以下のコマンドを実行してください。

php artisan make:rule IsSendableEmailAddress

すると、ファイルが作成されるので中身を以下のように変更してください。

app/Rules/IsSendableEmailAddress.php

<?php

namespace App\Rules;

use App\Models\FailedEmail;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class IsSendableEmailAddress implements ValidationRule
{
    /**
     * Run the validation rule.
     *
     * @param  \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString  $fail
     */
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $exists = FailedEmail::where('email', $value)->exists();

        if ($exists === true) {

            $fail('以前にメール送信に失敗しているので、有効ではありません');

        }
    }
}

これで作業は完了です❗
お疲れ様でした😊✨

テストしてみる

では、実際にテストしてみましょう。
まずは「送信に失敗したメールアドレスの取得&保存」です。

なお、コマンドを実行する前にテスト用のメールログを用意します。(以下はChatGPTで作ったテスト用メールログです)

/var/log/mail.log

Sep 16 09:00:00 mailserver1.example.com postfix/smtpd[1234]: connect from localhost[127.0.0.1], status=sent
Sep 16 09:00:01 mailserver1.example.com postfix/smtpd[1234]: ABC123456789: client=localhost[127.0.0.1], status=deferred
Sep 16 09:00:02 mailserver1.example.com postfix/pickup[5678]: DEF987654321: uid=48 from=<user1@example.com>, status=bounced
Sep 16 09:00:03 mailserver1.example.com postfix/cleanup[9876]: DEF987654321: message-id=<12345abcdef@example.com>, status=rejected
Sep 16 09:00:04 mailserver1.example.com postfix/qmgr[9876]: DEF987654321: from=<user1@example.com>, size=9876, nrcpt=1 (queue active), status=sent
Sep 16 09:00:05 mailserver1.example.com postfix/smtp[4321]: DEF987654321: to=<user2@example.com>, relay=mail.example.net[192.168.1.2]:25, delay=1.5, delays=0.04/0/0.14/1.3, dsn=2.0.0, status=deferred
Sep 16 09:00:06 mailserver1.example.com postfix/qmgr[9876]: DEF987654321: removed, status=sent
Sep 16 09:04:08 mailserver1.example.com postfix/qmgr[9876]: GHI654321098: from=<user1@example.com>, size=8765, nrcpt=1 (queue active), status=bounced
Sep 16 09:04:09 mailserver1.example.com postfix/smtp[9876]: connect to mail.example.org[203.0.113.1]:25: Connection timed out, status=rejected
Sep 16 09:04:10 mailserver1.example.com postfix/smtp[9876]: GHI654321098: host mail.example.net[192.168.1.2] said: 450 4.7.1 <user3@example.net>: Recipient address rejected: Mail from <192.168.1.3> was refused due to the sender IP found in ERS-QIL. For details, query the IP reputation on https://example.com/ers/. (in reply to RCPT TO command), status=deferred

以下のコマンドを(ご自身のパスに合わせて)実行してください。

tail -n 100 /var/log/mail.log > /path/to/laravel/storage/logs/maillog_copy.txt && php /path/to/laravel/artisan extract:failed_email

なお、このコマンドはcrontabで定期実行することを想定したものです。
実際には以下のようにするといいでしょう。(この例は30分ごとの実行です)

*/30 * * * * tail -n 100 /var/log/mail.log > /path/to/laravel/storage/logs/maillog_copy.txt && php /path/to/laravel/artisan extract:failed_email

では実際はどうなったでしょうか。

結果は・・・・・・

はい❗
うまく動いているようです。

では、テーブルの方も確認しておきましょう。

どうなったでしょうか・・・・・・

はい❗
うまくテーブルに保存され、ステータスも保存されています。

では、その後メール送信がうまくいったことを想定してメールログの最終行(つまりメールアドレスはuser10@example.com)のstatussentへ変更してもう一度実行してみます。

すると・・・・・・

はい❗
user10@example.comのデータが消えました。

成功です😊✨

では、続いてバリデーションのチェックです。
ルートに以下を追加して実行してみます。

※ なお、この中のuser9@example.comは送信失敗メールとして保存されたアドレスです。

routes/web.php

use Illuminate\Support\Facades\Validator;
use App\Rules\IsSendableEmailAddress;

// 省略

Route::get('failed_email', function(){

    $checking_email = 'user9@example.com';

    $validator = Validator::make(
        ['email' => $checking_email],
        ['email' => [
            'required',
            'email',
            'max:255',
            new IsSendableEmailAddress(),
        ]]
    );

    if($validator->fails()) {

        $error_messages = $validator->errors()->all();
        dd($error_messages);

    }

    return 'バリデーションを通過しました';
});

うまくいくでしょうか・・・・・・

はい❗
うまくバリデーションも効きました。

すべて成功です。😊👍

企業様へのご提案

今回のようにLaravelだけでは解決できない問題もいろいろなテクノロジーを組み合わせると解決することができるようになります。

もし今回のような機能をご希望でしたらいつでもお問い合わせからご相談ください。

お待ちしております。😊✨

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回はメールログから送信失敗メールを取得してバリデーション化してみました。

なお、今回の記事で重要な点は「ひとつひとつは特に対して難しい作業ではない」という部分です。

つまり、無数にある機能の中から必要なものを組み合わせることによって、いろいろなことが実現できるよというのがコンセプトでした。

ぜひ皆さんも今回のような「掛け合わせ」で何か機能を作ってみてくださいね。

ではでは〜❗

「ゲームはほぼやらなく
なっちゃったけど、
『くにおくん』の新作はやりたいな〜❗」

このエントリーをはてなブックマークに追加       follow us in feedly