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 +28 -4
- package/carlyemail.js +103 -3
- package/package.json +4 -1
- package/sdk.d.ts +37 -0
- package/sdk.js +49 -8
- package/webhooks.d.ts +35 -0
- package/webhooks.js +110 -0
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
|
-
**
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
+
"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
|
-
|
|
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
|
+
}
|