@saastemly/better-email
v0.1.0
Published
One email transport for Better Auth and its plugins — ready-made callbacks, shared wording, and a log of what was sent.
Maintainers
Readme
better-email
One email transport for Better Auth and its plugins — ready-made callbacks, shared wording, and a record of what was sent.
Why
Better Auth has no global email setting, by design: every feature takes its own callback and you write the function.
emailVerification: { sendVerificationEmail: … }
magicLink({ sendMagicLink: … })
emailOTP({ sendVerificationOTP: … })That is right for a library and awkward for an app, which ends up with the same API key closed over in five places, five slightly different HTML strings, a sign-in email still saying "Acme" three releases after the rename, and no single answer to did the magic link actually go out?
const emailer = createEmailer({
appName: "Deki",
from: "Deki <[email protected]>",
locale: "da",
transport: apiKey ? resendTransport({ apiKey }) : smtpTransport(), // MailDev
onSent: logSends(async () => (await getCurrentAuthContext()).context.adapter),
});
magicLink({ sendMagicLink: emailer.auth.sendMagicLink })
emailOTP({ sendVerificationOTP: emailer.auth.sendVerificationOTP })
email({ emailer }) // the plugin: a log, and a test-send endpointemailer.send() is there for everything else an app sends, so order
confirmations go out with the same sender and land in the same log.
Transports
| | for | delivery |
|---|---|---|
| resendTransport | production | live |
| smtpTransport | MailDev / MailHog / Mailpit on localhost:1025 | reported as not delivery |
| consoleTransport | no dependencies at all | reported as not delivery |
smtpTransport speaks the plain-text subset of SMTP with no TLS and no AUTH,
because that is exactly what a development catcher listens for. It refuses a
non-loopback host unless you pass allowInsecure, so a typo in a hostname
cannot put a message on the wire in the clear. It works in a Worker
(cloudflare:sockets) and in Node/Bun (node:net).
The decisions worth knowing
- An auth email never throws. Better Auth's own guidance is not to await these, so a slow provider cannot be timed. Throwing is worse than slow: it turns a mail outage into a failed sign-in, and an address that errors while another does not tells an attacker which accounts exist. Transports return failures; they do not raise them.
- A recorder is never reported as delivery. MailDev's "sent" means "caught
here".
transport: "none"marks that, and/email/testreportsdelivered: falsefor it — a test message that lied about this would be worse than no test message. - Bodies are not stored. A magic link in a log is a credential sitting in a table more people can read than can read the inbox it was sent to. The log keeps recipient, subject, kind, provider id and the provider's own error.
- A failing log never fails a send. A database that is down should not turn a delivered magic link into an error the customer sees.
- HTML derived from text is escaped, so a name cannot inject markup.
The log is the point
When a customer says the confirmation never arrived, the question is whether the provider accepted it. Without a log the only answer is a shrug and a resend. With one it is a row carrying the provider's own words — "The from address is not verified" is a five-minute fix or an unanswerable mystery, depending only on whether anybody wrote it down.
GET /email/log?status=failed
POST /email/test { to }