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

@codefusion-cc/mail

v0.1.3

Published

Mail for an app on Cloudflare Workers and D1: the provider contract with header-safe addresses and subjects, an outbox that survives failures (rows in D1, retries with growing pauses, an idempotency key per mail, a failed state for a person, runs that can

Readme

@codefusion-cc/mail

Mail for an app on Cloudflare Workers and D1, taken out of dieselpaul so every app sends mail through the same tested code: the provider contract, an outbox that survives failures, a mailer that sends only where mail is meant to go out, and a template. It is not commerce: any app with a D1 database can use it. It has no dependencies.

npm install @codefusion-cc/mail

| Entry | Runs in | Holds | | --- | --- | --- | | @codefusion-cc/mail | Workers | Mail, the checks that keep input out of headers, MailProvider, createMailer, createMailOutbox, renderMail | | @codefusion-cc/mail/resend | Workers | resendProvider | | @codefusion-cc/mail/migrations | Node | syncMigrations, behind the codefusion-mail-migrations command | | @codefusion-cc/mail/testing | tests | fakeMailProvider, fakeResend, fakeMailContext |

Nothing in the root knows a provider; each provider is an entry of its own, so a Worker bundles only the one it imports. The package holds no words for people: failures carry a code and the provider's own answer, and the app words them (createMailer's wording); the template renders only what it is given.

A mail, and what cannot get into its headers

import { prepareMail, isMailFailure, type Mail } from '@codefusion-cc/mail'

const mail: Mail = { to: 'Jan Kowalski <[email protected]>', subject: 'Order 42', text: 'Thank you.\n', html, replyTo: '[email protected]' }

What a visitor typed ends up in a subject, a name or a reply address. Every provider here passes a mail through prepareMail first:

  • Recipient and reply address must be one address ([email protected] or Name <[email protected]>). A line break or any other control character, a second address, or more than 254 characters: the mail fails as invalid, no request is made, and it is never retried.
  • Subject: control characters (line breaks among them) become spaces, and it is cut at 998 characters (SUBJECT_MAX) without halving a character. The mail still goes out.
  • Sender: config.from (an address, or Shop <[email protected]>), config.name and config.replyTo of the integration. A name is quoted when it holds anything but letters, digits and spaces; a line break in it becomes a space. A sender address that is not an address fails as unconfigured, which is retried: the owner corrects it and the mail goes out.
  • Idempotency key (Mail.key): 1 to 256 visible ASCII characters, since it travels in an HTTP header.

Text in any script (Polish, Hebrew, Arabic, emoji) passes unchanged.

Providers

import { resendProvider } from '@codefusion-cc/mail/resend'

const failure = await resendProvider.send({ config: { from: '[email protected]', name: 'Shop' }, secrets: { apiKey }, fetch }, mail)

A provider never throws for what it can meet at run time. It answers null when the provider took the mail, else a MailFailure:

| code | Means | retry | | --- | --- | --- | | invalid | the mail's own data (an address, the key); no request was made | no | | unconfigured | no provider, or no usable sender address | yes | | unauthorized | the provider refused the key (401) | yes | | rejected | the provider refused this mail (400, 422) | no | | provider | any other answer that is not a success (403, 429, 5xx…) | yes | | timeout | no answer within 30 s (MAIL_TIMEOUT_MS), the body included | yes | | unreachable | no connection | yes |

Everything but the mail's own data is retried, because the owner or the provider puts it right (a key, an unverified domain, a limit, an outage) and the mail then goes out. After timeout and unreachable the provider may have sent the mail all the same, which is what the idempotency key is for.

Resend (secrets.apiKey): POST /emails, batches of 100 through POST /emails/batch. Mail.key goes out in Idempotency-Key with a digest of the request's body (outbox-12-20261010093000-<sha256>). Resend keeps a key for 24 hours, and the same request again within them sends nothing new. The same key with another body would be 409 invalid_idempotent_request, and Resend's documentation does not say whether a request it refused keeps its key, so that 409 cannot be read as "sent". With the digest a changed body (a sender put right after a 403, a new footer) is a new request, and every 409 is a failure to try again. Two things follow:

  • A mail whose body changes between two attempts goes out twice when the first attempt's answer was lost. Keep what prepare and the mailer's html add the same from one attempt to the next (a date, not a time).
  • If Resend does keep a refused request's key, it may answer the unchanged mail with that refusal for a day, and the mail then goes out with the last attempt (some 44 hours after the first) and not with the next. The outbox lists it as retrying, with the refusal, and alerts after three attempts. test/resend.live.test.ts asks the real API which it is (two mails at most, to Resend's own test address, only with RESEND_LIVE_KEY and RESEND_LIVE_FROM set).

