التعامل مع ارتداد الايميلات والشكاوى من أمازون SES باستخدام SNS و Laravel

نشر في 6 أبريل 2022

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

لكن أولا ما هو ارتداد رسائل البريد الإلكتروني؟ ما هي الشكاوى؟ وما هي سمعة الإرسال؟ سأحاول هنا التوضيح قدر استطاعتي.

ارتداد رسائل البريد الإلكتروني

هو ما يحدث عندما لا يمكن إيصال رسالة قمت بإرسالها إلى عنوان بريد إلكتروني معين ما يعرف ب (Email Bounce) ويوجد نوعين منه:

  1. ارتداد نهائي (Hard Email Bounce) هو سبب نهائي يمنع رسائلك من الوصول إلى عنوان بريد معين كأن يكون هذه العنوان غير موجود من الأساس أو مزود خدمة البريد الإلكتروني يمنع بشكل تام إيصال الرسائل إلى هذا العنوان.
  2. ارتداد مؤقت (Soft Email Bounce) هو سبب مؤقت يمنع وصول رسائلك إلى عنوان معين مثل أن يكون صندوق البريد الخاص به ممتلئ أو أنه غير نشط حاليا لاستقبال الرسائل أو حجم رسالة البريد التي أرسلتها كبير للغاية وغيرها من الأسباب التي تمنع وصول رسائلك مؤقتًا.

الشكاوى

ما يعرف ب (Complaint) ببساطة هي عندما يقوم صاحب عنوان البريد الذي ترسل إليه بتصنيف رسائلك على أنها بريد غير هام أو احتيال (Spam)

سمعة الإرسال

تحدد مدى ثقة خدمة الإرسال ومزودي خدمة البريد الإلكتروني في رسائلك. إذا كنت ترسل الكثير من الرسائل إلى عناوين بريد وهمية -ما يتسبب في Hard Bounce- أو يتم تصنيف الكثير من رسائلك على أنها بريد غير هام أو احتيالي فهذا يضر بسمعة الإرسال الخاصة بك وقد يتم تصنيف رسائلك ك Spam بشكل تلقائي أو الأسوأ أن يتم حظرك من مزود خدمة بريد ولا يتلقى رسائلك على الإطلاق.

لنبدأ المقال

سنقوم هنا بإنشاء نظام يعمل على ٣ مراحل كالتالي

  1. يقوم SES بإشعار خدمة SNS عند حدوث شكوى أو ارتداد للايميل
  2. تقوم خدمة SNS بإرسال إشعار إلى رابط خطاف (Web Hook) لتطبيقنا
  3. نقوم بتلقي الإشعار في التطبيق الخاص بنا -والذي هنا يستخدم Laravel- ونقوم بالتعامل معه على حسب نوع الاشعار

سأفترض أنك قمت بإعداد حسابك في AWS بالفعل وقمت بإعداد تطبيقك لاستخدام خدمة SES من أمازون لإرسال رسائل البريد كما هو موضح هنا في توثيق لارافيل

سنبدأ بإضافة Controller للتعامل مع الإشعار عند وصوله بإستخدام الأمر التالي

$ php artisan make:controller SnsNotificationsController -i

ومن ثم إضافة endpoint إلى ملف routes/web.php

Route::post('/aws/sns-notifications', \App\Http\Controllers\SnsNotificationsController::class);

كذلك إضافة الـ endpoint إلى قائمة التجاهل في ملف app/Http/Middleware/VerifyCsrfToken.php حتى يمكننا تلقي الإشعارات "الطلبات" من خدمة SNS

protected $except = [
  'aws/sns-notifications'
];

سنحتاج أيضا لتنزيل هذه الحزمة والتي نستخدمها للتحقق من الاشعارات التي تصلنا من SNS باستخدام الأمر التالي

$ composer require aws/aws-php-sns-message-validator

الآن نبدأ بكتابة الدالة الأساسية التي تقوم بتلقي الاشعار من SNS والتعامل معه حسب نوعه

public function __invoke()
{
  $snsMessage = Message::fromRawPostData();
  $validator = new MessageValidator();

  // نبدأ بالتحقق من الرسالة التي وصلتنا مع الإشعار
  try {
    $validator->validate($snsMessage);
  } catch (InvalidSnsMessageException $e) {
    // نرد بخطأ 404 إذا كانت الرسالة غير صالحة
    Log::error('SNS Message Validation Error: ' . $e->getMessage());
    abort(404);
  }

  // نتعامل مع الإشعار حسب نوعه
  switch ($snsMessage['Type']) {
    // إشعار تأكيد الاشتراك
    case 'SubscriptionConfirmation':
      $this->confirmSubscribtion($snsMessage['SubscribeURL']);
      break;

    case 'Notification':
      $messageBody = json_decode($snsMessage['Message']);

      // إشعار ارتدادا الإيميل
      if (
        $messageBody->notificationType === 'Bounce' &&
        $messageBody->bounce->bounceType === 'Permanent'
      ) {
        $this->handleHardBounce($messageBody);
      }

      // إشعار شكوى
      if ($messageBody->notificationType === 'Complaint')
        $this->handleComplaint($messageBody);

      break;

    default:
      abort(404);
      break;
  }
}

