Almost any web app with authenticated users will need to send some emails. You could work around it by using an authenticated service like Auth0 or AWS Incognito, but let’s assume you’ve set up Remix with a stack that ships with built-in authentication.
Kent C. Dodds' Epic Stack comes pre-wired to use Resend, and that’s a great option; however, my current client already has a large AWS infrastructure setup, so it makes sense to use Amazon’s Simple Email Service (SES).
The Plan
AWS publishes an SES package on NPM that I could use directly, but I’m not going to do that for two reasons:
1. Switching email services later will involve a major re-write
2. It’s hard to use
Nodemailer is the de-facto standard for sending emails from Node, and since Remix is basically React Router Node Edition™️, it will work with Remix. Nodemailer is going to handle all the tricky bits of proper email formatting. Combining Nodemailer with the SES adapter will be easier than making SES work on its own, and if we choose to switch from SES in the future, it will be quick to change.
AWS Credentials
You’ll need to add SESFullAccess permissions to your server’s IAM (Identity and Access Management, how AWS manages permissions). Setting up AWS is outside the scope of this guide. You are here because you have to use AWS, so ask the person who manages AWS for your organization. If you want the less complex mail-sending route, use Resend.
Packages to Install
Nodemailer: nodemailer
With millions of weekly downloads, this is a trusted email service for Node. It will do the heavy lifting of making sure our emails are correctly formatted for SES.
Nodemailer Types: @types/nodemailer
Assuming you are using Typescript. Leave this out if you are using JavaScript.
Client SES: @aws-sdk/client-ses
This package from the AWS SDK will help us tie into SES with minimal headache.
Credential Provider Node: @aws-sdk/credential-provider-node
Amazon has several ways of securely handling credentials, and this package handles all of them. It will look in your Node environment variables as well as other places on your server that your devops team might be looking to hide things. You don’t need this, but it does simplify things by letting your DevOps team select from multiple credential approaches without having to write new code.
npm i nodemailer @types/nodemailer @aws-sdk/client-ses @aws-sdk/credential-provider-node
Tangent: What if your email service isn’t set up yet?
We’re going to build a fake email setup first. If you already have an email service, feel free to skip to the real implementation.
As a React consultant, it's common to have a client who knows what they want to build but doesn’t have a name and domain yet. You can’t send emails without a domain, but you need to send emails because transactional emails are critical for some flows, including authentication. You can’t wait to build authentication, so what do you do?
Remix can interact with a database, and most stacks ship with Prisma, so you’ll need to build out a fake email system. It's not much more than a table that holds a few email fields and a basic page to display all those emails. Build a sendEmail()
function that takes all the arguments you’ll need for the real email service, but until that service is ready, I dump the data into the database. You’ll see it mixed in with my real email-sending function below, but first, here is the fake email system starting with the Prisma schema.
model Email {
id String @id @default(cuid())
to String
from String
subject String
replyTo String?
text String?
html String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Then, you can build your fake email database query, keeping it out of the loader and out of the client.
import { prisma } from "~/db.server";
import type { Email } from "@prisma/client";
export async function getAllMail(): Promise<Array<Email>> {
return await prisma.email.findMany({
orderBy: { createdAt: "desc" },
take: 10, // in dev you probably only need the last few, adjust as necessary
});
}
And now for your fake email page. Style as you see fit.
import { useLoaderData } from "@remix-run/react";
import { getAllMail } from "~/services/mail/getAllMail";
export async function loader() {
const mail = await getAllMail();
return { mail };
}
export default function MailPage() {
const { mail } = useLoaderData<typeof loader>();
return (
<div>
<h1>The Mail</h1>
{mail?.length ? (
mail.map((mail) => (
<div
key={mail.id}
style={{
padding: ".75rem",
border: "1px solid #555555",
}}
>
<ul>
<li><b>To:</b> {mail.to}</li>
<li><b>From:</b> {mail.from}</li>
<li><b>Created at:</b> {mail.createdAt}</li>
<li><b>{mail.subject}</b></li>
</ul>
<div>
{mail.text ? (
<p>{mail.text}</p>
) : (
<div
dangerouslySetInnerHTML={{ __html: mail.html || "" }}
/>
)}
</div>
</div>
))
) : (
<p>Sorry, no mail.</p>
)}
</div>
);
}
Now you have a page to view your fake emails, perfect for development. I like to add links to this page in places where you might need to check an email, such as after you create an account and need to verify your email address by clicking a link in an email.
{process.env.NODE_ENV === "development" && (<Link to="/emails">View dev emails</Link>)"}
Let’s get back to the real email service!
Creating Your Email Method
First, we’ll create a type - you can skip this if you're using JS. You’ll notice this matches the database table from the fake email system (if you chose to build that), but it's also a common type that should work for most email services.
export type EmailType = {
to: string;
from: string;
replyTo?: string;
subject: string;
text?: string;
html?: string;
};
Let’s build out our email-sending method. Your Remix code can end up in your client bundle, and if someone gets your email credentials, they can send spam from your account, which will result in all of your emails getting flagged by your user’s spam filters. Be sure to use server
in the file name, like sendEmail.server.ts
import { prisma } from "~/db.server";
import type { EmailType } from "./types";
import nodemailer from "nodemailer";
let aws = require("@aws-sdk/client-ses");
let { defaultProvider } = require("@aws-sdk/credential-provider-node");
/**
* Places the email into a database table for later review
* Use only for development
*/
async function sendFakeMail(email: EmailType) {
return prisma.fakeEmail.create({
data: email,
});
}
const ses = new aws.SES({
apiVersion: "2010-12-01",
region: "us-east-1",
defaultProvider,
});
// You could instead do something like this and skip the credential library
// const ses = new aws.SES({
// apiVersion: "2010-12-01",
// region: "us-east-1",
// credentials: {
// accessKeyId: process.env.SES_ACCESS_KEY,
// secretAccessKey: process.env.SES_SECRET_ACCESS_KEY,
// },
// });
const transporter = nodemailer.createTransport({
SES: { ses, aws },
});
/**
* Sends a real email using AWS SES
* Use in production
*/
function sendSesMail(email: EmailType) {
transporter.sendMail(
email, // how lucky, our EmailType matches
(error, info) => {
if (error) {
console.error('Error sending email:', error);
} else {
console.log('Email sent:', info.messageId);
}
}
);
};
/**
* Sends an email using whatever service is appropriate for the environment
*/
export async function sendMail(email: EmailType) {
if (process.env.NODE_ENV === "development") {
return await sendDbMail(email);
} else {
return await sendSesMail(email);
}
}
And now go send some emails!
Remember, this is a server-side function. You can’t trigger an email from your React components directly. Instead, use this email-sending method in your Remix loaders and actions. It might look something like this:
import type { ActionArgs } from "@remix-run/node";
import { Form } from "@remix-run/react";
import invariant from "tiny-invariant";
import { createSupportRequest } from "../../services/createSupportRequest";
import { sendMail } from "../../services/sendMail";
export const action = async ({ request }: ActionArgs) => {
const formData = await request.formData();
const category = formData.get("selectCategory");
const email = formData.get("email");
const description = formData.get("description");
invariant(category && typeof category === "string", "Category is required");
invariant(email && typeof email === "string", "Email is required");
invariant(description && typeof description === "string", "Description is required");
await createSupportRequest({ email, category, description });
await sendMail({
to: email,
from: "support@email.com",
subject: `Support request received: ${category}`,
html: `
<p>Thank you for your support request, we will get back to you as soon as possible.</p>
<p>Category: ${category}</p>
<p>Description: ${description}</p>
`,
})
return null;
};
export default function Support() {
return (
<Form method="post">
<div>
<label htmlFor="selectCategory">Select a category</label>
<select id="selectCategory" name="selectCategory">
<option value="account">Account</option>
<option value="billing">Billing</option>
<option value="technical">Technical</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label htmlFor="email">Your email</label>
<textarea id="email" name="email" />
</div>
<div>
<label htmlFor="description">Additional details</label>
<textarea id="description" name="description" />
</div>
<button type="submit">Submit</button>
</Form>
)
}
This is a bit simplified for the post. In the real world, I would most likely put the sendMail
inside createSupportRequest
, as I think it makes sense for the support request creation to own the email. When would you create a support request without sending that confirmation email?
Summary
You now have a Remix app that can send emails through AWS Simple Email Service (SES), and if you didn’t skip the middle part, a dev-friendly solution to sending emails when the organization is not yet ready to send emails!
If you have questions or feedback about this post, share them in our Community Discord. We’re always available to talk through challenges, network with fellow devs, and build community.
Previous Post