الأدلة التقنية

البريد التفاعلي Transactional Email: كيف تصل رسائل تطبيقك في وقتها

رابط استعادة كلمة المرور الذي لا يصل يعني مستخدماً لا يستطيع الدخول، لذلك نشرح ما هو البريد التفاعلي ولماذا تفصله عن النشرات بنطاق وسمعة مستقلين، وكيف ترسله من تطبيقك بطابور وإعادة محاولة، ومتى تختار Amazon SES أو منصة تستضيفها بنفسك مثل Postal.

البريد التفاعلي Transactional Email: كيف تصل رسائل تطبيقك في وقتها

يضغط المستخدم على «نسيت كلمة المرور» في موقعك، ثم ينتظر دقيقة ويحدث صندوق الوارد، ثم يفتح مجلد Spam، ثم يضغط على الزر مرة ثانية وثالثة، وفي النهاية يكتب لفريق الدعم أن الموقع لا يعمل، والسبب في أغلب الحالات ليس في الكود الذي يغير كلمة المرور، وإنما في الرسالة التي لم تصل، لأنها خرجت من نفس العنوان الذي أرسل أمس نشرة تسويقية إلى عشرين ألف مشترك، وبعض المشتركين ضغطوا على «هذه رسالة مزعجة»، فقررت Gmail أن رسائل هذا النطاق لا تستحق صندوق الوارد هذا الأسبوع. هذه الرسالة التي يرسلها تطبيقك تلقائياً لشخص واحد نتيجة فعل قام به، مثل رابط استعادة كلمة المرور أو رمز الدخول، تسمى البريد التفاعلي Transactional Email، ويسميها بعض المطورين الرسائل الآلية أو رسائل المعاملات، وهي أهم بريد يخرج من مؤسستك وأقلها حظاً من الاهتمام.

وفي دليل استضافة البريد الإلكتروني للمؤسسات رسمنا الصورة الكاملة لبريد المؤسسة من الصناديق إلى النشرات، أما هذا المقال فيركز على المسار الثالث الذي يخرج من تطبيقاتك، ويكتب للمطور ولمسؤول الموقع معاً.

وسوف نناقش في هذا المقال ما يلي:

  • ما هو البريد التفاعلي، وكيف يختلف عن النشرات وعن البريد اليومي للموظفين.
  • لماذا يجب أن يخرج من مسار مستقل بنطاق فرعي وسمعة خاصة به، وما الذي يحدث عندما لا تفصله.
  • ما الذي تحتاجه من نظام الإرسال: السرعة، والارتدادات والشكاوى عبر Webhooks، وقائمة الحظر، والقوالب، والسجلات، وجانب الخصوصية في تتبع الفتح.
  • الإرسال عبر SMTP أم عبر HTTP API.
  • جانب المطور: الطابور Queue وإعادة المحاولة، ومنع الإرسال المكرر، وما لا تكتبه في السجلات، مع مثال كامل بلغة Python ومخرجه الحقيقي.
  • الخيارات المتاحة: خدمات مدارة مثل Amazon SES، ومنصات مفتوحة المصدر تستضيفها بنفسك مثل Postal، وأيها يناسبك.

ما هو البريد التفاعلي؟

البريد التفاعلي هو رسالة يرسلها نظامك تلقائياً إلى شخص واحد بعينه، لأن هذا الشخص قام بفعل ينتظر نتيجته، أو لأن حدثاً وقع في حسابه يجب أن يعرف به، وبالتالي فالمستخدم هو الذي طلب الرسالة بفعله، وهو ينتظرها الآن. وأشهر أمثلته ما يلي:

  • استعادة كلمة المرور Password Reset: رابط لمرة واحدة ينتهي بعد دقائق.
  • تأكيد التسجيل Email Verification: رابط يثبت أن البريد يخص صاحب الحساب.
  • رمز التحقق لمرة واحدة One-Time Password، واختصاراً OTP: رقم من ست خانات يكتبه المستخدم خلال دقائق.
  • الفواتير والإيصالات Invoices and Receipts: تأكيد الدفع أو الطلب أو الحجز.
  • التنبيهات Alerts: دخول من جهاز جديد، تغيير البريد أو كلمة المرور، اقتراب انتهاء الاشتراك، أو تعليق جديد على تذكرة.

والفرق بينه وبين الأنواع الأخرى يشبه الفرق بين الإيصال الذي يعطيك إياه البنك بعد أن تسحب مبلغاً، والمنشور الإعلاني الذي يوزعه البنك على كل من يمر أمام الفرع، فالإيصال يخصك وحدك، وتنتظره، ووجوده ضروري لتكمل ما بدأت، أما المنشور فلن يتضرر أحد إذا وصل متأخراً أو لم يصل. والجدول التالي يضع الأنواع الثلاثة جنباً إلى جنب:

البريد اليومي للموظفينالنشرات والتسويق Newslettersالبريد التفاعلي Transactional
من يكتبه؟شخص يكتب لشخصفريق التسويق أو المحتوىالكود، نتيجة فعل المستخدم
إلى من؟مستلم أو عدد قليلقائمة مشتركين كاملةشخص واحد بعينه
شكل الحجمثابت طوال اليومدفعات كبيرة في أوقات الحملاترسائل متفرقة تتبع نشاط المستخدمين
إذا تأخر ساعة؟إزعاج بسيطلا مشكلة غالباًالمستخدم لا يستطيع الدخول أو الدفع
إلغاء الاشتراكلا يوجدإلزامي، وبنقرة واحدة عند Gmailلا يوجد، لأن المستخدم هو الذي طلبه
يخرج منخادم البريد mail.example.comlistmonk عبر خادم وسيطتطبيقك عبر خدمة إرسال مستقلة

لاحظ السطر الأخير في الجدول، فالرسالة التفاعلية لا يجوز أن تحمل محتوى تسويقياً، والسبب أنك عندما تضيف عرضاً ترويجياً أسفل رسالة الفاتورة فقد حولتها إلى رسالة تسويقية في نظر المستلم وفي نظر مزودي البريد، وأصبحت تحتاج رابط إلغاء اشتراك، وأصبح من حق المستلم أن يضغط على «رسالة مزعجة»، وكل ضغطة من هذه تحسب على سمعة المسار الذي يحمل رسائل استعادة كلمة المرور.

