carlyemail 0.6.6 → 0.8.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 +12 -0
- package/carlyemail.js +33 -2
- package/package.json +1 -1
- package/sdk.d.ts +10 -0
- package/sdk.js +5 -0
- package/webhooks.d.ts +25 -2
- package/webhooks.js +85 -7
package/README.md
CHANGED
|
@@ -80,6 +80,7 @@ npm install -g carlyemail
|
|
|
80
80
|
| | |
|
|
81
81
|
|---|---|
|
|
82
82
|
| `signup --human-email <e> --username <u>` | Create an account and an inbox |
|
|
83
|
+
| `send-code` | Email the owner a code to confirm the account |
|
|
83
84
|
| `verify <code>` | Confirm the owner email |
|
|
84
85
|
| `whoami` | Identity and scope of the current key |
|
|
85
86
|
| `env` | Print `CARLYEMAIL_API_KEY` and `CARLYEMAIL_INBOX` shell exports |
|
|
@@ -170,12 +171,23 @@ import { createEmailHandler } from "carlyemail/webhooks";
|
|
|
170
171
|
|
|
171
172
|
const handleEmail = createEmailHandler({
|
|
172
173
|
secret: env.CARLYEMAIL_WEBHOOK_SECRET,
|
|
174
|
+
inbox: "frontdesk@carlyemail.com",
|
|
175
|
+
allowFrom: ["sam@gmail.com"],
|
|
173
176
|
async onEmail(event) {
|
|
174
177
|
console.log(event.message);
|
|
175
178
|
},
|
|
176
179
|
});
|
|
180
|
+
|
|
181
|
+
export default {
|
|
182
|
+
fetch: (request, env, ctx) => handleEmail(request, env, ctx),
|
|
183
|
+
};
|
|
177
184
|
```
|
|
178
185
|
|
|
186
|
+
Only `message.received` reaches `onEmail`: spam, blocked and unauthenticated
|
|
187
|
+
mail does not, nor does mail from the inbox itself, a sender outside
|
|
188
|
+
`allowFrom`, or a redelivery. The handler answers 202 and runs `onEmail` after,
|
|
189
|
+
through `ctx.waitUntil` when it is given one. A bad signature gets 401.
|
|
190
|
+
|
|
179
191
|
## Licence
|
|
180
192
|
|
|
181
193
|
MIT — see `LICENSE`. That covers **this CLI only**. The CarlyEmail service it
|
package/carlyemail.js
CHANGED
|
@@ -18,7 +18,7 @@ import { basename, extname, 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.8.0";
|
|
22
22
|
|
|
23
23
|
const DEFAULT_API = "https://api.carlyemail.com";
|
|
24
24
|
const CONFIG_DIR = join(homedir(), ".carlyemail");
|
|
@@ -416,7 +416,17 @@ define(
|
|
|
416
416
|
ctx.print(ok(bold(out.inbox_id)));
|
|
417
417
|
ctx.print(ok(`key saved to ${saved}`));
|
|
418
418
|
ctx.print("");
|
|
419
|
-
|
|
419
|
+
|
|
420
|
+
// Sign-up emails nothing by itself. The person at this terminal is the one
|
|
421
|
+
// asking, so ask for them; the key is already saved if this fails.
|
|
422
|
+
try {
|
|
423
|
+
await request(config, "POST", "/v0/agent/verification-code");
|
|
424
|
+
ctx.print(arrow(`check ${human_email} for a 6-digit code, then:`));
|
|
425
|
+
} catch {
|
|
426
|
+
ctx.print(arrow("the code did not send — try again with:"));
|
|
427
|
+
ctx.print(` carlyemail send-code`);
|
|
428
|
+
ctx.print(arrow("then:"));
|
|
429
|
+
}
|
|
420
430
|
ctx.print(` carlyemail verify <code>`);
|
|
421
431
|
ctx.print("");
|
|
422
432
|
ctx.print(dim(`Until the code is confirmed the account can only email ${human_email}.`));
|
|
@@ -462,6 +472,27 @@ define("verify", "Confirm the owner email with the code", "carlyemail verify 123
|
|
|
462
472
|
ctx.print(ok("verified — this account can send anywhere now"));
|
|
463
473
|
});
|
|
464
474
|
|
|
475
|
+
define(
|
|
476
|
+
"send-code",
|
|
477
|
+
"Email the owner a code to confirm the account",
|
|
478
|
+
"carlyemail send-code",
|
|
479
|
+
async (ctx) => {
|
|
480
|
+
const out = await request(ctx.config, "POST", "/v0/agent/verification-code");
|
|
481
|
+
if (out.verified) {
|
|
482
|
+
ctx.print(ok("already verified — this account can send anywhere"));
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
ctx.print(
|
|
486
|
+
arrow(
|
|
487
|
+
out.sent
|
|
488
|
+
? `check ${out.owner_email} for a 6-digit code, then:`
|
|
489
|
+
: `a code went to ${out.owner_email} in the last 10 minutes — use that one:`
|
|
490
|
+
)
|
|
491
|
+
);
|
|
492
|
+
ctx.print(` carlyemail verify <code>`);
|
|
493
|
+
}
|
|
494
|
+
);
|
|
495
|
+
|
|
465
496
|
define("whoami", "Show the key's identity and scope", "carlyemail whoami", async (ctx) => {
|
|
466
497
|
const me = await request(ctx.config, "GET", "/v0/auth/me");
|
|
467
498
|
emit(ctx, me, () => {
|
package/package.json
CHANGED
package/sdk.d.ts
CHANGED
|
@@ -43,6 +43,15 @@ export interface AgentSignupResponse {
|
|
|
43
43
|
organization_id: string;
|
|
44
44
|
inbox_id: string;
|
|
45
45
|
api_key: string;
|
|
46
|
+
verified?: boolean;
|
|
47
|
+
next_step?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AgentVerificationCodeResponse {
|
|
51
|
+
sent: boolean;
|
|
52
|
+
verified: boolean;
|
|
53
|
+
owner_email?: string | null;
|
|
54
|
+
detail: string;
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
export interface AgentVerifyRequest {
|
|
@@ -781,6 +790,7 @@ export declare class Agent {
|
|
|
781
790
|
signUp(body: AgentSignupRequest): Promise<AgentSignupResponse>;
|
|
782
791
|
signIn(body: AgentSigninRequest): Promise<AgentSigninResponse>;
|
|
783
792
|
verifySignIn(body: AgentSigninVerifyRequest): Promise<AgentSigninVerifyResponse>;
|
|
793
|
+
requestVerificationCode(): Promise<AgentVerificationCodeResponse>;
|
|
784
794
|
verify(body: AgentVerifyRequest): Promise<AgentVerifyResponse>;
|
|
785
795
|
}
|
|
786
796
|
|
package/sdk.js
CHANGED
|
@@ -88,6 +88,11 @@ class Agent {
|
|
|
88
88
|
return this.$.request("POST", `/v0/agent/sign-in/verify`, { body, auth: false });
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** Request Verification Code */
|
|
92
|
+
requestVerificationCode() {
|
|
93
|
+
return this.$.request("POST", `/v0/agent/verification-code`);
|
|
94
|
+
}
|
|
95
|
+
|
|
91
96
|
/** Verify */
|
|
92
97
|
verify(body) {
|
|
93
98
|
return this.$.request("POST", `/v0/agent/verify`, { body });
|
package/webhooks.d.ts
CHANGED
|
@@ -28,8 +28,31 @@ export declare function verifyWebhook(
|
|
|
28
28
|
options?: VerifyWebhookOptions,
|
|
29
29
|
): Promise<CarlyEmailEvent>;
|
|
30
30
|
|
|
31
|
-
export declare
|
|
31
|
+
export declare const RECEIVED: "message.received";
|
|
32
|
+
export declare const SPAM: "message.received.spam";
|
|
33
|
+
export declare const BLOCKED: "message.received.blocked";
|
|
34
|
+
export declare const UNAUTHENTICATED: "message.received.unauthenticated";
|
|
35
|
+
|
|
36
|
+
export interface CreateEmailHandlerOptions {
|
|
32
37
|
secret: string;
|
|
33
38
|
onEmail: (event: CarlyEmailEvent, request: Request) => unknown | Promise<unknown>;
|
|
39
|
+
/** The address being answered, so mail it sent itself is dropped. */
|
|
40
|
+
inbox?: string;
|
|
41
|
+
/** Who may write: `emma@example.com` or `@example.com`. Empty means anyone. */
|
|
42
|
+
allowFrom?: string[] | string;
|
|
43
|
+
/** Event types that reach `onEmail`, matched exactly. Defaults to `message.received`. */
|
|
44
|
+
eventTypes?: string[];
|
|
34
45
|
toleranceSeconds?: number;
|
|
35
|
-
|
|
46
|
+
/** Event ids held in memory to ignore redeliveries. Defaults to 2048. */
|
|
47
|
+
remember?: number;
|
|
48
|
+
/**
|
|
49
|
+
* `true` (the default) answers 202 and runs `onEmail` afterwards, handing it
|
|
50
|
+
* to a `waitUntil` found among the handler's later arguments. `false` runs it
|
|
51
|
+
* first and lets an exception become a 5xx so the delivery retries.
|
|
52
|
+
*/
|
|
53
|
+
background?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export declare function createEmailHandler(
|
|
57
|
+
options: CreateEmailHandlerOptions,
|
|
58
|
+
): (request: Request, ...context: unknown[]) => Promise<Response>;
|
package/webhooks.js
CHANGED
|
@@ -81,30 +81,108 @@ export async function verifyWebhook(secret, body, headers, options = {}) {
|
|
|
81
81
|
throw new WebhookVerificationError("webhook signature does not match");
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// The four ways a message arrives. They are separate event types rather than a
|
|
85
|
+
// flag on one type, so a subscription to RECEIVED alone never sees the other
|
|
86
|
+
// three. Match them exactly: `startsWith("message.received")` is true of all
|
|
87
|
+
// four, and hands spam and forged senders straight to the model.
|
|
88
|
+
export const RECEIVED = "message.received";
|
|
89
|
+
export const SPAM = "message.received.spam";
|
|
90
|
+
export const BLOCKED = "message.received.blocked";
|
|
91
|
+
export const UNAUTHENTICATED = "message.received.unauthenticated";
|
|
92
|
+
|
|
93
|
+
function addressOf(from) {
|
|
94
|
+
const value = String(from ?? "").trim();
|
|
95
|
+
const angled = value.match(/<([^<>]*)>\s*$/);
|
|
96
|
+
return (angled ? angled[1] : value).trim().toLowerCase();
|
|
97
|
+
}
|
|
98
|
+
|
|
84
99
|
/**
|
|
85
100
|
* Build a fetch-compatible handler with a Cloudflare-style `onEmail(event)`
|
|
86
|
-
* callback
|
|
87
|
-
*
|
|
101
|
+
* callback, making the same decisions as the Python `create_email_router`:
|
|
102
|
+
* verify the raw body, admit only the event types asked for (exactly), drop
|
|
103
|
+
* the inbox's own mail, check the sender, ignore a redelivery, and answer the
|
|
104
|
+
* delivery before the agent runs.
|
|
105
|
+
*
|
|
106
|
+
* A model turn can outlast the delivery timeout, and a timed-out delivery is
|
|
107
|
+
* retried, so by default the handler answers 202 and runs `onEmail` after. Pass
|
|
108
|
+
* the runtime's context as a later argument (`handle(request, env, ctx)` in a
|
|
109
|
+
* Worker) and the run goes to its `waitUntil`. `background: false` runs
|
|
110
|
+
* `onEmail` first and lets an exception become a 5xx so the delivery retries.
|
|
88
111
|
*/
|
|
89
|
-
export function createEmailHandler({
|
|
112
|
+
export function createEmailHandler({
|
|
113
|
+
secret,
|
|
114
|
+
onEmail,
|
|
115
|
+
inbox = "",
|
|
116
|
+
allowFrom = [],
|
|
117
|
+
eventTypes = [RECEIVED],
|
|
118
|
+
toleranceSeconds = 300,
|
|
119
|
+
remember = 2048,
|
|
120
|
+
background = true,
|
|
121
|
+
}) {
|
|
90
122
|
if (!secret) throw new Error("createEmailHandler requires a webhook secret");
|
|
91
123
|
if (typeof onEmail !== "function") throw new TypeError("createEmailHandler requires onEmail");
|
|
92
124
|
|
|
93
|
-
|
|
125
|
+
const ownAddress = inbox.toLowerCase();
|
|
126
|
+
const allowed = new Set(
|
|
127
|
+
(typeof allowFrom === "string" ? allowFrom.split(",") : allowFrom)
|
|
128
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
129
|
+
.filter(Boolean),
|
|
130
|
+
);
|
|
131
|
+
const admitted = new Set(eventTypes);
|
|
132
|
+
// Bounded and in memory, so it resets with the isolate. Redeliveries cluster
|
|
133
|
+
// in the minutes after a failure, which this covers; record `event_id`
|
|
134
|
+
// somewhere durable if acting twice on one email is costly.
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
|
|
137
|
+
function allows(from) {
|
|
138
|
+
if (!allowed.size) return true;
|
|
139
|
+
// Compared whole, never as a substring: `emma@example.com.attacker.net`
|
|
140
|
+
// is a domain anyone can register and sign mail from.
|
|
141
|
+
const address = addressOf(from);
|
|
142
|
+
if (!address.includes("@")) return false;
|
|
143
|
+
return allowed.has(address) || allowed.has(`@${address.split("@").pop()}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function firstSighting(eventId) {
|
|
147
|
+
if (seen.has(eventId)) return false;
|
|
148
|
+
seen.add(eventId);
|
|
149
|
+
if (seen.size > remember) seen.delete(seen.values().next().value);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return async function handle(request, ...context) {
|
|
94
154
|
const body = new Uint8Array(await request.arrayBuffer());
|
|
95
155
|
let event;
|
|
96
156
|
try {
|
|
97
157
|
event = await verifyWebhook(secret, body, request.headers, { toleranceSeconds });
|
|
98
158
|
} catch (error) {
|
|
159
|
+
// 401 and nothing else: an unverified caller learns the URL exists.
|
|
99
160
|
if (error instanceof WebhookVerificationError) {
|
|
100
|
-
return new Response(error.message, { status:
|
|
161
|
+
return new Response(error.message, { status: 401 });
|
|
101
162
|
}
|
|
102
163
|
throw error;
|
|
103
164
|
}
|
|
104
165
|
|
|
105
|
-
|
|
166
|
+
const message = event?.message;
|
|
167
|
+
if (
|
|
168
|
+
!admitted.has(event?.event_type) ||
|
|
169
|
+
typeof message !== "object" ||
|
|
170
|
+
message === null ||
|
|
171
|
+
(ownAddress && addressOf(message.from) === ownAddress) ||
|
|
172
|
+
!allows(message.from) ||
|
|
173
|
+
(event.event_id && !firstSighting(event.event_id))
|
|
174
|
+
) {
|
|
175
|
+
return new Response(null, { status: 204 });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!background) {
|
|
106
179
|
await onEmail(event, request);
|
|
180
|
+
return new Response(null, { status: 204 });
|
|
107
181
|
}
|
|
108
|
-
|
|
182
|
+
const run = Promise.resolve()
|
|
183
|
+
.then(() => onEmail(event, request))
|
|
184
|
+
.catch((error) => console.error("carlyemail: onEmail failed", error));
|
|
185
|
+
context.find((item) => typeof item?.waitUntil === "function")?.waitUntil(run);
|
|
186
|
+
return new Response(null, { status: 202 });
|
|
109
187
|
};
|
|
110
188
|
}
|