@fabrika/app 0.4.7 → 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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  A visitor who has no account submits a form on a public page, confirms the e-mail address through a link, attaches
4
4
  files, and asks for a new link when the first one is lost. HR reviews the records, an HR lead edits the code lists of
5
- open positions and of the reasons a decision records, HR exports every candidate with an audit row, and a member
5
+ open positions and of the reasons a decision records, HR exports every candidate with an audit row, and an HR person
6
6
  checks the mail setup with a test e-mail to their own address. This file is an application of its own (`intake`); its
7
7
  `package.json`, `tsconfig.json` and `src/app-kit.ts` are those of `app-setup.md`.
8
8
 
@@ -13,10 +13,11 @@ visitor do. The visitor never reaches a handler without a credential:
13
13
  2. **A form** appends one row to its inbox table and answers `{ result: { received: true } }`, for a new and a known
14
14
  address alike. It runs no handler: a handler would give every anonymous request a transaction, and a different
15
15
  answer for a known address would tell a stranger who registered.
16
- 3. **A cron job** drains the inbox, creates the record, issues a link bound to it (`issues`), and sends the e-mail.
17
- Each form kicks it (`then: 'intake'`), so it runs within seconds of a submission; its schedule is the recovery.
18
- It is the outbox recipe of `references/jobs.md`: bounded batches, each record its own work with an attempt count,
19
- a dead state for a record that keeps failing, and an idempotency key on the e-mail.
16
+ 3. **A cron job** drains the inbox, creates the record, issues a link bound to it (`issues`), and sends the e-mail
17
+ through the installation's transport (`mailer: 'installation'`, `ctx.mail`). Each form kicks it (`then: 'intake'`),
18
+ so it runs within seconds of a submission; its schedule is the recovery. It is the outbox recipe of
19
+ `references/jobs.md`: bounded batches, each record its own work with an attempt count and a backoff, a dead state
20
+ for a record that keeps failing, an idempotency key on the e-mail, and a retry only where the transport allows it.
20
21
  4. **A link mount** serves the visitor's own record: `verify` and the record's files, under the link's key.
21
22
  5. **A second form** asks for a new link. The job revokes the old one and sends a new one, within a budget per address.
22
23
 
