Symfony: cómo enviar muchos, muchos, muchos emails gratis

Ya estoy por aquí otra vez. Hoy traigo un code-kata muy divertido, también peligroso 😱 si lo ejecutas sin medida, o sin configurarlo bien 😀 Pero estos códigos fuentes pueden hacerte ahorrar mucho dinero, a tí o a la empresa donde trabajes 🤑 No es una tarea complicada, simplemente se trata de enviar muchos emails, muchos, del orden de miles o decenas de miles de emails al mes.

Esta es una tarea tan antigua como programar el propio envío de los emails dentro de los servidores de correo electrónico. Es decir, en este post tienes el cómo hay que programar el envío de emails, lo más profesionalmente posible. No sólo en Symfony con PHP, sino también para otros lenguajes de programación, y en otro tipo de aplicaciones. En CMSs como Magento por ejemplo, esto ya viene incluso construido y listo para trabajar, simplemente hay que usarlo. Pero si lo estas construyendo tú mismo, este es tu post 😉

En CMSs como Magento por ejemplo, esto ya viene incluso construido y listo para trabajar, simplemente hay que usarlo.

Es fácil que nos marquen como SPAM, y para evitarlo, muchas empresas contratan este servicio a empresas como Mailchimp, Sendinblue, Mailrelay, AcumbaMail, Mailjet, etcétera.. Los servidores de correo electrónico normales directamente nos cortan el servicio si enviamos muchos correos seguidos. Si enviamos muchos emails transaccionales, o mailings, directamente con nuestros servidores de correo electrónico, conviene hacer las cosas como se indica en este post. Si quieres ahorrar este coste de contratar un servidor de correo SMTP de alta disponibilidad, anualmente puede sumar mucho dinero. Estamos hablando de quizá cientos de euros al año, o miles, dependiendo de cada caso..

Un poco de teoría, el patron de diseño cola de mensajes

La estrategia para abordar este problema, es usar el patrón cola de mensajes para hacer el trabajo. Es decir, en este patrón de diseño de software, cierta parte del trabajo que hace un programa se realiza encolando las tareas. Luego, poco a poco, se van procesando las tareas para no colapsar el sistema completo ya que esta parte del sistema está, hasta cierto punto, desacoplado del sistema completo. Digo hasta cierto punto, porque si no está en un microservicio, o en una parte server-less en la arquitectura total del sistema, entonces sigue pudiendo entonces colapsar el sistema completo en caso de error.

Encolar tareas es un proceso muy ligero. A veces las tareas conviene ejecutarlas al momento, pero sin embargo a veces las tareas son pesadas, y no se pueden ejecutar en el momento, con lo que no hay otra forma que ejecutarlas poco a poco. Para el caso de enviar muchos emails, lo mejor es usar este patrón.

Después, simplemente, hay que ejecutar los envíos, limitando en el tiempo cuántos se ejecutan en cada tanda de envío. Y así nos evitamos el contratar un servidor de correo electrónico de alta disponibilidad, pudiendo usar un servidor de correo normal y corriente.

Al grano, código fuente de una cola de mensajes para enviar emails poco a poco

Para tener el máximo control de todo esto dejo el código fuente aquí debajo. También al final dejo enlaces para componentes Symfony con lo que se podría simplicar más aún este trabajo.

En Symfony necesitaremos pocas cosas para implementarlo nosotros al 100%. Lo primero es la cola de mensajes, que por ejemplo puede ser así el encabezado de los campos. La siguiente entidad representa la cola de mensajes de correo electrónico:

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="App\Repository\EmailQeueRepository")
 */
class EmailQeue
{
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="subject", type="string", length=255)
     */
    private $subject;

    /**
     * @var string
     *
     * @ORM\Column(name="fromEmail", type="string", length=255)
     */
    private $fromEmail;

    /**
     * @var string
     *
     * @ORM\Column(name="fromName", type="string", length=255)
     */
    private $fromName;

    /**
     * @var string
     *
     * @ORM\Column(name="toEmail", type="string", length=255)
     */
    private $toEmail;

    /**
     * @var string
     *
     * @ORM\Column(name="toName", type="string", length=255)
     */
    private $toName;

    /**
     * @var string
     *
     * @ORM\Column(name="bodyHtml", type="text")
     */
    private $bodyHtml;

    /**
     * @var string
     *
     * @ORM\Column(name="bodyTxt", type="text")
     */
    private $bodyTxt;

..utilizando el generador de códigos de Symfony no debemos de tener problemas para generar tantos campos extra como queramos. Simplemente tendremos que añadir tantos emails aquí, como si de cualquier otra entidad se tratara.

Lo siguiente será un comando que vamos a ejecutar a cada minuto para enviar cierta cantidad de emails. Podemos también ejecutarlo a cada 2 minutos o cada 5 minutos, dependerá de cada proyecto cómo queramos configurarlo. Este comando limita en total de emails por segundo, y en total de emails por ejecución. Para este post lo tenemos limitado a 7 emails por segundo y a 30 emails máximo por ejecución:

<?php

namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Output\OutputInterface;
use App\Service\MailingManager;

