Отправка электронных писем асинхронно через AWS SES

Опубликовано: 2022-03-10
Краткий обзор ↬ Одновременная отправка большого количества транзакционных электронных писем, если она не спроектирована должным образом, может стать узким местом для приложения и ухудшить работу пользователя. Часть проблемы заключается в синхронном подключении к SMTP-серверу из приложения. В этой статье мы рассмотрим, как асинхронно отправлять электронные письма извне приложения, используя комбинацию AWS S3, Lambda и SES.

Большинство приложений отправляют электронные письма для связи со своими пользователями. Транзакционные электронные письма инициируются взаимодействием пользователя с приложением, например, приветствием нового пользователя после регистрации на сайте, предоставлением пользователю ссылки для сброса пароля или прикреплением счета-фактуры после того, как пользователь совершит покупку. Во всех этих предыдущих случаях обычно требуется отправить пользователю только одно электронное письмо. Однако в некоторых других случаях приложению необходимо отправить намного больше электронных писем, например, когда пользователь публикует новый контент на сайте, и все его подписчики (которые на такой платформе, как Twitter, могут составлять миллионы пользователей) получат уведомление. В этой последней ситуации, при неправильной архитектуре, отправка электронной почты может стать узким местом в приложении.

Именно это произошло в моем случае. У меня есть сайт, которому может потребоваться отправить 20 электронных писем после некоторых действий, инициированных пользователем (например, уведомления пользователя всем ее подписчикам). Первоначально он полагался на отправку электронных писем через популярного облачного SMTP-провайдера (например, SendGrid, Mandrill, Mailjet и Mailgun), однако ответ пользователю занимал секунды. Очевидно, подключение к SMTP-серверу для отправки этих 20 писем значительно замедляло процесс.

После осмотра выяснил источники проблемы:

  1. Синхронное соединение
    Приложение подключается к SMTP-серверу и синхронно ожидает подтверждения, прежде чем продолжить выполнение процесса.
  2. Высокая задержка
    Хотя мой сервер находится в Сингапуре, серверы провайдера SMTP, которым я пользовался, расположены в США, из-за чего соединение туда и обратно занимает значительное время.
  3. Нет возможности повторного использования SMTP-соединения
    При вызове функции для отправки письма функция отправляет письмо немедленно, создавая в этот момент новое SMTP-соединение (она не предлагает собрать все письма и отправить их все вместе в конце запроса, по одному SMTP-серверу). связь).

Из-за # 1 время, которое пользователь должен ждать ответа, привязано ко времени, которое требуется для отправки электронных писем. Из-за № 2 время отправки одного электронного письма относительно велико. А из-за № 3 время отправки 20 электронных писем в 20 раз превышает время, необходимое для отправки одного электронного письма. Хотя отправка только одного электронного письма может не сильно замедлить работу приложения, отправка 20 электронных писем, безусловно, влияет на работу пользователя.

Давайте посмотрим, как мы можем решить эту проблему.

Еще после прыжка! Продолжить чтение ниже ↓

Обращаем внимание на природу транзакционных электронных писем

Прежде всего, мы должны заметить, что не все электронные письма одинаково важны. Мы можем разделить электронные письма на две группы: приоритетные и неприоритетные электронные письма. Например, если пользователь забыл пароль для доступа к учетной записи, он будет ожидать электронное письмо со ссылкой для сброса пароля сразу же в своем почтовом ящике; это приоритетная электронная почта. Напротив, отправка электронного письма с уведомлением о том, что кто-то, за кем мы следим, опубликовал новый контент, не обязательно должна немедленно поступать в папку «Входящие» пользователя; это неприоритетное электронное письмо.

Решение должно оптимизировать способ отправки этих двух категорий электронных писем. Предполагая, что в процессе будет отправлено всего несколько (может быть, 1 или 2) приоритетных писем, а основная часть писем будет неприоритетной, тогда мы разрабатываем решение следующим образом:

  • Приоритетные электронные письма могут просто избежать проблемы с высокой задержкой, используя поставщика SMTP, расположенного в том же регионе, где развернуто приложение. Помимо хороших исследований, это включает в себя интеграцию нашего приложения с API провайдера.
  • Электронные письма, не являющиеся приоритетными, можно отправлять асинхронно или в пакетах, когда многие электронные письма отправляются вместе. Реализуемый на уровне приложений, он требует соответствующего стека технологий.

