carlyemail 0.2.0 → 0.4.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/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
+ }