class AppSendNextEmailsFromQeueCommand extends Command
{
    const MAX_EMAILS_PER_SECOND = 7;
    const MAX_EMAILS_PER_CRON_EXECUTION = 30;
    private $mailingManager;
    protected static $defaultName = 'app:send-next-emails-from-qeue';

    public function __construct(MailingManager $mailingManager)
    {
        $this->mailingManager = $mailingManager;

        parent::__construct();
    }

    protected function configure()
    {
        $this
            ->setDescription('It send enqeued emails.')
            ->addArgument('kmax', InputArgument::OPTIONAL, 'KMAX value.')
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $kmax = $input->getArgument('kmax');

        if (empty($kmax) or !is_numeric($kmax)) {
            $totalSent = $this->mailingManager->sendNextEmailsFromQeue(
                self::MAX_EMAILS_PER_SECOND, self::MAX_EMAILS_PER_CRON_EXECUTION
            );
        } else {
            $totalSent = $this->mailingManager->sendNextEmailsFromQeue(
                self::MAX_EMAILS_PER_SECOND, $kmax
            );
        }

        $output->writeln($totalSent.' sent emails!');
    }
}

Para que esté todo bien organizado, hay que meter en un servicio de Symfony el envío de emails. El envío poco a poco, se debe lanzar a cada minuto con el programador de tareas. Así que abrimos el programador de tareas, y en un servidor GNU/Linux debería de quedar tal que así:

* * * * * php /ruta/donde/tienes/tu/proyecto/bin/console app:send-next-emails-from-qeue

Para terminar sólo queda el código del servicio que hace el envío limitado:

<?php

namespace App\Service;

use Doctrine\ORM\EntityManagerInterface;
use Swift_Mailer;
use Twig\Environment;
use App\Entity\EmailQeue;

class MailingManager
{
    private $entityManager;

    public function __construct(EntityManagerInterface $entityManager, Swift_Mailer $mailer, Environment $twig)
    {
        $this->entityManager = $entityManager;
        $this->mailer = $mailer;
        $this->twig = $twig;
    }

    public function sendNextEmailsFromQeue($maxEmailsPerSecond, $maxEmailsPerCronExecution)
    {
        $nextMails = $this->entityManager->getRepository('App:EmailQeue')->findBy(
            array(),
            array('id' => 'ASC')
        );

        $cont = $contInGroups = $contSentEmails = $contTotalSentEmails = 0;
        $timeStart = microtime(true);
        foreach ($nextMails as $mail) {
            ++$cont;
            ++$contInGroups;

            $message = (new \Swift_Message())
                ->setSubject($mail->getSubject())
                ->setFrom([$mail->getFromEmail() => $mail->getFromName()])
                ->setTo([$mail->getToEmail() => $mail->getToName()])
                ->setBody(
                   $mail->getBodyHtml(),
                   'text/html'
                )
                ->addPart(
                   $mail->getBodyTxt(),
                   'text/plain'
            );

            $this->mailer->send($message);
            // When sent, the email is removed from the queue..
            $this->entityManager->remove($mail);

            ++$contTotalSentEmails;
            ++$contSentEmails;

            if ($contTotalSentEmails >= $maxEmailsPerCronExecution) {
                break;
            }

            $timeEnd = microtime(true);
            if ($contSentEmails >= $maxEmailsPerSecond) {
                $timeConsumed = $timeEnd - $timeStart;

                $waitTime = 1000000 - $timeConsumed * 1000000;
                if ($waitTime > 0) {
                    usleep($waitTime);
                }

                $contSentEmails = 0;
                $timeStart = microtime(true);
            }

            $timeEnd = microtime(true);
            $timeConsumed = $timeEnd - $timeStart;
        }

        $this->entityManager->flush();

        return $contTotalSentEmails;
    }
}

Algo más de teoría, la legalidad y el control del destinatario

Queda en el aire el tema de los destinatarios. Debemos de controlar que los destinos de tus emails han aceptado que se los envíes. También debemos de controlar si los emails llegan al destino, si existe el email de destino, etcétera.

Todo esto son lo que se llaman los BOUNCES y REJECTIONS de los correos electrónicos.

Resumiendo y para terminar, ya que todo esto se sale del post, necesitarás controlar los destinos en una lista de direcciones de correo. Por ejemplo, si el destinatario deniega que le envíes cierto tipo de emails, deberías de saberlo para que en un futuro no le incluyas como destino de tus emails. Si el email de destino no existe, entonces debes de guardarlo marcado como inexistente, para evitar enviarle más emails.

Cuida tu reputación o tu servidor de correo electrónico quedará marcado como SPAMMER.

Terminando

He tratado de resumir los códigos fuentes en un proyecto desastre que tengo por aquí. Si algo no funciona no dudes en dejar un mensaje aquí abajo.

Sólo me queda citar que en las últimas versiones de Symfony se están implementando componentes de colas de mensajes muy útiles para hacer este trabajo. También se puede usar el encolamiento de los emails de forma natural con la memoria o ficheros.

Pero si no quieres depender de componentes externos, y tener total control de este sistema para modificarlo a tu antojo, acabarás haciendo algo parecido a lo del post.

Otro día más.. ¡un saludo!

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *