@fabrika/email 0.0.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 +33 -0
- package/src/email.ts +48 -0
- package/src/index.ts +3 -0
- package/src/resend.ts +138 -0
- package/src/validation.ts +62 -0
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fabrika/email",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/contember/fabrika-platform.git",
|
|
8
|
+
"directory": "packages/email"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"!src/**/__tests__/**"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"bun": "./src/index.ts",
|
|
20
|
+
"types": "./src/index.ts",
|
|
21
|
+
"default": "./src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"./resend": {
|
|
24
|
+
"bun": "./src/resend.ts",
|
|
25
|
+
"types": "./src/resend.ts",
|
|
26
|
+
"default": "./src/resend.ts"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "bun test src/__tests__"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/email.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface EmailMessage {
|
|
2
|
+
readonly to: string
|
|
3
|
+
readonly subject: string
|
|
4
|
+
readonly text: string
|
|
5
|
+
readonly html?: string
|
|
6
|
+
readonly idempotencyKey: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface EmailDeliveryResult {
|
|
10
|
+
readonly status: 'accepted'
|
|
11
|
+
readonly provider: string
|
|
12
|
+
readonly messageId: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface EmailSender {
|
|
16
|
+
send(message: EmailMessage): Promise<EmailDeliveryResult>
|
|
17
|
+
}
|
|
18
|
+
|
|
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'
|
|
29
|
+
|
|
30
|
+
export interface EmailDeliveryErrorOptions {
|
|
31
|
+
readonly code: EmailDeliveryErrorCode
|
|
32
|
+
readonly retryable: boolean
|
|
33
|
+
readonly httpStatus?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class EmailDeliveryError extends Error {
|
|
37
|
+
readonly code: EmailDeliveryErrorCode
|
|
38
|
+
readonly retryable: boolean
|
|
39
|
+
readonly httpStatus: number | undefined
|
|
40
|
+
|
|
41
|
+
constructor(message: string, options: EmailDeliveryErrorOptions) {
|
|
42
|
+
super(message)
|
|
43
|
+
this.name = 'EmailDeliveryError'
|
|
44
|
+
this.code = options.code
|
|
45
|
+
this.retryable = options.retryable
|
|
46
|
+
this.httpStatus = options.httpStatus
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { EmailDeliveryError } from './email.js'
|
|
2
|
+
export type { EmailDeliveryErrorCode, EmailDeliveryErrorOptions, EmailDeliveryResult, EmailMessage, EmailSender } from './email.js'
|
|
3
|
+
export { isValidEmailAddress, isValidEmailFrom, validateEmailMessage } from './validation.js'
|
package/src/resend.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { EmailDeliveryError, type EmailDeliveryResult, type EmailMessage, type EmailSender } from './email.js'
|
|
2
|
+
import { isValidEmailFrom, validateEmailMessage } from './validation.js'
|
|
3
|
+
|
|
4
|
+
const RESEND_EMAILS_URL = 'https://api.resend.com/emails'
|
|
5
|
+
|
|
6
|
+
export type EmailFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
7
|
+
|
|
8
|
+
export interface ResendEmailSenderOptions {
|
|
9
|
+
readonly apiKey: string
|
|
10
|
+
readonly from: string
|
|
11
|
+
readonly fetch?: EmailFetch
|
|
12
|
+
readonly timeoutMs?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ResendEmailSender implements EmailSender {
|
|
16
|
+
readonly #apiKey: string
|
|
17
|
+
readonly #from: string
|
|
18
|
+
readonly #fetch: EmailFetch
|
|
19
|
+
readonly #timeoutMs: number
|
|
20
|
+
|
|
21
|
+
constructor(options: ResendEmailSenderOptions) {
|
|
22
|
+
if (!validApiKey(options.apiKey)) {
|
|
23
|
+
throw new EmailDeliveryError('Resend API key is invalid', { code: 'invalid_configuration', retryable: false })
|
|
24
|
+
}
|
|
25
|
+
if (!isValidEmailFrom(options.from)) {
|
|
26
|
+
throw new EmailDeliveryError('Resend sender is invalid', { code: 'invalid_configuration', retryable: false })
|
|
27
|
+
}
|
|
28
|
+
const timeoutMs = options.timeoutMs ?? 10_000
|
|
29
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 120_000) {
|
|
30
|
+
throw new EmailDeliveryError('Resend timeout is invalid', { code: 'invalid_configuration', retryable: false })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
this.#apiKey = options.apiKey
|
|
34
|
+
this.#from = options.from
|
|
35
|
+
this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init))
|
|
36
|
+
this.#timeoutMs = timeoutMs
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async send(message: EmailMessage): Promise<EmailDeliveryResult> {
|
|
40
|
+
validateEmailMessage(message)
|
|
41
|
+
|
|
42
|
+
let response: Response
|
|
43
|
+
const signal = AbortSignal.timeout(this.#timeoutMs)
|
|
44
|
+
try {
|
|
45
|
+
response = await this.#fetch(RESEND_EMAILS_URL, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: {
|
|
48
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
49
|
+
'Content-Type': 'application/json',
|
|
50
|
+
'Idempotency-Key': message.idempotencyKey,
|
|
51
|
+
},
|
|
52
|
+
signal,
|
|
53
|
+
body: JSON.stringify({
|
|
54
|
+
from: this.#from,
|
|
55
|
+
to: message.to,
|
|
56
|
+
subject: message.subject,
|
|
57
|
+
text: message.text,
|
|
58
|
+
...(message.html === undefined ? {} : { html: message.html }),
|
|
59
|
+
}),
|
|
60
|
+
})
|
|
61
|
+
} catch {
|
|
62
|
+
if (signal.aborted) {
|
|
63
|
+
throw new EmailDeliveryError('Resend request timed out', { code: 'request_timeout', retryable: true })
|
|
64
|
+
}
|
|
65
|
+
throw new EmailDeliveryError('Resend request failed', { code: 'network_error', retryable: true })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!response.ok) throw await responseError(response)
|
|
69
|
+
|
|
70
|
+
const messageId = await readMessageId(response)
|
|
71
|
+
if (messageId === undefined) {
|
|
72
|
+
throw new EmailDeliveryError('Resend returned an invalid success response', {
|
|
73
|
+
code: 'malformed_response',
|
|
74
|
+
retryable: true,
|
|
75
|
+
httpStatus: response.status,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { status: 'accepted', provider: 'resend', messageId }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function responseError(response: Response): Promise<EmailDeliveryError> {
|
|
84
|
+
const providerError = await readProviderError(response)
|
|
85
|
+
if (response.status === 429) {
|
|
86
|
+
return new EmailDeliveryError('Resend rate limit exceeded', { code: 'rate_limited', retryable: true, httpStatus: response.status })
|
|
87
|
+
}
|
|
88
|
+
if (response.status >= 500) {
|
|
89
|
+
return new EmailDeliveryError('Resend is unavailable', { code: 'provider_unavailable', retryable: true, httpStatus: response.status })
|
|
90
|
+
}
|
|
91
|
+
if (response.status === 409 && providerError === 'concurrent_idempotent_requests') {
|
|
92
|
+
return new EmailDeliveryError('A matching Resend request is still in progress', {
|
|
93
|
+
code: 'request_in_progress',
|
|
94
|
+
retryable: true,
|
|
95
|
+
httpStatus: response.status,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
return new EmailDeliveryError(`Resend rejected the request (${response.status})`, {
|
|
99
|
+
code: 'provider_rejected',
|
|
100
|
+
retryable: false,
|
|
101
|
+
httpStatus: response.status,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function readMessageId(response: Response): Promise<string | undefined> {
|
|
106
|
+
const body = await readJson(response)
|
|
107
|
+
if (!isRecord(body)) return undefined
|
|
108
|
+
const id: unknown = Reflect.get(body, 'id')
|
|
109
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function readProviderError(response: Response): Promise<string | undefined> {
|
|
113
|
+
const body = await readJson(response)
|
|
114
|
+
if (!isRecord(body)) return undefined
|
|
115
|
+
const name: unknown = Reflect.get(body, 'name')
|
|
116
|
+
return typeof name === 'string' ? name : undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function readJson(response: Response): Promise<unknown> {
|
|
120
|
+
try {
|
|
121
|
+
return await response.json()
|
|
122
|
+
} catch {
|
|
123
|
+
return undefined
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
128
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function validApiKey(value: string): boolean {
|
|
132
|
+
if (value.length === 0 || value.trim() !== value) return false
|
|
133
|
+
for (const character of value) {
|
|
134
|
+
const code = character.charCodeAt(0)
|
|
135
|
+
if (code < 33 || code > 126) return false
|
|
136
|
+
}
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { EmailDeliveryError, type EmailMessage } from './email.js'
|
|
2
|
+
|
|
3
|
+
const DOT_ATOM_CHARACTER = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+$/
|
|
4
|
+
const DOMAIN_LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/
|
|
5
|
+
|
|
6
|
+
export function isValidEmailAddress(value: string): boolean {
|
|
7
|
+
if (value.length === 0 || value.length > 254 || value.trim() !== value || containsControlCharacter(value)) return false
|
|
8
|
+
|
|
9
|
+
const separator = value.indexOf('@')
|
|
10
|
+
if (separator <= 0 || separator !== value.lastIndexOf('@')) return false
|
|
11
|
+
|
|
12
|
+
const local = value.slice(0, separator)
|
|
13
|
+
const domain = value.slice(separator + 1)
|
|
14
|
+
if (local.length > 64 || domain.length === 0 || domain.length > 253 || !domain.includes('.')) return false
|
|
15
|
+
if (!local.split('.').every((part) => part.length > 0 && DOT_ATOM_CHARACTER.test(part))) return false
|
|
16
|
+
|
|
17
|
+
return domain.split('.').every((label) => label.length <= 63 && DOMAIN_LABEL.test(label))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isValidEmailFrom(value: string): boolean {
|
|
21
|
+
if (isValidEmailAddress(value)) return true
|
|
22
|
+
if (value.length === 0 || value.length > 384 || value.trim() !== value || containsControlCharacter(value) || !value.endsWith('>')) return false
|
|
23
|
+
|
|
24
|
+
const openingBracket = value.lastIndexOf('<')
|
|
25
|
+
if (openingBracket <= 0 || value.indexOf('<') !== openingBracket || value.indexOf('>') !== value.length - 1) return false
|
|
26
|
+
|
|
27
|
+
const displayName = value.slice(0, openingBracket).trim()
|
|
28
|
+
const address = value.slice(openingBracket + 1, -1)
|
|
29
|
+
return displayName.length > 0 && displayName.length <= 128 && !displayName.includes('<') && !displayName.includes('>')
|
|
30
|
+
&& isValidEmailAddress(address)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validateEmailMessage(message: EmailMessage): void {
|
|
34
|
+
if (!isValidEmailAddress(message.to)) throw invalidMessage('Email recipient is invalid')
|
|
35
|
+
if (message.subject.length === 0 || message.subject.length > 998 || containsControlCharacter(message.subject)) {
|
|
36
|
+
throw invalidMessage('Email subject is invalid')
|
|
37
|
+
}
|
|
38
|
+
if (message.text.trim().length === 0) throw invalidMessage('Email text is empty')
|
|
39
|
+
if (message.idempotencyKey.length === 0 || message.idempotencyKey.length > 256 || !isVisibleAscii(message.idempotencyKey)) {
|
|
40
|
+
throw invalidMessage('Email idempotency key is invalid')
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function invalidMessage(message: string): EmailDeliveryError {
|
|
45
|
+
return new EmailDeliveryError(message, { code: 'invalid_message', retryable: false })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function containsControlCharacter(value: string): boolean {
|
|
49
|
+
for (const character of value) {
|
|
50
|
+
const code = character.charCodeAt(0)
|
|
51
|
+
if (code < 32 || code === 127) return true
|
|
52
|
+
}
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isVisibleAscii(value: string): boolean {
|
|
57
|
+
for (const character of value) {
|
|
58
|
+
const code = character.charCodeAt(0)
|
|
59
|
+
if (code < 33 || code > 126) return false
|
|
60
|
+
}
|
|
61
|
+
return true
|
|
62
|
+
}
|