@molecule/api-emails-inbound-ses 1.0.0 → 1.0.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.
Files changed (2) hide show
  1. package/README.md +815 -0
  2. package/package.json +10 -9
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.0",
3
+ "version": "1.0.2",
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",
@@ -30,20 +31,20 @@
30
31
  ],
31
32
  "license": "Apache-2.0",
32
33
  "dependencies": {
33
- "mailparser": "3.9.14"
34
+ "mailparser": "3.9.28"
34
35
  },
35
36
  "devDependencies": {
36
- "@molecule/api-emails": "1.0.0",
37
- "@molecule/api-emails-inbound": "1.0.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
- "vitest": "4.1.10"
42
+ "vitest": "4.1.11"
42
43
  },
43
44
  "peerDependencies": {
44
- "@molecule/api-emails": "^1.0.0",
45
- "@molecule/api-emails-inbound": "^1.0.0",
46
- "@molecule/api-secrets": "^1.0.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",