Email API showdown: testing Mailgun vs. Resend with LaunchDarkly feature flags featured image

Zawinski’s Law states that “every piece of software will eventually expand until it has to read email”. Sending email is an even more ubiquitous use case.

Email is complicated; in a build/buy evaluation, using an email SDK / SAAS service rather than rolling your own is a no-brainer. But how do you determine which email service is best for your use case? 

You can research some vendors, read their documentation, and even smash that button on their website to request a demo from their sales team. But as a developer, I find that the best way to try out APIs is to build with them.


In this tutorial, you’ll learn how to use LaunchDarkly feature flags to toggle between 2 different email providers (Resend and Mailgun) in an ExpressJS application. For you impatient types, a repository with fully working code can be found on GitHub.

Prerequisites

Getting started with the example ExpressJS application

Since we’re essentially doing a bakeoff, our demo application is a cupcake shop. 

Run these commands in your terminal to clone the repository, install dependencies, and start the server.

git clone https://github.com/annthurium/expressjs-launchdarkly-email-service-starter/ 

cd expressjs-launchdarkly-email-service-starter

npm install

npm start

Load http://localhost:3000/password-reset.html in your browser. This form isn’t hooked up to anything yet! Later on, we’ll add an EmailService class to send the password reset emails.

Screenshot of password reset flow for a demo application: Tilde's Cupcake Shoppe.

Sending transactional password reset emails with Resend

Log in to your Resend account. Add a verified domain by following the instructions here: they will be specific to the hosting provider where your domain lives. 

Create a Resend API key with full access, following these instructions. Copy and paste it into your .env.example file. Rename that file to .env and save it. This step helps prevent you from accidentally compromising the API key by committing it to source control.


Create a new file, email-service.js, in the root of your project. Add the following lines of code. Replace the RESEND_DOMAIN with your verified domain.

// replace this with your resend domain
const RESEND_DOMAIN = "example.dev";

const EMAIL_TEMPLATES = {
 PASSWORD_RESET: {
   subject: "Reset Your Password",
   html: `
     <h1>Password Reset Request</h1>
     <p>Hello!</p>
     <p>We received a request to reset your password for Tilde's Cupcake Shoppe.</p>
     <p>Please click the link below to reset your password:</p>
     <p><a href="http://localhost:3000/reset-password">Reset Password</a></p>
     <p>If you didn't request this, you can safely ignore this email.</p>
     <p>Best regards,<br>Tilde's Cupcake Shoppe Team</p>
   `,
 },
};

const EMAIL_CONFIG = {
 RESEND_FROM: `Tilde's Cupcake Shoppe <password-reset@${RESEND_DOMAIN}>`,
};

class EmailService {
 constructor(resendClient) {
   this.resend = resendClient;
 }

 async sendPasswordReset(email) {
   const template = EMAIL_TEMPLATES.PASSWORD_RESET;
   const result = await this.sendWithResend(email, template);

   if (!result.success) {
     throw result.error;
   }
   return result.data;
 }

 async sendWithResend(email, template) {
   try {
     const data = await this.resend.emails.send({
       from: EMAIL_CONFIG.RESEND_FROM,
       to: email,
       subject: template.subject,
       html: template.html,
     });
     return { success: true, data };
   } catch (error) {
     console.error("Resend error:", error);
     return { success: false, error };
   }
 }
}

module.exports = EmailService;

Open index.js. Add or modify the following lines of code that are commented below, or just YOLO and copy-paste the whole file if that’s how you roll.

const express = require("express");
const path = require("path");
require("dotenv").config();
const serveStatic = require("serve-static");
const bodyParser = require("body-parser");

// add the following new dependencies:
const EmailService = require("./email-service");
const { Resend } = require("resend");

const app = express();

app.use(serveStatic(path.join(__dirname, "public")));
app.use(bodyParser.urlencoded({ extended: false }));

// add the following 2 lines to instantiate the email service:
const resend = new Resend(process.env.RESEND_API_KEY);
const emailService = new EmailService(resend);

