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 +11 -0
- package/carlyemail.js +1 -1
- package/package.json +1 -1
- package/webhooks.d.ts +25 -2
- package/webhooks.js +85 -7
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.
|
|
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
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
|
}
|