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 +28 -4
- package/carlyemail.js +136 -4
- package/package.json +4 -1
- package/sdk.d.ts +38 -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.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(
|
|
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
|
|
@@ -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
|
+
"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
|
-
|
|
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
|
+
}
|