@@ -42,6 +43,8 @@ export const vocabulary = defineVocabulary({
42
43
  'exports.read': 'Read the log of candidate exports',
43
44
  'people.list': "List the application's people",
44
45
  'people.manage': "Grant and revoke the application's roles",
46
+ 'mail.test': 'Send yourself a test e-mail',
47
+ 'mail.send': 'Send the test e-mails people asked for',
45
48
  },
46
49
  roles: {
47
50
  hr: {
@@ -50,6 +53,7 @@ export const vocabulary = defineVocabulary({
50
53
  'candidates.review',
51
54
  'positions.read',
52
55
  'people.list',
56
+ 'mail.test',
53
57
  ],
54
58
  },
55
59
  hr_lead: {
@@ -61,6 +65,7 @@ export const vocabulary = defineVocabulary({
61
65
  'reasons.manage',
62
66
  'exports.read',
63
67
  'people.list',
68
+ 'mail.test',
64
69
  ],
65
70
  },
66
71
  hr_admin: {
@@ -70,6 +75,7 @@ export const vocabulary = defineVocabulary({
70
75
  'positions.read',
71
76
  'people.list',
72
77
  'people.manage',
78
+ 'mail.test',
73
79
  ],
74
80
  },
75
81
  },
@@ -87,15 +93,17 @@ export const vocabulary = defineVocabulary({
87
93
  application builds no role management of its own.
88
94
  - `directory: 'labels'` lets every `people.list` holder read each person's label, for naming the author of a change.
89
95
  A label spells a user's address, so the approver reviews it closely.
90
- - The link's holder and the job get their actions from the link mount and the job's `actions`, not from a role.
96
+ - The link's holder and the jobs get their actions from the link mount and each job's `actions`, not from a role.
97
+ `mail.send` is no role's: only the job that sends the test e-mails holds it.
91
98
 
92
99
  ```sql db/migrations/20261013090000_intake.sql
93
100
  CREATE TYPE candidate_status AS ENUM ('new', 'screening', 'interview', 'hired', 'rejected');
94
- CREATE TYPE link_state AS ENUM ('due', 'issued', 'sent', 'failed');
101
+ CREATE TYPE link_state AS ENUM ('due', 'issued', 'sending', 'sent', 'failed');
95
102
 
96
103
  -- The link delivery is the intake job's own state: each step is recorded here before the next effect starts.
97
- -- attempts counts the failed attempts of the current delivery and last_failure names the step that failed;
98
- -- a delivery that failed five times is 'failed', which an operator sees. link_seal holds the issued link's URL,
104
+ -- attempts counts the failed attempts of the current delivery, last_failure names the step that failed, and
105
+ -- next_attempt_at is when the job may try it again; a delivery that failed five times is 'failed', which an
106
+ -- operator sees. 'sending' is a send whose outcome is not known yet. link_seal holds the issued link's URL,
99
107
  -- sealed, until the e-mail is known sent, so a retry sends the same link under the same idempotency key.
100
108
  CREATE TABLE candidates (
101
109
  id UUID PRIMARY KEY,
@@ -110,6 +118,7 @@ CREATE TABLE candidates (
110
118
  sends INTEGER NOT NULL DEFAULT 0,
111
119
  window_started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
112
120
  attempts INTEGER NOT NULL DEFAULT 0,
121
+ next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
113
122
  last_failure TEXT,
114
123
  created_by TEXT NOT NULL,
115
124
  version INTEGER NOT NULL
@@ -167,6 +176,19 @@ CREATE TABLE candidate_exports (
167
176
  created_by TEXT NOT NULL,
168
177
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
169
178
  );
179
+
180
+ -- The outbox of the test e-mails: the endpoint records one, and the testMails job sends it. 'sending' is a send
181
+ -- whose outcome is not known yet; next_attempt_at is when the job may try a failed one again.
182
+ CREATE TABLE test_mails (
183
+ id UUID PRIMARY KEY,
184
+ recipient TEXT NOT NULL,
185
+ state TEXT NOT NULL DEFAULT 'due' CONSTRAINT test_mails_state CHECK (state IN ('due', 'sending', 'sent', 'failed')),
186
+ attempts INTEGER NOT NULL DEFAULT 0,
187
+ next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
188
+ last_failure TEXT,
189
+ created_by TEXT NOT NULL,
190
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
191
+ );
170
192
  ```
171
193
 
172
194
  ```sql db/migrations/20261013090100_attachments.sql
@@ -222,6 +244,7 @@ export const tables = defineTables(vocabulary, columns)({
222
244
  sends: { read: 'candidates.process' },
223
245
  window_started_at: { read: 'candidates.process' },
224
246
  attempts: { read: 'candidates.process' },
247
+ next_attempt_at: { read: 'candidates.process' },
225
248
  last_failure: { read: 'candidates.process' },
226
249
  },
227
250
  },
@@ -263,6 +286,12 @@ export const tables = defineTables(vocabulary, columns)({
263
286
  insert: ['row_count'],
264
287
  createdBy: 'created_by',
265
288
  },
289
+ test_mails: {
290
+ scope: { app: 'the test e-mails people asked for, until the job sends them' },
291
+ actions: { read: 'mail.send', insert: 'mail.test' },
292
+ insert: ['recipient'],
293
+ createdBy: 'created_by',
294
+ },
266
295
  })
267
296
  ```
268
297
 
@@ -273,7 +302,7 @@ export const tables = defineTables(vocabulary, columns)({
273
302
  endpoint `on`, `in` or `over` a candidate reads it, read-only and every row, where its action is the table's read
274
303
  action: `decide` reads `reasons` with `candidates.review`. A position or a reason is deactivated, never deleted, so
275
304
  neither table has a delete action. `candidate_exports` is appended with the export's own action, `candidates.export`,
276
- and read with `exports.read`.
305
+ and read with `exports.read`. `test_mails` is appended with `mail.test` and read only by the job's `mail.send`.
277
306
  - The form writes each input field to the column of the same name: `full_name`, never `fullName`. tsc refuses another
278
307
  name at the form's `appendTo`, and the checker refuses it too. A `ctx.sql` row still reads it as `fullName`.
279
308
  - The form writes nothing else, so an inbox records the time of a submission only in a `DEFAULT now()` column
@@ -316,7 +345,7 @@ import { portal } from './src/portal/endpoints'
316
345
  import { positions } from './src/positions/endpoints'
317
346
  import { reasons } from './src/reasons/endpoints'
318
347
  import { Register, Resend } from './src/schemas'
319
- import { settings } from './src/settings/endpoints'
348
+ import { settings, testMails } from './src/settings/endpoints'
320
349
 
321
350
  export const contract = app.contract((c) => ({
322
351
  site: c.spa('/', { dir: 'dist/site', access: { public: true } }),
@@ -357,7 +386,7 @@ export const contract = app.contract((c) => ({
357
386
  scan: true,
358
387
  }),
359
388
  },
360
- jobs: { intake },
389
+ jobs: { intake, testMails },
361
390
  health: c.health('/healthz'),
362
391
  }))
363
392
  ```
@@ -396,33 +425,18 @@ export const contract = app.contract((c) => ({
396
425
 
397
426
  ## The job: inbox → record → link → e-mail
398
427
 
399
- ```ts src/mail.ts
428
+ ```ts src/links.ts
400
429
  import { defineDeps } from '@fabrika/app/implement'
401
430
 
402
- export interface Mail {
403
- readonly to: string
404
- readonly url: string
405
- /** The provider sends one message per key, however often the job hands it over. */
406
- readonly idempotencyKey: string
407
- }
408
-
409
- export interface Mailer {
410
- /** Sends the link; throws when the provider refuses, and gives up once `signal` aborts. */
411
- sendLink(mail: Mail, signal: AbortSignal): Promise<void>
412
- /** Sends a fixed test message to `to`, the same way. */
413
- sendTest(to: string, signal: AbortSignal): Promise<void>
431
+ export interface Links {
432
+ /** The address a link's path (`/p/#<key>`) is opened at: the application's public origin before it. */
433
+ address(path: string): string
414
434
  /** Seals a link's URL for the row that waits to e-mail it; `open` reads it back. */
415
435
  seal(url: string): Promise<string>
416
436
  open(sealed: string): Promise<string>
417
437
  }
418
438
 
419
- interface Message {
420
- readonly to: string
421
- readonly subject: string
422
- readonly text: string
423
- }
424
-
425
- /** Read when a mail is sent: the generated tests boot the application without its variables. */
439
+ /** Read when a link is used: the generated tests boot the application without its variables. */
426
440
  const variable = (env: Readonly<Record<string, unknown>>, name: string) => {
427
441
  const value = env[name]
428
442
  if (typeof value !== 'string' || value === '') {
@@ -431,7 +445,7 @@ const variable = (env: Readonly<Record<string, unknown>>, name: string) => {
431
445
  return value
432
446
  }
433
447
 
434
- export const mailer = defineDeps((env): Mailer => {
448
+ export const links = defineDeps((env): Links => {
435
449
  // LINK_SEAL_KEY is 32 random bytes in base64: `openssl rand -base64 32`.
436
450
  const sealKey = () =>
437
451
  crypto.subtle.importKey(
@@ -441,49 +455,8 @@ export const mailer = defineDeps((env): Mailer => {
441
455
  false,
442
456
  ['encrypt', 'decrypt'],
443
457
  )
444
- const send = async (
445
- message: Message,
446
- idempotencyKey: string | null,
447
- signal: AbortSignal,
448
- ) => {
449
- const response = await fetch(variable(env, 'MAIL_API_URL'), {
450
- method: 'POST',
451
- headers: {
452
- Authorization: `Bearer ${variable(env, 'MAIL_API_KEY')}`,
453
- 'Content-Type': 'application/json',
454
- ...(idempotencyKey === null ? {} : { 'Idempotency-Key': idempotencyKey }),
455
- },
456
- body: JSON.stringify(message),
457
- signal,
458
- })
459
- if (!response.ok) {
460
- throw new Error(`the mail provider answered ${response.status}`)
461
- }
462
- }
463
458
  return {
464
- sendLink: (mail, signal) =>
465
- send(
466
- {
467
- to: mail.to,
468
- subject: 'Confirm your address',
469
- text: `Open ${
470
- variable(env, 'INTAKE_ORIGIN')
471
- }${mail.url} to confirm your address.`,
472
- },
473
- mail.idempotencyKey,
474
- signal,
475
- ),
476
- // Each click is a new message: the test e-mail takes no key.
477
- sendTest: (to, signal) =>
478
- send(
479
- {
480
- to,
481
- subject: 'A test e-mail from the intake',
482
- text: 'The intake can send you e-mail.',
483
- },
484
- null,
485
- signal,
486
- ),
459
+ address: (path) => `${variable(env, 'INTAKE_ORIGIN')}${path}`,
487
460
  async seal(url) {
488
461
  const iv = crypto.getRandomValues(new Uint8Array(12))
489
462
  const sealed = await crypto.subtle.encrypt(
@@ -513,15 +486,16 @@ import { serve } from '@fabrika/app/implement'
513
486
  import { scoped } from '@fabrika/db'
514
487
  import { contract } from '../fabrika.contract'
515
488
  import { tables } from './db/tables'
516
- import { mailer } from './mail'
489
+ import { links } from './links'
517
490
 
518
- export default serve(contract, { database: scoped(tables), deps: mailer })
491
+ export default serve(contract, { database: scoped(tables), deps: links })
519
492
  ```
520
493
 
521
494
  ```ts src/intake/endpoints.ts
495
+ import { EmailDeliveryError } from '@fabrika/app/implement'
522
496
  import { z } from 'zod/v4'
523
497
  import { app } from '../app-kit'
524
- import { mailer } from '../mail'
498
+ import { links } from '../links'
525
499
 
526
500
  const Queued = z.strictObject({
527
501
  id: z.string(),
@@ -535,7 +509,7 @@ const State = z.strictObject({ linkState: z.string() })
535
509
  const Due = z.strictObject({
536
510
  id: z.string(),
537
511
  email: z.string(),
538
- linkState: z.enum(['due', 'issued']),
512
+ linkState: z.enum(['due', 'issued', 'sending']),
539
513
  linkId: z.nullable(z.string()),
540
514
  linkSeal: z.nullable(z.string()),
541
515
  revokeLinkId: z.nullable(z.string()),
@@ -566,22 +540,23 @@ export const intake = app.cron('* * * * *', {
566
540
  retries: 0,
567
541
  sql: 'write',
568
542
  issues: 'portal',
543
+ mailer: 'installation',
569
544
  })
570
545
  .handle(async ({ ctx }) => {
571
- const mail = mailer.get()
546
+ const urls = links.get()
572
547
  /**
573
548
  * One record's work. A failure is the record's (`failed` counts it) and the run goes on
574
549
  * with the next record, unless the attempt itself ended: the next run meets it as it is.
575
550
  */
576
551
  const eachRecord = async (
577
552
  work: () => Promise<void>,
578
- failed: () => Promise<void>,
553
+ failed: (error: unknown) => Promise<void>,
579
554
  ): Promise<void> => {
580
555
  try {
581
556
  await work()
582
557
  } catch (error) {
583
558
  if (ctx.signal.aborted) throw error
584
- await failed()
559
+ await failed(error)
585
560
  }
586
561
  }
587
562
  // A registration becomes a candidate once; the form told the visitor nothing either way.
@@ -630,29 +605,47 @@ export const intake = app.cron('* * * * *', {
630
605
  await tx.sql(Id)`
631
606
  update candidates
632
607
  set link_state = 'due', link_id = null, link_seal = null, attempts = 0,
633
- revoke_link_id = coalesce(link_id, revoke_link_id)
608
+ next_attempt_at = now(), revoke_link_id = coalesce(link_id, revoke_link_id)
634
609
  where email = ${ask.email} and verified_at is null
635
610
  and link_state in ('sent', 'failed')
636
611
  and (sends < ${SENDS_PER_DAY} or window_started_at < now() - interval '1 day')
637
612
  returning id`
638
613
  }
639
614
  })
640
- // Only a row whose budget has an e-mail left: spent rows would fill the batch and starve the rest.
615
+ // An installation without a transport: the deliveries wait as they are and spend no attempt.
616
+ if (!ctx.mail.available) return
617
+ // Only a row whose budget has an e-mail left and whose backoff has passed: the others would
618
+ // fill the batch and starve the rest.
641
619
  const due = await ctx.transaction((tx) =>
642
620
  tx.sql(Due)`
643
621
  select id, email, link_state, link_id, link_seal, revoke_link_id
644
622
  from candidates
645
- where link_state in ('due', 'issued')
623
+ where link_state in ('due', 'issued', 'sending') and next_attempt_at <= now()
646
624
  and (sends < ${SENDS_PER_DAY} or window_started_at < now() - interval '1 day')
647
625
  order by id limit ${BATCH}`
648
626
  )
649
627
  for (const candidate of due) {
628
+ // A send whose outcome is unknown goes out again only over a transport that deduplicates its key.
629
+ if (candidate.linkState === 'sending' && !ctx.mail.capabilities.idempotent) {
630
+ await ctx.transaction((tx) =>
631
+ tx.sql(Id)`
632
+ update candidates set link_state = 'failed', link_seal = null, last_failure = 'send'
633
+ where id = ${candidate.id} and link_state = 'sending' returning id`
634
+ )
635
+ console.error(
636
+ `intake: the delivery to ${candidate.id} may have been sent; it is dead, and a resend gives a new link`,
637
+ )
638
+ continue
639
+ }
650
640
  let step: Step = 'reserve'
651
641
  await eachRecord(async () => {
652
- // The e-mail is charged before any effect; a row other work holds waits for a later run.
642
+ // The e-mail and the wait are charged before any effect, so a run that dies mid-send waits too;
643
+ // a row other work holds waits for a later run.
653
644
  const reserved = await ctx.transaction((tx) =>
654
645
  tx.sql(Id)`
655
646
  update candidates set
647
+ next_attempt_at = now()
648
+ + least(interval '1 minute' * power(2, attempts), interval '1 hour'),
656
649
  sends = case when window_started_at < now() - interval '1 day' then 1 else sends + 1 end,
657
650
  window_started_at = case when window_started_at < now() - interval '1 day'
658
651
  then now() else window_started_at end
@@ -664,7 +657,7 @@ export const intake = app.cron('* * * * *', {
664
657
  )
665
658
  if (reserved.length === 0) return
666
659
  // An issued link may already have been e-mailed: it goes out again, never replaced.
667
- const issuedBefore = candidate.linkState === 'issued'
660
+ const issuedBefore = candidate.linkState !== 'due'
668
661
  && candidate.linkId !== null && candidate.linkSeal !== null
669
662
  ? { id: candidate.linkId, seal: candidate.linkSeal }
670
663
  : null
@@ -682,7 +675,7 @@ export const intake = app.cron('* * * * *', {
682
675
  }
683
676
  let link: { readonly id: string; readonly url: string }
684
677
  if (issuedBefore !== null) {
685
- link = { id: issuedBefore.id, url: await mail.open(issuedBefore.seal) }
678
+ link = { id: issuedBefore.id, url: await urls.open(issuedBefore.seal) }
686
679
  } else {
687
680
  step = 'issue'
688
681
  ctx.signal.throwIfAborted()
@@ -691,7 +684,7 @@ export const intake = app.cron('* * * * *', {
691
684
  mount: 'portal',
692
685
  days: LINK_DAYS,
693
686
  })
694
- const sealed = await mail.seal(issued.url)
687
+ const sealed = await urls.seal(issued.url)
695
688
  await ctx.transaction((tx) =>
696
689
  tx.sql(Id)`
697
690
  update candidates
@@ -702,25 +695,41 @@ export const intake = app.cron('* * * * *', {
702
695
  }
703
696
  step = 'send'
704
697
  ctx.signal.throwIfAborted()
705
- await mail.sendLink({
698
+ await ctx.transaction((tx) =>
699
+ tx.sql(Id)`
700
+ update candidates set link_state = 'sending'
701
+ where id = ${candidate.id} and link_id = ${link.id} returning id`
702
+ )
703
+ await ctx.mail.send({
706
704
  to: candidate.email,
707
- url: link.url,
705
+ subject: 'Confirm your address',
706
+ text: `Open ${urls.address(link.url)} to confirm your address.`,
708
707
  idempotencyKey: `intake-link-${link.id}`,
709
- }, AbortSignal.any([ctx.signal, AbortSignal.timeout(SEND_TIMEOUT_MS)]))
708
+ }, AbortSignal.timeout(SEND_TIMEOUT_MS))
710
709
  await ctx.transaction((tx) =>
711
710
  tx.sql(Id)`
712
711
  update candidates
713
712
  set link_state = 'sent', link_seal = null, attempts = 0, last_failure = null
714
713
  where id = ${candidate.id} and link_id = ${link.id} returning id`
715
714
  )
716
- }, async () => {
715
+ }, async (error) => {
716
+ // The transport says whether a send may go again: a refused recipient, or an unknown outcome
717
+ // over a transport that does not deduplicate, ends the delivery now.
718
+ const final = error instanceof EmailDeliveryError
719
+ && !ctx.mail.mayRetry(error)
720
+ const notSent = error instanceof EmailDeliveryError
721
+ && error.outcome === 'not_sent'
717
722
  const [counted] = await ctx.transaction((tx) =>
718
723
  tx.sql(State)`
719
724
  update candidates
720
725
  set attempts = attempts + 1, last_failure = ${step},
721
- link_state = case when attempts + 1 >= ${MAX_ATTEMPTS}
722
- then 'failed' else link_state end,
723
- link_seal = case when attempts + 1 >= ${MAX_ATTEMPTS}
726
+ next_attempt_at = now()
727
+ + least(interval '1 minute' * power(2, attempts), interval '1 hour'),
728
+ link_state = case
729
+ when ${final} or attempts + 1 >= ${MAX_ATTEMPTS} then 'failed'
730
+ when link_state = 'sending' and ${notSent} then 'issued'
731
+ else link_state end,
732
+ link_seal = case when ${final} or attempts + 1 >= ${MAX_ATTEMPTS}
724
733
  then null else link_seal end
725
734
  where id = ${candidate.id} returning link_state`
726
735
  )
@@ -734,7 +743,7 @@ export const intake = app.cron('* * * * *', {
734
743
  })
735
744
  ```
736
745
 
737
- The job keeps six invariants. Keep them when you change it:
746
+ The job keeps eight invariants. Keep them when you change it:
738
747
 
739
748
  - **Each record is its own work, in bounded batches.** A run takes at most 50 registrations, oldest first, and 50 due
740
749
  deliveries; the next run takes the rest. Each record's work runs in `eachRecord`: a failure (a row the insert refuses,
@@ -743,30 +752,42 @@ The job keeps six invariants. Keep them when you change it:
743
752
  dead: a registration stays in its inbox and the batch skips it, a delivery is `failed`; the job logs each once, by
744
753
  id, never by address. An operator decides: fix the row, or delete it. A resend revives a `failed` delivery, within
745
754
  the budget. A failure of an ended attempt (`ctx.signal` aborted) is the attempt's, not the record's: it is thrown,
746
- and the next run meets the record as it is. So each e-mail gets its own deadline inside the attempt's
747
- (`AbortSignal.any([ctx.signal, AbortSignal.timeout(SEND_TIMEOUT_MS)])`): a recipient whose e-mail hangs fails its
748
- own attempt and reaches the dead state, and every later record of the run still goes.
755
+ and the next run meets the record as it is. So each e-mail gets its own deadline inside the attempt's: `ctx.mail.send`
756
+ always takes the attempt's `ctx.signal`, and the job adds `AbortSignal.timeout(SEND_TIMEOUT_MS)`. A recipient whose
757
+ e-mail hangs fails its own attempt and reaches the dead state, and every later record of the run still goes.
758
+ - **A failed delivery waits before the next try.** Its failure sets `next_attempt_at` one minute ahead, doubled per
759
+ attempt up to an hour, and the batch takes only a row whose time has come. Kicks can start several runs within
760
+ seconds; without the wait, a short provider outage would spend every attempt of a delivery at once.
761
+ - **The transport decides whether a send goes again.** `ctx.mail.mayRetry(error)` is false for a refused recipient
762
+ or configuration, and for an outcome that is unknown over a transport that does not deduplicate a key
763
+ (`ctx.mail.capabilities`): such a delivery is dead at once, and a resend gives it a new link. The row is `sending` while its e-mail is out: a run that finds a `sending` row (a crash, a
764
+ lost answer) sends it again only when `ctx.mail.capabilities.idempotent` holds, and otherwise leaves it dead for a
765
+ person, because the e-mail may have arrived. A failure the transport says did not send it (`outcome: 'not_sent'`)
766
+ returns the row to `issued`.
749
767
  - **A record is claimed where it is consumed.** Each registration is read again `for update skip locked` in the
750
768
  transaction that deletes it and inserts the candidate, the resend batch is `for update skip locked` in the delete that
751
769
  takes it, and the reservation skips a candidate row other work holds. A row someone else locks (an operator's
752
770
  session, an HR request) waits for a later run and blocks nothing behind it.
753
771
  - **The budget is charged first.** Each delivery reserves one e-mail of the address's daily budget, in its own
754
- transaction, before any effect. A delivery that fails after the e-mail left is still counted, so a provider that
755
- times out cannot make the job mail one address without end. Only a row with an e-mail left is selected for the batch
772
+ transaction, before any effect, and sets its `next_attempt_at` there too. A delivery that fails after the e-mail
773
+ left is still counted, so a provider that times out cannot make the job mail one address without end, and a run
774
+ that dies mid-send leaves a row that waits out its backoff instead of going again at the next kick. Only a row with an e-mail left is selected for the batch
756
775
  of 50, so spent rows never crowd out the others; the reservation checks the budget again in its own transaction.
757
776
  - **Each step is recorded before the next effect.** Revoke a superseded link, then record it; issue, then record the
758
- link's id and its sealed URL (`issued`); send, then record `sent`. A run that stops anywhere (a crash, a refused
777
+ link's id and its sealed URL (`issued`); record `sending`, send, then record `sent`. A run that stops anywhere (a crash, a refused
759
778
  e-mail, IAM down) is finished by the next run its budget allows.
760
- - **The link is the delivery's identity.** Its e-mail carries `Idempotency-Key: intake-link-<link id>`. Until the
779
+ - **The link is the delivery's identity.** Its e-mail's idempotency key is `intake-link-<link id>`; `ctx.mail` puts
780
+ the application environment's prefix before it, so no other application on the transport shares it. Until the
761
781
  e-mail is known sent, the row keeps the link's URL in `link_seal`, sealed with AES-GCM under `LINK_SEAL_KEY`, never
762
- in plain text, and cleared once it is `sent` or dead. An `issued` row is sent again from it: the same message under
763
- the same key, which the provider sends once, so an answer or a commit lost after the provider took the e-mail
764
- sends no second one, and the link the visitor got still works. Only a resend replaces a link. IAM's `issue` takes
782
+ in plain text, and cleared once it is `sent` or dead. An `issued` or `sending` row is sent again from it: the same
783
+ message under the same key, which an idempotent transport sends once, so an answer or a commit lost after the
784
+ provider took the e-mail sends no second one, and the link the visitor got still works. Only a resend replaces a link. IAM's `issue` takes
765
785
  no key: a run that stops between the issue and its record leaves a link no row names, whose key only the job saw
766
786
  and which expires with its `days`.
767
787
  - **An effect starts only while the attempt is live.** `ctx.signal.throwIfAborted()` runs before each IAM call and the
768
- e-mail, and the e-mail takes `ctx.signal`. The runner aborts a timed-out attempt before the next one starts, so no
769
- effect of a stale attempt follows the next one's. `ctx.links` itself refuses to call IAM for an ended attempt.
788
+ e-mail, and `ctx.mail.send` aborts with `ctx.signal`. `ctx.mail`, like `ctx.links`, refuses to send for an ended
789
+ attempt. The runner aborts a timed-out attempt before the next one starts, so no
790
+ effect of a stale attempt follows the next one's.
770
791
 
771
792
  More on the job:
772
793
 
@@ -784,10 +805,15 @@ More on the job:
784
805
  retry. A failed run leaves a pending kick to the schedule too.
785
806
  - **A revoked link** still works until the token the proxy holds for it expires, at most 5 minutes. Revocation is the
786
807
  stop at IAM; it is not instant.
787
- - The e-mail goes through `deps` (`src/mail.ts`), built from application variables the human sets (`INTAKE_ORIGIN`,
788
- `MAIL_API_URL`, `MAIL_API_KEY`, and `LINK_SEAL_KEY`: `openssl rand -base64 32`). A new `LINK_SEAL_KEY` makes the
789
- rows waiting with a sealed link fail their `open` until they are dead; a resend gives each a new link. `link.url` is `/p/#<key>`: the key is in the fragment, which no server and no
790
- `Referer` sees.
808
+ - **`mailer: 'installation'`** gives the job `ctx.mail`, over the installation's mail transport: the application holds
809
+ no provider key and no transport code, and the approver sees the declaration marked "needs closer review". An
810
+ installation without a transport still deploys it: `ctx.mail.available` is false, and the job delivers nothing and
811
+ leaves every delivery as it is, so no attempt is spent. They leave on the first run after the operator adds a
812
+ transport and the application is deployed again. An endpoint has no `ctx.mail`.
813
+ - The link's address and its seal come from `deps` (`src/links.ts`), built from application variables the human sets
814
+ (`INTAKE_ORIGIN`, and `LINK_SEAL_KEY`: `openssl rand -base64 32`). A new `LINK_SEAL_KEY` makes the rows waiting with
815
+ a sealed link fail their `open` until they are dead; a resend gives each a new link. `link.url` is `/p/#<key>`: the
816
+ key is in the fragment, which no server and no `Referer` sees.
791
817
 
792
818
  ## The visitor's link: verify once, attach files
793
819
 
@@ -1336,38 +1362,153 @@ export const reasons = app.group({ callers: ['human'] }, (r) => ({
1336
1362
  ## A test e-mail to yourself
1337
1363
 
1338
1364
  ```ts src/settings/endpoints.ts
1365
+ import { EmailDeliveryError } from '@fabrika/app/implement'
1339
1366
  import { z } from 'zod/v4'
1340
1367
  import { app } from '../app-kit'
1341
- import { mailer } from '../mail'
1342
1368
 
1343
- const Sent = z.strictObject({ sentTo: z.string() })
1369
+ const Queued = z.strictObject({ queuedFor: z.string() })
1370
+ const Id = z.strictObject({ id: z.string() })
1371
+ const Claimed = z.strictObject({
1372
+ recipient: z.string(),
1373
+ state: z.string(),
1374
+ attempts: z.number(),
1375
+ })
1376
+
1377
+ /** The test e-mails a run takes; the next run takes the rest. */
1378
+ const BATCH = 50
1379
+ /** A test e-mail that failed this many attempts is dead: the job leaves it to an operator. */
1380
+ const MAX_ATTEMPTS = 5
1381
+ /** How long one e-mail may take: far inside the attempt's timeout. */
1382
+ const SEND_TIMEOUT_MS = 10000
1344
1383
 
1345
1384
  export const settings = app.group({ callers: ['human'] }, (r) => ({
1346
- testMail: r.mutation({ output: Sent, access: { member: true } })
1385
+ /** Records a test e-mail to the caller; the job it kicks sends it within seconds. */
1386
+ testMail: r.mutation({
1387
+ output: Queued,
1388
+ access: { action: 'mail.test', app: true },
1389
+ then: 'testMails',
1390
+ })
1347
1391
  .handle(async ({ ctx }) => {
1348
1392
  const to = ctx.principal.email
1349
- await mailer.get().sendTest(to, ctx.signal)
1350
- return { sentTo: to }
1393
+ await ctx.db.app.test_mails.insert({ recipient: to })
1394
+ return { queuedFor: to }
1351
1395
  }),
1352
1396
  }))
1397
+
1398
+ /** A failed send: dead, due again when it did not leave, or `sending` when it may have. */
1399
+ const stateAfter = (
1400
+ failure: unknown,
1401
+ mayRetry: boolean,
1402
+ attempts: number,
1403
+ ): 'due' | 'sending' | 'failed' => {
1404
+ if (!mayRetry || attempts >= MAX_ATTEMPTS) return 'failed'
1405
+ return failure instanceof EmailDeliveryError && failure.outcome === 'not_sent'
1406
+ ? 'due'
1407
+ : 'sending'
1408
+ }
1409
+
1410
+ export const testMails = app.cron('* * * * *', {
1411
+ actions: ['mail.send'],
1412
+ scope: 'app',
1413
+ retries: 0,
1414
+ sql: 'write',
1415
+ mailer: 'installation',
1416
+ })
1417
+ .handle(async ({ ctx }) => {
1418
+ // An installation without a transport: the rows wait as they are and spend no attempt.
1419
+ if (!ctx.mail.available) return
1420
+ const due = await ctx.transaction((tx) =>
1421
+ tx.sql(Id)`
1422
+ select id from test_mails
1423
+ where state in ('due', 'sending') and next_attempt_at <= now()
1424
+ order by created_at, id limit ${BATCH}`
1425
+ )
1426
+ for (const { id } of due) {
1427
+ // Claimed and charged before the send: a failure waits out its backoff, however many kicks arrive.
1428
+ const mail = await ctx.transaction(async (tx) => {
1429
+ const [row] = await tx.sql(Claimed)`
1430
+ select recipient, state, attempts from test_mails
1431
+ where id = ${id} and state in ('due', 'sending') and next_attempt_at <= now()
1432
+ for update skip locked`
1433
+ if (row === undefined) return null
1434
+ // A send whose outcome is unknown goes out again only over a transport that deduplicates its key.
1435
+ if (row.state === 'sending' && !ctx.mail.capabilities.idempotent) {
1436
+ await tx.sql(Id)`
1437
+ update test_mails set state = 'failed', last_failure = 'unknown'
1438
+ where id = ${id} returning id`
1439
+ console.error(
1440
+ `intake: test e-mail ${id} may have been sent; an operator decides`,
1441
+ )
1442
+ return null
1443
+ }
1444
+ await tx.sql(Id)`
1445
+ update test_mails set state = 'sending', attempts = attempts + 1,
1446
+ next_attempt_at = now()
1447
+ + least(interval '1 minute' * power(2, attempts), interval '1 hour')
1448
+ where id = ${id} returning id`
1449
+ return { recipient: row.recipient, attempts: row.attempts + 1 }
1450
+ })
1451
+ if (mail === null) continue
1452
+ let failure: unknown = null
1453
+ try {
1454
+ ctx.signal.throwIfAborted()
1455
+ await ctx.mail.send({
1456
+ to: mail.recipient,
1457
+ subject: 'A test e-mail from the intake',
1458
+ text: 'The intake can send you e-mail.',
1459
+ idempotencyKey: `test-mail-${id}`,
1460
+ }, AbortSignal.timeout(SEND_TIMEOUT_MS))
1461
+ } catch (error) {
1462
+ if (ctx.signal.aborted) throw error
1463
+ failure = error
1464
+ }
1465
+ if (failure === null) {
1466
+ await ctx.transaction((tx) =>
1467
+ tx.sql(Id)`
1468
+ update test_mails set state = 'sent', last_failure = null
1469
+ where id = ${id} returning id`
1470
+ )
1471
+ continue
1472
+ }
1473
+ const state = stateAfter(failure, ctx.mail.mayRetry(failure), mail.attempts)
1474
+ const code = failure instanceof EmailDeliveryError ? failure.code : 'send'
1475
+ await ctx.transaction((tx) =>
1476
+ tx.sql(Id)`
1477
+ update test_mails set state = ${state}, last_failure = ${code}
1478
+ where id = ${id} returning id`
1479
+ )
1480
+ if (state === 'failed') {
1481
+ console.error(`intake: test e-mail ${id} is dead; an operator decides`)
1482
+ }
1483
+ }
1484
+ })
1353
1485
  ```
1354
1486
 
1355
1487
  - **The address is the caller's, from the token.** `ctx.principal.email` is the mailbox IAM recorded for the person
1356
1488
  (lower-cased), so the input has no address field. IAM recorded it; how far it checked that the person controls it is
1357
- its login method's (an operator may accept an IdP's unverified address). An input that named one would let any member mail any address
1358
- past every consent the application asks for. Never take a recipient from the input for "send it to me".
1359
- - `{ member: true }` admits any member of the application and reaches no data: the endpoint mails only the caller.
1360
- The generated tests give a `member` endpoint no case, so the test below is yours. That is also why it may call the
1361
- provider itself: the generated cases boot without the mail variables (`references/endpoint.md`). The call takes
1362
- `ctx.signal`, so it stops when the person leaves or the request runs past its 10 s.
1363
- - The page shows the button only to someone signed in: its SPA reads `/hr/__fabrika-me.json` (`references/app-setup.md`).
1489
+ its login method's (an operator may accept an IdP's unverified address). An input that named one would let anyone
1490
+ who holds `mail.test` mail any address past every consent the application asks for. Never take a recipient from the
1491
+ input for "send it to me".
1492
+ - **The endpoint sends nothing.** It has no `ctx.mail`: it records the e-mail in `test_mails` in its own transaction,
1493
+ and `then: 'testMails'` kicks the job once that transaction committed. The person's answer does not wait for the
1494
+ run; the e-mail leaves seconds later. The schedule (`* * * * *`) sends what a kicked run left behind. The generated
1495
+ tests cover the endpoint like any `app` endpoint: a global holder of `mail.test` reaches it, a holder in one scope
1496
+ gets 403.
1497
+ - **The job is the outbox recipe in small** (`references/jobs.md`): bounded batches; each row claimed
1498
+ `for update skip locked` and charged (`attempts`, `next_attempt_at` one minute ahead, doubled per attempt up to an
1499
+ hour) before the send, so kicks cannot spend its attempts within seconds; the key `test-mail-<id>`; `sending` while
1500
+ the e-mail is out; and a retry only where `ctx.mail.mayRetry(error)` allows it. A row found `sending` (a crash, a
1501
+ lost answer) goes out again only over a transport that deduplicates its key, and is otherwise dead, because the
1502
+ e-mail may have arrived. `last_failure` keeps the transport's fixed code (`provider_rejected`), never its message.
1503
+ - The page shows the button only to someone who holds `mail.test`: its SPA reads `/hr/__fabrika-me.json`
1504
+ (`references/app-setup.md`).
1364
1505
 
1365
1506
  ## Tests
1366
1507
 
1367
1508
  - The generated tests cover HR's endpoints, the files and the link's procedures: the endpoint access kit asks each
1368
1509
  link procedure as a holder of a key bound to a seeded candidate. Each `app` endpoint gets two cases: a holder of
1369
- its action in one candidate only is 403, and a global holder reaches it. `positions`, `reasons` and
1370
- `candidate_exports` get a policy probe of their actions, held globally and in one scope, and of a scoped site that
1510
+ its action in one candidate only is 403, and a global holder reaches it. `positions`, `reasons`,
1511
+ `candidate_exports` and `test_mails` get a policy probe of their actions, held globally and in one scope, and of a scoped site that
1371
1512
  reads every row with the read action and none with another. The probe of `candidates` also lists every candidate
1372
1513
  from an `app` endpoint of `candidates.export`, its `appRead`. An endpoint that `issues` gets the cases of any `on`
1373
1514
  endpoint: the generated tests' stand-in IAM issues its link (`references/tests.md`).
@@ -1382,56 +1523,44 @@ export const settings = app.group({ callers: ['human'] }, (r) => ({
1382
1523
  TURNSTILE_SECRET: '…', TURNSTILE_SITE_KEY: '…' }` and `challengeFetch`, a stand-in for Turnstile's answer, to `testKit`, and a fresh
1383
1524
  `challengeToken` with each submission: a token is single use. Test each stop of a delivery (a refused e-mail, IAM
1384
1525
  down, a crash after each step, an answer lost after the provider took the e-mail): the next run must leave exactly
1385
- one live, e-mailed link and the provider one message per key. Test a bad record beside good ones too: a registration
1386
- the insert refuses, a recipient the provider always refuses and one whose e-mail hangs end dead after `MAX_ATTEMPTS`
1387
- runs, while every other record is delivered and every run succeeds; a registration another session locks waits.
1388
- - A test that submits a form kicks the job too, and the kicked run may start at once: arm a fault before the
1389
- submission, never between it and the run. `kit.runJob('intake')` first waits for the kicked runs, then runs once.
1390
- - The e-mail provider is a stand-in too: `testKit(app, { env: { MAIL_API_URL, MAIL_API_KEY: 'test', INTAKE_ORIGIN:
1391
- 'https://intake.test', LINK_SEAL_KEY } })` points the mailer at a test URL, which `standInMail()` answers, one
1392
- message per idempotency key as a provider does; a test's `LINK_SEAL_KEY` is `Buffer.alloc(32, 1).toString('base64')`.
1393
- Call its `restore()` in `afterEach`.
1394
- - The test e-mail goes to the caller's own address: the test sends no address and reads the recipient from the stand-in.
1395
-
1396
- ```ts src/__tests__/mail-stand-in.ts
1397
- import { spyOn } from 'bun:test'
1398
-
1399
- /** The mail provider of a test; `env.MAIL_API_URL` points the mailer here. */
1400
- export const MAIL_API_URL = 'https://mail.test/send'
1401
-
1402
- /**
1403
- * Answers the mail provider's URL and keeps each mail with its idempotency key, sending one per key as the provider
1404
- * does; every other request goes on to the real `fetch`.
1405
- */
1406
- export const standInMail = () => {
1407
- const sent: { readonly key: string | null; readonly body: string }[] = []
1408
- const passOn = globalThis.fetch
1409
- const answer = async (...args: Parameters<typeof fetch>) => {
1410
- const request = new Request(...args)
1411
- if (request.url !== MAIL_API_URL) return passOn(...args)
1412
- const key = request.headers.get('Idempotency-Key')
1413
- if (!sent.some((mail) => mail.key !== null && mail.key === key)) {
1414
- sent.push({ key, body: await request.text() })
1415
- }
1416
- return new Response(null, { status: 202 })
1417
- }
1418
- const spy = spyOn(globalThis, 'fetch').mockImplementation(
1419
- Object.assign(answer, { preconnect: passOn.preconnect }),
1420
- )
1421
- return { sent, restore: () => spy.mockRestore() }
1422
- }
1423
- ```
1526
+ one live, e-mailed link and the transport one message per key. Test a bad record beside good ones too: a
1527
+ registration the insert refuses and a recipient whose e-mail hangs end dead after `MAX_ATTEMPTS` runs, a recipient
1528
+ the transport refuses ends dead at once, and every other record is delivered and every run succeeds; a
1529
+ registration another session locks waits. A failed delivery waits for its `next_attempt_at`: a test that runs the
1530
+ job again moves that column into the past first, as an operator's repair would.
1531
+ - A test that submits a form, or calls a mutation that declares `then`, kicks the job too, and the kicked run may start
1532
+ at once: arm a fault before the call, never between it and the run. `kit.drain()` waits for the kicked runs, and
1533
+ `kit.runJob('intake')` first waits for them, then runs once.
1534
+ - The mail transport is the kit's stand-in: `kit.mail`, a `MemoryEmailSender` that takes every message a job sends
1535
+ through `ctx.mail`, once per key as an idempotent provider does. `kit.mail.accepted` is what reached the inbox and
1536
+ `kit.mail.handoffs` every send with its answer. `refuse(code?)`, `unavailable()`, `unreachable()`, `loseAnswers(n)`
1537
+ and `during(hook)` inject a failure until `heal()`. A message carries the key the transport got,
1538
+ `transportMailKey(kit.mailKeyScope, key)`. A transport that does not deduplicate is
1539
+ `testKit(app, { mail: new MemoryEmailSender({ capabilities: { idempotent: false, confirmation: 'queued' } }) })`:
1540
+ test there that a lost answer leaves a delivery dead, never sent twice. `mail: 'none'` is an installation without a
1541
+ transport: test that its run leaves the rows queued, and that a kit with a transport over the same database sends
1542
+ them. One served application boots one kit, so the second kit serves the contract again (`serve(contract, { … })`,
1543
+ as `src/app.ts` does).
1544
+ - `LINK_SEAL_KEY` and `INTAKE_ORIGIN` are application variables: a test of the job passes them in `testKit`'s `env`,
1545
+ `LINK_SEAL_KEY` as `Buffer.alloc(32, 1).toString('base64')`.
1546
+ - The test e-mail goes to the caller's own address: the test sends no address and reads the recipient from `kit.mail`.
1424
1547
 
1425
1548
  ```ts src/__tests__/settings.test.ts
1426
1549
  import { createClient } from '@fabrika/app/client'
1427
- import { type IamMembershipRpc, testKit } from '@fabrika/app/testing'
1550
+ import { serve } from '@fabrika/app/implement'
1551
+ import {
1552
+ type IamMembershipRpc,
1553
+ testKit,
1554
+ transportMailKey,
1555
+ } from '@fabrika/app/testing'
1556
+ import { scoped } from '@fabrika/db'
1428
1557
  import { testDatabase } from '@fabrika/db/testing'
1429
1558
  import { MemoryFileObjects } from '@fabrika/platform/testing'
1430
1559
  import { afterAll, afterEach, describe, expect, test } from 'bun:test'
1431
- import { z } from 'zod/v4'
1432
1560
  import { contract } from '../../fabrika.contract'
1433
1561
  import app from '../app'
1434
- import { MAIL_API_URL, standInMail } from './mail-stand-in'
1562
+ import { tables } from '../db/tables'
1563
+ import { links } from '../links'
1435
1564
 
1436
1565
  const postgres = await testDatabase({
1437
1566
  migrations: new URL('../../db/migrations/', import.meta.url),
@@ -1453,42 +1582,82 @@ const membership: IamMembershipRpc = {
1453
1582
  revokeLink: refused,
1454
1583
  }
1455
1584
 
1456
- const Mail = z.strictObject({
1457
- to: z.string(),
1458
- subject: z.string(),
1459
- text: z.string(),
1460
- })
1461
-
1462
1585
  if (postgres !== null) {
1463
1586
  afterAll(() => postgres.close())
1464
1587
  const kit = testKit(app, {
1465
1588
  database: postgres.database,
1466
1589
  files: new MemoryFileObjects(),
1467
1590
  membership,
1468
- env: {
1469
- MAIL_API_URL,
1470
- MAIL_API_KEY: 'test',
1471
- INTAKE_ORIGIN: 'https://intake.test',
1472
- TURNSTILE_SECRET: 'test',
1473
- TURNSTILE_SITE_KEY: 'test-site-key',
1474
- },
1591
+ env: { TURNSTILE_SECRET: 'test', TURNSTILE_SITE_KEY: 'test-site-key' },
1475
1592
  })
1476
- let restore = () => {}
1477
- afterEach(() => restore())
1593
+ afterEach(() => kit.mail.heal())
1594
+ const sentTo = (address: string) =>
1595
+ kit.mail.handoffs.filter((handoff) => handoff.message.to === address)
1478
1596
 
1479
1597
  describe('the test e-mail', () => {
1480
1598
  test("goes to the caller's own address, which the call does not name", async () => {
1481
- const mail = standInMail()
1482
- restore = mail.restore
1483
1599
  const lead = kit.testCaller([{ role: 'hr_lead' }], {
1484
1600
  email: 'Lead@Intake.test',
1485
1601
  })
1486
1602
  const api = createClient(contract, 'api', { fetch: kit.browser(lead) })
1487
- expect(await api.settings.testMail()).toEqual({ sentTo: 'lead@intake.test' })
1488
- expect(mail.sent.map((sent) => Mail.parse(JSON.parse(sent.body)).to))
1489
- .toEqual([
1490
- 'lead@intake.test',
1491
- ])
1603
+ expect(await api.settings.testMail()).toEqual({
1604
+ queuedFor: 'lead@intake.test',
1605
+ })
1606
+ // The mutation kicked the job: the e-mail leaves without a scheduled run.
1607
+ await kit.drain()
1608
+ const [sent] = sentTo('lead@intake.test')
1609
+ expect(sent?.answer).toBe('accepted')
1610
+ expect(sent?.message.idempotencyKey)
1611
+ .toStartWith(transportMailKey(kit.mailKeyScope, 'test-mail-'))
1612
+ })
1613
+
1614
+ test('waits while the installation has no mail, and leaves once it has a transport', async () => {
1615
+ // One served application boots one kit: this one serves the contract again.
1616
+ const noMail = testKit(
1617
+ serve(contract, { database: scoped(tables), deps: links }),
1618
+ {
1619
+ database: postgres.database,
1620
+ files: new MemoryFileObjects(),
1621
+ membership,
1622
+ env: { TURNSTILE_SECRET: 'test', TURNSTILE_SITE_KEY: 'test-site-key' },
1623
+ mail: 'none',
1624
+ },
1625
+ )
1626
+ const admin = noMail.testCaller([{ role: 'hr_admin' }], {
1627
+ email: 'waiting@intake.test',
1628
+ })
1629
+ const api = createClient(contract, 'api', { fetch: noMail.browser(admin) })
1630
+ await api.settings.testMail()
1631
+ await noMail.drain()
1632
+ expect(await noMail.runJob('testMails')).toEqual({
1633
+ status: 'succeeded',
1634
+ attempts: 1,
1635
+ })
1636
+ // The same database under a transport: the row that waited leaves.
1637
+ expect(await kit.runJob('testMails')).toEqual({
1638
+ status: 'succeeded',
1639
+ attempts: 1,
1640
+ })
1641
+ expect(sentTo('waiting@intake.test').map((handoff) => handoff.answer))
1642
+ .toEqual(['accepted'])
1643
+ })
1644
+
1645
+ test('one the transport refuses is dead: no later run sends it', async () => {
1646
+ // Armed before the call: the kicked run may start at once.
1647
+ kit.mail.refuse('provider_rejected')
1648
+ const admin = kit.testCaller([{ role: 'hr_admin' }], {
1649
+ email: 'admin@intake.test',
1650
+ })
1651
+ const api = createClient(contract, 'api', { fetch: kit.browser(admin) })
1652
+ await api.settings.testMail()
1653
+ await kit.drain()
1654
+ kit.mail.heal()
1655
+ expect(await kit.runJob('testMails')).toEqual({
1656
+ status: 'succeeded',
1657
+ attempts: 1,
1658
+ })
1659
+ expect(sentTo('admin@intake.test').map((handoff) => handoff.answer))
1660
+ .toEqual(['provider_rejected'])
1492
1661
  })
1493
1662
  })
1494
1663
  }