
九保すこひです(フリーランスのITコンサルタント、エンジニア)
さてさて、サイトの運営をしているとホントにいろいろなところで修正が必要な部分が出てくるとは思うのですが、この間困ったのが今回のテーマにした、
(実は)メールが送信されていなかった
という不具合でした
というのも、Laravel
側ではエラーも出ず送信できるんですが、送信先のセキュリティソフトなどが受取を拒否している場合があり、そんな場合はLaravel
側では把握することができず、メールログを見てやっと内容がわかるというものでした。(gmail
のspf
が必要になった、とかもですね)
そして、どうやらpostfix
を使えば通知メッセージを自分に送信することはできるようですが、メール送信できない問題の根本解決できるわけではありません。
そこで
今回は、メールログを定期的にチェックして「送信に失敗したメールアドレス」をデータベースに貯めておき、次回はバリデーションでエラーを出すようにしてみることにしました。(つまり、本人に「前のメール送信失敗したからダメよ」と伝えるようにしたいわけです)
ぜひ何かの参考になりましたら嬉しいです。
「
ChatGPT
がいい回答したときは
『ナイス!』
と言っておだてるようにしています」
開発環境: Laravel 10.x
目次 [非表示]
実装する手順
まずは今回の実装をする手順をまとめておきます。
- メールログから最後の一部を切り取って Laravel 側のテキストに保存する(※)
- Laravel のコマンドを実行して問題のあるメールアドレスを取得
- それらを DB へ保存
- 保存したメールアドレスを使ってバリデーションを実施する
※ 環境によるかもですが、私の場合だと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
)のstatus
をsent
へ変更してもう一度実行してみます。
すると・・・・・・
はい
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
だけでは解決できない問題もいろいろなテクノロジーを組み合わせると解決することができるようになります。
もし今回のような機能をご希望でしたらいつでもお問い合わせからご相談ください。
お待ちしております。
おわりに
ということで、今回はメールログから送信失敗メールを取得してバリデーション化してみました。
なお、今回の記事で重要な点は「ひとつひとつは特に対して難しい作業ではない」という部分です。
つまり、無数にある機能の中から必要なものを組み合わせることによって、いろいろなことが実現できるよというのがコンセプトでした。
ぜひ皆さんも今回のような「掛け合わせ」で何か機能を作ってみてくださいね。
ではでは〜
「ゲームはほぼやらなく
なっちゃったけど、
『くにおくん』の新作はやりたいな〜」