Transactional email setup: SES sandbox, SPF and DKIM
A transactional email setup checklist: the SES sandbox, Microsoft 365 and Gmail SMTP traps, SPF, DKIM and DMARC, and why a failed email must never fail signup.
By Hamza Ghouri
Transactional email is the mail your app sends because a user did something: a welcome message, a verification code, a password reset. It looks like the easy part of a backend, and it fails in ways that are hard to see from a developer's laptop. This transactional email setup checklist comes from wiring email into a Node backend and writing down the steps for the next project: provider choice, the AWS SES sandbox, the SMTP traps in Microsoft 365 and Google Workspace, DNS records, and the bug that turned one missing template into a broken signup.
Pick the provider first, because every value differs
The backends I work on use one of two shapes: nodemailer over SMTP, or SendGrid's API. So the first question when setting up email is which provider, because the host, the port, the credentials and the DNS records all depend on the answer. Asking for an API key before knowing the provider wastes everyone's time.
Each provider then has its own trap:
- SendGrid shows an API key once, when you create it. Copy it into your secret store straight away or create a new one.
- Microsoft 365 has SMTP AUTH turned off by default per mailbox. And where basic authentication is disabled for the whole tenant, SMTP with a password cannot work at all, whatever you set on the mailbox. Microsoft's guide to authenticated client SMTP submission covers both settings, including that security defaults turn SMTP AUTH off.
- Google Workspace needs an app password for SMTP, and an app password needs 2-Step Verification turned on first.
None of these show up as a clear error in your code. They show up as an authentication failure that looks like a wrong password.
Why does Amazon SES work in testing but not for real users?
New SES accounts start in a sandbox. In the sandbox you can only send to verified email addresses and domains, so every test to your own verified inbox works and every message to a real user fails. The SES production access guide also lists a limit of 200 messages per 24 hours and 1 per second in the sandbox. Request production access before launch, and budget about a day for it.
That is the definition of a silent failure: sending appears to work in testing and fails for real users. My rule is that no app goes live on SES until the account is out of the sandbox, and the last test before launch is a signup with an address nobody on the team has verified.
SPF, DKIM and DMARC on every sending domain
Whatever the provider, SPF, DKIM and DMARC have to be in place on the sending domain, or mail lands in spam. They do different jobs:
- SPF lists the servers allowed to send mail for your domain.
- DKIM signs each message, so the receiver can check it came from you and was not changed on the way.
- DMARC tells receivers what to do when SPF or DKIM fails, and where to send reports.
Every provider gives you the exact records to add. Add all of them before the first real email goes out, then send a test message to a mailbox you control and check the headers for passing results. A verification code that lands in spam is, from the user's side, a verification code that never arrived.
Keys per environment, and a sender address that exists
Two smaller rules I follow on every setup:
- Restrict keys and split them by environment. Where the provider allows it, the key can only send mail, nothing else. Staging and production get separate keys, so a leaked staging key can be revoked without taking production email down with it.
- Never invent a sender address. A made-up
noreply@address bounces every reply and damages the domain's reputation. Use a real mailbox on the domain, or ask which address the business wants replies to go to.
The same thinking about keeping test credentials away from production is in keeping test API keys out of production builds.
The template that never reached dist
The first email the backend tried to send threw ENOENT, and POST /auth/register returned 500. The templates were Handlebars files next to the TypeScript source. tsc compiles .ts files and copies nothing else, so the templates folder never existed in dist.
The fix is to copy non-TypeScript assets as part of the build. In package.json that can be as plain as:
{
"scripts": {
"build": "tsc && cp -R src/templates dist/templates"
}
}
The templates themselves are kebab-case .hbs files on a shared layout partial: welcome, a verification code, forgot-password, and password-changed. A shared layout keeps the branding in one place, and the HTML rendering quirks I covered in HTML email gotchas in Gmail only need fixing once.
A failed email must not fail the request
The missing template exposed a worse problem. The 500 came after the user row was written. So the account existed, no code was sent, and the user saw an error for a signup that had half happened.
The rule I took from it: a failed email must not fail the request that triggered it. Save the user, respond, and treat the email as a separate step that can fail on its own and be retried. In Express, the shape looks like this, with illustrative function names:
router.post("/auth/register", async (req, res, next) => {
try {
const user = await createUser(req.body);
res.status(201).json({ id: user.id, verificationRequired: true });
sendVerificationCode(user).catch((err) =>
logger.error({ err, userId: user.id }, "verification email failed"),
);
} catch (err) {
next(err);
}
});
Pair that with a resend endpoint, so a user whose code never arrived can ask for another one, and with logs that make every failed send visible. It is the same pattern as replying first and fanning out later: the response depends on the work the user asked for, and the follow-up work gets its own error handling. For anything beyond one message per request, I'd move sending to a queue with retries, as in the abandoned cart reminder setup.
Where I'd start
- Decide the provider first, then collect only the values that provider needs.
- On SES, request production access before launch, and test with an address nobody has verified.
- Add SPF, DKIM and DMARC for the sending domain and check the headers of a real message.
- Use send-only keys, one per environment, and a real sender address.
- Make sure templates are copied into the build output.
- Never let an email failure turn a successful action into an error response.
If you need email wired into a backend this way, it is the kind of work I take on through my services.