كيف يخرج البريد من مؤسستك في ثلاثة مسارات؟

قبل أن نتحدث عن الفصل وأسبابه، الشكل التالي يبين كيف تبدو المسارات الثلاثة عندما تكون مفصولة بشكل صحيح، ولكل مسار نطاقه الفرعي Subdomain وسمعته الخاصة به:

مخطط ثلاثة مسارات للبريد في مؤسسة: بريد الموظفين اليومي يخرج من خادم البريد على النطاق example.com، والنشرات تخرج من listmonk عبر خادم وسيط SMTP Relay على النطاق news.example.com، والبريد التفاعلي يخرج من التطبيق إلى طابور وعامل خلفي ثم إلى Postal أو Amazon SES على النطاق notify.example.com، والارتدادات والشكاوى تعود من سيرفرات المستلمين إلى Postal أو SES ثم إلى التطبيق عبر Webhook، وخط فاصل يبين أن ارتفاع الشكاوى على النشرات لا يمنع وصول رسائل استعادة كلمة المرور
كل مسار يخرج من نطاق فرعي خاص به، ومزودو البريد يحكمون على كل مسار بنطاقه وعنوانه

في المخطط أعلاه لاحظ التالي:

  • بريد الموظفين يبقى على خادم البريد الأساسي ونطاق المؤسسة الرئيسي Root Domain، فلا تخرج منه نشرات ولا رسائل تطبيقات.
  • النشرات تخرج من listmonk من النطاق news.example.com عبر خادم وسيط SMTP Relay، وهذا المسار هو الذي يتلقى الشكاوى عادة بعد كل حملة كبيرة.
  • البريد التفاعلي لا يخرج من داخل الطلب Request الذي يرسله المستخدم، وإنما يكتبه التطبيق في طابور Queue، ثم يرسله عامل خلفي Worker إلى Postal أو Amazon SES من النطاق notify.example.com.
  • الخط المتقطع البنفسجي هو الطريق العكسي، فالارتداد Bounce والشكوى Complaint يعودان من سيرفرات المستلمين إلى خدمة الإرسال، ثم يصلان إلى تطبيقك عبر Webhook حتى يتوقف عن الإرسال إلى العنوان الذي لا يعمل.

لماذا لا ترسل كل شيء من عنوان واحد؟

لنفرض أن لديك متجراً صغيراً، والطريقة الأسهل التي يبدأ بها أغلب الفرق هي إنشاء صندوق [email protected] على خادم البريد أو عند مزود البريد المستضاف، ثم وضع اسم المستخدم وكلمة المرور في إعدادات التطبيق، فترسل منه رسائل التسجيل واستعادة كلمة المرور، ثم يأتي فريق التسويق ويرسل منه النشرة الشهرية أيضاً لأن الإعداد جاهز. وهذا الحل يعمل في الأسابيع الأولى، ولكنه يفشل لثلاثة أسباب:

  1. السمعة Reputation تحسب للنطاق ولعنوان IP الذي يرسل، وليس لنوع الرسالة، فعندما يضغط عدد من المشتركين على «رسالة مزعجة» بعد حملة كبيرة، فإن Gmail لا تعرف أن الرسالة التالية من نفس النطاق رابط استعادة كلمة مرور ينتظره شخص الآن، وتعاملها كما عاملت النشرة. وتطلب Gmail في إرشادات المرسلين أن يبقى معدل الشكاوى أقل من 0.3%، وهذا رقم تصل إليه حملة واحدة سيئة التوقيت بسهولة.
  2. مزود الإرسال نفسه يحاسب الحساب كاملاً، فخدمة Amazon SES مثلاً تضع الحساب تحت المراجعة عندما ترتفع الارتدادات أو الشكاوى، وقد توقف قدرة الحساب على الإرسال إذا لم تعالج المشكلة، وعندها تتوقف رسائل استعادة كلمة المرور مع النشرة في نفس اللحظة.
  3. خادم البريد الأساسي وصندوق noreply لم يصمما لهذا العمل، فلا توجد فيهما Webhooks تخبر تطبيقك بالارتداد، ولا توجد قائمة حظر Suppression List تمنع الإرسال إلى عنوان ارتد عشر مرات، وحدود الإرسال في البريد المستضاف تكفي لموظف وليس لتطبيق فيه آلاف المستخدمين.

والحل الأنسب هو أن تعطي كل مسار هوية مستقلة، وأقل ذلك نطاق فرعي لكل مسار، مثل notify.example.com للبريد التفاعلي وnews.example.com للنشرات، مع حساب إرسال مستقل لكل منهما، ثم تزيد الفصل بحسب حجمك: مسار مستقل Stream داخل خدمة الإرسال كما في Message Streams في Postmark، أو مجموعات إعداد Configuration Sets في Amazon SES، أو مجموعة عناوين IP Pool مستقلة في Postal، وصولاً إلى عناوين IP مخصصة لكل مسار.

وكل نطاق فرعي يرسل يحتاج سجلات SPF وDKIM وDMARC خاصة به، ولن نشرحها هنا لأننا شرحناها بالتفصيل في شرح SPF وDKIM وDMARC للمبتدئين، وإذا كانت سجلات DNS نفسها جديدة عليك فابدأ بـ شرح DNS وسجلاته للمبتدئين.

وقد يتساءل البعض: هل أحتاج عنوان IP مخصصاً Dedicated IP لكل مسار من البداية؟ والإجابة لا، والسبب أن العنوان المخصص يحتاج حجماً منتظماً من الرسائل حتى يبني سمعة، وعنوان جديد يرسل مئة رسالة في اليوم لا يملك سمعة أصلاً، وقد يكون أسوأ من العناوين المشتركة Shared IPs عند مزود جيد يراقبها. لذلك ابدأ بالنطاقات الفرعية والحسابات أو المسارات المستقلة، وانتقل إلى العناوين المخصصة عندما يصبح حجمك عشرات الآلاف من الرسائل يومياً، وتجد تفاصيل ذلك في Amazon SES في صفحة العناوين المخصصة.

ما الذي تحتاجه من نظام البريد التفاعلي؟