A batch whose mails all have keys carries one key made from theirs and its body.

A provider of your own implements MailProvider with prepareMail, providerCall (the request with the time limit, never throwing; a header no request can carry, such as a key saved with a line break, is refused before any fetch could repeat it in an error) and httpFailure:

export const myProvider: MailProvider = {
  name: 'My provider',
  send: async (ctx, mail) => {
    const ready = prepareMail(ctx, mail)
    if (isMailFailure(ready)) return ready
    const answer = await providerCall<{ message?: string }>(ctx, 'My provider', 'https://api.example/send', { method: 'POST', body: JSON.stringify({ from: ready.from, to: ready.to, subject: ready.subject, text: ready.text, html: ready.html }) })
    if (isMailFailure(answer)) return answer
    return answer.res.ok ? null : httpFailure('My provider', answer.res.status, answer.data?.message)
  },
}

ctx is { config, secrets, fetch }; a plugin's context from @codefusion-cc/commerce is one, so a shop hands it over as it is and keeps the provider's key sealed in its integrations table.

The mailer

const mailer = createMailer({
  live: env.APP_ENV === 'production', // a preview or a local copy never sends: its mails only reach `log`
  required: env.APP_ENV === 'production', // here a missing provider is a failure to retry, not a logged mail
  transport: async () => ({ provider: resendProvider, ctx, id: 'resend' }), // read once, at the first mail
  html: mail => renderMail(theme, { subject: mail.subject, blocks: textBlocks(mail.text) }).html, // for a mail written as text
  log: mail => console.info(`[mail] to ${maskEmail(mail.to)} | ${mail.subject}`), // never the text in production: it has links with tokens
  wording: failure => words(failure), // the app's language, for the panel and the outbox's last error
  onFailure: (failure, transport, what) => report(failure, transport, what),
})

await mailer.attempt(mail) // null, or the failure; never throws
await mailer.send(mail) // the failure goes to onFailure only
await mailer.sendMany(mails) // the indexes worth sending again later

sendMany uses the provider's batches, else six mails at a time and at most thirty per invocation (a Worker's connection and request limits); after a failure worth a retry the rest waits with it.

The outbox

A mail is a row of mail_outbox, written in the same db.batch as what it is about, so when the order is in the database its mail is too. It is sent at once, after the answer to the customer, and a failed one is tried again by the scheduled run.

const outbox = createMailOutbox({ db: env.DB, send: mailer.attempt, onAlert: mails => tellOwner(env, mails) })

const results = await env.DB.batch([insertOrder, outbox.enqueue({ ...rendered, to: order.email, kind: 'order' }, { fill: { sql: 'SELECT number FROM orders WHERE token = ?', params: [token] } })])
ctx.waitUntil(outbox.deliver(queuedIds(results, [1])))

// scheduled: every two minutes, and hourly
await outbox.run()
await outbox.prune()