// replace the body of this function with this code:
app.post("/reset-password", async (req, res) => {
 const userEmailAddress = req.body.email;
 let emailServiceResponseData;
 try {
   emailServiceResponseData = await emailService.sendPasswordReset(
     userEmailAddress
   );
 } catch (error) {
   console.error("Error sending password reset email:", error);
   return res.status(500).json({
     message: "Failed to send password reset email. Please try again later.",
   });
 }

 console.log(emailServiceResponseData);
 res.status(200).json({
   message: "Check your email inbox for password reset instructions.",
 });
});

const server = app.listen(3000, function (err) {
 if (err) console.log("Error in server setup");
 console.log(`Server listening on http://localhost:3000`);
});

Go to http://localhost:3000/password-reset.html in your browser. Put in your email address and click Reset. Check your email inbox.

Screenshot of a password reset email from Tilde's Cupcake Shoppe, sent via Resend.

Using Mailgun’s JS SDK to send transactional password reset emails

Now it's time to add Mailgun to our EmailService as a second provider. 

Log in to your Mailgun account

To create an API key, go to the following page and click “Add new key”. When the New API Key modal pops up, input a description for your key (such as “ExpressJS LaunchDarkly demo”) and click Create Key.

Screenshot of modal for creating a Mailgun API key.

Copy the key into your .env file. Save the file. 


Mailgun provides a free test domain, which is pretty cool! Go to https://app.mailgun.com/mg/sending/domains. To use it, you’ll need to add your email address as an authorized recipient. Do so in the sidebar and click Save Recipient.

Screenshot of Mailgun form for adding an authorized recipient to test email sending.

You’ll receive a validation email in your inbox. Click the button in the email to confirm your authorization. 

Copy your sandbox domain from the Mailgun dashboard into a variable, MAILGUN_DOMAIN, at the very beginning of email-service.js. While you’re at it, replace all the code below where the RESEND_DOMAIN variable is defined with the following:

// replace this with your mailgun domain
const MAILGUN_DOMAIN = "sandbox123.mailgun.org";
const RESEND_DOMAIN = "example.dev";

const EMAIL_TEMPLATES = {
 PASSWORD_RESET: {
   subject: "Reset Your Password",
   html: `
     <h1>Password Reset Request</h1>
     <p>Hello!</p>
     <p>We received a request to reset your password for Tilde's Cupcake Shoppe.</p>
     <p>Please click the link below to reset your password:</p>
     <p><a href="http://localhost:3000/reset-password">Reset Password</a></p>
     <p>If you didn't request this, you can safely ignore this email.</p>
     <p>Best regards,<br>Tilde's Cupcake Shoppe Team</p>
   `,
 },
};

const EMAIL_CONFIG = {
 MAILGUN_DOMAIN,
 MAILGUN_FROM: `Tilde's Cupcake Shoppe <mailgun@${MAILGUN_DOMAIN}>`,
 RESEND_FROM: `Tilde's Cupcake Shoppe <password-reset@${RESEND_DOMAIN}>`,
};

class EmailService {
 constructor(mailgunClient, resendClient) {
   this.mailgun = mailgunClient;
   this.resend = resendClient;
 }

 async sendPasswordReset(email, provider = "mailgun") {
   const template = EMAIL_TEMPLATES.PASSWORD_RESET;
   let result;
   if (provider === "resend") {
     result = await this.sendWithResend(email, template);
   } else {
     result = await this.sendWithMailgun(email, template);
   }
   if (!result.success) {
     throw result.error;
   }
   return result.data;
 }

 async sendWithMailgun(email, template) {
   try {
     const data = await this.mailgun.messages.create(
       EMAIL_CONFIG.MAILGUN_DOMAIN,
       {
         from: EMAIL_CONFIG.MAILGUN_FROM,
         to: [email],
         subject: template.subject,
         html: template.html,
       }
     );
     return { success: true, data };
   } catch (error) {
     console.error("Mailgun error:", error);
     return { success: false, error };
   }
 }

 async sendWithResend(email, template) {
   try {
     const data = await this.resend.emails.send({
       from: EMAIL_CONFIG.RESEND_FROM,
       to: email,
       subject: template.subject,
       html: template.html,
     });
     return { success: true, data };
   } catch (error) {
     console.error("Resend error:", error);
     return { success: false, error };
   }
 }
}

