@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 +9 -8
- package/skill/SKILL.md +12 -6
- package/skill/references/app-setup.md +11 -10
- package/skill/references/dev.md +20 -11
- package/skill/references/endpoint.md +6 -4
- package/skill/references/files.md +39 -35
- package/skill/references/jobs.md +70 -12
- package/skill/references/public-intake.md +360 -191
- package/skill/references/tests.md +10 -1
- package/src/bun.ts +27 -2
- package/src/contract/artifact-schema.ts +3 -0
- package/src/contract/build.ts +51 -5
- package/src/contract/context.ts +21 -6
- package/src/contract/declaration.ts +6 -2
- package/src/contract/group.ts +22 -4
- package/src/contract/kit.ts +19 -4
- package/src/implement/boot.ts +38 -1
- package/src/implement/dispatch.ts +15 -3
- package/src/implement/implement.ts +3 -0
- package/src/implement/index.ts +10 -1
- package/src/implement/invocation.ts +38 -6
- package/src/implement/serve.ts +4 -0
- package/src/jobs/mail.ts +261 -0
- package/src/jobs/runner.ts +129 -32
- package/src/jobs/types.ts +32 -0
- package/src/testing/dev.ts +55 -1
- package/src/testing/endpoint-access.ts +16 -1
- package/src/testing/index.ts +3 -0
- package/src/testing/kit.ts +44 -4
- package/src/testing/membership.ts +13 -3
|
@@ -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
|
|
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'`),
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
|
98
|
-
-- a delivery that failed five times is 'failed', which an
|
|
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/
|
|
428
|
+
```ts src/links.ts
|
|
400
429
|
import { defineDeps } from '@fabrika/app/implement'
|
|
401
430
|
|
|
402
|
-
export interface
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
489
|
+
import { links } from './links'
|
|
517
490
|
|
|
518
|
-
export default serve(contract, { database: scoped(tables), deps:
|
|
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 {
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
755
|
-
times out cannot make the job mail one address without end
|
|
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
|
|
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
|
|
763
|
-
the same key, which
|
|
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
|
|
769
|
-
|
|
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
|
-
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1350
|
-
return {
|
|
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
|
|
1358
|
-
past every consent the application asks for. Never take a recipient from the
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
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
|
|
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
|
|
1386
|
-
the insert refuses
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
1477
|
-
|
|
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({
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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
|
}
|