ومن ثم سنقوم بكتابة دوال للتعامل مع كل نوع من الإشعارات

  1. إشعار تأكيد الاشتراك وهو أول اشعار يقوم SNS بإرساله عند قيامك بإضافة رابط خطاف (Webhook) للتحقق من صلاحية هذه الرابط٬ ويكون عبارة عن رسالة تحوي رابط سنقوم بفتحة لتأكيد الاشتراك لتلقي الاشعارات.
protected function confirmSubscribtion($subscribeURL)
{
  $response = Http::get($subscribeURL);
  if ($response->failed()) {
    $response->throw();
    abort(404);
  }

  return response('Subscription Confirmed.');
}
  1. إشعار حدوث ارتداد لرسالة بريد إلكتروني
protected function handleHardBounce($messageBody)
{
  $bouncedEmailAddress = $messageBody->bounce->bouncedRecipients[0]->emailAddress;

  // نقوم بإرسال حدث "event" للتعامل مع البريد لبذي سبب الارتداد
  EmailHardBounced::dispatch($bouncedEmailAddress);

  return response('Hard Bounce Handled.');
}
***

نتوقف هنا قليلا

اتبعت أسلوبًا للتعامل مع الإشعارت حيث يتم فصل العملية لجزئين

  1. استقبال الإشعار واستخراج المعلومات اللازمة ثم يتم هنا إثارة حدث (Dispatch Event) يحوي هذه المعلومات
  2. يتولى مستمع (Listener) الاستماع لهذا الحدث (Event) واتخاذ الإجرائات الفعلية التي يجب إتخاذها

لماذا؟ الإشعار مرتبط بخدمة AWS SNS بينما الإجراء الذي يتم إتخاذه مرتبط بتطبيقنا لذا إذا قررنا الانتقال أو استخدام أي خدمة أخرى فسنستقبل إشعار مختلف لكن سيظل الإجراء الذي نتخذه ثابت وموحد ولن نضطر عندها لتعديل كل الكود يكفي عندها أن نقوم بإنشاْ Controller جديد لاستقبال الإشعارات ثم يقوم التطبيق بالتعامل مع الخدمة الجديدة بنفس الكيفية

هكذا نقوم بإنشاء حدث (Event) لإعلام التطبيق بالارتداد

$ php artisan make:event EmailHardBounced
<?php

namespace App\Events;

use Illuminate\Foundation\Events\Dispatchable;

class EmailHardBounced
{
  use Dispatchable;

  public $bouncedEmailAddress;

  /**
   * Create a new event instance.
   *
   * @param string $bouncedEmailAddress
   * @return void
   */
  public function __construct($bouncedEmailAddress)
  {
    $this->bouncedEmailAddress = $bouncedEmailAddress;
  }
}

ثم نقوم بإنشاء (Listener) للاستماع للحدث ومن ثم اتخاذ الإجراء المناسب والإجراء هنا إضافة البريد الإلكتروني إلى قائمة العناوين المعطلة

$ php artisan make:listener SuppressSubscriber
<?php

namespace App\Listeners;

use App\Events\EmailHardBounced;
use App\Models\Subscriber;

class SuppressSubscriber
{
  /**
   * Handle the event.
   *
   * @param EmailBounced $event
   * @return void
   */
  public function handle(EmailHardBounced $event)
  {
    Subscriber::where('email', $event->bouncedEmailAddress)->get()->map(function ($subscriber) {
      $subscriber->suppress();
    });
  }
}

ولا ننسى إضافة الحدث والمستمع لملف app/Providers/EventServiceProvider.php كالتالي

protected $listen = [
  \App\Events\EmailHardBounced::class => [
    \App\Listeners\SuppressSubscriber::class,
  ],
];
***
  1. إشعار شكوى أي أن أحد رسائلك تم تصنيفها كـ Spam
protected function handleComplaint($messageBody)
{
  $complainedEmailAddress = $messageBody->complaint->complainedRecipients[0]->emailAddress;

  // نقوم في هدهد بإضافة custom header للايميل للتعرف على النشرة وهنا نقوم بإستخراجه
  $newlsetterId = Arr::first($messageBody->mail->headers, function ($header) {
    return $header->name === 'X-Hodhod-Newsletter';
  })->value;

  EmailGotComplaint::dispatch($complainedEmailAddress, $newlsetterId);

  return response('Complaint Handled.');
}