Далее давайте определим стек технологий для асинхронной отправки электронных писем.

Определение стека технологий

Примечание. Я решил основывать свой стек на сервисах AWS, поскольку мой веб-сайт уже размещен на AWS EC2. В противном случае у меня были бы накладные расходы на перемещение данных между сетями нескольких компаний. Однако мы можем реализовать наше решение и с помощью других поставщиков облачных услуг.

Мой первый подход состоял в том, чтобы настроить очередь. Через очередь я мог бы заставить приложение больше не отправлять электронные письма, а вместо этого публиковать сообщение с содержимым электронной почты и метаданными в очереди, а затем другой процесс извлекает сообщения из очереди и отправляет электронные письма.

Однако при проверке службы очередей от AWS, называемой SQS, я решил, что это не подходящее решение, потому что:

  • Это довольно сложно настроить;
  • Стандартное сообщение очереди может хранить только до 256 КБ информации, чего может быть недостаточно, если электронное письмо имеет вложения (например, счет). И хотя можно разбить большое сообщение на более мелкие сообщения, сложность возрастает еще больше.

Затем я понял, что могу идеально имитировать поведение очереди с помощью комбинации других сервисов AWS, S3 и Lambda, которые гораздо проще настроить. S3, облачное хранилище объектов для хранения и извлечения данных, может действовать как репозиторий для загрузки сообщений, а Lambda, вычислительная служба, которая запускает код в ответ на события, может выбирать сообщение и выполнять с ним операцию.

Другими словами, мы можем настроить процесс отправки электронной почты следующим образом:

  1. Приложение загружает файл с содержимым электронной почты и метаданными в корзину S3.
  2. Всякий раз, когда новый файл загружается в корзину S3, S3 запускает событие, содержащее путь к новому файлу.
  3. Функция Lambda выбирает событие, читает файл и отправляет электронное письмо.

Наконец, мы должны решить, как отправлять электронные письма. Мы можем либо продолжать использовать провайдера SMTP, который у нас уже есть, с функцией Lambda, взаимодействующей с их API, либо использовать сервис AWS для отправки электронных писем, называемый SES. Использование SES имеет как преимущества, так и недостатки:

Преимущества:

  • Очень прост в использовании из AWS Lambda (достаточно 2 строк кода).
  • Это дешевле: сборы Lambda рассчитываются на основе времени, необходимого для выполнения функции, поэтому подключение к SES из сети AWS займет меньше времени, чем подключение к внешнему серверу, благодаря чему функция завершается раньше и стоит меньше. . (Если только SES недоступен в том же регионе, где размещено приложение; в моем случае, поскольку SES не предлагается в Азиатско-Тихоокеанском регионе (Сингапур), где расположен мой сервер EC2, мне, возможно, лучше подключиться к какому-нибудь Внешний поставщик SMTP из Азии).

Недостатки:

  • Предоставляется не так много статистических данных для мониторинга наших отправленных электронных писем, и добавление более мощных требует дополнительных усилий (например, отслеживание того, какой процент электронных писем был открыт или какие ссылки были нажаты, должно быть настроено через AWS CloudWatch).
  • Если мы продолжим использовать провайдера SMTP для отправки приоритетных электронных писем, у нас не будет всей статистики в одном месте.

Для простоты в приведенном ниже коде мы будем использовать SES.

Затем мы определили логику процесса и стека следующим образом: приложение отправляет приоритетные электронные письма, как обычно, но для неприоритетных оно загружает файл с содержимым электронной почты и метаданными в S3; этот файл асинхронно обрабатывается функцией Lambda, которая подключается к SES для отправки электронной почты.

Приступим к реализации решения.

Различие между приоритетными и неприоритетными электронными письмами

Короче говоря, это все зависит от приложения, поэтому нам нужно определиться с электронной почтой по электронной почте. Я опишу решение, которое я реализовал для WordPress, которое требует некоторых хаков вокруг ограничений из функции wp_mail . Для других платформ приведенная ниже стратегия тоже будет работать, но вполне возможно, что будут стратегии и получше, не требующие хаков для работы.