سواءً اخترت خدمة مدارة أو منصة تستضيفها بنفسك، فهذه المعايير التي تحكم بها عليها، وهي نفسها ما سوف نستخدمه عند الحديث عن الأدوات:

  • السرعة Latency: رمز OTP الذي يصل بعد عشر دقائق انتهت صلاحيته قبل أن يصل، لذلك يجب أن تخرج الرسالة خلال ثوان، وأن لا تقف خلف حملة نشرات فيها خمسون ألف رسالة في نفس الطابور.
  • الوصول إلى صندوق الوارد Deliverability: توقيع DKIM على نطاقك الفرعي، وعنوان إرسال سمعته نظيفة، ومراقبة معدلات الارتداد والشكاوى، فالرسالة التي تصل إلى Spam لم تصل في نظر المستخدم.
  • الارتدادات والشكاوى عبر Webhooks: عندما يرتد بريد Bounce لأن العنوان غير موجود، أو يضغط المستلم على «رسالة مزعجة» Complaint، يجب أن يعرف تطبيقك بذلك آلياً، فيرسل لك النظام طلب HTTP إلى عنوان تحدده، كما في أحداث Postal مثل MessageBounced وMessageDeliveryFailed، أو عبر Amazon SNS في Amazon SES.
  • قائمة الحظر Suppression List: العنوان الذي ارتد ارتداداً دائماً Hard Bounce أو اشتكى لا ترسل إليه مرة أخرى، والسبب أن الإرسال المتكرر إلى عناوين ميتة هو أسرع طريق لخسارة السمعة، وأغلب الخدمات تدير هذه القائمة تلقائياً، مثل قائمة الحظر في Amazon SES، ولكن تطبيقك يحتاج أن يعرف بها أيضاً حتى يخبر المستخدم أن بريده لا يستقبل.
  • القوالب Templates: نص الرسالة بالعربية والإنجليزية، ونسخة HTML ونسخة نصية Plain Text، والمتغيرات مثل اسم المستخدم والرابط، في مكان واحد يراجعه الفريق، وليس نصوصاً متفرقة داخل الكود.
  • السجلات والتتبع Logs and Event Tracking: عندما يكتب المستخدم للدعم أن الرسالة لم تصل، يجب أن تجيب خلال دقيقة: هل خرجت، ومتى، وما الذي رد به سيرفر المستلم، فهذا هو الفرق بين «سوف نبحث» و«سيرفر شركتك رفض الرسالة الساعة 10:42 لأن صندوقك ممتلئ».

والصورة التالية تبين طريق الارتداد والشكوى من سيرفر المستلم حتى يصل إلى تطبيقك، وكيف تمنع قائمة الحظر الرسالة التالية إلى العنوان نفسه:

مخطط في خمس خطوات: التطبيق يرسل رسالة استعادة كلمة المرور عبر Postal أو Amazon SES إلى سيرفر المستلم، فيرد سيرفر المستلم بارتداد دائم Hard Bounce برمز 550 أو يضغط المستلم على رسالة مزعجة فتصل شكوى Complaint، فتضيف خدمة الإرسال العنوان إلى قائمة الحظر Suppression List وترسل Webhook إلى التطبيق، فيعلم التطبيق العنوان بأنه لا يستقبل ويطلب من المستخدم تحديثه، وعند محاولة الإرسال التالية إلى العنوان نفسه تتوقف الرسالة قبل أن تخرج
الارتداد والشكوى يعودان عبر Webhook، فيتوقف التطبيق وخدمة الإرسال عن مراسلة العنوان الذي لا يستقبل

ويبقى جانب الخصوصية Privacy في تتبع الفتح والنقر Open and Click Tracking، فتتبع الفتح يضيف صورة مخفية إلى الرسالة يطلبها برنامج البريد من سيرفرك عند الفتح، فتعرف متى فتح المستخدم رسالته ومن أي عنوان، وتتبع النقر يستبدل كل رابط في الرسالة برابط يمر بسيرفر الإرسال أولاً، كما يشرح توثيق Postal. وهذه البيانات قد تفيد في النشرات، ولكنها في البريد التفاعلي لا تضيف شيئاً تقريباً، وأرقامها غير دقيقة لأن كثيراً من برامج البريد تحجب الصور الخارجية، والأخطر أن تتبع النقر يمرر رابط استعادة كلمة المرور بما فيه من توكن Token عبر نطاق التتبع ويحفظه في سجلاته. لذلك عطل تتبع الفتح والنقر في مسار البريد التفاعلي، وإذا احتجت أن تعرف أن المستخدم استخدم الرابط فتطبيقك يعرف ذلك عندما يفتح الرابط.

⚠️
إذا كنت تستخدم listmonk للنشرات، فهو يوفر واجهة للرسائل التفاعلية أيضاً، ولا ينصح باستخدامها لرسائل استعادة كلمة المرور على نفس إعداد SMTP الذي يرسل الحملات، والسبب أنك تعيد بهذا جمع المسارين في سمعة واحدة. إذا استخدمتها فاجعل لها خادم SMTP مستقلاً ونطاقاً فرعياً مستقلاً.

الإرسال عبر SMTP أم عبر HTTP API؟

كل خدمات البريد التفاعلي تقريباً تقبل الرسائل بطريقتين: بروتوكول SMTP المعروف على المنفذ 587، أو واجهة HTTP API ترسل إليها طلب JSON فيه المستلم والموضوع والمحتوى. والجدول التالي يبين الفرق العملي بينهما:

SMTPHTTP API
التوافقيعمل مع أي برنامج وأي إطار عمل Framework، وأغلب التطبيقات الجاهزة مثل Gitea وGhost لا تعرف غيرهيحتاج مكتبة الخدمة أو كوداً يكتب الطلب
تغيير المزودتغير أربعة متغيرات: السيرفر والمنفذ والمستخدم وكلمة المرورتعيد كتابة جزء من الكود لأن لكل خدمة شكل طلبها
السرعةعدة رسائل ذهاباً وإياباً في كل اتصال: EHLO ثم STARTTLS ثم AUTH ثم الرسالةطلب HTTPS واحد
الخصائص الإضافيةتمر عبر ترويسات Headers خاصة بكل خدمةالقوالب والوسوم Tags والجدولة ومفتاح منع التكرار Idempotency Key في نفس الطلب
الأخطاءرموز SMTP مثل 535 و550رموز HTTP ورسالة JSON واضحة