module.exports = EmailService;

Back in index.js, add some logic to switch to Mailgun as a default provider so we can try it out:

const express = require("express");
const path = require("path");
require("dotenv").config();
const serveStatic = require("serve-static");
const bodyParser = require("body-parser");

const EmailService = require("./email-service");
const { Resend } = require("resend");

// add the following new dependencies:
const formData = require("form-data");
const Mailgun = require("mailgun.js");
const mailgun = new Mailgun(formData);
const mg = mailgun.client({
 username: "api",
 key: process.env.MAILGUN_API_KEY,
});

const app = express();

app.use(serveStatic(path.join(__dirname, "public")));
app.use(bodyParser.urlencoded({ extended: false }));

const resend = new Resend(process.env.RESEND_API_KEY);

// pass the Mailgun client to the EmailService as a new argument
const emailService = new EmailService(mg, resend);

// replace the body of this function with the following:
app.post("/reset-password", async (req, res) => {
 const userEmailAddress = req.body.email;

 const emailProvider = "mailgun";
 let emailServiceResponseData;
 try {
   emailServiceResponseData = await emailService.sendPasswordReset(
     userEmailAddress,
     emailProvider
   );
 } catch (error) {
   console.error("Error sending password reset email:", error);
   return res.status(500).json({
     message: "Failed to send password reset email. Please try again later.",
   });
 }

 console.log(emailServiceResponseData);
 res.status(200).json({
   message: "Check your email inbox for password reset instructions.",
 });
});

const server = app.listen(3000, function (err) {
 if (err) console.log("Error in server setup");
 console.log(`Server listening on http://localhost:3000`);
});

If you test the password reset flow again at http://localhost:3000/password-reset.html, you should receive an email identical to the previous one, except it came from the Mailgun sandbox domain. 

Just in case it doesn’t show up, check your spam folder.

Using a LaunchDarkly flag to toggle between email providers

We'll need a feature flag to toggle between email providers, so let's create one.

Head to the LaunchDarkly app. Click “Create Flag.” 

Create Flag buttons in a project with no existing flags.

Create a flag with the following configuration:

  • Name: email-provider
  • Key: email-provider
  • Description: toggle between different email providers
  • Configuration: Custom
  • Type: String

Screenshot of initial flag configuration for email-provider feature flag.

Variations:

  • Name: resend, Value: resend
  • Name: mailgun, Value: mailgun
  • When targeting is ON, serve resend, when targeting is OFF serve mailgun. (I arbitrarily picked Mailgun as the “default” provider, but there’s no reason you couldn’t do this the other way around.)

Screenshot of flag variations for the email-provider flag.

Click “Create flag.” On the next screen, click the … menu next to “Production.” Use the dropdown to copy your SDK key.

Screenshot of how to copy the SDK key for the email-provider flag.

Paste it into your .env file. Save the file.


Update the code in index.js to add the LaunchDarkly SDK and evaluate the email-provider flag’s value. New additions are commented below, although the whole file is there for your convenience:

const express = require("express");
const path = require("path");
require("dotenv").config();
const serveStatic = require("serve-static");
// add the LaunchDarkly server SDK
const launchDarkly = require("@launchdarkly/node-server-sdk");
const bodyParser = require("body-parser");


const EmailService = require("./email-service");
const { Resend } = require("resend");
const resend = new Resend(process.env.RESEND_API_KEY);
const formData = require("form-data");
const Mailgun = require("mailgun.js");
const mailgun = new Mailgun(formData);
const mg = mailgun.client({
 username: "api",
 key: process.env.MAILGUN_API_KEY,
});

const app = express();

app.use(serveStatic(path.join(__dirname, "public")));
app.use(bodyParser.urlencoded({ extended: false }));

const emailService = new EmailService(mg, resend);

