carlyemail 0.3.0 → 0.4.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 CHANGED
@@ -62,6 +62,13 @@ npx carlyemail send \
62
62
  --text "Sent by an agent with its own address."
63
63
  ```
64
64
 
65
+ Load the saved key and its first inbox into the current shell before starting
66
+ LangChain, the Claude Agent SDK, Eve, Mastra, or another framework:
67
+
68
+ ```bash
69
+ eval "$(npx carlyemail env)"
70
+ ```
71
+
65
72
  Install it if you use it often:
66
73
 
67
74
  ```bash
@@ -75,6 +82,7 @@ npm install -g carlyemail
75
82
  | `signup --human-email <e> --username <u>` | Create an account and an inbox |
76
83
  | `verify <code>` | Confirm the owner email |
77
84
  | `whoami` | Identity and scope of the current key |
85
+ | `env` | Print `CARLYEMAIL_API_KEY` and `CARLYEMAIL_INBOX` shell exports |
78
86
  | `inboxes` | List inboxes |
79
87
  | `create --username <u>` | Create an inbox |
80
88
  | `delete <inbox> --yes` | Delete an inbox and its mail |
@@ -126,10 +134,10 @@ rather than a login.
126
134
 
127
135
  ## Notes
128
136
 
129
- **Sign-up is idempotent by owner email, and rotates the key.** Running it again
130
- for an address that already has an account issues a new key and **revokes the
131
- previous one**. It is how you recover a lost key; it is not a way to add a
132
- second inbox. Use `create` for that.
137
+ **An owner email is not a login.** Running sign-up again for an existing address
138
+ returns `account_exists` and leaves every key untouched. Recover access with an
139
+ emailed code at https://console.carlyemail.com; use `create` from an
140
+ authenticated CLI session to add another inbox.
133
141
 
134
142
  **`delete` requires `--yes`.** It removes the inbox and every message in it,
135
143
  and there is no undo.
@@ -152,6 +160,22 @@ Node 18 or newer. No dependencies — this is one file that uses Node's built-in
152
160
  `fetch`, so `npx` never resolves a tree and never fails for a reason unrelated
153
161
  to the thing you asked for.
154
162
 
163
+ ## Inbound email in a Worker
164
+
165
+ The dependency-free webhook helper uses Web Crypto, so the same verified
166
+ `onEmail` callback runs in Cloudflare Workers, Node, and other edge runtimes:
167
+
168
+ ```js
169
+ import { createEmailHandler } from "carlyemail/webhooks";
170
+
171
+ const handleEmail = createEmailHandler({
172
+ secret: env.CARLYEMAIL_WEBHOOK_SECRET,
173
+ async onEmail(event) {
174
+ console.log(event.message);
175
+ },
176
+ });
177
+ ```
178
+
155
179
  ## Licence
156
180
 
157
181
  MIT — see `LICENSE`. That covers **this CLI only**. The CarlyEmail service it
package/carlyemail.js CHANGED
@@ -18,7 +18,7 @@ import { join } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { randomBytes } from "node:crypto";
20
20
 
21
- export const VERSION = "0.3.0";
21
+ export const VERSION = "0.4.0";
22
22
 
23
23
  const DEFAULT_API = "https://api.carlyemail.com";
24
24
  const CONFIG_DIR = join(homedir(), ".carlyemail");
@@ -40,6 +40,11 @@ export function maskKey(key) {
40
40
  return `${key.slice(0, 9)}${"·".repeat(8)}${key.slice(-4)}`;
41
41
  }
42
42
 
43
+ /** Quote one value for POSIX shells. `env` is designed to be used with eval. */
44
+ export function shellQuote(value) {
45
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
46
+ }
47
+
43
48
  // ---------------------------------------------------------------- config
44
49
 
