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 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.6.6";
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
- ctx.print(arrow(`check ${human_email} for a 6-digit code, then:`));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carlyemail",
3
- "version": "0.6.6",
3
+ "version": "0.8.0",
4
4
  "description": "Real email inboxes your agent can send, receive and reply from. SDK and CLI.",
5
5
  "keywords": [
6
6
  "email",
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 function createEmailHandler(options: {
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
- }): (request: Request) => Promise<Response>;
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. Handler errors are allowed through so the delivery gets a 5xx and
87
- * retries; only verification errors are turned into terminal 400 responses.
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({ secret, onEmail, toleranceSeconds = 300 }) {
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
- return async function handle(request) {
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: 400 });
161
+ return new Response(error.message, { status: 401 });
101
162
  }
102
163
  throw error;
103
164
  }
104
165
 
105
- if (event.event_type?.startsWith("message.received")) {
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
- return new Response(null, { status: 204 });
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
  }