| What holds | How | Tested by | | --- | --- | --- | | A mail is not lost with the Worker | the row is in the batch; a batch that fails leaves neither order nor mail | a failing batch; a mail sent by a later instance | | Retries with growing pauses | 5 min, 30 min, 2 h, 6 h, 12 h, 23 h (OUTBOX_RETRY_MINUTES), seven attempts in all; no pause as long as the day Resend keeps a key | each pause read from the row; not sent before it is due; every pause against the 24 hours | | A mail that fails for ever stops | after the last attempt, or at once when the failure is not worth a retry, it is failed: kept with its content for a person, alerted once (onAlert, after 3 attempts) | seven failed attempts; a refused mail; the alert counted per attempt | | A repeated request sends one mail | every attempt carries the same Mail.key (outbox-<id>-<queued at>), so the provider sends the same mail once | a mail sent but answered too late, tried again: delivered once; a Worker killed after the provider took the mail and before that was saved | | A repeated request queues one mail | enqueue(mail, { key }): a key already in the outbox queues nothing | the same key in one batch, two batches, five at once | | Two runs never send a mail twice | a run takes mails by one UPDATE … RETURNING that counts the attempt, stamps its claim and leases them for 10 minutes | two runs at once over 25 mails; two calls for one mail in flight | | “Send again” clicked twice sends once | resend(id) starts the attempts over and takes the mail in the same UPDATE … RETURNING; other clicks and the scheduled run get nothing, and only the call that sent the mail answers sent | 2, 3 and 10 clicks whose statements interleave, and as they come; a click against the scheduled run; clicks while the first is at the provider; a click that lost to one that failed | | An old attempt cannot undo a newer one | a failure is saved only by the run that still holds the claim; a success always | late failure after a newer success; late success before a newer failure | | A Worker that died in the last attempt | the scheduled run fails the mail with an alert instead of sending it again | attempts used up and lease over; lease still running | | A run is bounded | 10 due mails per run (OUTBOX_PER_RUN), 30 of the ones just queued (IMMEDIATE_MAX) | 25 due mails; 35 queued at once | | A sent mail keeps no content | text and HTML are emptied (links with access tokens); deleted after 30 days, failed ones after 90 | the row after sending; the cleanup at both sides of each limit |

Also: queue(mails) for mails that belong to no batch (it sends them directly when the queue cannot be written), queueAfter(claim, mail, rest) for a mail behind a conditional write (a reminder's mark), prepare for what depends on the moment of sending, and for the panel list(), resend(id) and remove(id), which refuse a mail a run is sending. list() gives the mails waiting for another attempt (retrying), the failed ones (failed) and the ones no run has tried yet (waiting): a mail is there for a moment after it was queued, and one that stays says the scheduled run does not reach the queue. The slot ({{ref}}, or the app's own) in a queued mail is replaced by its reference, which fill reads in the batch: the number a new order only gets there. In the HTML the reference is text: one that holds < or & is no markup there.

A row holds at most 2 MB in D1, text and HTML together, and a mail the database refuses fails the whole batch: the order with it. outboxFits(mail) says beforehand whether a mail fits (OUTBOX_MAIL_MAX_BYTES, 1.5 MB of UTF-8), for a mail that carries a text whose length the app does not decide. There are no attachments: an order confirmation carries its terms and the withdrawal form in its body, which every provider delivers and every mail program shows.

Migration

The table comes with the package, the way @codefusion-cc/commerce ships its own:

// package.json
"scripts": { "postinstall": "codefusion-mail-migrations" } // after codefusion-commerce-migrations, in a shop

// wrangler.jsonc, the D1 binding
"migrations_dir": "migrations",
"migrations_pattern": "migrations/**/*.sql"
migrations/0000_mail/

codefusion-mail-migrations copies the package's migrations into migrations/0000_mail after every install (--check fails when the copy differs), where Wrangler applies them before the app's own. A migration only adds, with IF NOT EXISTS, so an app that made mail_outbox itself (dieselpaul) keeps it with every row; its own migration that made it must say IF NOT EXISTS too, and must no longer add the claim column, since on a new database the package's migration runs first.

The template

const { subject, text, html } = renderMail(
  { name: 'Shop', site: 'https://shop.example', logo: 'https://shop.example/icons/logo.png', footer: ['Shop sp. z o.o.', 'ul. Długa 1, 00-001 Warszawa'], color: '#d9192a', lang: 'pl' },
  { subject: 'Order 42', blocks: [{ text: 'Thank you.' }, { table: [['Item × 2', '30,00 zł']], total: ['Total', '30,00 zł'] }, { button: 'See the order', url }, { document: terms }] },
)

Text and HTML come from the same blocks: text, heading, table, button, box, small and document (a longer text the app parsed from its own format into paragraphs, headings and lists). Everything is escaped; an http(s) address in a text, a box or small becomes a link unless the block says links: false, which is for what a visitor typed (a note, a name), so the app's mail makes no link of a stranger's choosing (a mail program may still make one of a bare address by itself); a button or a document's link goes only to http(s), mailto:, tel: or a path of the app; a theme's colour, language and logo are left out when they are not what they should be. No outside resource but the logo, styles in attributes.

