Skip to the content
Krizaka TypeScript
Krizaka TypeScript

@krizaka/mail

Queued mail sent after the commit, once per key, never to a suppressed address; the provider is the admin's choice; bounces and complaints suppress.

Install

bashTerminal
npm install @krizaka/mail

Also published as subpath exports: @krizaka/mail/settings, @krizaka/mail/testkit.

Mail for every Krizaka product: queued in the caller's transaction through @krizaka/outbox, delivered once per idempotency key, never to a suppressed address, with the provider the admin chose. No vendor SDK, no environment variable, no wall clock, no database client.

  • It takes mail already rendered. Templates are @krizaka/email's (a UI package this one never imports). emailPorts(store, clock) gives @krizaka/email its three ports (DeliveryLog, SuppressionList, IdempotencyStore) over the same tables, so a direct sendEmail and the queue share one log, one suppression list and one set of keys.
  • At least once, never lost. deliver checks the suppression list, checks the key, sends, and only then claims the key: a crash between the send and the claim sends again (a duplicate reset mail beats a lost one); a redelivery after the claim is a no-op.
  • Addresses are hashed (SHA-256 of the normal form) in the suppression list and in the delivery log. The queued message holds the address until the outbox purges it.
  • Providers are adapters over an injected fetch: mailgunProvider, resendProvider, logProvider (development, writes no body). providerFor(settings.provider, keys, fetch, write) picks the one the admin chose and refuses a provider without its key instead of falling back to log.
  • Bounces and complaints come back through the provider's signed webhook: parseMailgunWebhook and parseResendWebhook verify the signature and the age, and answer the addresses to suppress (a permanent failure or hard bounce, a complaint, an unsubscribe). A transient bounce is ignored.
  • The admin's settings (@krizaka/mail/settings): mail.provider (log, mailgun, resend), mail.from, mail.replyTo, mail.retentionDays. Keys, the Mailgun domain and the webhook signing keys stay in the environment (MAIL_PROVIDER-style names, read by the product and passed in).
import { createOutbox, runWorker } from "@krizaka/outbox";
import { createMail, dbMailStore, MAIL_TOPIC, providerFor } from "@krizaka/mail";
import { mailSettings, readMailSettings } from "@krizaka/mail/settings";

const mail = createMail({
  clock,
  outbox: createOutbox({ clock, settings: outboxSettings }),
  settings: () => readMailSettings((key) => settings.get(key)),
  store: dbMailStore(query),
  provider: (s) => providerFor(s.provider, { apiKey: env.MAIL_API_KEY, domain: env.MAIL_DOMAIN }, fetch, console.log),
});

// in the use case's transaction: the mail leaves only if the transaction commits
await mail.queue(tx, { to, subject, text, category: "transactional", template: "password-reset", locale: "fr", idempotencyKey: `reset:${resetId}` });

// in the worker process
await runWorker({ outbox, query, owner, topic: MAIL_TOPIC, deliver: (m) => mail.deliver(m).then(() => undefined), sleep });

// in the webhook route
const suppression = parseMailgunWebhook(rawBody, env.MAIL_WEBHOOK_SIGNING_KEY, nowSeconds);
if (suppression) await mail.suppress(suppression);

Tables (schema mail, mailSchema): delivery, suppression, idempotency. Event: evt.mail.requested.v1 on topic mail. mailStoreContract (@krizaka/mail/testkit) is the contract every store runs.

Apache-2.0.

Exports

The 50 names the package exports, read from its types.