app.post("/reset-password", async (req, res) => {
 const userEmailAddress = req.body.email;
 console.log("email", userEmailAddress);
 // define the context, which will be passed to LaunchDarkly:
 const context = {
   kind: "user",
   key: userEmailAddress,
   anonymous: true,
 };

 // evaluate the flag
 const emailProvider = await ldClient.variation(
   "email-provider",
   context,
   "mailgun"
 );
 console.log("emailProvider", emailProvider);

 let emailServiceResponseData;
 try {
   emailServiceResponseData = await emailService.sendPasswordReset(
     userEmailAddress,
     emailProvider
   );
 } catch (error) {
   console.error("Error sending password reset email:", error);
   return res.status(500).json({
     message: "Failed to send password reset email. Please try again later.",
   });
 }

 console.log(emailServiceResponseData);
 res.status(200).json({
   message: "Check your email inbox for password reset instructions.",
 });
});

// Initialize the LaunchDarkly client
const ldClient = launchDarkly.init(process.env.LAUNCHDARKLY_SDK_KEY);

// Add the waitForInitialization function to ensure the client is ready before starting the server
const timeoutInSeconds = 5;
let server;
ldClient.waitForInitialization({ timeout: timeoutInSeconds }).then(() => {
 const port = 3000;
 server = app.listen(port, function (err) {
   if (err) console.log("Error in server setup");
   console.log(`Server listening on http://localhost:${port}`);
 });
});

// Add the following new function to gracefully close the connection to the LaunchDarkly server.
process.on("SIGTERM", () => {
 console.log("SIGTERM signal received: closing HTTP server");
 server.close(async () => {
   console.log("HTTP server closed");
   ldClient.close(() => {
     console.log("LaunchDarkly client closed");
   });
 });
});

Go through the reset password flow again. By default, you’ll receive an email from Mailgun.

Navigate back to the LaunchDarkly app. Turn the email-provider flag on.

How to turn the email-provider flag ON in the LaunchDarkly UI.

If your LaunchDarkly app is configured to require it, you may need to add a comment explaining these changes. 

After enabling the flag, the password reset flow now sends emails via Resend. Nice work!

Resend vs Mailgun in 2024: pros and cons

In case you’re curious, here’s a summary of my top pros and cons for these email APIs:

Mailgun

Pros

  • You don’t need to verify a domain, Mailgun provides a sandbox domain to test with
  • Free tier offers an adequate volume of mail sending to test a prototype
  • Integrated logs on the dashboard are useful

Cons:

  • You can only send test emails to verified email accounts 
  • If you use the free sandbox domain, your email might end up in spam
  • Offers a more limited number of SDKs (for example, no Python).
  • The JS API for sending email had a few odd conventions. Why is “form-data” a dependency for the mail client? And why are the domain and the “from” address separate parameters? It seems like Mailgun could infer that on the back end, just saying.

Resend

Pros:

  • Clean, modern feeling API
  • You don’t need to verify receiving email address to get started
  • Resend provides extra email test addresses if you want to test bounced emails, spam, etc.
  • Offers loads of SDKs; even if you’re an Elixir hipster they’ve got you covered
  • Developer documentation has a nice UI
  • Free tier offers an adequate volume of mail sending to test a prototype

Cons:

  • You must verify a domain before you can test, and that’s not free.
  • The free tier only lets you have one domain at a time

Wrapping it up: testing email providers with a LaunchDarkly feature flag

If you’ve been following along, you’ve learned how to use LaunchDarkly flags to toggle between two different email providers in an ExpressJS app. The flexibility of an email toggle flag could be useful in many ways:

  • You could use LaunchDarkly’s experimentation features to see if one service performs better based on your metrics of choice (such as delivery speed, cost, etc.)
  • You could use feature flags to more safely migrate from your old email provider to a new one
  • Or you can keep both providers and have a primary and a backup service, especially if you’re on usage-based pricing plans. That approach helps mitigate the risk of outages on a critical path like password resets.

Thanks for reading! If you have any questions, or want to offer me some cupcakes, you can reach me via email (tthurium@launchdarkly.com), X/Twitter, Mastodon, Discord or LinkedIn.

Like what you read?
Get a demo
Related Content

More about Feature Flags

November 7, 2024