والصورة التالية تبين الطريقتين: محادثة SMTP بعدة رسائل ذهاباً وإياباً، وطلب HTTP واحد يحمل الرسالة وخصائصها:

مخطط من جزأين: في جزء SMTP على المنفذ 587 يتبادل التطبيق مع خدمة الإرسال عدة رسائل بالترتيب EHLO ثم STARTTLS ثم AUTH ثم MAIL FROM وRCPT TO ثم DATA، ويعمل مع أي تطبيق جاهز مثل Gitea وGhost، وتغيير المزود يعني تغيير السيرفر والمنفذ والمستخدم وكلمة المرور، وفي جزء HTTP API على المنفذ 443 يرسل التطبيق طلب HTTPS واحداً فيه JSON بالمستلم والقالب والمتغيرات والوسوم ومفتاح منع التكرار، ويعود رد واحد برقم الرسالة، وتغيير المزود يعني إعادة كتابة كود الإرسال
SMTP محادثة يفهمها كل تطبيق، وHTTP API طلب واحد يحمل القوالب والوسوم ومفتاح منع التكرار

والقاعدة العملية أن SMTP هو الخيار الأنسب للتطبيقات الجاهزة التي لا تملك كودها، وللفرق التي تريد أن تبقى حرة في تغيير المزود، أما HTTP API فهو الأنسب عندما تكتب التطبيق بنفسك وتحتاج القوالب والوسوم والسرعة، وكل من Postal وAmazon SES يدعمان الطريقتين، وبالتالي فالقرار لا يربطك بالخدمة.

كيف ترسل البريد التفاعلي من تطبيقك؟

الطريقة التي يكتبها أغلب المطورين في البداية هي استدعاء دالة الإرسال داخل الطلب نفسه، فعندما يضغط المستخدم على «نسيت كلمة المرور» يولد الكود التوكن، ثم يفتح اتصال SMTP، ثم يرسل الرسالة، ثم يعيد الصفحة «تم إرسال الرابط». وهذا الكود يعمل على جهاز المطور، ولكنه يفشل في الإنتاج للأسباب التالية:

  • اتصال SMTP قد يأخذ ثانية أو ثانيتين في الحالة الطبيعية، وقد يأخذ ثلاثين ثانية حتى ينتهي وقته Timeout إذا كانت خدمة الإرسال بطيئة، والمستخدم ينتظر طوال هذا الوقت أمام صفحة لا تتحرك.
  • إذا فشل الإرسال لأن الخدمة رفضت الاتصال لحظياً، فالرسالة ضاعت، والمستخدم رأى خطأ، أو الأسوأ أنه رأى «تم الإرسال» لأن الكود ابتلع الخطأ.
  • المستخدم الذي لا يرى الرسالة يضغط على الزر مرة ثانية وثالثة، فيصله ثلاث رسائل بثلاثة روابط، اثنان منها لم تعد صالحة.

والطريقة الصحيحة هي أن يكتب الطلب «يجب إرسال هذه الرسالة» في طابور Queue ثم يعود للمستخدم فوراً، ويأتي عامل خلفي Background Worker يقرأ الطابور ويرسل، فإذا فشل الإرسال أعاد المحاولة Retry بعد فترة تتضاعف في كل مرة Exponential Backoff، وإذا فشل بعد عدد محدد من المحاولات سجل الفشل ليراه الفريق. والطابور قد يكون Redis أو RabbitMQ أو مكتبة مثل Celery وSidekiq وBullMQ، وقد يكون جدولاً في قاعدة البيانات نفسها يسمى صندوق الصادر Outbox، وهو ما سوف نستخدمه في المثال لأنه لا يحتاج أي خدمة إضافية.

والصورة التالية تبين الفرق بين الطريقتين:

مخطط من جزأين: في جزء الإرسال داخل الطلب ينتظر المستخدم اتصال SMTP من ثانية إلى ثلاثين ثانية حتى ينتهي الوقت، وإذا رفض الاتصال ضاعت الرسالة، ثم يضغط المستخدم على الزر مرة ثانية وثالثة فتصله ثلاث رسائل وثلاثة روابط اثنان منها لم تعد صالحة، وفي جزء الطابور والعامل الخلفي يضغط المستخدم فيكتب الطلب سطراً في جدول Outbox ويعود فوراً، ثم يقرأ العامل الخلفي Worker السطر ويرسله إلى Postal أو SES، وإذا فشل الإرسال أعاد المحاولة بعد ثانيتين ثم أربع ثوان، وبعد خمس محاولات فاشلة يسجل الفشل ليراه الفريق
الطلب يكتب الرسالة في الطابور ويعود فوراً، والعامل الخلفي يرسلها ويعيد المحاولة إذا فشل الاتصال

وإضافة إلى الطابور، هذه ثلاث قواعد لا تتنازل عنها:

  • منع التكرار Idempotency: اربط كل رسالة بمفتاح فريد يصف سببها، مثل password-reset:user-42:req-7f3a، فإذا كتب الطلب نفس الرسالة مرتين لأي سبب، أو أعاد المتصفح إرسال النموذج، تبقى رسالة واحدة في الطابور. وبعض الخدمات تقبل هذا المفتاح في الطلب نفسه، مثل مفتاح منع التكرار في Resend الذي يرفض الطلب المكرر خلال 24 ساعة.
  • لا تكتب الأسرار في السجلات: كلمة مرور SMTP ومفتاح API تقرأ من متغيرات البيئة Environment Variables وليس من الكود، ونص الرسالة الذي يحمل رابط الاستعادة أو رمز OTP لا يكتب في السجلات Logs أبداً، والسبب أن السجلات تنسخ إلى أنظمة مراقبة يقرؤها أشخاص كثيرون، ومن يقرأ التوكن يستطيع تغيير كلمة مرور المستخدم، كما يوصي دليل OWASP للسجلات. وحتى عنوان المستخدم يمكن إخفاء جزء منه في السجل.
  • الروابط لمرة واحدة ولمدة قصيرة: توكن استعادة كلمة المرور يولد عشوائياً بطول كاف، ويحفظ في قاعدة البيانات بعد تمريره بدالة هاش Hash وليس كما هو، ويعمل مرة واحدة فقط، وينتهي بعد مدة قصيرة مثل 30 دقيقة، كما يشرح دليل OWASP لاستعادة كلمة المرور، وبالتالي فالرسالة التي تصل متأخرة أو تقع في يد غير صاحبها لا تفتح الحساب.

