carlyemail 0.6.6 → 0.7.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
@@ -170,12 +170,23 @@ import { createEmailHandler } from "carlyemail/webhooks";
170
170
 
171
171
  const handleEmail = createEmailHandler({
172
172
  secret: env.CARLYEMAIL_WEBHOOK_SECRET,
173
+ inbox: "frontdesk@carlyemail.com",
174
+ allowFrom: ["sam@gmail.com"],
173
175
  async onEmail(event) {
174
176
  console.log(event.message);
175
177
  },
176
178
  });
179
+
180
+ export default {
181
+ fetch: (request, env, ctx) => handleEmail(request, env, ctx),
182
+ };
177
183
  ```
178
184
 
185
+ Only `message.received` reaches `onEmail`: spam, blocked and unauthenticated
186
+ mail does not, nor does mail from the inbox itself, a sender outside
187
+ `allowFrom`, or a redelivery. The handler answers 202 and runs `onEmail` after,
188
+ through `ctx.waitUntil` when it is given one. A bad signature gets 401.
189
+
179
190
  ## Licence
180
191
 
181
192
  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.7.0";
22
22
 
23
23
  const DEFAULT_API = "https://api.carlyemail.com";
24
24
  const CONFIG_DIR = join(homedir(), ".carlyemail");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carlyemail",
3
- "version": "0.6.6",
3
+ "version": "0.7.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/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
  }