mymx 0.3.5 → 0.3.7

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/dist/index.cjs DELETED
@@ -1,869 +0,0 @@
1
- "use strict";
2
- const require_errors = require('./errors-DOkb_I6u.cjs');
3
- const require_signing = require('./signing-Bqe14lp0.cjs');
4
- const require_zod = require('./zod-CyQz4zEa.cjs');
5
- const node_crypto = require_errors.__toESM(require("node:crypto"));
6
-
7
- //#region src/schema.generated.ts
8
- const emailReceivedEventJsonSchema = {
9
- "$schema": "http://json-schema.org/draft-07/schema#",
10
- "$ref": "#/definitions/EmailReceivedEvent",
11
- "definitions": {
12
- "EmailReceivedEvent": {
13
- "type": "object",
14
- "properties": {
15
- "id": {
16
- "type": "string",
17
- "description": "Unique delivery event ID.\n\nThis ID is stable across retries to the same endpoint - use it as your idempotency/dedupe key. Note that the same email delivered to different endpoints will have different event IDs.\n\nFormat: `evt_` prefix followed by a SHA-256 hash (64 hex characters). Example: `evt_a1b2c3d4e5f6...` (68 characters total)"
18
- },
19
- "event": {
20
- "type": "string",
21
- "const": "email.received",
22
- "description": "Event type identifier. Always `\"email.received\"` for this event type."
23
- },
24
- "version": {
25
- "$ref": "#/definitions/WebhookVersion",
26
- "description": "API version in date format (YYYY-MM-DD). Use this to detect version mismatches between webhook and SDK."
27
- },
28
- "delivery": {
29
- "type": "object",
30
- "properties": {
31
- "endpoint_id": {
32
- "type": "string",
33
- "description": "ID of the webhook endpoint receiving this event. Matches the endpoint ID from your MyMX dashboard."
34
- },
35
- "attempt": {
36
- "type": "number",
37
- "description": "Delivery attempt number, starting at 1. Increments with each retry if previous attempts failed."
38
- },
39
- "attempted_at": {
40
- "type": "string",
41
- "description": "ISO 8601 timestamp (UTC) when this delivery was attempted.",
42
- "examples": ["2025-01-15T10:30:00.000Z"]
43
- }
44
- },
45
- "required": [
46
- "endpoint_id",
47
- "attempt",
48
- "attempted_at"
49
- ],
50
- "additionalProperties": false,
51
- "description": "Metadata about this webhook delivery."
52
- },
53
- "email": {
54
- "type": "object",
55
- "properties": {
56
- "id": {
57
- "type": "string",
58
- "description": "Unique email ID in MyMX. Use this ID when calling MyMX APIs to reference this email."
59
- },
60
- "received_at": {
61
- "type": "string",
62
- "description": "ISO 8601 timestamp (UTC) when MyMX received the email.",
63
- "examples": ["2025-01-15T10:29:55.123Z"]
64
- },
65
- "smtp": {
66
- "type": "object",
67
- "properties": {
68
- "helo": {
69
- "type": ["string", "null"],
70
- "description": "HELO/EHLO hostname from the sending server. Null if not provided during SMTP transaction."
71
- },
72
- "mail_from": {
73
- "type": "string",
74
- "description": "SMTP envelope sender (MAIL FROM command). This is the bounce address, which may differ from the From header."
75
- },
76
- "rcpt_to": {
77
- "type": "array",
78
- "items": { "type": "string" },
79
- "description": "SMTP envelope recipients (RCPT TO commands). All addresses that received this email in a single delivery."
80
- }
81
- },
82
- "required": [
83
- "helo",
84
- "mail_from",
85
- "rcpt_to"
86
- ],
87
- "additionalProperties": false,
88
- "description": "SMTP envelope information. This is the \"real\" sender/recipient info from the SMTP transaction, which may differ from the headers (e.g., BCC recipients)."
89
- },
90
- "headers": {
91
- "type": "object",
92
- "properties": {
93
- "message_id": {
94
- "type": ["string", "null"],
95
- "description": "Message-ID header value. Null if the email had no Message-ID header."
96
- },
97
- "subject": {
98
- "type": ["string", "null"],
99
- "description": "Subject header value. Null if the email had no Subject header."
100
- },
101
- "from": {
102
- "type": "string",
103
- "description": "From header value. May include display name: `\"John Doe\" <john@example.com>`"
104
- },
105
- "to": {
106
- "type": "string",
107
- "description": "To header value. May include multiple addresses or display names."
108
- },
109
- "date": {
110
- "type": ["string", "null"],
111
- "description": "Date header value as it appeared in the email. Null if the email had no Date header."
112
- }
113
- },
114
- "required": [
115
- "message_id",
116
- "subject",
117
- "from",
118
- "to",
119
- "date"
120
- ],
121
- "additionalProperties": false,
122
- "description": "Parsed email headers. These are extracted from the email content, not the SMTP envelope."
123
- },
124
- "content": {
125
- "type": "object",
126
- "properties": {
127
- "raw": {
128
- "$ref": "#/definitions/RawContent",
129
- "description": "Raw email in RFC 5322 format. May be inline (base64) or download-only depending on size."
130
- },
131
- "download": {
132
- "type": "object",
133
- "properties": {
134
- "url": {
135
- "type": "string",
136
- "description": "HTTPS URL to download the raw email. Returns the email as-is in RFC 5322 format."
137
- },
138
- "expires_at": {
139
- "type": "string",
140
- "description": "ISO 8601 timestamp (UTC) when this URL expires. Download before this time or the URL will return 403."
141
- }
142
- },
143
- "required": ["url", "expires_at"],
144
- "additionalProperties": false,
145
- "description": "Download information for the raw email. Always present, even if raw content is inline."
146
- }
147
- },
148
- "required": ["raw", "download"],
149
- "additionalProperties": false,
150
- "description": "Raw email content and download information."
151
- },
152
- "parsed": {
153
- "$ref": "#/definitions/ParsedData",
154
- "description": "Parsed email content (body text, HTML, attachments). Check `status` to determine if parsing succeeded."
155
- },
156
- "analysis": {
157
- "type": "object",
158
- "properties": { "spamassassin": {
159
- "type": "object",
160
- "properties": { "score": {
161
- "type": "number",
162
- "description": "Overall spam score (sum of all rule scores). Higher scores indicate higher likelihood of spam. Unbounded - can be negative (ham) or very high (spam)."
163
- } },
164
- "required": ["score"],
165
- "additionalProperties": false,
166
- "description": "SpamAssassin analysis results."
167
- } },
168
- "required": ["spamassassin"],
169
- "additionalProperties": false,
170
- "description": "Email analysis and classification results. May be absent if analysis was not performed."
171
- }
172
- },
173
- "required": [
174
- "id",
175
- "received_at",
176
- "smtp",
177
- "headers",
178
- "content",
179
- "parsed"
180
- ],
181
- "additionalProperties": false,
182
- "description": "The email that triggered this event."
183
- }
184
- },
185
- "required": [
186
- "id",
187
- "event",
188
- "version",
189
- "delivery",
190
- "email"
191
- ],
192
- "additionalProperties": false,
193
- "description": "Webhook payload for the `email.received` event.\n\nThis is delivered to your webhook endpoint when MyMX receives an email matching your domain configuration."
194
- },
195
- "WebhookVersion": {
196
- "type": "string",
197
- "description": "Valid webhook version format (YYYY-MM-DD date string). The SDK accepts any valid date-formatted version, not just the current one, for forward and backward compatibility."
198
- },
199
- "RawContent": {
200
- "anyOf": [{ "$ref": "#/definitions/RawContentInline" }, { "$ref": "#/definitions/RawContentDownloadOnly" }],
201
- "description": "Raw email content - a discriminated union on `included`."
202
- },
203
- "RawContentInline": {
204
- "type": "object",
205
- "properties": {
206
- "included": {
207
- "type": "boolean",
208
- "const": true,
209
- "description": "Discriminant indicating raw content is included inline."
210
- },
211
- "encoding": {
212
- "type": "string",
213
- "const": "base64",
214
- "description": "Encoding used for the data field. Always \"base64\"."
215
- },
216
- "max_inline_bytes": {
217
- "type": "number",
218
- "description": "Maximum size in bytes for inline inclusion. Emails larger than this threshold require download."
219
- },
220
- "size_bytes": {
221
- "type": "number",
222
- "description": "Actual size of the raw email in bytes."
223
- },
224
- "sha256": {
225
- "type": "string",
226
- "description": "SHA-256 hash of the raw email content (hex-encoded). Use this to verify integrity after base64 decoding."
227
- },
228
- "data": {
229
- "type": "string",
230
- "description": "Base64-encoded raw email (RFC 5322 format). Decode with `Buffer.from(data, 'base64')` in Node.js."
231
- }
232
- },
233
- "required": [
234
- "included",
235
- "encoding",
236
- "max_inline_bytes",
237
- "size_bytes",
238
- "sha256",
239
- "data"
240
- ],
241
- "additionalProperties": false,
242
- "description": "Raw email content included inline (base64 encoded).\n\nWhen the raw email is small enough (under {@link max_inline_bytes } ), it's included directly in the webhook payload for convenience."
243
- },
244
- "RawContentDownloadOnly": {
245
- "type": "object",
246
- "properties": {
247
- "included": {
248
- "type": "boolean",
249
- "const": false,
250
- "description": "Discriminant indicating raw content must be downloaded."
251
- },
252
- "reason_code": {
253
- "type": "string",
254
- "const": "size_exceeded",
255
- "description": "Reason the content wasn't included inline."
256
- },
257
- "max_inline_bytes": {
258
- "type": "number",
259
- "description": "Maximum size in bytes for inline inclusion. The email exceeded this threshold."
260
- },
261
- "size_bytes": {
262
- "type": "number",
263
- "description": "Actual size of the raw email in bytes."
264
- },
265
- "sha256": {
266
- "type": "string",
267
- "description": "SHA-256 hash of the raw email content (hex-encoded). Use this to verify integrity after download."
268
- }
269
- },
270
- "required": [
271
- "included",
272
- "reason_code",
273
- "max_inline_bytes",
274
- "size_bytes",
275
- "sha256"
276
- ],
277
- "additionalProperties": false,
278
- "description": "Raw email content not included (must be downloaded).\n\nWhen the raw email exceeds {@link max_inline_bytes } , it's not included in the webhook payload. Use the download URL from {@link EmailReceivedEvent.email.content.download } to fetch it."
279
- },
280
- "ParsedData": {
281
- "anyOf": [{ "$ref": "#/definitions/ParsedDataComplete" }, { "$ref": "#/definitions/ParsedDataFailed" }],
282
- "description": "Parsed email content - a discriminated union on `status`."
283
- },
284
- "ParsedDataComplete": {
285
- "type": "object",
286
- "properties": {
287
- "status": {
288
- "type": "string",
289
- "const": "complete",
290
- "description": "Discriminant indicating successful parsing."
291
- },
292
- "error": {
293
- "type": "null",
294
- "description": "Always null when parsing succeeds."
295
- },
296
- "body_text": {
297
- "type": ["string", "null"],
298
- "description": "Plain text body of the email. Null if the email had no text/plain part."
299
- },
300
- "body_html": {
301
- "type": ["string", "null"],
302
- "description": "HTML body of the email. Null if the email had no text/html part."
303
- },
304
- "reply_to": {
305
- "anyOf": [{
306
- "type": "array",
307
- "items": { "$ref": "#/definitions/EmailAddress" }
308
- }, { "type": "null" }],
309
- "description": "Parsed Reply-To header addresses. Null if the email had no Reply-To header."
310
- },
311
- "cc": {
312
- "anyOf": [{
313
- "type": "array",
314
- "items": { "$ref": "#/definitions/EmailAddress" }
315
- }, { "type": "null" }],
316
- "description": "Parsed CC header addresses. Null if the email had no CC header."
317
- },
318
- "bcc": {
319
- "anyOf": [{
320
- "type": "array",
321
- "items": { "$ref": "#/definitions/EmailAddress" }
322
- }, { "type": "null" }],
323
- "description": "Parsed BCC header addresses. Null if the email had no BCC header. Note: BCC is only available for outgoing emails or when explicitly provided."
324
- },
325
- "in_reply_to": {
326
- "anyOf": [{
327
- "type": "array",
328
- "items": { "type": "string" }
329
- }, { "type": "null" }],
330
- "description": "In-Reply-To header values (Message-IDs of the email(s) being replied to). Null if the email had no In-Reply-To header. Per RFC 5322, this can contain multiple Message-IDs, though typically just one.",
331
- "examples": [["<original-message-id@example.com>"]]
332
- },
333
- "references": {
334
- "anyOf": [{
335
- "type": "array",
336
- "items": { "type": "string" }
337
- }, { "type": "null" }],
338
- "description": "References header values (Message-IDs of the email thread). Null if the email had no References header.",
339
- "examples": [["<msg1@example.com>", "<msg2@example.com>"]]
340
- },
341
- "attachments": {
342
- "type": "array",
343
- "items": { "$ref": "#/definitions/WebhookAttachment" },
344
- "description": "List of attachments with metadata. Use {@link attachments_download_url } to download the actual files."
345
- },
346
- "attachments_download_url": {
347
- "type": ["string", "null"],
348
- "description": "HTTPS URL to download all attachments as a tar.gz archive. Null if the email had no attachments. URL expires - check the expiration before downloading."
349
- }
350
- },
351
- "required": [
352
- "status",
353
- "error",
354
- "body_text",
355
- "body_html",
356
- "reply_to",
357
- "cc",
358
- "bcc",
359
- "in_reply_to",
360
- "references",
361
- "attachments",
362
- "attachments_download_url"
363
- ],
364
- "additionalProperties": false,
365
- "description": "Parsed email content when parsing succeeded.\n\nUse the discriminant `status: \"complete\"` to narrow from {@link ParsedData } ."
366
- },
367
- "EmailAddress": {
368
- "type": "object",
369
- "properties": {
370
- "address": {
371
- "type": "string",
372
- "description": "The email address portion (e.g., \"john@example.com\").\n\nThis is the raw value from the email header with no validation applied. May contain unusual but valid formats like quoted local parts."
373
- },
374
- "name": {
375
- "type": ["string", "null"],
376
- "description": "The display name portion, if present. Null if the address had no display name.\n\nMay contain any characters including unicode, emoji, or special characters as they appeared in the original email header."
377
- }
378
- },
379
- "required": ["address", "name"],
380
- "additionalProperties": false,
381
- "description": "A parsed email address with optional display name.\n\nThis structure is used in the `parsed` section of the webhook payload (e.g., `reply_to`, `cc`, `bcc`). For unparsed header strings, see the `headers` section (e.g., `event.email.headers.from`)."
382
- },
383
- "WebhookAttachment": {
384
- "type": "object",
385
- "properties": {
386
- "filename": {
387
- "type": ["string", "null"],
388
- "description": "Original filename from the email. May be null if the attachment had no filename specified."
389
- },
390
- "content_type": {
391
- "type": "string",
392
- "description": "MIME content type (e.g., \"application/pdf\", \"image/png\")."
393
- },
394
- "size_bytes": {
395
- "type": "number",
396
- "description": "Size of the attachment in bytes."
397
- },
398
- "sha256": {
399
- "type": "string",
400
- "description": "SHA-256 hash of the attachment content (hex-encoded). Use this to verify attachment integrity after download."
401
- },
402
- "part_index": {
403
- "type": "number",
404
- "description": "Zero-based index of this part in the MIME structure."
405
- },
406
- "tar_path": {
407
- "type": "string",
408
- "description": "Path to this attachment within the downloaded tar.gz archive."
409
- }
410
- },
411
- "required": [
412
- "filename",
413
- "content_type",
414
- "size_bytes",
415
- "sha256",
416
- "part_index",
417
- "tar_path"
418
- ],
419
- "additionalProperties": false,
420
- "description": "Metadata for an email attachment.\n\nAttachment content is not included directly in the webhook payload. Use the `attachments_download_url` from {@link ParsedDataComplete } to download all attachments as a tar.gz archive."
421
- },
422
- "ParsedDataFailed": {
423
- "type": "object",
424
- "properties": {
425
- "status": {
426
- "type": "string",
427
- "const": "failed",
428
- "description": "Discriminant indicating parsing failed."
429
- },
430
- "error": {
431
- "$ref": "#/definitions/ParsedError",
432
- "description": "Details about why parsing failed."
433
- },
434
- "body_text": {
435
- "type": "null",
436
- "description": "Always null when parsing fails."
437
- },
438
- "body_html": {
439
- "type": "null",
440
- "description": "Always null when parsing fails."
441
- },
442
- "reply_to": {
443
- "type": "null",
444
- "description": "Always null when parsing fails."
445
- },
446
- "cc": {
447
- "type": "null",
448
- "description": "Always null when parsing fails."
449
- },
450
- "bcc": {
451
- "type": "null",
452
- "description": "Always null when parsing fails."
453
- },
454
- "in_reply_to": {
455
- "type": "null",
456
- "description": "Always null when parsing fails."
457
- },
458
- "references": {
459
- "type": "null",
460
- "description": "Always null when parsing fails."
461
- },
462
- "attachments": {
463
- "type": "array",
464
- "items": { "$ref": "#/definitions/WebhookAttachment" },
465
- "description": "May contain partial attachment metadata even when parsing failed. Useful for debugging or recovering partial data."
466
- },
467
- "attachments_download_url": {
468
- "type": "null",
469
- "description": "Always null when parsing fails."
470
- }
471
- },
472
- "required": [
473
- "status",
474
- "error",
475
- "body_text",
476
- "body_html",
477
- "reply_to",
478
- "cc",
479
- "bcc",
480
- "in_reply_to",
481
- "references",
482
- "attachments",
483
- "attachments_download_url"
484
- ],
485
- "additionalProperties": false,
486
- "description": "Parsed email content when parsing failed.\n\nUse the discriminant `status: \"failed\"` to narrow from {@link ParsedData } ."
487
- },
488
- "ParsedError": {
489
- "type": "object",
490
- "properties": {
491
- "code": {
492
- "type": "string",
493
- "enum": ["PARSE_FAILED", "ATTACHMENT_EXTRACTION_FAILED"],
494
- "description": "Error code indicating the type of failure.\n- `PARSE_FAILED`: The email could not be parsed (e.g., malformed MIME)\n- `ATTACHMENT_EXTRACTION_FAILED`: Email parsed but attachments couldn't be extracted"
495
- },
496
- "message": {
497
- "type": "string",
498
- "description": "Human-readable error message describing what went wrong."
499
- },
500
- "retryable": {
501
- "type": "boolean",
502
- "description": "Whether retrying might succeed. If true, the error was transient (e.g., timeout). If false, the email itself is problematic."
503
- }
504
- },
505
- "required": [
506
- "code",
507
- "message",
508
- "retryable"
509
- ],
510
- "additionalProperties": false,
511
- "description": "Error details when email parsing fails."
512
- }
513
- }
514
- };
515
-
516
- //#endregion
517
- //#region src/parsing.ts
518
- /**
519
- * Parse a raw body string/Buffer into JSON with helpful error messages.
520
- *
521
- * Handles:
522
- * - Empty/whitespace bodies
523
- * - BOM (byte order mark) prefix stripping
524
- * - Detailed JSON syntax error messages with position
525
- *
526
- * @param rawBody - The raw request body (string or Buffer)
527
- * @returns The parsed JSON value
528
- * @throws WebhookPayloadError with helpful message on failure
529
- * @internal
530
- */
531
- function parseJsonBody(rawBody) {
532
- const bodyStr = typeof rawBody === "string" ? rawBody : require_signing.bufferToString(rawBody, "request body");
533
- if (!bodyStr || bodyStr.trim() === "") throw new require_errors.WebhookPayloadError("PAYLOAD_EMPTY_BODY", "Received empty request body", "The request body is empty. Check your web framework is correctly passing the request body.");
534
- try {
535
- const cleanBody = bodyStr.charCodeAt(0) === 65279 ? bodyStr.slice(1) : bodyStr;
536
- return JSON.parse(cleanBody);
537
- } catch (e) {
538
- const jsonError = e;
539
- const positionMatch = jsonError.message.match(/position\s*(\d+)/i);
540
- const position = positionMatch?.[1];
541
- throw new require_errors.WebhookPayloadError("JSON_PARSE_FAILED", "Failed to parse webhook body as JSON", position ? `Invalid JSON at position ${position}. Check your web framework isn't truncating the request body.` : `Invalid JSON: ${jsonError.message}. Check the raw request body is valid JSON.`, jsonError);
542
- }
543
- }
544
-
545
- //#endregion
546
- //#region src/index.ts
547
- /**
548
- * Cast input to EmailReceivedEvent after verifying event type.
549
- * NOTE: This only checks the event field, not the full schema.
550
- * For full validation, use handleWebhook() or validateEmailReceivedEvent().
551
- * @internal
552
- */
553
- function asEmailReceivedEvent(input) {
554
- const obj = input;
555
- if (obj.event !== "email.received") throw new require_errors.WebhookPayloadError("PAYLOAD_UNKNOWN_EVENT", `Unknown event type "${obj.event}"`, obj.event === void 0 ? "The 'event' field is undefined. Make sure the webhook payload is complete." : `Expected "email.received" event but got "${obj.event}".`);
556
- return input;
557
- }
558
- /**
559
- * Parse a webhook payload, returning typed events for known types
560
- * and UnknownEvent for future event types.
561
- *
562
- * This provides forward-compatibility: when MyMX adds new event types,
563
- * your code won't break - you'll receive an UnknownEvent that you can
564
- * handle or ignore.
565
- *
566
- * For most use cases, prefer `handleWebhook()` which also verifies
567
- * the signature and validates the payload schema.
568
- *
569
- * @param input - The parsed JSON payload
570
- * @returns Typed event for known types, UnknownEvent for unknown types
571
- * @throws WebhookPayloadError if the input is not a valid webhook structure
572
- *
573
- * @example
574
- * ```typescript
575
- * import { parseWebhookEvent } from 'mymx';
576
- *
577
- * const event = parseWebhookEvent(JSON.parse(rawBody));
578
- *
579
- * if (event.event === "email.received") {
580
- * // TypeScript knows this is EmailReceivedEvent
581
- * console.log(event.email.headers.subject);
582
- * } else {
583
- * // Handle or log unknown event types
584
- * console.log("Unknown event:", event.event);
585
- * }
586
- * ```
587
- */
588
- function parseWebhookEvent(input) {
589
- if (input === null) throw new require_errors.WebhookPayloadError("PAYLOAD_NULL", "Received null instead of webhook payload", "Check that your request body variable is defined.");
590
- if (input === void 0) throw new require_errors.WebhookPayloadError("PAYLOAD_UNDEFINED", "Received undefined instead of webhook payload", "Make sure you're passing the request body to parseWebhookEvent()");
591
- if (Array.isArray(input)) throw new require_errors.WebhookPayloadError("PAYLOAD_IS_ARRAY", "Received array instead of webhook payload object", "Webhook payloads must be objects, not arrays.");
592
- if (typeof input !== "object") throw new require_errors.WebhookPayloadError("PAYLOAD_WRONG_TYPE", `Received ${typeof input} instead of webhook payload object`, "Webhook payloads must be objects.");
593
- const obj = input;
594
- if (!("event" in obj) || typeof obj.event !== "string") throw new require_errors.WebhookPayloadError("PAYLOAD_MISSING_EVENT", "Missing 'event' field in payload", "This doesn't look like a MyMX webhook payload.");
595
- switch (obj.event) {
596
- case "email.received": return asEmailReceivedEvent(input);
597
- default: return input;
598
- }
599
- }
600
- /**
601
- * Type guard to check if a webhook event is an EmailReceivedEvent.
602
- *
603
- * @example
604
- * ```typescript
605
- * const event = parseWebhookEvent(payload);
606
- * if (isEmailReceivedEvent(event)) {
607
- * // TypeScript knows event is EmailReceivedEvent
608
- * console.log(event.email.headers.subject);
609
- * }
610
- * ```
611
- */
612
- function isEmailReceivedEvent(event) {
613
- return typeof event === "object" && event !== null && "event" in event && event.event === "email.received";
614
- }
615
- /**
616
- * Extract signature header from various header formats.
617
- */
618
- function getSignatureHeader(headers) {
619
- if (headers instanceof Headers) return headers.get("mymx-signature") ?? "";
620
- const obj = headers;
621
- const key = Object.keys(obj).find((k) => k.toLowerCase() === "mymx-signature");
622
- if (!key) return "";
623
- const value = obj[key];
624
- if (Array.isArray(value)) return value[0] ?? "";
625
- return value ?? "";
626
- }
627
- /**
628
- * Verify, parse, and validate a webhook in one call.
629
- *
630
- * This is the recommended way to handle MyMX webhooks. It:
631
- * 1. Verifies the signature to ensure the webhook is authentic
632
- * 2. Parses the JSON body
633
- * 3. Validates the payload against the schema with Zod
634
- * 4. Returns a fully typed EmailReceivedEvent
635
- *
636
- * @param options - The webhook data and secret
637
- * @returns A validated EmailReceivedEvent
638
- * @throws {WebhookVerificationError} If signature verification fails
639
- * @throws {WebhookPayloadError} If JSON parsing fails
640
- * @throws {WebhookValidationError} If schema validation fails
641
- *
642
- * @example
643
- * ```typescript
644
- * import { handleWebhook, MyMXWebhookError } from 'mymx';
645
- *
646
- * app.post('/webhooks/email', express.raw({ type: 'application/json' }), (req, res) => {
647
- * try {
648
- * const event = handleWebhook({
649
- * body: req.body,
650
- * headers: req.headers,
651
- * secret: process.env.MYMX_WEBHOOK_SECRET,
652
- * });
653
- *
654
- * console.log('Email from:', event.email.headers.from);
655
- * res.json({ received: true });
656
- * } catch (err) {
657
- * if (err instanceof MyMXWebhookError) {
658
- * console.error(`[${err.code}] ${err.message}`);
659
- * return res.status(400).json({ error: err.code });
660
- * }
661
- * throw err;
662
- * }
663
- * });
664
- * ```
665
- */
666
- function handleWebhook(options) {
667
- const { body, headers, secret, toleranceSeconds } = options;
668
- const signature = getSignatureHeader(headers);
669
- require_signing.verifyWebhookSignature({
670
- rawBody: body,
671
- signatureHeader: signature,
672
- secret,
673
- toleranceSeconds
674
- });
675
- const parsed = parseJsonBody(body);
676
- return require_zod.validateEmailReceivedEvent(parsed);
677
- }
678
- /**
679
- * Returns headers for the optional "content discard" feature.
680
- *
681
- * If you have the "content discard" setting enabled in your MyMX dashboard,
682
- * returning this header tells MyMX to permanently delete the email content
683
- * after successful delivery. Requires BOTH the dashboard setting AND this header.
684
- *
685
- * **Warning:** Only use this if you can durably guarantee you've processed the email.
686
- * Once discarded, the email content is gone forever.
687
- *
688
- * @returns Headers object to spread into your response
689
- *
690
- * @example Express (only if using content discard)
691
- * ```typescript
692
- * app.post('/webhook', (req, res) => {
693
- * const event = handleWebhook({ ... });
694
- * // Durably save the email first!
695
- * await db.saveEmail(event);
696
- * res.set(confirmedHeaders()).json({ received: true });
697
- * });
698
- * ```
699
- *
700
- * @example Fetch API / Next.js (only if using content discard)
701
- * ```typescript
702
- * return new Response(JSON.stringify({ received: true }), {
703
- * status: 200,
704
- * headers: {
705
- * 'Content-Type': 'application/json',
706
- * ...confirmedHeaders(),
707
- * },
708
- * });
709
- * ```
710
- */
711
- function confirmedHeaders() {
712
- return { [require_signing.MYMX_CONFIRMED_HEADER]: "true" };
713
- }
714
- /**
715
- * Check if the download URL for a webhook event has expired.
716
- *
717
- * @param event - The webhook event
718
- * @param now - Optional current time for testing (defaults to Date.now())
719
- * @returns true if the download URL has expired
720
- *
721
- * @example
722
- * ```typescript
723
- * if (isDownloadExpired(event)) {
724
- * console.log("Download URL has expired, cannot fetch raw email");
725
- * } else {
726
- * const response = await fetch(event.email.content.download.url);
727
- * }
728
- * ```
729
- */
730
- function isDownloadExpired(event, now = Date.now()) {
731
- const expiresAt = new Date(event.email.content.download.expires_at).getTime();
732
- return now >= expiresAt;
733
- }
734
- /**
735
- * Get the time remaining (in milliseconds) before the download URL expires.
736
- * Returns 0 if already expired.
737
- *
738
- * @param event - The webhook event
739
- * @param now - Optional current time for testing (defaults to Date.now())
740
- * @returns Milliseconds until expiration, or 0 if expired
741
- *
742
- * @example
743
- * ```typescript
744
- * const remaining = getDownloadTimeRemaining(event);
745
- * if (remaining > 60000) {
746
- * // More than 1 minute left, safe to download
747
- * }
748
- * ```
749
- */
750
- function getDownloadTimeRemaining(event, now = Date.now()) {
751
- const expiresAt = new Date(event.email.content.download.expires_at).getTime();
752
- return Math.max(0, expiresAt - now);
753
- }
754
- /**
755
- * Check if the raw email content is included inline in the event.
756
- *
757
- * Use this to check before calling `decodeRawEmail()` to avoid try/catch.
758
- *
759
- * @param event - The webhook event
760
- * @returns true if raw content is included inline, false if download required
761
- *
762
- * @example
763
- * ```typescript
764
- * if (isRawIncluded(event)) {
765
- * const rawEmail = decodeRawEmail(event);
766
- * } else {
767
- * const response = await fetch(event.email.content.download.url);
768
- * }
769
- * ```
770
- */
771
- function isRawIncluded(event) {
772
- return event.email.content.raw.included;
773
- }
774
- /**
775
- * Decode the raw email content from an EmailReceivedEvent.
776
- *
777
- * Throws if the raw content is not included inline (i.e., must be downloaded).
778
- * By default, verifies the SHA-256 hash matches after decoding.
779
- *
780
- * NOTE: This function assumes a well-formed event from `handleWebhook()`.
781
- * Passing a manually constructed event with missing fields (e.g., `raw.data`
782
- * undefined when `raw.included` is true) will result in undefined behavior.
783
- *
784
- * @param event - The webhook event containing the raw email
785
- * @param options - Decoding options
786
- * @returns The decoded raw email as a Buffer
787
- * @throws {RawEmailDecodeError} If content not included or hash mismatch
788
- *
789
- * @example
790
- * ```typescript
791
- * import { handleWebhook, decodeRawEmail, isRawIncluded } from 'mymx';
792
- *
793
- * const event = handleWebhook({ body, headers, secret });
794
- *
795
- * if (isRawIncluded(event)) {
796
- * const rawEmail = decodeRawEmail(event);
797
- * // rawEmail is a Buffer containing the RFC 5322 email
798
- * } else {
799
- * // Must download from event.email.content.download.url
800
- * }
801
- * ```
802
- */
803
- function decodeRawEmail(event, options = {}) {
804
- const { verify = true } = options;
805
- const raw = event.email.content.raw;
806
- if (!raw.included) throw new require_errors.RawEmailDecodeError("NOT_INCLUDED", `Raw email not included inline (size: ${raw.size_bytes} bytes, threshold: ${raw.max_inline_bytes} bytes). Download from: ${event.email.content.download.url}`);
807
- const decoded = Buffer.from(raw.data, "base64");
808
- if (verify) {
809
- const hash = (0, node_crypto.createHash)("sha256").update(decoded).digest("hex");
810
- if (hash !== raw.sha256) throw new require_errors.RawEmailDecodeError("HASH_MISMATCH", `SHA-256 hash mismatch. Expected: ${raw.sha256}, got: ${hash}. The raw email data may be corrupted.`);
811
- }
812
- return decoded;
813
- }
814
- /**
815
- * Verify downloaded raw email content against the SHA-256 hash in the event.
816
- *
817
- * Use this after fetching from `event.email.content.download.url` to ensure
818
- * the downloaded content matches what MyMX received.
819
- *
820
- * @param downloaded - The downloaded raw email content (Buffer, ArrayBuffer, or Uint8Array)
821
- * @param event - The webhook event containing the expected hash
822
- * @returns The verified content as a Buffer
823
- * @throws {RawEmailDecodeError} If hash doesn't match
824
- *
825
- * @example
826
- * ```typescript
827
- * import { handleWebhook, verifyRawEmailDownload, isRawIncluded } from 'mymx';
828
- *
829
- * const event = handleWebhook({ body, headers, secret });
830
- *
831
- * if (!isRawIncluded(event)) {
832
- * const response = await fetch(event.email.content.download.url);
833
- * const arrayBuffer = await response.arrayBuffer();
834
- * const verified = verifyRawEmailDownload(arrayBuffer, event);
835
- * // verified is a Buffer containing the RFC 5322 email
836
- * }
837
- * ```
838
- */
839
- function verifyRawEmailDownload(downloaded, event) {
840
- const buffer = Buffer.isBuffer(downloaded) ? downloaded : Buffer.from(downloaded);
841
- const hash = (0, node_crypto.createHash)("sha256").update(buffer).digest("hex");
842
- const expected = event.email.content.raw.sha256;
843
- if (hash !== expected) throw new require_errors.RawEmailDecodeError("HASH_MISMATCH", `SHA-256 hash mismatch. Expected: ${expected}, got: ${hash}. The downloaded content may be corrupted.`);
844
- return buffer;
845
- }
846
-
847
- //#endregion
848
- exports.MYMX_CONFIRMED_HEADER = require_signing.MYMX_CONFIRMED_HEADER
849
- exports.MYMX_SIGNATURE_HEADER = require_signing.MYMX_SIGNATURE_HEADER
850
- exports.MyMXWebhookError = require_errors.MyMXWebhookError
851
- exports.PAYLOAD_ERRORS = require_errors.PAYLOAD_ERRORS
852
- exports.RAW_EMAIL_ERRORS = require_errors.RAW_EMAIL_ERRORS
853
- exports.RawEmailDecodeError = require_errors.RawEmailDecodeError
854
- exports.VERIFICATION_ERRORS = require_errors.VERIFICATION_ERRORS
855
- exports.WEBHOOK_VERSION = require_errors.WEBHOOK_VERSION
856
- exports.WebhookPayloadError = require_errors.WebhookPayloadError
857
- exports.WebhookValidationError = require_errors.WebhookValidationError
858
- exports.WebhookVerificationError = require_errors.WebhookVerificationError
859
- exports.confirmedHeaders = confirmedHeaders
860
- exports.decodeRawEmail = decodeRawEmail
861
- exports.emailReceivedEventJsonSchema = emailReceivedEventJsonSchema
862
- exports.getDownloadTimeRemaining = getDownloadTimeRemaining
863
- exports.handleWebhook = handleWebhook
864
- exports.isDownloadExpired = isDownloadExpired
865
- exports.isEmailReceivedEvent = isEmailReceivedEvent
866
- exports.isRawIncluded = isRawIncluded
867
- exports.parseWebhookEvent = parseWebhookEvent
868
- exports.verifyRawEmailDownload = verifyRawEmailDownload
869
- exports.verifyWebhookSignature = require_signing.verifyWebhookSignature