npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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:

  1. 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.
  2. Attempts one send.
  3. On success: deletes the row, logs INFO.
  4. On failure: increments attempts, stores last_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

  1. Install: npm install @steve31415/resend-mailer.

  2. Migrate: add migrations/NNNN_email_outbox.sql containing EMAIL_OUTBOX_MIGRATION, then apply it locally and remotely (npx wrangler d1 migrations apply <db> --remote).

  3. Secret: npx wrangler secret put RESEND_API_KEY.

  4. Send — pass db so 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 }
    );
  5. Drain — add a cron trigger ("* * * * *" or "*/5 * * * *") in wrangler.toml and call the processor from scheduled():

    import { processEmailOutbox } from '@steve31415/resend-mailer';
    
    async scheduled(event, env, ctx) {
      await processEmailOutbox({ apiKey: env.RESEND_API_KEY, logger, db: env.DB });
    }
  6. 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.