Envio de e-mails de forma assíncrona por meio do AWS SES
Publicados: 2022-03-10A maioria dos aplicativos envia e-mails para se comunicar com seus usuários. E-mails transacionais são aqueles acionados pela interação do usuário com o aplicativo, como ao receber um novo usuário após o cadastro no site, fornecer ao usuário um link para redefinir a senha ou anexar uma fatura após o usuário realizar uma compra. Todos esses casos anteriores normalmente exigem o envio de apenas um e-mail para o usuário. Em alguns outros casos, porém, o aplicativo precisa enviar muito mais e-mails, como quando um usuário publica um novo conteúdo no site, e todos os seus seguidores (que, em uma plataforma como o Twitter, podem chegar a milhões de usuários) receberão um notificação. Nesta última situação, não arquitetada adequadamente, o envio de e-mails pode se tornar um gargalo na aplicação.
Foi o que aconteceu no meu caso. Eu tenho um site que pode precisar enviar 20 e-mails após algumas ações acionadas pelo usuário (como notificações do usuário para todos os seus seguidores). Inicialmente, ele contava com o envio de e-mails por meio de um popular provedor SMTP baseado em nuvem (como SendGrid, Mandrill, Mailjet e Mailgun), mas a resposta ao usuário levaria segundos. Evidentemente, conectar-se ao servidor SMTP para enviar esses 20 e-mails estava desacelerando significativamente o processo.
Após a inspeção, descobri as fontes do problema:
- Conexão síncrona
A aplicação se conecta ao servidor SMTP e aguarda uma confirmação, de forma síncrona, antes de continuar a execução do processo. - Alta latência
Enquanto meu servidor está localizado em Cingapura, o provedor SMTP que eu estava usando tem seus servidores localizados nos EUA, fazendo com que a conexão de ida e volta leve um tempo considerável. - Sem reutilização da conexão SMTP Ao chamar a função para enviar um e-mail, a função envia o e-mail imediatamente, criando uma nova conexão SMTP naquele momento (não oferece coletar todos os e-mails e enviá-los todos juntos ao final da solicitação, sob um único SMTP conexão).
Por causa do nº 1, o tempo que o usuário deve esperar pela resposta está vinculado ao tempo que leva para enviar os e-mails. Por causa do nº 2, o tempo para enviar um e-mail é relativamente alto. E por causa do nº 3, o tempo para enviar 20 e-mails é 20 vezes o tempo necessário para enviar um e-mail. Embora o envio de apenas um e-mail possa não tornar o aplicativo muito mais lento, o envio de 20 e-mails certamente o torna, afetando a experiência do usuário.
Vamos ver como podemos resolver esse problema.
Prestando atenção à natureza dos e-mails transacionais
Antes de mais nada, devemos notar que nem todos os emails são iguais em importância. Podemos categorizar amplamente os emails em dois grupos: emails prioritários e não prioritários. Por exemplo, se o usuário esqueceu a senha de acesso à conta, ele esperará o e-mail com o link de redefinição de senha imediatamente em sua caixa de entrada; que é um e-mail prioritário. Por outro lado, enviar um e-mail notificando que alguém que seguimos postou um novo conteúdo não precisa chegar imediatamente à caixa de entrada do usuário; que é um e-mail não prioritário.
A solução deve otimizar a forma como essas duas categorias de e-mails são enviadas. Assumindo que haverá apenas alguns (talvez 1 ou 2) emails prioritários a serem enviados durante o processo, e a maior parte dos emails não serão prioritários, então projetamos a solução da seguinte forma:
- Emails prioritários podem simplesmente evitar o problema de alta latência usando um provedor SMTP localizado na mesma região em que o aplicativo é implantado. Além de uma boa pesquisa, isso envolve a integração do nosso aplicativo com a API do provedor.
- E -mails não prioritários podem ser enviados de forma assíncrona e em lotes onde muitos e-mails são enviados juntos. Implementado no nível do aplicativo, requer uma pilha de tecnologia apropriada.
Vamos definir a pilha de tecnologia para enviar e-mails de forma assíncrona em seguida.
Definindo a pilha de tecnologia
Observação: decidi basear minha pilha nos serviços da AWS porque meu site já está hospedado no AWS EC2. Caso contrário, eu teria uma sobrecarga ao mover dados entre as redes de várias empresas. No entanto, podemos implementar nossa solução usando outros provedores de serviços em nuvem também.
Minha primeira abordagem foi configurar uma fila. Por meio de uma fila, eu poderia fazer com que o aplicativo não enviasse mais os e-mails, mas publicasse uma mensagem com o conteúdo e os metadados do e-mail em uma fila e, em seguida, outro processo pegasse as mensagens da fila e enviasse os e-mails.
Porém, ao verificar o serviço de filas da AWS, chamado SQS, decidi que não era uma solução adequada, pois:
- É bastante complexo de configurar;
- Uma mensagem de fila padrão pode armazenar apenas até 256 kb de informações, o que pode não ser suficiente se o email tiver anexos (uma fatura, por exemplo). E mesmo que seja possível dividir uma mensagem grande em mensagens menores, a complexidade cresce ainda mais.
Então percebi que poderia imitar perfeitamente o comportamento de uma fila por meio de uma combinação de outros serviços da AWS, S3 e Lambda, que são muito mais fáceis de configurar. O S3, uma solução de armazenamento de objetos em nuvem para armazenar e recuperar dados, pode atuar como repositório para carregar as mensagens, e o Lambda, um serviço de computação que executa código em resposta a eventos, pode selecionar uma mensagem e executar uma operação com ela.
Em outras palavras, podemos configurar nosso processo de envio de e-mail assim:
- O aplicativo carrega um arquivo com o conteúdo do email + metadados para um bucket do S3.
- Sempre que um novo arquivo é carregado no bucket do S3, o S3 aciona um evento contendo o caminho para o novo arquivo.
- Uma função do Lambda seleciona o evento, lê o arquivo e envia o email.
Finalmente, temos que decidir como enviar e-mails. Podemos continuar usando o provedor SMTP que já temos, fazendo com que a função Lambda interaja com suas APIs, ou usar o serviço da AWS para envio de emails, chamado SES. O uso do SES tem vantagens e desvantagens:
Benefícios:
- Muito simples de usar de dentro do AWS Lambda (só leva 2 linhas de código).
- É mais barato: as taxas do Lambda são calculadas com base no tempo necessário para executar a função, portanto, conectar-se ao SES de dentro da rede da AWS levará menos tempo do que conectar-se a um servidor externo, fazendo com que a função termine mais cedo e custando menos . (A menos que o SES não esteja disponível na mesma região em que o aplicativo está hospedado; no meu caso, como o SES não é oferecido na região Ásia-Pacífico (Cingapura), onde meu servidor EC2 está localizado, talvez seja melhor conectar-me a alguns provedor SMTP externo baseado na Ásia).
Desvantagens:
- Não são fornecidas muitas estatísticas para monitorar nossos e-mails enviados, e adicionar mais poderosos requer esforço extra (por exemplo: rastrear qual porcentagem de e-mails foi aberta ou quais links foram clicados deve ser configurado por meio do AWS CloudWatch).
- Se continuarmos usando o provedor SMTP para enviar os e-mails prioritários, não teremos nossas estatísticas todas juntas em um só lugar.
Para simplificar, no código abaixo, usaremos SES.
Definimos então a lógica do processo e da pilha da seguinte forma: O aplicativo envia emails prioritários como de costume, mas para os não prioritários, ele carrega um arquivo com conteúdo de email e metadados para o S3; esse arquivo é processado de forma assíncrona por uma função do Lambda, que se conecta ao SES para enviar o email.
Vamos começar a implementar a solução.
Diferenciando entre e-mails prioritários e não prioritários
Em suma, tudo isso depende do aplicativo, então precisamos decidir e-mail por e-mail. Vou descrever uma solução que implementei para o WordPress, que requer alguns hacks em torno das restrições da função wp_mail
. Para outras plataformas, a estratégia abaixo também funcionará, mas possivelmente haverá estratégias melhores, que não exigem hacks para funcionar.
A maneira de enviar um email no WordPress é chamando a função wp_mail
, e não queremos mudar isso (por exemplo: chamando a função wp_mail_synchronous
ou wp_mail_asynchronous
), então nossa implementação de wp_mail
precisará lidar com casos síncronos e assíncronos, e precisará saber a qual grupo o e-mail pertence. Infelizmente, wp_mail
não oferece nenhum parâmetro extra a partir do qual possamos avaliar esta informação, como pode ser visto em sua assinatura:
function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() )
Então, para descobrir a categoria do email, adicionamos uma solução hacky: por padrão, fazemos um email pertencer ao grupo prioritário e se $to
contém um email específico (por exemplo: [email protected]), ou se $subject
começar com uma string especial (por exemplo: “[Non-priority!]“), então ele pertence ao grupo non-priority (e removemos o email ou string correspondente do assunto). wp_mail
é uma função conectável, então podemos substituí-la simplesmente implementando uma nova função com a mesma assinatura em nosso arquivo functions.php. Inicialmente, contém o mesmo código da função wp_mail
original, localizada no arquivo wp-includes/pluggable.php, para extrair todos os parâmetros:
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;
E então verificamos se não é prioritário, caso em que bifurcamos para uma lógica separada na função send_asynchronous_mail
ou, se não for, continuamos executando o mesmo código da função wp_mail
original:
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 // ... }
Em nossa função send_asynchronous_mail
, ao invés de enviar o email direto para o S3, simplesmente adicionamos o email a uma variável global $emailqueue
, a partir da qual podemos enviar todos os emails juntos para o S3 em uma única conexão ao final da requisição:
function send_asynchronous_mail($to, $subject, $message, $headers, $attachments) { global $emailqueue; if (!$emailqueue) { $emailqueue = array(); } // Add email to queue. Code continues below... }
Podemos fazer upload de um arquivo por e-mail ou podemos agrupá-los para que em 1 arquivo contenhamos muitos e-mails. Como $headers
contém meta de email (de, content-type e charset, CC, BCC e campos de resposta), podemos agrupar emails sempre que eles tiverem os mesmos $headers
. Dessa forma, todos esses emails podem ser carregados no mesmo arquivo para o S3, e as $headers
serão incluídas apenas uma vez no arquivo, em vez de uma vez por email:
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 }
Por fim, a função send_asynchronous_mail
retorna true
. Observe que este código é hacky: true
normalmente significa que o e-mail foi enviado com sucesso, mas, neste caso, ele ainda nem foi enviado e pode falhar perfeitamente. Por isso, a função que chama wp_mail
não deve tratar uma resposta true
como “o email foi enviado com sucesso”, mas uma confirmação de que foi enfileirado. É por isso que é importante restringir essa técnica a emails não prioritários para que, se falhar, o processo continue tentando novamente em segundo plano e o usuário não espere que o email já esteja em sua caixa de entrada:
function send_asynchronous_mail($to, $subject, $message, $headers, $attachments) { // Continued from above... // That's it! return true; }
Carregando e-mails para o S3
Em meu artigo anterior “Compartilhando dados entre vários servidores por meio do AWS S3”, descrevi como criar um bucket no S3 e como fazer upload de arquivos para o bucket por meio do SDK. Todo o código abaixo dá continuidade à implementação de uma solução para WordPress, por isso nos conectamos à AWS usando o SDK para PHP.
Podemos estender da classe abstrata AWS_S3
(introduzida em meu artigo anterior) para conectar ao S3 e fazer upload dos emails para um bucket “async-emails” no final da solicitação (acionado pelo gancho wp_footer
). Observe que devemos manter a ACL como “privada”, pois não queremos que os e-mails sejam expostos à internet:
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();
Começamos a iterar pelos pares de cabeçalhos => emaildata salvos na variável global $emailqueue
, e obtemos uma configuração padrão da função get_default_email_meta
se os cabeçalhos estiverem vazios. No código abaixo, recupero apenas o campo “de” dos cabeçalhos (o código para extrair todos os cabeçalhos pode ser copiado da função original 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... } } }
Por fim, fazemos upload dos e-mails para o S3. Decidimos quantos e-mails enviar por arquivo com a intenção de economizar dinheiro. As funções lambda são cobradas com base na quantidade de tempo que precisam para serem executadas, calculadas em intervalos de 100 ms. Quanto mais tempo uma função requer, mais cara ela se torna.
Enviar todos os e-mails fazendo upload de 1 arquivo por e-mail, então, é mais caro do que enviar 1 arquivo por muitos e-mails, pois a sobrecarga da execução da função é calculada uma vez por e-mail, em vez de apenas uma vez para muitos e-mails, e também porque o envio de muitos e-mails juntos preenchem os intervalos de 100ms mais completamente.
Por isso, fazemos upload de muitos e-mails por arquivo. Quantos e-mails? As funções do Lambda têm um tempo máximo de execução (3 segundos por padrão) e, se a operação falhar, ela continuará tentando novamente desde o início, não de onde falhou. Portanto, se o arquivo contiver 100 e-mails e o Lambda conseguir enviar 50 e-mails antes do tempo máximo, ele falhará e tentará executar a operação novamente, enviando os primeiros 50 e-mails novamente. Para evitar isso, devemos escolher um número de e-mails por arquivo que acreditamos ser suficiente para processar antes que o tempo máximo acabe. Em nossa situação, poderíamos optar por enviar 25 e-mails por arquivo. O número de e-mails depende da aplicação (e-mails maiores demoram mais para serem enviados, e o tempo para enviar um e-mail vai depender da infraestrutura), então devemos fazer alguns testes para chegar ao número certo.
O conteúdo do arquivo é simplesmente um objeto JSON, contendo o meta do email na propriedade “meta” e o pedaço de emails na propriedade “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), ]); } } } }
Para simplificar, no código acima, não estou carregando os anexos no S3. Se nossos e-mails precisarem incluir anexos, devemos usar a função SES SendRawEmail
em vez de SendEmail
(que é usado no script Lambda abaixo).
Tendo adicionado a lógica para fazer upload dos arquivos com e-mails para o S3, podemos avançar para a codificação da função Lambda.
Codificando o script Lambda
As funções lambda também são chamadas de funções sem servidor, não porque não sejam executadas em um servidor, mas porque o desenvolvedor não precisa se preocupar com o servidor: o desenvolvedor simplesmente fornece o script e a nuvem se encarrega de provisionar o servidor, implantar e executando o script. Portanto, conforme mencionado anteriormente, as funções do Lambda são cobradas com base no tempo de execução da função.
O script Node.js a seguir faz o trabalho necessário. Invocada pelo evento “Put” do S3, que indica que um novo objeto foi criado no bucket, a função:
- Obtém o caminho do novo objeto (sob a variável
srcKey
) e o bucket (sob a variávelsrcBucket
). - Faz o download do objeto, por meio de
s3.getObject
. - Analisa o conteúdo do objeto, por meio de
JSON.parse(response.Body.toString())
e extrai os emails e a meta do email. - Itera por todos os e-mails e os envia por meio
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); }); };
Em seguida, devemos fazer upload e configurar a função Lambda para AWS, que envolve:
- Criando uma função de execução que concede permissões do Lambda para acessar o S3.
- Criando um pacote .zip contendo todo o código, ou seja, a função Lambda que estamos criando + todos os módulos Node.js necessários.
- Fazendo upload deste pacote para a AWS usando uma ferramenta CLI.
Como fazer essas coisas está devidamente explicado no site da AWS, no Tutorial sobre como usar o AWS Lambda com o Amazon S3.
Conectando o S3 com a função Lambda
Por fim, tendo o bucket e a função Lambda criados, precisamos conectar os dois juntos, para que sempre que houver um novo objeto criado no bucket, ele acione um evento para executar a função Lambda. Para fazer isso, vamos ao painel do S3 e clicamos na linha do bucket, que mostrará suas propriedades:
Em seguida, clicando em Propriedades, rolamos para baixo até o item “Eventos”, e lá clicamos em Adicionar uma notificação e inserimos os seguintes campos:
- Nome: nome da notificação, ex: “EmailSender”;
- Eventos: “Put”, que é o evento acionado quando um novo objeto é criado no bucket;
- Enviar para: “Função Lambda”;
- Lambda: nome do nosso Lambda recém-criado, ex: “LambdaEmailSender”.
Por fim, também podemos definir o bucket do S3 para excluir automaticamente os arquivos que contêm os dados do email após algum tempo. Para isso, vamos até a aba Management do bucket, e criamos uma nova regra de Lifecycle, definindo após quantos dias os emails devem expirar:
É isso. A partir deste momento, ao adicionar um novo objeto no bucket do S3 com o conteúdo e meta dos emails, ele acionará a função Lambda, que lerá o arquivo e se conectará ao SES para enviar os emails.
Implementei esta solução no meu site, e ela voltou a ser rápida: ao descarregar o envio de e-mails para um processo externo, não faz diferença se os aplicativos enviarem 20 ou 5000 e-mails, a resposta ao usuário que acionou a ação será imediato.
Conclusão
Neste artigo analisamos por que o envio de muitos emails transacionais em uma única solicitação pode se tornar um gargalo no aplicativo e criamos uma solução para lidar com o problema: em vez de nos conectarmos ao servidor SMTP de dentro do aplicativo (síncrono), podemos enviar os e-mails de uma função externa, de forma assíncrona, com base em uma pilha de AWS S3 + Lambda + SES.
Ao enviar e-mails de forma assíncrona, o aplicativo consegue enviar milhares de e-mails, mas a resposta ao usuário que acionou a ação não será afetada. No entanto, para garantir que o usuário não fique esperando o e-mail chegar na caixa de entrada, também decidimos dividir os e-mails em dois grupos, prioritários e não prioritários, e enviar apenas os e-mails não prioritários de forma assíncrona. Fornecemos uma implementação para WordPress, que é bastante hacky devido às limitações da função wp_mail
para envio de e-mails.
Uma lição deste artigo é que as funcionalidades sem servidor em um aplicativo baseado em servidor funcionam muito bem: sites executados em um CMS como o WordPress podem melhorar seu desempenho implementando apenas recursos específicos na nuvem e evitar uma grande complexidade resultante da migração sites altamente dinâmicos para uma arquitetura totalmente sem servidor.