@krizaka/mail
Courriel en file : envoyé après le commit, une fois par clé, jamais à une adresse supprimée ; le fournisseur est le choix de l'admin ; rebonds et plaintes suppriment.
Installer
npm install @krizaka/mailAussi publié en sous-chemins : @krizaka/mail/settings, @krizaka/mail/testkit.
Le README ci-dessous est celui du paquet sur npm, en anglais.
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
Les 50 noms que le paquet exporte, lus dans ses types.
| Nom | Nature | Ce que c’est |
|---|---|---|
bareAddress | fonction | The bare address of Name <address> or address. |
checkAddress | fonction | |
checkMessage | fonction | |
checkSettings | fonction | |
Clock | type | |
createMail | fonction | The mail of a product. |
dbMailStore | fonction | The PostgreSQL store of « mail » over a Query, in schema mail (or the one the product renamed it to). |
DELIVERY_STATUS | constante | |
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 | fonction | 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 | fonction | The full SHA-256 of an address: suppressions and the log never hold the address itself (Loi 25). |
logProvider | fonction | The development provider: writes one JSON line per mail (without its body) and sends nothing. |
Mail | type | |
MAIL_BOUNDS | constante | |
MAIL_CODE | constante | |
MAIL_DEFAULTS | constante | |
MAIL_EVENT | constante | |
MAIL_TOPIC | constante | The topic the mail is queued on, and the event that names it: the outbox routes by these. |
MailCode | type | |
MailDeps | type | |
MailError | classe | |
MailgunOptions | type | |
mailgunProvider | fonction | 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 | constante | 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 | fonction | An in-memory store, for unit tests and demos: the behaviour of the database's (the contract kit runs on both). |
normalizeAddress | fonction | |
OutgoingMail | type | A mail the provider receives: the message with the sender the settings give. |
parseMailgunWebhook | fonction | 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 | fonction | 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 | fonction | 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 | fonction | 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 | constante | Why an address is never mailed again. |
SuppressionReason | type | |
SvixHeaders | type | |
uuidOf | fonction | A stable UUID (version 5 shaped) from a seed: the same key queued twice is one outbox message. |
@krizaka/mail/settings
| Nom | Nature | Ce que c’est |
|---|---|---|
mailSettings | constante | 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 | fonction | The module's settings as createMail takes them, from the product's accessor ((key) => service.get(key, ctx)). |
@krizaka/mail/testkit
| Nom | Nature | Ce que c’est |
|---|---|---|
mailStoreContract | fonction | What every MailStore adapter must do: the same behaviour behind the same port. |
T0 | constante |
Paquet
| Version sur npm | 0.1.2 (npm) |
| Licence | Apache-2.0 |
| Node.js | >=22 |
| Dépendances | @krizaka/db workspace:^, @krizaka/settings workspace:^, @krizaka/time workspace:^ |
| Dépendances à fournir | vitest >=4, zod >=3.23 |
| Code source |
Journal des versions
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
La cloche, les canaux et les préférences : un notify écrit l'élément et un message d'outbox par canal activé ; les plafonds de rafale tiennent en concurrence ; l'admin règle les défauts.
@krizaka/credits
Un seul grand livre de crédits : octrois qui expirent avec plafonds par jour et par mois, achats, réservations et remboursements, exact en concurrence.