NameKindWhat it is
bareAddressfunctionThe bare address of Name <address> or address.
checkAddressfunction
checkMessagefunction
checkSettingsfunction
Clocktype
createMailfunctionThe mail of a product.
dbMailStorefunctionThe PostgreSQL store of « mail » over a Query, in schema mail (or the one the product renamed it to).
DELIVERY_STATUSconstant
DeliveryEntrytypeOne line of the delivery log: what, to whom (a hash, never the address), how it went.
DeliveryInputtypeA line to write: the instant is a Date.
DeliveryRecordtypeA line as read back.
DeliveryStatustype
emailPortsfunctionThe three ports of @krizaka/email (DeliveryLog, SuppressionList, IdempotencyStore) over this module's store, so a product that calls sendEmail dire…
FetchLiketypeThe part of fetch the providers use: inject the global one (fetch), or a fake in a test.
hashAddressfunctionThe full SHA-256 of an address: suppressions and the log never hold the address itself (Loi 25).
logProviderfunctionThe development provider: writes one JSON line per mail (without its body) and sends nothing.
Mailtype
MAIL_BOUNDSconstant
MAIL_CODEconstant
MAIL_DEFAULTSconstant
MAIL_EVENTconstant
MAIL_TOPICconstantThe topic the mail is queued on, and the event that names it: the outbox routes by these.
MailCodetype
MailDepstype
MailErrorclass
MailgunOptionstype
mailgunProviderfunctionMailgun's messages API (form-encoded, basic auth).
MailMessagetypeThe mail a product queues.
MailOutboxtypeWhat the module needs of @krizaka/outbox (its Outbox satisfies it): append in the caller's transaction.
MailProvidertypeOne provider of mail (an adapter over fetch).
mailSchemaconstantThe tables of « mail », in their own Postgres schema: delivery (the log, with the recipient as a hash, never a body), suppression (the addresses never ma…
MailSettingstype
MailStoretypeWhere the module keeps its state.
memoryMailStorefunctionAn in-memory store, for unit tests and demos: the behaviour of the database's (the contract kit runs on both).
normalizeAddressfunction
OutgoingMailtypeA mail the provider receives: the message with the sender the settings give.
parseMailgunWebhookfunctionA Mailgun webhook (JSON body): checks the signature (HMAC-SHA256 of timestamp + token with the webhook signing key, hex) and its age, then answers the addr…
parseResendWebhookfunctionA Resend webhook (signed the Svix way): HMAC-SHA256 over id.timestamp.body with the base64 secret that follows whsec_, any of the space-separated `v1,<ba…
providerForfunctionThe provider the admin chose (mail.provider).
ProviderKeystypeThe keys a provider needs: they stay in the environment, the product reads them and passes them here.
Querytype
QueuedMailtypeThe part of an outbox message the deliverer reads (OutboxMessage satisfies it).
ResendOptionstype
resendProviderfunctionResend's emails API (JSON, bearer key, its own Idempotency-Key).
SenttypeWhat the provider answered.
SuppressiontypeWhat a provider's webhook tells us about one address.
SUPPRESSION_REASONconstantWhy an address is never mailed again.
SuppressionReasontype
SvixHeaderstype
uuidOffunctionA stable UUID (version 5 shaped) from a seed: the same key queued twice is one outbox message.

@krizaka/mail/settings

NameKindWhat it is
mailSettingsconstantThe settings of « mail », a fragment the product adds to its registry (@krizaka/mail/settings): the admin picks the provider and the sender without a redep…
readMailSettingsfunctionThe module's settings as createMail takes them, from the product's accessor ((key) => service.get(key, ctx)).

@krizaka/mail/testkit

NameKindWhat it is
mailStoreContractfunctionWhat every MailStore adapter must do: the same behaviour behind the same port.
T0constant

Package

Version on npm0.1.2 (npm)
LicenceApache-2.0
Node.js>=22
Dependencies@krizaka/db workspace:^, @krizaka/settings workspace:^, @krizaka/time workspace:^
Peer dependenciesvitest >=4, zod >=3.23
Sourcemail

Changelog

0.1.2

Patch changes

0.1.1

Patch changes

0.1.0

Minor changes

  • New package (#58): @krizaka/mail. createMail (queue in the caller's transaction through the outbox topic mail, deliver for the worker: suppression and key checks, send, then claim, so a crash sends again rather than loses; suppress, unsuppress, purge), mailgunProvider, resendProvider, logProvider and providerFor over an injected fetch, parseMailgunWebhook and parseResendWebhook (signature, age, hard bounces and complaints), mailSchema (mail.delivery, mail.suppression as address hashes, mail.idempotency), dbMailStore, memoryMailStore, emailPorts (the three ports of @krizaka/email, structurally), @krizaka/mail/settings (mail.provider, mail.from, mail.replyTo, mail.retentionDays), evt.mail.requested.v1 and mailStoreContract.

    @krizaka/settings gains the text kind (text(group, default, { max, empty })): one trimmed line of free text, for an address or a name.

Patch changes

On this page