45
50
  /**
@@ -109,7 +114,12 @@ class UsageError extends Error {}
109
114
 
110
115
  // ---------------------------------------------------------------- transport
111
116
 
112
- export async function request(config, method, path, { body, auth = true, fetchImpl = fetch } = {}) {
117
+ export async function request(
118
+ config,
119
+ method,
120
+ path,
121
+ { body, auth = true, raw = false, fetchImpl = fetch } = {}
122
+ ) {
113
123
  const headers = { accept: "application/json" };
114
124
  if (body !== undefined) headers["content-type"] = "application/json";
115
125
  if (auth) {
@@ -146,7 +156,9 @@ export async function request(config, method, path, { body, auth = true, fetchIm
146
156
  }
147
157
  }
148
158
  if (!response.ok) throw new ApiError(response.status, parsed);
149
- return parsed;
159
+ // The zone file is text/plain, not our envelope. Parsing it returns null,
160
+ // which is how it silently printed nothing.
161
+ return raw ? text : parsed;
150
162
  }
151
163
 
152
164
  // ---------------------------------------------------------------- arguments
@@ -571,6 +583,74 @@ define("webhooks", "List webhook endpoints", "carlyemail webhooks", async (ctx)
571
583
  });
572
584
  });
573
585
 
586
+ /** Print a domain's records with each one's status. */
587
+ function printRecords(ctx, domain) {
588
+ for (const r of domain.records || []) {
589
+ // The per-record status is the whole point: a stuck domain needs to say
590
+ // which record is wrong, not that something is.
591
+ const mark = r.status === "VALID" ? paint(32, "✓") : dim("·");
592
+ const priority = r.priority ? dim(` (priority ${r.priority})`) : "";
593
+ ctx.print(` ${mark} ${dim(r.type.padEnd(5))} ${r.name}`);
594
+ ctx.print(` ${dim("→")} ${r.value}${priority}`);
595
+ }
596
+ }
597
+
598
+ define(
599
+ "domain",
600
+ "Add a custom sending domain, or re-check one",
601
+ "carlyemail domain mail.yourcompany.com [--subdomains] [--check] [--zone]",
602
+ async (ctx) => {
603
+ const name = ctx.positional[0];
604
+ if (!name) throw new UsageError("carlyemail domain <name> [--check]");
605
+
606
+ /** `verify` and the zone file take an id; a person has a name. */
607
+ const findId = async () => {
608
+ const out = await request(ctx.config, "GET", "/v0/domains");
609
+ const found = (out.domains || []).find((d) => d.domain === name);
610
+ if (!found) throw new UsageError(`${name} is not on this account`);
611
+ return found.domain_id;
612
+ };
613
+
614
+ if (ctx.flags.zone) {
615
+ // Straight to stdout with nothing around it, so it can be redirected into
616
+ // a file and handed to a DNS provider's import button.
617
+ const id = await findId();
618
+ ctx.print(await request(ctx.config, "GET", `/v0/domains/${id}/zone-file`, { raw: true }));
619
+ return;
620
+ }
621
+
622
+ let domain = ctx.flags.check
623
+ ? await request(ctx.config, "POST", `/v0/domains/${await findId()}/verify`)
624
+ // `domain`, not `name`. The docs said `name` for months, which 422s.
625
+ : await request(ctx.config, "POST", "/v0/domains", {
626
+ body: { domain: name, subdomains_enabled: Boolean(ctx.flags.subdomains) },
627
+ });
628
+
629
+ // Create is idempotent and returns an existing domain unchanged. Make the
630
+ // flag useful there too: `domain example.com --subdomains` means enabled
631
+ // whether this is the first run or the fifth.
632
+ if (ctx.flags.subdomains && !domain.subdomains_enabled) {
633
+ domain = await request(ctx.config, "PATCH", `/v0/domains/${domain.domain_id}`, {
634
+ body: { subdomains_enabled: true },
635
+ });
636
+ }
637
+
638
+ emit(ctx, domain, () => {
639
+ const verified = domain.status === "VERIFIED";
640
+ ctx.print(`${verified ? paint(32, "✓") : dim("…")} ${bold(name)} ${dim(domain.status)}`);
641
+ if (verified) {
642
+ ctx.print(dim(` carlyemail create --username hello --domain ${name}`));
643
+ return;
644
+ }
645
+ ctx.print("");
646
+ printRecords(ctx, domain);
647
+ ctx.print("");
648
+ ctx.print(dim(` publish these, then: carlyemail domain ${name} --check`));
649
+ ctx.print(dim(` or take the file: carlyemail domain ${name} --zone > ${name}.zone`));
650
+ });
651
+ }
652
+ );
653
+
574
654
  define("domains", "List custom sending domains", "carlyemail domains", async (ctx) => {
575
655
  const out = await request(ctx.config, "GET", "/v0/domains");
576
656
  emit(ctx, out, () => {
@@ -642,6 +722,25 @@ define("mcp", "Print the MCP endpoint for Claude and other clients", "carlyemail
642
722
  ctx.print(dim("An API key works too: Authorization: Bearer <key>"));
643
723
  });
644
724
 
725
+ define(
726
+ "env",
727
+ "Export this account for an agent framework",
728
+ 'eval "$(carlyemail env)"',
729
+ async (ctx) => {
730
+ // Resolve the inbox through the active key instead of trusting saved CLI
731
+ // state. CARLYEMAIL_API_KEY may intentionally point at a different account,
732
+ // pod, or inbox than ~/.carlyemail/config.json.
733
+ const out = await request(ctx.config, "GET", "/v0/inboxes?limit=1&ascending=true");
734
+ const inbox = out?.inboxes?.[0]?.email;
735
+ if (!inbox) throw new UsageError("This key cannot see an inbox to export.");
736
+
737
+ // This command is the explicit escape hatch from the 0600 CLI config into
738
+ // a process environment. Normal commands continue to mask the credential.
739
+ ctx.print(`export CARLYEMAIL_API_KEY=${shellQuote(ctx.config.api_key)}`);
740
+ ctx.print(`export CARLYEMAIL_INBOX=${shellQuote(inbox)}`);
741
+ }
742
+ );
743
+
645
744
  // ---------------------------------------------------------------- help
646
745
 
647
746
  export function helpText() {
@@ -661,6 +760,7 @@ export function helpText() {
661
760
  bold("Getting started"),
662
761
  " carlyemail signup",
663
762
  " carlyemail verify 123456",
763
+ ' eval "$(carlyemail env)"' + dim(" # load the key and inbox into this shell"),
664
764
  " carlyemail send --from my-agent@agents.carlyemail.com \\",
665
765
  ' --to someone@example.com --subject Hi --text "Hello from an agent"',
666
766
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carlyemail",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Real email inboxes your agent can send, receive and reply from. SDK and CLI.",
5
5
  "keywords": [
6
6
  "email",
@@ -27,12 +27,15 @@
27
27
  "types": "./sdk.d.ts",
28
28
  "exports": {
29
29
  ".": "./sdk.js",
30
+ "./webhooks": "./webhooks.js",
30
31
  "./cli": "./carlyemail.js"
31
32
  },
32
33
  "files": [
33
34
  "carlyemail.js",
34
35
  "sdk.js",
35
36
  "sdk.d.ts",
37
+ "webhooks.js",
38
+ "webhooks.d.ts",
36
39
  "README.md",
37
40
  "LICENSE"
38
41
  ],
package/sdk.d.ts CHANGED
@@ -1,5 +1,23 @@
1
1
  // Generated by scripts/build_sdk.py from the OpenAPI spec. Do not edit.
2
2
 
3
+ export interface AgentSigninRequest {
4
+ human_email: string;
5
+ }
6
+
7
+ export interface AgentSigninResponse {
8
+ accepted?: boolean;
9
+ }
10
+
11
+ export interface AgentSigninVerifyRequest {
12
+ human_email: string;
13
+ otp_code: string;
14
+ }
15
+
16
+ export interface AgentSigninVerifyResponse {
17
+ organization_id: string;
18
+ api_key: string;
19
+ }
20
+
3
21
  export interface AgentSignupRequest {
4
22
  human_email: string;
5
23
  username: string;
@@ -127,6 +145,7 @@ export interface CreateInboxWebhookRequest {
127
145
  url: string;
128
146
  event_types?: Array<string> | null;
129
147
  client_id?: string | null;
148
+ headers?: Record<string, string> | null;
130
149
  }
131
150
 
132
151
  export interface CreateListEntryRequest {
@@ -145,6 +164,7 @@ export interface CreateWebhookRequest {
145
164
  client_id?: string | null;
146
165
  inbox_ids?: Array<string> | null;
147
166
  pod_ids?: Array<string> | null;
167
+ headers?: Record<string, string> | null;
148
168
  }
149
169
 
150
170
  export interface DomainOut {
@@ -537,6 +557,11 @@ export interface UpdateThreadResponse {
537
557
  labels: Array<string>;
538
558
  }
539
559
 
560
+ export interface UpdateWebhookHeadersRequest {
561
+ headers?: Record<string, string> | null;
562
+ remove_headers?: Array<string> | null;
563
+ }
564
+
540
565
  export interface UpdateWebhookRequest {
541
566
  event_types?: Array<string> | null;
542
567
  add_inbox_ids?: Array<string> | null;
@@ -561,6 +586,10 @@ export interface VerificationRecord {
561
586
  priority?: number | null;
562
587
  }
563
588
 
589
+ export interface WebhookHeadersResponse {
590
+ header_names: Array<string>;
591
+ }
592
+
564
593
  export interface WebhookOut {
565
594
  webhook_id: string;
566
595
  url: string;
@@ -590,6 +619,8 @@ export interface ClientOptions {
590
619
 
591
620
  export declare class Agent {
592
621
  signUp(body: AgentSignupRequest): Promise<AgentSignupResponse>;
622
+ signIn(body: AgentSigninRequest): Promise<AgentSigninResponse>;
623
+ verifySignIn(body: AgentSigninVerifyRequest): Promise<AgentSigninVerifyResponse>;
593
624
  verify(body: AgentVerifyRequest): Promise<AgentVerifyResponse>;
594
625
  }
595
626
 
@@ -727,6 +758,8 @@ export declare class Pods {
727
758
  getWebhook(webhookId: string, podId: string): Promise<WebhookOut>;
728
759
  updateWebhook(webhookId: string, podId: string, body: UpdateWebhookRequest): Promise<WebhookOut>;
729
760
  deleteWebhook(webhookId: string, podId: string): Promise<void>;
761
+ getWebhookHeaders(webhookId: string, podId: string): Promise<WebhookHeadersResponse>;
762
+ updateWebhookHeaders(webhookId: string, podId: string, body: UpdateWebhookHeadersRequest): Promise<void>;
730
763
  listApiKeys(podId: string, query?: { limit?: number | null; pageToken?: string | null }): Promise<ListApiKeysResponse>;
731
764
  createApiKey(podId: string, body: CreateApiKeyRequest): Promise<CreateApiKeyResponse>;
732
765
  deleteApiKey(apiKeyId: string, podId: string): Promise<void>;
@@ -748,11 +781,15 @@ export declare class Webhooks {
748
781
  get(webhookId: string): Promise<WebhookOut>;
749
782
  update(webhookId: string, body: UpdateWebhookRequest): Promise<WebhookOut>;
750
783
  delete(webhookId: string): Promise<void>;
784
+ getWebhookHeaders(webhookId: string): Promise<WebhookHeadersResponse>;
785
+ updateWebhookHeaders(webhookId: string, body: UpdateWebhookHeadersRequest): Promise<void>;
751
786
  listInbox(inboxId: string, query?: { limit?: number | null; pageToken?: string | null; ascending?: boolean }): Promise<ListWebhooksResponse>;
752
787
  createInbox(inboxId: string, body: CreateInboxWebhookRequest): Promise<WebhookOut>;
753
788
  getInbox(inboxId: string, webhookId: string): Promise<WebhookOut>;
754
789
  updateInbox(inboxId: string, webhookId: string, body: UpdateInboxWebhookRequest): Promise<WebhookOut>;
755
790
  deleteInbox(inboxId: string, webhookId: string): Promise<void>;
791
+ getInboxWebhookHeaders(inboxId: string, webhookId: string): Promise<WebhookHeadersResponse>;
792
+ updateInboxWebhookHeaders(inboxId: string, webhookId: string, body: UpdateWebhookHeadersRequest): Promise<void>;
756
793
  }
757
794
 
758
795
  export declare class CarlyEmail {
package/sdk.js CHANGED
@@ -22,18 +22,19 @@ const DEFAULT_BASE_URL = "https://api.carlyemail.com";
22
22
 
23
23
  class Transport {
24
24
  constructor(options = {}) {
25
- const key =
25
+ // No key is a legal state: `agent.signUp` is how you get one, and demanding
26
+ // one to call it would be circular. Methods that need a key say so.
27
+ this.apiKey =
26
28
  options.apiKey ??
27
29
  (typeof process !== "undefined" ? process.env?.CARLYEMAIL_API_KEY : undefined);
28
- if (!key) {
29
- throw new Error("No API key. Pass { apiKey } or set CARLYEMAIL_API_KEY.");
30
- }
31
- this.apiKey = key;
32
30
  this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
33
31
  this.fetch = options.fetch ?? globalThis.fetch;
34
32
  }
35
33
 
36
- async request(method, path, { query, body } = {}) {
34
+ async request(method, path, { query, body, auth = true } = {}) {
35
+ if (auth && !this.apiKey) {
36
+ throw new Error("No API key. Pass { apiKey } or set CARLYEMAIL_API_KEY.");
37
+ }
37
38
  const url = new URL(this.baseUrl + path);
38
39
  for (const [name, value] of Object.entries(query ?? {})) {
39
40
  if (value === undefined || value === null) continue;
@@ -47,7 +48,7 @@ class Transport {
47
48
  const response = await this.fetch(url, {
48
49
  method,
49
50
  headers: {
50
- authorization: `Bearer ${this.apiKey}`,
51
+ ...(this.apiKey && auth ? { authorization: `Bearer ${this.apiKey}` } : {}),
51
52
  ...(body === undefined ? {} : { "content-type": "application/json" }),
52
53
  },
53
54
  body: body === undefined ? undefined : JSON.stringify(body),
@@ -74,7 +75,17 @@ class Agent {
74
75
 
75
76
  /** Sign Up */
76
77
  signUp(body) {
77
- return this.$.request("POST", `/v0/agent/sign-up`, { body });
78
+ return this.$.request("POST", `/v0/agent/sign-up`, { body, auth: false });
79
+ }
80
+
81
+ /** Sign In */
82
+ signIn(body) {
83
+ return this.$.request("POST", `/v0/agent/sign-in`, { body, auth: false });
84
+ }
85
+
86
+ /** Verify Sign In */
87
+ verifySignIn(body) {
88
+ return this.$.request("POST", `/v0/agent/sign-in/verify`, { body, auth: false });
78
89
  }
79
90
 
80
91
  /** Verify */
@@ -663,6 +674,16 @@ class Pods {
663
674
  return this.$.request("DELETE", `/v0/pods/${encode(podId)}/webhooks/${encode(webhookId)}`);
664
675
  }
665
676
 
677
+ /** Get Webhook Headers */
678
+ getWebhookHeaders(webhookId, podId) {
679
+ return this.$.request("GET", `/v0/pods/${encode(podId)}/webhooks/${encode(webhookId)}/headers`);
680
+ }
681
+
682
+ /** Update Webhook Headers */
683
+ updateWebhookHeaders(webhookId, podId, body) {
684
+ return this.$.request("PATCH", `/v0/pods/${encode(podId)}/webhooks/${encode(webhookId)}/headers`, { body });
685
+ }
686
+
666
687
  /** List Api Keys */
667
688
  listApiKeys(podId, query = {}) {
668
689
  return this.$.request("GET", `/v0/pods/${encode(podId)}/api-keys`, { query: { "limit": query.limit, "page_token": query.pageToken } });
@@ -741,6 +762,16 @@ class Webhooks {
741
762
  return this.$.request("DELETE", `/v0/webhooks/${encode(webhookId)}`);
742
763
  }
743
764
 
765
+ /** Get Webhook Headers */
766
+ getWebhookHeaders(webhookId) {
767
+ return this.$.request("GET", `/v0/webhooks/${encode(webhookId)}/headers`);
768
+ }
769
+
770
+ /** Update Webhook Headers */
771
+ updateWebhookHeaders(webhookId, body) {
772
+ return this.$.request("PATCH", `/v0/webhooks/${encode(webhookId)}/headers`, { body });
773
+ }
774
+
744
775
  /** List Inbox Webhooks */
745
776
  listInbox(inboxId, query = {}) {
746
777
  return this.$.request("GET", `/v0/inboxes/${encode(inboxId)}/webhooks`, { query: { "limit": query.limit, "page_token": query.pageToken, "ascending": query.ascending } });
@@ -765,6 +796,16 @@ class Webhooks {
765
796
  deleteInbox(inboxId, webhookId) {
766
797
  return this.$.request("DELETE", `/v0/inboxes/${encode(inboxId)}/webhooks/${encode(webhookId)}`);
767
798
  }
799
+
800
+ /** Get Inbox Webhook Headers */
801
+ getInboxWebhookHeaders(inboxId, webhookId) {
802
+ return this.$.request("GET", `/v0/inboxes/${encode(inboxId)}/webhooks/${encode(webhookId)}/headers`);
803
+ }
804
+
805
+ /** Update Inbox Webhook Headers */
806
+ updateInboxWebhookHeaders(inboxId, webhookId, body) {
807
+ return this.$.request("PATCH", `/v0/inboxes/${encode(inboxId)}/webhooks/${encode(webhookId)}/headers`, { body });
808
+ }
768
809
  }
769
810
 
770
811
  export class CarlyEmail {
package/webhooks.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ export interface CarlyEmailEvent {
2
+ type: "event";
3
+ event_type: string;
4
+ event_id: string;
5
+ message?: Record<string, unknown>;
6
+ thread?: Record<string, unknown>;
7
+ send?: Record<string, unknown>;
8
+ delivery?: Record<string, unknown>;
9
+ bounce?: Record<string, unknown>;
10
+ complaint?: Record<string, unknown>;
11
+ reject?: Record<string, unknown>;
12
+ domain?: Record<string, unknown>;
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ export interface VerifyWebhookOptions {
17
+ toleranceSeconds?: number;
18
+ /** Unix timestamp in seconds; intended for deterministic tests. */
19
+ now?: number;
20
+ }
21
+
22
+ export declare class WebhookVerificationError extends Error {}
23
+
24
+ export declare function verifyWebhook(
25
+ secret: string,
26
+ body: string | Uint8Array | ArrayBuffer,
27
+ headers: Headers | Record<string, string>,
28
+ options?: VerifyWebhookOptions,
29
+ ): Promise<CarlyEmailEvent>;
30
+
31
+ export declare function createEmailHandler(options: {
32
+ secret: string;
33
+ onEmail: (event: CarlyEmailEvent, request: Request) => unknown | Promise<unknown>;
34
+ toleranceSeconds?: number;
35
+ }): (request: Request) => Promise<Response>;
package/webhooks.js ADDED
@@ -0,0 +1,110 @@
1
+ // Webhook verification and an email-channel handler for Workers, Node, and edge
2
+ // runtimes. Web Crypto only; no Node-specific imports and no dependencies.
3
+
4
+ export class WebhookVerificationError extends Error {
5
+ constructor(message, options) {
6
+ super(message, options);
7
+ this.name = "WebhookVerificationError";
8
+ }
9
+ }
10
+
11
+ function header(headers, name) {
12
+ if (typeof headers?.get === "function") return headers.get(name);
13
+ const wanted = name.toLowerCase();
14
+ const found = Object.entries(headers ?? {}).find(([key]) => key.toLowerCase() === wanted);
15
+ return found?.[1];
16
+ }
17
+
18
+ function bytes(value) {
19
+ if (typeof value === "string") return new TextEncoder().encode(value);
20
+ if (value instanceof Uint8Array) return value;
21
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
22
+ throw new TypeError("webhook body must be a string, Uint8Array, or ArrayBuffer");
23
+ }
24
+
25
+ function decodeBase64(value, label) {
26
+ try {
27
+ const binary = atob(value);
28
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
29
+ } catch (error) {
30
+ throw new WebhookVerificationError(`${label} is not valid base64`, { cause: error });
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Verify a CarlyEmail/Svix webhook and return its parsed event body.
36
+ *
37
+ * The unmodified raw body is required. A framework-parsed JSON object cannot be
38
+ * reconstructed byte-for-byte and will fail the signature by design.
39
+ */
40
+ export async function verifyWebhook(secret, body, headers, options = {}) {
41
+ const messageId = header(headers, "webhook-id") ?? header(headers, "svix-id");
42
+ const timestamp = header(headers, "webhook-timestamp") ?? header(headers, "svix-timestamp");
43
+ const signatures =
44
+ header(headers, "webhook-signature") ?? header(headers, "svix-signature");
45
+ if (!messageId || !timestamp || !signatures) {
46
+ throw new WebhookVerificationError("missing webhook signature headers");
47
+ }
48
+
49
+ const sentAt = Number(timestamp);
50
+ if (!Number.isSafeInteger(sentAt)) {
51
+ throw new WebhookVerificationError("webhook timestamp is not an integer");
52
+ }
53
+ const now = options.now ?? Math.floor(Date.now() / 1000);
54
+ const toleranceSeconds = options.toleranceSeconds ?? 300;
55
+ if (Math.abs(now - sentAt) > toleranceSeconds) {
56
+ throw new WebhookVerificationError("webhook timestamp is outside the allowed window");
57
+ }
58
+
59
+ const rawBody = bytes(body);
60
+ const prefix = new TextEncoder().encode(`${messageId}.${timestamp}.`);
61
+ const signed = new Uint8Array(prefix.length + rawBody.length);
62
+ signed.set(prefix);
63
+ signed.set(rawBody, prefix.length);
64
+ const keyBytes = decodeBase64(secret.replace(/^whsec_/, ""), "webhook secret");
65
+ const key = await crypto.subtle.importKey(
66
+ "raw",
67
+ keyBytes,
68
+ { name: "HMAC", hash: "SHA-256" },
69
+ false,
70
+ ["verify"],
71
+ );
72
+
73
+ for (const candidate of signatures.split(/\s+/)) {
74
+ const [version, encoded] = candidate.split(",", 2);
75
+ if (version !== "v1" || !encoded) continue;
76
+ const signature = decodeBase64(encoded, "webhook signature");
77
+ if (await crypto.subtle.verify("HMAC", key, signature, signed)) {
78
+ return JSON.parse(new TextDecoder().decode(rawBody));
79
+ }
80
+ }
81
+ throw new WebhookVerificationError("webhook signature does not match");
82
+ }
83
+
84
+ /**
85
+ * Build a fetch-compatible handler with a Cloudflare-style `onEmail(event)`
86
+ * callback. Handler errors are allowed through so the delivery gets a 5xx and
87
+ * retries; only verification errors are turned into terminal 400 responses.
88
+ */
89
+ export function createEmailHandler({ secret, onEmail, toleranceSeconds = 300 }) {
90
+ if (!secret) throw new Error("createEmailHandler requires a webhook secret");
91
+ if (typeof onEmail !== "function") throw new TypeError("createEmailHandler requires onEmail");
92
+
93
+ return async function handle(request) {
94
+ const body = new Uint8Array(await request.arrayBuffer());
95
+ let event;
96
+ try {
97
+ event = await verifyWebhook(secret, body, request.headers, { toleranceSeconds });
98
+ } catch (error) {
99
+ if (error instanceof WebhookVerificationError) {
100
+ return new Response(error.message, { status: 400 });
101
+ }
102
+ throw error;
103
+ }
104
+
105
+ if (event.event_type?.startsWith("message.received")) {
106
+ await onEmail(event, request);
107
+ }
108
+ return new Response(null, { status: 204 });
109
+ };
110
+ }