Open source

mail-log-laravel เก็บทุกอีเมลที่แอป Laravel ส่ง ดูย้อนหลังได้ว่าส่งถึงใคร สำเร็จหรือไม่

แพ็กเกจที่บันทึกทุกอีเมลที่แอป Laravel ส่งออก จัดกลุ่มตาม template และ record ที่เป็นต้นเรื่อง พร้อมหน้า /mail-log ให้ดูเนื้อหา ผู้รับ ไฟล์แนบ และ error ของแต่ละฉบับ

เผยแพร่
เวลาอ่าน
8 นาที

ปัญหาที่เจอ

คำถามที่ต้องตอบบ่อยเมื่อแอปส่งอีเมลหาลูกค้า คือ "ระบบส่งอีเมลยืนยันคำสั่งซื้อนี้ไปหรือยัง ส่งถึงใคร ส่งเมื่อไร" ถ้าในแอปไม่มีบันทึกไว้ ก็ต้องไปไล่ดูใน log ของผู้ให้บริการส่งอีเมล ซึ่งแยกอยู่อีกที่ เก็บย้อนหลังได้จำกัด และไม่รู้ว่าอีเมลฉบับนั้นมาจากคำสั่งซื้อไหนในระบบ

Laravel Telescope มีหน้าดูอีเมลที่ส่งออก แต่ออกแบบมาใช้ตอนพัฒนา เมื่ออยู่นอกเครื่อง local ค่าเริ่มต้นจะเก็บเฉพาะ exception, job ที่ล้มเหลว และ scheduled task และคำสั่ง prune จะลบข้อมูลที่เก่ากว่า 24 ชั่วโมง

mail-log-laravel จึงทำหน้าที่เดียวคือเก็บประวัติอีเมลขาออกบน production ไว้นานพอที่จะตอบคำถามแบบนี้ได้ (ค่าเริ่มต้น 365 วัน) และผูกแต่ละฉบับกับ record ในแอปที่เป็นต้นเรื่อง เว็บนี้เองก็ใช้แพ็กเกจนี้เก็บอีเมลที่ระบบส่ง

ทำงานอย่างไร

flowchart LR
  A["Mail::send() / Notification"] --> S[MessageSending]
  S -->|fingerprint| G[("mail_log_groups<br/>หนึ่งแถวต่อกลุ่ม")]
  S -->|แถวใหม่ Pending| E[("mail_logs<br/>หนึ่งแถวต่อการส่ง")]
  OK[MessageSent] -->|Sent + เวลาที่ใช้| E
  F["ส่งไม่ผ่าน / ค้างนานเกินไป"] -->|Failed + ข้อความ error| E
  G --> U[หน้า /mail-log]
  E --> U

ทุกอีเมลผ่าน event ของ Laravel เอง แพ็กเกจไม่ต้องเปลี่ยน mailer หรือ transport

แพ็กเกจฟัง event ที่ Laravel ส่งออกมาทุกครั้งที่มีการส่งอีเมล

  • ก่อนส่ง (MessageSending) คำนวณ fingerprint ของอีเมล หากลุ่มที่ตรงกัน ถ้ายังไม่มีก็สร้างใหม่ แล้วเพิ่มแถวการส่งสถานะ Pending พร้อมผู้รับ to, cc, bcc
  • ส่งสำเร็จ (MessageSent) เปลี่ยนแถวนั้นเป็น Sent บันทึกเวลาที่ใช้ส่ง และอัปเดตตัวนับของกลุ่ม
  • ส่งไม่ผ่าน เปลี่ยนแถวเป็น Failed พร้อมข้อความ error จาก mail server ครอบคลุมทั้ง Mailable และ Notification ที่ส่งผ่าน queue, การส่งแบบ sync ด้วย Mail::send() และ job ที่ retry ได้หลายครั้ง ซึ่งแต่ละครั้งที่ล้มเหลวจะเป็นหนึ่งแถว ขึ้นต้นด้วย Attempt 1 failed, the job will retry
  • ค้างนานเกินไป ถ้า worker ถูก kill กลางทาง ก็ไม่มี event ไหนบอกผลการส่ง คำสั่ง mail-log:settle-stale จะเปลี่ยนแถวที่ยัง Pending เกิน 15 นาทีเป็น Failed ทุกแถวจึงจบที่ Sent หรือ Failed เสมอ

