@fabrika/email 0.4.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/directory.ts CHANGED
@@ -6,15 +6,20 @@ import { createHash, randomUUID } from 'node:crypto'
6
6
  import { link, readdir, readFile, unlink, writeFile } from 'node:fs/promises'
7
7
  import { isAbsolute, join } from 'node:path'
8
8
  import { z } from 'zod'
9
- import { EmailDeliveryError, type EmailDeliveryResult, type EmailMessage, type EmailSender } from './email.js'
9
+ import { type EmailProvider, parseProviderVariables } from './config.js'
10
+ import { emailContentOf, sameEmailContent } from './content.js'
11
+ import { EmailDeliveryError, type EmailDeliveryResult, type EmailMessage, type EmailSender, type EmailSenderCapabilities } from './email.js'
10
12
  import { validateEmailMessage } from './validation.js'
11
13
 
12
14
  const capturedEmailSchema = z.object({
13
15
  messageId: z.string().min(1),
16
+ idempotencyKey: z.string(),
14
17
  to: z.string(),
15
18
  subject: z.string(),
16
19
  text: z.string(),
17
20
  html: z.string().optional(),
21
+ replyTo: z.string().optional(),
22
+ fromName: z.string().optional(),
18
23
  capturedAt: z.number().int(),
19
24
  })
20
25
 
@@ -27,52 +32,68 @@ export interface DirectoryEmailSenderOptions {
27
32
  }
28
33
 
