Skip to content

email-kit

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.

Terminal window
npm install @27colab/email-kit

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.

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.

samples/src/email-quickstart.ts
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 success
const 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 first
console.log((await emailMeThis({ ...msg, confirmed: true })).status);
console.log((await emailMeThis({ ...msg, confirmed: true })).status, 'again; outbox:', provider.outbox.length);
output
3 provider calls; Bienvenido
emails recorded: 1
refused: 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'
}
sent
sent again; outbox: 2

The two simulated failures plus the successful attempt make three provider calls but only one recorded email, and the repeated send did not add another.

Needs a key. Typechecked, not executed.

samples/src/email-resend.ts
// 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' });
  1. Validates the address and that there is content.
  2. Refuses if the recipient is on the suppression list (EmailRefusedError, reason suppressed). Bypass is explicit per call.
  3. Derives (or accepts) an idempotency key and returns the earlier result if one exists; the key is also sent to the provider (Idempotency-Key for Resend).
  4. Calls the provider with retry: exponential backoff with jitter, Retry-After honored, only for errors flagged retryable (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.

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.

ExportPurpose
createMailer({ provider, from, suppression?, idempotencyStore?, retry? })send(message, options?), sendTemplate(template, to, vars, { locale })
ResendProvider({ apiKey }), EmailProviderThe one real provider; the interface for others
defineTemplate, renderTemplate, resolveLocaleTyped templates with EN/ES (any locale) and safe HTML escaping; es-MX falls back to es
MemorySuppressionList, SuppressionList, unsubscribeHeadersSuppression and List-Unsubscribe headers
deriveIdempotencyKey, MemoryIdempotencyStore, IdempotencyStoreIdempotency
withRetry, isRetryableBackoff helpers
batchAttachments(files, { maxBytes, maxCount })Returns { batches, oversized }; oversized files are returned, never silently dropped
parseSpokenEmail, spellOut, createEmailMeThisBot 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.

  • 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.