وباتباع نفس الطريقة التي ذكرناها قبل قليل (Event/Listener) يمكننا اتخاذ إجراء مختلف للتعامل مع الشكاوى.

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

الآن يمكننا تجميع الكود ليكون ملف app/Http/Controllers/SnsNotificationsController.php لدينا بالكامل كالتالي:

<?php

namespace App\Http\Controllers;

use Aws\Sns\Message;
use Illuminate\Support\Arr;
use Aws\Sns\MessageValidator;
use App\Events\EmailHardBounced;
use App\Events\EmailGotComplaint;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Http;
use Aws\Sns\Exception\InvalidSnsMessageException;

class SnsNotificationsController extends Controller
{
  /**
   * Handle the incoming request.
   *
   * @return \Illuminate\Http\Response
   */
  public function __invoke()
  {
    $snsMessage = Message::fromRawPostData();
    $validator = new MessageValidator();

    // نبدأ بالتحقق من الرسالة التي وصلتنا مع الإشعار
    try {
      $validator->validate($snsMessage);
    } catch (InvalidSnsMessageException $e) {
      // نرد بخطأ 404 إذا كانت الرسالة غير صالحة
      Log::error('SNS Message Validation Error: ' . $e->getMessage());
      abort(404);
    }

    // نتعامل مع الإشعار حسب نوعه
    switch ($snsMessage['Type']) {
      // إشعار تأكيد الاشتراك
      case 'SubscriptionConfirmation':
        $this->confirmSubscribtion($snsMessage['SubscribeURL']);
        break;

      case 'Notification':
        $messageBody = json_decode($snsMessage['Message']);

        // إشعار ارتدادا الإيميل
        if (
          $messageBody->notificationType === 'Bounce' &&
          $messageBody->bounce->bounceType === 'Permanent'
        ) {
          $this->handleHardBounce($messageBody);
        }

        // إشعار شكوى
        if ($messageBody->notificationType === 'Complaint')
          $this->handleComplaint($messageBody);

        break;

      default:
        abort(404);
        break;
    }
  }


  /**
   * Confirm subscribtion to sns topic
   *
   * @param string $subscribeURL
   * @return \Illuminate\Http\Response
   */
  protected function confirmSubscribtion($subscribeURL)
  {
    $response = Http::get($subscribeURL);
    if ($response->failed()) {
      $response->throw();
      abort(404);
    }

    return response('Subscription Confirmed.');
  }


  /**
   * Handle hard bounced emails
   *
   * @param object $messageBody
   * @return \Illuminate\Http\Response
   */
  protected function handleHardBounce($messageBody)
  {
    $bouncedEmailAddress = $messageBody->bounce->bouncedRecipients[0]->emailAddress;

    EmailHardBounced::dispatch($bouncedEmailAddress);

    return response('Hard Bounce Handled.');
  }


  /**
   * Handle emails that got complaint.
   *
   * @param object $messageBody
   * @return \Illuminate\Http\Response
   */
  protected function handleComplaint($messageBody)
  {
    $complainedEmailAddress = $messageBody->complaint->complainedRecipients[0]->emailAddress;
    $newlsetterID = Arr::first($messageBody->mail->headers, function ($header) {
      return $header->name === 'X-Hodhod-Newsletter';
    })->value;

    EmailGotComplaint::dispatch($complainedEmailAddress, $newlsetterID);

    return response('Complaint Handled.');
  }
}

إعدادات الحساب في AWS

بقي لنا الآن إعداد خدمات SES و SNS لإرسال الاشعارات إلى تطبيقنا

لنبدأ بإعداد SES

انتقل إلى خدمة SES في حسابك ومن القائمة الجانبية "Verified Identitites" بعد إضافتك الهوية التي سترسل عبرها الرسائل سواء عنوان النطاق أو البريد الإلكتروني في حالتنا هنا gohodhod.com

صورة قائمة الهويات من خدمة AWS SES

نقوم بالضغط عليه ومن ثم الانتقال لتبويب "Notifications" ثم الضغط على Edit للتعديل

من هنا يمكننا اختيار توجيه الاشعارات الخاصة بعمليات الارتداد والشكاوى إلى خدمة الاشعارات من أمازون (SNS)٬ كما هو واضح سنبدأ بالضغط على "Create SNS topic"

صورة Create SNS topic من خدمة AWS SES

وبعد إضافة ال topic سنقوم بإختيار توجيه كل الاشعارات إلى كما هو موضح في الصورة التالية

صورة إعدادات الإشعارات من خدمة AWS SES