A theme can carry the app's own look instead of the template's grey: palette (the nine colours of a mail: page, surface, raised, ink, strong, muted, line, accent, onAccent), fonts (body, and display for the name and headings; the mail loads no typeface, so each is a list that ends with one every device has) and radius. With dark, a second palette, the mail says it has both looks and carries one small style sheet that recolours it where the reader's program is dark (Apple Mail, Outlook on the web and on a Mac); the attributes keep the light look for every program that drops style sheets. Gmail follows neither: it recolours a mail by itself.

const theme: MailTheme = {
  name: 'Shop',
  site: 'https://shop.example',
  palette: { page: '#f4eee2', surface: '#fbf8f1', raised: '#ece3d1', ink: '#1c1712', strong: '#120e0a', muted: '#6b5f4f', line: '#d9ccb5', accent: '#946512', onAccent: '#fff8e8' },
  dark: { page: '#0c0a08', surface: '#15120e', raised: '#201a13', ink: '#f1e8d8', strong: '#fffaf0', muted: '#a8998a', line: '#31291f', accent: '#e0ad4f', onAccent: '#1a1206' },
  fonts: { body: "Inter, system-ui, -apple-system, 'Segoe UI', sans-serif", display: "Georgia, 'Times New Roman', serif" },
  radius: 6,
}

A word longer than the mail is wide (an account number, a name without spaces) breaks, so a phone shows the whole mail; only a table's amounts stay on one line. The text version ends with the footer's lines and the site under --, and with the last block when the theme has neither.

Testing

import { fakeMailContext, fakeMailProvider, fakeResend } from '@codefusion-cc/mail/testing'

const fake = fakeMailProvider()
fake.failNext() // the next request fails, worth a retry
const hold = fake.hold() // the next request waits for hold.release()
fake.requests // what a real provider would have been asked
fake.delivered // what a customer would have got: one per idempotency key

const resend = fakeResend() // Resend's API as a fetch: 401, 403, 422, idempotency keys, answers on demand, 'drop', 'hang'
const strict = fakeResend({ refusedKeepsKey: true }) // the same, and a request it refused keeps its key, which Resend's documentation leaves open

A shop sends an order confirmation