Способ отправки электронной почты в WordPress — вызов функции wp_mail , и мы не хотим это менять (например, вызывая функцию wp_mail_synchronous или wp_mail_asynchronous ), поэтому наша реализация wp_mail должна будет обрабатывать как синхронные, так и асинхронные случаи, и нужно будет знать, к какой группе принадлежит электронная почта. К сожалению, wp_mail не предлагает никакого дополнительного параметра, из которого мы могли бы оценить эту информацию, как это видно из его подписи:

 function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() )

Затем, чтобы узнать категорию письма, мы добавляем хакерское решение: по умолчанию мы делаем письмо принадлежащим к группе приоритетов, и если $to содержит определенное письмо (например: [email protected]), или если $subject начинается со специальной строки (например: «[Без приоритета!]»), то он принадлежит к неприоритетной группе (и мы удаляем соответствующий адрес электронной почты или строку из темы). wp_mail — это подключаемая функция, поэтому мы можем переопределить ее, просто реализовав новую функцию с той же сигнатурой в нашем файле functions.php. Изначально он содержит тот же код исходной функции wp_mail , расположенный в файле wp-includes/pluggable.php, для извлечения всех параметров:

 if ( !function_exists( 'wp_mail' ) ) : function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) { $atts = apply_filters( 'wp_mail', compact( 'to', 'subject', 'message', 'headers', 'attachments' ) ); if ( isset( $atts['to'] ) ) { $to = $atts['to']; } if ( !is_array( $to ) ) { $to = explode( ',', $to ); } if ( isset( $atts['subject'] ) ) { $subject = $atts['subject']; } if ( isset( $atts['message'] ) ) { $message = $atts['message']; } if ( isset( $atts['headers'] ) ) { $headers = $atts['headers']; } if ( isset( $atts['attachments'] ) ) { $attachments = $atts['attachments']; } if ( ! is_array( $attachments ) ) { $attachments = explode( "\n", str_replace( "\r\n", "\n", $attachments ) ); } // Continue below... } endif;

И затем мы проверяем, является ли он неприоритетным, и в этом случае мы затем разветвляемся на отдельную логику в функции send_asynchronous_mail или, если это не так, мы продолжаем выполнять тот же код, что и в исходной функции wp_mail :

 function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) { // Continued from above... $hacky_email = "[email protected]"; if (in_array($hacky_email, $to)) { // Remove the hacky email from $to array_splice($to, array_search($hacky_email, $to), 1); // Fork to asynchronous logic return send_asynchronous_mail($to, $subject, $message, $headers, $attachments); } // Continue all code from original function in wp-includes/pluggable.php // ... }

В нашей функции send_asynchronous_mail вместо загрузки электронной почты прямо на S3 мы просто добавляем электронную почту в глобальную переменную $emailqueue , из которой мы можем загружать все электронные письма вместе на S3 в одном соединении в конце запроса:

 function send_asynchronous_mail($to, $subject, $message, $headers, $attachments) { global $emailqueue; if (!$emailqueue) { $emailqueue = array(); } // Add email to queue. Code continues below... }

Мы можем загружать по одному файлу на электронное письмо, или мы можем объединить их так, чтобы в 1 файле содержалось много электронных писем. Поскольку $headers содержит метаданные электронной почты (от, тип контента и кодировку, поля CC, BCC и поле для ответа), мы можем группировать электронные письма вместе, если они имеют одинаковые $headers . Таким образом, все эти электронные письма могут быть загружены в один и тот же файл на S3, а метаинформация $headers будет включена в файл только один раз, а не один раз для каждого электронного письма:

 function send_asynchronous_mail($to, $subject, $message, $headers, $attachments) { // Continued from above... // Add email to the queue $emailqueue[$headers] = $emailqueue[$headers] ?? array(); $emailqueue[$headers][] = array( 'to' => $to, 'subject' => $subject, 'message' => $message, 'attachments' => $attachments, ); // Code continues below }

Наконец, функция send_asynchronous_mail возвращает true . Обратите внимание, что этот код является хакерским: значение true обычно означает, что электронное письмо было отправлено успешно, но в этом случае оно еще даже не было отправлено, и оно может завершиться ошибкой. Из-за этого функция, вызывающая wp_mail , не должна обрабатывать true ответ как «электронное письмо было успешно отправлено», а как подтверждение того, что оно поставлено в очередь. Вот почему важно ограничить этот метод неприоритетными электронными письмами, чтобы в случае сбоя процесс мог продолжать повторять попытки в фоновом режиме, и пользователь не ожидал, что электронное письмо уже находится в ее почтовом ящике:

 function send_asynchronous_mail($to, $subject, $message, $headers, $attachments) { // Continued from above... // That's it! return true; }

Загрузка писем на S3

В моей предыдущей статье «Обмен данными между несколькими серверами через AWS S3» я описал, как создать корзину в S3 и как загружать файлы в корзину через SDK. Весь приведенный ниже код продолжает реализацию решения для WordPress, поэтому мы подключаемся к AWS с помощью SDK для PHP.

Мы можем расширить абстрактный класс AWS_S3 (представленный в моей предыдущей статье), чтобы подключиться к S3 и загрузить электронные письма в корзину «async-emails» в конце запроса (запускается через хук wp_footer ). Обратите внимание, что мы должны сохранить ACL как «частный», поскольку мы не хотим, чтобы электронные письма были доступны в Интернете:

 class AsyncEmails_AWS_S3 extends AWS_S3 { function __construct() { // Send all emails at the end of the execution add_action("wp_footer", array($this, "upload_emails_to_s3"), PHP_INT_MAX); } protected function get_acl() { return "private"; } protected function get_bucket() { return "async-emails"; } function upload_emails_to_s3() { $s3Client = $this->get_s3_client(); // Code continued below... } } new AsyncEmails_AWS_S3();

Мы начинаем перебирать пары заголовков => данные электронной почты, сохраненные в глобальной переменной $emailqueue , и получаем конфигурацию по умолчанию из функции get_default_email_meta , если заголовки пусты. В приведенном ниже коде я извлекаю только поле «от» из заголовков (код для извлечения всех заголовков можно скопировать из оригинальной функции wp_mail ):

 class AsyncEmails_AWS_S3 extends AWS_S3 { public function get_default_email_meta() { // Code continued from above... return array( 'from' => sprintf( '%s <%s>', get_bloginfo('name'), get_bloginfo('admin_email') ), 'contentType' => 'text/html', 'charset' => strtolower(get_option('blog_charset')) ); } public function upload_emails_to_s3() { // Code continued from above... global $emailqueue; foreach ($emailqueue as $headers => $emails) { $meta = $this->get_default_email_meta(); // Retrieve the "from" from the headers $regexp = '/From:\s*(([^\<]*?) <)? ?\s*\n/i'; if(preg_match($regexp, $headers, $matches)) { $meta['from'] = sprintf( '%s <%s>', $matches[2], $matches[3] ); } // Code continued below... } } } class AsyncEmails_AWS_S3 extends AWS_S3 { public function get_default_email_meta() { // Code continued from above... return array( 'from' => sprintf( '%s <%s>', get_bloginfo('name'), get_bloginfo('admin_email') ), 'contentType' => 'text/html', 'charset' => strtolower(get_option('blog_charset')) ); } public function upload_emails_to_s3() { // Code continued from above... global $emailqueue; foreach ($emailqueue as $headers => $emails) { $meta = $this->get_default_email_meta(); // Retrieve the "from" from the headers $regexp = '/From:\s*(([^\<]*?) <)? ?\s*\n/i'; if(preg_match($regexp, $headers, $matches)) { $meta['from'] = sprintf( '%s <%s>', $matches[2], $matches[3] ); } // Code continued below... } } }

Наконец, мы загружаем электронные письма в S3. Мы решаем, сколько писем загружать в файл, чтобы сэкономить деньги. Плата за лямбда-функции зависит от времени, необходимого для их выполнения, рассчитанного для интервалов в 100 мс. Чем больше времени требует функция, тем дороже она становится.

Таким образом, отправка всех электронных писем путем загрузки 1 файла для каждого электронного письма обходится дороже, чем загрузка 1 файла для многих электронных писем, поскольку накладные расходы на выполнение функции вычисляются один раз для каждого электронного письма, а не только один раз для многих электронных писем, а также потому, что отправка многих электронных писем вместе заполняет 100-мс промежутки более тщательно.

Таким образом, мы загружаем много писем в файл. Сколько писем? Лямбда-функции имеют максимальное время выполнения (по умолчанию 3 секунды), и если операция завершится ошибкой, она будет повторять попытку с самого начала, а не с того места, где произошла ошибка. Таким образом, если файл содержит 100 электронных писем, и Lambda успевает отправить 50 электронных писем до истечения максимального времени, то происходит сбой, и он повторяет попытку выполнить операцию снова, отправляя первые 50 электронных писем еще раз. Чтобы избежать этого, мы должны выбрать количество писем в файле, которое, как мы уверены, достаточно для обработки до истечения максимального времени. В нашей ситуации мы могли бы выбрать отправку 25 писем на файл. Количество электронных писем зависит от приложения (большие электронные письма будут отправляться дольше, а время отправки электронного письма будет зависеть от инфраструктуры), поэтому мы должны провести некоторое тестирование, чтобы определить правильное число.

Содержимое файла — это просто объект JSON, содержащий метаданные электронной почты в свойстве «meta» и фрагмент электронных писем в свойстве «emails»:

 class AsyncEmails_AWS_S3 extends AWS_S3 { public function upload_emails_to_s3() { // Code continued from above... foreach ($emailqueue as $headers => $emails) { // Code continued from above... // Split the emails into chunks of no more than the value of constant EMAILS_PER_FILE: $chunks = array_chunk($emails, EMAILS_PER_FILE); $filename = time().rand(); for ($chunk_count = 0; $chunk_count < count($chunks); $chunk_count++) { $body = array( 'meta' => $meta, 'emails' => $chunks[$chunk_count], ); // Upload to S3 $s3Client->putObject([ 'ACL' => $this->get_acl(), 'Bucket' => $this->get_bucket(), 'Key' => $filename.$chunk_count.'.json', 'Body' => json_encode($body), ]); } } } }

Для простоты в приведенном выше коде я не загружаю вложения в S3. Если наши электронные письма должны включать вложения, мы должны использовать функцию SES SendRawEmail вместо SendEmail (которая используется в сценарии Lambda ниже).

Добавив логику для загрузки файлов с электронными письмами в S3, мы можем перейти к кодированию функции Lambda.

Кодирование лямбда-скрипта

Лямбда-функции также называются бессерверными функциями не потому, что они не запускаются на сервере, а потому, что разработчику не нужно беспокоиться о сервере: разработчик просто предоставляет сценарий, а облако заботится о подготовке сервера, развертывании и запуск скрипта. Следовательно, как упоминалось ранее, функции Lambda оплачиваются в зависимости от времени выполнения функции.

Следующий сценарий Node.js выполняет требуемую работу. Вызывается событием S3 «Put», указывающим, что в корзине создан новый объект, функция:

  1. Получает путь к новому объекту (в переменной srcKey ) и корзину (в переменной srcBucket ).
  2. Загружает объект через s3.getObject .
  3. Анализирует содержимое объекта с помощью JSON.parse(response.Body.toString()) и извлекает электронные письма и метаданные электронной почты.
  4. Перебирает все электронные письма и отправляет их через ses.sendEmail .
 var async = require('async'); var aws = require('aws-sdk'); var s3 = new aws.S3(); exports.handler = function(event, context, callback) { var srcBucket = event.Records[0].s3.bucket.name; var srcKey = decodeURIComponent(event.Records[0].s3.object.key.replace(/\+/g, " ")); // Download the file from S3, parse it, and send the emails async.waterfall([ function download(next) { // Download the file from S3 into a buffer. s3.getObject({ Bucket: srcBucket, Key: srcKey }, next); }, function process(response, next) { var file = JSON.parse(response.Body.toString()); var emails = file.emails; var emailsMeta = file.meta; // Check required parameters if (emails === null || emailsMeta === null) { callback('Bad Request: Missing required data: ' + response.Body.toString()); return; } if (emails.length === 0) { callback('Bad Request: No emails provided: ' + response.Body.toString()); return; } var totalEmails = emails.length; var sentEmails = 0; for (var i = 0; i < totalEmails; i++) { var email = emails[i]; var params = { Destination: { ToAddresses: email.to }, Message: { Subject: { Data: email.subject, Charset: emailsMeta.charset } }, Source: emailsMeta.from }; if (emailsMeta.contentType == 'text/html') { params.Message.Body = { Html: { Data: email.message, Charset: emailsMeta.charset } }; } else { params.Message.Body = { Text: { Data: email.message, Charset: emailsMeta.charset } }; } // Send the email var ses = new aws.SES({ "region": "us-east-1" }); ses.sendEmail(params, function(err, data) { if (err) { console.error('Unable to send email due to an error: ' + err); callback(err); } sentEmails++; if (sentEmails == totalEmails) { next(); } }); } } ], function (err) { if (err) { console.error('Unable to send emails due to an error: ' + err); callback(err); } // Success callback(null); }); };

Затем мы должны загрузить и настроить функцию Lambda в AWS, что включает в себя:

  1. Создание роли исполнения, предоставляющей Lambda разрешения на доступ к S3.
  2. Создание пакета .zip, содержащего весь код, т. е. создаваемую функцию Lambda + все необходимые модули Node.js.
  3. Загрузка этого пакета в AWS с помощью инструмента CLI.

Как это сделать, подробно описано на сайте AWS в Учебном руководстве по использованию AWS Lambda с Amazon S3.

Подключение S3 к лямбда-функции

Наконец, создав ведро и функцию Lambda, нам нужно соединить их вместе, чтобы всякий раз, когда в ведре создается новый объект, он вызывал событие для выполнения функции Lambda. Для этого мы идем на панель инструментов S3 и нажимаем на строку корзины, которая покажет ее свойства:

Отображение свойств корзины на панели инструментов S3
Если щелкнуть строку корзины, отобразятся свойства корзины. (Большой превью)

Затем, нажав «Свойства», мы прокручиваем вниз до пункта «События», там мы нажимаем «Добавить уведомление» и вводим следующие поля:

  • Имя: название уведомления, например: «EmailSender»;
  • События: «Поместить» — событие, которое запускается, когда в корзине создается новый объект;
  • Отправить: «Лямбда-функция»;
  • Lambda: имя нашей вновь созданной Lambda, например: «LambdaEmailSender».
Настройка S3 с помощью Lambda
Добавление уведомления в S3 для запуска события для Lambda. (Большой превью)

Наконец, мы также можем настроить корзину S3 на автоматическое удаление файлов, содержащих данные электронной почты, через некоторое время. Для этого мы переходим на вкладку «Управление» корзины и создаем новое правило жизненного цикла, определяя, через сколько дней истекает срок действия электронных писем:

Правило жизненного цикла
Настройка правила жизненного цикла для автоматического удаления файлов из корзины. (Большой превью)

Вот и все. С этого момента при добавлении нового объекта в корзину S3 с содержимым и метаданными для электронных писем будет запускаться функция Lambda, которая будет читать файл и подключаться к SES для отправки электронных писем.

Я реализовал это решение на своем сайте, и оно снова стало быстрым: разгружая отправку электронных писем внешнему процессу, не имеет значения, отправляют ли приложения 20 или 5000 электронных писем, ответ пользователю, который инициировал действие, будет немедленный.

Заключение

В этой статье мы проанализировали, почему отправка большого количества транзакционных писем в одном запросе может стать узким местом в приложении, и создали решение для решения этой проблемы: вместо подключения к SMTP-серверу из приложения (синхронно) мы можем отправлять электронные письма из внешней функции асинхронно на основе стека AWS S3 + Lambda + SES.

Отправляя электронные письма асинхронно, приложение может отправлять тысячи электронных писем, но это не повлияет на ответ пользователю, инициировавшему действие. Однако, чтобы гарантировать, что пользователь не ждет, пока электронное письмо поступит в папку «Входящие», мы также решили разделить электронные письма на две группы, приоритетные и неприоритетные, и отправлять только неприоритетные электронные письма асинхронно. Мы предоставили реализацию для WordPress, которая довольно хакерская из-за ограничений функции wp_mail для отправки писем.

Урок из этой статьи заключается в том, что бессерверные функции в серверном приложении работают довольно хорошо: сайты, работающие на CMS, такой как WordPress, могут повысить свою производительность, реализуя только определенные функции в облаке и избегая больших сложностей, связанных с миграцией. высокодинамичных сайтов на полностью бессерверную архитектуру.