email-kit
Package @27colab/email-kit · Source on GitHub · MIT · Node 18+ · ESM · zero runtime dependencies
A small toolkit for sending transactional email safely: a provider-agnostic send interface, a Resend adapter, retries, idempotency, suppression-list checks, EN/ES templates, attachment batching, and an “email me this” helper for chat and voice bots.
It is a reference implementation of the boring parts around sending, extracted from patterns used in the author’s own projects and rewritten clean. See the repository’s SCOPE.md for what was and was not carried over.
Install
Section titled “Install”npm install @27colab/email-kitBring your own keys
Section titled “Bring your own keys”A Resend API key (RESEND_API_KEY) and a verified sending domain. Sending from your own domain needs SPF, DKIM and DMARC records; your provider generates the DKIM values (for Resend: Domains in the dashboard). Until a domain is verified, most providers only deliver to the account owner. This library does not manage DNS.
Run it without keys
Section titled “Run it without keys”FakeProvider records an outbox and can fail on demand. This sample sends a localized template through two simulated transient failures, shows dedupe, a suppression refusal and the “email me this” flow.
import { createMailer, MemorySuppressionList, defineTemplate, createEmailMeThis } from '@27colab/email-kit';import { FakeProvider } from '@27colab/email-kit/testing';
const provider = new FakeProvider().failNext(2); // two transient failures, then successconst suppression = new MemorySuppressionList();const mailer = createMailer({ provider, from: 'Acme <hello@example.com>', suppression });
const welcome = defineTemplate<{ name: string }>({ id: 'welcome', defaultLocale: 'en', locales: { en: { subject: 'Welcome', text: 'Hi {{name}}!', html: '<p>Hi {{name}}!</p>' }, es: { subject: 'Bienvenido', text: '¡Hola {{name}}!', html: '<p>¡Hola {{name}}!</p>' }, },});
await mailer.sendTemplate(welcome, 'ana@example.com', { name: 'Ana' }, { locale: 'es-MX' }); // falls back to "es"console.log(provider.calls, 'provider calls;', provider.outbox[0]?.message.subject);
// Same content to the same recipient dedupes by default.await mailer.sendTemplate(welcome, 'ana@example.com', { name: 'Ana' }, { locale: 'es-MX' });console.log('emails recorded:', provider.outbox.length);
// Suppressed recipients are refused before the provider is called.await suppression.add('stop@example.com', 'unsubscribed');await mailer.send({ to: 'stop@example.com', subject: 's', text: 't' }).catch((e) => console.log('refused:', e.reason));
// "Email me this" for chat and voice bots.const emailMeThis = createEmailMeThis(mailer, { locale: 'en' });const msg = { address: 'jane dot doe at example dot com', subject: 'Your notes', text: 'Here they are.', requestId: 'call-42-turn-3' };console.log(await emailMeThis({ ...msg, confirmed: false })); // read it back firstconsole.log((await emailMeThis({ ...msg, confirmed: true })).status);console.log((await emailMeThis({ ...msg, confirmed: true })).status, 'again; outbox:', provider.outbox.length);3 provider calls; Bienvenidoemails recorded: 1refused: suppressed{ status: 'needs-confirmation', readBack: 'j a n e dot d o e at e x a m p l e dot c o m'}sentsent again; outbox: 2The two simulated failures plus the successful attempt make three provider calls but only one recorded email, and the repeated send did not add another.
With Resend
Section titled “With Resend”Needs a key. Typechecked, not executed.
// Typechecked, not run: real provider, attachments, unsubscribe headers.import { batchAttachments, createMailer, ResendProvider, unsubscribeHeaders, type Attachment } from '@27colab/email-kit';
const mailer = createMailer({ provider: new ResendProvider({ apiKey: process.env.RESEND_API_KEY! }), from: 'Acme <hello@example.com>',});
export async function sendReport(to: string, files: Attachment[]) { const { batches, oversized } = batchAttachments(files, { maxBytes: 15 * 1024 * 1024, maxCount: 10 }); for (const batch of batches) await mailer.send({ to, subject: 'Your files', text: 'Attached.', attachments: batch }); return oversized; // too big on their own: send these as links}
export const headers = unsubscribeHeaders({ httpsUrl: 'https://example.com/unsubscribe/abc' });What send does, in order
Section titled “What send does, in order”- Validates the address and that there is content.
- Refuses if the recipient is on the suppression list (
EmailRefusedError, reasonsuppressed). Bypass is explicit per call. - Derives (or accepts) an idempotency key and returns the earlier result if one exists; the key is also sent to the provider (
Idempotency-Keyfor Resend). - Calls the provider with retry: exponential backoff with jitter,
Retry-Afterhonored, only for errors flaggedretryable(Resend: network errors, 429, 5xx).
The default idempotency store is in-memory, so it dedupes within one process. Pass your own IdempotencyStore (Redis, a database table) to dedupe across restarts. Identical content to the same recipient dedupes by default; pass idempotencyScope (such as an order id) when you legitimately resend the same text.
“Email me this” for bots
Section titled ““Email me this” for bots”createEmailMeThis(mailer) returns a function that sends only to the address the user gave for themselves, only after a confirmed read-back, and at most once per requestId. parseSpokenEmail understands “dot / at / dash / underscore / plus” and Spanish “punto / arroba / guion / guion bajo / más”, and returns undefined for anything invalid so you re-prompt rather than guess.
API overview
Section titled “API overview”| Export | Purpose |
|---|---|
createMailer({ provider, from, suppression?, idempotencyStore?, retry? }) | send(message, options?), sendTemplate(template, to, vars, { locale }) |
ResendProvider({ apiKey }), EmailProvider | The one real provider; the interface for others |
defineTemplate, renderTemplate, resolveLocale | Typed templates with EN/ES (any locale) and safe HTML escaping; es-MX falls back to es |
MemorySuppressionList, SuppressionList, unsubscribeHeaders | Suppression and List-Unsubscribe headers |
deriveIdempotencyKey, MemoryIdempotencyStore, IdempotencyStore | Idempotency |
withRetry, isRetryable | Backoff helpers |
batchAttachments(files, { maxBytes, maxCount }) | Returns { batches, oversized }; oversized files are returned, never silently dropped |
parseSpokenEmail, spellOut, createEmailMeThis | Bot helpers |
FakeProvider (@27colab/email-kit/testing) | In-memory provider: outbox, failNext(n), calls |
You host the unsubscribe endpoint and call suppression.add(address, 'unsubscribed') from it.
Limits
Section titled “Limits”- In-memory defaults. Suppression and idempotency are per-process until you supply your own stores.
- One real provider, with message shapes following Resend’s public documentation at the time of writing; it is not exercised against the live API in CI.
- Pre-1.0 and unaudited. Review it before you rely on it.
- No deliverability management. DNS records, warm-up and reputation are yours.