What a shop on @codefusion-cc/commerce does, checked against bigusson's Worker (worker/env.ts, worker/integrations.ts, wrangler.jsonc). The words of the mail are the shop's own.

  1. Install and migrate. npm install @codefusion-cc/mail; "postinstall": "codefusion-commerce-migrations && codefusion-mail-migrations"; add migrations/0000_mail/ to .gitignore (the D1 binding already has migrations_pattern). The deploy applies the migration with the others.

  2. Configuration. In worker/env.ts: APP_ENV (a var: production on the real shop only), SITE_URL, and until the panel saves integrations, MAIL_FROM (a var: Shop <[email protected]>) and RESEND_API_KEY (a secret). Once the panel exists, the key is the resend integration's sealed secret and the sender its from, name and replyTo fields.

  3. worker/mail.ts, the shop's one way to send:

    import { createMailOutbox, createMailer, maskEmail, queuedIds, renderMail, textBlocks, type MailFailure, type MailTheme } from '@codefusion-cc/mail'
    import { resendProvider } from '@codefusion-cc/mail/resend'
    import { integrationStore } from './integrations'
    
    const theme = (env: Env): MailTheme => ({ name: 'Shop', site: env.SITE_URL, logo: `${env.SITE_URL}/icons/logo.png`, footer: sellerLines, color: '#111111', lang: 'pl' })
    
    export function mailer(env: Env) {
      const production = env.APP_ENV === 'production'
      return createMailer({
        live: production,
        required: production,
        // The integration the owner turned on, else the Worker's own secret.
        transport: async () => {
          const ctx = await integrationStore.enabledContext(env, env.SITE_URL, 'resend')
          if (ctx) return { provider: resendProvider, ctx, id: 'resend' }
          if (!env.RESEND_API_KEY || !env.MAIL_FROM) return null
          return { provider: resendProvider, ctx: { config: { from: env.MAIL_FROM }, secrets: { apiKey: env.RESEND_API_KEY }, fetch: (url, init) => fetch(url, init) }, id: null }
        },
        html: mail => renderMail(theme(env), { subject: mail.subject, blocks: textBlocks(mail.text) }).html,
        log: mail => console.info(production ? `[mail] ${maskEmail(mail.to)} | ${mail.subject}` : `[mail] ${mail.to} | ${mail.subject}\n${mail.text}`),
        wording: (failure: MailFailure) => words(failure),
        onFailure: (failure, transport) => (transport.id && failure.retry ? integrationStore.recordError(env, transport.id, new Error(failure.message)) : undefined),
      })
    }
    
    export const outbox = (env: Env) => createMailOutbox({ db: env.DB, send: mailer(env).attempt, onAlert: mails => tellOwner(env, mails) })
  4. The order. createOrder's extend adds the mail to the order's own batch. orderConfirmation of @codefusion-cc/commerce lays the order out (the lines to the total, what the customer typed, the seller, the documents) in the words the shop passes for the language of the checkout:

    import { OUTBOX_SLOT, enqueueMail, outboxFits, queuedIds, renderMail } from '@codefusion-cc/mail'
    import { createOrder, orderConfirmation, orderMailOf } from '@codefusion-cc/commerce'
    
    const result = await createOrder(env.DB, body, {
      ...options,
      extend: draft => {
        const own = { columns: { inpost_point: point } } // the shop's own part, as before
        // A shop that does not require an address gets no mail for an order without one: the outbox would hold a mail to nobody.
        if (draft.customer.email === '') return own
        // The number is the database's, in this batch only: the slot stands for it until then.
        const order = orderMailOf(draft, own, OUTBOX_SLOT)
        const mail = (terms: 'text' | 'link') => ({ to: draft.customer.email, replyTo: shopEmail, kind: 'order', ...renderMail(theme(env, lang), orderConfirmation(order, orderWords(lang, draft, terms))) })
        const full = mail('text')
        return { ...own, statements: [enqueueMail(env.DB, outboxFits(full) ? full : mail('link'), { fill: draft.number })] }
      },
    })
    if (result.status === 'created') ctx.waitUntil(outbox(env).deliver(queuedIds(result.results, [0])))

    extend runs again when a product changed under the order, so it only reads. The mail is queued exactly when the order is made: a refused, repeated or failed order queues none, and a mail the outbox refuses undoes the order. The confirmation is the durable medium the law asks for: the shop's words carry the right of withdrawal, the full terms (documents, as { document } blocks) and the withdrawal form, so they stay with the customer when the shop's page changes. A link in their place is for terms too long for a row only.

  5. The scheduled run. "triggers": { "crons": ["*/2 * * * *", "17 * * * *"] } in wrangler.jsonc, and in worker/index.ts:

    async scheduled(event, env, ctx) {
      ctx.waitUntil(event.cron === '17 * * * *' ? outbox(env).prune() : outbox(env).run())
    }
  6. Tests. fakeResend() behind the Worker's fetch in tests; assert on delivered.

What the owner provides

  • A Resend account (its free plan covers a small shop) and the sending domain added in it: Domains → Add Domain, region Ireland (eu-west-1). The region cannot be changed later without adding the domain again.

  • These DNS records in the domain's zone, with the values Resend shows for the domain (in Cloudflare, Resend's Sign in to Cloudflare button adds them):

    | Type | Name | Value | Priority | | --- | --- | --- | --- | | MX | send | feedback-smtp.eu-west-1.amazonses.com | 10 | | TXT | send | v=spf1 include:amazonses.com ~all | | | TXT | resend._domainkey | p=… (the DKIM key Resend shows; DNS only, not proxied) | | | TXT | _dmarc | v=DMARC1; p=none; (recommended; leave an existing one as it is) | |

    They sit on the send subdomain and on resend._domainkey, so the domain's own MX and SPF records, and the mailbox behind them, stay as they are.

  • An API key: API Keys → Create API Key, permission Sending access, limited to that domain. It goes into the Worker's secret RESEND_API_KEY, or into the resend integration in the panel; never into a file or a chat.

  • The sender (no-reply@<domain> with the shop's name) and the address answers go to.