ถ้าการบันทึกเกิดปัญหา เช่นฐานข้อมูลมีปัญหาชั่วคราว แพ็กเกจจะรายงาน exception แล้วปล่อยให้อีเมลส่งต่อไปตามปกติ การเก็บ log ต้องไม่เป็นเหตุให้อีเมลจริงส่งไม่ออก

กลุ่มกับการส่ง

หัวใจของแพ็กเกจคือการแยก กลุ่ม ออกจาก การส่ง สมมติแอปส่ง OrderShippedMail ของคำสั่งซื้อ #10234 ถึงลูกค้า แล้วส่งสำเนาถึงคลังสินค้าอีกฉบับ หน้า /mail-log จะแสดงเป็นหนึ่งกลุ่มที่มี 2 sends ไม่ใช่สองแถวที่หน้าตาเหมือนกัน ส่วนคำสั่งซื้อ #10235 จะเป็นอีกกลุ่ม

กลุ่มตัดสินจาก fingerprint ซึ่งค่าเริ่มต้นคือ คลาสของ Mailable + record ที่เป็นต้นเรื่อง ไม่ใช้เนื้อหาอีเมล เพราะเนื้อหามักต่างกันในแต่ละผู้รับ เช่นคำทักทายที่มีชื่อ หรือลิงก์ที่มีลายเซ็น ถ้าเอาเนื้อหามาคิดด้วย อีเมลฉบับเดียวกันจะแตกเป็นหลายกลุ่ม

เนื้อหา HTML, ข้อความ และไฟล์แนบ เก็บไว้ที่ระดับกลุ่มจากการส่งครั้งแรก ใช้เป็นตัวอย่างว่าอีเมลกลุ่มนี้หน้าตาเป็นอย่างไร ส่วนแต่ละแถวการส่งเก็บผู้รับ สถานะ เวลา และ error ฐานข้อมูลจึงไม่โตตามจำนวนผู้รับ แม้อีเมลฉบับเดียวจะส่งถึงคนหลายร้อยคน

ติดตั้ง

composer require phattarachai/mail-log-laravel
php artisan mail-log:install
php artisan migrate

mail-log:install รันซ้ำได้โดยไม่ทับของเดิม และใส่ --dry-run เพื่อดูก่อนว่าจะทำอะไรบ้าง คำสั่งนี้

  • publish config/mail-log.php และ migration ของตาราง mail_log_groups กับ mail_logs
  • publish migration ของ spatie/laravel-medialibrary ถ้าแอปยังไม่มีตาราง media (แพ็กเกจใช้เก็บไฟล์แนบ)
  • เพิ่ม MAIL_LOG_ENABLED, MAIL_LOG_RETENTION_DAYS, MAIL_LOG_UI_PATH ใน .env เฉพาะตัวที่ยังไม่มี
  • พิมพ์โค้ดที่ต้องวางเองในขั้นถัดไป

ให้สิทธิ์เข้าหน้า /mail-log

ค่าเริ่มต้นเปิดหน้า /mail-log ให้เข้าได้เฉพาะตอน APP_DEBUG=true บน production จึงเข้าไม่ได้จนกว่าจะกำหนดเองว่าใครเข้าได้ ใส่ใน AppServiceProvider::boot()

use Phattarachai\MailLogLaravel\MailLog;
use Phattarachai\MailLogLaravel\Models\MailLogGroup;

public function boot(): void
{
    MailLogGroup::registerMorphMap();

    MailLog::auth(fn ($request) => $request->user()?->isAdmin() ?? false);
}

registerMorphMap() ลงทะเบียนชื่อสั้นของ model กลุ่ม ให้ตาราง media เก็บเป็น mail_log_group แทนชื่อคลาสเต็ม

ตั้ง schedule

ต้องมีงานสองตัวรันประจำ

use Illuminate\Support\Facades\Schedule;
use Phattarachai\MailLogLaravel\Models\MailLogGroup;

Schedule::command('model:prune', ['--model' => [MailLogGroup::class]])->daily();
Schedule::command('mail-log:settle-stale')->everyTenMinutes();
  • model:prune ลบกลุ่มที่ไม่มีการส่งเพิ่มเกิน MAIL_LOG_RETENTION_DAYS วัน (ค่าเริ่มต้น 365) แถวการส่งของกลุ่มนั้นถูกลบตามไปด้วย
  • mail-log:settle-stale ปิดแถวที่ค้าง Pending นานเกิน MAIL_LOG_PENDING_TIMEOUT_MINUTES (ค่าเริ่มต้น 15 นาที) ให้เป็น Failed ใส่ --minutes=N เพื่อกำหนดเวลาเองในรอบนั้น

