@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/LICENSE +115 -0
- package/README.md +1159 -0
- package/dist/api.d.ts +141 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +244 -0
- package/dist/api.js.map +1 -0
- package/dist/browser-guard.d.ts +2 -0
- package/dist/browser-guard.d.ts.map +1 -0
- package/dist/browser-guard.js +19 -0
- package/dist/browser-guard.js.map +1 -0
- package/dist/index.d.ts +71 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +71 -0
- package/dist/index.js.map +1 -0
- package/dist/provider.d.ts +117 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +417 -0
- package/dist/provider.js.map +1 -0
- package/dist/secrets.d.ts +17 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +52 -0
- package/dist/secrets.js.map +1 -0
- package/dist/types.d.ts +229 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/dist/utilities.d.ts +148 -0
- package/dist/utilities.d.ts.map +1 -0
- package/dist/utilities.js +257 -0
- package/dist/utilities.js.map +1 -0
- package/package.json +53 -0
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).
|