@marktiderman/genesis-notifications 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +251 -2
- package/dist/blocks.d.ts +10 -0
- package/dist/blocks.js +35 -0
- package/dist/blocks.js.map +1 -0
- package/dist/chunk-2BG336II.js +124 -0
- package/dist/chunk-2BG336II.js.map +1 -0
- package/dist/chunk-7PHA3WVG.js +51 -0
- package/dist/chunk-7PHA3WVG.js.map +1 -0
- package/dist/chunk-EWZB35V6.js +113 -0
- package/dist/chunk-EWZB35V6.js.map +1 -0
- package/dist/chunk-UXNWI7OS.js +118 -0
- package/dist/chunk-UXNWI7OS.js.map +1 -0
- package/dist/email-render-CID_pXDy.d.ts +53 -0
- package/dist/email.d.ts +46 -0
- package/dist/email.js +64 -0
- package/dist/email.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/native.d.ts +92 -0
- package/dist/native.js +170 -0
- package/dist/native.js.map +1 -0
- package/dist/react.d.ts +98 -0
- package/dist/react.js +4 -0
- package/dist/react.js.map +1 -0
- package/dist/runner.d.ts +174 -0
- package/dist/runner.js +3 -0
- package/dist/runner.js.map +1 -0
- package/dist/server.d.ts +658 -0
- package/dist/server.js +995 -0
- package/dist/server.js.map +1 -0
- package/dist/supabase.d.ts +118 -0
- package/dist/supabase.js +3 -0
- package/dist/supabase.js.map +1 -0
- package/dist/types-BilguCPW.d.ts +332 -0
- package/dist/web.d.ts +79 -0
- package/dist/web.js +233 -0
- package/dist/web.js.map +1 -0
- package/functions/genesis-notifications/index.ts +60 -0
- package/functions/genesis-notifications/notifications.ts +18 -0
- package/migrations/0001_genesis_notifications.sql +2057 -0
- package/migrations/tests/0001_genesis_notifications.test.sql +473 -0
- package/package.json +191 -3
- package/src/blocks.ts +42 -0
- package/src/core/index.ts +43 -0
- package/src/core/registry.ts +354 -0
- package/src/core/types.ts +90 -0
- package/src/core/validate.ts +73 -0
- package/src/email/index.tsx +142 -0
- package/src/index.ts +6 -0
- package/src/native/index.tsx +283 -0
- package/src/react/index.tsx +226 -0
- package/src/runner/index.ts +299 -0
- package/src/server/crypto.ts +44 -0
- package/src/server/dns.ts +106 -0
- package/src/server/email-render.ts +90 -0
- package/src/server/errors.ts +61 -0
- package/src/server/expo.ts +133 -0
- package/src/server/handler.ts +127 -0
- package/src/server/index.ts +57 -0
- package/src/server/notify.ts +100 -0
- package/src/server/rate.ts +47 -0
- package/src/server/resend.ts +143 -0
- package/src/server/runner.ts +524 -0
- package/src/server/secret-links.ts +53 -0
- package/src/server/store.ts +150 -0
- package/src/server/unsubscribe.ts +78 -0
- package/src/server/webhook.ts +55 -0
- package/src/supabase/index.ts +210 -0
- package/src/web/index.tsx +428 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an email says, and the checks every one passes: subjects and names on
|
|
3
|
+
* one line, addresses well formed, every value escaped in the default body.
|
|
4
|
+
*/
|
|
5
|
+
import type { RenderedText } from "../core/registry";
|
|
6
|
+
import type { NotificationBrand } from "../core/types";
|
|
7
|
+
|
|
8
|
+
export interface EmailContent {
|
|
9
|
+
subject: string;
|
|
10
|
+
html: string;
|
|
11
|
+
text: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface EmailTemplateContext {
|
|
15
|
+
type: string;
|
|
16
|
+
category: string;
|
|
17
|
+
/** The notification's `data`. */
|
|
18
|
+
data: Record<string, unknown>;
|
|
19
|
+
/** What the registry's render says (title, body, url): a starting point. */
|
|
20
|
+
text: RenderedText;
|
|
21
|
+
recipient: { email: string; name: string | null };
|
|
22
|
+
/** The caller's brand over the tenant's default. */
|
|
23
|
+
brand: NotificationBrand;
|
|
24
|
+
tenantId: string | null;
|
|
25
|
+
transactional: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Put this where the secret link goes (`href={secretLink}`): the runner
|
|
28
|
+
* swaps in the real link at send time, so the stored render never holds it.
|
|
29
|
+
* Null when the type has none.
|
|
30
|
+
*/
|
|
31
|
+
secretLink: string | null;
|
|
32
|
+
/** The signed one-click unsubscribe link, for the footer. Null for transactional mail. */
|
|
33
|
+
unsubscribeUrl: string | null;
|
|
34
|
+
/** The app's absolute link for `text.url`, when `appUrl` is set. */
|
|
35
|
+
url: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A type's email: React Email (`@marktiderman/genesis-notifications/email`), or any HTML. */
|
|
39
|
+
export type EmailTemplate = (context: EmailTemplateContext) => EmailContent | Promise<EmailContent>;
|
|
40
|
+
|
|
41
|
+
export function escapeHtml(text: string): string {
|
|
42
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A header value on one line: control characters and line separators become spaces; cut to `max`. */
|
|
46
|
+
export function headerSafe(text: string, max = 200): string {
|
|
47
|
+
// eslint-disable-next-line no-control-regex
|
|
48
|
+
const flat = text.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, " ").replace(/\s+/g, " ").trim();
|
|
49
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A bare address safe as a header value. */
|
|
53
|
+
export function isSafeAddress(value: unknown): value is string {
|
|
54
|
+
// eslint-disable-next-line no-control-regex
|
|
55
|
+
return typeof value === "string" && value.length <= 254 && /^[^\s@<>,;:"()[\]\\]+@[^\s@<>,;:"()[\]\\]+\.[^\s@<>,;:"()[\]\\]+$/.test(value) && !/[\u0000-\u001f\u007f]/.test(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A display name with nothing that could break out of the From header (or need quoting): no quotes, brackets, ":", ";", "@", "," or "\\". */
|
|
59
|
+
export function displayName(name: string): string {
|
|
60
|
+
return headerSafe(name.replace(/["<>()[\]:;@,\\]/g, ""), 100);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The default email when a type has no template: its title, body and a button, every value escaped. */
|
|
64
|
+
export const defaultEmailTemplate: EmailTemplate = (ctx) => {
|
|
65
|
+
const title = ctx.text.title;
|
|
66
|
+
const body = ctx.text.body ?? "";
|
|
67
|
+
const link = ctx.secretLink ?? ctx.url;
|
|
68
|
+
const accent = typeof ctx.brand.color === "string" && /^#[0-9a-fA-F]{3,8}$/.test(ctx.brand.color) ? ctx.brand.color : "#111827";
|
|
69
|
+
const from = ctx.brand.name ? escapeHtml(ctx.brand.name) : "";
|
|
70
|
+
const p = "font-family:sans-serif;font-size:15px;line-height:1.5;margin:0 0 16px;color:#111827";
|
|
71
|
+
const html =
|
|
72
|
+
`<!doctype html><html><body style="margin:0;padding:24px;background:#f9fafb">` +
|
|
73
|
+
`<div style="max-width:560px;margin:0 auto;background:#ffffff;border-radius:8px;padding:24px">` +
|
|
74
|
+
(from ? `<p style="${p};font-weight:600;color:${accent}">${from}</p>` : "") +
|
|
75
|
+
`<p style="${p};font-size:18px;font-weight:600">${escapeHtml(title)}</p>` +
|
|
76
|
+
(body ? `<p style="${p};white-space:pre-wrap">${escapeHtml(body)}</p>` : "") +
|
|
77
|
+
(link ? `<p style="${p}"><a href="${escapeHtml(link)}" style="display:inline-block;background:${accent};color:#ffffff;padding:10px 16px;border-radius:6px;text-decoration:none">Open</a></p>` : "") +
|
|
78
|
+
(ctx.unsubscribeUrl ? `<p style="${p};font-size:12px;color:#6b7280"><a href="${escapeHtml(ctx.unsubscribeUrl)}" style="color:#6b7280">Unsubscribe</a> from these emails.</p>` : "") +
|
|
79
|
+
`</div></body></html>`;
|
|
80
|
+
const text = [from, title, body, link ?? "", ctx.unsubscribeUrl ? `Unsubscribe: ${ctx.unsubscribeUrl}` : ""].filter(Boolean).join("\n\n");
|
|
81
|
+
return { subject: title, html, text };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** JSON with sorted keys: the same bytes however a database reordered them. */
|
|
85
|
+
export function canonicalJson(value: unknown): string {
|
|
86
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
87
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
88
|
+
const entries = Object.entries(value as Record<string, unknown>).filter(([, v]) => v !== undefined).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
89
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
|
|
90
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A send failure with a message that is safe to store and log (a status and
|
|
3
|
+
* a provider's error code, never content, an address or a secret). The
|
|
4
|
+
* runner turns it into a retry or a failure.
|
|
5
|
+
*/
|
|
6
|
+
export class SendError extends Error {
|
|
7
|
+
override name = "SendError";
|
|
8
|
+
readonly retryable: boolean;
|
|
9
|
+
readonly retryAfterSeconds: number | undefined;
|
|
10
|
+
readonly status: number | undefined;
|
|
11
|
+
constructor(message: string, options: { retryable?: boolean; retryAfterSeconds?: number; status?: number } = {}) {
|
|
12
|
+
super(message.slice(0, 500));
|
|
13
|
+
this.retryable = options.retryable ?? false;
|
|
14
|
+
this.retryAfterSeconds = options.retryAfterSeconds;
|
|
15
|
+
this.status = options.status;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Whether an HTTP status is worth retrying unchanged: 408, 425, 429 and 5xx. */
|
|
20
|
+
export function isRetryableStatus(status: number): boolean {
|
|
21
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Seconds from a Retry-After header (delta-seconds or an HTTP date), else `fallback`. 1 s to a day. */
|
|
25
|
+
export function retryAfterSeconds(header: string | null | undefined, fallback = 60, now = Date.now()): number {
|
|
26
|
+
const clamp = (s: number) => Math.min(86_400, Math.max(1, s));
|
|
27
|
+
const value = header?.trim();
|
|
28
|
+
if (value && /^\d+$/.test(value)) return clamp(Number(value));
|
|
29
|
+
if (value && /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(value)) {
|
|
30
|
+
const at = Date.parse(value);
|
|
31
|
+
if (Number.isFinite(at)) return clamp(Math.ceil((at - now) / 1000));
|
|
32
|
+
}
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Every request gives up after this long. */
|
|
37
|
+
export const REQUEST_TIMEOUT_MS = 10_000;
|
|
38
|
+
|
|
39
|
+
export interface HttpDeps {
|
|
40
|
+
/** Default the global `fetch`. */
|
|
41
|
+
fetch?: typeof fetch;
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `fetch` with a timeout, and the caller's abort signal too. */
|
|
46
|
+
export async function timedFetch(deps: HttpDeps, url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> {
|
|
47
|
+
const f = deps.fetch ?? fetch;
|
|
48
|
+
const timeout = AbortSignal.timeout(deps.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
49
|
+
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
50
|
+
try {
|
|
51
|
+
return await f(url, { ...init, signal: combined });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const name = (error as { name?: string } | null)?.name;
|
|
54
|
+
throw new SendError(name === "TimeoutError" || name === "AbortError" ? "Timed out" : "Network error", { retryable: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Reads and drops a body we won't use (some runtimes leak a connection otherwise). */
|
|
59
|
+
export function discard(response: Response): void {
|
|
60
|
+
response.body?.cancel().catch(() => {});
|
|
61
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expo's push service: https://docs.expo.dev/push-notifications/sending-notifications/
|
|
3
|
+
*
|
|
4
|
+
* POST /--/api/v2/push/send up to 100 messages; one ticket each, in order
|
|
5
|
+
* POST /--/api/v2/push/getReceipts up to 1000 ticket ids; a receipt for each one ready
|
|
6
|
+
*
|
|
7
|
+
* Nothing here logs; an error names a status or Expo's error code, never a
|
|
8
|
+
* token or a message.
|
|
9
|
+
*/
|
|
10
|
+
import { SendError, discard, isRetryableStatus, retryAfterSeconds, timedFetch, type HttpDeps } from "./errors";
|
|
11
|
+
|
|
12
|
+
export const EXPO_SEND_URL = "https://exp.host/--/api/v2/push/send";
|
|
13
|
+
export const EXPO_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
|
|
14
|
+
/** Expo takes up to 100 messages per send. */
|
|
15
|
+
export const EXPO_SEND_BATCH = 100;
|
|
16
|
+
/** And up to 1000 ticket ids per receipts request. */
|
|
17
|
+
export const EXPO_RECEIPTS_BATCH = 1000;
|
|
18
|
+
|
|
19
|
+
/** One message for Expo. */
|
|
20
|
+
export interface PushMessage {
|
|
21
|
+
to: string;
|
|
22
|
+
title?: string;
|
|
23
|
+
subtitle?: string;
|
|
24
|
+
body?: string;
|
|
25
|
+
/** Reaches the app (`notification.request.content.data`): ids and the link, never content. */
|
|
26
|
+
data?: Record<string, string | number | boolean | null>;
|
|
27
|
+
sound?: "default" | null;
|
|
28
|
+
badge?: number;
|
|
29
|
+
/** Android: the notification channel (one per category). */
|
|
30
|
+
channelId?: string;
|
|
31
|
+
/** The device category whose actions it shows (Reply, Mark as read). */
|
|
32
|
+
categoryId?: string;
|
|
33
|
+
/** iOS: groups notifications on the lock screen (a conversation). */
|
|
34
|
+
threadId?: string;
|
|
35
|
+
/** Replaces an earlier one with the same id, in transit (and on screen on iOS). */
|
|
36
|
+
collapseId?: string;
|
|
37
|
+
/** Android: replaces one already on screen with the same tag. */
|
|
38
|
+
tag?: string;
|
|
39
|
+
ttl?: number;
|
|
40
|
+
priority?: "default" | "normal" | "high";
|
|
41
|
+
/** iOS: lets the app's Notification Service Extension change it (rich push). */
|
|
42
|
+
mutableContent?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type PushTicket = { status: "ok"; id: string } | { status: "error"; code: string };
|
|
46
|
+
export type PushReceipt = { status: "ok" } | { status: "error"; code: string };
|
|
47
|
+
|
|
48
|
+
/** Sends pushes and reads receipts; `expoPushProvider` is the built-in one. */
|
|
49
|
+
export interface PushProvider {
|
|
50
|
+
readonly name: string;
|
|
51
|
+
/** At most `EXPO_SEND_BATCH` messages; one ticket per message, in order. Throws a `SendError` when the request failed. */
|
|
52
|
+
send(messages: readonly PushMessage[], options?: { signal?: AbortSignal }): Promise<PushTicket[]>;
|
|
53
|
+
/** Receipts for at most `EXPO_RECEIPTS_BATCH` tickets; ids with none yet are left out. */
|
|
54
|
+
receipts(ids: readonly string[], options?: { signal?: AbortSignal }): Promise<Record<string, PushReceipt>>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ExpoOptions extends HttpDeps {
|
|
58
|
+
/** Expo's access token, for "Enhanced Security for Push Notifications" (turn it on). */
|
|
59
|
+
accessToken?: string;
|
|
60
|
+
sendUrl?: string;
|
|
61
|
+
receiptsUrl?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** An Expo error code (`DeviceNotRegistered`), safe to store; anything else is `Unknown`. */
|
|
65
|
+
export function expoErrorCode(value: { details?: { error?: unknown } } | undefined): string {
|
|
66
|
+
const code = value?.details?.error;
|
|
67
|
+
return typeof code === "string" && /^[A-Za-z_]{1,64}$/.test(code) ? code : "Unknown";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Expo refused the request itself (a 4xx other than 429): resend its messages one by one. */
|
|
71
|
+
export function refusedOutright(error: unknown): boolean {
|
|
72
|
+
return error instanceof SendError && error.status !== undefined && error.status >= 400 && error.status < 500 && error.status !== 429;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Expo's push service as a `PushProvider`.
|
|
77
|
+
*
|
|
78
|
+
* @stability Beta
|
|
79
|
+
*/
|
|
80
|
+
export function expoPushProvider(options: ExpoOptions = {}): PushProvider {
|
|
81
|
+
const headers = {
|
|
82
|
+
accept: "application/json",
|
|
83
|
+
"content-type": "application/json",
|
|
84
|
+
...(options.accessToken ? { authorization: `Bearer ${options.accessToken}` } : {}),
|
|
85
|
+
};
|
|
86
|
+
async function post(url: string, body: unknown, signal?: AbortSignal): Promise<unknown> {
|
|
87
|
+
const response = await timedFetch(options, url, { method: "POST", headers, body: JSON.stringify(body) }, signal);
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
// The body of an error is never read: it can echo the request back.
|
|
90
|
+
const retryAfter = response.status === 429 ? retryAfterSeconds(response.headers.get("retry-after"), 30) : undefined;
|
|
91
|
+
discard(response);
|
|
92
|
+
throw new SendError(`Expo push answered HTTP ${response.status}`, {
|
|
93
|
+
retryable: isRetryableStatus(response.status),
|
|
94
|
+
status: response.status,
|
|
95
|
+
...(retryAfter !== undefined ? { retryAfterSeconds: retryAfter } : {}),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
return await response.json();
|
|
100
|
+
} catch {
|
|
101
|
+
throw new SendError("Expo push answered with something that isn't JSON", { retryable: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
name: "expo",
|
|
106
|
+
async send(messages, { signal } = {}) {
|
|
107
|
+
if (messages.length > EXPO_SEND_BATCH) throw new Error(`expoPushProvider: at most ${EXPO_SEND_BATCH} messages`);
|
|
108
|
+
if (messages.length === 0) return [];
|
|
109
|
+
const json = (await post(options.sendUrl ?? EXPO_SEND_URL, messages, signal)) as { data?: unknown };
|
|
110
|
+
const tickets = Array.isArray(json?.data) ? (json.data as { status?: string; id?: unknown; details?: { error?: unknown } }[]) : [];
|
|
111
|
+
// A short or malformed answer: the messages without a ticket failed, never "sent".
|
|
112
|
+
return messages.map((_, i): PushTicket => {
|
|
113
|
+
const t = tickets[i];
|
|
114
|
+
if (t?.status === "ok" && typeof t.id === "string" && t.id.length > 0 && t.id.length <= 128) return { status: "ok", id: t.id };
|
|
115
|
+
if (t?.status === "error") return { status: "error", code: expoErrorCode(t) };
|
|
116
|
+
return { status: "error", code: "NoTicket" };
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
async receipts(ids, { signal } = {}) {
|
|
120
|
+
if (ids.length > EXPO_RECEIPTS_BATCH) throw new Error(`expoPushProvider: at most ${EXPO_RECEIPTS_BATCH} ids`);
|
|
121
|
+
if (ids.length === 0) return {};
|
|
122
|
+
const json = (await post(options.receiptsUrl ?? EXPO_RECEIPTS_URL, { ids }, signal)) as { data?: unknown };
|
|
123
|
+
const data = json?.data && typeof json.data === "object" && !Array.isArray(json.data) ? (json.data as Record<string, { status?: string; details?: { error?: unknown } }>) : {};
|
|
124
|
+
const out: Record<string, PushReceipt> = {};
|
|
125
|
+
for (const id of ids) {
|
|
126
|
+
const r = data[id];
|
|
127
|
+
if (r?.status === "ok") out[id] = { status: "ok" };
|
|
128
|
+
else if (r?.status === "error") out[id] = { status: "error", code: expoErrorCode(r) };
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createNotificationsHandler`: the notifier's HTTP surface, as a
|
|
3
|
+
* `(Request) => Promise<Response>` for an Edge Function, a worker or any
|
|
4
|
+
* server with Fetch API requests. Matched by the path's last segments, so
|
|
5
|
+
* it mounts under any prefix:
|
|
6
|
+
*
|
|
7
|
+
* POST …/run send what is due (Authorization: Bearer <cronSecret>)
|
|
8
|
+
* POST …/receipts check push receipts (Authorization: Bearer <cronSecret>)
|
|
9
|
+
* POST …/webhooks/resend bounces, complaints, deliveries (Svix-signed)
|
|
10
|
+
* GET …/unsubscribe?t=… a page with one button (GET never unsubscribes: link scanners follow links)
|
|
11
|
+
* POST …/unsubscribe?t=… unsubscribes; RFC 8058 one-click posts `List-Unsubscribe=One-Click` here
|
|
12
|
+
*
|
|
13
|
+
* Answers carry counts and statuses only.
|
|
14
|
+
*/
|
|
15
|
+
import { timingSafeEqual } from "./crypto";
|
|
16
|
+
import { escapeHtml } from "./email-render";
|
|
17
|
+
import type { NotificationRunner } from "./runner";
|
|
18
|
+
import type { NotificationStore } from "./store";
|
|
19
|
+
import { verifyUnsubscribeToken } from "./unsubscribe";
|
|
20
|
+
import { emailEventOf, verifyResendWebhook } from "./webhook";
|
|
21
|
+
|
|
22
|
+
export interface NotificationsHandlerOptions {
|
|
23
|
+
runner: NotificationRunner;
|
|
24
|
+
store: NotificationStore;
|
|
25
|
+
/** The bearer secret a scheduler sends to /run and /receipts (32 characters or more). */
|
|
26
|
+
cronSecret: string;
|
|
27
|
+
/** The unsubscribe signing secret (the runner's `email.unsubscribe.secret`). */
|
|
28
|
+
unsubscribeSecret?: string;
|
|
29
|
+
/** Resend's webhook signing secret (`whsec_…`). */
|
|
30
|
+
resendWebhookSecret?: string;
|
|
31
|
+
/** Words on the unsubscribe page. */
|
|
32
|
+
copy?: Partial<{ title: string; confirm: string; button: string; done: string; invalid: string }>;
|
|
33
|
+
log?: (message: string) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const COPY = {
|
|
37
|
+
title: "Unsubscribe",
|
|
38
|
+
confirm: "Stop getting these emails?",
|
|
39
|
+
button: "Unsubscribe",
|
|
40
|
+
done: "You're unsubscribed. You won't get these emails any more.",
|
|
41
|
+
invalid: "This link isn't valid any more.",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function page(title: string, body: string, status = 200): Response {
|
|
45
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>${escapeHtml(title)}</title></head><body style="font-family:sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;line-height:1.5">${body}</body></html>`;
|
|
46
|
+
return new Response(html, { status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" } });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function json(body: unknown, status = 200): Response {
|
|
50
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "cache-control": "no-store" } });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The handler.
|
|
55
|
+
*
|
|
56
|
+
* @stability Beta
|
|
57
|
+
*/
|
|
58
|
+
export function createNotificationsHandler(options: NotificationsHandlerOptions): (request: Request) => Promise<Response> {
|
|
59
|
+
if (!options.cronSecret || options.cronSecret.length < 32) throw new Error("createNotificationsHandler: cronSecret must be at least 32 characters");
|
|
60
|
+
const copy = { ...COPY, ...options.copy };
|
|
61
|
+
const enc = new TextEncoder();
|
|
62
|
+
const authorized = (request: Request) => {
|
|
63
|
+
const header = request.headers.get("authorization") ?? "";
|
|
64
|
+
const given = header.startsWith("Bearer ") ? header.slice(7) : "";
|
|
65
|
+
return timingSafeEqual(enc.encode(given), enc.encode(options.cronSecret));
|
|
66
|
+
};
|
|
67
|
+
const log = (m: string) => {
|
|
68
|
+
try {
|
|
69
|
+
options.log?.(m);
|
|
70
|
+
} catch {
|
|
71
|
+
// ignored
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return async (request) => {
|
|
76
|
+
const url = new URL(request.url);
|
|
77
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
78
|
+
try {
|
|
79
|
+
if (path.endsWith("/run") || path.endsWith("/receipts")) {
|
|
80
|
+
if (request.method !== "POST") return json({ error: "method_not_allowed" }, 405);
|
|
81
|
+
if (!authorized(request)) return json({ error: "unauthorized" }, 401);
|
|
82
|
+
if (path.endsWith("/run")) return json({ ok: true, ...(await options.runner.run({ signal: request.signal })) });
|
|
83
|
+
return json({ ok: true, ...(await options.runner.checkReceipts({ signal: request.signal })) });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (path.endsWith("/webhooks/resend")) {
|
|
87
|
+
if (request.method !== "POST") return json({ error: "method_not_allowed" }, 405);
|
|
88
|
+
if (!options.resendWebhookSecret) return json({ error: "not_configured" }, 404);
|
|
89
|
+
const body = await request.text();
|
|
90
|
+
if (body.length > 256_000 || !(await verifyResendWebhook(options.resendWebhookSecret, request.headers, body))) return json({ error: "bad_signature" }, 401);
|
|
91
|
+
let payload: unknown;
|
|
92
|
+
try {
|
|
93
|
+
payload = JSON.parse(body);
|
|
94
|
+
} catch {
|
|
95
|
+
return json({ error: "bad_json" }, 400);
|
|
96
|
+
}
|
|
97
|
+
const event = emailEventOf(payload);
|
|
98
|
+
if (!event) return json({ ok: true, ignored: true });
|
|
99
|
+
const ours = await options.store.emailEvent(event.providerId, event.event, event.permanent);
|
|
100
|
+
return json({ ok: true, ours });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (path.endsWith("/unsubscribe")) {
|
|
104
|
+
if (!options.unsubscribeSecret) return page(copy.title, `<p>${escapeHtml(copy.invalid)}</p>`, 404);
|
|
105
|
+
const token = url.searchParams.get("t") ?? "";
|
|
106
|
+
const claims = await verifyUnsubscribeToken(options.unsubscribeSecret, token);
|
|
107
|
+
if (!claims) return page(copy.title, `<p>${escapeHtml(copy.invalid)}</p>`, 400);
|
|
108
|
+
if (request.method === "GET") {
|
|
109
|
+
// Relative: behind a gateway (Supabase's /functions/v1) the function doesn't see its public path.
|
|
110
|
+
const action = `?t=${encodeURIComponent(token)}`;
|
|
111
|
+
return page(
|
|
112
|
+
copy.title,
|
|
113
|
+
`<h1 style="font-size:1.25rem">${escapeHtml(copy.confirm)}</h1><form method="post" action="${escapeHtml(action)}"><input type="hidden" name="List-Unsubscribe" value="One-Click"><button type="submit" style="font-size:1rem;padding:.5rem 1rem">${escapeHtml(copy.button)}</button></form>`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (request.method !== "POST") return json({ error: "method_not_allowed" }, 405);
|
|
117
|
+
await options.store.unsubscribe(claims.userId ?? null, claims.userId ? null : (claims.emailHash ?? null), claims.category, claims.tenantId ?? null);
|
|
118
|
+
return page(copy.title, `<p>${escapeHtml(copy.done)}</p>`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return json({ error: "not_found" }, 404);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
log(`notifications handler: ${error instanceof Error ? error.name : "error"} on ${path.split("/").slice(-1)[0]}`);
|
|
124
|
+
return json({ error: "server_error" }, 500);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@marktiderman/genesis-notifications/server`: `notify()`, the runner that
|
|
3
|
+
* sends what it queued (Expo push, Resend email), the HTTP handler (cron,
|
|
4
|
+
* webhooks, one-click unsubscribe), `resolveSecretLink` and the sender
|
|
5
|
+
* domain check. Runs wherever `fetch` and WebCrypto do (Node 20+, Deno,
|
|
6
|
+
* workers). Never import it into a browser or app bundle: it is for the
|
|
7
|
+
* service role.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
export { notify, cancelNotification, syncNotificationTypes, type CancelInput, type NotifyOptions } from "./notify";
|
|
12
|
+
export {
|
|
13
|
+
supabaseNotificationStore,
|
|
14
|
+
NotificationStoreError,
|
|
15
|
+
type ClaimedDevice,
|
|
16
|
+
type ClaimedJob,
|
|
17
|
+
type NotificationStore,
|
|
18
|
+
type PushTicketRow,
|
|
19
|
+
type RenderedEmail,
|
|
20
|
+
type RpcClient,
|
|
21
|
+
type SenderIdentity,
|
|
22
|
+
type SettleOutcome,
|
|
23
|
+
} from "./store";
|
|
24
|
+
export { createNotificationRunner, type EmailOptions, type NotificationRunner, type NotificationRunnerOptions } from "./runner";
|
|
25
|
+
export { createNotificationsHandler, type NotificationsHandlerOptions } from "./handler";
|
|
26
|
+
export {
|
|
27
|
+
expoPushProvider,
|
|
28
|
+
expoErrorCode,
|
|
29
|
+
refusedOutright,
|
|
30
|
+
EXPO_RECEIPTS_BATCH,
|
|
31
|
+
EXPO_RECEIPTS_URL,
|
|
32
|
+
EXPO_SEND_BATCH,
|
|
33
|
+
EXPO_SEND_URL,
|
|
34
|
+
type ExpoOptions,
|
|
35
|
+
type PushMessage,
|
|
36
|
+
type PushProvider,
|
|
37
|
+
type PushReceipt,
|
|
38
|
+
type PushTicket,
|
|
39
|
+
} from "./expo";
|
|
40
|
+
export { createRateLimiter, type RateLimiter, type RateLimiterOptions } from "./rate";
|
|
41
|
+
export { resendProvider, RESEND_API, RESEND_DEFAULT_PER_SECOND, type EmailMessage, type EmailProvider, type EmailSendResult, type ResendOptions } from "./resend";
|
|
42
|
+
export { resolveSecretLink, SecretLinkError, SECRET_LINK_PLACEHOLDER, type SecretLinkRef, type SecretLinks } from "./secret-links";
|
|
43
|
+
export { createUnsubscribeToken, verifyUnsubscribeToken, unsubscribeHeaders, emailHash, type UnsubscribeClaims } from "./unsubscribe";
|
|
44
|
+
export { verifyResendWebhook, emailEventOf, WEBHOOK_TOLERANCE_SECONDS } from "./webhook";
|
|
45
|
+
export { checkSenderDomain, isReservedDomain, senderOwnershipRecord, SENDER_OWNERSHIP_HOST, type DomainCheck, type DomainCheckOptions } from "./dns";
|
|
46
|
+
export {
|
|
47
|
+
canonicalJson,
|
|
48
|
+
defaultEmailTemplate,
|
|
49
|
+
displayName,
|
|
50
|
+
escapeHtml,
|
|
51
|
+
headerSafe,
|
|
52
|
+
isSafeAddress,
|
|
53
|
+
type EmailContent,
|
|
54
|
+
type EmailTemplate,
|
|
55
|
+
type EmailTemplateContext,
|
|
56
|
+
} from "./email-render";
|
|
57
|
+
export { SendError, isRetryableStatus, retryAfterSeconds, REQUEST_TIMEOUT_MS, type HttpDeps } from "./errors";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `notify()`: what a domain calls, from its server. It checks the input
|
|
3
|
+
* (the registry knows the type, the recipients are well formed, `data`
|
|
4
|
+
* holds no secret) and calls `genesis_notify`, which decides channels from
|
|
5
|
+
* preferences and queues the sends. A domain's own SQL can call
|
|
6
|
+
* `genesis_notify` directly with the same arguments.
|
|
7
|
+
*/
|
|
8
|
+
import type { NotificationRegistry } from "../core/registry";
|
|
9
|
+
import type { NotifyInput, NotifyResult, Recipient } from "../core/types";
|
|
10
|
+
import { assertNoSecrets, recipientsToJson } from "../core/validate";
|
|
11
|
+
import { NotificationStoreError, type RpcClient } from "./store";
|
|
12
|
+
|
|
13
|
+
export interface NotifyOptions {
|
|
14
|
+
/** Checks the type against the registry before the database does. */
|
|
15
|
+
registry?: NotificationRegistry;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Queue a notification for each recipient. Resolves one result per recipient
|
|
20
|
+
* that exists (a deleted login is left out); the same idempotency key again
|
|
21
|
+
* (for the same type and tenant) resolves `created: false` and sends nothing more.
|
|
22
|
+
*
|
|
23
|
+
* @stability Beta
|
|
24
|
+
*/
|
|
25
|
+
export async function notify<K extends string>(client: RpcClient, input: NotifyInput<K>, options: NotifyOptions = {}): Promise<NotifyResult[]> {
|
|
26
|
+
const t = options.registry?.type(input.type);
|
|
27
|
+
if (input.secret !== undefined && t && !t.secretLink) throw new Error(`notify: type ${input.type} carries no secret link`);
|
|
28
|
+
// A one-time secret is one person's: never copied onto another recipient's email.
|
|
29
|
+
if (input.secret !== undefined && input.recipients.length !== 1) throw new Error("notify: a secret goes to exactly one recipient");
|
|
30
|
+
if (!input.idempotencyKey || input.idempotencyKey.length > 200) throw new Error("notify: an idempotency key of 1 to 200 characters is required");
|
|
31
|
+
assertNoSecrets(input.data);
|
|
32
|
+
if (input.badge !== undefined && (!Number.isInteger(input.badge) || input.badge < 0)) throw new Error("notify: badge is a whole number, 0 or more");
|
|
33
|
+
const { data, error } = await client.rpc("genesis_notify", {
|
|
34
|
+
p_type: input.type,
|
|
35
|
+
p_recipients: recipientsToJson(input.recipients),
|
|
36
|
+
p_data: input.data ?? {},
|
|
37
|
+
p_idempotency_key: input.idempotencyKey,
|
|
38
|
+
p_thread_key: input.threadKey ?? null,
|
|
39
|
+
p_collapse_key: input.collapseKey ?? null,
|
|
40
|
+
p_badge: input.badge ?? null,
|
|
41
|
+
p_tenant_id: input.tenantId ?? null,
|
|
42
|
+
p_brand: input.brand ?? null,
|
|
43
|
+
p_secret: input.secret ?? null,
|
|
44
|
+
});
|
|
45
|
+
if (error) throw new NotificationStoreError("genesis_notify", error.code ?? null);
|
|
46
|
+
return ((data as { notification_id: string; recipient_key: string; created: boolean }[] | null) ?? []).map((r) => ({
|
|
47
|
+
notificationId: r.notification_id,
|
|
48
|
+
recipientKey: r.recipient_key,
|
|
49
|
+
created: r.created,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CancelInput {
|
|
54
|
+
/** The type the notifications were made with: a key is a domain's, per type. */
|
|
55
|
+
type: string;
|
|
56
|
+
/** The key the notifications were made with. */
|
|
57
|
+
idempotencyKey: string;
|
|
58
|
+
/** The tenant they were made for. Default: those made with no tenant (never another tenant's). */
|
|
59
|
+
tenantId?: string | null;
|
|
60
|
+
/** Only these recipients (as `notify()` takes them). Default: everyone the key reached. */
|
|
61
|
+
recipients?: readonly Recipient[];
|
|
62
|
+
/** Archive the feed rows too. Default false. */
|
|
63
|
+
archive?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Stop what hasn't gone out for a type, tenant and idempotency key (a release
|
|
68
|
+
* rolled back, an event undone): every pending send job is marked cancelled
|
|
69
|
+
* and never claimed. A job already sent is left alone; one being sent right
|
|
70
|
+
* now ends cancelled unless that attempt goes through (it is never retried).
|
|
71
|
+
* With `archive`, the feed rows are archived too.
|
|
72
|
+
*
|
|
73
|
+
* @stability Beta
|
|
74
|
+
*/
|
|
75
|
+
export async function cancelNotification(client: RpcClient, input: CancelInput): Promise<{ cancelled: number; archived: number }> {
|
|
76
|
+
if (!input.type) throw new Error("cancelNotification: a type is required");
|
|
77
|
+
if (!input.idempotencyKey || input.idempotencyKey.length > 200) throw new Error("cancelNotification: an idempotency key of 1 to 200 characters is required");
|
|
78
|
+
const { data, error } = await client.rpc("genesis_notifications_cancel", {
|
|
79
|
+
p_type: input.type,
|
|
80
|
+
p_idempotency_key: input.idempotencyKey,
|
|
81
|
+
p_tenant_id: input.tenantId ?? null,
|
|
82
|
+
p_recipients: input.recipients ? recipientsToJson(input.recipients) : null,
|
|
83
|
+
p_archive: input.archive ?? false,
|
|
84
|
+
});
|
|
85
|
+
if (error) throw new NotificationStoreError("genesis_notifications_cancel", error.code ?? null);
|
|
86
|
+
const row = (Array.isArray(data) ? data[0] : data) as { cancelled?: number; archived?: number } | undefined;
|
|
87
|
+
return { cancelled: Number(row?.cancelled ?? 0), archived: Number(row?.archived ?? 0) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Mirror the registry into `notification_types` (run it on deploy, or the
|
|
92
|
+
* server's first request): types left out are retired, never deleted.
|
|
93
|
+
*
|
|
94
|
+
* @stability Beta
|
|
95
|
+
*/
|
|
96
|
+
export async function syncNotificationTypes(client: RpcClient, registry: NotificationRegistry): Promise<number> {
|
|
97
|
+
const { data, error } = await client.rpc("genesis_notifications_sync_types", { p_rows: registry.toTypeRows() });
|
|
98
|
+
if (error) throw new NotificationStoreError("genesis_notifications_sync_types", error.code ?? null);
|
|
99
|
+
return Number(data ?? 0);
|
|
100
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A request pacer: at most `perSecond` requests start in any second, spaced
|
|
3
|
+
* evenly, however many callers wait at once. Each provider instance has its
|
|
4
|
+
* own; two runners running at once each keep to it, so set it for that.
|
|
5
|
+
*/
|
|
6
|
+
export interface RateLimiter {
|
|
7
|
+
/** Resolves when the caller may start its request; rejects if `signal` aborts first. */
|
|
8
|
+
acquire(signal?: AbortSignal): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RateLimiterOptions {
|
|
12
|
+
perSecond: number;
|
|
13
|
+
now?: () => number;
|
|
14
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
if (signal?.aborted) return reject(signal.reason ?? new Error("Aborted"));
|
|
20
|
+
const timer = setTimeout(() => {
|
|
21
|
+
signal?.removeEventListener("abort", onAbort);
|
|
22
|
+
resolve();
|
|
23
|
+
}, ms);
|
|
24
|
+
const onAbort = () => {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
reject(signal?.reason ?? new Error("Aborted"));
|
|
27
|
+
};
|
|
28
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @stability Beta */
|
|
33
|
+
export function createRateLimiter(options: RateLimiterOptions): RateLimiter {
|
|
34
|
+
if (!(options.perSecond > 0) || !Number.isFinite(options.perSecond)) throw new Error("createRateLimiter: perSecond must be a positive number");
|
|
35
|
+
const gap = 1000 / options.perSecond;
|
|
36
|
+
const now = options.now ?? Date.now;
|
|
37
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
38
|
+
let next = 0;
|
|
39
|
+
return {
|
|
40
|
+
async acquire(signal) {
|
|
41
|
+
const at = Math.max(now(), next);
|
|
42
|
+
next = at + gap;
|
|
43
|
+
const wait = at - now();
|
|
44
|
+
if (wait > 0) await sleep(wait, signal);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|