ผูกอีเมลกับ record ด้วย HasMailLog

ติดตั้งเสร็จ ทุกอีเมลที่แอปส่งจะถูกบันทึกทันที แต่จะได้ประโยชน์เต็มที่เมื่อบอกแพ็กเกจว่าอีเมลฉบับนั้นมาจาก record ไหน ใส่ trait HasMailLog ใน Mailable

use App\Models\Order;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Headers;
use Phattarachai\MailLogLaravel\Concerns\HasMailLog;

class OrderShippedMail extends Mailable
{
    use HasMailLog;

    public function __construct(public Order $order) {}

    protected function mailLogModel(): ?Model
    {
        return $this->order;
    }

    public function headers(): Headers
    {
        return $this->withMailLog(new Headers());
    }
}

ใช้แค่สองส่วน mailLogModel() บอกว่าอีเมลนี้เกี่ยวกับคำสั่งซื้อไหน และ headers() ส่งต่อให้ withMailLog() ซึ่งแนบ header X-Mail-* ไปกับอีเมล listener อ่าน header พวกนี้เพื่อคำนวณ fingerprint หลังจากนั้นจะส่ง OrderShippedMail ของคำสั่งซื้อเดียวกันถึงใครกี่ครั้ง ก็จะรวมอยู่ในกลุ่มเดียว

trait มี method อื่นให้ override เมื่อต้องการ ทุกตัวมีค่าเริ่มต้นที่ใช้ได้อยู่แล้ว

method ใช้เมื่อ
mailLogModel(): ?Model บอก record ที่เป็นต้นเรื่อง เช่น Order, Invoice, User
mailLogNotificationClass(): ?string Mailable ถูกสร้างใน toMail() ของ Notification และอยากให้หน้า /mail-log แสดงชื่อ Notification
mailLogFingerprintHints(): array อยากแยกกลุ่มละเอียดขึ้น เช่น ตาม tenant หรือ A/B variant
mailLogFingerprintMode(): ?array เปลี่ยนสิ่งที่ใช้คิดกลุ่มของ Mailable นี้ เลือกได้จาก class, notification_class, model, hints, subject, body, mailer
mailLogSkip(): bool ไม่ต้องบันทึก Mailable นี้เลย

ลิงก์กลับไปที่ record

ให้ model ที่เป็นต้นเรื่อง implement MailLogLinkable หน้า /mail-log จะแสดงชื่อที่อ่านง่ายแทน Order#42 และกดลิงก์กลับไปหน้า record นั้นในแอปได้

use Phattarachai\MailLogLaravel\Contracts\MailLogLinkable;

class Order extends Model implements MailLogLinkable
{
    public function mailLogTitle(): string
    {
        return "คำสั่งซื้อ #{$this->number}";
    }

    public function mailLogUrl(): ?string
    {
        return route('admin.orders.show', $this);
    }
}

อีเมลที่ไม่ได้ใส่ trait

อีเมลที่ไม่ได้ใส่ trait ก็ยังถูกบันทึก เช่น Mail::raw() หรือ Notification ที่ใช้ MailMessage ตรง ๆ แต่ไม่มีข้อมูลคลาสและ record ให้ใช้ แพ็กเกจจึงคิดกลุ่มจาก subject, mailer และเนื้อหาอีเมลแทน ก่อนคำนวณจะตัดส่วนที่เปลี่ยนทุกฉบับออกจากเนื้อหา คือ query string อย่าง token, signature, expires ลิงก์ verify-email และ reset password และวันเวลา อีเมลแบบเดียวกันจึงยังรวมกลุ่มกันได้ แก้รูปแบบที่ใช้ตัดได้ที่ fingerprint.body_strip_patterns ใน config

ลิงก์ลับในเนื้ออีเมล

อีเมลอย่าง reset password, ยืนยันอีเมล หรือลิงก์ login มีลิงก์ที่ใช้แทนรหัสผ่านได้ ถ้าเก็บเนื้ออีเมลตามที่ส่งจริง ใครที่เข้าหน้า /mail-log ได้ก็จะเห็นลิงก์ที่ยังใช้งานได้ของผู้รับคนแรก แพ็กเกจจึงปิดบังส่วนนี้ก่อนเก็บลงฐานข้อมูลเป็นค่าเริ่มต้น ค่าของ token, signature, expires, code, hash, otp ใน query string และ token ใน path ของ reset password และ verify email จะถูกเก็บเป็น [redacted] อีเมลที่ผู้รับได้รับยังเป็นฉบับเต็มเหมือนเดิม เพิ่มรูปแบบที่ต้องการปิดบังได้ที่ redact.patterns ใน config

