@fabrika/email 0.8.1 → 0.9.0-cloudflare.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 +31 -10
- package/src/ahasend.ts +172 -0
- package/src/cloudflare.ts +142 -0
- package/src/conformance.ts +54 -0
- package/src/content.ts +3 -1
- package/src/directory.ts +4 -1
- package/src/email.ts +12 -0
- package/src/http-sender.ts +138 -0
- package/src/index.ts +2 -0
- package/src/lettermint.ts +153 -0
- package/src/memory.ts +3 -1
- package/src/platform-wire.ts +6 -0
- package/src/platform.ts +2 -1
- package/src/postmark.ts +136 -0
- package/src/providers.ts +33 -2
- package/src/resend.ts +48 -85
- package/src/smartemailing.ts +30 -74
- package/src/validation.ts +31 -3
- package/types/src/ahasend.d.ts +26 -0
- package/types/src/cloudflare.d.ts +20 -0
- package/types/src/config.d.ts +48 -0
- package/types/src/conformance.d.ts +59 -0
- package/types/src/content.d.ts +23 -0
- package/types/src/directory.d.ts +84 -0
- package/types/src/email.d.ts +142 -0
- package/types/src/http-sender.d.ts +49 -0
- package/types/src/http.d.ts +20 -0
- package/types/src/index.d.ts +5 -0
- package/types/src/lettermint.d.ts +19 -0
- package/types/src/memory.d.ts +42 -0
- package/types/src/platform-wire.d.ts +63 -0
- package/types/src/platform.d.ts +19 -0
- package/types/src/postmark.d.ts +19 -0
- package/types/src/providers.d.ts +29 -0
- package/types/src/resend.d.ts +16 -0
- package/types/src/smartemailing.d.ts +24 -0
- package/types/src/validation.d.ts +35 -0
package/src/resend.ts
CHANGED
|
@@ -1,80 +1,65 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import { type EmailProvider,
|
|
2
|
+
import { type EmailProvider, parseProviderVariables } from './config.js'
|
|
3
3
|
import { base64Of } from './content.js'
|
|
4
|
+
import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities, oneClickUnsubscribeHeaders } from './email.js'
|
|
4
5
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
type
|
|
8
|
-
|
|
9
|
-
type
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from './
|
|
13
|
-
import { parseTimeout, postToProvider, type ProviderResponse } from './http.js'
|
|
14
|
-
import { isValidEmailSenderIdentity, isVisibleAscii, validateEmailAttachments, validateEmailMessage } from './validation.js'
|
|
6
|
+
credentialVariable,
|
|
7
|
+
formatMailbox,
|
|
8
|
+
type HttpEmailRequest,
|
|
9
|
+
HttpEmailSender,
|
|
10
|
+
type HttpEmailSenderOptions,
|
|
11
|
+
isCredential,
|
|
12
|
+
} from './http-sender.js'
|
|
13
|
+
import type { ProviderResponse } from './http.js'
|
|
15
14
|
|
|
16
15
|
const RESEND_EMAILS_URL = 'https://api.resend.com/emails'
|
|
17
16
|
const LABEL = 'Resend'
|
|
18
|
-
const
|
|
17
|
+
const BROADCAST_TAG = { name: 'category', value: 'broadcast' }
|
|
18
|
+
const resendSuccessSchema = z.object({ id: z.string() })
|
|
19
19
|
const resendErrorSchema = z.object({ name: z.string() })
|
|
20
20
|
const QUOTA_ERRORS: ReadonlySet<string> = new Set(['daily_quota_exceeded', 'monthly_quota_exceeded'])
|
|
21
|
-
/** RFC 5322 specials a display name may still hold after validation; any of them makes the name a quoted string. */
|
|
22
|
-
const DISPLAY_NAME_SPECIALS = /[()[\]:;@,.]/
|
|
23
21
|
/** Resend refuses a message over 40 MB once its attachments are base64; 25 MiB of bytes leaves room for the rest. */
|
|
24
22
|
const RESEND_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
|
25
23
|
|
|
26
|
-
export interface ResendEmailSenderOptions {
|
|
24
|
+
export interface ResendEmailSenderOptions extends HttpEmailSenderOptions {
|
|
27
25
|
readonly apiKey: string
|
|
28
|
-
readonly from: EmailSenderIdentity
|
|
29
|
-
readonly fetch?: EmailFetch
|
|
30
|
-
readonly timeoutMs?: number
|
|
31
26
|
}
|
|
32
27
|
|
|
33
|
-
export class ResendEmailSender
|
|
28
|
+
export class ResendEmailSender extends HttpEmailSender {
|
|
34
29
|
readonly capabilities: EmailSenderCapabilities = {
|
|
35
30
|
idempotent: true,
|
|
36
31
|
confirmation: 'sync',
|
|
37
32
|
maxBatch: 1,
|
|
38
33
|
listUnsubscribe: true,
|
|
39
34
|
maxAttachmentBytes: RESEND_MAX_ATTACHMENT_BYTES,
|
|
35
|
+
// Resend takes one-to-one marketing mail through this API, with consent and an unsubscribe; it has no channel apart.
|
|
36
|
+
broadcast: true,
|
|
40
37
|
}
|
|
41
38
|
readonly #apiKey: string
|
|
42
|
-
readonly #from: EmailSenderIdentity
|
|
43
|
-
readonly #fetch: EmailFetch
|
|
44
|
-
readonly #timeoutMs: number
|
|
45
39
|
|
|
46
40
|
constructor(options: ResendEmailSenderOptions) {
|
|
47
|
-
if (!
|
|
41
|
+
if (!isCredential(options.apiKey)) {
|
|
48
42
|
throw new EmailDeliveryError('Resend API key is invalid', { code: 'invalid_configuration' })
|
|
49
43
|
}
|
|
50
|
-
|
|
51
|
-
throw new EmailDeliveryError('Resend sender is invalid', { code: 'invalid_configuration' })
|
|
52
|
-
}
|
|
53
|
-
|
|
44
|
+
super({ label: LABEL, provider: 'resend' }, options)
|
|
54
45
|
this.#apiKey = options.apiKey
|
|
55
|
-
this.#from = options.from
|
|
56
|
-
this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init))
|
|
57
|
-
this.#timeoutMs = parseTimeout(LABEL, options.timeoutMs)
|
|
58
46
|
}
|
|
59
47
|
|
|
60
|
-
|
|
61
|
-
validateEmailMessage(message)
|
|
62
|
-
validateEmailAttachments(message, this.capabilities, LABEL)
|
|
48
|
+
protected request(message: EmailMessage): HttpEmailRequest {
|
|
63
49
|
const attachments = message.attachments ?? []
|
|
64
|
-
|
|
65
|
-
const response = await postToProvider({
|
|
66
|
-
label: LABEL,
|
|
67
|
-
fetch: this.#fetch,
|
|
50
|
+
return {
|
|
68
51
|
url: RESEND_EMAILS_URL,
|
|
69
52
|
headers: { Authorization: `Bearer ${this.#apiKey}`, 'Idempotency-Key': message.idempotencyKey },
|
|
70
53
|
body: {
|
|
71
|
-
from: formatMailbox(this
|
|
54
|
+
from: formatMailbox(this.from.address, message.fromName ?? this.from.name),
|
|
72
55
|
to: message.to,
|
|
73
56
|
subject: message.subject,
|
|
74
57
|
text: message.text,
|
|
75
58
|
...(message.html === undefined ? {} : { html: message.html }),
|
|
76
59
|
...(message.replyTo === undefined ? {} : { reply_to: message.replyTo }),
|
|
77
60
|
...(message.unsubscribeUrl === undefined ? {} : { headers: oneClickUnsubscribeHeaders(message.unsubscribeUrl) }),
|
|
61
|
+
// Tagged, so the account's logs and webhooks tell broadcast mail from transactional.
|
|
62
|
+
...(message.broadcast === true ? { tags: [BROADCAST_TAG] } : {}),
|
|
78
63
|
...(attachments.length === 0
|
|
79
64
|
? {}
|
|
80
65
|
: {
|
|
@@ -85,22 +70,37 @@ export class ResendEmailSender implements EmailSender {
|
|
|
85
70
|
})),
|
|
86
71
|
}),
|
|
87
72
|
},
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
91
75
|
|
|
92
|
-
|
|
76
|
+
protected messageId(response: ProviderResponse): string | undefined {
|
|
77
|
+
return resendSuccessSchema.safeParse(response.body).data?.id
|
|
78
|
+
}
|
|
93
79
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
80
|
+
protected override refusal(response: ProviderResponse): EmailDeliveryError | undefined {
|
|
81
|
+
const status = response.status
|
|
82
|
+
const name = resendErrorSchema.safeParse(response.body).data?.name
|
|
83
|
+
if (status === 401 || status === 403) {
|
|
84
|
+
// A 403 is also an unverified sender domain.
|
|
85
|
+
return new EmailDeliveryError(`Resend refused the credentials or the sender (${status})`, { code: 'provider_unauthorized', httpStatus: status })
|
|
97
86
|
}
|
|
98
|
-
|
|
99
|
-
|
|
87
|
+
if (status === 409 && name === 'concurrent_idempotent_requests') {
|
|
88
|
+
return new EmailDeliveryError('A matching Resend request is still in progress', { code: 'request_in_progress', httpStatus: status })
|
|
89
|
+
}
|
|
90
|
+
if (status === 409 && name === 'invalid_idempotent_request') {
|
|
91
|
+
return new EmailDeliveryError('Resend already accepted a different message under this idempotency key', {
|
|
92
|
+
code: 'idempotency_conflict',
|
|
93
|
+
httpStatus: status,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
if (status === 429 && name !== undefined && QUOTA_ERRORS.has(name)) {
|
|
97
|
+
return new EmailDeliveryError('Resend sending quota exceeded', { code: 'quota_exceeded', httpStatus: status })
|
|
98
|
+
}
|
|
99
|
+
return undefined
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
const resendVariablesSchema = z.object({ FABRIKA_EMAIL_RESEND_API_KEY:
|
|
103
|
+
const resendVariablesSchema = z.object({ FABRIKA_EMAIL_RESEND_API_KEY: credentialVariable })
|
|
104
104
|
|
|
105
105
|
export const resendEmailProvider: EmailProvider = {
|
|
106
106
|
variables: [{ name: 'FABRIKA_EMAIL_RESEND_API_KEY', secret: true, required: true }],
|
|
@@ -109,40 +109,3 @@ export const resendEmailProvider: EmailProvider = {
|
|
|
109
109
|
return new ResendEmailSender({ apiKey: variables.FABRIKA_EMAIL_RESEND_API_KEY, from, fetch })
|
|
110
110
|
},
|
|
111
111
|
}
|
|
112
|
-
|
|
113
|
-
function responseError(response: ProviderResponse): EmailDeliveryError {
|
|
114
|
-
const status = response.status
|
|
115
|
-
const parsed = resendErrorSchema.safeParse(response.body)
|
|
116
|
-
const name = parsed.success ? parsed.data.name : undefined
|
|
117
|
-
if (status === 401 || status === 403) {
|
|
118
|
-
return new EmailDeliveryError(`Resend refused the credentials or the sender (${status})`, { code: 'provider_unauthorized', httpStatus: status })
|
|
119
|
-
}
|
|
120
|
-
if (status === 409 && name === 'concurrent_idempotent_requests') {
|
|
121
|
-
return new EmailDeliveryError('A matching Resend request is still in progress', { code: 'request_in_progress', httpStatus: status })
|
|
122
|
-
}
|
|
123
|
-
if (status === 409 && name === 'invalid_idempotent_request') {
|
|
124
|
-
return new EmailDeliveryError('Resend already accepted a different message under this idempotency key', {
|
|
125
|
-
code: 'idempotency_conflict',
|
|
126
|
-
httpStatus: status,
|
|
127
|
-
})
|
|
128
|
-
}
|
|
129
|
-
if (status === 429 && name !== undefined && QUOTA_ERRORS.has(name)) {
|
|
130
|
-
return new EmailDeliveryError('Resend sending quota exceeded', { code: 'quota_exceeded', httpStatus: status })
|
|
131
|
-
}
|
|
132
|
-
if (status === 429) {
|
|
133
|
-
return new EmailDeliveryError('Resend rate limit exceeded', { code: 'rate_limited', httpStatus: status })
|
|
134
|
-
}
|
|
135
|
-
if (status >= 500) {
|
|
136
|
-
return new EmailDeliveryError('Resend is unavailable', { code: 'provider_unavailable', httpStatus: status })
|
|
137
|
-
}
|
|
138
|
-
return new EmailDeliveryError(`Resend rejected the request (${status})`, { code: 'provider_rejected', httpStatus: status })
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function formatMailbox(address: string, name: string | undefined): string {
|
|
142
|
-
if (name === undefined) return address
|
|
143
|
-
return DISPLAY_NAME_SPECIALS.test(name) ? `"${name}" <${address}>` : `${name} <${address}>`
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function validApiKey(value: string): boolean {
|
|
147
|
-
return value.length > 0 && isVisibleAscii(value)
|
|
148
|
-
}
|
package/src/smartemailing.ts
CHANGED
|
@@ -2,83 +2,66 @@
|
|
|
2
2
|
// verified only against a stubbed fetch. It has no idempotency key, and a 201 only queues the request.
|
|
3
3
|
|
|
4
4
|
import { z } from 'zod'
|
|
5
|
-
import { type EmailProvider,
|
|
5
|
+
import { type EmailProvider, parseProviderVariables } from './config.js'
|
|
6
|
+
import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities } from './email.js'
|
|
6
7
|
import {
|
|
7
|
-
|
|
8
|
-
type
|
|
9
|
-
|
|
10
|
-
type
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
} from './
|
|
14
|
-
import {
|
|
15
|
-
import { isValidEmailSenderIdentity, isVisibleAscii, validateEmailAttachments, validateEmailMessage } from './validation.js'
|
|
8
|
+
credentialVariable,
|
|
9
|
+
type HttpEmailRequest,
|
|
10
|
+
HttpEmailSender,
|
|
11
|
+
type HttpEmailSenderOptions,
|
|
12
|
+
isCredential,
|
|
13
|
+
optionalVariable,
|
|
14
|
+
} from './http-sender.js'
|
|
15
|
+
import type { ProviderResponse } from './http.js'
|
|
16
16
|
|
|
17
17
|
const SMARTEMAILING_SEND_URL = 'https://app.smartemailing.cz/api/v3/send/transactional-emails-bulk'
|
|
18
|
-
const LABEL = 'SmartEmailing'
|
|
19
18
|
const DEFAULT_TAG = 'fabrika'
|
|
20
19
|
const TAG_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
|
|
21
|
-
const smartEmailingSuccessSchema = z.object({ data: z.tuple([z.object({ id: z.string()
|
|
20
|
+
const smartEmailingSuccessSchema = z.object({ data: z.tuple([z.object({ id: z.string() })]) })
|
|
22
21
|
|
|
23
|
-
export interface SmartEmailingEmailSenderOptions {
|
|
22
|
+
export interface SmartEmailingEmailSenderOptions extends HttpEmailSenderOptions {
|
|
24
23
|
/** The account's user name or e-mail: the first half of the Basic credential. */
|
|
25
24
|
readonly username: string
|
|
26
25
|
readonly apiKey: string
|
|
27
|
-
readonly from: EmailSenderIdentity
|
|
28
26
|
/** Groups the messages in SmartEmailing's statistics. */
|
|
29
27
|
readonly tag?: string
|
|
30
|
-
readonly fetch?: EmailFetch
|
|
31
|
-
readonly timeoutMs?: number
|
|
32
28
|
}
|
|
33
29
|
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
/** Its error body's `message` is free text that may quote the request, so a refusal is classified by status alone. */
|
|
31
|
+
export class SmartEmailingEmailSender extends HttpEmailSender {
|
|
32
|
+
/**
|
|
33
|
+
* The API takes up to five tasks a request, but `send` carries one. Its API documents no message headers, so a
|
|
34
|
+
* one-click unsubscribe is refused rather than sent without one.
|
|
35
|
+
*/
|
|
36
36
|
readonly capabilities: EmailSenderCapabilities = { idempotent: false, confirmation: 'queued', maxBatch: 1 }
|
|
37
37
|
readonly #authorization: string
|
|
38
|
-
readonly #from: EmailSenderIdentity
|
|
39
38
|
readonly #tag: string
|
|
40
|
-
readonly #fetch: EmailFetch
|
|
41
|
-
readonly #timeoutMs: number
|
|
42
39
|
|
|
43
40
|
constructor(options: SmartEmailingEmailSenderOptions) {
|
|
44
41
|
if (!validUsername(options.username)) {
|
|
45
42
|
throw new EmailDeliveryError('SmartEmailing username is invalid', { code: 'invalid_configuration' })
|
|
46
43
|
}
|
|
47
|
-
if (!
|
|
44
|
+
if (!isCredential(options.apiKey)) {
|
|
48
45
|
throw new EmailDeliveryError('SmartEmailing API key is invalid', { code: 'invalid_configuration' })
|
|
49
46
|
}
|
|
50
|
-
|
|
51
|
-
throw new EmailDeliveryError('SmartEmailing sender is invalid', { code: 'invalid_configuration' })
|
|
52
|
-
}
|
|
47
|
+
super({ label: 'SmartEmailing', provider: 'smartemailing' }, options)
|
|
53
48
|
const tag = options.tag ?? DEFAULT_TAG
|
|
54
49
|
if (!TAG_PATTERN.test(tag)) {
|
|
55
50
|
throw new EmailDeliveryError('SmartEmailing tag is invalid', { code: 'invalid_configuration' })
|
|
56
51
|
}
|
|
57
52
|
|
|
58
53
|
this.#authorization = `Basic ${btoa(`${options.username}:${options.apiKey}`)}`
|
|
59
|
-
this.#from = options.from
|
|
60
54
|
this.#tag = tag
|
|
61
|
-
this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init))
|
|
62
|
-
this.#timeoutMs = parseTimeout(LABEL, options.timeoutMs)
|
|
63
55
|
}
|
|
64
56
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (message.unsubscribeUrl !== undefined) {
|
|
69
|
-
throw new EmailDeliveryError('SmartEmailing cannot write the List-Unsubscribe headers', { code: 'invalid_message' })
|
|
70
|
-
}
|
|
71
|
-
validateEmailAttachments(message, this.capabilities, LABEL)
|
|
72
|
-
|
|
73
|
-
const senderName = message.fromName ?? this.#from.name
|
|
74
|
-
const response = await postToProvider({
|
|
75
|
-
label: LABEL,
|
|
76
|
-
fetch: this.#fetch,
|
|
57
|
+
protected request(message: EmailMessage): HttpEmailRequest {
|
|
58
|
+
const senderName = message.fromName ?? this.from.name
|
|
59
|
+
return {
|
|
77
60
|
url: SMARTEMAILING_SEND_URL,
|
|
78
61
|
headers: { Authorization: this.#authorization },
|
|
79
62
|
body: {
|
|
80
63
|
sender_credentials: {
|
|
81
|
-
from: this
|
|
64
|
+
from: this.from.address,
|
|
82
65
|
...(senderName === undefined ? {} : { sender_name: senderName }),
|
|
83
66
|
...(message.replyTo === undefined ? {} : { reply_to: message.replyTo }),
|
|
84
67
|
},
|
|
@@ -91,28 +74,18 @@ export class SmartEmailingEmailSender implements EmailSender {
|
|
|
91
74
|
},
|
|
92
75
|
tasks: [{ recipient: { emailaddress: message.to } }],
|
|
93
76
|
},
|
|
94
|
-
signal,
|
|
95
|
-
timeoutMs: this.#timeoutMs,
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
// The error body's `message` is free text that may quote the request, so only the status is classified.
|
|
99
|
-
if (!response.ok) throw responseError(response.status)
|
|
100
|
-
|
|
101
|
-
const parsed = smartEmailingSuccessSchema.safeParse(response.body)
|
|
102
|
-
if (!parsed.success) {
|
|
103
|
-
throw new EmailDeliveryError('SmartEmailing returned an invalid success response', { code: 'malformed_response', httpStatus: response.status })
|
|
104
77
|
}
|
|
78
|
+
}
|
|
105
79
|
|
|
106
|
-
|
|
80
|
+
protected messageId(response: ProviderResponse): string | undefined {
|
|
81
|
+
return smartEmailingSuccessSchema.safeParse(response.body).data?.data[0].id
|
|
107
82
|
}
|
|
108
83
|
}
|
|
109
84
|
|
|
110
85
|
const smartEmailingVariablesSchema = z.object({
|
|
111
86
|
FABRIKA_EMAIL_SMARTEMAILING_USERNAME: z.string().refine(validUsername),
|
|
112
|
-
FABRIKA_EMAIL_SMARTEMAILING_API_KEY:
|
|
113
|
-
FABRIKA_EMAIL_SMARTEMAILING_TAG:
|
|
114
|
-
value === undefined || TAG_PATTERN.test(value)
|
|
115
|
-
),
|
|
87
|
+
FABRIKA_EMAIL_SMARTEMAILING_API_KEY: credentialVariable,
|
|
88
|
+
FABRIKA_EMAIL_SMARTEMAILING_TAG: optionalVariable((value) => TAG_PATTERN.test(value)),
|
|
116
89
|
})
|
|
117
90
|
|
|
118
91
|
export const smartEmailingEmailProvider: EmailProvider = {
|
|
@@ -133,23 +106,6 @@ export const smartEmailingEmailProvider: EmailProvider = {
|
|
|
133
106
|
},
|
|
134
107
|
}
|
|
135
108
|
|
|
136
|
-
function responseError(status: number): EmailDeliveryError {
|
|
137
|
-
if (status === 401 || status === 403) {
|
|
138
|
-
return new EmailDeliveryError(`SmartEmailing refused the credentials (${status})`, { code: 'provider_unauthorized', httpStatus: status })
|
|
139
|
-
}
|
|
140
|
-
if (status === 429) {
|
|
141
|
-
return new EmailDeliveryError('SmartEmailing rate limit exceeded', { code: 'rate_limited', httpStatus: status })
|
|
142
|
-
}
|
|
143
|
-
if (status >= 500) {
|
|
144
|
-
return new EmailDeliveryError('SmartEmailing is unavailable', { code: 'provider_unavailable', httpStatus: status })
|
|
145
|
-
}
|
|
146
|
-
return new EmailDeliveryError(`SmartEmailing rejected the request (${status})`, { code: 'provider_rejected', httpStatus: status })
|
|
147
|
-
}
|
|
148
|
-
|
|
149
109
|
function validUsername(value: string): boolean {
|
|
150
|
-
return
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function validApiKey(value: string): boolean {
|
|
154
|
-
return value.length > 0 && isVisibleAscii(value)
|
|
110
|
+
return isCredential(value) && !value.includes(':')
|
|
155
111
|
}
|
package/src/validation.ts
CHANGED
|
@@ -64,6 +64,7 @@ export function validateEmailMessage(message: EmailMessage): void {
|
|
|
64
64
|
if (message.replyTo !== undefined && !isValidEmailAddress(message.replyTo)) throw invalidMessage('Email reply-to address is invalid')
|
|
65
65
|
if (message.fromName !== undefined && !isValidEmailDisplayName(message.fromName)) throw invalidMessage('Email sender name is invalid')
|
|
66
66
|
if (message.unsubscribeUrl !== undefined && !isUnsubscribeUrl(message.unsubscribeUrl)) throw invalidMessage('Email unsubscribe URL is invalid')
|
|
67
|
+
if (message.broadcast === true && message.unsubscribeUrl === undefined) throw invalidMessage('A broadcast email needs an unsubscribe URL')
|
|
67
68
|
const attachments = message.attachments ?? []
|
|
68
69
|
if (attachments.length > MAX_EMAIL_ATTACHMENTS) throw invalidMessage(`Email has more than ${MAX_EMAIL_ATTACHMENTS} attachments`)
|
|
69
70
|
for (const attachment of attachments) {
|
|
@@ -73,15 +74,42 @@ export function validateEmailMessage(message: EmailMessage): void {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
|
-
* Refuses a message whose attachments `capabilities` does not carry: any attachment where the transport sends none,
|
|
77
|
-
* more bytes together than its `maxAttachmentBytes
|
|
77
|
+
* Refuses a message whose attachments `capabilities` does not carry: any attachment where the transport sends none,
|
|
78
|
+
* more bytes together than its `maxAttachmentBytes`, or a file whose extension it refuses. `label` names the transport
|
|
79
|
+
* in the message.
|
|
78
80
|
*/
|
|
79
81
|
export function validateEmailAttachments(message: EmailMessage, capabilities: EmailSenderCapabilities, label: string): void {
|
|
80
|
-
|
|
82
|
+
const attachments = message.attachments ?? []
|
|
83
|
+
if (attachments.length === 0) return
|
|
81
84
|
const limit = capabilities.maxAttachmentBytes
|
|
82
85
|
if (limit === undefined) throw invalidMessage(`${label} sends no attachments`)
|
|
83
86
|
const bytes = attachmentBytes(message)
|
|
84
87
|
if (bytes > limit) throw invalidMessage(`Email attachments have ${bytes} bytes, more than the ${limit} ${label} sends`)
|
|
88
|
+
const refused = capabilities.refusedAttachmentExtensions ?? []
|
|
89
|
+
if (refused.length > 0 && attachments.some((attachment) => refused.includes(extensionOf(attachment.filename)))) {
|
|
90
|
+
throw invalidMessage(`${label} sends no attachment of this file type`)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Refuses a `broadcast` message where the transport sends none. */
|
|
95
|
+
export function validateEmailBroadcast(message: EmailMessage, capabilities: EmailSenderCapabilities, label: string): void {
|
|
96
|
+
if (message.broadcast === true && capabilities.broadcast !== true) throw invalidMessage(`${label} sends no broadcast email`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Refuses a text or HTML body shorter than the transport's `minBodyLength`, counted in characters once trimmed. */
|
|
100
|
+
export function validateEmailBodies(message: EmailMessage, capabilities: EmailSenderCapabilities, label: string): void {
|
|
101
|
+
const min = capabilities.minBodyLength
|
|
102
|
+
if (min === undefined) return
|
|
103
|
+
const short = (body: string): boolean => [...body.trim()].length < min
|
|
104
|
+
if (short(message.text) || (message.html !== undefined && short(message.html))) {
|
|
105
|
+
throw invalidMessage(`${label} sends no body shorter than ${min} characters`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The extension after the last dot, lowercase; empty without one. */
|
|
110
|
+
function extensionOf(filename: string): string {
|
|
111
|
+
const dot = filename.lastIndexOf('.')
|
|
112
|
+
return dot < 0 ? '' : filename.slice(dot + 1).toLowerCase()
|
|
85
113
|
}
|
|
86
114
|
|
|
87
115
|
/** The most files one message carries. */
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type EmailProvider } from './config.js';
|
|
2
|
+
import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities } from './email.js';
|
|
3
|
+
import { type HttpEmailRequest, HttpEmailSender, type HttpEmailSenderOptions } from './http-sender.js';
|
|
4
|
+
import type { ProviderResponse } from './http.js';
|
|
5
|
+
export interface AhaSendEmailSenderOptions extends HttpEmailSenderOptions {
|
|
6
|
+
/** An API key v2, scoped to `messages:send:{domain}` at least. */
|
|
7
|
+
readonly apiKey: string;
|
|
8
|
+
/** The account the key belongs to: the path of every request. */
|
|
9
|
+
readonly accountId: string;
|
|
10
|
+
/**
|
|
11
|
+
* The address a `broadcast` message is sent from, on a domain the account has configured as a Marketing domain:
|
|
12
|
+
* AhaSend's terms allow marketing mail through the API from such a domain only. Without it, a broadcast is refused.
|
|
13
|
+
*/
|
|
14
|
+
readonly broadcastFrom?: string;
|
|
15
|
+
}
|
|
16
|
+
/** A refusal's body is one free-text `message`, so only the status is read. */
|
|
17
|
+
export declare class AhaSendEmailSender extends HttpEmailSender {
|
|
18
|
+
#private;
|
|
19
|
+
/** `broadcast` only with `broadcastFrom`. */
|
|
20
|
+
readonly capabilities: EmailSenderCapabilities;
|
|
21
|
+
constructor(options: AhaSendEmailSenderOptions);
|
|
22
|
+
protected request(message: EmailMessage): Promise<HttpEmailRequest>;
|
|
23
|
+
protected messageId(response: ProviderResponse): string | undefined;
|
|
24
|
+
protected refusal(response: ProviderResponse): EmailDeliveryError | undefined;
|
|
25
|
+
}
|
|
26
|
+
export declare const ahaSendEmailProvider: EmailProvider;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type EmailProvider } from './config.js';
|
|
2
|
+
import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities } from './email.js';
|
|
3
|
+
import { type HttpEmailRequest, HttpEmailSender, type HttpEmailSenderOptions } from './http-sender.js';
|
|
4
|
+
import type { ProviderResponse } from './http.js';
|
|
5
|
+
export interface CloudflareEmailSenderOptions extends HttpEmailSenderOptions {
|
|
6
|
+
/** An account API token with the Email Sending Write permission and nothing else. */
|
|
7
|
+
readonly apiToken: string;
|
|
8
|
+
/** The account the token sends for: the path of every request. */
|
|
9
|
+
readonly accountId: string;
|
|
10
|
+
}
|
|
11
|
+
/** A refusal's body carries numeric codes and machine-readable messages; only the status is read. */
|
|
12
|
+
export declare class CloudflareEmailSender extends HttpEmailSender {
|
|
13
|
+
#private;
|
|
14
|
+
readonly capabilities: EmailSenderCapabilities;
|
|
15
|
+
constructor(options: CloudflareEmailSenderOptions);
|
|
16
|
+
protected request(message: EmailMessage): HttpEmailRequest;
|
|
17
|
+
protected messageId(response: ProviderResponse, message: EmailMessage): string | undefined;
|
|
18
|
+
protected refusal(response: ProviderResponse): EmailDeliveryError | undefined;
|
|
19
|
+
}
|
|
20
|
+
export declare const cloudflareEmailProvider: EmailProvider;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type EmailFetch, type EmailSender } from './email.js';
|
|
3
|
+
export type EmailEnvironment = Readonly<Record<string, string | undefined>>;
|
|
4
|
+
export interface EmailSenderIdentity {
|
|
5
|
+
readonly address: string;
|
|
6
|
+
readonly name?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface EmailProviderVariable {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly secret: boolean;
|
|
11
|
+
readonly required: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface EmailProvider {
|
|
14
|
+
/** Provider-specific variables, beside FABRIKA_EMAIL_PROVIDER/FROM/FROM_NAME. */
|
|
15
|
+
readonly variables: readonly EmailProviderVariable[];
|
|
16
|
+
/** Parses its own variables; throws `invalid_configuration` naming the variable, never its value. */
|
|
17
|
+
create(input: {
|
|
18
|
+
readonly from: EmailSenderIdentity;
|
|
19
|
+
readonly env: EmailEnvironment;
|
|
20
|
+
readonly fetch?: EmailFetch;
|
|
21
|
+
}): EmailSender;
|
|
22
|
+
}
|
|
23
|
+
export type EmailProviderRegistry = Readonly<Record<string, EmailProvider>>;
|
|
24
|
+
export type EmailTransportConfig = {
|
|
25
|
+
readonly kind: 'none';
|
|
26
|
+
} | {
|
|
27
|
+
readonly kind: 'provider';
|
|
28
|
+
readonly provider: string;
|
|
29
|
+
readonly from: EmailSenderIdentity;
|
|
30
|
+
readonly variables: readonly EmailProviderVariable[];
|
|
31
|
+
};
|
|
32
|
+
/** The variable that selects the provider. */
|
|
33
|
+
export declare const EMAIL_PROVIDER_VARIABLE = "FABRIKA_EMAIL_PROVIDER";
|
|
34
|
+
/** `FABRIKA_EMAIL_PROVIDER=none`: the installation states it has no transport, which an absent variable does not. */
|
|
35
|
+
export declare const NO_EMAIL_PROVIDER = "none";
|
|
36
|
+
/** Throws one `invalid_configuration` listing every missing or invalid variable name. */
|
|
37
|
+
export declare function readEmailTransportConfig(env: EmailEnvironment, registry: EmailProviderRegistry): EmailTransportConfig;
|
|
38
|
+
/** null for `none` or an absent provider. */
|
|
39
|
+
export declare function emailSenderFromEnv(env: EmailEnvironment, registry: EmailProviderRegistry, options?: {
|
|
40
|
+
readonly fetch?: EmailFetch;
|
|
41
|
+
}): EmailSender | null;
|
|
42
|
+
/** The non-empty string `FABRIKA_EMAIL_*` entries of any binding object (Worker bindings, process.env). */
|
|
43
|
+
export declare function pickEmailEnvironment(source: object): EmailEnvironment;
|
|
44
|
+
/**
|
|
45
|
+
* A provider's own variables through its schema. A failure is one `invalid_configuration` naming each offending
|
|
46
|
+
* variable and never its value, in the form `readEmailTransportConfig` merges into its own refusal.
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseProviderVariables<Schema extends z.ZodTypeAny>(schema: Schema, env: EmailEnvironment): z.output<Schema>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type EmailAttachmentContent } from './content.js';
|
|
2
|
+
import { type EmailFetch, type EmailMessage, type EmailSender } from './email.js';
|
|
3
|
+
/** What the provider received, or what a local sender stored, in the message's own terms. */
|
|
4
|
+
export interface WireMessage {
|
|
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
|
+
readonly idempotencyKey?: string;
|
|
12
|
+
/** The URL of the `List-Unsubscribe` header, when `List-Unsubscribe-Post` says one click. */
|
|
13
|
+
readonly unsubscribeUrl?: string;
|
|
14
|
+
/** Each attachment with its bytes as base64. */
|
|
15
|
+
readonly attachments?: readonly EmailAttachmentContent[];
|
|
16
|
+
/** Whether the provider got it as broadcast mail, on its channel for them where it has one. */
|
|
17
|
+
readonly broadcast?: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** A stored or sent message in wire terms: what a local harness answers for an `EmailMessage` it kept. */
|
|
20
|
+
export declare function wireMessageOf(message: EmailMessage): WireMessage;
|
|
21
|
+
/** How the provider answers. `inProgress` and `idempotencyConflict` are required when the sender is idempotent. */
|
|
22
|
+
export interface ProviderAnswers {
|
|
23
|
+
accepted(request: Request): Response;
|
|
24
|
+
malformedSuccess(): Response;
|
|
25
|
+
unauthorized(): Response;
|
|
26
|
+
rejected(): Response;
|
|
27
|
+
rateLimited(): Response;
|
|
28
|
+
unavailable(): Response;
|
|
29
|
+
quotaExceeded?(): Response;
|
|
30
|
+
inProgress?(): Response;
|
|
31
|
+
idempotencyConflict?(): Response;
|
|
32
|
+
}
|
|
33
|
+
export interface EmailSenderHttpHarness {
|
|
34
|
+
readonly kind: 'http';
|
|
35
|
+
/** Credentials the sender holds; none may appear in an error or a log line. */
|
|
36
|
+
readonly secrets: readonly string[];
|
|
37
|
+
create(fetch: EmailFetch, options: {
|
|
38
|
+
readonly timeoutMs: number;
|
|
39
|
+
}): EmailSender;
|
|
40
|
+
readRequest(request: Request): Promise<WireMessage>;
|
|
41
|
+
readonly answers: ProviderAnswers;
|
|
42
|
+
}
|
|
43
|
+
/** A local sender's I/O cannot be held open from outside, so the abort-during-I/O cases run for HTTP senders only. */
|
|
44
|
+
export interface EmailSenderLocalHarness {
|
|
45
|
+
readonly kind: 'local';
|
|
46
|
+
/** Called before each case. */
|
|
47
|
+
create(): Promise<EmailSender>;
|
|
48
|
+
/** What the sender created by the last `create` stored, oldest first. */
|
|
49
|
+
stored(): Promise<readonly WireMessage[]>;
|
|
50
|
+
/** Called after each case. */
|
|
51
|
+
cleanup?(): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
export type EmailSenderConformanceHarness = EmailSenderHttpHarness | EmailSenderLocalHarness;
|
|
54
|
+
/**
|
|
55
|
+
* Registers the conformance cases for one sender. HTTP harnesses also get the provider answer table, the thrown and
|
|
56
|
+
* silent fetch, and an abort or a timeout while the request or the answer body is in flight; a local harness gets
|
|
57
|
+
* only the already-aborted case, because nothing can hold its I/O open.
|
|
58
|
+
*/
|
|
59
|
+
export declare function describeEmailSenderConformance(name: string, harness: EmailSenderConformanceHarness): void;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { EmailMessage } from './email.js';
|
|
2
|
+
/** An attachment as a local sender keeps and compares it: the bytes as base64. */
|
|
3
|
+
export interface EmailAttachmentContent {
|
|
4
|
+
readonly filename: string;
|
|
5
|
+
readonly contentType: string;
|
|
6
|
+
readonly content: string;
|
|
7
|
+
}
|
|
8
|
+
/** What a local sender compares when one idempotency key arrives twice: everything the recipient would see. */
|
|
9
|
+
export interface EmailContent {
|
|
10
|
+
readonly to: string;
|
|
11
|
+
readonly subject: string;
|
|
12
|
+
readonly text: string;
|
|
13
|
+
readonly html?: string;
|
|
14
|
+
readonly replyTo?: string;
|
|
15
|
+
readonly fromName?: string;
|
|
16
|
+
readonly unsubscribeUrl?: string;
|
|
17
|
+
readonly broadcast?: true;
|
|
18
|
+
readonly attachments?: readonly EmailAttachmentContent[];
|
|
19
|
+
}
|
|
20
|
+
export declare function sameEmailContent(left: EmailContent, right: EmailContent): boolean;
|
|
21
|
+
export declare function emailContentOf(message: EmailMessage): EmailContent;
|
|
22
|
+
/** `bytes` as standard base64, on every runtime: `btoa` over chunks, so a large file never overflows the call stack. */
|
|
23
|
+
export declare function base64Of(bytes: Uint8Array): string;
|