@steve31415/resend-mailer
v1.0.0
Published
Resend email client with a D1-backed outbox for durable retry
Downloads
155
Readme
@steve31415/resend-mailer
Transactional email for Cloudflare Workers via the Resend API, with durable retry backed by a D1 outbox table.
A Worker can't sleep for minutes — let alone hours — waiting to retry a failed
send, and waitUntil dies with the invocation. So a failed send is persisted to
a D1 table (email_outbox) and re-attempted by the app's cron. Retry state
survives Worker restarts, deploys, and multi-hour provider outages.
This package consolidates the near-identical Resend wrappers that previously
lived in watchdog, coder, and lurch, and adds the retry layer none of them had.
Install
npm install @steve31415/resend-mailer@steve31415/log-logger-ts is a peer dependency — the package logs through the
Logger you pass in, never to console directly.
API
sendEmail(msg, deps): Promise<SendEmailResult>
interface EmailMessage {
from: string; // e.g. 'Watchdog <[email protected]>'
to: string;
subject: string;
text?: string; // at least one of text / html is required
html?: string;
}
interface MailerDeps {
apiKey: string; // RESEND_API_KEY
logger: Logger; // @steve31415/log-logger-ts
db?: D1DatabaseLike; // enables durable retry
fetchImpl?: typeof fetch; // defaults to global fetch
now?: () => number; // defaults to Date.now
retryDelaysMinutes?: readonly number[]; // defaults to DEFAULT_RETRY_DELAYS_MINUTES
}
interface SendEmailResult {
success: boolean;
emailId?: string; // Resend message id, on success
error?: string;
queued?: boolean; // true when the failure was written to the outbox
}Never throws. Behaviour:
| Situation | Result | Queued? |
| --- | --- | --- |
| Delivered | { success: true, emailId } | — |
| Neither text nor html | { success: false, error }, ERROR log | no |
| apiKey falsy | { success: false, error }, ERROR log | no — a retry can't help |
| Non-2xx from Resend | { success: false, error }, ERROR log | yes, if db given |
| Network error / timeout | { success: false, error }, ERROR log | yes, if db given |
Without db, failures are logged and reported but not retried (queued: false).
processEmailOutbox(deps): Promise<OutboxRunSummary>
deps is the same shape, but db is required. Returns
{ due, sent, requeued, abandoned }. Call it from the app's scheduled()
handler. Never throws.
Per run it selects rows whose next_attempt_at <= now (indexed, capped at 25
rows per run so a large backlog can't blow the Worker's CPU budget — leftovers
go to the next tick) and, for each:
- Claims the row —
UPDATE ... SET next_attempt_at = <next slot> WHERE id = ? AND next_attempt_at = <the value we read>. If zero rows changed, another invocation got there first and the row is skipped. This is what keeps overlapping cron ticks from sending duplicates. - Attempts one send.
- On success: deletes the row, logs INFO.
- On failure: increments
attempts, storeslast_error, and leaves the row claimed for its next slot. When the schedule is exhausted it logs an ERROR containing the full message (recipient, subject, truncated body, attempt count, last error) and deletes the row — that log is the only remaining record of what didn't get delivered.
It logs a one-line INFO summary only when due > 0, so a once-a-minute cron
against an empty outbox stays silent.
If apiKey is falsy while rows are due, the run logs an ERROR and defers every
row untouched rather than burning attempts against a key that isn't there.
EMAIL_OUTBOX_MIGRATION: string
The DDL for the outbox table. The package never runs DDL itself — copy this into a D1 migration in the consuming app:
CREATE TABLE IF NOT EXISTS email_outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at INTEGER NOT NULL,
next_attempt_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 1,
from_addr TEXT NOT NULL,
to_addr TEXT NOT NULL,
subject TEXT NOT NULL,
text_body TEXT,
html_body TEXT,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS idx_email_outbox_next_attempt ON email_outbox(next_attempt_at);Timestamps are epoch milliseconds. attempts counts attempts made, so a
freshly queued row starts at 1 (the original direct send).
DEFAULT_RETRY_DELAYS_MINUTES: readonly number[]
[5, 30, 180, 720, 1440]. Also exported: enqueueEmail (queue a message
without attempting a send first), RESEND_API_URL, and the D1DatabaseLike
type family.
Retry policy
Delay before each retry, measured from the failed attempt:
| Attempt | When |
| --- | --- |
| 1 | immediately (the sendEmail call) |
| 2 | +5 min |
| 3 | +35 min |
| 4 | +3h 35m |
| 5 | +15h 35m |
| 6 | +39h 35m |
Six attempts over ~40 hours, then give up with an ERROR log. Override with
deps.retryDelaysMinutes (its length sets the number of retries).
Actual timing is quantised to the cron interval, and a retry slot is only
approximate: the claim moves next_attempt_at forward at the start of an
attempt, so a slow send shifts the following slot slightly earlier relative to
its own completion.
All failures are retried, including 401/403 and other 4xx. This is
deliberate. Payloads here are programmatic (the app builds from/to/subject
itself), so a permanently-invalid request is rare; the realistic 4xx is an
expired or misconfigured RESEND_API_KEY. The key is re-read from the
environment on every outbox run, so rotating the secret flushes the whole
backlog instead of having already discarded it. The cost of the rare truly
permanent 4xx is six log lines over two days.
Duplicate sends
The design favours "at least once" over "at most once": if a send succeeds but the row delete fails (or the Worker is killed between the two), the message is sent again on a later run. For transactional mail that's the right trade against silently losing it.
Consumer wiring checklist
Install:
npm install @steve31415/resend-mailer.Migrate: add
migrations/NNNN_email_outbox.sqlcontainingEMAIL_OUTBOX_MIGRATION, then apply it locally and remotely (npx wrangler d1 migrations apply <db> --remote).Secret:
npx wrangler secret put RESEND_API_KEY.Send — pass
dbso failures are retried:import { sendEmail } from '@steve31415/resend-mailer'; const result = await sendEmail( { from: 'Watchdog <[email protected]>', to: '[email protected]', subject, html }, { apiKey: env.RESEND_API_KEY, logger, db: env.DB } );Drain — add a cron trigger (
"* * * * *"or"*/5 * * * *") inwrangler.tomland call the processor fromscheduled():import { processEmailOutbox } from '@steve31415/resend-mailer'; async scheduled(event, env, ctx) { await processEmailOutbox({ apiKey: env.RESEND_API_KEY, logger, db: env.DB }); }From address: use the convention
AppName <[email protected]>— e.g.Watchdog <[email protected]>,Coder <[email protected]>. The sending domain must be verified in Resend.
Development
npm test # vitest
npm run build # tsc -> dist/Published via GitHub Actions on a v* tag (npm Trusted Publishing, no tokens).
Source is ESM compiled with moduleResolution: NodeNext, so relative imports
carry explicit .js extensions.