หน้า /mail-log

หน้า dashboard มี CSS และ JavaScript ที่ build มาแล้วในแพ็กเกจ แบบเดียวกับ Horizon และ Pulse แอปจะใช้ Blade, Livewire หรือ Inertia ก็ได้ ไม่ต้องติดตั้ง npm package หรือ build อะไรเพิ่ม

รายการกลุ่ม

รายการกลุ่มอีเมลในหน้า /mail-log

หน้าแรกเรียงกลุ่มตามเวลาที่ส่งล่าสุด แต่ละแถวบอก subject, ผู้รับ, คลาสของ Mailable, record ที่เป็นต้นเรื่อง, mailer, จำนวนครั้งที่ส่ง, สถานะล่าสุด และเวลาส่งล่าสุดในรูปแบบวันที่ไทย กลุ่มที่มีการส่งล้มเหลวจะมีป้ายบอกจำนวนให้เห็นทันที

กรองเฉพาะกลุ่มที่มีการส่งล้มเหลว

ช่องค้นหาหาได้จาก subject, ชื่อคลาสของ Mailable หรือ Notification และอีเมลผู้รับทั้ง to, cc, bcc โดยไม่สนตัวพิมพ์เล็กใหญ่ กรองตามสถานะล่าสุด หรือเลือกดูเฉพาะกลุ่มที่เคยส่งล้มเหลวได้ ถ้าลูกค้าแจ้งว่าไม่ได้รับอีเมล ค้นด้วยอีเมลของลูกค้าก็จะเห็นทุกฉบับที่ระบบส่งหาเขา

รายละเอียดกลุ่ม

หน้ารายละเอียดของกลุ่มอีเมล

หน้ากลุ่มบอกภาพรวมด้านบน ได้แก่ จำนวนครั้งที่ส่ง, จำนวนที่ล้มเหลวหรือยังรอผล, อัตราสำเร็จ, เวลาส่งครั้งแรกและล่าสุด ถัดมาเป็นตัวอย่างอีเมลจริงที่ผู้รับเห็น แสดงใน <iframe srcdoc> แบบ sandbox สคริปต์ในอีเมลจึงทำงานในหน้า admin ไม่ได้ กรอบตัวอย่างสูงตามความยาวของอีเมล ด้านล่างเป็นตาราง Sends ของการส่งทุกครั้งในกลุ่มนี้ บอกผู้รับ สถานะ เวลาที่ส่งสำเร็จ และเวลาที่ใช้ส่ง ส่วนแถบ Metadata บอก From, Mailable, Mailer, record ที่ผูกไว้พร้อมลิงก์ และ fingerprint

เนื้ออีเมลฉบับข้อความล้วนในแท็บ Text

ตัวอย่างอีเมลสลับดูได้สามแบบ HTML คือหน้าตาจริง, Text คือฉบับข้อความล้วนที่ส่งไปพร้อมกัน และ Source คือ HTML ดิบ ใช้ไล่หาว่า template render อะไรออกมา หรือเช็กว่าเลขพัสดุและลิงก์ในอีเมลถูกต้อง

เมื่อส่งไม่สำเร็จ

แถวที่ส่งล้มเหลวพร้อมข้อความ error

แถวที่ล้มเหลวมีป้ายสีแดงและข้อความ error จาก mail server เช่น SMTP เชื่อมต่อไม่ได้ หรือปลายทางตอบว่าไม่มีกล่องจดหมายนี้ ข้อความแบบนี้บอกได้ทันทีว่าต้องแก้ที่ระบบหรือที่อีเมลผู้รับ

ผู้รับและไฟล์แนบ

รายชื่อผู้รับและไฟล์แนบของกลุ่ม

ส่วน Recipients รวมทุกที่อยู่ที่กลุ่มนี้เคยส่งถึงทั้ง to, cc, bcc โดยไม่ซ้ำ เรียงตามลำดับที่ส่งถึงก่อน ส่วนไฟล์แนบเก็บผ่าน medialibrary ดาวน์โหลดกลับมาดูได้ว่าแนบไฟล์อะไรไป ไฟล์ที่ใหญ่กว่า MAIL_LOG_ATTACHMENT_MAX_BYTES (ค่าเริ่มต้น 10 MB) จะไม่ถูกเก็บ

