carlyemail 0.3.0 → 0.5.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.5.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
@@ -241,9 +253,14 @@ define(
241
253
  }
242
254
  if (!username) throw new UsageError("--username is required");
243
255
 
256
+ // Not prompted for. Two questions is a sign-up; three is a form, and this
257
+ // one has a default that works and a `carlyemail name` to change it later.
258
+ const display_name =
259
+ typeof ctx.flags["display-name"] === "string" ? ctx.flags["display-name"] : undefined;
260
+
244
261
  const out = await request(ctx.config, "POST", "/v0/agent/sign-up", {
245
262
  auth: false,
246
- body: { human_email, username, source: "cli" },
263
+ body: { human_email, username, source: "cli", display_name },
247
264
  });
248
265
 
249
266
  // Saved before anything else is printed: the key is returned exactly once
@@ -304,10 +321,37 @@ define("create", "Create an inbox", "carlyemail create --username support", asyn
304
321
  const username = ctx.positional[0] || required(ctx.flags, "username");
305
322
  const body = { username };
306
323
  if (typeof ctx.flags.domain === "string") body.domain = ctx.flags.domain;
324
+ if (typeof ctx.flags["display-name"] === "string") body.display_name = ctx.flags["display-name"];
307
325
  const inbox = await request(ctx.config, "POST", "/v0/inboxes", { body });
308
326
  emit(ctx, inbox, () => ctx.print(ok(bold(inbox.email))));
309
327
  });
310
328
 
329
+ define(
330
+ "name",
331
+ "Set the name recipients see beside an address",
332
+ 'carlyemail name support@carlyemail.com "Support Bot"',
333
+ async (ctx) => {
334
+ const inbox = ctx.positional[0];
335
+ if (!inbox) throw new UsageError('which inbox? carlyemail name <inbox> "Display Name"');
336
+ // Joined, not `positional[1]`: an unquoted `carlyemail name x@y.com Support
337
+ // Bot` is the obvious way to type this and would otherwise silently set
338
+ // "Support". Nothing after the address clears the name rather than being a
339
+ // usage error, so there is a way to take one off.
340
+ const display_name = ctx.positional.slice(1).join(" ");
341
+
342
+ const out = await request(ctx.config, "PATCH", `/v0/inboxes/${encodeURIComponent(inbox)}`, {
343
+ body: { display_name },
344
+ });
345
+ emit(ctx, out, () =>
346
+ ctx.print(
347
+ out.display_name
348
+ ? ok(`${bold(out.email)} now sends as ${bold(out.display_name)}`)
349
+ : ok(`${bold(out.email)} now sends with no display name`)
350
+ )
351
+ );
352
+ }
353
+ );
354
+
311
355
  define("delete", "Delete an inbox and its mail", "carlyemail delete support@carlyemail.com", async (ctx) => {
312
356
  const inbox = ctx.positional[0];
313
357
  if (!inbox) throw new UsageError("which inbox? carlyemail delete <inbox>");
@@ -571,6 +615,74 @@ define("webhooks", "List webhook endpoints", "carlyemail webhooks", async (ctx)
571
615
  });
572
616
  });
573
617
 