والصورة التالية تبين كيف يمنع مفتاح منع التكرار الرسالة الثانية في الطابور، ثم عند خدمة الإرسال إذا أعاد العامل المحاولة:

مخطط في خمس خطوات: المستخدم يضغط على الزر مرتين فيصل طلبان بالمفتاح نفسه password-reset:user-42:req-7f3a، فيكتب الطلب الأول سطراً في جدول Outbox بحالة queued، ويرفض قيد UNIQUE السطر الثاني فيظهر duplicate ignored، ثم يرسل العامل الخلفي رسالة واحدة تصل إلى المستخدم، وفي الأسفل يعيد العامل المحاولة بالمفتاح نفسه في الترويسة Idempotency-Key فتقبل الخدمة الطلب الأول وترفض المكرر، كما تفعل Resend خلال 24 ساعة
الطلب المكرر يحمل المفتاح نفسه، فيبقى في الطابور سطر واحد وتصل رسالة واحدة

مثال كامل بلغة Python

المثال التالي يطبق ما سبق في ملف واحد بلغة Python دون أي مكتبة خارجية: جدول Outbox في SQLite يمنع التكرار بقيد UNIQUE على مفتاح الرسالة، ودالة enqueue يستدعيها الطلب فتكتب سطراً وتعود، وعامل خلفي يرسل عبر SMTP بالإعدادات التي يقرؤها من متغيرات البيئة، ويعيد المحاولة بفترات 2 ثم 4 ثم 8 ثوان، ولا يكتب في السجل إلا مفتاح الرسالة وجزءاً من عنوان المستلم. واحفظه باسم mailer.py:

import json, logging, os, smtplib, sqlite3, ssl, sys, time, uuid
from email.message import EmailMessage

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
log = logging.getLogger("mailer")

DB = os.environ.get("OUTBOX_DB", "outbox.db")
MAX_ATTEMPTS = 5

def db():
    conn = sqlite3.connect(DB)
    conn.execute("""CREATE TABLE IF NOT EXISTS outbox (
        id TEXT PRIMARY KEY,
        idempotency_key TEXT UNIQUE NOT NULL,
        recipient TEXT NOT NULL,
        template TEXT NOT NULL,
        payload TEXT NOT NULL,
        status TEXT NOT NULL DEFAULT 'pending',
        attempts INTEGER NOT NULL DEFAULT 0,
        next_attempt_at REAL NOT NULL DEFAULT 0)""")
    return conn

def enqueue(conn, key, recipient, template, payload):
    """Called from the web request: write one row and return at once."""
    cur = conn.execute(
        "INSERT OR IGNORE INTO outbox (id, idempotency_key, recipient, template, payload) VALUES (?, ?, ?, ?, ?)",
        (str(uuid.uuid4()), key, recipient, template, json.dumps(payload)))
    conn.commit()
    log.info("enqueue key=%s %s", key, "queued" if cur.rowcount else "duplicate ignored")

def render(template, payload):
    if template == "password-reset":
        return ("Reset your password",
                f"Use this link within 30 minutes: {payload['link']}\n"
                "If you did not ask for this, ignore this email.")
    raise ValueError(template)

def send(row):
    job_id, key, recipient, template, payload = row
    subject, body = render(template, json.loads(payload))
    msg = EmailMessage()
    msg["From"] = os.environ["MAIL_FROM"]
    msg["To"] = recipient
    msg["Subject"] = subject
    msg["Message-ID"] = f"<{job_id}@{os.environ['MAIL_FROM'].split('@')[1]}>"
    msg.set_content(body)
    with smtplib.SMTP(os.environ["SMTP_HOST"], int(os.environ["SMTP_PORT"]), timeout=10) as s:
        if os.environ.get("SMTP_STARTTLS", "true") == "true":
            s.starttls(context=ssl.create_default_context())
        s.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
        s.send_message(msg)

def mask(addr):
    user, domain = addr.split("@")
    return f"{user[0]}***@{domain}"

def work_once(conn):
    rows = conn.execute(
        "SELECT id, idempotency_key, recipient, template, payload FROM outbox "
        "WHERE status = 'pending' AND next_attempt_at <= ?", (time.time(),)).fetchall()
    for row in rows:
        job_id, key, recipient = row[0], row[1], row[2]
        try:
            send(row)
        except (OSError, smtplib.SMTPException) as e:
            attempts = conn.execute("SELECT attempts FROM outbox WHERE id = ?", (job_id,)).fetchone()[0] + 1
            if attempts >= MAX_ATTEMPTS:
                conn.execute("UPDATE outbox SET status = 'failed', attempts = ? WHERE id = ?", (attempts, job_id))
                log.error("job=%s to=%s failed for good: %s", key, mask(recipient), type(e).__name__)
            else:
                delay = 2 ** attempts
                conn.execute("UPDATE outbox SET attempts = ?, next_attempt_at = ? WHERE id = ?",
                             (attempts, time.time() + delay, job_id))
                log.warning("job=%s to=%s attempt %d failed (%s), retry in %ds",
                            key, mask(recipient), attempts, type(e).__name__, delay)
        else:
            conn.execute("UPDATE outbox SET status = 'sent' WHERE id = ?", (job_id,))
            log.info("job=%s to=%s sent", key, mask(recipient))
        conn.commit()

if __name__ == "__main__":
    conn = db()
    if sys.argv[1] == "enqueue":
        token = "kq3V9xTq0b1Lr8wE"  # in the real app: secrets.token_urlsafe(32), stored hashed
        enqueue(conn, "password-reset:user-42:req-7f3a", "[email protected]", "password-reset",
                {"link": f"https://app.example.com/reset?token={token}"})
    elif sys.argv[1] == "worker":
        while conn.execute("SELECT count(*) FROM outbox WHERE status = 'pending'").fetchone()[0]:
            work_once(conn)
            time.sleep(1)
        log.info("outbox: %s", dict(conn.execute("SELECT status, count(*) FROM outbox GROUP BY status").fetchall()))