ส่งอีเมลทดสอบ

หน้าต่างส่งอีเมลทดสอบ

ปุ่ม Test send ส่งอีเมลทดสอบพร้อมข้อความและไฟล์แนบไปที่อีเมลที่กรอก ใช้เช็กว่าการตั้งค่า mailer บน production ส่งออกได้จริง โดยไม่ต้องเข้า tinker อีเมลทดสอบก็ถูกบันทึกเป็นกลุ่มหนึ่งเหมือนอีเมลอื่น ถ้าไม่อยากให้มีปุ่มนี้ ตั้ง MAIL_LOG_TEST_SEND_ENABLED=false

ตั้งค่า

ตัวแปร ค่าเริ่มต้น ใช้ทำอะไร
MAIL_LOG_ENABLED true เปิดหรือปิดการบันทึกทั้งหมด
MAIL_LOG_RETENTION_DAYS 365 เก็บกลุ่มที่ไม่มีการส่งเพิ่มไว้กี่วัน ตั้งเป็น null ถ้าไม่ต้องการลบ
MAIL_LOG_PENDING_TIMEOUT_MINUTES 15 แถวที่ค้าง Pending นานเกินนี้ mail-log:settle-stale จะเปลี่ยนเป็น Failed
MAIL_LOG_REDACT_BODIES true ปิดบังลิงก์ลับและ token ในเนื้ออีเมลก่อนเก็บ
MAIL_LOG_UI_PATH mail-log path ของหน้า dashboard ตั้งเป็น null ถ้าไม่ต้องการหน้านี้
MAIL_LOG_TEST_SEND_ENABLED true แสดงปุ่ม Test send
MAIL_LOG_MAX_RECIPIENTS_PER_EVENT 200 จำนวนผู้รับสูงสุดที่เก็บต่อการส่งหนึ่งครั้ง
MAIL_LOG_ATTACHMENT_MAX_BYTES 10485760 ขนาดไฟล์แนบสูงสุดที่เก็บ
MAIL_LOG_UI_BACK_URL url('/') ลิงก์กลับเข้าแอปที่มุมซ้ายบน ตั้งเป็น false เพื่อซ่อน

ค่าที่เหลือ เช่น ชื่อตาราง, disk ที่เก็บไฟล์แนบ และจำนวนแถวต่อหน้า อยู่ใน config/mail-log.php

ข้อควรรู้

  • Sent แปลว่า mail server รับอีเมลไปแล้ว ไม่ได้ยืนยันว่าถึง inbox ของผู้รับ ถ้าปลายทางตีกลับทีหลังหรืออีเมลเข้า spam แพ็กเกจจะไม่รู้ ข้อมูลส่วนนั้นต้องดูจากผู้ให้บริการส่งอีเมล
  • เนื้อหาที่เห็นเป็นของการส่งครั้งแรกในกลุ่ม ถ้าเนื้อหาต่างกันในแต่ละผู้รับ หน้า /mail-log จะแสดงเฉพาะฉบับแรก อีเมลที่มีข้อมูลส่วนตัวมากจนไม่อยากเก็บไว้เลย ให้ override mailLogSkip() ให้คืน true
  • ต้องมี spatie/laravel-medialibrary แพ็กเกจติดตั้งให้เอง และใช้ตาราง media ร่วมกับแอป ถ้าแอปใช้ medialibrary อยู่แล้วก็ใช้ร่วมกันได้เลย
  • หน้า dashboard แสดงวันที่แบบไทย เช่น 19 พ.ค. 69 · 15:07 ส่วนข้อความอื่นในหน้าเป็นภาษาอังกฤษ

เวอร์ชันที่รองรับ

โพสต์นี้อ้างอิงเวอร์ชัน 0.3.3 ถ้าอัปเกรดจาก 0.2 ให้เพิ่ม key ใหม่ใน config/mail-log.php ที่ publish ไว้ และตั้ง schedule ของ mail-log:settle-stale ไม่ต้องรัน migration

  • PHP 8.4 ขึ้นไป
  • Laravel 12 และ 13
  • spatie/laravel-medialibrary 11.x

GitHub: phattarachai/mail-log-laravel

อ่านต่อ

ล่าสุด

ดูทั้งหมด →