618
+ /** Print a domain's records with each one's status. */
619
+ function printRecords(ctx, domain) {
620
+ for (const r of domain.records || []) {
621
+ // The per-record status is the whole point: a stuck domain needs to say
622
+ // which record is wrong, not that something is.
623
+ const mark = r.status === "VALID" ? paint(32, "✓") : dim("·");
624
+ const priority = r.priority ? dim(` (priority ${r.priority})`) : "";
625
+ ctx.print(` ${mark} ${dim(r.type.padEnd(5))} ${r.name}`);
626
+ ctx.print(` ${dim("→")} ${r.value}${priority}`);
627
+ }
628
+ }
629
+
630
+ define(
631
+ "domain",
632
+ "Add a custom sending domain, or re-check one",
633
+ "carlyemail domain mail.yourcompany.com [--subdomains] [--check] [--zone]",
634
+ async (ctx) => {
635
+ const name = ctx.positional[0];
636
+ if (!name) throw new UsageError("carlyemail domain <name> [--check]");
637
+
638
+ /** `verify` and the zone file take an id; a person has a name. */
639
+ const findId = async () => {
640
+ const out = await request(ctx.config, "GET", "/v0/domains");
641
+ const found = (out.domains || []).find((d) => d.domain === name);
642
+ if (!found) throw new UsageError(`${name} is not on this account`);
643
+ return found.domain_id;
644
+ };
645
+
646
+ if (ctx.flags.zone) {
647
+ // Straight to stdout with nothing around it, so it can be redirected into
648
+ // a file and handed to a DNS provider's import button.
649
+ const id = await findId();
650
+ ctx.print(await request(ctx.config, "GET", `/v0/domains/${id}/zone-file`, { raw: true }));
651
+ return;
652
+ }
653
+
654
+ let domain = ctx.flags.check
655
+ ? await request(ctx.config, "POST", `/v0/domains/${await findId()}/verify`)
656
+ // `domain`, not `name`. The docs said `name` for months, which 422s.
657
+ : await request(ctx.config, "POST", "/v0/domains", {
658
+ body: { domain: name, subdomains_enabled: Boolean(ctx.flags.subdomains) },
659
+ });
660
+
661
+ // Create is idempotent and returns an existing domain unchanged. Make the
662
+ // flag useful there too: `domain example.com --subdomains` means enabled
663
+ // whether this is the first run or the fifth.
664
+ if (ctx.flags.subdomains && !domain.subdomains_enabled) {
665
+ domain = await request(ctx.config, "PATCH", `/v0/domains/${domain.domain_id}`, {
666
+ body: { subdomains_enabled: true },
667
+ });
668
+ }
669
+
670
+ emit(ctx, domain, () => {
671
+ const verified = domain.status === "VERIFIED";
672
+ ctx.print(`${verified ? paint(32, "✓") : dim("…")} ${bold(name)} ${dim(domain.status)}`);
673
+ if (verified) {
674
+ ctx.print(dim(` carlyemail create --username hello --domain ${name}`));
675
+ return;
676
+ }
677
+ ctx.print("");
678
+ printRecords(ctx, domain);
679
+ ctx.print("");
680
+ ctx.print(dim(` publish these, then: carlyemail domain ${name} --check`));
681
+ ctx.print(dim(` or take the file: carlyemail domain ${name} --zone > ${name}.zone`));
682
+ });
683
+ }
684
+ );
685
+
574
686
  define("domains", "List custom sending domains", "carlyemail domains", async (ctx) => {
575
687
  const out = await request(ctx.config, "GET", "/v0/domains");
576
688
  emit(ctx, out, () => {
@@ -642,6 +754,25 @@ define("mcp", "Print the MCP endpoint for Claude and other clients", "carlyemail
642
754
  ctx.print(dim("An API key works too: Authorization: Bearer <key>"));
643
755
  });
644
756
 
757
+ define(
758
+ "env",
759
+ "Export this account for an agent framework",
760
+ 'eval "$(carlyemail env)"',
761
+ async (ctx) => {
762
+ // Resolve the inbox through the active key instead of trusting saved CLI
763
+ // state. CARLYEMAIL_API_KEY may intentionally point at a different account,
764
+ // pod, or inbox than ~/.carlyemail/config.json.
765
+ const out = await request(ctx.config, "GET", "/v0/inboxes?limit=1&ascending=true");
766
+ const inbox = out?.inboxes?.[0]?.email;
767
+ if (!inbox) throw new UsageError("This key cannot see an inbox to export.");
768
+
769
+ // This command is the explicit escape hatch from the 0600 CLI config into
770
+ // a process environment. Normal commands continue to mask the credential.
771
+ ctx.print(`export CARLYEMAIL_API_KEY=${shellQuote(ctx.config.api_key)}`);
772
+ ctx.print(`export CARLYEMAIL_INBOX=${shellQuote(inbox)}`);
773
+ }
774
+ );
775
+
645
776
  // ---------------------------------------------------------------- help
646
777
 
647
778
  export function helpText() {
@@ -661,6 +792,7 @@ export function helpText() {
661
792
  bold("Getting started"),
662
793
  " carlyemail signup",
663
794
  " carlyemail verify 123456",
795
+ ' eval "$(carlyemail env)"' + dim(" # load the key and inbox into this shell"),
664
796
  " carlyemail send --from my-agent@agents.carlyemail.com \\",
665
797
  ' --to someone@example.com --subject Hi --text "Hello from an agent"',
666
798
  "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carlyemail",
3
- "version": "0.3.0",
3
+ "version": "0.5.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,8 +1,27 @@
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;
24
+ display_name?: string | null;
6
25
  source?: string | null;
7
26
  referrer?: string | null;
8
27
  }
@@ -127,6 +146,7 @@ export interface CreateInboxWebhookRequest {
127
146
  url: string;
128
147
  event_types?: Array<string> | null;
129
148
  client_id?: string | null;
149
+ headers?: Record<string, string> | null;
130
150
  }
131
151
 
132
152
  export interface CreateListEntryRequest {
@@ -145,6 +165,7 @@ export interface CreateWebhookRequest {
145
165
  client_id?: string | null;
146
166
  inbox_ids?: Array<string> | null;
147
167
  pod_ids?: Array<string> | null;
168
+ headers?: Record<string, string> | null;
148
169
  }
149
170
 
150
171
  export interface DomainOut {
@@ -537,6 +558,11 @@ export interface UpdateThreadResponse {
537
558
  labels: Array<string>;
538
559
  }
539
560
 
561
+ export interface UpdateWebhookHeadersRequest {
562
+ headers?: Record<string, string> | null;
563
+ remove_headers?: Array<string> | null;
564
+ }
565
+
540
566
  export interface UpdateWebhookRequest {
541
567
  event_types?: Array<string> | null;
542
568
  add_inbox_ids?: Array<string> | null;
@@ -561,6 +587,10 @@ export interface VerificationRecord {
561
587
  priority?: number | null;
562
588
  }
563
589
 
590
+ export interface WebhookHeadersResponse {
591
+ header_names: Array<string>;
592
+ }
593
+
564
594
  export interface WebhookOut {
565
595
  webhook_id: string;
566
596
  url: string;
@@ -590,6 +620,8 @@ export interface ClientOptions {
590
620
 
591
621
  export declare class Agent {
592
622
  signUp(body: AgentSignupRequest): Promise<AgentSignupResponse>;
623
+ signIn(body: AgentSigninRequest): Promise<AgentSigninResponse>;
624
+ verifySignIn(body: AgentSigninVerifyRequest): Promise<AgentSigninVerifyResponse>;
593
625
  verify(body: AgentVerifyRequest): Promise<AgentVerifyResponse>;
594
626
  }
595
627
 
@@ -727,6 +759,8 @@ export declare class Pods {
727
759
  getWebhook(webhookId: string, podId: string): Promise<WebhookOut>;
728
760
  updateWebhook(webhookId: string, podId: string, body: UpdateWebhookRequest): Promise<WebhookOut>;
729
761
  deleteWebhook(webhookId: string, podId: string): Promise<void>;
762
+ getWebhookHeaders(webhookId: string, podId: string): Promise<WebhookHeadersResponse>;
763
+ updateWebhookHeaders(webhookId: string, podId: string, body: UpdateWebhookHeadersRequest): Promise<void>;
730
764
  listApiKeys(podId: string, query?: { limit?: number | null; pageToken?: string | null }): Promise<ListApiKeysResponse>;
731
765
  createApiKey(podId: string, body: CreateApiKeyRequest): Promise<CreateApiKeyResponse>;
732
766
  deleteApiKey(apiKeyId: string, podId: string): Promise<void>;
@@ -748,11 +782,15 @@ export declare class Webhooks {
748
782
  get(webhookId: string): Promise<WebhookOut>;
749
783
  update(webhookId: string, body: UpdateWebhookRequest): Promise<WebhookOut>;
750
784
  delete(webhookId: string): Promise<void>;
785
+ getWebhookHeaders(webhookId: string): Promise<WebhookHeadersResponse>;
786
+ updateWebhookHeaders(webhookId: string, body: UpdateWebhookHeadersRequest): Promise<void>;
751
787
  listInbox(inboxId: string, query?: { limit?: number | null; pageToken?: string | null; ascending?: boolean }): Promise<ListWebhooksResponse>;
752
788
  createInbox(inboxId: string, body: CreateInboxWebhookRequest): Promise<WebhookOut>;
753
789
  getInbox(inboxId: string, webhookId: string): Promise<WebhookOut>;
754
790
  updateInbox(inboxId: string, webhookId: string, body: UpdateInboxWebhookRequest): Promise<WebhookOut>;
755
791
  deleteInbox(inboxId: string, webhookId: string): Promise<void>;
792
+ getInboxWebhookHeaders(inboxId: string, webhookId: string): Promise<WebhookHeadersResponse>;
793
+ updateInboxWebhookHeaders(inboxId: string, webhookId: string, body: UpdateWebhookHeadersRequest): Promise<void>;
756
794
  }
757
795
 
758
796
  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
+ }