@molecule/api-emails-inbound-agentmail 1.0.0

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 ADDED
@@ -0,0 +1,1159 @@
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-09-04T12:07:54.526Z
7
+ -->
8
+
9
+ # @molecule/api-emails-inbound-agentmail
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
+ AgentMail inbound-email provider for molecule.dev.
16
+
17
+ Implements `@molecule/api-emails-inbound`'s `InboundEmailProvider`
18
+ interface against AgentMail's `message.received` webhook. Verifies the
19
+ Svix signature headers (`svix-id` / `svix-timestamp` / `svix-signature`,
20
+ HMAC-SHA256 over `id.timestamp.body` keyed by the `whsec_` secret) with
21
+ replay protection, normalizes the JSON payload, hydrates through the
22
+ AgentMail API what the webhook leaves out (attachment bytes; bodies over
23
+ the 1 MB cap), and replies through AgentMail's own reply endpoint. Built
24
+ on the global `fetch` — no SDK.
25
+
26
+ ## Quick Start
27
+
28
+ ```typescript
29
+ import { setProvider } from '@molecule/api-emails-inbound'
30
+ import { provider as agentMailInbound } from '@molecule/api-emails-inbound-agentmail'
31
+
32
+ setProvider(agentMailInbound)
33
+ ```
34
+
35
+ ## Type
36
+
37
+ `provider`
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ npm install @molecule/api-emails-inbound-agentmail @molecule/api-emails-inbound @molecule/api-secrets
43
+ ```
44
+
45
+ ## API
46
+
47
+ ### Interfaces
48
+
49
+ #### `AgentMailAttachmentDownload`
50
+
51
+ Response of `GET /v0/inboxes/{inbox_id}/messages/{message_id}/attachments/{attachment_id}`:
52
+ the attachment's metadata plus a presigned, time-limited `download_url`
53
+ from which the raw bytes are fetched.
54
+
55
+ ```typescript
56
+ interface AgentMailAttachmentDownload extends AgentMailAttachmentMeta {
57
+ /** Presigned URL serving the raw attachment bytes (no auth header needed). */
58
+ download_url: string
59
+ /** When `download_url` stops working. */
60
+ expires_at?: string
61
+ }
62
+ ```
63
+
64
+ #### `AgentMailAttachmentMeta`
65
+
66
+ Attachment METADATA as carried by a webhook payload or a `GET message`
67
+ response. The bytes are never inline — see
68
+ {@link AgentMailAttachmentDownload}.
69
+
70
+ ```typescript
71
+ interface AgentMailAttachmentMeta {
72
+ /** AgentMail's identifier for the attachment. */
73
+ attachment_id: string
74
+ /** Size of the attachment in bytes. */
75
+ size: number
76
+ /** Original filename, when the sender supplied one. */
77
+ filename?: string
78
+ /** MIME type, when known. */
79
+ content_type?: string
80
+ /** `inline` for `cid:`-referenced parts, `attachment` otherwise. */
81
+ content_disposition?: 'inline' | 'attachment'
82
+ /** Content-ID for inline parts referenced from the HTML body. */
83
+ content_id?: string
84
+ }
85
+ ```
86
+
87
+ #### `AgentMailErrorBody`
88
+
89
+ AgentMail's error envelope.
90
+
91
+ ```typescript
92
+ interface AgentMailErrorBody {
93
+ /** Legacy error type name (e.g. `NotFoundError`). */
94
+ name?: string
95
+ /** Machine-readable error code (e.g. `unknown_api_key`, `rate_limit_exceeded`). */
96
+ code?: string
97
+ /** Human-readable description. */
98
+ message?: string
99
+ /** Concrete remediation steps, when AgentMail supplied them. */
100
+ fix?: string
101
+ /** Link to the error's documentation. */
102
+ docs?: string
103
+ }
104
+ ```
105
+
106
+ #### `AgentMailMessage`
107
+
108
+ An AgentMail message as it appears in a `message.received` webhook payload
109
+ and in the `GET message` response (same schema).
110
+
111
+ `message_id` is the RFC 5322 `Message-ID`, INCLUDING its angle brackets
112
+ (`<abc@agentmail.to>`); AgentMail uses that exact string as the path
113
+ parameter of every per-message endpoint.
114
+
115
+ ```typescript
116
+ interface AgentMailMessage {
117
+ /** Opaque id of the inbox that received the message (NOT its address). */
118
+ inbox_id: string
119
+ /** Opaque id of the conversation thread. */
120
+ thread_id?: string
121
+ /** RFC 5322 Message-ID, with angle brackets. */
122
+ message_id: string
123
+ /** AgentMail labels, e.g. `['received']`. */
124
+ labels?: string[]
125
+ /** ISO 8601 time AgentMail received the message. */
126
+ timestamp?: string
127
+ /**
128
+ * Sender mailbox (`Alice <alice@example.com>`). The API reference spells
129
+ * this `from`; the webhooks guide's example spells it `from_` — both are
130
+ * documented, so both are read.
131
+ */
132
+ from?: string
133
+ /** Alternate documented spelling of {@link AgentMailMessage.from}. */
134
+ from_?: string
135
+ /** `To:` recipients. */
136
+ to?: string[]
137
+ /** `Cc:` recipients. */
138
+ cc?: string[]
139
+ /** `Bcc:` recipients. */
140
+ bcc?: string[]
141
+ /** `Reply-To:` addresses. */
142
+ reply_to?: string[]
143
+ /** Subject line. */
144
+ subject?: string
145
+ /** Short body preview. */
146
+ preview?: string
147
+ /**
148
+ * Plain-text body. Omitted (together with `html`) when the webhook payload
149
+ * would exceed AgentMail's 1 MB cap — fetch the message via the API then.
150
+ */
151
+ text?: string
152
+ /** HTML body. Omitted under the same 1 MB rule as `text`. */
153
+ html?: string
154
+ /** Attachment metadata only — bytes come from the attachment endpoint. */
155
+ attachments?: AgentMailAttachmentMeta[]
156
+ /** `In-Reply-To` header value, with angle brackets. */
157
+ in_reply_to?: string
158
+ /** `References` header values, with angle brackets. */
159
+ references?: string[]
160
+ /** Raw message headers as a name → value map. */
161
+ headers?: Record<string, string>
162
+ /** Message size in bytes. */
163
+ size?: number
164
+ /** ISO 8601 creation time. */
165
+ created_at?: string
166
+ /** ISO 8601 last-update time. */
167
+ updated_at?: string
168
+ }
169
+ ```
170
+
171
+ #### `AgentMailReplyAttachment`
172
+
173
+ One attachment in an {@link AgentMailReplyRequest}.
174
+
175
+ ```typescript
176
+ interface AgentMailReplyAttachment {
177
+ /** Filename shown to the recipient. */
178
+ filename?: string
179
+ /** MIME type. */
180
+ content_type?: string
181
+ /** `inline` for `cid:`-referenced parts. */
182
+ content_disposition?: 'inline' | 'attachment'
183
+ /** Content-ID for inline parts. */
184
+ content_id?: string
185
+ /** Base64-encoded attachment bytes. */
186
+ content?: string
187
+ }
188
+ ```
189
+
190
+ #### `AgentMailReplyRequest`
191
+
192
+ Request body of `POST /v0/inboxes/{inbox_id}/messages/{message_id}/reply`.
193
+ Every field is optional on the wire; AgentMail threads the reply itself
194
+ (there is no `subject` — the original's is reused).
195
+
196
+ ```typescript
197
+ interface AgentMailReplyRequest {
198
+ /** Recipient(s). */
199
+ to?: string | string[]
200
+ /** CC recipient(s). */
201
+ cc?: string | string[]
202
+ /** BCC recipient(s). */
203
+ bcc?: string | string[]
204
+ /** Reply-To address(es). */
205
+ reply_to?: string | string[]
206
+ /** Plain-text body. */
207
+ text?: string
208
+ /** HTML body. */
209
+ html?: string
210
+ /** Attachments; `content` is the base64-encoded payload. */
211
+ attachments?: AgentMailReplyAttachment[]
212
+ /** Custom message headers. */
213
+ headers?: Record<string, string>
214
+ /** Message labels. */
215
+ labels?: string[]
216
+ }
217
+ ```
218
+
219
+ #### `AgentMailReplyResponse`
220
+
221
+ Response of the reply endpoint.
222
+
223
+ ```typescript
224
+ interface AgentMailReplyResponse {
225
+ /** Message-ID of the created reply. */
226
+ message_id: string
227
+ /** Thread the reply belongs to. */
228
+ thread_id?: string
229
+ }
230
+ ```
231
+
232
+ #### `AgentMailWebhookEvent`
233
+
234
+ Top-level shape of an AgentMail webhook delivery for the
235
+ `message.received*` event family.
236
+
237
+ ```typescript
238
+ interface AgentMailWebhookEvent {
239
+ /** Always `event`. */
240
+ type?: string
241
+ /**
242
+ * `message.received`, `message.received.spam`,
243
+ * `message.received.blocked`, or `message.received.unauthenticated`.
244
+ */
245
+ event_type: string
246
+ /** Unique id of this event. */
247
+ event_id?: string
248
+ /** The received message. */
249
+ message: AgentMailMessage
250
+ }
251
+ ```
252
+
253
+ #### `InboundEmail`
254
+
255
+ A normalized inbound email, produced by parsing a provider webhook
256
+ payload through {@link InboundEmailProvider.parseWebhookPayload}.
257
+
258
+ All providers (Mailgun Routes, SES Inbound, fixtures, etc.) return this
259
+ same shape so handler code can treat inbound mail uniformly. Provider
260
+ specifics (raw MIME, signing tokens, etc.) MUST NOT leak into this type.
261
+
262
+ ```typescript
263
+ interface InboundEmail {
264
+ /**
265
+ * Stable provider-supplied identifier for the message. Used for
266
+ * deduplication when the same webhook is retried.
267
+ */
268
+ id: string
269
+ /**
270
+ * Sender address (RFC 5322 mailbox), e.g. `'alice@example.com'`.
271
+ */
272
+ from: string
273
+ /**
274
+ * Primary recipient addresses (the values from the `To:` header).
275
+ */
276
+ to: string[]
277
+ /**
278
+ * Carbon-copy recipient addresses, if present.
279
+ */
280
+ cc?: string[]
281
+ /**
282
+ * Subject line, decoded to a plain string. May be empty.
283
+ */
284
+ subject: string
285
+ /**
286
+ * Plain-text body of the message, if present.
287
+ */
288
+ textBody?: string
289
+ /**
290
+ * HTML body of the message, if present.
291
+ */
292
+ htmlBody?: string
293
+ /**
294
+ * Decoded attachments. An empty array when the message has none.
295
+ */
296
+ attachments?: InboundEmailAttachment[]
297
+ /**
298
+ * All headers from the raw message, lowercased keys to canonicalize the
299
+ * many capitalizations that mail servers use. Multi-value headers
300
+ * (`Received:`, etc.) are joined with newlines or returned as arrays at
301
+ * provider discretion — see provider docs.
302
+ */
303
+ headers: Record<string, string | string[]>
304
+ /**
305
+ * Server-side timestamp the inbound provider received the message.
306
+ */
307
+ receivedAt: Date
308
+ /**
309
+ * Optional `Message-ID` header value for threading. Surfaced separately
310
+ * from {@link headers} because helpdesk handlers almost always need it.
311
+ */
312
+ messageId?: string
313
+ /**
314
+ * Optional `In-Reply-To` header value for threading replies into an
315
+ * existing ticket.
316
+ */
317
+ inReplyTo?: string
318
+ /**
319
+ * Optional `References` header values for threading.
320
+ */
321
+ references?: string[]
322
+ }
323
+ ```
324
+
325
+ #### `InboundEmailAttachment`
326
+
327
+ A binary attachment carried by an inbound email.
328
+
329
+ Providers normalize whatever multipart/MIME representation they receive
330
+ into this neutral shape. The body is base64-encoded so the type is
331
+ JSON-serializable across IPC, queue, and webhook boundaries.
332
+
333
+ ```typescript
334
+ interface InboundEmailAttachment {
335
+ /**
336
+ * The original filename as supplied by the sender, or a provider-derived
337
+ * fallback when the sender omitted one.
338
+ */
339
+ name: string
340
+ /**
341
+ * MIME type of the attachment (e.g. `'application/pdf'`, `'image/png'`).
342
+ * Defaults to `'application/octet-stream'` when the provider cannot
343
+ * determine the type.
344
+ */
345
+ contentType: string
346
+ /**
347
+ * Attachment payload, base64-encoded.
348
+ */
349
+ contentBase64: string
350
+ /**
351
+ * Optional size hint in bytes of the decoded payload. Providers MAY set
352
+ * this from upstream headers without decoding the payload themselves.
353
+ */
354
+ sizeBytes?: number
355
+ /**
356
+ * Optional Content-ID, used for inline images referenced from the HTML
357
+ * body via `cid:` URLs.
358
+ */
359
+ contentId?: string
360
+ }
361
+ ```
362
+
363
+ #### `InboundEmailProvider`
364
+
365
+ Inbound-email provider interface.
366
+
367
+ Implementations (Mailgun Routes, SES Inbound, etc.) live in separate
368
+ bond packages (`@molecule/api-emails-inbound-mailgun-routes`,
369
+ `@molecule/api-emails-inbound-ses`). The interface is deliberately
370
+ minimal: a webhook arrives at the host application's HTTP layer, the
371
+ raw headers and body are handed to the provider, and the provider
372
+ returns a normalized {@link InboundEmail}.
373
+
374
+ Signature verification is mandatory for any provider that runs against
375
+ a public webhook endpoint; {@link verifySignature} is the hook for
376
+ that. Providers without signed webhooks SHOULD return `false` rather
377
+ than `true` so callers can decide whether to accept unsigned mail.
378
+
379
+ ````typescript
380
+ interface InboundEmailProvider {
381
+ /**
382
+ * Parses the raw webhook payload (HTTP headers + body) into a
383
+ * normalized {@link InboundEmail}.
384
+ *
385
+ * @param headers - HTTP request headers received by the webhook
386
+ * endpoint. Lowercased keys are recommended but not required;
387
+ * implementations MUST handle either casing.
388
+ * @param body - Raw HTTP request body. May be a `Buffer` (e.g. from a
389
+ * raw body parser), a `string`, or an already-parsed object provided
390
+ * by an upstream JSON middleware.
391
+ * @returns The normalized inbound email.
392
+ */
393
+ parseWebhookPayload(
394
+ headers: Record<string, string | string[] | undefined>,
395
+ body: Buffer | string | Record<string, unknown>,
396
+ ): Promise<InboundEmail>
397
+ /**
398
+ * Verifies the signature of a webhook request, using whatever scheme
399
+ * the provider exposes (Mailgun HMAC, SES SNS subscription
400
+ * confirmation, etc.). Implementations MUST be constant-time when
401
+ * comparing secrets.
402
+ *
403
+ * A genuinely invalid webhook (forged, stale, malformed, tampered
404
+ * signature) resolves `false` — that is the normal, expected failure
405
+ * path and callers map it to a `401`. Implementations MAY instead THROW
406
+ * a tagged configuration error (e.g. via `configNotConfiguredError()`
407
+ * from `@molecule/api-secrets`) when the provider itself is
408
+ * misconfigured — for example a missing signing key/secret. This is a
409
+ * DISTINCT failure class from a `false` return: a misconfigured server
410
+ * is not the same problem as a forged request, and collapsing both into
411
+ * the same `false` makes a broken deployment indistinguishable from an
412
+ * attack, with no trace either way. `@molecule/api-emails-inbound-mailgun`
413
+ * follows this pattern — `verifySignature` throws the tagged
414
+ * `config.notConfigured` error when `MAILGUN_API_KEY` is unset, and
415
+ * resolves `false` for every other verification failure.
416
+ *
417
+ * @param headers - HTTP request headers received by the webhook
418
+ * endpoint.
419
+ * @param body - Raw HTTP request body. Implementations that need the
420
+ * exact bytes (e.g. for HMAC) MUST be passed a `Buffer`.
421
+ * @returns `true` when the signature is valid, `false` for an
422
+ * invalid/forged/stale/malformed webhook.
423
+ * @throws {Error} Implementations MAY throw a tagged configuration error
424
+ * when the provider is missing required configuration (e.g. an unset
425
+ * signing key) — a server misconfiguration, not an invalid request.
426
+ * @example
427
+ * ```typescript
428
+ * // In an HTTP handler bound to the inbound webhook URL:
429
+ * const ok = await verifySignature(req.headers, req.rawBody)
430
+ * if (!ok) return res.status(401).end()
431
+ * // A thrown configuration error (server misconfigured) is deliberately
432
+ * // NOT caught above — do not wrap this call in a try/catch that maps
433
+ * // every failure to the same 401. Let it propagate to standard error
434
+ * // middleware, which maps a tagged config error to a 503, distinct
435
+ * // from the 401 an invalid/forged webhook gets.
436
+ * ```
437
+ */
438
+ verifySignature(
439
+ headers: Record<string, string | string[] | undefined>,
440
+ body: Buffer | string,
441
+ ): Promise<boolean>
442
+ /**
443
+ * Optional: dispatches an outbound reply through the provider's own
444
+ * reply mechanism. Providers that do not support reply dispatch (e.g.
445
+ * pure inbound-only adapters) SHOULD omit this method; callers MUST
446
+ * use {@link InboundEmailProvider.supportsReply} to detect support.
447
+ *
448
+ * @param email - The original inbound email being replied to.
449
+ * @param reply - The reply payload.
450
+ * @returns Result of the dispatch.
451
+ */
452
+ replyTo?(email: InboundEmail, reply: InboundEmailReply): Promise<InboundEmailReplyResult>
453
+ /**
454
+ * Indicates whether the provider supports outbound reply dispatch via
455
+ * {@link replyTo}. Implementations SHOULD return a stable `true` /
456
+ * `false` based on their own configuration; the property is a function
457
+ * so providers can defer to runtime configuration if needed.
458
+ *
459
+ * @returns `true` when {@link replyTo} is implemented and ready to use.
460
+ */
461
+ supportsReply(): boolean
462
+ }
463
+ ````
464
+
465
+ #### `InboundEmailReply`
466
+
467
+ Outgoing reply produced by handler code in response to an
468
+ {@link InboundEmail}. Providers that support the optional
469
+ {@link InboundEmailProvider.replyTo} method translate this into whatever
470
+ outbound mechanism their upstream offers (Mailgun reply route, SES
471
+ SendEmail, etc.).
472
+
473
+ For providers that do NOT expose an outbound reply path, handler code
474
+ SHOULD fall back to the regular `@molecule/api-emails` outbound bond.
475
+
476
+ ```typescript
477
+ interface InboundEmailReply {
478
+ /**
479
+ * Subject line for the outbound reply. If omitted, providers SHOULD
480
+ * default to the original subject prefixed with `'Re: '` (locale-aware
481
+ * prefixing is the caller's responsibility).
482
+ */
483
+ subject?: string
484
+ /**
485
+ * Plain-text body of the reply, if any.
486
+ */
487
+ textBody?: string
488
+ /**
489
+ * HTML body of the reply, if any.
490
+ */
491
+ htmlBody?: string
492
+ /**
493
+ * Attachments to send with the reply.
494
+ */
495
+ attachments?: InboundEmailAttachment[]
496
+ /**
497
+ * Optional override for the `From:` address. Defaults to the address
498
+ * the original message was sent to (the inbound mailbox).
499
+ */
500
+ from?: string
501
+ /**
502
+ * Optional additional headers to set on the outbound message.
503
+ */
504
+ headers?: Record<string, string>
505
+ }
506
+ ```
507
+
508
+ #### `InboundEmailReplyResult`
509
+
510
+ Result of a successful reply dispatch via
511
+ {@link InboundEmailProvider.replyTo}.
512
+
513
+ ```typescript
514
+ interface InboundEmailReplyResult {
515
+ /**
516
+ * Provider-supplied identifier for the dispatched outbound message.
517
+ */
518
+ id: string
519
+ }
520
+ ```
521
+
522
+ ### Classes
523
+
524
+ #### `AgentMailApiError`
525
+
526
+ A non-2xx response from the AgentMail API, carrying the documented error
527
+ envelope's `code` / `name` / `fix` and — for `429` — the `Retry-After`
528
+ delay. The message never includes the API key.
529
+
530
+ ### Functions
531
+
532
+ #### `_resetInboxMemo()`
533
+
534
+ Clears the in-process inbox record. Exposed for tests.
535
+
536
+ ```typescript
537
+ function _resetInboxMemo(): void
538
+ ```
539
+
540
+ #### `agentMailRequest(method, path, body)`
541
+
542
+ Performs one authenticated JSON call against the AgentMail API.
543
+
544
+ ```typescript
545
+ function agentMailRequest(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T>
546
+ ```
547
+
548
+ - `method` — HTTP method.
549
+ - `path` — Path under the base URL (must start with `/`).
550
+ - `body` — Optional JSON request body.
551
+
552
+ **Returns:** The parsed JSON response.
553
+
554
+ #### `bodyToString(body)`
555
+
556
+ Coerces the request body into a UTF-8 string. Buffers are decoded as
557
+ UTF-8 (AgentMail POSTs `application/json`), strings are returned as-is.
558
+
559
+ ```typescript
560
+ function bodyToString(body: string | Buffer<ArrayBufferLike>): string
561
+ ```
562
+
563
+ - `body` — The raw body.
564
+
565
+ **Returns:** The body as a UTF-8 string.
566
+
567
+ #### `buildSignedContent(id, timestamp, body)`
568
+
569
+ Builds the exact bytes Svix signs: `${id}.${timestamp}.` followed by the
570
+ raw request body, unchanged. Returned as a Buffer so a body that is not
571
+ valid UTF-8 still signs byte-for-byte.
572
+
573
+ ```typescript
574
+ function buildSignedContent(
575
+ id: string,
576
+ timestamp: string,
577
+ body: string | Buffer<ArrayBufferLike>,
578
+ ): Buffer<ArrayBufferLike>
579
+ ```
580
+
581
+ - `id` — The `svix-id` header value.
582
+ - `timestamp` — The `svix-timestamp` header value (as received).
583
+ - `body` — The raw request body.
584
+
585
+ **Returns:** The signed content.
586
+
587
+ #### `decodeWebhookSecret(secret)`
588
+
589
+ Decodes a Svix-style signing secret into raw key bytes: strip the
590
+ `whsec_` prefix, then base64-decode the remainder. A secret without the
591
+ prefix is base64-decoded as-is (the Svix libraries do the same).
592
+
593
+ ```typescript
594
+ function decodeWebhookSecret(secret: string): Buffer<ArrayBufferLike>
595
+ ```
596
+
597
+ - `secret` — The signing secret as configured.
598
+
599
+ **Returns:** The HMAC key bytes (empty when the secret decodes to nothing).
600
+
601
+ #### `downloadAttachment(inboxId, messageId, attachmentId)`
602
+
603
+ Downloads an attachment's bytes: resolves the presigned `download_url`
604
+ via {@link getAttachmentDownload}, then GETs it. The presigned request
605
+ deliberately carries NO `Authorization` header — the URL is self-
606
+ authenticating, and object stores reject a request that presents two
607
+ auth mechanisms at once.
608
+
609
+ ```typescript
610
+ function downloadAttachment(
611
+ inboxId: string,
612
+ messageId: string,
613
+ attachmentId: string,
614
+ ): Promise<{ meta: AgentMailAttachmentDownload; content: Buffer }>
615
+ ```
616
+
617
+ - `inboxId` — The inbox id.
618
+ - `messageId` — The message id, exactly as AgentMail supplied it.
619
+ - `attachmentId` — The attachment id from the message's metadata.
620
+
621
+ **Returns:** The metadata and the raw bytes.
622
+
623
+ #### `getApiKey()`
624
+
625
+ Reads the AgentMail API key from the environment, throwing the tagged
626
+ `config.notConfigured` error (never revealing any value) when unset.
627
+
628
+ ```typescript
629
+ function getApiKey(): string
630
+ ```
631
+
632
+ **Returns:** The API key.
633
+
634
+ #### `getAttachmentDownload(inboxId, messageId, attachmentId)`
635
+
636
+ Fetches an attachment's metadata + presigned `download_url`.
637
+
638
+ ```typescript
639
+ function getAttachmentDownload(
640
+ inboxId: string,
641
+ messageId: string,
642
+ attachmentId: string,
643
+ ): Promise<AgentMailAttachmentDownload>
644
+ ```
645
+
646
+ - `inboxId` — The inbox id.
647
+ - `messageId` — The message id, exactly as AgentMail supplied it.
648
+ - `attachmentId` — The attachment id from the message's metadata.
649
+
650
+ **Returns:** The attachment metadata and download URL.
651
+
652
+ #### `getBaseUrl()`
653
+
654
+ Resolves the API base URL: `AGENTMAIL_BASE_URL` when set (trailing
655
+ slashes stripped), else {@link DEFAULT_BASE_URL}.
656
+
657
+ ```typescript
658
+ function getBaseUrl(): string
659
+ ```
660
+
661
+ **Returns:** The base URL without a trailing slash.
662
+
663
+ #### `getHeader(headers, name)`
664
+
665
+ Returns the value of `headers[name]` (case-insensitive) coerced to a
666
+ single string.
667
+
668
+ ```typescript
669
+ function getHeader(
670
+ headers: Record<string, string | string[] | undefined>,
671
+ name: string,
672
+ ): string | undefined
673
+ ```
674
+
675
+ - `headers` — The headers object.
676
+ - `name` — The header name (case-insensitive).
677
+
678
+ **Returns:** The header value as a single string, or `undefined` if absent.
679
+
680
+ #### `getMessage(inboxId, messageId)`
681
+
682
+ Fetches a full message — used to hydrate `text` / `html` when the webhook
683
+ payload omitted them (AgentMail drops both once the payload would exceed
684
+ 1 MB).
685
+
686
+ ```typescript
687
+ function getMessage(inboxId: string, messageId: string): Promise<AgentMailMessage>
688
+ ```
689
+
690
+ - `inboxId` — The inbox id.
691
+ - `messageId` — The message id, exactly as AgentMail supplied it.
692
+
693
+ **Returns:** The message.
694
+
695
+ #### `headerToString(value)`
696
+
697
+ Coerces an HTTP header value (which may be `string`, `string[]`, or
698
+ `undefined`) to a single string. Multi-value headers are joined with
699
+ `, ` per RFC 9110 §5.2.
700
+
701
+ ```typescript
702
+ function headerToString(value: string | string[] | undefined): string | undefined
703
+ ```
704
+
705
+ - `value` — The header value to coerce.
706
+
707
+ **Returns:** The header value as a single string, or `undefined` when the header was not present.
708
+
709
+ #### `isRecord(value)`
710
+
711
+ Narrowing guard for a plain JSON object.
712
+
713
+ ```typescript
714
+ function isRecord(value: unknown): boolean
715
+ ```
716
+
717
+ - `value` — Any value.
718
+
719
+ **Returns:** `true` when `value` is a non-null, non-array object.
720
+
721
+ #### `lowercaseHeaderMap(value)`
722
+
723
+ Recovers the core's normalized headers map (lowercased names,
724
+ multi-value headers as arrays) from AgentMail's `headers` object. Any
725
+ non-string value is skipped; a missing or malformed map yields `{}`.
726
+
727
+ ```typescript
728
+ function lowercaseHeaderMap(value: unknown): Record<string, string | string[]>
729
+ ```
730
+
731
+ - `value` — The raw `headers` field.
732
+
733
+ **Returns:** Normalized headers map.
734
+
735
+ #### `messagePath(inboxId, messageId)`
736
+
737
+ Path of a message resource. Both ids are URL-encoded — AgentMail's
738
+ `message_id` is the RFC 5322 Message-ID INCLUDING angle brackets and `@`.
739
+
740
+ ```typescript
741
+ function messagePath(inboxId: string, messageId: string): string
742
+ ```
743
+
744
+ - `inboxId` — The inbox id.
745
+ - `messageId` — The message id, exactly as AgentMail supplied it.
746
+
747
+ **Returns:** `/v0/inboxes/{inbox_id}/messages/{message_id}`.
748
+
749
+ #### `normalizeAddressList(value)`
750
+
751
+ Normalizes an address field that AgentMail types as `string` in one
752
+ place and `string[]` in another into a trimmed, non-empty string array.
753
+
754
+ ```typescript
755
+ function normalizeAddressList(value: unknown): string[]
756
+ ```
757
+
758
+ - `value` — The raw field value.
759
+
760
+ **Returns:** The addresses (empty when the field is absent or malformed).
761
+
762
+ #### `parseJsonBody(body)`
763
+
764
+ Parses a JSON request body. Accepts the raw bytes/string of the request
765
+ or an already-parsed object (Express's JSON middleware gives us the
766
+ latter when a JSON route captures the webhook).
767
+
768
+ ```typescript
769
+ function parseJsonBody(body: string | Buffer<ArrayBufferLike> | Record<string, unknown>): unknown
770
+ ```
771
+
772
+ - `body` — Raw body or pre-parsed object.
773
+
774
+ **Returns:** The parsed JSON value.
775
+
776
+ #### `parseRetryAfterSeconds(value)`
777
+
778
+ Parses a `Retry-After` header, which is equally valid as delta-seconds
779
+ (`'30'`) or an HTTP-date, into whole seconds from now.
780
+
781
+ ```typescript
782
+ function parseRetryAfterSeconds(value: string | null | undefined): number | undefined
783
+ ```
784
+
785
+ - `value` — The raw header value.
786
+
787
+ **Returns:** Seconds to wait (never negative), or `undefined` when absent or unparseable.
788
+
789
+ #### `parseSignatureHeader(value)`
790
+
791
+ Splits a `svix-signature` header — a space-delimited list of
792
+ `<version>,<base64>` entries (e.g. `v1,abc v1,def`) — into the `v1`
793
+ signatures. Entries of any other version are ignored, not rejected: Svix
794
+ may add versions and a receiver is expected to match on any one it
795
+ understands.
796
+
797
+ ```typescript
798
+ function parseSignatureHeader(value: string | undefined): string[]
799
+ ```
800
+
801
+ - `value` — The raw header value.
802
+
803
+ **Returns:** The base64 `v1` signatures (empty when none are present).
804
+
805
+ #### `parseTimestamp(value)`
806
+
807
+ Parses an ISO 8601 timestamp into a `Date`.
808
+
809
+ ```typescript
810
+ function parseTimestamp(value: unknown): Date | undefined
811
+ ```
812
+
813
+ - `value` — The raw field value.
814
+
815
+ **Returns:** The date, or `undefined` when absent or unparseable.
816
+
817
+ #### `parseWebhookPayload(_headers, body)`
818
+
819
+ Parses an AgentMail `message.received` webhook payload into a normalized
820
+ {@link InboundEmail}, hydrating through the API whatever the webhook
821
+ left out:
822
+
823
+ - **Bodies.** When BOTH `text` and `html` are absent (AgentMail drops
824
+ them once the payload would exceed 1 MB), the message is fetched via
825
+ `GET /v0/inboxes/{inbox_id}/messages/{message_id}`.
826
+ - **Attachments.** The webhook carries metadata only; each attachment's
827
+ bytes are downloaded (metadata → presigned URL → bytes).
828
+
829
+ Either needs `AGENTMAIL_API_KEY`; a message that needs neither makes no
830
+ network call at all. When `AGENTMAIL_INBOX_ID` is set, an event for any
831
+ other inbox is rejected.
832
+
833
+ `id` is AgentMail's `message_id` VERBATIM (the Message-ID with its angle
834
+ brackets — the exact string every per-message endpoint takes as its path
835
+ parameter); `messageId` is the same value without the brackets, for
836
+ threading headers. Both are stable across Svix redeliveries, so dedupe
837
+ on `id`.
838
+
839
+ ```typescript
840
+ function parseWebhookPayload(
841
+ _headers: Record<string, string | string[] | undefined>,
842
+ body: string | Buffer<ArrayBufferLike> | Record<string, unknown>,
843
+ ): Promise<InboundEmail>
844
+ ```
845
+
846
+ - `_headers` — HTTP headers (unused — AgentMail puts everything in the body).
847
+ - `body` — The raw JSON body, a string, or an already-parsed object.
848
+
849
+ **Returns:** The normalized inbound email.
850
+
851
+ #### `replyTo(email, reply)`
852
+
853
+ Dispatches a reply through AgentMail's reply endpoint from the inbox
854
+ that received the original message. AgentMail threads the reply itself
855
+ (`In-Reply-To`, `References`, subject), so `reply.subject` and
856
+ `reply.from` have no effect — the reply always comes from the inbox,
857
+ under the original subject.
858
+
859
+ ```typescript
860
+ function replyTo(email: InboundEmail, reply: InboundEmailReply): Promise<InboundEmailReplyResult>
861
+ ```
862
+
863
+ - `email` — The original inbound email being replied to.
864
+ - `reply` — The reply payload.
865
+
866
+ **Returns:** The reply dispatch result (`id` = the new message's Message-ID).
867
+
868
+ #### `replyToMessage(inboxId, messageId, body)`
869
+
870
+ Sends a reply to a message from the inbox that received it. AgentMail
871
+ threads the reply (`In-Reply-To` / `References` / subject) itself.
872
+
873
+ ```typescript
874
+ function replyToMessage(
875
+ inboxId: string,
876
+ messageId: string,
877
+ body: AgentMailReplyRequest,
878
+ ): Promise<AgentMailReplyResponse>
879
+ ```
880
+
881
+ - `inboxId` — The inbox id.
882
+ - `messageId` — The message id, exactly as AgentMail supplied it.
883
+ - `body` — The reply.
884
+
885
+ **Returns:** The created message's ids.
886
+
887
+ #### `safeEqualBase64(a, b)`
888
+
889
+ Constant-time comparison of two base64-encoded digests.
890
+
891
+ ```typescript
892
+ function safeEqualBase64(a: string, b: string): boolean
893
+ ```
894
+
895
+ - `a` — The first digest.
896
+ - `b` — The second digest.
897
+
898
+ **Returns:** `true` when the decoded bytes are equal.
899
+
900
+ #### `supportsReply()`
901
+
902
+ Indicates that this provider supports outbound reply dispatch via
903
+ {@link replyTo}. Replies use AgentMail's own API — no outbound
904
+ `@molecule/api-emails` transport is involved.
905
+
906
+ ```typescript
907
+ function supportsReply(): boolean
908
+ ```
909
+
910
+ **Returns:** Always `true`.
911
+
912
+ #### `unwrapMessageId(value)`
913
+
914
+ Strips surrounding angle brackets from a `Message-ID` value.
915
+
916
+ ```typescript
917
+ function unwrapMessageId(value: unknown): string | undefined
918
+ ```
919
+
920
+ - `value` — The raw value (with or without angle brackets).
921
+
922
+ **Returns:** The value without angle brackets, or `undefined` if input was empty or not a string.
923
+
924
+ #### `verifySignature(headers, body)`
925
+
926
+ Verifies an AgentMail (Svix) webhook signature: HMAC-SHA256 over
927
+ `${svix-id}.${svix-timestamp}.${rawBody}` keyed by the base64-decoded
928
+ `whsec_` secret, base64-encoded, matched against ANY `v1,…` entry of the
929
+ `svix-signature` header in constant time. The Standard-Webhooks aliases
930
+ `webhook-id` / `webhook-timestamp` / `webhook-signature` are accepted
931
+ too. Timestamps outside the replay window are rejected.
932
+
933
+ `body` MUST be the exact bytes received — a parsed-then-re-serialized
934
+ JSON body will not verify.
935
+
936
+ Distinguishes SERVER MISCONFIGURATION from a genuinely invalid webhook:
937
+ an unset `AGENTMAIL_WEBHOOK_SECRET` THROWS the tagged
938
+ `config.notConfigured` error (mapped by the API error middleware to a
939
+ clean 503) instead of returning `false`. Missing signature headers, a
940
+ stale timestamp and a tampered signature all resolve `false` (401) —
941
+ those ARE the "this request is not from AgentMail" class.
942
+
943
+ ```typescript
944
+ function verifySignature(
945
+ headers: Record<string, string | string[] | undefined>,
946
+ body: string | Buffer<ArrayBufferLike>,
947
+ ): Promise<boolean>
948
+ ```
949
+
950
+ - `headers` — HTTP headers; the three `svix-*` signing headers.
951
+ - `body` — Raw HTTP request body (JSON bytes, unchanged).
952
+
953
+ **Returns:** `true` when the signature verifies and the timestamp is fresh; `false` for a malformed/stale/forged webhook.
954
+
955
+ ### Constants
956
+
957
+ #### `agentMailInboundSecretDefinitions`
958
+
959
+ Secret definitions required by the AgentMail inbound-email bond.
960
+
961
+ ```typescript
962
+ const agentMailInboundSecretDefinitions: SecretDefinition[]
963
+ ```
964
+
965
+ #### `API_REQUEST_TIMEOUT_MS`
966
+
967
+ Timeout (ms) for a JSON API call. Bounds a hanging AgentMail endpoint so
968
+ the webhook handler fails (and AgentMail retries) instead of stalling.
969
+
970
+ ```typescript
971
+ const API_REQUEST_TIMEOUT_MS: 15000
972
+ ```
973
+
974
+ #### `ATTACHMENT_DOWNLOAD_TIMEOUT_MS`
975
+
976
+ Timeout (ms) for downloading one attachment's bytes from its presigned
977
+ URL. Larger than {@link API_REQUEST_TIMEOUT_MS} because it moves the
978
+ attachment payload, not a small JSON document.
979
+
980
+ ```typescript
981
+ const ATTACHMENT_DOWNLOAD_TIMEOUT_MS: 60000
982
+ ```
983
+
984
+ #### `DEFAULT_BASE_URL`
985
+
986
+ Default AgentMail API base URL (production).
987
+
988
+ ```typescript
989
+ const DEFAULT_BASE_URL: 'https://api.agentmail.to'
990
+ ```
991
+
992
+ #### `DEFAULT_REPLAY_WINDOW_SECONDS`
993
+
994
+ Default replay window for inbound webhook timestamps, in seconds.
995
+
996
+ AgentMail delivers webhooks through Svix, whose documented default
997
+ tolerance for `svix-timestamp` is five minutes.
998
+
999
+ ```typescript
1000
+ const DEFAULT_REPLAY_WINDOW_SECONDS: 300
1001
+ ```
1002
+
1003
+ #### `provider`
1004
+
1005
+ The AgentMail inbound-email provider implementing the
1006
+ {@link InboundEmailProvider} interface.
1007
+
1008
+ ```typescript
1009
+ const provider: InboundEmailProvider
1010
+ ```
1011
+
1012
+ #### `SIGNATURE_VERSION`
1013
+
1014
+ The only signature-scheme version this bond understands.
1015
+
1016
+ ```typescript
1017
+ const SIGNATURE_VERSION: 'v1'
1018
+ ```
1019
+
1020
+ #### `WEBHOOK_SECRET_PREFIX`
1021
+
1022
+ Prefix Svix puts on webhook signing secrets before the base64 key.
1023
+
1024
+ ```typescript
1025
+ const WEBHOOK_SECRET_PREFIX: 'whsec_'
1026
+ ```
1027
+
1028
+ ## Core Interface
1029
+
1030
+ Implements `@molecule/api-emails-inbound` interface.
1031
+
1032
+ ## Bond Wiring
1033
+
1034
+ Setup function to register this provider with the core interface:
1035
+
1036
+ ```typescript
1037
+ import { setProvider } from '@molecule/api-emails-inbound'
1038
+ import { provider } from '@molecule/api-emails-inbound-agentmail'
1039
+
1040
+ export function setupEmailsInboundAgentmail(): void {
1041
+ setProvider(provider)
1042
+ }
1043
+ ```
1044
+
1045
+ ## Injection Notes
1046
+
1047
+ ### Requirements
1048
+
1049
+ Peer dependencies:
1050
+
1051
+ - `@molecule/api-emails-inbound` ^1.0.1
1052
+ - `@molecule/api-secrets` ^1.0.1
1053
+
1054
+ ### Environment Variables
1055
+
1056
+ - `AGENTMAIL_API_KEY` _(required)_ — AgentMail API key
1057
+ - Setup: AgentMail console → API keys. The key must have access to the inbox that receives mail.
1058
+ - Get it here: [https://console.agentmail.to](https://console.agentmail.to)
1059
+ - Example: `am_...`
1060
+ - `AGENTMAIL_WEBHOOK_SECRET` _(required)_ — AgentMail webhook signing secret
1061
+ - Setup: Create a webhook for message.received in the AgentMail console and copy its signing secret (starts with whsec_).
1062
+ - Get it here: [https://docs.agentmail.to/webhook-verification](https://docs.agentmail.to/webhook-verification)
1063
+ - Example: `whsec_...`
1064
+ - `AGENTMAIL_INBOX_ID` _(optional)_ — Inbox id
1065
+ - Setup: Optional: the inbox replies are sent from when the webhook payload does not carry one.
1066
+ - Get it here: [https://console.agentmail.to](https://console.agentmail.to)
1067
+ - Example: `inbox_...`
1068
+
1069
+ ### Runtime Dependencies
1070
+
1071
+ - `@molecule/api-emails-inbound`
1072
+ - `@molecule/api-secrets`
1073
+
1074
+ - **The webhook route is PUBLIC and needs the RAW body.** Mount it outside
1075
+ any auth middleware and hand `verifySignature()` the exact bytes
1076
+ received — `express.raw({ type: 'application/json' })` on that route, or
1077
+ the body-parser bond's `req.rawBody`. A body that went through
1078
+ `express.json()` and was re-stringified will NOT verify.
1079
+ - **Nothing arrives until BOTH exist at AgentMail: the inbox
1080
+ (`POST /v0/inboxes`) and a webhook registered for it
1081
+ (`POST /v0/webhooks` with `url` + `event_types: ['message.received']`,
1082
+ optionally scoped by `inbox_ids`).** The create-webhook response's
1083
+ `secret` IS `AGENTMAIL_WEBHOOK_SECRET`. Subscribe this URL to
1084
+ `message.received*` only — any other event type (`message.sent`,
1085
+ `message.bounced`, …) makes `parseWebhookPayload()` throw.
1086
+ - `verifySignature()` THROWS the tagged `config.notConfigured` error
1087
+ (→ 503 via the API error middleware) when `AGENTMAIL_WEBHOOK_SECRET` is
1088
+ unset, and resolves `false` for a missing/stale/forged signature. Let the
1089
+ throw propagate — mapping it to the same 401 as a forged webhook hides a
1090
+ misconfigured server behind "invalid signature".
1091
+ - **`parseWebhookPayload()` may call the AgentMail API.** Attachments
1092
+ arrive as metadata only and are downloaded (metadata → presigned
1093
+ `download_url` → bytes); when both `text` and `html` are missing (the
1094
+ 1 MB payload cap) the message is fetched. Both need
1095
+ `AGENTMAIL_API_KEY` (tagged config error if unset) and count against
1096
+ AgentMail's per-key rate limit. A `429` surfaces as an
1097
+ `AgentMailApiError` with `retryAfterSeconds` — let it propagate as a
1098
+ 5xx so AgentMail redelivers later; never swallow it into a 200, which
1099
+ loses the mail. A message with bodies and no attachments makes no
1100
+ network call.
1101
+ - **Replies use AgentMail's reply endpoint, not `@molecule/api-emails`.**
1102
+ The reply is sent from the inbox that received the message and AgentMail
1103
+ threads it itself, so `reply.subject` and `reply.from` are ignored. The
1104
+ inbox is resolved from `AGENTMAIL_INBOX_ID`, else from the in-process
1105
+ record `parseWebhookPayload()` kept — set `AGENTMAIL_INBOX_ID` whenever
1106
+ a reply is sent from a later request or after a restart. When set it
1107
+ also makes `parseWebhookPayload()` reject events for any other inbox.
1108
+ - `InboundEmail.id` is AgentMail's `message_id` verbatim — the Message-ID
1109
+ INCLUDING angle brackets, which is also the path parameter of every
1110
+ per-message endpoint; `messageId` is the same value without brackets.
1111
+ Dedupe on `id`.
1112
+ - The sender field is documented under two spellings (`from` in the API
1113
+ reference, `from_` in the webhooks guide); both are read.
1114
+
1115
+ ## E2E Tests
1116
+
1117
+ Integration checklist — drive the real inbound endpoint (live preview, no
1118
+ mocks), adapt each item to this app's actual inbox/ticket/thread flows, and
1119
+ check every box off one by one. A box you can't check is an integration bug
1120
+ to fix — not a skip. COUNTERPARTY: the app can't receive a real email in the
1121
+ sandbox, so YOU play the mail provider — POST a realistic inbound-email
1122
+ webhook to the app's inbound endpoint with `sandbox_fetch` (curl runs inside
1123
+ the container). Model the payload on the BONDED provider's real format
1124
+ (Mailgun: form fields `sender`/`recipient`/`subject`/`body-plain`/`body-html`/
1125
+ `attachment-N` plus the `timestamp`/`token`/`signature` triple; SES/Postmark:
1126
+ their JSON), and the happy-path POST must carry a VALID signature — compute it
1127
+ the way the provider does (Mailgun signs HMAC-SHA256 of `timestamp+token` with
1128
+ `MAILGUN_API_KEY` inside the replay window; read the key from the Environment
1129
+ panel / `.env.molecule`). Never disable `verifySignature()` or mock
1130
+ `parseWebhookPayload()` to go green — that proves nothing.
1131
+
1132
+ - [ ] A signed sample webhook to the inbound endpoint parses into the
1133
+ normalized fields (from / to / subject / textBody / htmlBody) AND the app
1134
+ ACTS on it — it files the mail into the right place (creates a ticket, a
1135
+ comment on a thread, or a reply-thread) keyed off the recipient (`support@`)
1136
+ or a plus-address / thread token (`reply+<id>@`). Verify the CREATED record
1137
+ (a DB row, and it shows up in the UI) — not just a 200.
1138
+ - [ ] Routing is correct: an email to `support@` opens a NEW ticket, while
1139
+ `reply+<id>@` (or an `In-Reply-To` / `References` match) threads onto the
1140
+ EXISTING one — each lands in the right user's / conversation's place, never
1141
+ a stranger's.
1142
+ - [ ] Attachments survive: an inbound message with an attachment has it
1143
+ decoded from `attachments[].contentBase64` and stored on the app's OWN
1144
+ storage (the uploads bond), not left as a provider link — the stored file
1145
+ opens from the ticket.
1146
+ - [ ] Retries don't duplicate: re-POST the SAME webhook (providers retry slow
1147
+ / 5xx deliveries) and confirm handling is idempotent — one ticket, not two
1148
+ (dedupe on `id` / `messageId`).
1149
+ - [ ] Malformed / empty payloads (missing `body-plain`, no attachments, absent
1150
+ headers) are handled without a crash — a clean response, not a 500 stack
1151
+ trace.
1152
+ - [ ] SECURITY — the endpoint is AUTHENTICATED: a forged POST with a bad or
1153
+ missing signature (or a `timestamp` outside the replay window) is REJECTED
1154
+ (401) and creates NO record, so an attacker can't inject mail into another
1155
+ user's thread. A missing signing key is a DISTINCT 503, not a 401 — a
1156
+ server misconfig must not masquerade as an accepted or forged webhook.
1157
+ - [ ] SECURITY — the parsed `htmlBody` is sanitized before it is rendered
1158
+ anywhere: a `<script>` / `onerror=` in an inbound body must NOT execute when
1159
+ the ticket is viewed (no stored XSS from an inbound email body).