Nodemailer is not the problem
It composes correct MIME, handles attachments and encodings properly, speaks SMTP well, and has done so reliably for over a decade. Nothing here is a criticism of the library.
The problem is the shape of the code almost everyone writes around it, which works perfectly until it is carrying something that matters.
// The version in every tutorial, and in a lot of production code
app.post('/signup', async (req, res) => {
const user = await createUser(req.body);
await transporter.sendMail({
to: user.email,
subject: 'Welcome',
html: render(user),
});
res.json({ ok: true });
});Failure one — the send is on the request path
That await ties your signup latency to a third party. When the SMTP provider has a slow minute, your signup endpoint has a slow minute. When it times out, the user sees a failed signup for an account that was already created.
Worse, the retry is now the user's finger. They press the button again, and you have two accounts or two emails.
Enqueue instead. The API accepts the message, writes a row, returns an ID, and a worker does the sending. Your endpoint should never wait on someone else's network.
Failure two — no idempotency
Anything can be delivered twice: a retried HTTP request, a redelivered queue message, a worker killed between sending and marking done. Without an idempotency key, "at least once" delivery means your customer gets two receipts.
Generate a key per logical message, store it with a unique constraint in your database, and return the original result on a repeat. It must be a durable constraint, not a cache entry — an eviction at the wrong moment sends the password reset twice.
await sada.emails.send(
{ to: user.email, subject: 'Welcome', html },
{ idempotencyKey: `welcome:${user.id}` },
);Failure three — nothing suppresses
A hard bounce means the address does not exist. If your code keeps sending to it — because a nightly job iterates all users — your bounce rate climbs, and bounce rate is one of the two numbers that gets an account throttled or suspended.
You need an account-wide suppression list, written automatically on hard bounce, complaint and unsubscribe, and checked before a message is accepted rather than at send time. Failing fast and loudly is the point: a silent skip hides the problem.
Failure four — retries that do more harm than good
A naive retry loop treats every error the same. It should not. A 4xx from the provider is usually permanent — retrying a malformed address forever accomplishes nothing. A 5xx or a 429 is worth retrying with exponential backoff, respecting Retry-After.
And a retry must never re-run the composition step non-deterministically. If your template embeds a timestamp or a fresh token, retrying produces a different message, and your idempotency key no longer describes what you sent.
A note on Bun
Nodemailer works under Bun, and so does most of the surrounding ecosystem. The parts worth verifying yourself before committing are the ones leaning hardest on Node built-ins: an SMTP listener needs node:net and node:tls, and DKIM signing needs RSA-SHA256 through node:crypto.
That is an afternoon of spiking, and it is much cheaper than discovering an edge case inside signature generation after launch.
What the fixed version looks like
app.post('/signup', async (req, res) => {
const user = await createUser(req.body);
// Returns in milliseconds. Delivery happens on a worker.
const { id } = await sada.emails.send({
to: user.email,
template: 'welcome',
variables: { name: user.name },
}, { idempotencyKey: `welcome:${user.id}` });
res.json({ ok: true, messageId: id });
});