@fabrika/email 0.4.8 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/email",
3
- "version": "0.4.8",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,6 +29,26 @@
29
29
  "bun": "./src/directory.ts",
30
30
  "types": "./src/directory.ts",
31
31
  "default": "./src/directory.ts"
32
+ },
33
+ "./memory": {
34
+ "bun": "./src/memory.ts",
35
+ "types": "./src/memory.ts",
36
+ "default": "./src/memory.ts"
37
+ },
38
+ "./smartemailing": {
39
+ "bun": "./src/smartemailing.ts",
40
+ "types": "./src/smartemailing.ts",
41
+ "default": "./src/smartemailing.ts"
42
+ },
43
+ "./providers": {
44
+ "bun": "./src/providers.ts",
45
+ "types": "./src/providers.ts",
46
+ "default": "./src/providers.ts"
47
+ },
48
+ "./conformance": {
49
+ "bun": "./src/conformance.ts",
50
+ "types": "./src/conformance.ts",
51
+ "default": "./src/conformance.ts"
32
52
  }
33
53
  },
34
54
  "scripts": {
package/src/config.ts ADDED
@@ -0,0 +1,166 @@
1
+ // The installation's mail transport as environment variables: `FABRIKA_EMAIL_PROVIDER` picks a provider from the
2
+ // registry the composition hands in, and each provider declares and parses its own variables. Every refusal names
3
+ // variables and never a value, because most of them are credentials.
4
+
5
+ import { z } from 'zod'
6
+ import { EmailDeliveryError, type EmailFetch, type EmailSender } from './email.js'
7
+ import { isValidEmailDisplayName, parseEmailFrom } from './validation.js'
8
+
9
+ export type EmailEnvironment = Readonly<Record<string, string | undefined>>
10
+
11
+ export interface EmailSenderIdentity {
12
+ readonly address: string
13
+ readonly name?: string
14
+ }
15
+
16
+ export interface EmailProviderVariable {
17
+ readonly name: string
18
+ readonly secret: boolean
19
+ readonly required: boolean
20
+ }
21
+
22
+ export interface EmailProvider {
23
+ /** Provider-specific variables, beside FABRIKA_EMAIL_PROVIDER/FROM/FROM_NAME. */
24
+ readonly variables: readonly EmailProviderVariable[]
25
+ /** Parses its own variables; throws `invalid_configuration` naming the variable, never its value. */
26
+ create(input: { readonly from: EmailSenderIdentity; readonly env: EmailEnvironment; readonly fetch?: EmailFetch }): EmailSender
27
+ }
28
+
29
+ export type EmailProviderRegistry = Readonly<Record<string, EmailProvider>>
30
+
31
+ export type EmailTransportConfig =
32
+ | { readonly kind: 'none' }
33
+ | { readonly kind: 'provider'; readonly provider: string; readonly from: EmailSenderIdentity; readonly variables: readonly EmailProviderVariable[] }
34
+
35
+ /** The variable that selects the provider. */
36
+ export const EMAIL_PROVIDER_VARIABLE = 'FABRIKA_EMAIL_PROVIDER'
37
+ /** `FABRIKA_EMAIL_PROVIDER=none`: the installation states it has no transport, which an absent variable does not. */
38
+ export const NO_EMAIL_PROVIDER = 'none'
39
+ const FROM_VARIABLE = 'FABRIKA_EMAIL_FROM'
40
+ const FROM_NAME_VARIABLE = 'FABRIKA_EMAIL_FROM_NAME'
41
+ const EMAIL_VARIABLE_PREFIX = 'FABRIKA_EMAIL_'
42
+ const UNSET_SENDER: EmailSenderIdentity = { address: 'unset@example.invalid' }
43
+
44
+ const senderSchema = z.string().transform((value, context): EmailSenderIdentity => {
45
+ const identity = parseEmailFrom(value)
46
+ if (identity === null) {
47
+ context.addIssue({ code: z.ZodIssueCode.custom })
48
+ return z.NEVER
49
+ }
50
+ return identity
51
+ })
52
+ const displayNameSchema = z.string().refine(isValidEmailDisplayName)
53
+
54
+ interface Transport {
55
+ readonly config: EmailTransportConfig
56
+ readonly sender: EmailSender | null
57
+ }
58
+
59
+ interface ConfigurationProblem {
60
+ /** The variable the problem names; empty when a provider's refusal names none. */
61
+ readonly variable: string
62
+ readonly text: string
63
+ }
64
+
65
+ /** An `invalid_configuration` that keeps its problems apart, so `readTransport` can merge a provider's into its own. */
66
+ class EmailConfigurationError extends EmailDeliveryError {
67
+ readonly problems: readonly ConfigurationProblem[]
68
+
69
+ constructor(problems: readonly ConfigurationProblem[]) {
70
+ super(`Email configuration is invalid: ${problems.map((problem) => problem.text).join('; ')}`, { code: 'invalid_configuration' })
71
+ this.problems = problems
72
+ }
73
+ }
74
+
75
+ /** Throws one `invalid_configuration` listing every missing or invalid variable name. */
76
+ export function readEmailTransportConfig(env: EmailEnvironment, registry: EmailProviderRegistry): EmailTransportConfig {
77
+ return readTransport(env, registry, undefined).config
78
+ }
79
+
80
+ /** null for `none` or an absent provider. */
81
+ export function emailSenderFromEnv(
82
+ env: EmailEnvironment,
83
+ registry: EmailProviderRegistry,
84
+ options: { readonly fetch?: EmailFetch } = {},
85
+ ): EmailSender | null {
86
+ return readTransport(env, registry, options.fetch).sender
87
+ }
88
+
89
+ /** The non-empty string `FABRIKA_EMAIL_*` entries of any binding object (Worker bindings, process.env). */
90
+ export function pickEmailEnvironment(source: object): EmailEnvironment {
91
+ const entries: [string, unknown][] = Object.entries(source)
92
+ const picked: Record<string, string> = {}
93
+ for (const [name, value] of entries) {
94
+ if (name.startsWith(EMAIL_VARIABLE_PREFIX) && typeof value === 'string' && value !== '') picked[name] = value
95
+ }
96
+ return picked
97
+ }
98
+
99
+ /**
100
+ * A provider's own variables through its schema. A failure is one `invalid_configuration` naming each offending
101
+ * variable and never its value, in the form `readEmailTransportConfig` merges into its own refusal.
102
+ */
103
+ export function parseProviderVariables<Schema extends z.ZodTypeAny>(schema: Schema, env: EmailEnvironment): z.output<Schema> {
104
+ const parsed = schema.safeParse(env)
105
+ if (parsed.success) return parsed.data
106
+ const names = [...new Set(parsed.error.issues.map((issue) => String(issue.path[0] ?? '')))]
107
+ throw new EmailConfigurationError(names.map((name) => ({ variable: name, text: `${name || 'a variable'} is missing or invalid` })))
108
+ }
109
+
110
+ function readTransport(env: EmailEnvironment, registry: EmailProviderRegistry, fetch: EmailFetch | undefined): Transport {
111
+ const providerName = presentValue(env[EMAIL_PROVIDER_VARIABLE])
112
+ if (providerName === undefined || providerName === NO_EMAIL_PROVIDER) return { config: { kind: 'none' }, sender: null }
113
+ const provider = Object.hasOwn(registry, providerName) ? registry[providerName] : undefined
114
+ if (provider === undefined) {
115
+ throw new EmailDeliveryError(`${EMAIL_PROVIDER_VARIABLE} must be none or one of: ${Object.keys(registry).join(', ')}`, {
116
+ code: 'invalid_configuration',
117
+ })
118
+ }
119
+
120
+ const problems: ConfigurationProblem[] = []
121
+ const problem = (variable: string, text: string): void => {
122
+ problems.push({ variable, text: `${variable} ${text}` })
123
+ }
124
+ const fromValue = presentValue(env[FROM_VARIABLE])
125
+ const parsedFrom = fromValue === undefined ? undefined : senderSchema.safeParse(fromValue).data
126
+ if (fromValue === undefined) problem(FROM_VARIABLE, 'is missing')
127
+ else if (parsedFrom === undefined) problem(FROM_VARIABLE, 'is invalid')
128
+ const fromName = presentValue(env[FROM_NAME_VARIABLE])
129
+ if (fromName !== undefined && !displayNameSchema.safeParse(fromName).success) problem(FROM_NAME_VARIABLE, 'is invalid')
130
+ else if (fromName !== undefined && parsedFrom?.name !== undefined) {
131
+ problem(FROM_NAME_VARIABLE, `is refused because ${FROM_VARIABLE} already carries a display name`)
132
+ }
133
+ for (const variable of provider.variables) {
134
+ if (variable.required && presentValue(env[variable.name]) === undefined) problem(variable.name, 'is missing')
135
+ }
136
+ const from = parsedFrom === undefined || fromName === undefined || parsedFrom.name !== undefined
137
+ ? parsedFrom
138
+ : { address: parsedFrom.address, name: fromName }
139
+
140
+ // The provider checks its own values; building it does no I/O. Without a usable sender a stand-in identity still
141
+ // lets it report its variables, and that sender is never returned.
142
+ let sender: EmailSender | null = null
143
+ try {
144
+ sender = provider.create({ from: from ?? UNSET_SENDER, env, fetch })
145
+ } catch (error) {
146
+ problems.push(...providerProblems(error, problems))
147
+ }
148
+ if (from === undefined || sender === null || problems.length > 0) throw new EmailConfigurationError(problems)
149
+
150
+ return { config: { kind: 'provider', provider: providerName, from, variables: provider.variables }, sender }
151
+ }
152
+
153
+ /** A provider's refusal as problems, without those for a variable already reported. */
154
+ function providerProblems(error: unknown, reported: readonly ConfigurationProblem[]): ConfigurationProblem[] {
155
+ if (error instanceof EmailConfigurationError) {
156
+ return error.problems.filter((candidate) => !reported.some((problem) => problem.variable !== '' && problem.variable === candidate.variable))
157
+ }
158
+ // An error message is a fixed string by the `EmailDeliveryError` contract, so it may join the refusal.
159
+ if (error instanceof EmailDeliveryError && error.code === 'invalid_configuration') return [{ variable: '', text: error.message }]
160
+ throw error
161
+ }
162
+
163
+ /** Deploy tooling (GitHub Actions among it) passes an unset variable as an empty string, so empty means absent. */
164
+ function presentValue(value: string | undefined): string | undefined {
165
+ return value === undefined || value === '' ? undefined : value
166
+ }
@@ -0,0 +1,414 @@
1
+ // The cases every `EmailSender` passes, built-in or not. An adapter's own test file calls
2
+ // `describeEmailSenderConformance` once with a harness: an HTTP adapter gets a stubbed fetch and says how to read its
3
+ // request and how its provider answers; a local sender says where it stores what it took.
4
+
5
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
6
+ import {
7
+ EMAIL_DELIVERY_ERRORS,
8
+ EmailDeliveryError,
9
+ type EmailDeliveryErrorCode,
10
+ type EmailFetch,
11
+ type EmailMessage,
12
+ type EmailSender,
13
+ } from './email.js'
14
+
15
+ /** What the provider received, or what a local sender stored, in the message's own terms. */
16
+ export interface WireMessage {
17
+ readonly to: string
18
+ readonly subject: string
19
+ readonly text: string
20
+ readonly html?: string
21
+ readonly replyTo?: string
22
+ readonly fromName?: string
23
+ readonly idempotencyKey?: string
24
+ }
25
+
26
+ /** How the provider answers. `inProgress` and `idempotencyConflict` are required when the sender is idempotent. */
27
+ export interface ProviderAnswers {
28
+ accepted(request: Request): Response
29
+ malformedSuccess(): Response
30
+ unauthorized(): Response
31
+ rejected(): Response
32
+ rateLimited(): Response
33
+ unavailable(): Response
34
+ quotaExceeded?(): Response
35
+ inProgress?(): Response
36
+ idempotencyConflict?(): Response
37
+ }
38
+
39
+ export interface EmailSenderHttpHarness {
40
+ readonly kind: 'http'
41
+ /** Credentials the sender holds; none may appear in an error or a log line. */
42
+ readonly secrets: readonly string[]
43
+ create(fetch: EmailFetch, options: { readonly timeoutMs: number }): EmailSender
44
+ readRequest(request: Request): Promise<WireMessage>
45
+ readonly answers: ProviderAnswers
46
+ }
47
+
48
+ /** A local sender's I/O cannot be held open from outside, so the abort-during-I/O cases run for HTTP senders only. */
49
+ export interface EmailSenderLocalHarness {
50
+ readonly kind: 'local'
51
+ /** Called before each case. */
52
+ create(): Promise<EmailSender>
53
+ /** What the sender created by the last `create` stored, oldest first. */
54
+ stored(): Promise<readonly WireMessage[]>
55
+ /** Called after each case. */
56
+ cleanup?(): Promise<void>
57
+ }
58
+
59
+ export type EmailSenderConformanceHarness = EmailSenderHttpHarness | EmailSenderLocalHarness
60
+
61
+ const TIMEOUT_MS = 50
62
+ const SUBJECT = 'Conformance subject 5d1c'
63
+ const TEXT = 'Conformance text body 8e2f'
64
+ const HTML = '<p>Conformance html body 3a7b</p>'
65
+ const MESSAGE_MARKERS = [SUBJECT, TEXT, HTML]
66
+ /** Shorter strings in a response body are field names and statuses, which an error message may share by chance. */
67
+ const MIN_BODY_MARKER_LENGTH = 8
68
+
69
+ const FULL_MESSAGE: EmailMessage = {
70
+ to: 'recipient@example.test',
71
+ subject: SUBJECT,
72
+ text: TEXT,
73
+ html: HTML,
74
+ idempotencyKey: 'conformance/full-1',
75
+ replyTo: 'replies@example.test',
76
+ fromName: 'Conformance Sender',
77
+ }
78
+
79
+ const MINIMAL_MESSAGE: EmailMessage = {
80
+ to: 'recipient@example.test',
81
+ subject: SUBJECT,
82
+ text: TEXT,
83
+ idempotencyKey: 'conformance/minimal-1',
84
+ }
85
+
86
+ const INVALID_MESSAGES: readonly { readonly problem: string; readonly message: EmailMessage }[] = [
87
+ { problem: 'a bad recipient', message: { ...MINIMAL_MESSAGE, to: 'not-an-address' } },
88
+ { problem: 'an empty subject', message: { ...MINIMAL_MESSAGE, subject: '' } },
89
+ { problem: 'a subject with CRLF', message: { ...MINIMAL_MESSAGE, subject: 'Bad\r\nsubject' } },
90
+ { problem: 'a blank text', message: { ...MINIMAL_MESSAGE, text: ' ' } },
91
+ { problem: 'an empty key', message: { ...MINIMAL_MESSAGE, idempotencyKey: '' } },
92
+ { problem: 'a key with a space', message: { ...MINIMAL_MESSAGE, idempotencyKey: 'has space' } },
93
+ { problem: 'a key over 256 characters', message: { ...MINIMAL_MESSAGE, idempotencyKey: 'x'.repeat(257) } },
94
+ { problem: 'a bad reply-to', message: { ...MINIMAL_MESSAGE, replyTo: 'not-an-address' } },
95
+ { problem: 'a sender name with brackets', message: { ...MINIMAL_MESSAGE, fromName: 'Bad <name>' } },
96
+ { problem: 'a sender name over 128 characters', message: { ...MINIMAL_MESSAGE, fromName: 'x'.repeat(129) } },
97
+ ]
98
+
99
+ /**
100
+ * Registers the conformance cases for one sender. HTTP harnesses also get the provider answer table, the thrown and
101
+ * silent fetch, and an abort or a timeout while the request or the answer body is in flight; a local harness gets
102
+ * only the already-aborted case, because nothing can hold its I/O open.
103
+ */
104
+ export function describeEmailSenderConformance(name: string, harness: EmailSenderConformanceHarness): void {
105
+ describe(`${name} conforms to EmailSender`, () => {
106
+ if (harness.kind === 'http') describeHttpSender(harness)
107
+ else describeLocalSender(harness)
108
+ })
109
+ }
110
+
111
+ type Handler = (request: Request, signal: AbortSignal | undefined) => Promise<Response>
112
+
113
+ interface HttpCase {
114
+ readonly sender: EmailSender
115
+ readonly requests: Request[]
116
+ /** Every string of length ≥ MIN_BODY_MARKER_LENGTH in the bodies the provider answered with. */
117
+ readonly bodyMarkers: string[]
118
+ /** The status of each answer, in order. */
119
+ readonly statuses: number[]
120
+ }
121
+
122
+ function describeHttpSender(harness: EmailSenderHttpHarness): void {
123
+ /** Read after the call settles, when the answer bodies are known. */
124
+ const forbidden = (current: HttpCase) => (): string[] => [...MESSAGE_MARKERS, ...harness.secrets, ...current.bodyMarkers]
125
+
126
+ /** `stalledBody`: the handler answers a body that never ends, which the stub must not read itself. */
127
+ function httpCase(handler: Handler, options: { readonly stalledBody?: boolean } = {}): HttpCase {
128
+ const requests: Request[] = []
129
+ const bodyMarkers: string[] = []
130
+ const statuses: number[] = []
131
+ const fetch: EmailFetch = async (input, init) => {
132
+ const request = new Request(input, init)
133
+ requests.push(request.clone())
134
+ const response = await handler(request, init?.signal ?? undefined)
135
+ statuses.push(response.status)
136
+ if (options.stalledBody !== true) bodyMarkers.push(...markersOf(await response.clone().text()))
137
+ return response
138
+ }
139
+ return { sender: harness.create(fetch, { timeoutMs: TIMEOUT_MS }), requests, bodyMarkers, statuses }
140
+ }
141
+
142
+ const answering = (answer: () => Response): HttpCase => httpCase(async () => answer())
143
+
144
+ test('reports its capabilities', () => {
145
+ expectCapabilities(answering(() => harness.answers.malformedSuccess()).sender)
146
+ })
147
+
148
+ test('answers an accepted result', async () => {
149
+ const current = httpCase(async (request) => harness.answers.accepted(request))
150
+ expectAccepted(await current.sender.send(FULL_MESSAGE, new AbortController().signal))
151
+ expect(current.requests).toHaveLength(1)
152
+ })
153
+
154
+ test('hands every field of the message to the provider', async () => {
155
+ const current = httpCase(async (request) => harness.answers.accepted(request))
156
+ await current.sender.send(FULL_MESSAGE, new AbortController().signal)
157
+ const wire = await harness.readRequest(only(current.requests))
158
+ expect(wire).toMatchObject(contentOf(FULL_MESSAGE))
159
+ if (current.sender.capabilities.idempotent) expect(wire.idempotencyKey).toBe(FULL_MESSAGE.idempotencyKey)
160
+ })
161
+
162
+ test('leaves html, reply-to and sender name out when the message has none', async () => {
163
+ const current = httpCase(async (request) => harness.answers.accepted(request))
164
+ await current.sender.send(MINIMAL_MESSAGE, new AbortController().signal)
165
+ expectOptionalFieldsAbsent(await harness.readRequest(only(current.requests)))
166
+ })
167
+
168
+ test.each(INVALID_MESSAGES)('refuses $problem as invalid_message without a request', async ({ message }) => {
169
+ const current = httpCase(async (request) => harness.answers.accepted(request))
170
+ await expectRejection(() => current.sender.send(message, new AbortController().signal), 'invalid_message', forbidden(current))
171
+ expect(current.requests).toHaveLength(0)
172
+ })
173
+
174
+ test('an already aborted signal answers aborted without a request', async () => {
175
+ const current = httpCase(async (request) => harness.answers.accepted(request))
176
+ await expectRejection(() => current.sender.send(FULL_MESSAGE, AbortSignal.abort()), 'aborted', forbidden(current))
177
+ expect(current.requests).toHaveLength(0)
178
+ })
179
+
180
+ test('an abort during the request answers aborted with an unknown outcome', async () => {
181
+ const controller = new AbortController()
182
+ const current = httpCase((_request, signal) => {
183
+ if (signal === undefined) return Promise.reject(new Error('the request carries no signal'))
184
+ const pending = new Promise<Response>((_resolve, reject) => {
185
+ signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
186
+ })
187
+ controller.abort()
188
+ return pending
189
+ })
190
+ const error = await expectRejection(() => current.sender.send(FULL_MESSAGE, controller.signal), 'aborted', forbidden(current))
191
+ expect(error.outcome).toBe('unknown')
192
+ })
193
+
194
+ test('every provider answer maps to its code and keeps the HTTP status', async () => {
195
+ const probe = answering(() => harness.answers.malformedSuccess()).sender
196
+ const answers = harness.answers
197
+ const table: [string, (() => Response) | undefined, EmailDeliveryErrorCode][] = [
198
+ ['unauthorized', () => answers.unauthorized(), 'provider_unauthorized'],
199
+ ['rejected', () => answers.rejected(), 'provider_rejected'],
200
+ ['rateLimited', () => answers.rateLimited(), 'rate_limited'],
201
+ ['unavailable', () => answers.unavailable(), 'provider_unavailable'],
202
+ ['malformedSuccess', () => answers.malformedSuccess(), 'malformed_response'],
203
+ ['quotaExceeded', answers.quotaExceeded?.bind(answers), 'quota_exceeded'],
204
+ ['inProgress', answers.inProgress?.bind(answers), 'request_in_progress'],
205
+ ['idempotencyConflict', answers.idempotencyConflict?.bind(answers), 'idempotency_conflict'],
206
+ ]
207
+ for (const [answerName, answer, code] of table) {
208
+ if (answer === undefined) {
209
+ const requiredByIdempotency = code === 'request_in_progress' || code === 'idempotency_conflict'
210
+ if (requiredByIdempotency && probe.capabilities.idempotent) throw new Error(`An idempotent sender's harness must supply ${answerName}`)
211
+ continue
212
+ }
213
+ const current = answering(answer)
214
+ const error = await expectRejection(() => current.sender.send(FULL_MESSAGE, new AbortController().signal), code, forbidden(current))
215
+ expect(error.httpStatus).toBe(only(current.statuses))
216
+ }
217
+ })
218
+
219
+ test('a fetch that throws answers network_error', async () => {
220
+ const current = httpCase(() => Promise.reject(new Error(`socket closed ${harness.secrets.join(' ')} ${TEXT}`)))
221
+ await expectRejection(() => current.sender.send(FULL_MESSAGE, new AbortController().signal), 'network_error', forbidden(current))
222
+ })
223
+
224
+ test('a provider that never answers times out at the configured timeout', async () => {
225
+ const current = httpCase((_request, signal) =>
226
+ new Promise<Response>((_resolve, reject) => {
227
+ signal?.addEventListener('abort', () => reject(new Error('timed out')), { once: true })
228
+ })
229
+ )
230
+ const started = Date.now()
231
+ await expectRejection(() => current.sender.send(FULL_MESSAGE, new AbortController().signal), 'request_timeout', forbidden(current))
232
+ expect(Date.now() - started).toBeGreaterThanOrEqual(TIMEOUT_MS - 5)
233
+ })
234
+
235
+ test('an abort while the answer body arrives answers aborted', async () => {
236
+ const controller = new AbortController()
237
+ const current = httpCase(async () => {
238
+ setTimeout(() => controller.abort(), 5)
239
+ return stalledAnswer()
240
+ }, { stalledBody: true })
241
+ const error = await expectRejection(() => current.sender.send(FULL_MESSAGE, controller.signal), 'aborted', forbidden(current))
242
+ expect(error.outcome).toBe('unknown')
243
+ })
244
+
245
+ test('an answer body that never finishes times out', async () => {
246
+ const current = httpCase(async () => stalledAnswer(), { stalledBody: true })
247
+ await expectRejection(() => current.sender.send(FULL_MESSAGE, new AbortController().signal), 'request_timeout', forbidden(current))
248
+ })
249
+ }
250
+
251
+ /** A 200 whose body never ends. */
252
+ function stalledAnswer(): Response {
253
+ return new Response(new ReadableStream({ start() {} }), { status: 200 })
254
+ }
255
+
256
+ function describeLocalSender(harness: EmailSenderLocalHarness): void {
257
+ let sender: EmailSender | null = null
258
+ const current = (): EmailSender => {
259
+ if (sender === null) throw new Error('the harness created no sender')
260
+ return sender
261
+ }
262
+
263
+ beforeEach(async () => {
264
+ sender = await harness.create()
265
+ })
266
+
267
+ afterEach(async () => {
268
+ sender = null
269
+ await harness.cleanup?.()
270
+ })
271
+
272
+ test('reports its capabilities', () => {
273
+ expectCapabilities(current())
274
+ })
275
+
276
+ test('answers an accepted result', async () => {
277
+ expectAccepted(await current().send(FULL_MESSAGE, new AbortController().signal))
278
+ })
279
+
280
+ test('stores every field of the message', async () => {
281
+ await current().send(FULL_MESSAGE, new AbortController().signal)
282
+ expect(only(await harness.stored())).toMatchObject(contentOf(FULL_MESSAGE))
283
+ })
284
+
285
+ test('leaves html, reply-to and sender name out when the message has none', async () => {
286
+ await current().send(MINIMAL_MESSAGE, new AbortController().signal)
287
+ expectOptionalFieldsAbsent(only(await harness.stored()))
288
+ })
289
+
290
+ test.each(INVALID_MESSAGES)('refuses $problem as invalid_message and stores nothing', async ({ message }) => {
291
+ await expectRejection(() => current().send(message, new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
292
+ expect(await harness.stored()).toHaveLength(0)
293
+ })
294
+
295
+ test('an already aborted signal answers aborted and stores nothing', async () => {
296
+ await expectRejection(() => current().send(FULL_MESSAGE, AbortSignal.abort()), 'aborted', () => MESSAGE_MARKERS)
297
+ expect(await harness.stored()).toHaveLength(0)
298
+ })
299
+
300
+ test('an idempotent sender stores a repeated message once and refuses a different one under the same key', async () => {
301
+ const target = current()
302
+ if (!target.capabilities.idempotent) return
303
+ const first = await target.send(FULL_MESSAGE, new AbortController().signal)
304
+ const repeated = await target.send(FULL_MESSAGE, new AbortController().signal)
305
+ expect(repeated.messageId).toBe(first.messageId)
306
+ expect(await harness.stored()).toHaveLength(1)
307
+ await expectRejection(
308
+ () => target.send({ ...FULL_MESSAGE, text: `${TEXT} changed` }, new AbortController().signal),
309
+ 'idempotency_conflict',
310
+ () => MESSAGE_MARKERS,
311
+ )
312
+ expect(await harness.stored()).toHaveLength(1)
313
+ })
314
+ }
315
+
316
+ function expectCapabilities(sender: EmailSender): void {
317
+ const { idempotent, confirmation, maxBatch } = sender.capabilities
318
+ expect(typeof idempotent).toBe('boolean')
319
+ expect(['sync', 'queued']).toContain(confirmation)
320
+ expect(Number.isSafeInteger(maxBatch) && maxBatch > 0).toBe(true)
321
+ }
322
+
323
+ function expectAccepted(result: Awaited<ReturnType<EmailSender['send']>>): void {
324
+ expect(result.status).toBe('accepted')
325
+ expect(result.provider).not.toBe('')
326
+ expect(result.messageId).not.toBe('')
327
+ }
328
+
329
+ function expectOptionalFieldsAbsent(wire: WireMessage): void {
330
+ expect(wire.html).toBeUndefined()
331
+ expect(wire.replyTo).toBeUndefined()
332
+ expect(wire.fromName).toBeUndefined()
333
+ }
334
+
335
+ function contentOf(message: EmailMessage): WireMessage {
336
+ return {
337
+ to: message.to,
338
+ subject: message.subject,
339
+ text: message.text,
340
+ ...(message.html === undefined ? {} : { html: message.html }),
341
+ ...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
342
+ ...(message.fromName === undefined ? {} : { fromName: message.fromName }),
343
+ }
344
+ }
345
+
346
+ /** The rejection, checked against the classification table and for anything it must never carry. */
347
+ async function expectRejection(
348
+ run: () => Promise<unknown>,
349
+ code: EmailDeliveryErrorCode,
350
+ forbidden: () => readonly string[],
351
+ ): Promise<EmailDeliveryError> {
352
+ const logged: string[] = []
353
+ const restore = captureConsole(logged)
354
+ let reason: unknown
355
+ try {
356
+ reason = await run().then(() => undefined, (error: unknown) => error)
357
+ } finally {
358
+ restore()
359
+ }
360
+ if (!(reason instanceof EmailDeliveryError)) throw new Error(`expected an EmailDeliveryError with code ${code}`)
361
+ expect(reason.code).toBe(code)
362
+ expect({ retryable: reason.retryable, outcome: reason.outcome }).toEqual(EMAIL_DELIVERY_ERRORS[code])
363
+ const surfaces = [reason.message, String(reason), JSON.stringify(reason), ...logged]
364
+ for (const value of forbidden()) {
365
+ for (const surface of surfaces) expect(surface).not.toContain(value)
366
+ }
367
+ return reason
368
+ }
369
+
370
+ function captureConsole(logged: string[]): () => void {
371
+ const originals = { log: console.log, info: console.info, warn: console.warn, error: console.error, debug: console.debug }
372
+ const record = (...args: unknown[]): void => {
373
+ logged.push(args.map((arg) => (arg instanceof Error ? `${arg.message} ${arg.stack ?? ''}` : safeString(arg))).join(' '))
374
+ }
375
+ console.log = record
376
+ console.info = record
377
+ console.warn = record
378
+ console.error = record
379
+ console.debug = record
380
+ return () => {
381
+ Object.assign(console, originals)
382
+ }
383
+ }
384
+
385
+ function safeString(value: unknown): string {
386
+ if (typeof value === 'string') return value
387
+ try {
388
+ return JSON.stringify(value) ?? String(value)
389
+ } catch {
390
+ return String(value)
391
+ }
392
+ }
393
+
394
+ function markersOf(body: string): string[] {
395
+ const markers: string[] = []
396
+ const collect = (value: unknown): void => {
397
+ if (typeof value === 'string' && value.length >= MIN_BODY_MARKER_LENGTH) markers.push(value)
398
+ else if (Array.isArray(value)) value.forEach(collect)
399
+ else if (typeof value === 'object' && value !== null) Object.values(value).forEach(collect)
400
+ }
401
+ try {
402
+ collect(JSON.parse(body))
403
+ } catch {
404
+ collect(body)
405
+ }
406
+ return markers
407
+ }
408
+
409
+ function only<T>(items: readonly T[]): T {
410
+ expect(items).toHaveLength(1)
411
+ const item = items[0]
412
+ if (item === undefined) throw new Error('expected exactly one item')
413
+ return item
414
+ }
package/src/content.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { EmailMessage } from './email.js'
2
+
3
+ /** What a local sender compares when one idempotency key arrives twice: everything the recipient would see. */
4
+ export interface EmailContent {
5
+ readonly to: string
6
+ readonly subject: string
7
+ readonly text: string
8
+ readonly html?: string
9
+ readonly replyTo?: string
10
+ readonly fromName?: string
11
+ }
12
+
13
+ export function sameEmailContent(left: EmailContent, right: EmailContent): boolean {
14
+ return left.to === right.to && left.subject === right.subject && left.text === right.text && left.html === right.html
15
+ && left.replyTo === right.replyTo && left.fromName === right.fromName
16
+ }
17
+
18
+ export function emailContentOf(message: EmailMessage): EmailContent {
19
+ return {
20
+ to: message.to,
21
+ subject: message.subject,
22
+ text: message.text,
23
+ ...(message.html === undefined ? {} : { html: message.html }),
24
+ ...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
25
+ ...(message.fromName === undefined ? {} : { fromName: message.fromName }),
26
+ }
27
+ }