Implementing a mail microservice in NodeJS with BullMQ (3/3)

Tutorial for sending HTML as PDFs attachments using nodemailer and BullMQ.

PDFs as Email Attachments

In this last chapter we are going to add a more advance feature to our mailing service, namely the option to send HTML content as PDFs attachments. This will show how using BullMQ it is trivial to offload and distribute more heavy tasks to a fleet of workers.

The code for this tutorial can be found here: https://github.com/taskforcesh/bullmq-mailbot on the part3 branch. And there is also a plain JS version of the tutorial here: https://github.com/igolskyi/bullmq-mailbot-js.

We will use puppeteer in order to render the HTML into a PDF. Puppeteer is an excellent choice which just works and provides very nice results. You will notice that sending the emails now is much more time consuming, our workers need finally to do some work!. However since we can have as many workers as we want distributed in different machines, we should have no problem in scaling a solution like this for handling any workload we throw at it.

We will start by enhancing our "Mail" interface (now renamed to MailJob) so that we can also specify attachments:

export interface MailJob {
  mailOpts: {
    from: string;
    to: string;
    subject: string;
    text?: string;
    html?: string;
    generateTextFromHTML?: boolean;
  };
  htmlAttachments?: {
    name: string;
    html: string;
  }[];
}
mail-job.ts

Attachments are optional and we want to allow any number of them so we accept an array.

Since we are using "puppeteer", we need to install it as a dependency:

yarn add puppeteer

Next we need to enhance our processor so that we generate the PDFs, one for every item in the htmlAttachments array:

import puppeteer from "puppeteer";

export default async (job: Job<MailJob>) => {
  let attachments;
  if (job.data.htmlAttachments) {
    attachments = await Promise.all(
      job.data.htmlAttachments.map(async (attachment) => {
        const browser = await puppeteer.launch({
          headless: true,
          args: ["--no-sandbox", "--disable-setuid-sandbox"],
        });
        const page = await browser.newPage();

        await page.setContent(attachment.html);

        const pdf = await page.pdf({ format: "a4", printBackground: true });

        await browser.close();

        return { filename: `${attachment.name}.pdf`, content: pdf };
      })
    );
  }

  return transporter.sendMail({ ...job.data.mailOpts, attachments });
};
mail.processor.ts

Now we use the "async" keyword for our processor to simplify the code using "await" for all the asynchronous operations. Note that we launch a new browser for every job, an optimization may be to move this outside of the processor itself, however  I am not sure this is completely safe if you have concurrency enabled in your workers. I will leave it as an exercise to the reader to determine if this is feasible or not :).

Regarding the arguments sent to puppeteer:

"--no-sandbox", "--disable-setuid-sandbox"

you must now that these are unsafe arguments if the HTML you send to puppeteer is not trusted, if this is not good enough for your use case please read here on how to fix it: https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md#setting-up-chrome-linux-sandbox

With the processor ready we can now write a simple test to check that everything works:

client.enqueue("invoice", {
  mailOpts: {
    from: "manast@taskforce.sh",
    to: args[0],
    subject: "Your service invoice",
    text: "Please see the attached invoice\n Kind Regards \n Economy team\n",
  },
  htmlAttachments: [
    {
      name: "invoice-0001",
      html: "<html><body><div>This is just a dummy Invoice</div></body></html>",
    },
  ],
});
send-attachment.ts

This will result in an email that includes a PDF with the HTML as content.

And with this post we end third and last part of these tutorial series!



Follow me on twitter if you want to be the first to know when I publish new tutorials and tips for Bull/BullMQ.

And remember, subscribing to Taskforce.sh is the greatest way to help supporting future BullMQ development!