ولتجربته دون أن ترسل بريداً حقيقياً، نشغل Mailpit، وهو سيرفر SMTP للاختبار يستقبل الرسائل ويعرضها في واجهة ويب ولا يرسلها لأحد، ونجعل منافذه على 127.0.0.1 فقط:

docker run -d --name lab-txmail-mailpit -p 127.0.0.1:41025:1025 -p 127.0.0.1:41080:8025 axllent/mailpit:v1.31.4 --smtp-auth-accept-any --smtp-auth-allow-insecure

بعد ذلك نضع إعدادات الإرسال في متغيرات البيئة، وفي الإنتاج تكون هذه القيم عنوان Postal أو Amazon SES على المنفذ 587 مع SMTP_STARTTLS=true، وكلمة المرور تأتي من ملف .env أو من مدير الأسرار Secrets Manager وليس من الكود:

export SMTP_HOST=127.0.0.1 SMTP_PORT=41025 SMTP_USER=app SMTP_PASSWORD=change-me SMTP_STARTTLS=false [email protected]

الآن سوف نطلب نفس الرسالة مرتين، كما يحدث عندما يضغط المستخدم على الزر مرتين، ثم نوقف Mailpit حتى نرى إعادة المحاولة، ونشغل العامل الخلفي ونعيد تشغيل Mailpit بعد أربع ثوان:

python mailer.py enqueue
python mailer.py enqueue
docker stop lab-txmail-mailpit
(sleep 4; docker start lab-txmail-mailpit) & python mailer.py worker

والمخرج سوف يكون كما يلي:

08:21:09 INFO enqueue key=password-reset:user-42:req-7f3a queued
08:21:09 INFO enqueue key=password-reset:user-42:req-7f3a duplicate ignored
08:21:12 WARNING job=password-reset:user-42:req-7f3a to=s***@example.com attempt 1 failed (ConnectionRefusedError), retry in 2s
08:21:17 INFO job=password-reset:user-42:req-7f3a to=s***@example.com sent
08:21:18 INFO outbox: {'sent': 1}

وللتأكد أن رسالة واحدة فقط وصلت، نسأل واجهة Mailpit البرمجية عن الرسائل التي استقبلها:

curl -s http://127.0.0.1:41080/api/v1/messages | python -c "import sys,json;d=json.load(sys.stdin);print('total',d['total']);[print(m['From']['Address'],m['To'][0]['Address'],m['Subject'],m['MessageID']) for m in d['messages']]"
total 1
[email protected] [email protected] Reset your password [email protected]

في المخرج أعلاه لاحظ التالي:

  • الطلب الثاني لم يضف رسالة ثانية، فقد رفض قيد UNIQUE على idempotency_key السطر المكرر، وكتب السجل duplicate ignored، ووصلت رسالة واحدة فقط total 1.
  • المحاولة الأولى فشلت بالخطأ ConnectionRefusedError لأن سيرفر SMTP كان متوقفاً، وهذا ما يحدث عندما تعيد تشغيل Postal أو يتعطل الاتصال لحظياً، فلم تضع الرسالة، وأعاد العامل المحاولة بعد ثانيتين ونجح، والمستخدم لم ينتظر أي شيء من هذا لأن الطلب انتهى عند enqueue.
  • السجل لا يحتوي على التوكن ولا على نص الرسالة ولا على كلمة مرور SMTP، وعنوان المستخدم مكتوب بالشكل s***@example.com، ومع ذلك يكفي المفتاح password-reset:user-42:req-7f3a لفريق الدعم حتى يعرف ما حدث للرسالة.
  • الحقل Message-ID مبني من رقم السطر في الطابور، فإذا وصلك ارتداد أو شكوى عبر Webhook عن هذه الرسالة تستطيع أن تربطه بالسطر وبالمستخدم مباشرة.

ويبقى حد يجب أن تعرفه، فإذا توقف العامل الخلفي بعد أن قبل سيرفر SMTP الرسالة وقبل أن يكتب sent في الجدول، فسوف يعيد إرسالها عند تشغيله مرة أخرى، وبالتالي فهذا التصميم يضمن أن الرسالة تخرج مرة واحدة على الأقل At-Least-Once وليس مرة واحدة بالضبط، وهذا مقبول في أغلب الحالات لأن رسالة مكررة نادراً أفضل من رسالة ضائعة، وإذا أردت أن تقلل التكرار أكثر فاستخدم خدمة تقبل مفتاح منع التكرار في الطلب نفسه. وبعد أن تنتهي من التجربة احذف سيرفر الاختبار:

docker rm -f lab-txmail-mailpit

أين ترسل: خدمة مدارة أم منصة تستضيفها بنفسك؟

بعد أن عرفنا ما نحتاجه من النظام، يبقى السؤال عن الطرف الذي يأخذ الرسالة من العامل الخلفي ويسلمها لسيرفر المستلم، وهنا أمامك طريقان: خدمة مدارة Managed Service تدفع لها لكل ألف رسالة وهي تتحمل السمعة والعناوين، أو منصة مفتوحة المصدر تثبتها على سيرفرك فتكون السمعة والعناوين مسؤوليتك. وهذه الأدلة التي يحيل إليها هذا القسم:

دليلتثبيت Postal لإرسال البريد التفاعلي من تطبيقاتكمنصة إرسال مفتوحة المصدر على سيرفرك تشبه Amazon SES، ترسل منها تطبيقاتك رسائل التسجيل واستعادة كلمة المرور عبر SMTP أو HTTP API. يشرح هذا الدليل تثبيت Postal بالأداة الرسمية، وسجلات DNS التي يطلبها، وWebhooks والنسخ الاحتياطي والتحديث.دليلإرسال بريد خادمك عبر Amazon SES مع mailcow وPostfix وStalwartإذا كان مزودك يحجب المنفذ 25 أو كانت رسائل سيرفرك الجديد تصل إلى Spam، فسوف نرسل البريد الصادر عبر Amazon SES ويبقى الاستقبال والصناديق على سيرفرك، مع DKIM وMAIL FROM وDMARC والارتدادات، والإعداد الدقيق لكل خادم بريد وأي تطبيق.دليلتثبيت listmonk لإدارة النشرات البريدية على خادمكإذا كانت نشرتك البريدية تخرج اليوم من Gmail بخانة BCC أو من خدمة يرتفع سعرها مع كل مشترك، فسوف نثبت في هذا الدليل listmonk مع PostgreSQL على سيرفرك، ونربطه بمزود SMTP تختاره أنت، ثم نرسل أول حملة ونعالج الرسائل المرتدة، وتبقى قائمة المشتركين لديك.

الخدمات المدارة

أشهرها Amazon SES وهو الأرخص غالباً لكل رسالة، وقد شرحنا إعداده خطوة بخطوة مع DKIM والارتدادات والشكاوى عبر Amazon SNS في دليله، ومعه خدمات أخرى معروفة مثل Postmark وMailgun وSendGrid وResend وBrevo، وكلها تقدم SMTP وHTTP API وWebhooks وقائمة حظر، والفرق بينها في السعر وشكل الواجهة البرمجية ومكان مراكز البيانات وطريقة فصل المسارات. والخدمة المدارة هي الأنسب لأغلب الفرق الصغيرة، والسبب أن بناء سمعة عنوان IP جديد يحتاج وقتاً وحجماً منتظماً، بينما يبدأ المزود بعناوين لها سمعة ويراقبها فريق متخصص.

وقد يتساءل البعض: إذا كانت الخدمة المدارة أسهل فلماذا يستضيف أحد هذا بنفسه؟ والإجابة أن هناك أسباباً حقيقية، منها أن تبقى بيانات المستخدمين ونصوص الرسائل وسجلاتها داخل سيرفراتك لأسباب تنظيمية، أو أن يكون الحجم كبيراً جداً فتصبح التكلفة بنداً مهماً، أو أن تحتاج تحكماً كاملاً في العناوين والطوابير، أو أن تخدم عدة تطبيقات وعملاء من منصة واحدة.

المنصات مفتوحة المصدر التي تستضيفها بنفسك

عندما تستضيف منصة الإرسال بنفسك فأنت تتحمل كل ما يتحمله مزود البريد: المنفذ 25 الصادر مفتوحاً عند مزود السيرفر، وسجل PTR، وعنوان IP غير موجود في قوائم الحظر Blocklists، ومراقبة السمعة كل أسبوع، وقد شرحنا هذه المتطلبات العامة في دليل استضافة البريد للمؤسسات. وفيما يلي الأدوات التي راجعناها في مستودعاتها ومواقعها الرسمية في أكتوبر 2026، مع ما تقدمه فعلاً وما لا تقدمه.

Postal لمن يريد منصة إرسال كاملة على سيرفره

Postal منصة إرسال بريد كاملة تصف نفسها بأنها بديل مفتوح المصدر لخدمات مثل SendGrid وMailgun وPostmark، مكتوبة بلغة Ruby ومرخصة بترخيص MIT، ومستودعها على GitHub منذ 2017، وصدر الإصدار 3 في مارس 2024، وآخر إصدار عند كتابة هذا المقال هو 3.3.7 في يونيو 2026. وهي ترسل البريد بنفسها مباشرة إلى سيرفرات المستلمين، وتقبل الرسائل عبر SMTP وعبر HTTP API، وفيها سيرفرات بريد افتراضية لكل تطبيق أو عميل، ومجموعات عناوين IP Pools، وWebhooks للأحداث، وقائمة حظر مدمجة، وسجل كامل لكل رسالة، ووضع تطوير يحجز الرسائل دون أن يرسلها، كما في قائمة خصائصها.

نقاط القوة: أقدم أداة في هذه القائمة وأكثرها استخداماً، وواجهة ويب تعرض كل رسالة وما رد به سيرفر المستلم، وفصل المسارات فيها بالسيرفرات الافتراضية وIP Pools، ويمكن أن ترسل عبر خادم وسيط عبر الإعداد postal.smtp_relays إذا كان مزودك يحجب المنفذ 25.

نقاط الضعف: يوصي التوثيق بسيرفر مخصص لها بذاكرة 4GB ومعالجين على الأقل مع MariaDB، وتتبع الفتح والنقر يحتاج أن تعطله بنفسك لمسار البريد التفاعلي، ولا تجد في قائمة خصائصها قوالب للرسائل، فتبقى القوالب في تطبيقك.

📘
إذا قررت أن Postal هو الأنسب لك، فقد شرحنا تثبيته وإعداده خطوة بخطوة في دليل تثبيت Postal.

Hyvor Relay لمن يريد طوابير منفصلة ومراقبة مدمجة

Hyvor Relay واجهة برمجية للبريد تستضيفها بنفسك، وتصف نفسها بأنها بديل لـ Amazon SES وMailgun وSendGrid، والواجهة الخلفية فيها مكتوبة بـ PHP وSymfony والعمال الذين يرسلون البريد بلغة Go، وتعتمد على PostgreSQL فقط قاعدة بيانات وطابوراً، ومرخصة بترخيص AGPL-3.0، وآخر إصدار عند كتابة هذا المقال هو 0.0.46 في أغسطس 2026. وفيها طابوران منفصلان للبريد التفاعلي والبريد الجماعي حتى تنفصل سمعة عناوينهما، وإدارة للارتدادات والشكاوى وقائمة الحظر، وWebhooks، وسجل لمحادثة SMTP مع سيرفر المستلم لمدة 30 يوماً، كما في مستودعها.

نقاط القوة: صممت من البداية للفكرة التي يشرحها هذا المقال، أي فصل التفاعلي عن الجماعي، ومعها خادم DNS مدمج يولد سجلات DKIM وSPF، ومقاييس Prometheus ولوحات Grafana للمراقبة.

نقاط الضعف: ما زالت قبل الإصدار 1.0، وتحتاج سيرفراً بعنوان IPv4 عام أو أكثر تعمل على شبكة المضيف Host Network مباشرة، وتطلب أن تفوض إليها إدارة DNS لنطاق فرعي خاص بها Instance Domain عبر سجلات NS، وهذه خطوة لا تناسب كل مؤسسة.

Plunk لمن يريد لوحة واحدة والإرسال يبقى عند SES

