@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
npm install @krizaka/mailAlso 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/emailits three ports (DeliveryLog,SuppressionList,IdempotencyStore) over the same tables, so a directsendEmailand the queue share one log, one suppression list and one set of keys. - At least once, never lost.
deliverchecks 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 tolog. - Bounces and complaints come back through the provider's signed webhook:
parseMailgunWebhookandparseResendWebhookverify 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.
| Name | Kind | What it is |
|---|---|---|
bareAddress | function | The bare address of Name <address> or address. |
checkAddress | function | |
checkMessage | function | |
checkSettings | function | |
Clock | type | |
createMail | function | The mail of a product. |
dbMailStore | function | The PostgreSQL store of « mail » over a Query, in schema mail (or the one the product renamed it to). |
DELIVERY_STATUS | constant | |
DeliveryEntry | type | One line of the delivery log: what, to whom (a hash, never the address), how it went. |
DeliveryInput | type | A line to write: the instant is a Date. |
DeliveryRecord | type | A line as read back. |
DeliveryStatus | type | |
emailPorts | function | The three ports of @krizaka/email (DeliveryLog, SuppressionList, IdempotencyStore) over this module's store, so a product that calls sendEmail dire… |
FetchLike | type | The part of fetch the providers use: inject the global one (fetch), or a fake in a test. |
hashAddress | function | The full SHA-256 of an address: suppressions and the log never hold the address itself (Loi 25). |
logProvider | function | The development provider: writes one JSON line per mail (without its body) and sends nothing. |
Mail | type | |
MAIL_BOUNDS | constant | |
MAIL_CODE | constant | |
MAIL_DEFAULTS | constant | |
MAIL_EVENT | constant | |
MAIL_TOPIC | constant | The topic the mail is queued on, and the event that names it: the outbox routes by these. |
MailCode | type | |
MailDeps | type | |
MailError | class | |
MailgunOptions | type | |
mailgunProvider | function | Mailgun's messages API (form-encoded, basic auth). |
MailMessage | type | The mail a product queues. |
MailOutbox | type | What the module needs of @krizaka/outbox (its Outbox satisfies it): append in the caller's transaction. |
MailProvider | type | One provider of mail (an adapter over fetch). |
mailSchema | constant | The 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… |
MailSettings | type | |
MailStore | type | Where the module keeps its state. |
memoryMailStore | function | An in-memory store, for unit tests and demos: the behaviour of the database's (the contract kit runs on both). |
normalizeAddress | function | |
OutgoingMail | type | A mail the provider receives: the message with the sender the settings give. |
parseMailgunWebhook | function | A 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… |
parseResendWebhook | function | A 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… |
providerFor | function | The provider the admin chose (mail.provider). |
ProviderKeys | type | The keys a provider needs: they stay in the environment, the product reads them and passes them here. |
Query | type | |
QueuedMail | type | The part of an outbox message the deliverer reads (OutboxMessage satisfies it). |
ResendOptions | type | |
resendProvider | function | Resend's emails API (JSON, bearer key, its own Idempotency-Key). |
Sent | type | What the provider answered. |
Suppression | type | What a provider's webhook tells us about one address. |
SUPPRESSION_REASON | constant | Why an address is never mailed again. |
SuppressionReason | type | |
SvixHeaders | type | |
uuidOf | function | A stable UUID (version 5 shaped) from a seed: the same key queued twice is one outbox message. |
@krizaka/mail/settings
| Name | Kind | What it is |
|---|---|---|
mailSettings | constant | The 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… |
readMailSettings | function | The module's settings as createMail takes them, from the product's accessor ((key) => service.get(key, ctx)). |
@krizaka/mail/testkit
| Name | Kind | What it is |
|---|---|---|
mailStoreContract | function | What every MailStore adapter must do: the same behaviour behind the same port. |
T0 | constant |
Package
| Version on npm | 0.1.2 (npm) |
| Licence | Apache-2.0 |
| Node.js | >=22 |
| Dependencies | @krizaka/db workspace:^, @krizaka/settings workspace:^, @krizaka/time workspace:^ |
| Peer dependencies | vitest >=4, zod >=3.23 |
| Source |
Changelog
0.1.2
Patch changes
- Updated dependencies [2a7898c]
- Updated dependencies [2272b57]
- @krizaka/[email protected]
- @krizaka/[email protected]
0.1.1
Patch changes
- Updated dependencies [0d19b64]
- @krizaka/[email protected]
- @krizaka/[email protected]
0.1.0
Minor changes
-
New package (#58):
@krizaka/mail.createMail(queuein the caller's transaction through the outbox topicmail,deliverfor the worker: suppression and key checks, send, then claim, so a crash sends again rather than loses;suppress,unsuppress,purge),mailgunProvider,resendProvider,logProviderandproviderForover an injectedfetch,parseMailgunWebhookandparseResendWebhook(signature, age, hard bounces and complaints),mailSchema(mail.delivery,mail.suppressionas 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.v1andmailStoreContract.@krizaka/settingsgains thetextkind (text(group, default, { max, empty })): one trimmed line of free text, for an address or a name.
Patch changes
- Updated dependencies [8210a9a]
- @krizaka/[email protected]
@krizaka/notifications
The bell, channels and preferences: one notify writes the item and one outbox message per enabled channel; burst caps hold under concurrency; the admin tunes defaults.
@krizaka/credits
One credits ledger: grants that expire with daily and monthly caps, purchases, holds and refunds, exact under concurrency.