29
34
  export class DirectoryEmailSender implements EmailSender {
35
+ readonly capabilities: EmailSenderCapabilities = { idempotent: true, confirmation: 'sync', maxBatch: 1 }
30
36
  readonly #directory: string
31
37
  readonly #now: () => number
32
38
 
33
39
  constructor(options: DirectoryEmailSenderOptions) {
34
40
  if (!isAbsolute(options.directory)) {
35
- throw new EmailDeliveryError('The mail capture directory must be an absolute path', { code: 'invalid_configuration', retryable: false })
41
+ throw new EmailDeliveryError('The mail capture directory must be an absolute path', { code: 'invalid_configuration' })
36
42
  }
37
43
  this.#directory = options.directory
38
44
  this.#now = options.now ?? Date.now
39
45
  }
40
46
 
41
- /** One file per idempotency key: a repeated send keeps the first message and answers the same id, as a provider does. */
42
- async send(message: EmailMessage): Promise<EmailDeliveryResult> {
47
+ /** One file per idempotency key: a repeated send of the same message answers the first id, as a provider does. */
48
+ async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
43
49
  validateEmailMessage(message)
50
+ if (signal.aborted) throw new EmailDeliveryError('The mail capture was aborted by the caller', { code: 'aborted' })
44
51
  const messageId = createHash('sha256').update(message.idempotencyKey).digest('hex')
45
- const captured: CapturedEmail = {
46
- messageId,
47
- to: message.to,
48
- subject: message.subject,
49
- text: message.text,
50
- ...(message.html === undefined ? {} : { html: message.html }),
51
- capturedAt: this.#now(),
52
- }
52
+ const captured: CapturedEmail = { messageId, idempotencyKey: message.idempotencyKey, ...emailContentOf(message), capturedAt: this.#now() }
53
+ const path = join(this.#directory, `${messageId}.json`)
53
54
  // Written aside and linked in, so a reader never sees a partial file and an existing one is never replaced.
54
55
  const pending = join(this.#directory, `.${messageId}.${randomUUID()}.tmp`)
56
+ let existing: CapturedEmail | null
55
57
  try {
56
58
  await writeFile(pending, JSON.stringify(captured), { flag: 'wx' })
57
- await link(pending, join(this.#directory, `${messageId}.json`)).catch((error: unknown) => {
59
+ existing = await link(pending, path).then(() => null, async (error: unknown) => {
58
60
  if (!isAlreadyCaptured(error)) throw error
61
+ return readCapturedEmail(path)
59
62
  })
60
63
  } catch {
61
- throw new EmailDeliveryError('The mail capture directory could not be written', { code: 'provider_unavailable', retryable: true })
64
+ throw new EmailDeliveryError('The mail capture directory could not be written', { code: 'provider_unavailable' })
62
65
  } finally {
63
66
  await unlink(pending).catch(() => undefined)
64
67
  }
68
+ if (existing !== null && !sameEmailContent(existing, captured)) {
69
+ throw new EmailDeliveryError('A different message was already captured under this idempotency key', { code: 'idempotency_conflict' })
70
+ }
65
71
  return { status: 'accepted', provider: 'directory', messageId }
66
72
  }
67
73
  }
68
74
 
75
+ const directoryVariablesSchema = z.object({ FABRIKA_EMAIL_DIRECTORY: z.string() })
76
+
77
+ /**
78
+ * `FABRIKA_EMAIL_PROVIDER=directory`: a local composition's capture. Never in `builtinEmailProviders`; a composition
79
+ * registers it. The capture records content only, so `FABRIKA_EMAIL_FROM` is not used.
80
+ */
81
+ export const directoryEmailProvider: EmailProvider = {
82
+ variables: [{ name: 'FABRIKA_EMAIL_DIRECTORY', secret: false, required: true }],
83
+ create: ({ env }) => new DirectoryEmailSender({ directory: parseProviderVariables(directoryVariablesSchema, env).FABRIKA_EMAIL_DIRECTORY }),
84
+ }
85
+
69
86
  /** Every message a `DirectoryEmailSender` captured in `directory`, oldest first. */
70
87
  export async function readCapturedEmails(directory: string): Promise<CapturedEmail[]> {
71
88
  const names = (await readdir(directory)).filter((name) => name.endsWith('.json') && !name.startsWith('.'))
72
- const emails = await Promise.all(names.map(async (name) => capturedEmailSchema.parse(JSON.parse(await readFile(join(directory, name), 'utf8')))))
89
+ const emails = await Promise.all(names.map((name) => readCapturedEmail(join(directory, name))))
73
90
  return emails.sort((left, right) => left.capturedAt - right.capturedAt || left.messageId.localeCompare(right.messageId))
74
91
  }
75
92
 
93
+ async function readCapturedEmail(path: string): Promise<CapturedEmail> {
94
+ return capturedEmailSchema.parse(JSON.parse(await readFile(path, 'utf8')))
95
+ }
96
+
76
97
  function isAlreadyCaptured(error: unknown): boolean {
77
98
  return error instanceof Error && 'code' in error && error.code === 'EEXIST'
78
99
  }
package/src/email.ts CHANGED
@@ -1,9 +1,16 @@
1
+ export type EmailFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
2
+
1
3
  export interface EmailMessage {
2
4
  readonly to: string
3
5
  readonly subject: string
4
6
  readonly text: string
5
7
  readonly html?: string
8
+ /** 1..256 visible ASCII characters (Resend's limit). A prefix the caller adds must fit inside it. */
6
9
  readonly idempotencyKey: string
10
+ /** One mailbox address. */
11
+ readonly replyTo?: string
12
+ /** Replaces the transport's display name for this message. */
13
+ readonly fromName?: string
7
14
  }
8
15
 
9
16
  export interface EmailDeliveryResult {
@@ -12,37 +19,67 @@ export interface EmailDeliveryResult {
12
19
  readonly messageId: string
13
20
  }
14
21
 
22
+ export interface EmailSenderCapabilities {
23
+ /** A repeated send under one key with the same message is not delivered again. */
24
+ readonly idempotent: boolean
25
+ /** 'sync': `accepted` means the provider took the message. 'queued': it queued the request and may still drop it. */
26
+ readonly confirmation: 'sync' | 'queued'
27
+ /** The most messages one call carries. `send` carries one, so every built-in adapter reports 1. */
28
+ readonly maxBatch: number
29
+ }
30
+
15
31
  export interface EmailSender {
16
- send(message: EmailMessage): Promise<EmailDeliveryResult>
32
+ readonly capabilities: EmailSenderCapabilities
33
+ send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult>
17
34
  }
18
35
 
19
- export type EmailDeliveryErrorCode =
20
- | 'invalid_configuration'
21
- | 'invalid_message'
22
- | 'network_error'
23
- | 'request_timeout'
24
- | 'rate_limited'
25
- | 'request_in_progress'
26
- | 'provider_rejected'
27
- | 'provider_unavailable'
28
- | 'malformed_response'
36
+ /** 'not_sent': the provider did not take the message. 'unknown': it may have taken it. */
37
+ export type EmailDeliveryOutcome = 'not_sent' | 'unknown'
38
+
39
+ /** The one classification of every failure: an adapter names the code, and the code decides the rest. */
40
+ export const EMAIL_DELIVERY_ERRORS = {
41
+ invalid_configuration: { retryable: false, outcome: 'not_sent' },
42
+ invalid_message: { retryable: false, outcome: 'not_sent' },
43
+ // The installation has no transport (`FABRIKA_EMAIL_PROVIDER=none`): the message waits for one, it is not refused.
44
+ mail_not_configured: { retryable: false, outcome: 'not_sent' },
45
+ aborted: { retryable: true, outcome: 'unknown' },
46
+ network_error: { retryable: true, outcome: 'unknown' },
47
+ request_timeout: { retryable: true, outcome: 'unknown' },
48
+ request_in_progress: { retryable: true, outcome: 'unknown' },
49
+ idempotency_conflict: { retryable: false, outcome: 'unknown' },
50
+ provider_unauthorized: { retryable: false, outcome: 'not_sent' },
51
+ rate_limited: { retryable: true, outcome: 'not_sent' },
52
+ quota_exceeded: { retryable: true, outcome: 'not_sent' },
53
+ provider_rejected: { retryable: false, outcome: 'not_sent' },
54
+ provider_unavailable: { retryable: true, outcome: 'unknown' },
55
+ malformed_response: { retryable: true, outcome: 'unknown' },
56
+ } as const satisfies Record<string, { readonly retryable: boolean; readonly outcome: EmailDeliveryOutcome }>
57
+
58
+ export type EmailDeliveryErrorCode = keyof typeof EMAIL_DELIVERY_ERRORS
29
59
 
30
60
  export interface EmailDeliveryErrorOptions {
31
61
  readonly code: EmailDeliveryErrorCode
32
- readonly retryable: boolean
33
62
  readonly httpStatus?: number
34
63
  }
35
64
 
65
+ /** Its message is a fixed string plus the HTTP status: never a credential, a response body, a subject or a body. */
36
66
  export class EmailDeliveryError extends Error {
37
67
  readonly code: EmailDeliveryErrorCode
38
68
  readonly retryable: boolean
69
+ readonly outcome: EmailDeliveryOutcome
39
70
  readonly httpStatus: number | undefined
40
71
 
41
72
  constructor(message: string, options: EmailDeliveryErrorOptions) {
42
73
  super(message)
43
74
  this.name = 'EmailDeliveryError'
44
75
  this.code = options.code
45
- this.retryable = options.retryable
76
+ this.retryable = EMAIL_DELIVERY_ERRORS[options.code].retryable
77
+ this.outcome = EMAIL_DELIVERY_ERRORS[options.code].outcome
46
78
  this.httpStatus = options.httpStatus
47
79
  }
48
80
  }
81
+
82
+ /** The one retry rule: an unknown outcome is retried only over an idempotent transport. */
83
+ export function mayRetryDelivery(error: EmailDeliveryError, capabilities: EmailSenderCapabilities): boolean {
84
+ return error.retryable && (error.outcome === 'not_sent' || capabilities.idempotent)
85
+ }
package/src/http.ts ADDED
@@ -0,0 +1,72 @@
1
+ // The request half both HTTP adapters share: the caller's signal, the adapter's own timeout, and how a thrown fetch
2
+ // is classified. Messages name the provider and nothing the request carried.
3
+
4
+ import { EmailDeliveryError, type EmailFetch } from './email.js'
5
+
6
+ export interface ProviderRequest {
7
+ /** The provider's name as it appears in error messages. */
8
+ readonly label: string
9
+ readonly fetch: EmailFetch
10
+ readonly url: string
11
+ readonly headers: Readonly<Record<string, string>>
12
+ readonly body: unknown
13
+ readonly signal: AbortSignal
14
+ readonly timeoutMs: number
15
+ }
16
+
17
+ export interface ProviderResponse {
18
+ readonly ok: boolean
19
+ readonly status: number
20
+ /** The parsed JSON body, or undefined when it is not JSON. Never put into an error. */
21
+ readonly body: unknown
22
+ }
23
+
24
+ /** The answer with its body already read, so an abort or a timeout while the body arrives is classified as one. */
25
+ export async function postToProvider(request: ProviderRequest): Promise<ProviderResponse> {
26
+ if (request.signal.aborted) throw abortedError(request.label)
27
+ const timeout = AbortSignal.timeout(request.timeoutMs)
28
+ const signal = AbortSignal.any([request.signal, timeout])
29
+ try {
30
+ const response = await request.fetch(request.url, {
31
+ method: 'POST',
32
+ headers: { ...request.headers, 'Content-Type': 'application/json' },
33
+ signal,
34
+ body: JSON.stringify(request.body),
35
+ })
36
+ const text = await untilAborted(response.text(), signal)
37
+ return { ok: response.ok, status: response.status, body: parseJson(text) }
38
+ } catch {
39
+ if (request.signal.aborted) throw abortedError(request.label)
40
+ if (timeout.aborted) throw new EmailDeliveryError(`${request.label} request timed out`, { code: 'request_timeout' })
41
+ throw new EmailDeliveryError(`${request.label} request failed`, { code: 'network_error' })
42
+ }
43
+ }
44
+
45
+ export function parseTimeout(label: string, value: number | undefined): number {
46
+ const timeoutMs = value ?? 10_000
47
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 120_000) {
48
+ throw new EmailDeliveryError(`${label} timeout is invalid`, { code: 'invalid_configuration' })
49
+ }
50
+ return timeoutMs
51
+ }
52
+
53
+ /** An injected fetch's body need not follow the signal, so the read races it. */
54
+ function untilAborted<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
55
+ return new Promise<T>((resolve, reject) => {
56
+ const onAbort = (): void => reject(signal.reason)
57
+ signal.addEventListener('abort', onAbort, { once: true })
58
+ pending.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort))
59
+ })
60
+ }
61
+
62
+ function parseJson(text: string): unknown {
63
+ try {
64
+ return JSON.parse(text)
65
+ } catch {
66
+ return undefined
67
+ }
68
+ }
69
+
70
+ function abortedError(label: string): EmailDeliveryError {
71
+ return new EmailDeliveryError(`${label} request was aborted by the caller`, { code: 'aborted' })
72
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,28 @@
1
- export { EmailDeliveryError } from './email.js'
2
- export type { EmailDeliveryErrorCode, EmailDeliveryErrorOptions, EmailDeliveryResult, EmailMessage, EmailSender } from './email.js'
3
- export { isValidEmailAddress, isValidEmailFrom, MAX_EMAIL_SUBJECT_LENGTH, validateEmailMessage } from './validation.js'
1
+ export {
2
+ EMAIL_PROVIDER_VARIABLE,
3
+ emailSenderFromEnv,
4
+ NO_EMAIL_PROVIDER,
5
+ parseProviderVariables,
6
+ pickEmailEnvironment,
7
+ readEmailTransportConfig,
8
+ } from './config.js'
9
+ export type {
10
+ EmailEnvironment,
11
+ EmailProvider,
12
+ EmailProviderRegistry,
13
+ EmailProviderVariable,
14
+ EmailSenderIdentity,
15
+ EmailTransportConfig,
16
+ } from './config.js'
17
+ export { EMAIL_DELIVERY_ERRORS, EmailDeliveryError, mayRetryDelivery } from './email.js'
18
+ export type {
19
+ EmailDeliveryErrorCode,
20
+ EmailDeliveryErrorOptions,
21
+ EmailDeliveryOutcome,
22
+ EmailDeliveryResult,
23
+ EmailFetch,
24
+ EmailMessage,
25
+ EmailSender,
26
+ EmailSenderCapabilities,
27
+ } from './email.js'
28
+ export { isValidEmailAddress, isValidEmailDisplayName, isValidEmailFrom, MAX_EMAIL_SUBJECT_LENGTH, validateEmailMessage } from './validation.js'
package/src/memory.ts ADDED
@@ -0,0 +1,174 @@
1
+ // A sender that keeps every message in memory and delivers nothing: the stand-in for tests and local development.
2
+ // Runtime-neutral. Its faults let a test reproduce each answer a provider can give, including the one where the
3
+ // provider took the message and the answer was lost.
4
+
5
+ import { type EmailContent, emailContentOf, sameEmailContent } from './content.js'
6
+ import {
7
+ EmailDeliveryError,
8
+ type EmailDeliveryErrorCode,
9
+ type EmailDeliveryResult,
10
+ type EmailMessage,
11
+ type EmailSender,
12
+ type EmailSenderCapabilities,
13
+ } from './email.js'
14
+ import { validateEmailMessage } from './validation.js'
15
+
16
+ export interface MemoryEmail {
17
+ readonly message: EmailMessage
18
+ readonly messageId: string
19
+ readonly acceptedAt: number
20
+ }
21
+
22
+ export interface MemoryHandoff {
23
+ readonly message: EmailMessage
24
+ readonly at: number
25
+ /** Whether this call added the message to `accepted`. */
26
+ readonly taken: boolean
27
+ readonly answer: 'accepted' | EmailDeliveryErrorCode
28
+ }
29
+
30
+ export type MemoryRefusal = 'provider_rejected' | 'provider_unauthorized' | 'rate_limited' | 'quota_exceeded'
31
+
32
+ export interface MemoryEmailSenderOptions {
33
+ readonly capabilities?: Partial<EmailSenderCapabilities>
34
+ readonly provider?: string
35
+ readonly now?: () => number
36
+ }
37
+
38
+ type MemoryHook = (message: EmailMessage, signal: AbortSignal) => Promise<void>
39
+ type MemoryFault = MemoryRefusal | 'provider_unavailable' | 'network_error'
40
+ type MemoryFailure = MemoryFault | 'aborted' | 'idempotency_conflict'
41
+
42
+ interface MutableHandoff {
43
+ readonly message: EmailMessage
44
+ readonly at: number
45
+ taken: boolean
46
+ answer: 'accepted' | EmailDeliveryErrorCode
47
+ }
48
+
49
+ interface KeyedEmail {
50
+ readonly content: EmailContent
51
+ readonly messageId: string
52
+ }
53
+
54
+ const FAULT_MESSAGES: Readonly<Record<MemoryFault, string>> = {
55
+ provider_rejected: 'The memory sender rejected the message',
56
+ provider_unauthorized: 'The memory sender refused the credentials',
57
+ rate_limited: 'The memory sender rate limit was exceeded',
58
+ quota_exceeded: 'The memory sender quota was exceeded',
59
+ provider_unavailable: 'The memory sender is unavailable',
60
+ network_error: 'The memory sender could not be reached',
61
+ }
62
+
63
+ export class MemoryEmailSender implements EmailSender {
64
+ readonly capabilities: EmailSenderCapabilities
65
+ readonly #provider: string
66
+ readonly #now: () => number
67
+ readonly #accepted: MemoryEmail[] = []
68
+ readonly #handoffs: MutableHandoff[] = []
69
+ readonly #byKey = new Map<string, KeyedEmail>()
70
+ #fault: MemoryFault | null = null
71
+ #lostAnswers = 0
72
+ #during: MemoryHook | null = null
73
+ #taken = 0
74
+
75
+ constructor(options: MemoryEmailSenderOptions = {}) {
76
+ this.capabilities = { idempotent: true, confirmation: 'sync', maxBatch: 1, ...options.capabilities }
77
+ if (!Number.isSafeInteger(this.capabilities.maxBatch) || this.capabilities.maxBatch < 1) {
78
+ throw new EmailDeliveryError('The memory sender batch size is invalid', { code: 'invalid_configuration' })
79
+ }
80
+ this.#provider = options.provider ?? 'memory'
81
+ this.#now = options.now ?? Date.now
82
+ }
83
+
84
+ /** What reached the inbox, oldest first. */
85
+ get accepted(): readonly MemoryEmail[] {
86
+ return [...this.#accepted]
87
+ }
88
+
89
+ /** Every call that passed validation and the abort check. */
90
+ get handoffs(): readonly MemoryHandoff[] {
91
+ return this.#handoffs.map((handoff) => ({ ...handoff }))
92
+ }
93
+
94
+ /** Every following call is refused with `code` and takes nothing. */
95
+ refuse(code: MemoryRefusal = 'provider_rejected'): void {
96
+ this.#fault = code
97
+ }
98
+
99
+ /** Every following call answers `provider_unavailable` and takes nothing. */
100
+ unavailable(): void {
101
+ this.#fault = 'provider_unavailable'
102
+ }
103
+
104
+ /** Every following call answers `network_error` and takes nothing. */
105
+ unreachable(): void {
106
+ this.#fault = 'network_error'
107
+ }
108
+
109
+ /** The next `count` calls take the message, then answer `network_error`. */
110
+ loseAnswers(count: number): void {
111
+ this.#lostAnswers = count
112
+ }
113
+
114
+ /** Runs after the handoff is recorded and before the answer; a rejection becomes `aborted` once the signal aborted, else `network_error`. */
115
+ during(hook: MemoryHook | null): void {
116
+ this.#during = hook
117
+ }
118
+
119
+ heal(): void {
120
+ this.#fault = null
121
+ this.#lostAnswers = 0
122
+ this.#during = null
123
+ }
124
+
125
+ async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
126
+ validateEmailMessage(message)
127
+ if (signal.aborted) throw failure('aborted')
128
+ const handoff: MutableHandoff = { message, at: this.#now(), taken: false, answer: 'network_error' }
129
+ this.#handoffs.push(handoff)
130
+ const answer = (code: MemoryFailure): EmailDeliveryError => {
131
+ handoff.answer = code
132
+ return failure(code)
133
+ }
134
+
135
+ if (this.#during !== null) {
136
+ try {
137
+ await this.#during(message, signal)
138
+ } catch {
139
+ throw answer(signal.aborted ? 'aborted' : 'network_error')
140
+ }
141
+ }
142
+ if (signal.aborted) throw answer('aborted')
143
+ if (this.#fault !== null) throw answer(this.#fault)
144
+
145
+ const content = emailContentOf(message)
146
+ const previous = this.capabilities.idempotent ? this.#byKey.get(message.idempotencyKey) : undefined
147
+ if (previous !== undefined && !sameEmailContent(previous.content, content)) throw answer('idempotency_conflict')
148
+ const messageId = previous?.messageId ?? this.#take(message, content)
149
+ handoff.taken = previous === undefined
150
+
151
+ if (this.#lostAnswers > 0) {
152
+ this.#lostAnswers -= 1
153
+ throw answer('network_error')
154
+ }
155
+ handoff.answer = 'accepted'
156
+ return { status: 'accepted', provider: this.#provider, messageId }
157
+ }
158
+
159
+ #take(message: EmailMessage, content: EmailContent): string {
160
+ this.#taken += 1
161
+ const messageId = `memory-${this.#taken}`
162
+ this.#accepted.push({ message, messageId, acceptedAt: this.#now() })
163
+ if (this.capabilities.idempotent) this.#byKey.set(message.idempotencyKey, { content, messageId })
164
+ return messageId
165
+ }
166
+ }
167
+
168
+ function failure(code: MemoryFailure): EmailDeliveryError {
169
+ if (code === 'aborted') return new EmailDeliveryError('The memory sender call was aborted by the caller', { code })
170
+ if (code === 'idempotency_conflict') {
171
+ return new EmailDeliveryError('A different message was already accepted under this idempotency key', { code })
172
+ }
173
+ return new EmailDeliveryError(FAULT_MESSAGES[code], { code })
174
+ }
@@ -0,0 +1,9 @@
1
+ import type { EmailProviderRegistry } from './config.js'
2
+ import { resendEmailProvider } from './resend.js'
3
+ import { smartEmailingEmailProvider } from './smartemailing.js'
4
+
5
+ /** The providers `FABRIKA_EMAIL_PROVIDER` can name in a stock installation. Memory and Directory are composition inputs. */
6
+ export const builtinEmailProviders = {
7
+ resend: resendEmailProvider,
8
+ smartemailing: smartEmailingEmailProvider,
9
+ } satisfies EmailProviderRegistry