@fabrika/app 0.4.8 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/app",
3
- "version": "0.4.8",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -93,13 +93,14 @@
93
93
  "test": "bun test --path-ignore-patterns='src/react/**' && bun test --isolate src/react"
94
94
  },
95
95
  "dependencies": {
96
- "@fabrika/auth": "0.4.8",
97
- "@fabrika/auth-core": "0.4.8",
98
- "@fabrika/db": "0.4.8",
99
- "@fabrika/jwks": "0.4.8",
100
- "@fabrika/platform": "0.4.8",
101
- "@fabrika/platform-node": "0.4.8",
102
- "@fabrika/proxy-core": "0.4.8",
96
+ "@fabrika/auth": "0.5.0",
97
+ "@fabrika/auth-core": "0.5.0",
98
+ "@fabrika/db": "0.5.0",
99
+ "@fabrika/email": "0.5.0",
100
+ "@fabrika/jwks": "0.5.0",
101
+ "@fabrika/platform": "0.5.0",
102
+ "@fabrika/platform-node": "0.5.0",
103
+ "@fabrika/proxy-core": "0.5.0",
103
104
  "jose": "^5.9.0"
104
105
  },
105
106
  "peerDependencies": {
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fabrika-app
3
- version: 0.4.8
3
+ version: 0.5.0
4
4
  description: >
5
5
  How to write and change a Fabrika application: src/vocabulary.ts, db/migrations/, src/db/tables.ts, the
6
6
  slices' endpoints.ts with their handlers, fabrika.contract.ts, jobs, files, webhooks, share links, connectors
@@ -226,6 +226,7 @@ the access check. `references/spa.md` shows the stack.
226
226
  | rows outside its capability | `unscoped: { <table>: 'read' }` | `ctx.db.unscoped.<table>` |
227
227
  | another application's export | `connect: ['<import>']` | `ctx.connect.<import>` |
228
228
  | a share link | `issues: '<link mount>'` | `ctx.links.issue` |
229
+ | e-mail (a cron job only) | `mailer: 'installation'` | `ctx.mail.send` |
229
230
  | a trigger, function or view | its migration | — |
230
231
 
231
232
  Scope every scoped table a raw statement reads with `${ctx.sql.scope(<capability>, '<table>', '<alias>')}`, a conjunct
@@ -242,8 +243,11 @@ Which promise the types, the checker, boot, the runtime or the approver keeps, a
242
243
  ## Rules
243
244
 
244
245
  - Data work first, external calls after. The invocation's one transaction rolls back on a throw; IAM, object storage
245
- and connector calls do not. An endpoint the generated tests cover calls no outside service (an e-mail, a provider's
246
- API): it records an outbox row, and a job sends it (`references/jobs.md`).
246
+ and connector calls do not. An endpoint the generated tests cover calls no outside service (a provider's API): it
247
+ records an outbox row, and a job makes the call (`references/jobs.md`).
248
+ - An endpoint never sends e-mail; it has no `ctx.mail`. It records an outbox row and declares `then: '<job>'`, which
249
+ starts that job once its transaction committed. The job declares `mailer` and sends with `ctx.mail`, under a key
250
+ derived from the row, and sends again only where `ctx.mail.mayRetry(error)` allows it (`references/jobs.md`).
247
251
  - No `as` (except `as const`), `any`, `@ts-ignore`, `@ts-expect-error`, `process.env` or `Bun.env`.
248
252
  - Never log an error object, a request, headers or the environment.
249
253
  - Never edit a generated file (`src/db/schema.generated.ts`, the generated tests, `fabrika/entry.generated.ts`,
@@ -296,7 +300,7 @@ change an uncommitted migration, migrate into a fresh schema with another `FABRI
296
300
 
297
301
  `bunx fabrika app dev` serves the application on a loopback port through its test kit, filled by `dev/seed.ts`
298
302
  through the endpoints. Pick a persona at `/__fabrika/dev`; the toolbar on every page switches it and lists the mail IAM
299
- sent. Write the seed and use it as `references/dev.md` says; it never replaces a test.
303
+ and the application's jobs sent. Write the seed and use it as `references/dev.md` says; it never replaces a test.
300
304
 
301
305
  ## References
302
306
 
@@ -306,7 +310,7 @@ their errors, the caller, the components, routing, theming, screen tests), `tabl
306
310
  (access forms, which `over`, the caller's scopes, handles, lists and aggregates, `fromRow`, `ctx.sql`, audited CSV
307
311
  downloads, members and labels), `tests.md` (generated tests, seeds, `testKit`, a reused scope, a caller's label, confirming a file ran),
308
312
  `errors.md` (checker findings, type errors, runtime answers), `dev.md` (the seed and the preview), `jobs.md` (which dimension `ctx.within` names, the UTC
309
- day), `files.md` and `webhooks-links-connectors.md`, all under `references/`. Two are applications of their own:
313
+ day, e-mail through `ctx.mail`, the outbox and `then`), `files.md` and `webhooks-links-connectors.md`, all under `references/`. Two are applications of their own:
310
314
  `start.md`, a new application before its first table (its first test, adding the first table, installing and updating
311
315
  this skill), and `public-intake.md`, a public page, a form, a job that issues links and e-mails them, a link holder's
312
316
  files, HR's review with its history.
@@ -314,6 +318,8 @@ files, HR's review with its history.
314
318
  ## Not built yet
315
319
 
316
320
  - The data layer runs on Bun and Postgres only (Zerops). On Cloudflare an application has no tables, jobs or files.
317
- - Jobs are cron only and app-wide: no `ctx.jobs.enqueue`, no job per scope. A list write is off the road (`sql`).
321
+ - Jobs are cron only and app-wide: no `ctx.jobs.enqueue`, no job per scope. A mutation's `then` starts a run of its
322
+ job; it carries nothing. A list write is off the road (`sql`).
323
+ - `ctx.mail` sends no attachments.
318
324
  - An MCP workspace has no shell, so `codegen` and `test-scaffold` cannot run there. Tell the human; never write a
319
325
  generated file by hand.
@@ -19,20 +19,20 @@ the SPA is added with it.
19
19
  "test": "bun test --isolate"
20
20
  },
21
21
  "dependencies": {
22
- "@fabrika/app": "0.4.8",
23
- "@fabrika/db": "0.4.8",
24
- "@fabrika/provider-zerops": "0.4.8",
25
- "@fabrika/proxy-core": "0.4.8",
26
- "@fabrika/ui": "0.4.8",
22
+ "@fabrika/app": "0.5.0",
23
+ "@fabrika/db": "0.5.0",
24
+ "@fabrika/provider-zerops": "0.5.0",
25
+ "@fabrika/proxy-core": "0.5.0",
26
+ "@fabrika/ui": "0.5.0",
27
27
  "kysely": "0.29.6",
28
28
  "react": "^19.0.0",
29
29
  "react-dom": "^19.0.0",
30
30
  "zod": "^3.25.76"
31
31
  },
32
32
  "devDependencies": {
33
- "@fabrika/check": "0.4.8",
34
- "@fabrika/cli": "0.4.8",
35
- "@fabrika/platform": "0.4.8",
33
+ "@fabrika/check": "0.5.0",
34
+ "@fabrika/cli": "0.5.0",
35
+ "@fabrika/platform": "0.5.0",
36
36
  "@happy-dom/global-registrator": "^20.11.2",
37
37
  "@types/bun": "latest",
38
38
  "@types/react": "^19.0.0",
@@ -158,15 +158,16 @@ export default serve(contract, { database: scoped(tables) })
158
158
  per-form limit: raise it, and use the challenge. `references/public-intake.md` builds a whole public intake on it.
159
159
  - `then: '<job>'` on `c.form` names a cron job of the same contract (`jobs`), which each accepted submission kicks
160
160
  once its row committed: a run starts within seconds, under the job's own key, and the answer stays the same constant
161
- and never waits for it. Kicks coalesce on the job's lease (at most one run and one pending, however many visitors
161
+ and never waits for it. A mutation declares the same `then` for the outbox row it records (`references/jobs.md`). Kicks coalesce on the job's lease (at most one run and one pending, however many visitors
162
162
  submit), so the job still drains its inbox in batches under its own budgets. Keep its schedule: it is the recovery
163
163
  path for a kicked run that died. A kick carries nothing; the job reads its inbox. `kit.runJob` first waits for the
164
164
  kicked runs.
165
165
  - `src/app.ts` composes and does nothing else. A dependency built from the application's own variables is
166
166
  `defineDeps(build)`, passed as `serve(contract, { database, deps })` and read in a handler with `get()`. Never read
167
167
  `process.env`. `build` runs at every boot, the generated tests' too, which pass no variable: read a variable when
168
- the dependency is used, and fail there (`src/mail.ts` in `references/public-intake.md`). A handler of an endpoint the
168
+ the dependency is used, and fail there (`src/links.ts` in `references/public-intake.md`). A handler of an endpoint the
169
169
  generated tests cover records an outbox row instead, and a job calls the dependency (`references/endpoint.md`).
170
+ E-mail needs no dependency: a job that declares `mailer` sends through `ctx.mail` (`references/jobs.md`).
170
171
 
171
172
  ## The deploy surface (Zerops)
172
173
 
@@ -64,27 +64,36 @@ export default seed
64
64
  does: a token is single use. For the widget on a page, set the `siteKey` variable to Turnstile's always-passing test
65
65
  key `1x00000000000000000000AA`. Its token is the same every time, and `app dev` admits that one token again at each
66
66
  submission; a deployed application records it like any other, so there it passes once.
67
- - Point an outside provider at the dev outbox: `app dev` captures every request the process sends to
68
- `https://outbox.invalid` (`DEV_OUTBOX_ORIGIN` of `@fabrika/app/testing`) and answers `200 {"id": …}`. The mail view
69
- lists each one, by the `to` and `subject` of a JSON body, and opens its body; its headers are not kept.
70
- - The seed's second argument reads the outbox: `outside.outbox()` is every captured request, oldest first
71
- (`{ id, method, path, contentType, body }`), so the seed can open a link the application mailed.
67
+ - A job's e-mail goes to the kit's mail stand-in (`dev.mail`, as `kit.mail` in a test): nothing is delivered, and no
68
+ mail variable is needed. The mail view lists each message, newest first, beside IAM's invitations: the recipient,
69
+ the subject, the key the job passed, the time and the transport's answer. It opens to every field, the text, and the
70
+ HTML in a sandboxed frame that runs no script and loads nothing. `dev.mail.refuse('provider_rejected')` in the seed
71
+ previews a refused one; `dev.mail.heal()` ends it.
72
+ - The seed's second argument reads what left the application: `outside.mails()` is every message a job handed to the
73
+ stand-in, oldest first (`{ id, at, to, subject, text, html, key, transportKey, answer, outcome, delivered, … }`), so
74
+ the seed can open a link the application mailed. Run the job first (`dev.runJob('<job>')`), or let a mutation's
75
+ `then` kick it and `await dev.drain()`.
76
+ - Point another outside service at the dev outbox: `app dev` captures every request the process sends to
77
+ `https://outbox.invalid` (`DEV_OUTBOX_ORIGIN` of `@fabrika/app/testing`) and answers `200 {"id": …}`. A section of
78
+ its own lists each one, by the `to` and `subject` of a JSON body, and opens its body; its headers are not kept.
79
+ `outside.outbox()` is every captured request, oldest first (`{ id, method, path, contentType, body }`).
72
80
 
73
81
  ```text dev/dev.env
74
82
  # Stand-in values for fabrika app dev.
75
83
  TURNSTILE_SECRET=dev
76
84
  TURNSTILE_SITE_KEY=1x00000000000000000000AA
77
- MAIL_API_URL=https://outbox.invalid/mail
78
- MAIL_API_KEY=dev
85
+ ARCHIVE_URL=https://outbox.invalid/archive
86
+ ARCHIVE_KEY=dev
79
87
  ```
80
88
 
81
89
  ## In the browser
82
90
 
83
91
  - `/__fabrika/dev` is the picker: the personas, another role of the vocabulary (a membership role with a scope value),
84
- anonymous, the link pages, and the mail: what IAM sent and what reached the dev outbox. It speaks the vocabulary's `locale` and shows its `displayName`.
92
+ anonymous, the link pages, the mail IAM and the application's jobs sent, and the dev outbox. It speaks the vocabulary's `locale` and shows its `displayName`.
85
93
  - Every SPA page carries a toolbar in the bottom corner: who you are, the personas to switch to (the page reloads as
86
- that caller), the picker, the link pages, and the mail. An invitation's link accepts it; the browser is the invited
87
- person from then on.
94
+ that caller), the picker, the link pages, the mail and the dev outbox. An invitation's link accepts it; the browser
95
+ is the invited person from then on. A job's message opens at `/__fabrika/dev/mail/<id>`.
88
96
  - The choice stays in a cookie the application never sees. The data is gone when the command stops.
89
97
  - It serves this machine only (`--host` is a loopback address, `--port=0` picks a free port), answers no request
90
- addressed to another host name, and runs no cron job on its schedule: the seed runs one with `dev.runJob`, a test with `kit.runJob`.
98
+ addressed to another host name, and runs no cron job on its schedule: the seed runs one with `dev.runJob`, a test with `kit.runJob`. A form's or a
99
+ mutation's `then` still starts its job, as deployed.
@@ -60,10 +60,12 @@ export const OrgInput = z.strictObject({ orgId: z.string().min(1).max(100) })
60
60
  (`{ kind: 'service', id, label }`) has no address. `label` is for display: the address as the person spelled it.
61
61
  - **An endpoint the generated tests cover calls no outside service.** They boot without your variables, so a handler
62
62
  that calls one answers 500 in each case, and a request cannot undo the call when its transaction rolls back. Such an
63
- endpoint records an outbox row in its transaction, and a job sends it with an attempt count and an idempotency key
64
- (`references/jobs.md`). A call that is the request's whole purpose, such as a test e-mail to the caller, lives in a
65
- `{ member: true }` endpoint, which they do not cover (`references/public-intake.md`). The framework's own calls (IAM,
66
- the file store, `ctx.connect`) are not outside.
63
+ endpoint records an outbox row in its transaction, and a job makes the call with an attempt count and an idempotency
64
+ key (`references/jobs.md`). A call that is the request's whole purpose lives in a `{ member: true }` endpoint, which
65
+ they do not cover. The framework's own calls (IAM, the file store, `ctx.connect`) are not outside.
66
+ - **An endpoint never sends e-mail**, covered or not: it has no `ctx.mail`. It records the message in an outbox row
67
+ and declares `then: '<job>'` on the mutation, so the job that declares `mailer` sends it within seconds of the
68
+ commit (`references/jobs.md`; the test e-mail in `references/public-intake.md`). Only a mutation declares `then`.
67
69
  - `ctx.principal.label` is a `string`, never `null`: the label IAM holds for the person (the one its directory
68
70
  shows), or the id when the token carries none. When the caller owns what it writes (the creator of a deal, the
69
71
  assignee of a task nobody was picked for), store `ctx.principal.id` and `ctx.principal.label`, never a
