@fabrika/app 0.4.8 → 0.5.1
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
package/src/implement/index.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
// @fabrika/app/implement — serving a contract: `serve` binds the handlers its endpoints carry, `boot` (called only by
|
|
2
2
|
// the generated entrypoint, through a runtime adapter) serves it. Imports `node:async_hooks`, so it is not browser-safe.
|
|
3
3
|
// `implement` (a mirrored handler tree) is the framework's own test entry, `./implement`, and no application's.
|
|
4
|
+
export {
|
|
5
|
+
EmailDeliveryError,
|
|
6
|
+
type EmailDeliveryErrorCode,
|
|
7
|
+
type EmailDeliveryOutcome,
|
|
8
|
+
type EmailDeliveryResult,
|
|
9
|
+
type EmailSender,
|
|
10
|
+
type EmailSenderCapabilities,
|
|
11
|
+
mayRetryDelivery,
|
|
12
|
+
} from '@fabrika/email'
|
|
4
13
|
export { FILE_SWEEP_JOB } from '../files/collections'
|
|
5
14
|
export type { FileCollectionHandle, FileCollectionReader, FileListing, ListedFile, OpenedFile } from '../files/handle'
|
|
6
15
|
export {
|
|
@@ -15,7 +24,7 @@ export {
|
|
|
15
24
|
export { crashEnvelope, type OperationsReport, type OperationsReporter, reportEnvelope } from '../jobs/report'
|
|
16
25
|
export { type ContractJobs, type JobClock, JobError, type JobFailureCode, type JobRunOptions, type JobRunResult } from '../jobs/runner'
|
|
17
26
|
export { secretScrubber } from '../jobs/scrub'
|
|
18
|
-
export type { JobContext, JobHandler, JobHandlersOf, JobPrincipal } from '../jobs/types'
|
|
27
|
+
export type { AppMail, AppMailMessage, JobContext, JobHandler, JobHandlersOf, JobPrincipal } from '../jobs/types'
|
|
19
28
|
export { LINK_BOOTSTRAP_SCRIPT } from './anonymous'
|
|
20
29
|
export { boot, BootError, type BootOptions, type ContractApp, type IamKeySource } from './boot'
|
|
21
30
|
export { readBoundedBytes, REQUEST_BUDGET_MS } from './bounded'
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// commits it after the handler returned and its output passed validation, and rolls it back otherwise. Every handle
|
|
4
4
|
// is bound to the invocation: called from another request, another `boot`, or after it ended, it refuses.
|
|
5
5
|
|
|
6
|
-
import type
|
|
6
|
+
import { type ContractArtifact, JOB_MAILERS, type JobMailer } from '@fabrika/auth-core'
|
|
7
7
|
import {
|
|
8
8
|
type DataAccess,
|
|
9
9
|
type DataHandles,
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from '@fabrika/db/framework'
|
|
17
17
|
import type { DataKind, UnscopedMode } from '@fabrika/db/tables'
|
|
18
18
|
import type { SqlMode } from '../contract/context'
|
|
19
|
+
import { DECLARED_FIELDS } from '../contract/declaration'
|
|
19
20
|
import type { FileSite } from '../files/collections'
|
|
20
21
|
import { CapabilityRefusedError, isActiveRequest, type RequestFrame } from './capability'
|
|
21
22
|
import { ServerFault } from './faults'
|
|
@@ -26,6 +27,8 @@ export interface SiteDeclaration {
|
|
|
26
27
|
readonly unscoped: Readonly<Record<string, UnscopedMode>>
|
|
27
28
|
readonly connect: readonly string[]
|
|
28
29
|
readonly issues: string | null
|
|
30
|
+
readonly then: string | null
|
|
31
|
+
readonly mailer: JobMailer | null
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
export type SiteFunction = (args: { readonly input?: unknown; readonly ctx: object }) => unknown
|
|
@@ -53,8 +56,14 @@ export function isSiteHandler(node: unknown): node is SiteHandler {
|
|
|
53
56
|
return typeof node === 'object' && node !== null && sites.has(node)
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
const
|
|
57
|
-
|
|
59
|
+
const NO_DECLARATION: SiteDeclaration = Object.freeze({
|
|
60
|
+
sql: null,
|
|
61
|
+
unscoped: Object.freeze({}),
|
|
62
|
+
connect: Object.freeze([]),
|
|
63
|
+
issues: null,
|
|
64
|
+
then: null,
|
|
65
|
+
mailer: null,
|
|
66
|
+
})
|
|
58
67
|
|
|
59
68
|
function isPlainObject(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
60
69
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
@@ -81,7 +90,7 @@ export function parseDeclaration(where: string, declared: unknown, kind: DataKin
|
|
|
81
90
|
return NO_DECLARATION
|
|
82
91
|
}
|
|
83
92
|
for (const key of Object.keys(declared)) {
|
|
84
|
-
if (!DECLARED_FIELDS.
|
|
93
|
+
if (!DECLARED_FIELDS.some((field) => field === key)) {
|
|
85
94
|
problems.push(`${where}: '${key}' is no declaration (declarations: ${DECLARED_FIELDS.join(', ')})`)
|
|
86
95
|
}
|
|
87
96
|
}
|
|
@@ -122,11 +131,22 @@ export function parseDeclaration(where: string, declared: unknown, kind: DataKin
|
|
|
122
131
|
if (issues !== undefined && typeof issues !== 'string') {
|
|
123
132
|
problems.push(`${where}: issues is not the name of a link mount`)
|
|
124
133
|
}
|
|
134
|
+
const then = ownField(declared, 'then')
|
|
135
|
+
if (then !== undefined && typeof then !== 'string') {
|
|
136
|
+
problems.push(`${where}: then is not the name of a job`)
|
|
137
|
+
}
|
|
138
|
+
const mailerField = ownField(declared, 'mailer')
|
|
139
|
+
const mailer = JOB_MAILERS.find((transport) => transport === mailerField) ?? null
|
|
140
|
+
if (mailerField !== undefined && mailer === null) {
|
|
141
|
+
problems.push(`${where}: mailer is not 'installation' or 'own'`)
|
|
142
|
+
}
|
|
125
143
|
return Object.freeze({
|
|
126
144
|
sql: sql === 'read' || sql === 'write' ? sql : null,
|
|
127
145
|
unscoped: Object.freeze(unscoped),
|
|
128
146
|
connect: Object.freeze(connectors),
|
|
129
147
|
issues: typeof issues === 'string' ? issues : null,
|
|
148
|
+
then: typeof then === 'string' ? then : null,
|
|
149
|
+
mailer,
|
|
130
150
|
})
|
|
131
151
|
}
|
|
132
152
|
|
|
@@ -167,13 +187,25 @@ export function approvedReach(site: string, artifact: ContractArtifact): SiteDec
|
|
|
167
187
|
unscoped: Object.freeze({ ...reach.unscoped }),
|
|
168
188
|
connect: Object.freeze([...reach.connect]),
|
|
169
189
|
issues: reach.issues,
|
|
190
|
+
then: reach.then ?? null,
|
|
191
|
+
mailer: reach.mailer ?? null,
|
|
170
192
|
})
|
|
171
193
|
}
|
|
172
194
|
|
|
173
|
-
/**
|
|
195
|
+
/**
|
|
196
|
+
* A declaration as one comparable line: its fields in a fixed order, its tables and connectors sorted; `then` and
|
|
197
|
+
* `mailer` only when declared.
|
|
198
|
+
*/
|
|
174
199
|
function describeDeclaration(declaration: SiteDeclaration): string {
|
|
175
200
|
const unscoped = Object.entries(declaration.unscoped).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
176
|
-
return JSON.stringify({
|
|
201
|
+
return JSON.stringify({
|
|
202
|
+
sql: declaration.sql,
|
|
203
|
+
unscoped,
|
|
204
|
+
connect: [...declaration.connect].sort(),
|
|
205
|
+
issues: declaration.issues,
|
|
206
|
+
...(declaration.then === null ? {} : { then: declaration.then }),
|
|
207
|
+
...(declaration.mailer === null ? {} : { mailer: declaration.mailer }),
|
|
208
|
+
})
|
|
177
209
|
}
|
|
178
210
|
|
|
179
211
|
/**
|
package/src/implement/serve.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// `serve(contract, { database, deps })`: how `src/app.ts` serves a contract of the staged kit. Its handlers sit
|
|
2
2
|
// beside their endpoints, so there is no handler tree to pass: `serve` collects the one `boot` binds by endpoint id.
|
|
3
3
|
|
|
4
|
+
import type { EmailSender } from '@fabrika/email'
|
|
4
5
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
5
6
|
import { isProcedure, type ProcedureTreeDeclaration, SURFACE_SECTIONS } from '../contract/declaration'
|
|
6
7
|
import type { AnyContract, Schema } from '../contract/define'
|
|
@@ -61,6 +62,8 @@ export interface ServeOptions {
|
|
|
61
62
|
/** `scoped(tables)` of `@fabrika/db`, which `boot` binds, or `null` for a kit without tables (`defineApp(vocabulary)`). */
|
|
62
63
|
readonly database: ImplementationDatabase | null
|
|
63
64
|
readonly deps?: AppDeps<unknown>
|
|
65
|
+
/** The transport of the jobs that declare `mailer: 'own'`, built once at boot from the application's own variables. */
|
|
66
|
+
readonly mail?: (env: ApplicationEnvironment) => EmailSender | null
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
/**
|
|
@@ -86,6 +89,7 @@ export function serve<const C extends AnyContract>(contract: C, options: ServeOp
|
|
|
86
89
|
database: options.database,
|
|
87
90
|
connect: snapshotsOf(contract),
|
|
88
91
|
handlers: (env, runtime) => handlerTree(contract, build?.(env, runtime) ?? ((work) => work())),
|
|
92
|
+
...(options.mail === undefined ? {} : { mail: options.mail }),
|
|
89
93
|
})
|
|
90
94
|
}
|
|
91
95
|
|
package/src/jobs/mail.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// `ctx.mail` of a job that declares `mailer`, over the transport `boot` resolved: the installation's (`FABRIKA_EMAIL_*`),
|
|
2
|
+
// the application's own (`serve(contract, { mail })`) or the test kit's stand-in. An installation without a transport
|
|
3
|
+
// says so (`FABRIKA_EMAIL_PROVIDER=none`): its jobs get `ctx.mail` that is not available, and each send waits for one.
|
|
4
|
+
// Each key carries the prefix of the installation and the application environment, so senders on one provider account
|
|
5
|
+
// never share a key. Nothing here logs a message or a key.
|
|
6
|
+
|
|
7
|
+
import { type ContractArtifact, JOB_SITE_PREFIX, type JobMailer, sha256Hex } from '@fabrika/auth-core'
|
|
8
|
+
import {
|
|
9
|
+
EMAIL_PROVIDER_VARIABLE,
|
|
10
|
+
EmailDeliveryError,
|
|
11
|
+
type EmailDeliveryResult,
|
|
12
|
+
type EmailMessage,
|
|
13
|
+
type EmailProviderRegistry,
|
|
14
|
+
type EmailSender,
|
|
15
|
+
emailSenderFromEnv,
|
|
16
|
+
mayRetryDelivery,
|
|
17
|
+
NO_EMAIL_PROVIDER,
|
|
18
|
+
pickEmailEnvironment,
|
|
19
|
+
} from '@fabrika/email'
|
|
20
|
+
import { z } from 'zod/v4'
|
|
21
|
+
import type { ApplicationEnvironment } from '../implement/implement'
|
|
22
|
+
import type { AppMail, AppMailMessage } from './types'
|
|
23
|
+
|
|
24
|
+
export const MAX_APP_MAIL_KEY_LENGTH = 200
|
|
25
|
+
const PREFIX_DIGEST_LENGTH = 32
|
|
26
|
+
const APP_MAIL_KEY = new RegExp(`^[\\x21-\\x7E]{1,${MAX_APP_MAIL_KEY_LENGTH}}$`)
|
|
27
|
+
|
|
28
|
+
/** Whose keys a prefix separates: the installation, by IAM's public origin, and the application environment in it. */
|
|
29
|
+
export interface MailKeyScope {
|
|
30
|
+
/** `FABRIKA_IAM_ISSUER` as an origin: it tells installations, and the local stack, apart. */
|
|
31
|
+
readonly issuer: string
|
|
32
|
+
/** `FABRIKA_IAM_APP_ID`, `<app>@<env>`. */
|
|
33
|
+
readonly appId: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `fabrika:<the first 32 hex of sha256('<issuer>\n<app>@<env>')>:` — 41 characters, whatever the names hold. Renaming
|
|
38
|
+
* IAM's domain changes every key once, so a message in flight across the rename may be sent again.
|
|
39
|
+
*/
|
|
40
|
+
export function mailKeyPrefix(scope: MailKeyScope): string {
|
|
41
|
+
return `fabrika:${sha256Hex(`${scope.issuer}\n${scope.appId}`).slice(0, PREFIX_DIGEST_LENGTH)}:`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The key the transport receives for the application's `key`. */
|
|
45
|
+
export function transportMailKey(scope: MailKeyScope, key: string): string {
|
|
46
|
+
return `${mailKeyPrefix(scope)}${key}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A mailer job's transport: one that sends, or the stated absence of one, whose every send fails `mail_not_configured`. */
|
|
50
|
+
export interface JobMailTransport {
|
|
51
|
+
readonly sender: EmailSender
|
|
52
|
+
readonly available: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The transports of a contract's mailer jobs, resolved once by `boot`; `null` where no job declares that mailer. */
|
|
56
|
+
export interface MailTransports {
|
|
57
|
+
readonly installation: JobMailTransport | null
|
|
58
|
+
readonly own: JobMailTransport | null
|
|
59
|
+
readonly keyPrefix: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The test kit's transport for every mailer job: a stand-in sender, or `'none'` for an installation without mail. */
|
|
63
|
+
export type MailStandIn = EmailSender | 'none'
|
|
64
|
+
|
|
65
|
+
/** The application's own transport, built once at boot from its own variables; `null` when they configure none. */
|
|
66
|
+
export type OwnMailTransport = (env: ApplicationEnvironment) => EmailSender | null
|
|
67
|
+
|
|
68
|
+
export interface JobMailInput {
|
|
69
|
+
readonly transport: JobMailTransport
|
|
70
|
+
readonly keyPrefix: string
|
|
71
|
+
/** The attempt's `ctx.signal`: it aborts every send of the attempt. */
|
|
72
|
+
readonly signal: AbortSignal
|
|
73
|
+
/** Throws once the attempt has ended. */
|
|
74
|
+
readonly assertLive: () => void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function jobMail(input: JobMailInput): AppMail {
|
|
78
|
+
const { transport: { sender, available }, keyPrefix, signal: attempt, assertLive } = input
|
|
79
|
+
return Object.freeze({
|
|
80
|
+
available,
|
|
81
|
+
capabilities: sender.capabilities,
|
|
82
|
+
async send(message: AppMailMessage, signal?: AbortSignal): Promise<EmailDeliveryResult> {
|
|
83
|
+
assertLive()
|
|
84
|
+
const key: unknown = message.idempotencyKey
|
|
85
|
+
if (typeof key !== 'string' || !APP_MAIL_KEY.test(key)) {
|
|
86
|
+
throw new EmailDeliveryError(`Email idempotency key is not 1..${MAX_APP_MAIL_KEY_LENGTH} visible ASCII characters`, { code: 'invalid_message' })
|
|
87
|
+
}
|
|
88
|
+
const transported: EmailMessage = {
|
|
89
|
+
to: message.to,
|
|
90
|
+
subject: message.subject,
|
|
91
|
+
text: message.text,
|
|
92
|
+
...(message.html === undefined ? {} : { html: message.html }),
|
|
93
|
+
idempotencyKey: `${keyPrefix}${key}`,
|
|
94
|
+
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
|
|
95
|
+
...(message.fromName === undefined ? {} : { fromName: message.fromName }),
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return await sender.send(transported, signal === undefined ? attempt : AbortSignal.any([attempt, signal]))
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error instanceof EmailDeliveryError) {
|
|
101
|
+
throw error
|
|
102
|
+
}
|
|
103
|
+
// An application's own transport may throw anything, and its message may quote a credential: it is dropped unread.
|
|
104
|
+
throw new EmailDeliveryError('The transport failed', { code: 'provider_unavailable' })
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
mayRetry: (error: unknown): boolean => error instanceof EmailDeliveryError && mayRetryDelivery(error, sender.capabilities),
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface MailTransportInput {
|
|
112
|
+
readonly artifact: ContractArtifact
|
|
113
|
+
/** The framework's variables, whose `FABRIKA_EMAIL_*` configure the installation's transport. */
|
|
114
|
+
readonly framework: Readonly<Record<string, string>>
|
|
115
|
+
readonly application: ApplicationEnvironment
|
|
116
|
+
/** `serve(contract, { mail })`. */
|
|
117
|
+
readonly own: OwnMailTransport | undefined
|
|
118
|
+
/** The test kit's transport for every mailer job; the variables and `own` are then not read. */
|
|
119
|
+
readonly standIn: MailStandIn | undefined
|
|
120
|
+
readonly registry: EmailProviderRegistry
|
|
121
|
+
/** Whose keys the prefix separates. */
|
|
122
|
+
readonly keyScope: MailKeyScope
|
|
123
|
+
/** A local composition: a missing transport is no mail, as `FABRIKA_EMAIL_PROVIDER=none` is, instead of a refused boot. */
|
|
124
|
+
readonly local: boolean
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The transports of the contract's mailer jobs, or why one of them cannot send, added to `problems`. */
|
|
128
|
+
export function resolveMailTransports(input: MailTransportInput, problems: string[]): MailTransports {
|
|
129
|
+
const keyPrefix = mailKeyPrefix(input.keyScope)
|
|
130
|
+
const jobs = mailerJobs(input.artifact)
|
|
131
|
+
if (input.own === undefined) {
|
|
132
|
+
for (const name of jobs.own) {
|
|
133
|
+
problems.push(`job '${name}' declares mailer: 'own', and serve(contract, { … }) has no mail: pass mail: (env) => EmailSender`)
|
|
134
|
+
}
|
|
135
|
+
} else if (jobs.own.length === 0) {
|
|
136
|
+
problems.push(`serve(contract, { mail }) builds a transport no job uses: declare mailer: 'own' on the job that sends, or remove mail`)
|
|
137
|
+
}
|
|
138
|
+
if (input.standIn !== undefined) {
|
|
139
|
+
const standIn = input.standIn === 'none' ? NO_MAIL : { sender: input.standIn, available: true }
|
|
140
|
+
return {
|
|
141
|
+
installation: jobs.installation.length === 0 ? null : standIn,
|
|
142
|
+
own: jobs.own.length === 0 ? null : standIn,
|
|
143
|
+
keyPrefix,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
installation: jobs.installation.length === 0 ? null : installationTransport(jobs.installation, input, problems),
|
|
148
|
+
own: jobs.own.length === 0 ? null : ownTransport(jobs.own, input, problems),
|
|
149
|
+
keyPrefix,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The names of the jobs that declare each mailer, by the approved artifact. */
|
|
154
|
+
function mailerJobs(artifact: ContractArtifact): Readonly<Record<JobMailer, readonly string[]>> {
|
|
155
|
+
const jobs: Record<JobMailer, string[]> = { installation: [], own: [] }
|
|
156
|
+
for (const name of Object.keys(artifact.jobs)) {
|
|
157
|
+
const mailer = artifact.declared?.[`${JOB_SITE_PREFIX}${name}`]?.mailer
|
|
158
|
+
if (mailer !== undefined) {
|
|
159
|
+
jobs[mailer].push(name)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return jobs
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function installationTransport(names: readonly string[], input: MailTransportInput, problems: string[]): JobMailTransport | null {
|
|
166
|
+
const env = pickEmailEnvironment(input.framework)
|
|
167
|
+
let sender: EmailSender | null
|
|
168
|
+
try {
|
|
169
|
+
sender = emailSenderFromEnv(env, input.registry)
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (!(error instanceof EmailDeliveryError)) {
|
|
172
|
+
throw error
|
|
173
|
+
}
|
|
174
|
+
// Its message names variables, never their values; a local installation is refused too, as it is misconfigured.
|
|
175
|
+
problems.push(`the installation's mail transport: ${error.message}`)
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
if (sender !== null) {
|
|
179
|
+
return { sender, available: true }
|
|
180
|
+
}
|
|
181
|
+
if (env[EMAIL_PROVIDER_VARIABLE] === NO_EMAIL_PROVIDER || input.local) {
|
|
182
|
+
return noMail(names, 'installation')
|
|
183
|
+
}
|
|
184
|
+
// Control writes the variable on every deploy that needs it, `none` included: its absence means no deploy wrote it.
|
|
185
|
+
for (const name of names) {
|
|
186
|
+
problems.push(
|
|
187
|
+
`job '${name}' declares mailer: 'installation', and FABRIKA_EMAIL_PROVIDER is not set: Control writes it when it deploys the application (none for an installation without a transport)`,
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function ownTransport(names: readonly string[], input: MailTransportInput, problems: string[]): JobMailTransport | null {
|
|
194
|
+
if (input.own === undefined) {
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
let built: unknown
|
|
198
|
+
try {
|
|
199
|
+
built = input.own(input.application)
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (!(error instanceof EmailDeliveryError)) {
|
|
202
|
+
throw error
|
|
203
|
+
}
|
|
204
|
+
problems.push(`serve's mail: ${error.message}`)
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
const sender = parseSender(built)
|
|
208
|
+
if (sender === undefined) {
|
|
209
|
+
problems.push("serve's mail returned no EmailSender (capabilities and send(message, signal))")
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
if (sender !== null) {
|
|
213
|
+
return { sender, available: true }
|
|
214
|
+
}
|
|
215
|
+
if (input.local) {
|
|
216
|
+
return noMail(names, 'own')
|
|
217
|
+
}
|
|
218
|
+
for (const name of names) {
|
|
219
|
+
problems.push(`job '${name}' declares mailer: 'own', and serve's mail built no transport from the application's variables`)
|
|
220
|
+
}
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const SenderCapabilities = z.object({ idempotent: z.boolean(), confirmation: z.enum(['sync', 'queued']), maxBatch: z.int().positive() })
|
|
225
|
+
|
|
226
|
+
/** What `serve`'s `mail` returned: a sender, `null` for none, `undefined` for anything else. */
|
|
227
|
+
function parseSender(value: unknown): EmailSender | null | undefined {
|
|
228
|
+
if (value === null) {
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
if (typeof value !== 'object') {
|
|
232
|
+
return undefined
|
|
233
|
+
}
|
|
234
|
+
const send: unknown = Reflect.get(value, 'send')
|
|
235
|
+
const capabilities = SenderCapabilities.safeParse(Reflect.get(value, 'capabilities'))
|
|
236
|
+
if (typeof send !== 'function' || !capabilities.success) {
|
|
237
|
+
return undefined
|
|
238
|
+
}
|
|
239
|
+
return Object.freeze({
|
|
240
|
+
capabilities: Object.freeze(capabilities.data),
|
|
241
|
+
send: (message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> => Reflect.apply(send, value, [message, signal]),
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Where the installation has no transport: every send fails `not_sent`, and the boot does not. */
|
|
246
|
+
export const NO_MAIL: JobMailTransport = Object.freeze({
|
|
247
|
+
sender: Object.freeze({
|
|
248
|
+
capabilities: Object.freeze({ idempotent: true, confirmation: 'sync', maxBatch: 1 }),
|
|
249
|
+
send: () => Promise.reject(new EmailDeliveryError('This installation has no mail transport', { code: 'mail_not_configured' })),
|
|
250
|
+
}),
|
|
251
|
+
available: false,
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
function noMail(names: readonly string[], kind: JobMailer): JobMailTransport {
|
|
255
|
+
for (const name of names) {
|
|
256
|
+
console.warn(
|
|
257
|
+
`fabrika: job '${name}' sends mail, and this installation has no ${kind} mail transport: ctx.mail.available is false, and every send fails with mail_not_configured`,
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
return NO_MAIL
|
|
261
|
+
}
|