ปัญหาที่เจอ
คำถามที่ต้องตอบบ่อยเมื่อแอปส่งอีเมลหาลูกค้า คือ "ระบบส่งอีเมลยืนยันคำสั่งซื้อนี้ไปหรือยัง ส่งถึงใคร ส่งเมื่อไร" ถ้าในแอปไม่มีบันทึกไว้ ก็ต้องไปไล่ดูใน 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 อะไรเพิ่ม
รายการกลุ่ม

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

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

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

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

แถวที่ล้มเหลวมีป้ายสีแดงและข้อความ 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จะแสดงเฉพาะฉบับแรก อีเมลที่มีข้อมูลส่วนตัวมากจนไม่อยากเก็บไว้เลย ให้ overridemailLogSkip()ให้คืน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-medialibrary11.x
GitHub: phattarachai/mail-log-laravel


