@coderize/email 0.0.0-stage → 0.1.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/CHANGELOG.md +13 -0
- package/README.md +344 -2
- package/encode.js +80 -0
- package/escape.js +15 -0
- package/index.d.ts +95 -0
- package/index.js +4 -0
- package/mailer.js +154 -0
- package/message.js +245 -0
- package/mime.js +106 -0
- package/package.json +59 -3
- package/sendError.js +51 -0
- package/sns.d.ts +46 -0
- package/sns.js +168 -0
package/mailer.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2'
|
|
2
|
+
|
|
3
|
+
import { normalizeMessage } from './message.js'
|
|
4
|
+
import { renderRaw } from './mime.js'
|
|
5
|
+
import { isTransientAwsError, SendError } from './sendError.js'
|
|
6
|
+
|
|
7
|
+
const LOG_ONLY = 'EMAIL NOT SENT (log only: set EMAIL_PROVIDER=ses to send)'
|
|
8
|
+
const PREVIEW_CHARS = 800
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The SendEmail input for a normalised message: Simple content, or Raw when there are attachments.
|
|
12
|
+
* @param {import('./message.js').NormalMessage} m
|
|
13
|
+
*/
|
|
14
|
+
export function sesInput(m) {
|
|
15
|
+
const input = {
|
|
16
|
+
FromEmailAddress: m.from,
|
|
17
|
+
Destination: {
|
|
18
|
+
ToAddresses: m.to,
|
|
19
|
+
...(m.cc.length ? { CcAddresses: m.cc } : {}),
|
|
20
|
+
// The envelope is the only place Bcc goes, on both paths.
|
|
21
|
+
...(m.bcc.length ? { BccAddresses: m.bcc } : {})
|
|
22
|
+
},
|
|
23
|
+
...(m.configurationSet ? { ConfigurationSetName: m.configurationSet } : {})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (m.attachments.length) {
|
|
27
|
+
// Reply-To and the custom headers are inside the raw message.
|
|
28
|
+
return { ...input, Content: { Raw: { Data: renderRaw(m) } } }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
...input,
|
|
33
|
+
...(m.replyTo.length ? { ReplyToAddresses: m.replyTo } : {}),
|
|
34
|
+
Content: {
|
|
35
|
+
Simple: {
|
|
36
|
+
Subject: { Data: m.subject, Charset: 'UTF-8' },
|
|
37
|
+
Body: {
|
|
38
|
+
...(m.text ? { Text: { Data: m.text, Charset: 'UTF-8' } } : {}),
|
|
39
|
+
...(m.html ? { Html: { Data: m.html, Charset: 'UTF-8' } } : {})
|
|
40
|
+
},
|
|
41
|
+
...(m.headers.length ? { Headers: m.headers.map(([Name, Value]) => ({ Name, Value })) } : {})
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What an AWS error says, minus anything that names a recipient: SES's MessageRejected quotes addresses,
|
|
49
|
+
* while AccessDenied quotes the ARN that is missing from the IAM policy, which is the part worth keeping.
|
|
50
|
+
* @param {unknown} error
|
|
51
|
+
*/
|
|
52
|
+
function errorFacts(error) {
|
|
53
|
+
const e = /** @type {any} */ (error)
|
|
54
|
+
return {
|
|
55
|
+
error: e?.name,
|
|
56
|
+
statusCode: e?.$metadata?.httpStatusCode,
|
|
57
|
+
requestId: e?.$metadata?.requestId,
|
|
58
|
+
detail: String(e?.message ?? '')
|
|
59
|
+
.replace(/[^\s<>()[\]"',;:]+@[^\s<>()[\]"',;:]+/g, '[address]')
|
|
60
|
+
.slice(0, 300)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @param {import('./message.js').NormalMessage} m */
|
|
65
|
+
const recipientCount = (m) => m.to.length + m.cc.length + m.bcc.length
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A mailer for one app. SES only when `EMAIL_PROVIDER` is `ses`; anything else logs the message instead
|
|
69
|
+
* of sending it, at error level in production (where it means mail is not going out) and at info level
|
|
70
|
+
* with a body preview elsewhere.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} [options]
|
|
73
|
+
* @param {string} [options.from] the app's default From, e.g. `process.env.EMAIL_FROM ?? 'App <no-reply@app.com>'`
|
|
74
|
+
* @param {string} [options.configurationSet] SES configuration set to name on every send; none by default
|
|
75
|
+
* @param {{ send: (command: SendEmailCommand) => Promise<{ MessageId?: string }> }} [options.client] an SESv2Client, injected in tests
|
|
76
|
+
* @param {string} [options.region] defaults to `AWS_REGION`, then us-east-1
|
|
77
|
+
* @param {{ info: Function, error: Function }} [options.logger] pino (`app.log`) or console, the default
|
|
78
|
+
* @param {Record<string, string | undefined>} [options.env] defaults to `process.env`; read once, here
|
|
79
|
+
*/
|
|
80
|
+
export function createMailer({ from, configurationSet, client, region, logger = console, env = process.env } = {}) {
|
|
81
|
+
const enabled = env.EMAIL_PROVIDER === 'ses'
|
|
82
|
+
const production = env.NODE_ENV === 'production'
|
|
83
|
+
let ses = client
|
|
84
|
+
const sesClient = () => (ses ??= new SESv2Client({ region: region || env.AWS_REGION || 'us-east-1' }))
|
|
85
|
+
const defaults = { from, configurationSet }
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {import('./message.js').NormalMessage} m
|
|
89
|
+
*/
|
|
90
|
+
function logOnly(m) {
|
|
91
|
+
if (production) {
|
|
92
|
+
logger.error({ recipients: recipientCount(m), subject: m.subject }, LOG_ONLY)
|
|
93
|
+
} else {
|
|
94
|
+
logger.info(
|
|
95
|
+
{
|
|
96
|
+
to: m.to,
|
|
97
|
+
...(m.cc.length ? { cc: m.cc } : {}),
|
|
98
|
+
...(m.bcc.length ? { bcc: m.bcc } : {}),
|
|
99
|
+
subject: m.subject,
|
|
100
|
+
...(m.attachments.length ? { attachments: m.attachments.map((a) => a.filename) } : {}),
|
|
101
|
+
body: (m.text ?? m.html ?? '').slice(0, PREVIEW_CHARS)
|
|
102
|
+
},
|
|
103
|
+
LOG_ONLY
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
return { status: /** @type {const} */ ('logged'), id: null }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Sends one message. Resolves `{ status: 'sent', id }` (id is SES's MessageId), `{ status: 'logged',
|
|
111
|
+
* id: null }` when EMAIL_PROVIDER is not ses, or `{ status: 'skipped', id: null }` when `to` is empty.
|
|
112
|
+
* Throws a SendError when the message did not go out, after logging it.
|
|
113
|
+
* @param {object} message
|
|
114
|
+
*/
|
|
115
|
+
async function send(message) {
|
|
116
|
+
let m
|
|
117
|
+
try {
|
|
118
|
+
m = normalizeMessage(message, defaults)
|
|
119
|
+
} catch (err) {
|
|
120
|
+
logger.error({ kind: 'permanent', reason: /** @type {Error} */ (err).message }, 'email not sent')
|
|
121
|
+
throw err
|
|
122
|
+
}
|
|
123
|
+
if (!m) return { status: /** @type {const} */ ('skipped'), id: null }
|
|
124
|
+
if (!enabled) return logOnly(m)
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const result = await sesClient().send(new SendEmailCommand(sesInput(m)))
|
|
128
|
+
// Production logs carry no addresses (some apps show their logs in an admin viewer); dev keeps them.
|
|
129
|
+
const who = production ? { recipients: recipientCount(m) } : { to: m.to }
|
|
130
|
+
logger.info({ messageId: result.MessageId, ...who, subject: m.subject }, 'email sent')
|
|
131
|
+
return { status: /** @type {const} */ ('sent'), id: result.MessageId ?? null }
|
|
132
|
+
} catch (cause) {
|
|
133
|
+
const kind = isTransientAwsError(cause) ? 'transient' : 'permanent'
|
|
134
|
+
logger.error({ kind, ...errorFacts(cause), recipients: recipientCount(m), subject: m.subject }, 'email not sent')
|
|
135
|
+
throw new SendError('Email not sent', kind, { cause })
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `send()` that never throws: `{ ok: true, status, id, error: null }`, or `{ ok: false, status:
|
|
141
|
+
* 'failed', id: null, error }` with the SendError. The failure is already logged.
|
|
142
|
+
* @param {object} message
|
|
143
|
+
*/
|
|
144
|
+
async function trySend(message) {
|
|
145
|
+
try {
|
|
146
|
+
return { ok: true, ...(await send(message)), error: null }
|
|
147
|
+
} catch (err) {
|
|
148
|
+
const error = err instanceof SendError ? err : new SendError('Email not sent', 'permanent', { cause: err })
|
|
149
|
+
return { ok: false, status: /** @type {const} */ ('failed'), id: null, error }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { mode: enabled ? 'ses' : 'log', send, trySend }
|
|
154
|
+
}
|
package/message.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Turns what a caller passes to send() into one checked shape, or refuses it with a permanent SendError.
|
|
3
|
+
Both paths (Simple and Raw) and the log transport read only the result, so a message that would fail in
|
|
4
|
+
production fails the same way in development.
|
|
5
|
+
|
|
6
|
+
Refusal messages name the field, never its value: they end up in logs, and the values are addresses
|
|
7
|
+
and content.
|
|
8
|
+
*/
|
|
9
|
+
import { encodedWords, headerValue, NEEDS_ENCODING } from './encode.js'
|
|
10
|
+
import { SendError } from './sendError.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} NormalMessage
|
|
14
|
+
* @property {string} from
|
|
15
|
+
* @property {string[]} to
|
|
16
|
+
* @property {string[]} cc
|
|
17
|
+
* @property {string[]} bcc
|
|
18
|
+
* @property {string[]} replyTo
|
|
19
|
+
* @property {string} subject
|
|
20
|
+
* @property {string | undefined} text
|
|
21
|
+
* @property {string | undefined} html
|
|
22
|
+
* @property {Array<[string, string]>} headers
|
|
23
|
+
* @property {Array<{ filename: string, contentType: string, content: Buffer }>} attachments
|
|
24
|
+
* @property {string | undefined} configurationSet
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const invalid = (reason) => new SendError(`Email not sent: ${reason}`, 'permanent')
|
|
28
|
+
|
|
29
|
+
// What we write ourselves, on one path or the other. A custom header with one of these names would
|
|
30
|
+
// either be dropped by SES or give the message two of them.
|
|
31
|
+
const RESERVED_HEADERS = new Set([
|
|
32
|
+
'bcc',
|
|
33
|
+
'cc',
|
|
34
|
+
'content-disposition',
|
|
35
|
+
'content-transfer-encoding',
|
|
36
|
+
'content-type',
|
|
37
|
+
'from',
|
|
38
|
+
'mime-version',
|
|
39
|
+
'reply-to',
|
|
40
|
+
'subject',
|
|
41
|
+
'to'
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
// RFC 5322 field name: printable ASCII except the colon.
|
|
45
|
+
const HEADER_NAME = /^[!-9;-~]+$/
|
|
46
|
+
// The addr-spec only, ASCII: no spaces, no list separators, no brackets or quotes.
|
|
47
|
+
const ADDR_SPEC = /^[!#-'*+\-.-9=?A-Z^-~]+@[A-Za-z0-9.-]+$/
|
|
48
|
+
// A display name that needs no quoting: atext and spaces.
|
|
49
|
+
const PLAIN_NAME = /^[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~ ]+$/
|
|
50
|
+
const CONFIGURATION_SET = /^[A-Za-z0-9_-]{1,64}$/
|
|
51
|
+
const MEDIA_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/
|
|
52
|
+
|
|
53
|
+
const TYPES = {
|
|
54
|
+
csv: 'text/csv',
|
|
55
|
+
gif: 'image/gif',
|
|
56
|
+
html: 'text/html',
|
|
57
|
+
ics: 'text/calendar',
|
|
58
|
+
jpeg: 'image/jpeg',
|
|
59
|
+
jpg: 'image/jpeg',
|
|
60
|
+
json: 'application/json',
|
|
61
|
+
pdf: 'application/pdf',
|
|
62
|
+
png: 'image/png',
|
|
63
|
+
svg: 'image/svg+xml',
|
|
64
|
+
txt: 'text/plain',
|
|
65
|
+
webp: 'image/webp',
|
|
66
|
+
xml: 'application/xml',
|
|
67
|
+
zip: 'application/zip'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One address, ready for a header and for SES's address fields: `Name <addr>` or a bare `addr`. A
|
|
72
|
+
* display name with specials is quoted; one with non-ASCII becomes encoded-words, which SES requires
|
|
73
|
+
* in FromEmailAddress and the destination lists too.
|
|
74
|
+
* @param {string} input
|
|
75
|
+
* @param {string} field where it came from, for the refusal message
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
export function formatAddress(input, field) {
|
|
79
|
+
const raw = input.trim()
|
|
80
|
+
if (/[\r\n]/.test(raw)) throw invalid(`${field} contains a line break`)
|
|
81
|
+
const bracketed = /^(.*)<([^<>]*)>$/s.exec(raw)
|
|
82
|
+
const addr = (bracketed ? bracketed[2] : raw).trim()
|
|
83
|
+
if (!ADDR_SPEC.test(addr)) throw invalid(`${field} is not a single email address`)
|
|
84
|
+
|
|
85
|
+
let name = bracketed ? bracketed[1].trim() : ''
|
|
86
|
+
if (name.length >= 2 && name.startsWith('"') && name.endsWith('"')) {
|
|
87
|
+
name = name.slice(1, -1).replace(/\\(.)/g, '$1')
|
|
88
|
+
}
|
|
89
|
+
if (!name) return addr
|
|
90
|
+
if (NEEDS_ENCODING.test(name)) return `${encodedWords(name, ' ')} <${addr}>`
|
|
91
|
+
if (PLAIN_NAME.test(name)) return `${name} <${addr}>`
|
|
92
|
+
return `"${name.replace(/["\\]/g, '\\$&')}" <${addr}>`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `to`, `cc`, `bcc` or `replyTo`: one address or a flat list. Empty entries are dropped. A nested list
|
|
97
|
+
* is refused rather than flattened, because it means a wrapper somewhere wrapped a list again (icrotary
|
|
98
|
+
* lost 11 raffle notifications that way).
|
|
99
|
+
* @param {unknown} value
|
|
100
|
+
* @param {string} field
|
|
101
|
+
* @returns {string[]}
|
|
102
|
+
*/
|
|
103
|
+
function addressList(value, field) {
|
|
104
|
+
if (value === undefined || value === null || value === '') return []
|
|
105
|
+
if (typeof value === 'string') return [formatAddress(value, field)]
|
|
106
|
+
if (!Array.isArray(value)) throw invalid(`${field} must be an address or a list of addresses`)
|
|
107
|
+
const out = []
|
|
108
|
+
value.forEach((entry, i) => {
|
|
109
|
+
if (Array.isArray(entry)) throw invalid(`${field} is a nested list: pass a flat list of addresses`)
|
|
110
|
+
if (entry === undefined || entry === null || entry === '') return
|
|
111
|
+
if (typeof entry !== 'string') throw invalid(`${field}[${i}] is not a string`)
|
|
112
|
+
out.push(formatAddress(entry, `${field}[${i}]`))
|
|
113
|
+
})
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {unknown} value
|
|
119
|
+
* @param {string} field
|
|
120
|
+
* @returns {string | undefined}
|
|
121
|
+
*/
|
|
122
|
+
function bodyPart(value, field) {
|
|
123
|
+
if (value === undefined || value === null || value === '') return undefined
|
|
124
|
+
if (typeof value !== 'string') throw invalid(`${field} must be a string`)
|
|
125
|
+
return value
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {unknown} headers
|
|
130
|
+
* @returns {Array<[string, string]>}
|
|
131
|
+
*/
|
|
132
|
+
function headerList(headers) {
|
|
133
|
+
if (headers === undefined || headers === null) return []
|
|
134
|
+
if (typeof headers !== 'object' || Array.isArray(headers)) {
|
|
135
|
+
throw invalid('headers must be an object of header names to values')
|
|
136
|
+
}
|
|
137
|
+
return Object.entries(headers).map(([name, value]) => {
|
|
138
|
+
if (!HEADER_NAME.test(name)) throw invalid(`header name ${JSON.stringify(name)} is not a valid field name`)
|
|
139
|
+
if (RESERVED_HEADERS.has(name.toLowerCase())) {
|
|
140
|
+
throw invalid(`header ${name} is set by @coderize/email: use the message field instead`)
|
|
141
|
+
}
|
|
142
|
+
if (typeof value !== 'string' && typeof value !== 'number') throw invalid(`header ${name} must be a string`)
|
|
143
|
+
const text = String(value)
|
|
144
|
+
if (/[\r\n]/.test(text)) throw invalid(`header ${name} contains a line break`)
|
|
145
|
+
return [name, headerValue(text)]
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {unknown} value
|
|
151
|
+
* @returns {string}
|
|
152
|
+
*/
|
|
153
|
+
function cleanFilename(value) {
|
|
154
|
+
if (typeof value !== 'string') return ''
|
|
155
|
+
// Control characters out (a tab or CR would break the header line), then only the last path segment.
|
|
156
|
+
const printable = [...value].filter((c) => c >= ' ' && c !== '\x7f').join('')
|
|
157
|
+
return (printable.split(/[/\\]/).pop() ?? '').trim()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @param {unknown} attachment
|
|
162
|
+
* @param {number} i
|
|
163
|
+
*/
|
|
164
|
+
function attachmentPart(attachment, i) {
|
|
165
|
+
const field = `attachments[${i}]`
|
|
166
|
+
if (!attachment || typeof attachment !== 'object') throw invalid(`${field} must be an object`)
|
|
167
|
+
const { filename, content, contentType } = /** @type {Record<string, unknown>} */ (attachment)
|
|
168
|
+
|
|
169
|
+
let bytes
|
|
170
|
+
if (content instanceof Uint8Array) {
|
|
171
|
+
bytes = Buffer.from(content.buffer, content.byteOffset, content.byteLength)
|
|
172
|
+
} else if (typeof content === 'string') {
|
|
173
|
+
// Resend took base64 text, and callers moved from it still pass it. Anything else in a string (a CSV
|
|
174
|
+
// written as text, say) would be decoded into garbage, so it is refused instead.
|
|
175
|
+
const b64 = content.replace(/\s+/g, '')
|
|
176
|
+
if (b64.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(b64)) {
|
|
177
|
+
throw invalid(`${field}.content is a string but not base64: pass a Buffer for raw bytes`)
|
|
178
|
+
}
|
|
179
|
+
bytes = Buffer.from(b64, 'base64')
|
|
180
|
+
} else {
|
|
181
|
+
throw invalid(`${field}.content must be a Buffer, a Uint8Array or a base64 string`)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const name = cleanFilename(filename)
|
|
185
|
+
if (!name) throw invalid(`${field}.filename is required`)
|
|
186
|
+
|
|
187
|
+
const extension = name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined
|
|
188
|
+
const type = contentType ?? TYPES[/** @type {keyof typeof TYPES} */ (extension)] ?? 'application/octet-stream'
|
|
189
|
+
if (typeof type !== 'string' || !MEDIA_TYPE.test(type.toLowerCase())) {
|
|
190
|
+
throw invalid(`${field}.contentType is not a media type`)
|
|
191
|
+
}
|
|
192
|
+
return { filename: name, contentType: type.toLowerCase(), content: bytes }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Checks and normalises a message. Returns null when `to` is empty, which `send()` reports as skipped.
|
|
197
|
+
* @param {unknown} message
|
|
198
|
+
* @param {{ from?: string, configurationSet?: string }} [defaults]
|
|
199
|
+
* @returns {NormalMessage | null}
|
|
200
|
+
*/
|
|
201
|
+
export function normalizeMessage(message, defaults = {}) {
|
|
202
|
+
if (!message || typeof message !== 'object') throw invalid('the message must be an object')
|
|
203
|
+
const m = /** @type {Record<string, unknown>} */ (message)
|
|
204
|
+
|
|
205
|
+
const to = addressList(m.to, 'to')
|
|
206
|
+
if (to.length === 0) return null
|
|
207
|
+
|
|
208
|
+
const fromInput = m.from ?? defaults.from
|
|
209
|
+
if (fromInput === undefined || fromInput === null || fromInput === '') {
|
|
210
|
+
throw invalid('no from address: pass `from` to createMailer() or to send()')
|
|
211
|
+
}
|
|
212
|
+
if (typeof fromInput !== 'string') throw invalid('from must be a string')
|
|
213
|
+
|
|
214
|
+
if (typeof m.subject !== 'string' || m.subject.trim() === '') throw invalid('subject is required')
|
|
215
|
+
const text = bodyPart(m.text, 'text')
|
|
216
|
+
const html = bodyPart(m.html, 'html')
|
|
217
|
+
if (!text && !html) throw invalid('text or html is required')
|
|
218
|
+
|
|
219
|
+
if (m.attachments !== undefined && m.attachments !== null && !Array.isArray(m.attachments)) {
|
|
220
|
+
throw invalid('attachments must be a list')
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const configurationSet = m.configurationSet ?? defaults.configurationSet
|
|
224
|
+
if (configurationSet !== undefined && configurationSet !== null && configurationSet !== '') {
|
|
225
|
+
if (typeof configurationSet !== 'string' || !CONFIGURATION_SET.test(configurationSet)) {
|
|
226
|
+
throw invalid('configurationSet is not a configuration set name')
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
from: formatAddress(fromInput, 'from'),
|
|
232
|
+
to,
|
|
233
|
+
cc: addressList(m.cc, 'cc'),
|
|
234
|
+
bcc: addressList(m.bcc, 'bcc'),
|
|
235
|
+
replyTo: addressList(m.replyTo, 'replyTo'),
|
|
236
|
+
// Line breaks in a subject come from interpolated data (a name, an org): fold them into spaces
|
|
237
|
+
// rather than refuse the message, so they can never start a header of their own.
|
|
238
|
+
subject: m.subject.replace(/[\r\n]+/g, ' '),
|
|
239
|
+
text,
|
|
240
|
+
html,
|
|
241
|
+
headers: headerList(m.headers),
|
|
242
|
+
attachments: /** @type {unknown[] | undefined} */ (m.attachments ?? []).map(attachmentPart),
|
|
243
|
+
configurationSet: /** @type {string | undefined} */ (configurationSet || undefined)
|
|
244
|
+
}
|
|
245
|
+
}
|
package/mime.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The raw MIME message for a send with attachments (RFC 2045/2046):
|
|
3
|
+
|
|
4
|
+
multipart/mixed
|
|
5
|
+
multipart/alternative (or a single text/plain or text/html part)
|
|
6
|
+
text/plain base64
|
|
7
|
+
text/html base64
|
|
8
|
+
attachment, one each base64, 76-character lines
|
|
9
|
+
|
|
10
|
+
Every line ends in CRLF, every header is 7-bit (RFC 2047 for a non-ASCII subject, display name or
|
|
11
|
+
header value), and every body part is base64, so no line comes near the 998-octet limit whatever the
|
|
12
|
+
HTML looks like. Bcc is never written here: SES gets it in the envelope only, which is what keeps it
|
|
13
|
+
blind.
|
|
14
|
+
|
|
15
|
+
Why not SES's Simple.Attachments: PDFs sent that way opened blank in real mail clients (icrotary,
|
|
16
|
+
2026-07), and binaries go out as text unless each attachment says ContentTransferEncoding: 'BASE64'.
|
|
17
|
+
icrotary-com-mono's buildRawMessage is the fleet reference this follows.
|
|
18
|
+
*/
|
|
19
|
+
import { randomUUID } from 'node:crypto'
|
|
20
|
+
|
|
21
|
+
import { base64Lines, CRLF, encodedWords, headerValue, NEEDS_ENCODING, percentEncoded, quoted } from './encode.js'
|
|
22
|
+
import { normalizeMessage } from './message.js'
|
|
23
|
+
import { SendError } from './sendError.js'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} type
|
|
27
|
+
* @param {string} text
|
|
28
|
+
* @returns {string[]}
|
|
29
|
+
*/
|
|
30
|
+
function textPart(type, text) {
|
|
31
|
+
return [
|
|
32
|
+
`Content-Type: ${type}; charset=UTF-8`,
|
|
33
|
+
'Content-Transfer-Encoding: base64',
|
|
34
|
+
'',
|
|
35
|
+
base64Lines(Buffer.from(text, 'utf8'))
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {'alternative' | 'mixed'} subtype
|
|
41
|
+
* @param {string[][]} parts
|
|
42
|
+
* @returns {string[]}
|
|
43
|
+
*/
|
|
44
|
+
function multipart(subtype, parts) {
|
|
45
|
+
// "=_" can't occur in base64 text, so the boundary can't collide with a part's content.
|
|
46
|
+
const boundary = `=_${subtype}_${randomUUID()}`
|
|
47
|
+
const lines = [`Content-Type: multipart/${subtype};${CRLF} boundary="${boundary}"`, '']
|
|
48
|
+
for (const part of parts) lines.push(`--${boundary}`, ...part)
|
|
49
|
+
lines.push(`--${boundary}--`)
|
|
50
|
+
return lines
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {{ filename: string, contentType: string, content: Buffer }} attachment
|
|
55
|
+
* @returns {string[]}
|
|
56
|
+
*/
|
|
57
|
+
function attachmentPart({ filename, contentType, content }) {
|
|
58
|
+
const ascii = !NEEDS_ENCODING.test(filename)
|
|
59
|
+
// RFC 2047 words inside the quoted name are what Gmail and Outlook read; filename* (RFC 2231) is the
|
|
60
|
+
// standard form, for the clients that prefer it.
|
|
61
|
+
const name = ascii ? quoted(filename) : encodedWords(filename, ' ')
|
|
62
|
+
const disposition = [`Content-Disposition: attachment;${CRLF} filename="${name}"`]
|
|
63
|
+
if (!ascii) disposition.push(`;${CRLF} filename*=UTF-8''${percentEncoded(filename)}`)
|
|
64
|
+
return [
|
|
65
|
+
`Content-Type: ${contentType};${CRLF} name="${name}"`,
|
|
66
|
+
'Content-Transfer-Encoding: base64',
|
|
67
|
+
disposition.join(''),
|
|
68
|
+
'',
|
|
69
|
+
base64Lines(content)
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The raw message for a normalised message.
|
|
75
|
+
* @param {import('./message.js').NormalMessage} m
|
|
76
|
+
* @returns {Buffer}
|
|
77
|
+
*/
|
|
78
|
+
export function renderRaw(m) {
|
|
79
|
+
const lines = [`From: ${m.from}`, `To: ${m.to.join(`,${CRLF} `)}`]
|
|
80
|
+
if (m.cc.length) lines.push(`Cc: ${m.cc.join(`,${CRLF} `)}`)
|
|
81
|
+
if (m.replyTo.length) lines.push(`Reply-To: ${m.replyTo.join(`,${CRLF} `)}`)
|
|
82
|
+
lines.push(`Subject: ${headerValue(m.subject)}`, 'MIME-Version: 1.0')
|
|
83
|
+
for (const [name, value] of m.headers) lines.push(`${name}: ${value}`)
|
|
84
|
+
|
|
85
|
+
const bodies = []
|
|
86
|
+
if (m.text) bodies.push(textPart('text/plain', m.text))
|
|
87
|
+
if (m.html) bodies.push(textPart('text/html', m.html))
|
|
88
|
+
const body = bodies.length === 2 ? multipart('alternative', bodies) : bodies[0]
|
|
89
|
+
lines.push(...(m.attachments.length ? multipart('mixed', [body, ...m.attachments.map(attachmentPart)]) : body))
|
|
90
|
+
|
|
91
|
+
return Buffer.from(`${lines.join(CRLF)}${CRLF}`, 'utf8')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Builds the raw MIME message `send()` hands SES for a message with attachments: the same input as
|
|
96
|
+
* `send()`, checked the same way. Exported for apps that need the bytes themselves (an .eml download, a
|
|
97
|
+
* test); sending goes through `send()`.
|
|
98
|
+
* @param {object} message
|
|
99
|
+
* @param {{ from?: string }} [defaults]
|
|
100
|
+
* @returns {Buffer}
|
|
101
|
+
*/
|
|
102
|
+
export function buildRawMessage(message, defaults) {
|
|
103
|
+
const normal = normalizeMessage(message, defaults)
|
|
104
|
+
if (!normal) throw new SendError('Email not sent: to is required', 'permanent')
|
|
105
|
+
return renderRaw(normal)
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coderize/email",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Shared SES email sending for CoderizeDEV apps: one send switch, Simple or raw MIME with attachments, classified errors, an HTML escaper and an SNS signature verifier",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ses",
|
|
7
|
+
"email",
|
|
8
|
+
"coderize"
|
|
9
|
+
],
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/CoderizeDEV/coderize-platform.git",
|
|
13
|
+
"directory": "packages/email"
|
|
14
|
+
},
|
|
15
|
+
"license": "UNLICENSED",
|
|
16
|
+
"author": "Coderize",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./index.d.ts",
|
|
21
|
+
"default": "./index.js"
|
|
22
|
+
},
|
|
23
|
+
"./sns": {
|
|
24
|
+
"types": "./sns.d.ts",
|
|
25
|
+
"default": "./sns.js"
|
|
26
|
+
},
|
|
27
|
+
"./package.json": "./package.json"
|
|
28
|
+
},
|
|
29
|
+
"types": "./index.d.ts",
|
|
30
|
+
"files": [
|
|
31
|
+
"CHANGELOG.md",
|
|
32
|
+
"encode.js",
|
|
33
|
+
"escape.js",
|
|
34
|
+
"index.d.ts",
|
|
35
|
+
"index.js",
|
|
36
|
+
"mailer.js",
|
|
37
|
+
"message.js",
|
|
38
|
+
"mime.js",
|
|
39
|
+
"sendError.js",
|
|
40
|
+
"sns.d.ts",
|
|
41
|
+
"sns.js"
|
|
42
|
+
],
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@aws-sdk/client-sesv2": "^3.1161.0",
|
|
45
|
+
"@types/node": "^24.19.2",
|
|
46
|
+
"postal-mime": "^4.2.0",
|
|
47
|
+
"typescript": "~6.0.3",
|
|
48
|
+
"vitest": "^5.0.4"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@aws-sdk/client-sesv2": "^3.1127.0"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=24"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"test": "vitest run && tsc -p test/types"
|
|
61
|
+
}
|
|
6
62
|
}
|
package/sendError.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `send()` throws when a message did not go out. `kind` tells the caller what to do about it:
|
|
3
|
+
*
|
|
4
|
+
* - `transient`: throttling, an SES 5xx or a network failure. Worth trying again later.
|
|
5
|
+
* - `permanent`: anything else (AccessDenied, an unverified identity, a message this package refused to
|
|
6
|
+
* build). Retrying the same message won't help.
|
|
7
|
+
*
|
|
8
|
+
* The message never quotes an address or any content. A failed SES call says `Email not sent` and keeps
|
|
9
|
+
* the AWS error as `cause`; a message refused before sending says why (`Email not sent: subject is
|
|
10
|
+
* required`).
|
|
11
|
+
*/
|
|
12
|
+
export class SendError extends Error {
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} message
|
|
15
|
+
* @param {'transient' | 'permanent'} kind
|
|
16
|
+
* @param {{ cause?: unknown }} [options]
|
|
17
|
+
*/
|
|
18
|
+
constructor(message, kind, options) {
|
|
19
|
+
super(message, options)
|
|
20
|
+
this.name = 'SendError'
|
|
21
|
+
this.kind = kind
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TRANSIENT_NAMES = new Set([
|
|
26
|
+
'ThrottlingException',
|
|
27
|
+
'TooManyRequestsException',
|
|
28
|
+
'LimitExceededException',
|
|
29
|
+
'InternalServerException',
|
|
30
|
+
'InternalFailure',
|
|
31
|
+
'ServiceUnavailable',
|
|
32
|
+
'RequestTimeout',
|
|
33
|
+
'TimeoutError'
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether an AWS SDK v3 error is worth retrying: the SDK marked it retryable, its name is a throttle or a
|
|
38
|
+
* server fault, SES answered 5xx, or there was no answer at all. An SDK error carries `$metadata` from
|
|
39
|
+
* the service, so one without it never reached SES (DNS, a reset connection, a timeout).
|
|
40
|
+
*
|
|
41
|
+
* Shared with SMS senders (SNS, End User Messaging), which fail the same ways.
|
|
42
|
+
* @param {unknown} error
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export function isTransientAwsError(error) {
|
|
46
|
+
const e = /** @type {any} */ (error)
|
|
47
|
+
if (e?.$retryable) return true
|
|
48
|
+
if (TRANSIENT_NAMES.has(e?.name)) return true
|
|
49
|
+
if (e?.$metadata?.httpStatusCode >= 500) return true
|
|
50
|
+
return !e?.$metadata && !e?.$fault
|
|
51
|
+
}
|
package/sns.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Types for `@coderize/email/sns`. Hand-written to match sns.js; test/types/consumer.ts type-checks them.
|
|
2
|
+
import type { KeyObject } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
/** An SNS message as posted to an HTTPS subscription (JSON, sent as text/plain). */
|
|
5
|
+
export interface SnsMessage {
|
|
6
|
+
Type: 'Notification' | 'SubscriptionConfirmation' | 'UnsubscribeConfirmation' | (string & {})
|
|
7
|
+
MessageId: string
|
|
8
|
+
TopicArn: string
|
|
9
|
+
Message: string
|
|
10
|
+
Timestamp: string
|
|
11
|
+
SignatureVersion: string
|
|
12
|
+
Signature: string
|
|
13
|
+
SigningCertURL: string
|
|
14
|
+
Subject?: string
|
|
15
|
+
SubscribeURL?: string
|
|
16
|
+
Token?: string
|
|
17
|
+
UnsubscribeURL?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The AWS region in an SNS topic ARN, or null. */
|
|
21
|
+
export declare function topicRegion(topicArn: unknown): string | null
|
|
22
|
+
|
|
23
|
+
/** Whether a URL is HTTPS on exactly `sns.<region>.amazonaws.com` (and ends in .pem when `pem`). */
|
|
24
|
+
export declare function isSnsUrl(url: unknown, options?: { region?: string | null; pem?: boolean }): boolean
|
|
25
|
+
|
|
26
|
+
/** The exact string SNS signed for this message, or null. */
|
|
27
|
+
export declare function signingString(message: Record<string, unknown> | null | undefined): string | null
|
|
28
|
+
|
|
29
|
+
/** Fetches (or reads from the cache) the signing certificate's public key. Throws unless it is valid now. */
|
|
30
|
+
export declare function fetchSigningCert(url: string, options?: { fetch?: typeof fetch }): Promise<KeyObject>
|
|
31
|
+
|
|
32
|
+
export interface VerifySnsOptions {
|
|
33
|
+
/** The topic, or topics, this route accepts. */
|
|
34
|
+
topicArn?: string | string[]
|
|
35
|
+
/** Replaces the certificate download (tests). */
|
|
36
|
+
fetchCert?: (url: string) => Promise<KeyObject | string>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether a message is genuinely from SNS (and from `topicArn`). Never throws. */
|
|
40
|
+
export declare function verifySnsMessage(message: unknown, options?: VerifySnsOptions): Promise<boolean>
|
|
41
|
+
|
|
42
|
+
/** GETs the SubscribeURL of a verified SubscriptionConfirmation. Throws when the URL is not SNS's. */
|
|
43
|
+
export declare function confirmSubscription(
|
|
44
|
+
message: SnsMessage | Record<string, unknown>,
|
|
45
|
+
options?: { fetch?: typeof fetch }
|
|
46
|
+
): Promise<{ ok: boolean; status: number }>
|