@@ -10,7 +10,7 @@ import { members } from './src/members/endpoints'
10
10
  import { priorities } from './src/priorities/endpoints'
11
11
  import { projects } from './src/projects/endpoints'
12
12
  import { reports } from './src/reports/endpoints'
13
- import { digest } from './src/tasks/digest'
13
+ import { archive } from './src/tasks/archive'
14
14
  import { tasks } from './src/tasks/endpoints'
15
15
  import { erase } from './src/tasks/erase'
16
16
 
@@ -34,7 +34,7 @@ export const contract = app.contract((c) => ({
34
34
  maxPerOwner: 20,
35
35
  }),
36
36
  },
37
- jobs: { digest, erase },
37
+ jobs: { archive, erase },
38
38
  health: c.health('/healthz'),
39
39
  }))
40
40
  ```
@@ -108,21 +108,22 @@ Every call needs the collection's `read`, so a write capability alone sees no fi
108
108
  document is declared with an action that is the collection's `read` too, or the check runs where the caller holds
109
109
  `read`: never mint or widen a capability to list.
110
110
 
111
- A job mints the owner's `Ref` with `ctx.ref` and opens each live file, here to attach it to an e-mail of `deps`:
111
+ A job mints the owner's `Ref` with `ctx.ref` and opens each live file, here to hand it to an archive service of
112
+ `deps`. `ctx.mail` carries no attachments, so a file leaves through a service of the application's own:
112
113
 
113
- ```ts src/tasks/digest.ts
114
+ ```ts src/tasks/archive.ts
114
115
  import { app } from '../app-kit'
115
- import { mailer } from '../mail'
116
+ import { archiver } from '../archive'
116
117
 
117
- /** The largest report one e-mail carries: the collection's maxBytes. */
118
+ /** The largest report the archive takes in one call: the collection's maxBytes. */
118
119
  const REPORT_MAX_BYTES = 5242880
119
120
 
120
- export const digest = app.cron('0 6 * * *', {
121
+ export const archive = app.cron('0 6 * * *', {
121
122
  actions: ['tasks.read'],
122
123
  scope: 'app',
123
124
  })
124
125
  .handle(async ({ ctx }) => {
125
- const mail = mailer.get()
126
+ const store = archiver.get()
126
127
  const tasks = ctx.db.over(ctx.within('tasks.read', 'project')).tasks
127
128
  const done = await tasks.list({ where: [['status', '=', 'done']], limit: 50 })
128
129
  for (const task of done.items) {
@@ -136,12 +137,12 @@ export const digest = app.cron('0 6 * * *', {
136
137
  )
137
138
  }
138
139
  ctx.signal.throwIfAborted()
139
- await mail.sendReports(task.title, reports, ctx.signal)
140
+ await store.putReports(task.id, reports, ctx.signal)
140
141
  }
141
142
  })
142
143
  ```