Plunk منصة تجمع البريد التفاعلي والحملات والأتمتة Workflows وإدارة جهات الاتصال في واجهة واحدة، مكتوبة بـ TypeScript ومرخصة بترخيص AGPL-3.0، وآخر إصدار هو v0.15.0 في سبتمبر 2026. ولاحظ أن النسخة التي تستضيفها بنفسك تحتاج حساب Amazon SES لإرسال البريد، وبالتالي فأنت تستضيف الواجهة والبيانات والقوالب، ويبقى الإرسال الفعلي وسمعة العناوين عند SES.

نقاط القوة: لوحة واحدة للقوالب والسجلات والإحصاءات، وتقبل SMTP وAPI، وتعفيك من إدارة المنفذ 25 وسمعة العناوين.

نقاط الضعف: تخلط التفاعلي مع الحملات في منصة واحدة، فيجب أن تفصلهما بنطاقات فرعية بنفسك، ولا تعمل دون حساب AWS.

أدوات أخرى تسمع عنها

  • useSend: بديل مفتوح المصدر لـ Resend بترخيص AGPL-3.0، ويقول مستودعه صراحة إنه يرسل عبر Amazon SES، وما زال يصف نفسه بأنه في مرحلة تجريبية Beta.
  • BillionMail: خادم بريد مع منصة نشرات وتسويق بترخيص AGPL-3.0، ويركز على الحملات أكثر من البريد التفاعلي، وآخر إصدار له v4.9 في ديسمبر 2025، ولذلك لا ننصح به لمسار البريد التفاعلي.
  • Notifuse: منصة نشرات وبريد تفاعلي ترسل عبر مزودين خارجيين مثل SES وPostmark وMailgun أو SMTP، ولاحظ أن ترخيصها تغير من الإصدار 40 إلى Business Source License، فلم تعد مفتوحة المصدر بالمعنى المعروف، والإصدارات حتى 39 بقيت بترخيص AGPL.

الأدوات في جدول واحد

الأداةالترخيصمن يرسل فعلياً؟آخر إصدار (أكتوبر 2026)مناسبة لـ
PostalMITPostal نفسه من عناوينك، أو عبر خادم وسيط3.3.7، يونيو 2026منصة إرسال كاملة على سيرفرك لعدة تطبيقات
Hyvor RelayAGPL-3.0عمال Go من عناوينك0.0.46، أغسطس 2026فريق مستعد لأداة حديثة قبل 1.0 ويريد طوابير منفصلة
PlunkAGPL-3.0Amazon SESv0.15.0، سبتمبر 2026لوحة واحدة للقوالب والحملات فوق SES
useSendAGPL-3.0Amazon SESv1.9.8، أغسطس 2026واجهة شبيهة بـ Resend فوق SES
BillionMailAGPL-3.0خادم البريد المدمجv4.9، ديسمبر 2025الحملات والنشرات، وليس البريد التفاعلي
Amazon SESخدمة مدارةAWSغير منطبقأغلب الفرق، وبأقل تكلفة لكل رسالة

أيها تختار؟

إذا كان تطبيقك يرسل بضعة آلاف من الرسائل في الشهر، ولا يوجد في فريقك من يراقب سمعة عنوان IP كل أسبوع، فالحل الأنسب هو خدمة مدارة مثل Amazon SES على النطاق notify.example.com بحساب أو مجموعة إعداد مستقلة عن النشرات، مع طابور وإعادة محاولة في تطبيقك كما في المثال أعلاه، وهذا الإعداد يكفي أغلب المواقع لسنوات.

وإذا كان لديك سبب واضح لتستضيف الإرسال بنفسك، سيادة على البيانات أو حجم كبير أو عدة تطبيقات وعملاء، ومعك سيرفر مزوده يفتح المنفذ 25 ويسمح بضبط PTR، فابدأ بـ Postal لأنه الأكثر نضجاً واستخداماً بين هذه الأدوات، وراقب Hyvor Relay حتى يصل إلى الإصدار 1.0 إذا كانت فكرة الطوابير المنفصلة والمراقبة المدمجة تهمك. وإذا كان ما تريده لوحة للقوالب والسجلات فقط، والإرسال الفعلي يبقى عند SES، فأدوات مثل Plunk تعطيك ذلك، ولكن لا تخلط فيها الحملات مع رسائل استعادة كلمة المرور على نطاق واحد.

وفي كل الحالات تبقى القواعد نفسها: نطاق فرعي مستقل للبريد التفاعلي، والنشرات من listmonk على نطاقها الخاص، وبريد الموظفين على خادم البريد الأساسي، ولا يلتقي مساران منها في سمعة واحدة.

الخلاصة

  • البريد التفاعلي رسالة يرسلها نظامك لشخص واحد لأنه قام بفعل ينتظر نتيجته، مثل استعادة كلمة المرور ورمز OTP والفاتورة، وتأخرها يعني أن المستخدم لا يستطيع إكمال ما بدأ.
  • افصله عن النشرات وعن بريد الموظفين بنطاق فرعي مستقل مثل notify.example.com وحساب أو مسار إرسال مستقل، والسبب أن السمعة تحسب للنطاق والعنوان، وحملة واحدة كثيرة الشكاوى قد توقف رسائل استعادة كلمة المرور.
  • اطلب من نظام الإرسال: السرعة، وWebhooks للارتدادات والشكاوى، وقائمة حظر، وسجلاً لكل رسالة، وعطل تتبع الفتح والنقر في هذا المسار لأنه يمرر روابط الاستعادة عبر سيرفر التتبع.
  • لا ترسل من داخل الطلب، وإنما من طابور وعامل خلفي يعيد المحاولة، مع مفتاح يمنع التكرار، ودون أسرار أو توكنات في السجلات، وبروابط لمرة واحدة تنتهي بعد دقائق.
  • الخدمة المدارة مثل Amazon SES هي الأنسب لأغلب الفرق، وإذا كان لديك سبب لاستضافة الإرسال بنفسك فابدأ بـ Postal، وتابع Hyvor Relay حتى يصل إلى الإصدار 1.0.

سجل التحديثات (Changelog)

  • أكتوبر 2026: كتابة المقال، وتجربة مثال Python 3.13 على Mailpit v1.31.4، ومراجعة Postal 3.3.7 وHyvor Relay 0.0.46 وPlunk v0.15.0 وuseSend v1.9.8 وBillionMail v4.9 وترخيص Notifuse في مستودعاتها وتوثيقها الرسمي.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.