@fabrika/email 0.8.0 → 0.8.2
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 +16 -1
- package/src/ahasend.ts +172 -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 +30 -2
- package/src/resend.ts +48 -85
- package/src/smartemailing.ts +30 -74
- package/src/validation.ts +31 -3
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. */
|