یک کاربر ثبتنام کرد و ایمیل فعالسازی نیامد. تیکت زد و ما هم همان کاری را کردیم که همه اولِ کار میکنند: خودمان یک حساب تستی ساختیم، ایمیل آمد، و جواب دادیم «از سمت ما مشکلی نیست، پوشهی اسپم را نگاه کنید». دو روز بعد سه تیکت مشابه داشتیم و یکی از آنها فاکتور خریدِ پرداختشده بود. آنوقت بود که فهمیدیم چیزی که خودمان اسمش را گذاشته بودیم «ایمیل سایت به اسپم میرود»، اصلاً یک مشکل نیست.
تستها متناقض بودند. ایمیل به یک آدرس میرسید، به آدرس دیگری اصلاً نمیرسید، و به سومی میرسید ولی مستقیم داخل اسپم. فاکتورها میآمدند و ایمیل بازیابی رمز نمیآمد. در لاگ لاراول هم هیچ استثنایی ثبت نشده بود. وقتی خطایی نیست و رفتار هم ثابت نیست، آدم شروع میکند به عوضکردنِ تصادفیِ تنظیمات — و ما دقیقاً همین کار را دو روز کردیم.
آخرش سه چیزِ جدا از هم پیدا کردیم که هر سه یک علامت داشتند. بخشی از ایمیلها اصلاً از سرور خارج نمیشد چون در صف مانده بود. بخشی خارج میشد ولی گیرنده چون نمیتوانست فرستنده را احراز کند بیاعتبارش میدانست. بخشی هم پذیرفته میشد و بهخاطر شکل خودِ پیام در اسپم میافتاد. این مقاله همان ترتیب را دنبال میکند، چون اگر از وسط شروع کنید — مثلاً مستقیم بروید سراغ رکوردهای DNS — ممکن است ساعتها روی چیزی وقت بگذارید که اصلاً علت نبوده است.
اول تشخیص: ایمیل نرفته یا رفته و در اسپم افتاده؟#
سه حالت ممکن است و هر سه از دید کاربر یکساناند: «ایمیل نیامد».
- برنامه اصلاً پیام را به سرور ایمیل تحویل نداده است.
- تحویل داده ولی سرور گیرنده آن را رد یا موقتاً پس زده است.
- گیرنده پذیرفته ولی در پوشهی اسپم گذاشته است.
برای جدا کردن این سه، از سمت خودتان شروع کنید. اول لاگ لاراول را نگاه کنید. اگر ارسال شکست خورده باشد، معمولاً یک استثنای مربوط به انتقال ایمیل آنجا هست:
tail -n 300 storage/logs/laravel.log | grep -i -E 'mail|smtp|transport'اگر لاگ خالی است، هنوز نمیدانید ارسال موفق بوده یا اصلاً اتفاق نیفتاده. برای همین یک تست بیخطر داریم: درایور ایمیل را موقتاً روی لاگ بگذارید، کش پیکربندی را پاک کنید و همان عملیات را دوباره اجرا کنید.
MAIL_MAILER=logphp artisan config:clearاگر متنِ کاملِ ایمیل داخل storage/logs/laravel.log نوشته شد، یعنی برنامه واقعاً دارد پیام را میسازد و «میفرستد» و مشکل بعد از لاراول اتفاق میافتد. اگر چیزی نوشته نشد، مشکل داخل خود برنامه است و رکوردهای DNS هیچ ربطی به آن ندارند.
نکتهای که ما را گیر انداخت: config:clear را فراموش نکنید. اگر config:cache گرفته باشید، تغییر .env تا وقتی کش پاک نشود هیچ اثری ندارد. و اگر مقدارها را از ویندوز کپی کردهاید، پایانخطها را چک کنید؛ پایانخط ویندوزی در فایل env تلهای است که رمز SMTP یکی از قربانیان کلاسیکش است.
برای تست دستی هم tinker کافی است:
Mail::raw('تست ارسال از سرور', function ($m) {
$m->to('you@example.com')->subject('تست ارسال');
});در سمت سرور، سیپنل صفحهای به نام Track Delivery دارد که گزارش تحویل پیامهای خروجی را نشان میدهد. اگر پیام شما آنجا نیست، یعنی هرگز به سرور ایمیل تحویل داده نشده. اگر هست و با خطا برگشته، متن خطا را همانجا میبینید — و آن متن معمولاً دقیقاً میگوید گیرنده چه ایرادی گرفته است.
اگر ایمیل در صف مانده باشد، هیچوقت ارسال نمیشود#
این متداولترین حالتی است که ما دیدیم و بامزهترین بخشش این است که هیچ خطایی تولید نمیکند.
اگر کلاس ایمیل شما ShouldQueue را پیاده کرده باشد و QUEUE_CONNECTION روی database باشد، لاراول پیام را نمیفرستد؛ فقط یک رکورد در جدول jobs میگذارد و بلافاصله پاسخ موفق میدهد. روی هاست اشتراکی پروسهی دائمی نداریم، پس اگر کرون کارگر را اجرا نکند، جدول jobs فقط بزرگتر میشود و هیچ ایمیلی نمیرود.
اول ببینید چند کار در صف مانده و چند کار شکست خورده:
SELECT COUNT(*) FROM jobs;php artisan queue:failedاگر شمارش jobs عددی بزرگ و رو به رشد است، مشکل شما اصلاً ایمیل نیست، صف است. روی سیپنل سادهترین راه یک کرونجاب کوتاه است که کارگر را اجرا میکند، صف را خالی میکند و بیرون میآید:
*/5 * * * * /usr/local/bin/ea-php84 /home/user/app/artisan queue:work --stop-when-empty --max-time=280 >> /home/user/logs/queue.log 2>&1مسیر مطلق مفسر پیاچپی را حتماً بنویسید؛ شل کرون غیرتعاملی است و نامهای مستعارِ حساب شما را نمیشناسد — تلهی alias در کرونجاب سیپنل خودش یک مقالهی جداست.
اگر تا اینجا تست MAIL_MAILER=log جواب داده و صف هم خالی میشود، برنامهی شما سالم است. از اینجا به بعد بحث این است که گیرنده چرا به شما اعتماد نمیکند.
سه لایهی احراز فرستنده: SPF و DKIM و DMARC#
پروتکل اصلی ایمیل هیچ تصوری از «فرستندهی واقعی» ندارد. هر سروری میتواند پیامی بفرستد که در فیلد From نام دامنهی شما نوشته شده باشد. سه لایهی SPF و DKIM و DMARC دقیقاً برای پر کردن همین حفره ساخته شدهاند و هرکدام چیز متفاوتی را ثابت میکنند.
SPF فهرستی است که در DNS دامنهی شما منتشر میشود و میگوید کدام سرورها اجازه دارند از طرف این دامنه ایمیل بفرستند. گیرنده آدرس سروری را که به آن وصل شده با این فهرست مقایسه میکند. SPF دربارهی محتوای پیام هیچ حرفی نمیزند.
DKIM یک امضای رمزنگاشتی روی خود پیام است. سرور فرستنده با کلید خصوصی، بخشهایی از هدر و بدنه را امضا میکند و کلید عمومی را در DNS میگذارد. گیرنده امضا را بررسی میکند و میفهمد پیام در راه دستکاری نشده و واقعاً از سمت دارندهی آن کلید آمده است. DKIM برخلاف SPF از فورواردها هم جان سالم به در میبرد.
DMARC لایهی سوم است و دو کار میکند. اول: میگوید نتیجهی دو لایهی قبل باید با دامنهای که کاربر در From میبیند همتراز باشد. دوم: به گیرنده میگوید اگر این همترازی برقرار نبود چه کند — هیچ، قرنطینه، یا رد. ضمناً امکان گرفتن گزارش دورهای هم دارد.
نکتهی مهم که خیلیها جا میاندازند: SPF روی آدرس پاکت (همان آدرسی که در گفتوگوی SMTP اعلام میشود) بررسی میشود، نه روی چیزی که کاربر در From میبیند. این دو میتوانند متفاوت باشند و همین جاست که DMARC وارد میشود و همترازی را الزامی میکند.
رکورد SPF را در Zone Editor سیپنل بسازید#
در سیپنل به بخش Zone Editor بروید، دامنه را انتخاب کنید و رکوردهای موجود را ببینید. SPF یک رکورد از نوع TXT روی خود دامنه است، نه یک نوع رکورد جداگانه.
| نام | نوع | مقدار نمونه |
|---|---|---|
| example.com | TXT | v=spf1 +mx +a +ip4:198.51.100.7 ~all |
| default._domainkey | TXT | v=DKIM1; k=rsa; p=... |
| _dmarc | TXT | v=DMARC1; p=none; rua=mailto:dmarc@example.com |
آیپی بالا فقط نمونه است. آیپیِ خروجیِ سرور خودتان را بگذارید؛ اگر ندارید، معمولاً +a و +mx برای هاست اشتراکی کافی است چون سرور ارسال همان سروری است که سایت روی آن اجرا میشود.
دو قاعده که نقضشان باعث میشود SPF شما بیاثر یا حتی مضر شود:
فقط یک رکورد SPF مجاز است. اگر دو رکورد TXT داشته باشید که هر دو با v=spf1 شروع شوند، نتیجه خطای دائمی است و گیرنده کل SPF شما را نامعتبر میداند — نه اینکه یکی را انتخاب کند. این معمولاً وقتی پیش میآید که یک بار سیپنل رکورد ساخته و یک بار هم شما دستی رکوردی برای سرویس دیگری اضافه کردهاید. راه درست ادغام است: همهی مکانیزمها داخل همان یک رکورد.
بیش از ده جستجوی DNS شکست میآورد. هر include و a و mx یک جستجوی DNS لازم دارد و سقف کل ده تاست. از این عدد که رد شوید، باز هم خطای دائمی میگیرید و انگار اصلاً SPF ندارید. ip4 و ip6 جستجو حساب نمیشوند، پس جایگزین کردن چند include با آیپی مستقیم راه نجات است.
در مورد پایانِ رکورد: ~all یعنی «هر سرور دیگری مشکوک است» و -all یعنی «قطعاً غیرمجاز». اگر مطمئن نیستید همهی مسیرهای ارسالتان را شمردهاید با ~all شروع کنید؛ سختگیرتر شدن بعداً ممکن است، برگرداندن ایمیلهای ازدسترفته نه.
هشدار: تغییر رکورد DNS آنی نیست. بسته به مقدار TTL و کشهای میانی، ممکن است چند ساعت طول بکشد تا همهی گیرندهها مقدار جدید را ببینند. بعد از هر تغییر، صبر کنید و بعد تست کنید؛ وگرنه دارید نتیجهی مقدار قدیمی را میبینید و بیدلیل تنظیمات درست را عوض میکنید.
DKIM را در Email Deliverability سیپنل فعال کنید#
سیپنل صفحهای به نام Email Deliverability دارد که برای همین ساخته شده: فهرست دامنهها با وضعیت هرکدام، و دکمهای برای اصلاح خودکار که اگر DNS روی همان سرور مدیریت شود رکوردهای لازم را خودش میسازد. با رفتن به Manage، مقدار کاملِ رکورد DKIM و SPF پیشنهادی را میبینید. DKIM سیپنل معمولاً با انتخابگر default منتشر میشود، یعنی نام رکورد default._domainkey است.
اینجا یک تلهی رایج هست که خیلی وقتها علت اصلی است: اگر DNS دامنهی شما جای دیگری مدیریت میشود، سیپنل رکورد را میسازد ولی آن رکورد هیچوقت منتشر نمیشود. باید مقدار را از همان صفحه کپی کنید و دستی در پنل مدیریت DNS واقعیتان بسازید. وگرنه سیپنل تا ابد وضعیت را ناقص نشان میدهد و شما دنبال مشکلی در سرور میگردید که آنجا نیست.
برای اطمینان، انتشار هر سه رکورد را از بیرون بررسی کنید:
dig +short TXT example.com
dig +short TXT default._domainkey.example.com
dig +short TXT _dmarc.example.comاگر dig در دسترس نیست، host -t TXT example.com هم همین کار را میکند. این بررسی را از بیرونِ سرور انجام دهید؛ سرور خودتان ممکن است نسخهی محلی زون را ببیند نه چیزی که دنیا میبیند.
DMARC را با p=none شروع کنید، نه با رد کردن#
وسوسهی طبیعی این است که همان اول p=reject بگذارید تا کار تمام شود. این کار خطرناک است: تا وقتی مطمئن نشدهاید همهی مسیرهای ارسال شما (سایت، پنل پشتیبانی، خبرنامه، فرم تماس) درست امضا و تراز میشوند، p=reject یعنی گیرندهها ایمیلهای واقعی شما را هم دور میریزند و شما هیچ خبری هم نمیشوید.
مسیر درست سه مرحله است. اول با سیاست «هیچ» شروع کنید و فقط گزارش بگیرید:
v=DMARC1; p=none; rua=mailto:dmarc@example.com; fo=1این رکورد از نوع TXT است و نامش _dmarc روی دامنهی شماست. با این تنظیم هیچ ایمیلی بهخاطر DMARC رد نمیشود، ولی گیرندهها دورهای گزارش میفرستند که چه تعداد پیام با نام دامنهی شما دیدهاند و چند تا از آنها SPF و DKIM را پاس کردهاند. گزارشها فایل XML هستند و خواندن دستیشان حوصله میخواهد، ولی همان چند هفتهی اول نشان میدهد کدام مسیر ارسالتان را فراموش کرده بودید.
بعد از اینکه گزارشها نشان دادند تقریباً همهی ترافیک واقعی شما پاس میشود، به p=quarantine بروید و بعد در صورت اطمینان به p=reject. پارامتر pct هم اجازه میدهد سیاست فقط روی درصدی از پیامها اعمال شود. اگر همترازیِ سختگیرانه میخواهید adkim=s و aspf=s را اضافه کنید، ولی برای اکثر فروشگاهها حالت پیشفرض که آسانگیرانه است کافیست.
تنظیم درست ارسال ایمیل در لاراول: smtp یا sendmail#
روی هاست اشتراکی دو گزینه دارید و انتخاب اشتباه میتواند ساعتها وقت بگیرد.
MAIL_MAILER=sendmail از باینری محلی سرور استفاده میکند. ساده است، ولی برای اجرای آن پیاچپی باید بتواند پروسهی بیرونی اجرا کند. روی خیلی از هاستهای اشتراکی ایرانی توابع اجرای پروسه در disable_functions بستهاند و نتیجه یک خطای مبهم است که هیچ ربطی به ایمیل ندارد. اگر با این حالت روبهرو شدید، محدودیت disable_functions روی هاست اشتراکی همان چیزی است که باید اول چک کنید.
MAIL_MAILER=smtp قابل پیشبینیتر است چون از یک حساب ایمیل واقعی روی همان سرور استفاده میکند و خطاهایش خوانا هستند:
MAIL_MAILER=smtp
MAIL_HOST=mail.example.com
MAIL_PORT=465
MAIL_USERNAME=no-reply@example.com
MAIL_PASSWORD="رمز حساب ایمیل"
MAIL_SCHEME=smtps
MAIL_FROM_ADDRESS="no-reply@example.com"
MAIL_FROM_NAME="${APP_NAME}"دربارهی پورت و رمزنگاری: پورت ۴۶۵ رمزنگاری را از همان ابتدای اتصال برقرار میکند و پورت ۵۸۷ اول اتصال ساده میزند و بعد ارتقا میدهد. این دو قابل جابهجایی نیستند؛ ترکیب اشتباهشان معمولاً به قطعِ بیصدای اتصال یا تایماوت ختم میشود. نام کلیدِ همین تنظیم بسته به نسخه فرق میکند: در نسخههای جدیدتر لاراول کانفیگ پیشفرض MAIL_SCHEME را میخواند (smtps برای پورت ۴۶۵ و خالیگذاشتنش برای ۵۸۷)، و در نسخههای قدیمیتر MAIL_ENCRYPTION با مقدار ssl یا tls. مرجعِ قطعی فایل config/mail.php پروژهی خودتان است — ببینید کدام متغیر محیطی را واقعاً میخواند و همان را در .env بگذارید؛ کلیدی که در کانفیگ نباشد بیسروصدا نادیده گرفته میشود.
بعد از هر تغییر، php artisan config:clear را فراموش نکنید.
آدرس فرستنده باید روی دامنهی خودتان باشد، نه جیمیل#
این تنها موردی بود که وقتی فهمیدیمش، حالمان بد شد. مقدار MAIL_FROM_ADDRESS در پروژه روی یک آدرس جیمیل تنظیم شده بود — احتمالاً از روزهای اول توسعه مانده بود.
ببینید این ترکیب از دید گیرنده چه شکلی است. سرور هاست شما وصل میشود و پیامی تحویل میدهد که در From نوشته یک آدرس جیمیل. گیرنده سه سؤال میپرسد و هر سه جواب بد میگیرد. آیا این سرور در فهرست SPF آن دامنه هست؟ نه، چون شما مالک آن دامنه نیستید و نمیتوانید رکوردش را عوض کنید. آیا پیام با کلید آن دامنه امضا شده؟ نه. آیا همترازی برقرار است؟ قطعاً نه.
یعنی شما با دست خودتان دقیقاً همان الگویی را میسازید که کل این سه لایه برای شناساییاش اختراع شدهاند: سروری که از طرف دامنهای که مالکش نیست ایمیل میفرستد. تمام رکوردهای DNSای که با زحمت روی دامنهی خودتان ساختهاید در این حالت اصلاً بررسی نمیشوند، چون گیرنده به دامنهی From نگاه میکند نه به دامنهی شما.
راهحل یک خط است: آدرس فرستنده را روی دامنهی خودتان بگذارید. اگر میخواهید پاسخها به صندوق شخصیتان بیاید، از Reply-To استفاده کنید — این فیلد هیچ نقشی در احراز هویت ندارد و آزاد است:
public function envelope(): Envelope
{
return new Envelope(
subject: 'فاکتور خرید شما',
replyTo: ['shop.support@gmail.com'],
);
}همینطور یک صندوق واقعی برای آدرس فرستنده بسازید. آدرس فرستندهای که وجود خارجی ندارد و پیامهای برگشتی را نمیپذیرد، خودش یک علامت منفی است.
ایمیل را داخل صف بفرستید تا کاربر منتظر SMTP نماند#
وقتی ارسال را مستقیم داخل چرخهی درخواست انجام میدهید، کاربر تا پایان گفتوگوی SMTP منتظر میماند. اگر سرور ایمیل کند باشد یا اتصال تایماوت بخورد، پرداختِ کاملاً موفق کاربر با خطای پانصد تمام میشود.
راه درست سپردن ارسال به صف است:
class InvoiceMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
}یا در محل فراخوانی از Mail::to($user)->queue(new InvoiceMail($order)) استفاده کنید.
ولی همان هشدار بخش دوم اینجا هم برقرار است: صف بدون کارگر یعنی هیچ ایمیلی. همان لحظه کرون کارگر را هم بسازید و با پر و خالی شدن جدول jobs مطمئن شوید کار میکند. بقیهی این تنظیمات ابتدایی را در چکلیست هاست اشتراکی برای لاراول جمع کردهایم.
عوامل دیگری که ایمیل را به اسپم میبرند#
فرض کنید هر سه لایه پاس میشوند و پیام هم واقعاً تحویل داده میشود. هنوز میشود در اسپم افتاد، چون فیلترها به شکل پیام هم نگاه میکنند.
| عامل | چرا اهمیت دارد |
|---|---|
| نبود نسخهی متنی | ایمیلِ فقط HTML الگوی رایج پیامهای تبلیغاتی است |
| لینک کوتاهشده | مقصد واقعی پنهان میماند و امتیاز منفی میگیرد |
| آیپی مشترکِ بدنام | همسایههای شما روی همان سرور اسپم فرستادهاند |
| محتوای تمامتصویر | متنی برای تحلیل نمیماند و مشکوک شمرده میشود |
| نبود لینک لغو اشتراک | برای ایمیلهای گروهی تقریباً حتمی است |
نسخهی متنی در لاراول یک خط کار دارد:
public function content(): Content
{
return new Content(
view: 'emails.invoice',
text: 'emails.invoice_plain',
);
}در ایمیل تراکنشی هیچ دلیلی برای کوتاهکردن آدرس وجود ندارد؛ آدرس کامل روی دامنهی خودتان بگذارید. اگر دامنهی لینک با دامنهی فرستنده یکی باشد امتیاز بهتری هم میگیرید.
اعتبار آیپی خروجی روی هاست اشتراکی دست شما نیست. اگر همه چیز درست است و باز هم ایمیلها مرتب در اسپم میافتند، این احتمال جدی است و راهحلش تنظیمات نیست؛ یا باید با پشتیبانی هاست صحبت کنید یا ارسال را به یک سرویس ارسال ایمیل اختصاصی بسپارید — که در آن حالت هم باید رکوردهای SPF و DKIM آن سرویس را روی دامنهی خودتان منتشر کنید تا همترازی حفظ شود.
و برای هر ایمیل گروهی — خبرنامه، اطلاعرسانی تخفیف، یادآوری سبد خرید — لینک لغو اشتراک بگذارید. نبودش شکایت کاربر را زیاد میکند و مستقیم روی اعتبار دامنه اثر میگذارد. فاکتور و بازیابی رمز این الزام را ندارند.
نتیجه را بسنجید: هدر Authentication-Results را بخوانید#
بعد از هر تغییر، حدس نزنید. یک ایمیل واقعی به یک صندوق واقعی بفرستید و هدرهای پیام دریافتی را باز کنید. در جیمیل از منوی خود پیام گزینهی نمایش متن اصلی را انتخاب کنید؛ در بیشتر کلاینتهای دیگر هم گزینهای با همین مضمون هست.
دنبال هدری به نام Authentication-Results بگردید. این هدر را سرور گیرنده اضافه میکند و نتیجهی هر سه بررسی را دقیقاً میگوید.
سه چیز را نگاه کنید: نتیجهی SPF باید pass باشد؛ نتیجهی DKIM باید pass باشد و دامنهی امضاکننده دامنهی خودتان؛ نتیجهی DMARC هم pass و دامنهی کنارش همان دامنهای که کاربر در From میبیند.
اگر DKIM نتیجهی none میدهد، پیام امضا نشده یا کلید عمومی در DNS پیدا نشده. اگر SPF نتیجهی fail میدهد، سروری که پیام را تحویل داده در فهرست شما نیست. و اگر DMARC شکست خورده ولی دو تای دیگر پاس شدهاند، تقریباً همیشه مسئله همترازی است.
یک بار دیگر یادآوری: بین ذخیرهی رکورد و دیدن اثرش فاصله هست. اگر هدر هنوز fail میگوید، پیش از تغییر دوباره چند ساعت صبر کنید و همان تست را تکرار کنید.
درس اصلی#
«ایمیل نمیرسد» یک تشخیص نیست، یک شکایت است. سه سیستم مستقل بین دکمهی ثبتنام کاربر و صندوق ورودی او ایستادهاند و هرکدام میتوانند بیسروصدا شکست بخورند: صف داخلی برنامه، احراز فرستنده در DNS، و قضاوت فیلتر دربارهی شکل پیام. تا وقتی نفهمیدهاید پیام در کدام مرحله گم میشود، هر تغییری شانسی است.
و اگر فقط یک چیز از این مقاله برمیدارید: آدرس فرستنده را روی دامنهی خودتان بگذارید. SPF و DKIM و DMARC وقتی معنا دارند که دامنهای که کاربر میبیند همان دامنهای باشد که شما مالکش هستید. بدون آن، بقیهی کارها فقط رکوردهاییاند که هیچکس نگاهشان نمیکند.