@fabrika/email 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 +21 -1
- package/src/config.ts +166 -0
- package/src/conformance.ts +414 -0
- package/src/content.ts +27 -0
- package/src/directory.ts +36 -15
- package/src/email.ts +50 -13
- package/src/http.ts +72 -0
- package/src/index.ts +28 -3
- package/src/memory.ts +174 -0
- package/src/providers.ts +9 -0
- package/src/resend.ts +80 -87
- package/src/smartemailing.ts +150 -0
- package/src/validation.ts +33 -8
package/src/resend.ts
CHANGED
|
@@ -1,133 +1,126 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { type EmailProvider, type EmailSenderIdentity, parseProviderVariables } from './config.js'
|
|
3
|
+
import {
|
|
4
|
+
EmailDeliveryError,
|
|
5
|
+
type EmailDeliveryResult,
|
|
6
|
+
type EmailFetch,
|
|
7
|
+
type EmailMessage,
|
|
8
|
+
type EmailSender,
|
|
9
|
+
type EmailSenderCapabilities,
|
|
10
|
+
} from './email.js'
|
|
11
|
+
import { parseTimeout, postToProvider, type ProviderResponse } from './http.js'
|
|
12
|
+
import { isValidEmailSenderIdentity, isVisibleAscii, validateEmailMessage } from './validation.js'
|
|
4
13
|
|
|
5
14
|
const RESEND_EMAILS_URL = 'https://api.resend.com/emails'
|
|
15
|
+
const LABEL = 'Resend'
|
|
6
16
|
const resendSuccessSchema = z.object({ id: z.string().min(1) })
|
|
7
17
|
const resendErrorSchema = z.object({ name: z.string() })
|
|
8
|
-
|
|
9
|
-
|
|
18
|
+
const QUOTA_ERRORS: ReadonlySet<string> = new Set(['daily_quota_exceeded', 'monthly_quota_exceeded'])
|
|
19
|
+
/** RFC 5322 specials a display name may still hold after validation; any of them makes the name a quoted string. */
|
|
20
|
+
const DISPLAY_NAME_SPECIALS = /[()[\]:;@,.]/
|
|
10
21
|
|
|
11
22
|
export interface ResendEmailSenderOptions {
|
|
12
23
|
readonly apiKey: string
|
|
13
|
-
readonly from:
|
|
24
|
+
readonly from: EmailSenderIdentity
|
|
14
25
|
readonly fetch?: EmailFetch
|
|
15
26
|
readonly timeoutMs?: number
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
export class ResendEmailSender implements EmailSender {
|
|
30
|
+
readonly capabilities: EmailSenderCapabilities = { idempotent: true, confirmation: 'sync', maxBatch: 1 }
|
|
19
31
|
readonly #apiKey: string
|
|
20
|
-
readonly #from:
|
|
32
|
+
readonly #from: EmailSenderIdentity
|
|
21
33
|
readonly #fetch: EmailFetch
|
|
22
34
|
readonly #timeoutMs: number
|
|
23
35
|
|
|
24
36
|
constructor(options: ResendEmailSenderOptions) {
|
|
25
37
|
if (!validApiKey(options.apiKey)) {
|
|
26
|
-
throw new EmailDeliveryError('Resend API key is invalid', { code: 'invalid_configuration'
|
|
27
|
-
}
|
|
28
|
-
if (!isValidEmailFrom(options.from)) {
|
|
29
|
-
throw new EmailDeliveryError('Resend sender is invalid', { code: 'invalid_configuration', retryable: false })
|
|
38
|
+
throw new EmailDeliveryError('Resend API key is invalid', { code: 'invalid_configuration' })
|
|
30
39
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
throw new EmailDeliveryError('Resend timeout is invalid', { code: 'invalid_configuration', retryable: false })
|
|
40
|
+
if (!isValidEmailSenderIdentity(options.from)) {
|
|
41
|
+
throw new EmailDeliveryError('Resend sender is invalid', { code: 'invalid_configuration' })
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
this.#apiKey = options.apiKey
|
|
37
45
|
this.#from = options.from
|
|
38
46
|
this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init))
|
|
39
|
-
this.#timeoutMs = timeoutMs
|
|
47
|
+
this.#timeoutMs = parseTimeout(LABEL, options.timeoutMs)
|
|
40
48
|
}
|
|
41
49
|
|
|
42
|
-
async send(message: EmailMessage): Promise<EmailDeliveryResult> {
|
|
50
|
+
async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
|
|
43
51
|
validateEmailMessage(message)
|
|
44
52
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
...(message.html === undefined ? {} : { html: message.html }),
|
|
62
|
-
}),
|
|
63
|
-
})
|
|
64
|
-
} catch {
|
|
65
|
-
if (signal.aborted) {
|
|
66
|
-
throw new EmailDeliveryError('Resend request timed out', { code: 'request_timeout', retryable: true })
|
|
67
|
-
}
|
|
68
|
-
throw new EmailDeliveryError('Resend request failed', { code: 'network_error', retryable: true })
|
|
69
|
-
}
|
|
53
|
+
const response = await postToProvider({
|
|
54
|
+
label: LABEL,
|
|
55
|
+
fetch: this.#fetch,
|
|
56
|
+
url: RESEND_EMAILS_URL,
|
|
57
|
+
headers: { Authorization: `Bearer ${this.#apiKey}`, 'Idempotency-Key': message.idempotencyKey },
|
|
58
|
+
body: {
|
|
59
|
+
from: formatMailbox(this.#from.address, message.fromName ?? this.#from.name),
|
|
60
|
+
to: message.to,
|
|
61
|
+
subject: message.subject,
|
|
62
|
+
text: message.text,
|
|
63
|
+
...(message.html === undefined ? {} : { html: message.html }),
|
|
64
|
+
...(message.replyTo === undefined ? {} : { reply_to: message.replyTo }),
|
|
65
|
+
},
|
|
66
|
+
signal,
|
|
67
|
+
timeoutMs: this.#timeoutMs,
|
|
68
|
+
})
|
|
70
69
|
|
|
71
|
-
if (!response.ok) throw
|
|
70
|
+
if (!response.ok) throw responseError(response)
|
|
72
71
|
|
|
73
|
-
const
|
|
74
|
-
if (
|
|
75
|
-
throw new EmailDeliveryError('Resend returned an invalid success response', {
|
|
76
|
-
code: 'malformed_response',
|
|
77
|
-
retryable: true,
|
|
78
|
-
httpStatus: response.status,
|
|
79
|
-
})
|
|
72
|
+
const parsed = resendSuccessSchema.safeParse(response.body)
|
|
73
|
+
if (!parsed.success) {
|
|
74
|
+
throw new EmailDeliveryError('Resend returned an invalid success response', { code: 'malformed_response', httpStatus: response.status })
|
|
80
75
|
}
|
|
81
76
|
|
|
82
|
-
return { status: 'accepted', provider: 'resend', messageId }
|
|
77
|
+
return { status: 'accepted', provider: 'resend', messageId: parsed.data.id }
|
|
83
78
|
}
|
|
84
79
|
}
|
|
85
80
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
81
|
+
const resendVariablesSchema = z.object({ FABRIKA_EMAIL_RESEND_API_KEY: z.string().refine(validApiKey) })
|
|
82
|
+
|
|
83
|
+
export const resendEmailProvider: EmailProvider = {
|
|
84
|
+
variables: [{ name: 'FABRIKA_EMAIL_RESEND_API_KEY', secret: true, required: true }],
|
|
85
|
+
create({ from, env, fetch }) {
|
|
86
|
+
const variables = parseProviderVariables(resendVariablesSchema, env)
|
|
87
|
+
return new ResendEmailSender({ apiKey: variables.FABRIKA_EMAIL_RESEND_API_KEY, from, fetch })
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function responseError(response: ProviderResponse): EmailDeliveryError {
|
|
92
|
+
const status = response.status
|
|
93
|
+
const parsed = resendErrorSchema.safeParse(response.body)
|
|
94
|
+
const name = parsed.success ? parsed.data.name : undefined
|
|
95
|
+
if (status === 401 || status === 403) {
|
|
96
|
+
return new EmailDeliveryError(`Resend refused the credentials or the sender (${status})`, { code: 'provider_unauthorized', httpStatus: status })
|
|
90
97
|
}
|
|
91
|
-
if (
|
|
92
|
-
return new EmailDeliveryError('Resend is
|
|
98
|
+
if (status === 409 && name === 'concurrent_idempotent_requests') {
|
|
99
|
+
return new EmailDeliveryError('A matching Resend request is still in progress', { code: 'request_in_progress', httpStatus: status })
|
|
93
100
|
}
|
|
94
|
-
if (
|
|
95
|
-
return new EmailDeliveryError('
|
|
96
|
-
code: '
|
|
97
|
-
|
|
98
|
-
httpStatus: response.status,
|
|
101
|
+
if (status === 409 && name === 'invalid_idempotent_request') {
|
|
102
|
+
return new EmailDeliveryError('Resend already accepted a different message under this idempotency key', {
|
|
103
|
+
code: 'idempotency_conflict',
|
|
104
|
+
httpStatus: status,
|
|
99
105
|
})
|
|
100
106
|
}
|
|
101
|
-
|
|
102
|
-
code: '
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async function readProviderError(response: Response): Promise<string | undefined> {
|
|
114
|
-
const parsed = resendErrorSchema.safeParse(await readJson(response))
|
|
115
|
-
return parsed.success ? parsed.data.name : undefined
|
|
107
|
+
if (status === 429 && name !== undefined && QUOTA_ERRORS.has(name)) {
|
|
108
|
+
return new EmailDeliveryError('Resend sending quota exceeded', { code: 'quota_exceeded', httpStatus: status })
|
|
109
|
+
}
|
|
110
|
+
if (status === 429) {
|
|
111
|
+
return new EmailDeliveryError('Resend rate limit exceeded', { code: 'rate_limited', httpStatus: status })
|
|
112
|
+
}
|
|
113
|
+
if (status >= 500) {
|
|
114
|
+
return new EmailDeliveryError('Resend is unavailable', { code: 'provider_unavailable', httpStatus: status })
|
|
115
|
+
}
|
|
116
|
+
return new EmailDeliveryError(`Resend rejected the request (${status})`, { code: 'provider_rejected', httpStatus: status })
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
} catch {
|
|
122
|
-
return undefined
|
|
123
|
-
}
|
|
119
|
+
function formatMailbox(address: string, name: string | undefined): string {
|
|
120
|
+
if (name === undefined) return address
|
|
121
|
+
return DISPLAY_NAME_SPECIALS.test(name) ? `"${name}" <${address}>` : `${name} <${address}>`
|
|
124
122
|
}
|
|
125
123
|
|
|
126
124
|
function validApiKey(value: string): boolean {
|
|
127
|
-
|
|
128
|
-
for (const character of value) {
|
|
129
|
-
const code = character.charCodeAt(0)
|
|
130
|
-
if (code < 33 || code > 126) return false
|
|
131
|
-
}
|
|
132
|
-
return true
|
|
125
|
+
return value.length > 0 && isVisibleAscii(value)
|
|
133
126
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// SmartEmailing's transactional API v3 (https://app.smartemailing.cz/docs/api/v3/), built from its OpenAPI document and
|
|
2
|
+
// verified only against a stubbed fetch. It has no idempotency key, and a 201 only queues the request.
|
|
3
|
+
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { type EmailProvider, type EmailSenderIdentity, parseProviderVariables } from './config.js'
|
|
6
|
+
import {
|
|
7
|
+
EmailDeliveryError,
|
|
8
|
+
type EmailDeliveryResult,
|
|
9
|
+
type EmailFetch,
|
|
10
|
+
type EmailMessage,
|
|
11
|
+
type EmailSender,
|
|
12
|
+
type EmailSenderCapabilities,
|
|
13
|
+
} from './email.js'
|
|
14
|
+
import { parseTimeout, postToProvider } from './http.js'
|
|
15
|
+
import { isValidEmailSenderIdentity, isVisibleAscii, validateEmailMessage } from './validation.js'
|
|
16
|
+
|
|
17
|
+
const SMARTEMAILING_SEND_URL = 'https://app.smartemailing.cz/api/v3/send/transactional-emails-bulk'
|
|
18
|
+
const LABEL = 'SmartEmailing'
|
|
19
|
+
const DEFAULT_TAG = 'fabrika'
|
|
20
|
+
const TAG_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
|
|
21
|
+
const smartEmailingSuccessSchema = z.object({ data: z.tuple([z.object({ id: z.string().min(1) })]) })
|
|
22
|
+
|
|
23
|
+
export interface SmartEmailingEmailSenderOptions {
|
|
24
|
+
/** The account's user name or e-mail: the first half of the Basic credential. */
|
|
25
|
+
readonly username: string
|
|
26
|
+
readonly apiKey: string
|
|
27
|
+
readonly from: EmailSenderIdentity
|
|
28
|
+
/** Groups the messages in SmartEmailing's statistics. */
|
|
29
|
+
readonly tag?: string
|
|
30
|
+
readonly fetch?: EmailFetch
|
|
31
|
+
readonly timeoutMs?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class SmartEmailingEmailSender implements EmailSender {
|
|
35
|
+
/** The API takes up to five tasks a request, but `send` carries one. */
|
|
36
|
+
readonly capabilities: EmailSenderCapabilities = { idempotent: false, confirmation: 'queued', maxBatch: 1 }
|
|
37
|
+
readonly #authorization: string
|
|
38
|
+
readonly #from: EmailSenderIdentity
|
|
39
|
+
readonly #tag: string
|
|
40
|
+
readonly #fetch: EmailFetch
|
|
41
|
+
readonly #timeoutMs: number
|
|
42
|
+
|
|
43
|
+
constructor(options: SmartEmailingEmailSenderOptions) {
|
|
44
|
+
if (!validUsername(options.username)) {
|
|
45
|
+
throw new EmailDeliveryError('SmartEmailing username is invalid', { code: 'invalid_configuration' })
|
|
46
|
+
}
|
|
47
|
+
if (!validApiKey(options.apiKey)) {
|
|
48
|
+
throw new EmailDeliveryError('SmartEmailing API key is invalid', { code: 'invalid_configuration' })
|
|
49
|
+
}
|
|
50
|
+
if (!isValidEmailSenderIdentity(options.from)) {
|
|
51
|
+
throw new EmailDeliveryError('SmartEmailing sender is invalid', { code: 'invalid_configuration' })
|
|
52
|
+
}
|
|
53
|
+
const tag = options.tag ?? DEFAULT_TAG
|
|
54
|
+
if (!TAG_PATTERN.test(tag)) {
|
|
55
|
+
throw new EmailDeliveryError('SmartEmailing tag is invalid', { code: 'invalid_configuration' })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.#authorization = `Basic ${btoa(`${options.username}:${options.apiKey}`)}`
|
|
59
|
+
this.#from = options.from
|
|
60
|
+
this.#tag = tag
|
|
61
|
+
this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init))
|
|
62
|
+
this.#timeoutMs = parseTimeout(LABEL, options.timeoutMs)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
|
|
66
|
+
validateEmailMessage(message)
|
|
67
|
+
|
|
68
|
+
const senderName = message.fromName ?? this.#from.name
|
|
69
|
+
const response = await postToProvider({
|
|
70
|
+
label: LABEL,
|
|
71
|
+
fetch: this.#fetch,
|
|
72
|
+
url: SMARTEMAILING_SEND_URL,
|
|
73
|
+
headers: { Authorization: this.#authorization },
|
|
74
|
+
body: {
|
|
75
|
+
sender_credentials: {
|
|
76
|
+
from: this.#from.address,
|
|
77
|
+
...(senderName === undefined ? {} : { sender_name: senderName }),
|
|
78
|
+
...(message.replyTo === undefined ? {} : { reply_to: message.replyTo }),
|
|
79
|
+
},
|
|
80
|
+
tag: this.#tag,
|
|
81
|
+
// Unverified: the API documents no rule for a message without `html_body`.
|
|
82
|
+
message_contents: {
|
|
83
|
+
subject: message.subject,
|
|
84
|
+
text_body: message.text,
|
|
85
|
+
...(message.html === undefined ? {} : { html_body: message.html }),
|
|
86
|
+
},
|
|
87
|
+
tasks: [{ recipient: { emailaddress: message.to } }],
|
|
88
|
+
},
|
|
89
|
+
signal,
|
|
90
|
+
timeoutMs: this.#timeoutMs,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// The error body's `message` is free text that may quote the request, so only the status is classified.
|
|
94
|
+
if (!response.ok) throw responseError(response.status)
|
|
95
|
+
|
|
96
|
+
const parsed = smartEmailingSuccessSchema.safeParse(response.body)
|
|
97
|
+
if (!parsed.success) {
|
|
98
|
+
throw new EmailDeliveryError('SmartEmailing returned an invalid success response', { code: 'malformed_response', httpStatus: response.status })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { status: 'accepted', provider: 'smartemailing', messageId: parsed.data.data[0].id }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const smartEmailingVariablesSchema = z.object({
|
|
106
|
+
FABRIKA_EMAIL_SMARTEMAILING_USERNAME: z.string().refine(validUsername),
|
|
107
|
+
FABRIKA_EMAIL_SMARTEMAILING_API_KEY: z.string().refine(validApiKey),
|
|
108
|
+
FABRIKA_EMAIL_SMARTEMAILING_TAG: z.string().optional().transform((value) => (value === '' ? undefined : value)).refine((value) =>
|
|
109
|
+
value === undefined || TAG_PATTERN.test(value)
|
|
110
|
+
),
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
export const smartEmailingEmailProvider: EmailProvider = {
|
|
114
|
+
variables: [
|
|
115
|
+
{ name: 'FABRIKA_EMAIL_SMARTEMAILING_USERNAME', secret: true, required: true },
|
|
116
|
+
{ name: 'FABRIKA_EMAIL_SMARTEMAILING_API_KEY', secret: true, required: true },
|
|
117
|
+
{ name: 'FABRIKA_EMAIL_SMARTEMAILING_TAG', secret: false, required: false },
|
|
118
|
+
],
|
|
119
|
+
create({ from, env, fetch }) {
|
|
120
|
+
const variables = parseProviderVariables(smartEmailingVariablesSchema, env)
|
|
121
|
+
return new SmartEmailingEmailSender({
|
|
122
|
+
username: variables.FABRIKA_EMAIL_SMARTEMAILING_USERNAME,
|
|
123
|
+
apiKey: variables.FABRIKA_EMAIL_SMARTEMAILING_API_KEY,
|
|
124
|
+
from,
|
|
125
|
+
tag: variables.FABRIKA_EMAIL_SMARTEMAILING_TAG,
|
|
126
|
+
fetch,
|
|
127
|
+
})
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function responseError(status: number): EmailDeliveryError {
|
|
132
|
+
if (status === 401 || status === 403) {
|
|
133
|
+
return new EmailDeliveryError(`SmartEmailing refused the credentials (${status})`, { code: 'provider_unauthorized', httpStatus: status })
|
|
134
|
+
}
|
|
135
|
+
if (status === 429) {
|
|
136
|
+
return new EmailDeliveryError('SmartEmailing rate limit exceeded', { code: 'rate_limited', httpStatus: status })
|
|
137
|
+
}
|
|
138
|
+
if (status >= 500) {
|
|
139
|
+
return new EmailDeliveryError('SmartEmailing is unavailable', { code: 'provider_unavailable', httpStatus: status })
|
|
140
|
+
}
|
|
141
|
+
return new EmailDeliveryError(`SmartEmailing rejected the request (${status})`, { code: 'provider_rejected', httpStatus: status })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validUsername(value: string): boolean {
|
|
145
|
+
return validApiKey(value) && !value.includes(':')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function validApiKey(value: string): boolean {
|
|
149
|
+
return value.length > 0 && isVisibleAscii(value)
|
|
150
|
+
}
|
package/src/validation.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EmailSenderIdentity } from './config.js'
|
|
1
2
|
import { EmailDeliveryError, type EmailMessage } from './email.js'
|
|
2
3
|
|
|
3
4
|
/** The longest subject a message may have, in UTF-16 code units: RFC 5322's line limit. */
|
|
@@ -20,17 +21,35 @@ export function isValidEmailAddress(value: string): boolean {
|
|
|
20
21
|
return domain.split('.').every((label) => label.length <= 63 && DOMAIN_LABEL.test(label))
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/** A mailbox address, or the legacy `Name <address>` form. */
|
|
23
25
|
export function isValidEmailFrom(value: string): boolean {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
return parseEmailFrom(value) !== null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Splits a bare address or the legacy `Name <address>` form. A name in one pair of double quotes loses them, since a
|
|
31
|
+
* transport quotes the name itself when it has to.
|
|
32
|
+
*/
|
|
33
|
+
export function parseEmailFrom(value: string): EmailSenderIdentity | null {
|
|
34
|
+
if (isValidEmailAddress(value)) return { address: value }
|
|
35
|
+
if (value.length === 0 || value.length > 384 || value.trim() !== value || containsControlCharacter(value) || !value.endsWith('>')) return null
|
|
26
36
|
|
|
27
37
|
const openingBracket = value.lastIndexOf('<')
|
|
28
|
-
if (openingBracket <= 0 || value.indexOf('<') !== openingBracket || value.indexOf('>') !== value.length - 1) return
|
|
38
|
+
if (openingBracket <= 0 || value.indexOf('<') !== openingBracket || value.indexOf('>') !== value.length - 1) return null
|
|
29
39
|
|
|
30
|
-
const displayName = value.slice(0, openingBracket).trim()
|
|
40
|
+
const displayName = unquoted(value.slice(0, openingBracket).trim())
|
|
31
41
|
const address = value.slice(openingBracket + 1, -1)
|
|
32
|
-
|
|
33
|
-
|
|
42
|
+
if (!isValidEmailDisplayName(displayName) || !isValidEmailAddress(address)) return null
|
|
43
|
+
return { address, name: displayName }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 1..128 characters, no control characters and none of `<>"\`, so a transport can always quote it. */
|
|
47
|
+
export function isValidEmailDisplayName(value: string): boolean {
|
|
48
|
+
return value.length > 0 && value.length <= 128 && !containsControlCharacter(value) && !/[<>"\\]/.test(value)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isValidEmailSenderIdentity(identity: EmailSenderIdentity): boolean {
|
|
52
|
+
return isValidEmailAddress(identity.address) && (identity.name === undefined || isValidEmailDisplayName(identity.name))
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
export function validateEmailMessage(message: EmailMessage): void {
|
|
@@ -42,10 +61,12 @@ export function validateEmailMessage(message: EmailMessage): void {
|
|
|
42
61
|
if (message.idempotencyKey.length === 0 || message.idempotencyKey.length > 256 || !isVisibleAscii(message.idempotencyKey)) {
|
|
43
62
|
throw invalidMessage('Email idempotency key is invalid')
|
|
44
63
|
}
|
|
64
|
+
if (message.replyTo !== undefined && !isValidEmailAddress(message.replyTo)) throw invalidMessage('Email reply-to address is invalid')
|
|
65
|
+
if (message.fromName !== undefined && !isValidEmailDisplayName(message.fromName)) throw invalidMessage('Email sender name is invalid')
|
|
45
66
|
}
|
|
46
67
|
|
|
47
68
|
function invalidMessage(message: string): EmailDeliveryError {
|
|
48
|
-
return new EmailDeliveryError(message, { code: 'invalid_message'
|
|
69
|
+
return new EmailDeliveryError(message, { code: 'invalid_message' })
|
|
49
70
|
}
|
|
50
71
|
|
|
51
72
|
function containsControlCharacter(value: string): boolean {
|
|
@@ -56,7 +77,11 @@ function containsControlCharacter(value: string): boolean {
|
|
|
56
77
|
return false
|
|
57
78
|
}
|
|
58
79
|
|
|
59
|
-
function
|
|
80
|
+
function unquoted(value: string): string {
|
|
81
|
+
return value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isVisibleAscii(value: string): boolean {
|
|
60
85
|
for (const character of value) {
|
|
61
86
|
const code = character.charCodeAt(0)
|
|
62
87
|
if (code < 33 || code > 126) return false
|