143
144
 
144
- ```ts src/mail.ts
145
+ ```ts src/archive.ts
145
146
  import { defineDeps } from '@fabrika/app/implement'
146
147
 
147
148
  export interface Report {
@@ -150,10 +151,10 @@ export interface Report {
150
151
  readonly bytes: Uint8Array
151
152
  }
152
153
 
153
- export interface Mailer {
154
- /** Sends the reports of one task; throws when the provider refuses, and gives up once `signal` aborts. */
155
- sendReports(
156
- title: string,
154
+ export interface Archiver {
155
+ /** Stores the reports of one task under its id; throws when the service refuses, and gives up once `signal` aborts. */
156
+ putReports(
157
+ taskId: string,
157
158
  reports: readonly Report[],
158
159
  signal: AbortSignal,
159
160
  ): Promise<void>
@@ -167,27 +168,28 @@ const variable = (env: Readonly<Record<string, unknown>>, name: string) => {
167
168
  return value
168
169
  }
169
170
 
170
- export const mailer = defineDeps((env): Mailer => ({
171
- async sendReports(title, reports, signal) {
172
- const response = await fetch(variable(env, 'MAIL_API_URL'), {
173
- method: 'POST',
174
- headers: {
175
- Authorization: `Bearer ${variable(env, 'MAIL_API_KEY')}`,
176
- 'Content-Type': 'application/json',
171
+ export const archiver = defineDeps((env): Archiver => ({
172
+ async putReports(taskId, reports, signal) {
173
+ const response = await fetch(
174
+ `${variable(env, 'ARCHIVE_URL')}/tasks/${taskId}`,
175
+ {
176
+ method: 'PUT',
177
+ headers: {
178
+ Authorization: `Bearer ${variable(env, 'ARCHIVE_KEY')}`,
179
+ 'Content-Type': 'application/json',
180
+ },
181
+ body: JSON.stringify({
182
+ files: reports.map((file) => ({
183
+ filename: file.name ?? 'report',
184
+ contentType: file.type,
185
+ content: Buffer.from(file.bytes).toString('base64'),
186
+ })),
187
+ }),
188
+ signal,
177
189
  },
178
- body: JSON.stringify({
179
- to: variable(env, 'REPORTS_TO'),
180
- subject: `Reports of ${title}`,
181
- attachments: reports.map((file) => ({
182
- filename: file.name ?? 'report',
183
- contentType: file.type,
184
- content: Buffer.from(file.bytes).toString('base64'),
185
- })),
186
- }),
187
- signal,
188
- })
190
+ )
189
191
  if (!response.ok) {
190
- throw new Error(`the mail provider answered ${response.status}`)
192
+ throw new Error(`the archive answered ${response.status}`)
191
193
  }
192
194
  },
193
195
  }))
@@ -197,12 +199,14 @@ export const mailer = defineDeps((env): Mailer => ({
197
199
  import { serve } from '@fabrika/app/implement'
198
200
  import { scoped } from '@fabrika/db'
199
201
  import { contract } from '../fabrika.contract'
202
+ import { archiver } from './archive'
200
203
  import { tables } from './db/tables'
201
- import { mailer } from './mail'
202
204
 
203
- export default serve(contract, { database: scoped(tables), deps: mailer })
205
+ export default serve(contract, { database: scoped(tables), deps: archiver })
204
206
  ```
205
207
 
208
+ The `PUT` under the task's id makes a repeated run replace the same object, so a retried attempt stores nothing twice.
209
+
206
210
  `removeAll` runs in a mutation and in a job's `tx.files`, in the same transaction as the handler's writes: an erasure
207
211
  that blanks a record and removes its files keeps both or neither. A query and a job's `ctx.files` only read. The
208
212
  framework's sweep deletes the removed files' objects within the hour, and so the objects of a deleted owner's files.
@@ -2,7 +2,9 @@
2
2
 
3
3
  Background work is a cron job: `app.cron(schedule, { actions, scope: 'app' })` with its `.handle(…)` in the
4
4
  `endpoints.ts` of the slice whose data it works on, mounted under `jobs` in the contract. There are no timers, loops,
5
- `setInterval` or self-scheduled work, and no `ctx.jobs.enqueue`: work for later is a row that the next run picks up.
5
+ `setInterval` or self-scheduled work, and no `ctx.jobs.enqueue`: work for later is a row that a run picks up. A
6
+ mutation that declares `then: '<job>'` starts a run within seconds of its commit (the outbox below); the schedule
7
+ picks up whatever that run leaves.
6
8
 
7
9
  ```ts src/overdue/endpoints.ts
8
10
  import { app } from '../app-kit'
@@ -135,19 +137,62 @@ export const contract = app.contract((c) => ({
135
137
  `ctx.links.issue(await ctx.ref('<issuedBy>', '<dimension>', id), { mount, days })`, and `ctx.links.revoke(id)` for
136
138
  a link it issued. It is a widening for closer review. `references/public-intake.md` is the whole recipe: a form's
137
139
  inbox, the record, the link and its e-mail, each step recorded before the next.
138
- - A job reads a file collection through `ctx.files('<collection>')` with a `Ref` it minted (`list`, `open` to attach a
139
- file to an e-mail), and removes an owner's files only inside its transaction, `tx.files('<collection>').removeAll(ref)`
140
+ - A job reads a file collection through `ctx.files('<collection>')` with a `Ref` it minted (`list`, `open` to hand a
141
+ file to another service), and removes an owner's files only inside its transaction, `tx.files('<collection>').removeAll(ref)`
140
142
  (`references/files.md`). The framework's own `fabrika-file-sweep` then deletes their objects; never write a sweep.
141
143
  - `ctx.signal` aborts when the attempt ends (its timeout, a lost lease). Call `ctx.signal.throwIfAborted()` before
142
144
  each external effect and pass the signal to `fetch`, so a stale attempt starts nothing after its replacement.
143
- - A failed attempt names its failure in `kit.runJob`'s answer and the log: `handler_failed`, or a framework refusal
144
- with its reason (`query_refused: undeclared_reach`, `capability_refused: undeclared_action`; `references/errors.md`).
145
+ - A failed attempt names its failure in `kit.runJob`'s answer and the log: `handler_failed`, a framework refusal
146
+ with its reason (`query_refused: undeclared_reach`, `capability_refused: undeclared_action`; `references/errors.md`),
147
+ or `mail_failed` with the transport's code (`mail_failed: provider_rejected`).
148
+
149
+ ## E-mail: `mailer` and `ctx.mail`
150
+
151
+ Only a cron job that declares `mailer` sends e-mail, through `ctx.mail`. An endpoint has no `ctx.mail` (a type error
152
+ that says so): it records an outbox row and kicks the job (below). `references/public-intake.md` shows both jobs whole.
153
+
154
+ - `mailer: 'installation'` sends through the installation's transport: the operator configures one provider, and
155
+ Control hands it to each application whose job declares it. The application holds no provider key and writes no
156
+ transport code. An installation without a transport still deploys it, and `ctx.mail.available` is then false.
157
+ - `mailer: 'own'` sends through the application's own transport: `serve(contract, { database, mail: (env) => sender })`,
158
+ where `sender` is an `EmailSender` (`@fabrika/app/implement`) built from the application's own variables, or `null`
159
+ when they configure none. Never name a variable `FABRIKA_EMAIL_*`: those are the installation's.
160
+ - `mailer` is reach the approver sees (`installation` for closer review). Declare it only after the human said yes.
161
+ Without it, `ctx.mail` is a type error that names the declaration. A boot without the declared transport fails and
162
+ says which job needs it; the test kit and `fabrika app dev` stand one in (`kit.mail`, `references/tests.md`).
163
+ - `ctx.mail.send({ to, subject, text, html?, replyTo?, fromName?, idempotencyKey }, signal?)` hands one message to the
164
+ transport and answers `{ status: 'accepted', provider, messageId }`. It always aborts with the attempt's
165
+ `ctx.signal`; pass `AbortSignal.timeout(ms)` for a deadline of the message's own. The sending address is the
166
+ transport's; `fromName` and `replyTo` are the application's, per message. There are no attachments.
167
+ - `idempotencyKey` is 1 to 200 visible ASCII characters, unique per message in the application environment, and
168
+ derived from the outbox row (`intake-link-<link id>`), never random. `ctx.mail` puts a prefix of the installation
169
+ and the environment in front of it, so no other application on the same provider account shares it.
170
+ - `ctx.mail.available` is false where the installation has no transport. Every send then fails `mail_not_configured`
171
+ (`not_sent`). Check it before the batch: when it is false, return and leave every row as it is, so no attempt is
172
+ spent. The rows leave on the first run after the operator adds a transport and redeploys the application.
173
+ - A refused send throws `EmailDeliveryError` (`@fabrika/app/implement`): a fixed `code` and an `outcome`, `not_sent`
174
+ (the message did not leave) or `unknown` (it may have). `ctx.mail.mayRetry(error)` says whether the message may go
175
+ again: false for a refused message or configuration, and for an `unknown` outcome over a transport that does not
176
+ deduplicate a key (`ctx.mail.capabilities.idempotent` is false). Where it is false, never send again: the row is
177
+ dead, for a person.
178
+ - A send error the handler does not catch fails the attempt as `mail_failed`, and the runner retries the attempt only
179
+ where `mayRetry` holds. Catch it per row instead, as the outbox below does.
180
+ - Never log a message, its address or its key: log the row's id and the error's `code`.
145
181
 
146
182
  ## An outbox: what a request asks a job to do
147
183
 
148
- A request handler calls no outside service (`references/endpoint.md`). It records what to do in a row, in its own
149
- transaction: a form's inbox row, or a state of its record (`link_state = 'due'`). A job does the work and calls the
150
- service. `references/public-intake.md` is the whole recipe; its rules:
184
+ A request handler calls no outside service and sends no e-mail (`references/endpoint.md`). It records what to do in a
185
+ row, in its own transaction: a form's inbox row, an outbox row (`test_mails`), or a state of its record
186
+ (`link_state = 'due'`). A job does the work and calls the service. `references/public-intake.md` is the whole recipe.
187
+
188
+ **The kick.** A mutation that records such a row declares `then: '<job>'`: once its transaction committed, the job
189
+ runs within seconds, under its own key, and the answer does not wait for it. A form's `then` does the same for its
190
+ inbox. A thrown handler, a refused answer and a failed commit kick nothing. Kicks coalesce: at most one run is active
191
+ and one pending, however many calls arrive. A kick carries nothing; the job reads its rows, and its schedule stays the
192
+ recovery. `then` names one cron job of the contract, as a literal (`r.mutation({ …, then: 'testMails' })`); a query,
193
+ an export, a webhook and a job declare none.
194
+
195
+ The rules of the job:
151
196
 
152
197
  - **Bounded batches.** Each run takes at most a fixed number of records (`order by received_at, id limit 50`), oldest
153
198
  first; the next run takes the rest.
@@ -157,13 +202,26 @@ service. `references/public-intake.md` is the whole recipe; its rules:
157
202
  `attempts`, keeps a fixed step code in `last_failure` (never the error's message, which can quote a value), and the
158
203
  run goes on with the next record. Rethrow when `ctx.signal` aborted: that failure is the attempt's, and the next
159
204
  run meets the record as it is. So give each external call a deadline of its own inside the attempt's
160
- (`AbortSignal.any([ctx.signal, AbortSignal.timeout(ms)])`): a call that hangs then fails its record, not the run.
205
+ (`AbortSignal.any([ctx.signal, AbortSignal.timeout(ms)])` for a `fetch`; `ctx.mail.send` adds `ctx.signal` itself,
206
+ so it takes only `AbortSignal.timeout(ms)`): a call that hangs then fails its record, not the run.
207
+ - **A backoff per record.** A failure sets the record's `next_attempt_at` one minute ahead, doubled per attempt up to
208
+ an hour (`now() + least(interval '1 minute' * power(2, attempts), interval '1 hour')`), and a run takes only records
209
+ whose time has come (`where next_attempt_at <= now()`). Kicks start runs seconds apart: without the wait, a short
210
+ provider outage spends every attempt of a record at once. Set it, with the attempt, in the transaction that claims
211
+ the record before the call (the test e-mail), and a run that dies during the call waits too. A record a person
212
+ revives (a resend) is due at once again.
161
213
  - **A dead state.** A record that failed `MAX_ATTEMPTS` times is dead: the batch skips it, the job logs it once by its
162
214
  id (never an address), and an operator fixes or deletes it. Give the record a way back when the cause can go away
163
215
  (a resend revives a dead delivery).
164
- - **A key on every external call**, derived from the record: the e-mail's `Idempotency-Key`, a provider's request id.
165
- The provider then acts once per key, however often the job hands the call over. A retry sends the same request
166
- under the same key: keep what it needs in the row until the call is known done (sealed, when it holds a secret).
216
+ - **A key on every external call**, derived from the record: the e-mail's `idempotencyKey`, a provider's request id.
217
+ A provider that deduplicates then acts once per key, however often the job hands the call over. A retry sends the
218
+ same request under the same key: keep what it needs in the row until the call is known done (sealed, when it holds a
219
+ secret).
220
+ - **A retry only where the transport allows it.** Mark the record `sending` before the send. A run that finds it still
221
+ `sending` (a crash, a lost answer) sends it again only when `ctx.mail.capabilities.idempotent` holds, and otherwise
222
+ makes it dead: the e-mail may have arrived, and a second one cannot be taken back. A failed send goes again only
223
+ where `ctx.mail.mayRetry(error)` holds, and is dead at once otherwise; one whose `outcome` is `not_sent` returns to
224
+ the state it had before `sending`.
167
225
  - **Each step recorded before the next effect**, so the next run finishes a stopped one.
168
226
  - An inbox needs a row key for this: `id UUID NOT NULL DEFAULT gen_random_uuid()`, since a form writes only its input
169
227
  fields.