@molecule/api-emails-inbound-ses 1.0.0 → 1.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/README.md +815 -0
- package/package.json +8 -7
package/README.md
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
AUTO-GENERATED — DO NOT EDIT THIS FILE.
|
|
3
|
+
Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
|
|
4
|
+
Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
|
|
5
|
+
To change this document, edit the module-level JSDoc in src/index.ts.
|
|
6
|
+
Generated: 2026-08-04T01:48:03.682Z
|
|
7
|
+
-->
|
|
8
|
+
|
|
9
|
+
# @molecule/api-emails-inbound-ses
|
|
10
|
+
|
|
11
|
+
> **Auto-generated, AI-first package reference** for the [molecule.dev](https://molecule.dev) ecosystem.
|
|
12
|
+
> It is written to be read by coding agents as much as by people, and is generated from this
|
|
13
|
+
> package's source — edit `src/index.ts` JSDoc, not this file.
|
|
14
|
+
|
|
15
|
+
AWS SES inbound-email provider for molecule.dev.
|
|
16
|
+
|
|
17
|
+
Implements `@molecule/api-emails-inbound`'s `InboundEmailProvider`
|
|
18
|
+
interface against AWS SES Inbound's SNS-delivery format. Validates SNS
|
|
19
|
+
notification signatures by fetching the publisher cert from a
|
|
20
|
+
`*.amazonaws.com`-allowlisted URL, parses the SES `mail` + `content`
|
|
21
|
+
fields, and decodes the embedded RFC 822 message via `mailparser`.
|
|
22
|
+
|
|
23
|
+
Outbound replies compose onto the bonded `@molecule/api-emails`
|
|
24
|
+
transport (typically `@molecule/api-emails-ses`) — this package does not
|
|
25
|
+
reimplement SMTP / SES SendEmail.
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import { setProvider } from '@molecule/api-emails-inbound'
|
|
31
|
+
import { provider as sesInbound } from '@molecule/api-emails-inbound-ses'
|
|
32
|
+
|
|
33
|
+
setProvider(sesInbound)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Type
|
|
37
|
+
|
|
38
|
+
`provider`
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install @molecule/api-emails-inbound-ses @molecule/api-emails @molecule/api-emails-inbound @molecule/api-secrets mailparser
|
|
44
|
+
npm install -D @types/mailparser
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## API
|
|
48
|
+
|
|
49
|
+
### Interfaces
|
|
50
|
+
|
|
51
|
+
#### `InboundEmail`
|
|
52
|
+
|
|
53
|
+
A normalized inbound email, produced by parsing a provider webhook
|
|
54
|
+
payload through {@link InboundEmailProvider.parseWebhookPayload}.
|
|
55
|
+
|
|
56
|
+
All providers (Mailgun Routes, SES Inbound, fixtures, etc.) return this
|
|
57
|
+
same shape so handler code can treat inbound mail uniformly. Provider
|
|
58
|
+
specifics (raw MIME, signing tokens, etc.) MUST NOT leak into this type.
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
interface InboundEmail {
|
|
62
|
+
/**
|
|
63
|
+
* Stable provider-supplied identifier for the message. Used for
|
|
64
|
+
* deduplication when the same webhook is retried.
|
|
65
|
+
*/
|
|
66
|
+
id: string
|
|
67
|
+
/**
|
|
68
|
+
* Sender address (RFC 5322 mailbox), e.g. `'alice@example.com'`.
|
|
69
|
+
*/
|
|
70
|
+
from: string
|
|
71
|
+
/**
|
|
72
|
+
* Primary recipient addresses (the values from the `To:` header).
|
|
73
|
+
*/
|
|
74
|
+
to: string[]
|
|
75
|
+
/**
|
|
76
|
+
* Carbon-copy recipient addresses, if present.
|
|
77
|
+
*/
|
|
78
|
+
cc?: string[]
|
|
79
|
+
/**
|
|
80
|
+
* Subject line, decoded to a plain string. May be empty.
|
|
81
|
+
*/
|
|
82
|
+
subject: string
|
|
83
|
+
/**
|
|
84
|
+
* Plain-text body of the message, if present.
|
|
85
|
+
*/
|
|
86
|
+
textBody?: string
|
|
87
|
+
/**
|
|
88
|
+
* HTML body of the message, if present.
|
|
89
|
+
*/
|
|
90
|
+
htmlBody?: string
|
|
91
|
+
/**
|
|
92
|
+
* Decoded attachments. An empty array when the message has none.
|
|
93
|
+
*/
|
|
94
|
+
attachments?: InboundEmailAttachment[]
|
|
95
|
+
/**
|
|
96
|
+
* All headers from the raw message, lowercased keys to canonicalize the
|
|
97
|
+
* many capitalizations that mail servers use. Multi-value headers
|
|
98
|
+
* (`Received:`, etc.) are joined with newlines or returned as arrays at
|
|
99
|
+
* provider discretion — see provider docs.
|
|
100
|
+
*/
|
|
101
|
+
headers: Record<string, string | string[]>
|
|
102
|
+
/**
|
|
103
|
+
* Server-side timestamp the inbound provider received the message.
|
|
104
|
+
*/
|
|
105
|
+
receivedAt: Date
|
|
106
|
+
/**
|
|
107
|
+
* Optional `Message-ID` header value for threading. Surfaced separately
|
|
108
|
+
* from {@link headers} because helpdesk handlers almost always need it.
|
|
109
|
+
*/
|
|
110
|
+
messageId?: string
|
|
111
|
+
/**
|
|
112
|
+
* Optional `In-Reply-To` header value for threading replies into an
|
|
113
|
+
* existing ticket.
|
|
114
|
+
*/
|
|
115
|
+
inReplyTo?: string
|
|
116
|
+
/**
|
|
117
|
+
* Optional `References` header values for threading.
|
|
118
|
+
*/
|
|
119
|
+
references?: string[]
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
#### `InboundEmailAttachment`
|
|
124
|
+
|
|
125
|
+
A binary attachment carried by an inbound email.
|
|
126
|
+
|
|
127
|
+
Providers normalize whatever multipart/MIME representation they receive
|
|
128
|
+
into this neutral shape. The body is base64-encoded so the type is
|
|
129
|
+
JSON-serializable across IPC, queue, and webhook boundaries.
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
interface InboundEmailAttachment {
|
|
133
|
+
/**
|
|
134
|
+
* The original filename as supplied by the sender, or a provider-derived
|
|
135
|
+
* fallback when the sender omitted one.
|
|
136
|
+
*/
|
|
137
|
+
name: string
|
|
138
|
+
/**
|
|
139
|
+
* MIME type of the attachment (e.g. `'application/pdf'`, `'image/png'`).
|
|
140
|
+
* Defaults to `'application/octet-stream'` when the provider cannot
|
|
141
|
+
* determine the type.
|
|
142
|
+
*/
|
|
143
|
+
contentType: string
|
|
144
|
+
/**
|
|
145
|
+
* Attachment payload, base64-encoded.
|
|
146
|
+
*/
|
|
147
|
+
contentBase64: string
|
|
148
|
+
/**
|
|
149
|
+
* Optional size hint in bytes of the decoded payload. Providers MAY set
|
|
150
|
+
* this from upstream headers without decoding the payload themselves.
|
|
151
|
+
*/
|
|
152
|
+
sizeBytes?: number
|
|
153
|
+
/**
|
|
154
|
+
* Optional Content-ID, used for inline images referenced from the HTML
|
|
155
|
+
* body via `cid:` URLs.
|
|
156
|
+
*/
|
|
157
|
+
contentId?: string
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
#### `InboundEmailProvider`
|
|
162
|
+
|
|
163
|
+
Inbound-email provider interface.
|
|
164
|
+
|
|
165
|
+
Implementations (Mailgun Routes, SES Inbound, etc.) live in separate
|
|
166
|
+
bond packages (`@molecule/api-emails-inbound-mailgun-routes`,
|
|
167
|
+
`@molecule/api-emails-inbound-ses`). The interface is deliberately
|
|
168
|
+
minimal: a webhook arrives at the host application's HTTP layer, the
|
|
169
|
+
raw headers and body are handed to the provider, and the provider
|
|
170
|
+
returns a normalized {@link InboundEmail}.
|
|
171
|
+
|
|
172
|
+
Signature verification is mandatory for any provider that runs against
|
|
173
|
+
a public webhook endpoint; {@link verifySignature} is the hook for
|
|
174
|
+
that. Providers without signed webhooks SHOULD return `false` rather
|
|
175
|
+
than `true` so callers can decide whether to accept unsigned mail.
|
|
176
|
+
|
|
177
|
+
````typescript
|
|
178
|
+
interface InboundEmailProvider {
|
|
179
|
+
/**
|
|
180
|
+
* Parses the raw webhook payload (HTTP headers + body) into a
|
|
181
|
+
* normalized {@link InboundEmail}.
|
|
182
|
+
*
|
|
183
|
+
* @param headers - HTTP request headers received by the webhook
|
|
184
|
+
* endpoint. Lowercased keys are recommended but not required;
|
|
185
|
+
* implementations MUST handle either casing.
|
|
186
|
+
* @param body - Raw HTTP request body. May be a `Buffer` (e.g. from a
|
|
187
|
+
* raw body parser), a `string`, or an already-parsed object provided
|
|
188
|
+
* by an upstream JSON middleware.
|
|
189
|
+
* @returns The normalized inbound email.
|
|
190
|
+
*/
|
|
191
|
+
parseWebhookPayload(
|
|
192
|
+
headers: Record<string, string | string[] | undefined>,
|
|
193
|
+
body: Buffer | string | Record<string, unknown>,
|
|
194
|
+
): Promise<InboundEmail>
|
|
195
|
+
/**
|
|
196
|
+
* Verifies the signature of a webhook request, using whatever scheme
|
|
197
|
+
* the provider exposes (Mailgun HMAC, SES SNS subscription
|
|
198
|
+
* confirmation, etc.). Implementations MUST be constant-time when
|
|
199
|
+
* comparing secrets.
|
|
200
|
+
*
|
|
201
|
+
* A genuinely invalid webhook (forged, stale, malformed, tampered
|
|
202
|
+
* signature) resolves `false` — that is the normal, expected failure
|
|
203
|
+
* path and callers map it to a `401`. Implementations MAY instead THROW
|
|
204
|
+
* a tagged configuration error (e.g. via `configNotConfiguredError()`
|
|
205
|
+
* from `@molecule/api-secrets`) when the provider itself is
|
|
206
|
+
* misconfigured — for example a missing signing key/secret. This is a
|
|
207
|
+
* DISTINCT failure class from a `false` return: a misconfigured server
|
|
208
|
+
* is not the same problem as a forged request, and collapsing both into
|
|
209
|
+
* the same `false` makes a broken deployment indistinguishable from an
|
|
210
|
+
* attack, with no trace either way. `@molecule/api-emails-inbound-mailgun`
|
|
211
|
+
* follows this pattern — `verifySignature` throws the tagged
|
|
212
|
+
* `config.notConfigured` error when `MAILGUN_API_KEY` is unset, and
|
|
213
|
+
* resolves `false` for every other verification failure.
|
|
214
|
+
*
|
|
215
|
+
* @param headers - HTTP request headers received by the webhook
|
|
216
|
+
* endpoint.
|
|
217
|
+
* @param body - Raw HTTP request body. Implementations that need the
|
|
218
|
+
* exact bytes (e.g. for HMAC) MUST be passed a `Buffer`.
|
|
219
|
+
* @returns `true` when the signature is valid, `false` for an
|
|
220
|
+
* invalid/forged/stale/malformed webhook.
|
|
221
|
+
* @throws {Error} Implementations MAY throw a tagged configuration error
|
|
222
|
+
* when the provider is missing required configuration (e.g. an unset
|
|
223
|
+
* signing key) — a server misconfiguration, not an invalid request.
|
|
224
|
+
* @example
|
|
225
|
+
* ```typescript
|
|
226
|
+
* // In an HTTP handler bound to the inbound webhook URL:
|
|
227
|
+
* const ok = await verifySignature(req.headers, req.rawBody)
|
|
228
|
+
* if (!ok) return res.status(401).end()
|
|
229
|
+
* // A thrown configuration error (server misconfigured) is deliberately
|
|
230
|
+
* // NOT caught above — do not wrap this call in a try/catch that maps
|
|
231
|
+
* // every failure to the same 401. Let it propagate to standard error
|
|
232
|
+
* // middleware, which maps a tagged config error to a 503, distinct
|
|
233
|
+
* // from the 401 an invalid/forged webhook gets.
|
|
234
|
+
* ```
|
|
235
|
+
*/
|
|
236
|
+
verifySignature(
|
|
237
|
+
headers: Record<string, string | string[] | undefined>,
|
|
238
|
+
body: Buffer | string,
|
|
239
|
+
): Promise<boolean>
|
|
240
|
+
/**
|
|
241
|
+
* Optional: dispatches an outbound reply through the provider's own
|
|
242
|
+
* reply mechanism. Providers that do not support reply dispatch (e.g.
|
|
243
|
+
* pure inbound-only adapters) SHOULD omit this method; callers MUST
|
|
244
|
+
* use {@link InboundEmailProvider.supportsReply} to detect support.
|
|
245
|
+
*
|
|
246
|
+
* @param email - The original inbound email being replied to.
|
|
247
|
+
* @param reply - The reply payload.
|
|
248
|
+
* @returns Result of the dispatch.
|
|
249
|
+
*/
|
|
250
|
+
replyTo?(email: InboundEmail, reply: InboundEmailReply): Promise<InboundEmailReplyResult>
|
|
251
|
+
/**
|
|
252
|
+
* Indicates whether the provider supports outbound reply dispatch via
|
|
253
|
+
* {@link replyTo}. Implementations SHOULD return a stable `true` /
|
|
254
|
+
* `false` based on their own configuration; the property is a function
|
|
255
|
+
* so providers can defer to runtime configuration if needed.
|
|
256
|
+
*
|
|
257
|
+
* @returns `true` when {@link replyTo} is implemented and ready to use.
|
|
258
|
+
*/
|
|
259
|
+
supportsReply(): boolean
|
|
260
|
+
}
|
|
261
|
+
````
|
|
262
|
+
|
|
263
|
+
#### `InboundEmailReply`
|
|
264
|
+
|
|
265
|
+
Outgoing reply produced by handler code in response to an
|
|
266
|
+
{@link InboundEmail}. Providers that support the optional
|
|
267
|
+
{@link InboundEmailProvider.replyTo} method translate this into whatever
|
|
268
|
+
outbound mechanism their upstream offers (Mailgun reply route, SES
|
|
269
|
+
SendEmail, etc.).
|
|
270
|
+
|
|
271
|
+
For providers that do NOT expose an outbound reply path, handler code
|
|
272
|
+
SHOULD fall back to the regular `@molecule/api-emails` outbound bond.
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
interface InboundEmailReply {
|
|
276
|
+
/**
|
|
277
|
+
* Subject line for the outbound reply. If omitted, providers SHOULD
|
|
278
|
+
* default to the original subject prefixed with `'Re: '` (locale-aware
|
|
279
|
+
* prefixing is the caller's responsibility).
|
|
280
|
+
*/
|
|
281
|
+
subject?: string
|
|
282
|
+
/**
|
|
283
|
+
* Plain-text body of the reply, if any.
|
|
284
|
+
*/
|
|
285
|
+
textBody?: string
|
|
286
|
+
/**
|
|
287
|
+
* HTML body of the reply, if any.
|
|
288
|
+
*/
|
|
289
|
+
htmlBody?: string
|
|
290
|
+
/**
|
|
291
|
+
* Attachments to send with the reply.
|
|
292
|
+
*/
|
|
293
|
+
attachments?: InboundEmailAttachment[]
|
|
294
|
+
/**
|
|
295
|
+
* Optional override for the `From:` address. Defaults to the address
|
|
296
|
+
* the original message was sent to (the inbound mailbox).
|
|
297
|
+
*/
|
|
298
|
+
from?: string
|
|
299
|
+
/**
|
|
300
|
+
* Optional additional headers to set on the outbound message.
|
|
301
|
+
*/
|
|
302
|
+
headers?: Record<string, string>
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
#### `InboundEmailReplyResult`
|
|
307
|
+
|
|
308
|
+
Result of a successful reply dispatch via
|
|
309
|
+
{@link InboundEmailProvider.replyTo}.
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
interface InboundEmailReplyResult {
|
|
313
|
+
/**
|
|
314
|
+
* Provider-supplied identifier for the dispatched outbound message.
|
|
315
|
+
*/
|
|
316
|
+
id: string
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
#### `SesInboundNotificationMessage`
|
|
321
|
+
|
|
322
|
+
Shape of the JSON payload SES publishes to SNS for inbound-email
|
|
323
|
+
notifications. Only the fields used by this bond are typed; SES emits a
|
|
324
|
+
superset including `verdicts`, `dkim`, etc.
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
interface SesInboundNotificationMessage {
|
|
328
|
+
/** Notification kind. We expect `Received` for inbound mail. */
|
|
329
|
+
notificationType: string
|
|
330
|
+
|
|
331
|
+
/** Metadata about the SES `mail` object. */
|
|
332
|
+
mail: {
|
|
333
|
+
/** ISO timestamp SES received the message. */
|
|
334
|
+
timestamp: string
|
|
335
|
+
/** Sender as decoded by SES. */
|
|
336
|
+
source: string
|
|
337
|
+
/** SES-assigned message ID. */
|
|
338
|
+
messageId: string
|
|
339
|
+
/** Envelope-recipient list. */
|
|
340
|
+
destination: string[]
|
|
341
|
+
/** Common headers SES extracts from the message. */
|
|
342
|
+
commonHeaders?: {
|
|
343
|
+
from?: string[]
|
|
344
|
+
to?: string[]
|
|
345
|
+
cc?: string[]
|
|
346
|
+
bcc?: string[]
|
|
347
|
+
subject?: string
|
|
348
|
+
messageId?: string
|
|
349
|
+
inReplyTo?: string
|
|
350
|
+
references?: string
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* All raw headers SES extracted, when `headersTruncated` is `false`.
|
|
354
|
+
*/
|
|
355
|
+
headers?: Array<{ name: string; value: string }>
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Raw RFC 822 message content, base64-encoded, present when the SES
|
|
360
|
+
* receipt rule includes the message content. Absent for header-only
|
|
361
|
+
* notifications.
|
|
362
|
+
*/
|
|
363
|
+
content?: string
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
#### `SnsNotificationPayload`
|
|
368
|
+
|
|
369
|
+
Shape of an Amazon SNS notification (or SubscriptionConfirmation /
|
|
370
|
+
UnsubscribeConfirmation) payload, as POSTed to a subscribed HTTPS
|
|
371
|
+
endpoint. Only the fields used by this bond are typed here.
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
interface SnsNotificationPayload {
|
|
375
|
+
/**
|
|
376
|
+
* Discriminates the kind of message: `Notification`,
|
|
377
|
+
* `SubscriptionConfirmation`, or `UnsubscribeConfirmation`.
|
|
378
|
+
*/
|
|
379
|
+
Type: string
|
|
380
|
+
|
|
381
|
+
/** A unique UUID for the message. */
|
|
382
|
+
MessageId: string
|
|
383
|
+
|
|
384
|
+
/** The notification topic ARN. */
|
|
385
|
+
TopicArn?: string
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Subject line as supplied by the publisher. Optional for notifications.
|
|
389
|
+
*/
|
|
390
|
+
Subject?: string
|
|
391
|
+
|
|
392
|
+
/** Message payload (string). For SES notifications this is JSON. */
|
|
393
|
+
Message: string
|
|
394
|
+
|
|
395
|
+
/** ISO 8601 timestamp when the message was published. */
|
|
396
|
+
Timestamp: string
|
|
397
|
+
|
|
398
|
+
/** Signature version. AWS SNS supports `1` (SHA1) and `2` (SHA256). */
|
|
399
|
+
SignatureVersion: string
|
|
400
|
+
|
|
401
|
+
/** Base64-encoded signature over the canonical string. */
|
|
402
|
+
Signature: string
|
|
403
|
+
|
|
404
|
+
/** URL of the X.509 PEM cert used to sign the message. */
|
|
405
|
+
SigningCertURL: string
|
|
406
|
+
|
|
407
|
+
/** Confirmation token (only on SubscriptionConfirmation messages). */
|
|
408
|
+
Token?: string
|
|
409
|
+
|
|
410
|
+
/** Subscribe URL (only on SubscriptionConfirmation messages). */
|
|
411
|
+
SubscribeURL?: string
|
|
412
|
+
|
|
413
|
+
/** Unsubscribe URL (only on Notification / UnsubscribeConfirmation). */
|
|
414
|
+
UnsubscribeURL?: string
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### Functions
|
|
419
|
+
|
|
420
|
+
#### `_resetSigningCertCache()`
|
|
421
|
+
|
|
422
|
+
Resets the cached signing certificates. Exposed for tests.
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
function _resetSigningCertCache(): void
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
#### `base64ToBuffer(value)`
|
|
429
|
+
|
|
430
|
+
Decodes a base64 string into a Node `Buffer`.
|
|
431
|
+
|
|
432
|
+
```typescript
|
|
433
|
+
function base64ToBuffer(value: string): Buffer<ArrayBufferLike>
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
- `value` — The base64 string.
|
|
437
|
+
|
|
438
|
+
**Returns:** The decoded buffer.
|
|
439
|
+
|
|
440
|
+
#### `bodyToString(body)`
|
|
441
|
+
|
|
442
|
+
Coerces the request body into a UTF-8 string. SNS POSTs JSON as UTF-8.
|
|
443
|
+
|
|
444
|
+
```typescript
|
|
445
|
+
function bodyToString(body: string | Buffer<ArrayBufferLike>): string
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
- `body` — The raw body.
|
|
449
|
+
|
|
450
|
+
**Returns:** The body as a UTF-8 string.
|
|
451
|
+
|
|
452
|
+
#### `buildSnsCanonicalString(payload)`
|
|
453
|
+
|
|
454
|
+
Builds the canonical string SNS signs for the supplied notification.
|
|
455
|
+
The exact field order is mandated by the SNS message-and-signature
|
|
456
|
+
format; the canonical string is built from key/value pairs separated by
|
|
457
|
+
`\n`, with a trailing `\n` after the last value.
|
|
458
|
+
|
|
459
|
+
```typescript
|
|
460
|
+
function buildSnsCanonicalString(payload: {
|
|
461
|
+
Type?: string
|
|
462
|
+
Message?: string
|
|
463
|
+
MessageId?: string
|
|
464
|
+
Subject?: string
|
|
465
|
+
SubscribeURL?: string
|
|
466
|
+
Timestamp?: string
|
|
467
|
+
Token?: string
|
|
468
|
+
TopicArn?: string
|
|
469
|
+
}): string
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
- `payload` — The SNS notification payload.
|
|
473
|
+
|
|
474
|
+
**Returns:** The canonical string suitable for HMAC verification.
|
|
475
|
+
|
|
476
|
+
#### `getHeader(headers, name)`
|
|
477
|
+
|
|
478
|
+
Returns the value of `headers[name]` (case-insensitive) coerced to a
|
|
479
|
+
single string.
|
|
480
|
+
|
|
481
|
+
```typescript
|
|
482
|
+
function getHeader(
|
|
483
|
+
headers: Record<string, string | string[] | undefined>,
|
|
484
|
+
name: string,
|
|
485
|
+
): string | undefined
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
- `headers` — The headers object.
|
|
489
|
+
- `name` — The header name (case-insensitive).
|
|
490
|
+
|
|
491
|
+
**Returns:** The header value as a single string, or `undefined` if absent.
|
|
492
|
+
|
|
493
|
+
#### `getSigningCertHostnameSuffixes()`
|
|
494
|
+
|
|
495
|
+
Returns the configured allowlist of hostname suffixes for the SNS
|
|
496
|
+
`SigningCertURL`. Defaults to {@link SNS_SIGNING_CERT_HOSTNAME_SUFFIXES}
|
|
497
|
+
when the env override is unset.
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
function getSigningCertHostnameSuffixes(): readonly string[]
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
**Returns:** The allowlist of hostname suffixes.
|
|
504
|
+
|
|
505
|
+
#### `headerToString(value)`
|
|
506
|
+
|
|
507
|
+
Coerces an HTTP header value (which may be `string`, `string[]`, or
|
|
508
|
+
`undefined`) to a single string. Multi-value headers are joined with
|
|
509
|
+
`, ` per RFC 9110 §5.2.
|
|
510
|
+
|
|
511
|
+
```typescript
|
|
512
|
+
function headerToString(value: string | string[] | undefined): string | undefined
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
- `value` — The header value to coerce.
|
|
516
|
+
|
|
517
|
+
**Returns:** The header value as a single string, or `undefined` when the header was not present.
|
|
518
|
+
|
|
519
|
+
#### `isAllowedSigningCertUrl(url)`
|
|
520
|
+
|
|
521
|
+
Validates that an SNS `SigningCertURL` is HTTPS and its hostname matches
|
|
522
|
+
an entry in the allowlist. Defends against SSRF and certificate
|
|
523
|
+
substitution attacks where an attacker tricks the verifier into fetching
|
|
524
|
+
a cert from a host they control.
|
|
525
|
+
|
|
526
|
+
```typescript
|
|
527
|
+
function isAllowedSigningCertUrl(url: string): boolean
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
- `url` — The candidate URL string.
|
|
531
|
+
|
|
532
|
+
**Returns:** `true` when the URL is acceptable.
|
|
533
|
+
|
|
534
|
+
#### `parseJsonBody(body)`
|
|
535
|
+
|
|
536
|
+
Parses a Buffer/string/object body as an SNS JSON payload. Throws
|
|
537
|
+
(caught by the caller) when the body is not valid JSON.
|
|
538
|
+
|
|
539
|
+
```typescript
|
|
540
|
+
function parseJsonBody(body: string | Buffer<ArrayBufferLike> | Record<string, unknown>): unknown
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
- `body` — The raw body.
|
|
544
|
+
|
|
545
|
+
**Returns:** The parsed object.
|
|
546
|
+
|
|
547
|
+
#### `parseRawMimeContent(raw, overrides)`
|
|
548
|
+
|
|
549
|
+
Parses a raw RFC 822 MIME message into a normalized {@link InboundEmail}.
|
|
550
|
+
|
|
551
|
+
Used internally by `parseWebhookPayload` after the SES `content` field
|
|
552
|
+
has been base64-decoded. Exposed so that applications using SES's
|
|
553
|
+
S3-only delivery mode can reuse the same parser by fetching the S3
|
|
554
|
+
object themselves and calling this helper.
|
|
555
|
+
|
|
556
|
+
```typescript
|
|
557
|
+
function parseRawMimeContent(
|
|
558
|
+
raw: string | Buffer<ArrayBufferLike>,
|
|
559
|
+
overrides?: Partial<InboundEmail>,
|
|
560
|
+
): Promise<InboundEmail>
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
- `raw` — The raw RFC 822 message bytes.
|
|
564
|
+
- `overrides` — Optional fields to override on the parsed result (e.g. an SES-assigned `id` or `receivedAt` timestamp).
|
|
565
|
+
|
|
566
|
+
**Returns:** The normalized inbound email.
|
|
567
|
+
|
|
568
|
+
#### `parseWebhookPayload(_headers, body)`
|
|
569
|
+
|
|
570
|
+
Parses an SNS notification carrying an SES inbound-email payload into a
|
|
571
|
+
normalized {@link InboundEmail}.
|
|
572
|
+
|
|
573
|
+
When the SES `Message.content` field is present, it is base64-decoded
|
|
574
|
+
and parsed as RFC 822 via `mailparser`. When `content` is absent
|
|
575
|
+
(header-only notifications), we synthesize an `InboundEmail` from the
|
|
576
|
+
SES `mail` metadata so the caller can still log/dedupe the message.
|
|
577
|
+
|
|
578
|
+
SubscriptionConfirmation messages are returned as a synthetic
|
|
579
|
+
`InboundEmail` whose `subject` is `'__sns:SubscriptionConfirmation'` and
|
|
580
|
+
whose `headers['x-sns-subscribe-url']` carries the confirmation URL —
|
|
581
|
+
applications inspect this so they can subscribe out-of-band.
|
|
582
|
+
|
|
583
|
+
```typescript
|
|
584
|
+
function parseWebhookPayload(
|
|
585
|
+
_headers: Record<string, string | string[] | undefined>,
|
|
586
|
+
body: string | Buffer<ArrayBufferLike> | Record<string, unknown>,
|
|
587
|
+
): Promise<InboundEmail>
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
- `_headers` — HTTP headers (unused).
|
|
591
|
+
- `body` — Raw HTTP body (SNS JSON).
|
|
592
|
+
|
|
593
|
+
**Returns:** The normalized inbound email.
|
|
594
|
+
|
|
595
|
+
#### `replyTo(email, reply)`
|
|
596
|
+
|
|
597
|
+
Dispatches an outbound reply through the bonded `@molecule/api-emails`
|
|
598
|
+
transport. The reply's `In-Reply-To` and `References` headers are
|
|
599
|
+
populated from the original message when present.
|
|
600
|
+
|
|
601
|
+
```typescript
|
|
602
|
+
function replyTo(email: InboundEmail, reply: InboundEmailReply): Promise<InboundEmailReplyResult>
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
- `email` — The original inbound email being replied to.
|
|
606
|
+
- `reply` — The reply payload.
|
|
607
|
+
|
|
608
|
+
**Returns:** The reply dispatch result.
|
|
609
|
+
|
|
610
|
+
#### `splitReferences(value)`
|
|
611
|
+
|
|
612
|
+
Splits a `References:` header (whitespace-separated `<message-id>`
|
|
613
|
+
tokens) into individual values, preserving the angle brackets.
|
|
614
|
+
|
|
615
|
+
```typescript
|
|
616
|
+
function splitReferences(value: string | undefined): string[]
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
- `value` — The raw header value.
|
|
620
|
+
|
|
621
|
+
**Returns:** Array of message-id tokens (with angle brackets) or empty.
|
|
622
|
+
|
|
623
|
+
#### `supportsReply()`
|
|
624
|
+
|
|
625
|
+
Indicates that this provider supports outbound reply dispatch via
|
|
626
|
+
{@link replyTo}. The reply path requires the outbound
|
|
627
|
+
`@molecule/api-emails` bond to be wired with a transport — typically
|
|
628
|
+
`@molecule/api-emails-ses`.
|
|
629
|
+
|
|
630
|
+
```typescript
|
|
631
|
+
function supportsReply(): boolean
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
**Returns:** Always `true`.
|
|
635
|
+
|
|
636
|
+
#### `unwrapMessageId(value)`
|
|
637
|
+
|
|
638
|
+
Strips surrounding angle brackets from a `Message-ID` value.
|
|
639
|
+
|
|
640
|
+
```typescript
|
|
641
|
+
function unwrapMessageId(value: string | undefined): string | undefined
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
- `value` — The raw value (with or without angle brackets).
|
|
645
|
+
|
|
646
|
+
**Returns:** The value without angle brackets, or `undefined` if input was empty.
|
|
647
|
+
|
|
648
|
+
#### `verifySignature(_headers, body)`
|
|
649
|
+
|
|
650
|
+
Verifies the signature of an SNS notification payload. Implements the
|
|
651
|
+
AWS SNS signature-verification flow:
|
|
652
|
+
|
|
653
|
+
1. Parse the JSON body.
|
|
654
|
+
2. Reject if `SigningCertURL` is not from an allowlisted host.
|
|
655
|
+
3. Fetch the X.509 certificate from `SigningCertURL`.
|
|
656
|
+
4. Build the canonical string per AWS docs (field order varies by
|
|
657
|
+
`Type`).
|
|
658
|
+
5. Verify the base64-decoded `Signature` against the canonical string
|
|
659
|
+
using SHA1 (`SignatureVersion === '1'`) or SHA256
|
|
660
|
+
(`SignatureVersion === '2'`).
|
|
661
|
+
6. When `AWS_SES_INBOUND_TOPIC_ARN` is set, also verify the payload's
|
|
662
|
+
`TopicArn` matches.
|
|
663
|
+
|
|
664
|
+
Errors NEVER leak signing material; failures simply return `false`.
|
|
665
|
+
|
|
666
|
+
```typescript
|
|
667
|
+
function verifySignature(
|
|
668
|
+
_headers: Record<string, string | string[] | undefined>,
|
|
669
|
+
body: string | Buffer<ArrayBufferLike>,
|
|
670
|
+
): Promise<boolean>
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
- `_headers` — HTTP headers (unused — SNS signs the body).
|
|
674
|
+
- `body` — Raw HTTP request body (JSON).
|
|
675
|
+
|
|
676
|
+
**Returns:** `true` when the signature is valid, `false` otherwise.
|
|
677
|
+
|
|
678
|
+
### Constants
|
|
679
|
+
|
|
680
|
+
#### `emailsInboundSesSecretDefinitions`
|
|
681
|
+
|
|
682
|
+
Secret definitions required by the AWS SES inbound-email bond.
|
|
683
|
+
|
|
684
|
+
```typescript
|
|
685
|
+
const emailsInboundSesSecretDefinitions: SecretDefinition[]
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
#### `provider`
|
|
689
|
+
|
|
690
|
+
The AWS SES inbound-email provider implementing the
|
|
691
|
+
{@link InboundEmailProvider} interface.
|
|
692
|
+
|
|
693
|
+
```typescript
|
|
694
|
+
const provider: InboundEmailProvider
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
#### `SNS_SIGNING_CERT_HOSTNAME_SUFFIXES`
|
|
698
|
+
|
|
699
|
+
Allowed hostname suffixes for the SNS `SigningCertURL`. AWS SNS only
|
|
700
|
+
publishes signing certificates from `*.amazonaws.com`; any URL outside
|
|
701
|
+
this allowlist MUST be rejected to defend against SSRF and certificate
|
|
702
|
+
substitution attacks.
|
|
703
|
+
|
|
704
|
+
Exposed for unit-testing; not part of the public bond surface.
|
|
705
|
+
|
|
706
|
+
```typescript
|
|
707
|
+
const SNS_SIGNING_CERT_HOSTNAME_SUFFIXES: readonly string[]
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
## Core Interface
|
|
711
|
+
|
|
712
|
+
Implements `@molecule/api-emails-inbound` interface.
|
|
713
|
+
|
|
714
|
+
## Bond Wiring
|
|
715
|
+
|
|
716
|
+
Setup function to register this provider with the core interface:
|
|
717
|
+
|
|
718
|
+
```typescript
|
|
719
|
+
import { setProvider } from '@molecule/api-emails-inbound'
|
|
720
|
+
import { provider } from '@molecule/api-emails-inbound-ses'
|
|
721
|
+
|
|
722
|
+
export function setupEmailsInboundSes(): void {
|
|
723
|
+
setProvider(provider)
|
|
724
|
+
}
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
## Injection Notes
|
|
728
|
+
|
|
729
|
+
### Requirements
|
|
730
|
+
|
|
731
|
+
Peer dependencies:
|
|
732
|
+
|
|
733
|
+
- `@molecule/api-emails` ^1.0.1
|
|
734
|
+
- `@molecule/api-emails-inbound` ^1.0.1
|
|
735
|
+
- `@molecule/api-secrets` ^1.0.1
|
|
736
|
+
|
|
737
|
+
### Environment Variables
|
|
738
|
+
|
|
739
|
+
- `AWS_ACCESS_KEY_ID` _(required)_ — AWS access key ID
|
|
740
|
+
- Setup: Create an IAM user with the needed policy (SES/S3/SQS) and create an access key under Security credentials.
|
|
741
|
+
- Get it here: [https://console.aws.amazon.com/iam/](https://console.aws.amazon.com/iam/)
|
|
742
|
+
- Example: `AKIA...`
|
|
743
|
+
- `AWS_SECRET_ACCESS_KEY` _(required)_ — AWS secret access key
|
|
744
|
+
- Setup: Shown once when creating the IAM access key — store it immediately.
|
|
745
|
+
- Get it here: [https://console.aws.amazon.com/iam/](https://console.aws.amazon.com/iam/)
|
|
746
|
+
- `AWS_SES_REGION` _(required)_ — AWS SES region
|
|
747
|
+
- Setup: The AWS region where SES is set up (and out of sandbox for production sending).
|
|
748
|
+
- Example: `us-east-1`
|
|
749
|
+
- `AWS_SES_INBOUND_TOPIC_ARN` _(optional)_ — SES inbound SNS topic ARN
|
|
750
|
+
- Setup: ARN of the SNS topic your SES receipt rule publishes inbound mail to.
|
|
751
|
+
- Get it here: [https://console.aws.amazon.com/ses/](https://console.aws.amazon.com/ses/)
|
|
752
|
+
- Example: `arn:aws:sns:us-east-1:123456789012:ses-inbound`
|
|
753
|
+
- `AWS_SNS_SIGNING_CERT_HOSTNAME_SUFFIXES` _(optional)_ — SNS signing-cert hostname allowlist — default: `.amazonaws.com`
|
|
754
|
+
- Setup: Comma-separated hostname suffixes allowed for SNS signature certificates; the default (.amazonaws.com) is fine.
|
|
755
|
+
- Example: `.amazonaws.com`
|
|
756
|
+
|
|
757
|
+
### Runtime Dependencies
|
|
758
|
+
|
|
759
|
+
- `@molecule/api-emails`
|
|
760
|
+
- `@molecule/api-emails-inbound`
|
|
761
|
+
- `@molecule/api-secrets`
|
|
762
|
+
- `mailparser`
|
|
763
|
+
|
|
764
|
+
The (uncached) signing-certificate fetch is bounded to a 5 second
|
|
765
|
+
timeout — a hanging/slow `SigningCertURL` endpoint fails fast into a
|
|
766
|
+
`false` verification result instead of stalling the webhook handler for
|
|
767
|
+
`fetch`'s much longer default. Once a cert is fetched it is cached
|
|
768
|
+
in-process, so this only affects the first verification against a given
|
|
769
|
+
`SigningCertURL`.
|
|
770
|
+
|
|
771
|
+
## E2E Tests
|
|
772
|
+
|
|
773
|
+
Integration checklist — drive the real inbound endpoint (live preview, no
|
|
774
|
+
mocks), adapt each item to this app's actual inbox/ticket/thread flows, and
|
|
775
|
+
check every box off one by one. A box you can't check is an integration bug
|
|
776
|
+
to fix — not a skip. COUNTERPARTY: the app can't receive a real email in the
|
|
777
|
+
sandbox, so YOU play the mail provider — POST a realistic inbound-email
|
|
778
|
+
webhook to the app's inbound endpoint with `sandbox_fetch` (curl runs inside
|
|
779
|
+
the container). Model the payload on the BONDED provider's real format
|
|
780
|
+
(Mailgun: form fields `sender`/`recipient`/`subject`/`body-plain`/`body-html`/
|
|
781
|
+
`attachment-N` plus the `timestamp`/`token`/`signature` triple; SES/Postmark:
|
|
782
|
+
their JSON), and the happy-path POST must carry a VALID signature — compute it
|
|
783
|
+
the way the provider does (Mailgun signs HMAC-SHA256 of `timestamp+token` with
|
|
784
|
+
`MAILGUN_API_KEY` inside the replay window; read the key from the Environment
|
|
785
|
+
panel / `.env.molecule`). Never disable `verifySignature()` or mock
|
|
786
|
+
`parseWebhookPayload()` to go green — that proves nothing.
|
|
787
|
+
|
|
788
|
+
- [ ] A signed sample webhook to the inbound endpoint parses into the
|
|
789
|
+
normalized fields (from / to / subject / textBody / htmlBody) AND the app
|
|
790
|
+
ACTS on it — it files the mail into the right place (creates a ticket, a
|
|
791
|
+
comment on a thread, or a reply-thread) keyed off the recipient (`support@`)
|
|
792
|
+
or a plus-address / thread token (`reply+<id>@`). Verify the CREATED record
|
|
793
|
+
(a DB row, and it shows up in the UI) — not just a 200.
|
|
794
|
+
- [ ] Routing is correct: an email to `support@` opens a NEW ticket, while
|
|
795
|
+
`reply+<id>@` (or an `In-Reply-To` / `References` match) threads onto the
|
|
796
|
+
EXISTING one — each lands in the right user's / conversation's place, never
|
|
797
|
+
a stranger's.
|
|
798
|
+
- [ ] Attachments survive: an inbound message with an attachment has it
|
|
799
|
+
decoded from `attachments[].contentBase64` and stored on the app's OWN
|
|
800
|
+
storage (the uploads bond), not left as a provider link — the stored file
|
|
801
|
+
opens from the ticket.
|
|
802
|
+
- [ ] Retries don't duplicate: re-POST the SAME webhook (providers retry slow
|
|
803
|
+
/ 5xx deliveries) and confirm handling is idempotent — one ticket, not two
|
|
804
|
+
(dedupe on `id` / `messageId`).
|
|
805
|
+
- [ ] Malformed / empty payloads (missing `body-plain`, no attachments, absent
|
|
806
|
+
headers) are handled without a crash — a clean response, not a 500 stack
|
|
807
|
+
trace.
|
|
808
|
+
- [ ] SECURITY — the endpoint is AUTHENTICATED: a forged POST with a bad or
|
|
809
|
+
missing signature (or a `timestamp` outside the replay window) is REJECTED
|
|
810
|
+
(401) and creates NO record, so an attacker can't inject mail into another
|
|
811
|
+
user's thread. A missing signing key is a DISTINCT 503, not a 401 — a
|
|
812
|
+
server misconfig must not masquerade as an accepted or forged webhook.
|
|
813
|
+
- [ ] SECURITY — the parsed `htmlBody` is sanitized before it is rendered
|
|
814
|
+
anywhere: a `<script>` / `onerror=` in an inbound body must NOT execute when
|
|
815
|
+
the ticket is viewed (no stored XSS from an inbound email body).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@molecule/api-emails-inbound-ses",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "AWS SES inbound email provider — parses SNS notification payloads carrying SES inbound messages into normalized InboundEmail.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
|
-
"dist"
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md"
|
|
21
22
|
],
|
|
22
23
|
"keywords": [
|
|
23
24
|
"molecule",
|
|
@@ -33,17 +34,17 @@
|
|
|
33
34
|
"mailparser": "3.9.14"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
|
-
"@molecule/api-emails": "1.0.
|
|
37
|
-
"@molecule/api-emails-inbound": "1.0.
|
|
37
|
+
"@molecule/api-emails": "1.0.1",
|
|
38
|
+
"@molecule/api-emails-inbound": "1.0.1",
|
|
38
39
|
"@types/mailparser": "3.4.6",
|
|
39
40
|
"@types/node": "26.1.2",
|
|
40
41
|
"typescript": "6.0.3",
|
|
41
42
|
"vitest": "4.1.10"
|
|
42
43
|
},
|
|
43
44
|
"peerDependencies": {
|
|
44
|
-
"@molecule/api-emails": "^1.0.
|
|
45
|
-
"@molecule/api-emails-inbound": "^1.0.
|
|
46
|
-
"@molecule/api-secrets": "^1.0.
|
|
45
|
+
"@molecule/api-emails": "^1.0.1",
|
|
46
|
+
"@molecule/api-emails-inbound": "^1.0.1",
|
|
47
|
+
"@molecule/api-secrets": "^1.0.1"
|
|
47
48
|
},
|
|
48
49
|
"repository": {
|
|
49
50
|
"type": "git",
|