@marktiderman/genesis-notifications 0.0.0-stage → 0.1.1
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,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@marktiderman/genesis-notifications/react`: the hooks every screen uses,
|
|
3
|
+
* on web and native alike. `NotificationsProvider` holds the client, the
|
|
4
|
+
* registry and the signed-in user, and listens on the person's private
|
|
5
|
+
* realtime channel: any change refetches (the broadcast carries ids only).
|
|
6
|
+
*
|
|
7
|
+
* useNotifications() the feed, each row rendered by the registry, plus mark read / archive
|
|
8
|
+
* useUnreadCount() the unread count, for a bell or a tab badge
|
|
9
|
+
* usePreferences() the category × channel matrix and its switches
|
|
10
|
+
*
|
|
11
|
+
* Built on TanStack Query (a peer): the app's QueryClientProvider must be above.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
import { createContext, useContext, useEffect, useMemo, type ReactNode } from "react";
|
|
16
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
17
|
+
import type { Channel, ChannelChoices, MatrixRow, NotificationRegistry, NotificationRow } from "../core/index";
|
|
18
|
+
import { notificationsClient, type FeedFilter, type NotificationsClient, type NotificationsSupabaseLike } from "../supabase/index";
|
|
19
|
+
|
|
20
|
+
interface Context {
|
|
21
|
+
client: NotificationsClient;
|
|
22
|
+
registry: NotificationRegistry;
|
|
23
|
+
userId: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const NotificationsContext = createContext<Context | null>(null);
|
|
27
|
+
|
|
28
|
+
export interface NotificationsProviderProps {
|
|
29
|
+
/** A Supabase client signed in as the person (or a `notificationsClient`). */
|
|
30
|
+
client: NotificationsSupabaseLike | NotificationsClient;
|
|
31
|
+
registry: NotificationRegistry;
|
|
32
|
+
/** The signed-in user; null while signed out (nothing is read). */
|
|
33
|
+
userId: string | null;
|
|
34
|
+
/** Listen on `user:<id>` and refetch on a change. Default true. */
|
|
35
|
+
realtime?: boolean;
|
|
36
|
+
children?: ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isClient = (c: NotificationsSupabaseLike | NotificationsClient): c is NotificationsClient => typeof (c as NotificationsClient).feed === "function";
|
|
40
|
+
|
|
41
|
+
/** The query keys, for an app that invalidates them itself. */
|
|
42
|
+
export const notificationKeys = {
|
|
43
|
+
all: (userId: string | null) => ["genesis-notifications", userId] as const,
|
|
44
|
+
feed: (userId: string | null, filter: FeedFilter, limit: number) => ["genesis-notifications", userId, "feed", filter, limit] as const,
|
|
45
|
+
unread: (userId: string | null) => ["genesis-notifications", userId, "unread"] as const,
|
|
46
|
+
choices: (userId: string | null) => ["genesis-notifications", userId, "choices"] as const,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Provides the notifier to the hooks.
|
|
51
|
+
*
|
|
52
|
+
* @stability Beta
|
|
53
|
+
*/
|
|
54
|
+
export function NotificationsProvider({ client, registry, userId, realtime = true, children }: NotificationsProviderProps) {
|
|
55
|
+
const resolved = useMemo(() => (isClient(client) ? client : notificationsClient(client)), [client]);
|
|
56
|
+
const queryClient = useQueryClient();
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!realtime || !userId) return;
|
|
59
|
+
try {
|
|
60
|
+
return resolved.subscribe(userId, () => {
|
|
61
|
+
void queryClient.invalidateQueries({ queryKey: notificationKeys.all(userId) });
|
|
62
|
+
});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.warn(`[genesis-notifications] couldn't listen for feed changes: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}, [resolved, userId, realtime, queryClient]);
|
|
68
|
+
const value = useMemo(() => ({ client: resolved, registry, userId }), [resolved, registry, userId]);
|
|
69
|
+
return <NotificationsContext.Provider value={value}>{children}</NotificationsContext.Provider>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The provider's client, registry and user. Throws outside one. */
|
|
73
|
+
export function useNotificationsContext(): Context {
|
|
74
|
+
const ctx = useContext(NotificationsContext);
|
|
75
|
+
if (!ctx) throw new Error("useNotifications: wrap the screen in <NotificationsProvider>.");
|
|
76
|
+
return ctx;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A feed row with what the registry says about it. */
|
|
80
|
+
export interface FeedItem extends NotificationRow {
|
|
81
|
+
title: string;
|
|
82
|
+
body?: string;
|
|
83
|
+
url?: string;
|
|
84
|
+
unread: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface UseNotificationsResult {
|
|
88
|
+
items: FeedItem[];
|
|
89
|
+
isLoading: boolean;
|
|
90
|
+
isRefetching: boolean;
|
|
91
|
+
error: Error | null;
|
|
92
|
+
refetch: () => Promise<unknown>;
|
|
93
|
+
markRead: (ids: string | readonly string[]) => Promise<void>;
|
|
94
|
+
markUnread: (ids: string | readonly string[]) => Promise<void>;
|
|
95
|
+
markAllRead: () => Promise<void>;
|
|
96
|
+
archive: (ids: string | readonly string[]) => Promise<void>;
|
|
97
|
+
/** Mark everything shown as seen (opening the feed); the unread count is about read, not seen. */
|
|
98
|
+
markAllSeen: () => Promise<void>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The person's feed.
|
|
103
|
+
*
|
|
104
|
+
* @stability Beta
|
|
105
|
+
*/
|
|
106
|
+
export function useNotifications(options: { filter?: FeedFilter; limit?: number } = {}): UseNotificationsResult {
|
|
107
|
+
const { client, registry, userId } = useNotificationsContext();
|
|
108
|
+
const filter = options.filter ?? "all";
|
|
109
|
+
const limit = options.limit ?? 50;
|
|
110
|
+
const queryClient = useQueryClient();
|
|
111
|
+
const query = useQuery({
|
|
112
|
+
queryKey: notificationKeys.feed(userId, filter, limit),
|
|
113
|
+
queryFn: () => client.feed({ filter, limit }),
|
|
114
|
+
enabled: userId !== null,
|
|
115
|
+
});
|
|
116
|
+
const items = useMemo<FeedItem[]>(
|
|
117
|
+
() =>
|
|
118
|
+
(query.data ?? []).map((row) => {
|
|
119
|
+
const text = registry.render(row.type, row.data ?? {});
|
|
120
|
+
return { ...row, ...text, unread: row.read_at === null };
|
|
121
|
+
}),
|
|
122
|
+
[query.data, registry],
|
|
123
|
+
);
|
|
124
|
+
const changed = () => queryClient.invalidateQueries({ queryKey: notificationKeys.all(userId) });
|
|
125
|
+
const mark = useMutation({
|
|
126
|
+
mutationFn: ({ ids, state }: { ids: readonly string[]; state: "seen" | "read" | "unread" | "archived" }) => client.mark(ids, state),
|
|
127
|
+
onSettled: changed,
|
|
128
|
+
});
|
|
129
|
+
const all = useMutation({ mutationFn: (state: "seen" | "read") => client.markAll(state), onSettled: changed });
|
|
130
|
+
const list = (ids: string | readonly string[]) => (typeof ids === "string" ? [ids] : ids);
|
|
131
|
+
return {
|
|
132
|
+
items,
|
|
133
|
+
isLoading: query.isLoading,
|
|
134
|
+
isRefetching: query.isRefetching,
|
|
135
|
+
error: (query.error as Error | null) ?? null,
|
|
136
|
+
refetch: () => query.refetch(),
|
|
137
|
+
markRead: async (ids) => void (await mark.mutateAsync({ ids: list(ids), state: "read" })),
|
|
138
|
+
markUnread: async (ids) => void (await mark.mutateAsync({ ids: list(ids), state: "unread" })),
|
|
139
|
+
archive: async (ids) => void (await mark.mutateAsync({ ids: list(ids), state: "archived" })),
|
|
140
|
+
markAllRead: async () => void (await all.mutateAsync("read")),
|
|
141
|
+
markAllSeen: async () => void (await all.mutateAsync("seen")),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The unread count (0 while signed out).
|
|
147
|
+
*
|
|
148
|
+
* @stability Beta
|
|
149
|
+
*/
|
|
150
|
+
export function useUnreadCount(): number {
|
|
151
|
+
const { client, userId } = useNotificationsContext();
|
|
152
|
+
const query = useQuery({ queryKey: notificationKeys.unread(userId), queryFn: () => client.unreadCount(), enabled: userId !== null });
|
|
153
|
+
return query.data ?? 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One category's row in the matrix. */
|
|
157
|
+
export interface PreferenceRow extends MatrixRow {
|
|
158
|
+
/** What each offered channel is now: the person's choice, else the default. */
|
|
159
|
+
values: Partial<Record<Channel, boolean>>;
|
|
160
|
+
/** Which values the person chose (the rest are defaults). */
|
|
161
|
+
chosen: Partial<Record<Channel, boolean>>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface UsePreferencesResult {
|
|
165
|
+
rows: PreferenceRow[];
|
|
166
|
+
status: "loading" | "ready" | "error";
|
|
167
|
+
error: Error | null;
|
|
168
|
+
saving: boolean;
|
|
169
|
+
/** Turn one category's channel on or off; `null` goes back to the default. Critical rows can't be turned off. */
|
|
170
|
+
set: (category: string, channel: Channel, on: boolean | null) => Promise<void>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The matrix with the person's choices applied. Exported for tests and custom screens. */
|
|
174
|
+
export function preferenceRows(registry: NotificationRegistry, choices: ChannelChoices): PreferenceRow[] {
|
|
175
|
+
return registry.matrix().map((row) => {
|
|
176
|
+
const chosen = choices[row.id] ?? {};
|
|
177
|
+
const values: Partial<Record<Channel, boolean>> = {};
|
|
178
|
+
for (const ch of row.channels) values[ch] = row.critical ? Boolean(row.defaults[ch]) : (chosen[ch] ?? Boolean(row.defaults[ch]));
|
|
179
|
+
return { ...row, values, chosen: row.critical ? {} : chosen };
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The person's preferences by category and channel. A tenant's default
|
|
185
|
+
* isn't shown here (the server applies it); what shows for a channel the
|
|
186
|
+
* person never chose is the type's default.
|
|
187
|
+
*
|
|
188
|
+
* @stability Beta
|
|
189
|
+
*/
|
|
190
|
+
export function usePreferences(): UsePreferencesResult {
|
|
191
|
+
const { client, registry, userId } = useNotificationsContext();
|
|
192
|
+
const queryClient = useQueryClient();
|
|
193
|
+
const key = notificationKeys.choices(userId);
|
|
194
|
+
const query = useQuery({ queryKey: key, queryFn: () => client.choices(userId!), enabled: userId !== null });
|
|
195
|
+
const save = useMutation({
|
|
196
|
+
mutationFn: ({ category, channel, on }: { category: string; channel: Channel; on: boolean | null }) => client.setChoice(category, channel, on),
|
|
197
|
+
onMutate: async ({ category, channel, on }) => {
|
|
198
|
+
await queryClient.cancelQueries({ queryKey: key });
|
|
199
|
+
const before = queryClient.getQueryData<ChannelChoices>(key);
|
|
200
|
+
const next: ChannelChoices = { ...(before ?? {}) };
|
|
201
|
+
const row = { ...(next[category] ?? {}) };
|
|
202
|
+
if (on === null) delete row[channel];
|
|
203
|
+
else row[channel] = on;
|
|
204
|
+
next[category] = row;
|
|
205
|
+
queryClient.setQueryData(key, next);
|
|
206
|
+
return { before };
|
|
207
|
+
},
|
|
208
|
+
onError: (_e, _v, context) => queryClient.setQueryData(key, context?.before),
|
|
209
|
+
// Read back through `choices()` (which folds in 0005's category switches), never the raw row the save returns.
|
|
210
|
+
onSettled: () => queryClient.invalidateQueries({ queryKey: key }),
|
|
211
|
+
});
|
|
212
|
+
const rows = useMemo(() => preferenceRows(registry, query.data ?? {}), [registry, query.data]);
|
|
213
|
+
return {
|
|
214
|
+
rows,
|
|
215
|
+
status: query.isError ? "error" : query.isSuccess ? "ready" : "loading",
|
|
216
|
+
error: ((query.error ?? save.error) as Error | null) ?? null,
|
|
217
|
+
saving: save.isPending,
|
|
218
|
+
set: async (category, channel, on) => {
|
|
219
|
+
const row = rows.find((r) => r.id === category);
|
|
220
|
+
if (!row || !row.channels.includes(channel) || (row.critical && on === false)) return;
|
|
221
|
+
await save.mutateAsync({ category, channel, on });
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export { notificationsClient, type NotificationsClient } from "../supabase/index";
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@marktiderman/genesis-notifications/runner`: the one outbox runner. Claim a
|
|
3
|
+
* batch of due jobs, send them (grouped, so a destination that takes many at
|
|
4
|
+
* once gets one request), and record what happened. No dependencies.
|
|
5
|
+
*
|
|
6
|
+
* The store is the caller's (a SQL outbox claimed with SKIP LOCKED, a
|
|
7
|
+
* queue): `claim` hands over leased jobs and `record` takes back outcomes.
|
|
8
|
+
* The runner owns the rest: turning each send result into an outcome with a
|
|
9
|
+
* retry time, jittered backoff, the deferral cap, and never throwing for one
|
|
10
|
+
* job's failure. The notifier's push and email channels run on it, and so do
|
|
11
|
+
* genesis-forms' destinations (Sheets, webhooks), each with its own claim.
|
|
12
|
+
*
|
|
13
|
+
* Outcomes:
|
|
14
|
+
* - `sent`: delivered (or already there, on a redelivery: `alreadyThere`).
|
|
15
|
+
* - `filtered`: the sender decided it doesn't apply; nothing sent, nothing to retry.
|
|
16
|
+
* - `skipped`: deliberately not sent (`notSent`: a reason safe to store); never retried.
|
|
17
|
+
* - `retry`: a retryable failure. With the service's own Retry-After it is
|
|
18
|
+
* deferred without counting an attempt (`counted: false`), until
|
|
19
|
+
* `maxDeferrals`; otherwise it backs off by attempt (`counted: true`), until
|
|
20
|
+
* `maxAttempts`.
|
|
21
|
+
* - `failed`: permanent, or out of attempts. With `disable: true` the receiver
|
|
22
|
+
* asked to be switched off (a 410 Gone): stop sending to it.
|
|
23
|
+
*
|
|
24
|
+
* It never logs content, addresses or secrets: only what `label` gives and
|
|
25
|
+
* the senders' safe error messages.
|
|
26
|
+
*
|
|
27
|
+
* @packageDocumentation
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** One claimed job. Extend it with what your sender needs. */
|
|
31
|
+
export interface RunnerJob {
|
|
32
|
+
/** Stable across retries (an idempotency key is made from it). */
|
|
33
|
+
id: string;
|
|
34
|
+
/** This attempt's number, from 1. */
|
|
35
|
+
attempt: number;
|
|
36
|
+
/** Deferrals so far (outcomes with `counted: false`); pass it back so `maxDeferrals` holds. Default 0. */
|
|
37
|
+
deferrals?: number | undefined;
|
|
38
|
+
/** An earlier attempt may have gone through (its lease ran out): senders that can look first do. */
|
|
39
|
+
redelivery?: boolean | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What a sender says about one job. */
|
|
43
|
+
export type JobResult =
|
|
44
|
+
| {
|
|
45
|
+
ok: true;
|
|
46
|
+
/** Deliberately sent nothing; the reason, safe to store. Recorded as `skipped`. */
|
|
47
|
+
notSent?: string | undefined;
|
|
48
|
+
/** It was already there (a redelivery), so nothing was sent this time. */
|
|
49
|
+
alreadyThere?: boolean | undefined;
|
|
50
|
+
}
|
|
51
|
+
| JobFailure;
|
|
52
|
+
|
|
53
|
+
export interface JobFailure {
|
|
54
|
+
ok: false;
|
|
55
|
+
/** The same request may succeed later (rate limited, the service down, a timeout). */
|
|
56
|
+
retryable: boolean;
|
|
57
|
+
/** The service said when to come back: retry after this many seconds, without counting an attempt. */
|
|
58
|
+
retryAfterSeconds?: number | undefined;
|
|
59
|
+
status?: number | undefined;
|
|
60
|
+
/** The receiver asked to be switched off. Always with `retryable: false`. */
|
|
61
|
+
disable?: true | undefined;
|
|
62
|
+
/** Safe to store and log: never secrets, content or addresses. */
|
|
63
|
+
error: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type JobOutcome =
|
|
67
|
+
| { status: "sent"; alreadyThere?: boolean }
|
|
68
|
+
| { status: "filtered" }
|
|
69
|
+
| { status: "skipped"; reason: string }
|
|
70
|
+
| {
|
|
71
|
+
status: "retry";
|
|
72
|
+
error: string;
|
|
73
|
+
retryAt: Date;
|
|
74
|
+
retryAfterSeconds: number;
|
|
75
|
+
/** Whether this attempt counts toward `maxAttempts`. False when the service said when to come back. */
|
|
76
|
+
counted: boolean;
|
|
77
|
+
httpStatus?: number;
|
|
78
|
+
}
|
|
79
|
+
| { status: "failed"; error: string; httpStatus?: number; disable?: true };
|
|
80
|
+
|
|
81
|
+
export interface RunReport {
|
|
82
|
+
claimed: number;
|
|
83
|
+
sent: number;
|
|
84
|
+
filtered: number;
|
|
85
|
+
skipped: number;
|
|
86
|
+
/** Retryable failures, backed off by attempt. */
|
|
87
|
+
retrying: number;
|
|
88
|
+
/** Retryable failures the service said when to retry; no attempt used. */
|
|
89
|
+
deferred: number;
|
|
90
|
+
failed: number;
|
|
91
|
+
/** Recorded after the claim was lost (`record` resolved false). */
|
|
92
|
+
stale: number;
|
|
93
|
+
/** `record` threw. */
|
|
94
|
+
unrecorded: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ClaimRequest<Filter> {
|
|
98
|
+
limit: number;
|
|
99
|
+
filter?: Filter | undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface SendContext {
|
|
103
|
+
signal?: AbortSignal | undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface RunnerOptions<J extends RunnerJob, Filter = undefined> {
|
|
107
|
+
/** Claims up to `limit` due jobs, each leased so no other runner sends it meanwhile. */
|
|
108
|
+
claim(request: ClaimRequest<Filter>): Promise<readonly J[]>;
|
|
109
|
+
/**
|
|
110
|
+
* Sends one group of jobs (those with the same `group` key) and resolves one
|
|
111
|
+
* result per job, in order, or `"filtered"`. A throw fails the whole group
|
|
112
|
+
* with a retryable error.
|
|
113
|
+
*/
|
|
114
|
+
send(group: readonly J[], context: SendContext): Promise<readonly (JobResult | "filtered")[]>;
|
|
115
|
+
/** Jobs with the same key are sent together. Default: each job alone. */
|
|
116
|
+
group?: ((job: J) => string) | undefined;
|
|
117
|
+
/**
|
|
118
|
+
* Records an outcome. Resolve `false` when the claim was no longer held: it
|
|
119
|
+
* is counted as stale. A rejection is counted as unrecorded; the lease runs
|
|
120
|
+
* out and the job comes back on its own.
|
|
121
|
+
*/
|
|
122
|
+
record(job: J, outcome: JobOutcome): Promise<void | boolean>;
|
|
123
|
+
now?: (() => number) | undefined;
|
|
124
|
+
/** Safe lines (ids, kinds, short reasons). Default: nothing is logged. */
|
|
125
|
+
log?: ((message: string) => void) | undefined;
|
|
126
|
+
/** How a job is named in a log line, capitalised ("Delivery d1 (email)"). Default `Job <id>`. */
|
|
127
|
+
label?: ((job: J) => string) | undefined;
|
|
128
|
+
/** How a job is named when recording it failed ("delivery d1"). Default `job <id>`. */
|
|
129
|
+
recordLabel?: ((job: J) => string) | undefined;
|
|
130
|
+
/** Jobs claimed per run. Default 25. */
|
|
131
|
+
batchSize?: number | undefined;
|
|
132
|
+
/** Counted attempts before a retryable failure is given up. Default 8. */
|
|
133
|
+
maxAttempts?: number | undefined;
|
|
134
|
+
/** Deferrals allowed before a further one counts as an attempt. Default 24. */
|
|
135
|
+
maxDeferrals?: number | undefined;
|
|
136
|
+
/** Seconds to wait after a counted failed attempt. Default `defaultBackoff`. */
|
|
137
|
+
backoff?: ((attempt: number) => number) | undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface RunOptions<Filter> {
|
|
141
|
+
filter?: Filter | undefined;
|
|
142
|
+
/** Overrides `batchSize` for this run. */
|
|
143
|
+
limit?: number | undefined;
|
|
144
|
+
/** Aborts the sends in flight. */
|
|
145
|
+
signal?: AbortSignal | undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface Runner<Filter = undefined> {
|
|
149
|
+
/** One claim, its sends and their records. Never rejects for a job's failure; a failing `claim` rejects. */
|
|
150
|
+
run(options?: RunOptions<Filter>): Promise<RunReport>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const DEFAULT_MAX_ATTEMPTS = 8;
|
|
154
|
+
export const DEFAULT_MAX_DEFERRALS = 24;
|
|
155
|
+
export const DEFAULT_BATCH_SIZE = 25;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 5 minutes, quadrupling each attempt, at most 24 hours: 5m, 20m, 80m, 5h20m,
|
|
159
|
+
* 21h20m, then 24h; about 3 days across 8 attempts. Each wait is jittered by
|
|
160
|
+
* up to ±20%, so jobs that failed together don't all come back at once.
|
|
161
|
+
*/
|
|
162
|
+
export function defaultBackoff(attempt: number, random?: () => number): number {
|
|
163
|
+
const nominal = Math.min(24 * 3600, 300 * 4 ** Math.max(0, attempt - 1));
|
|
164
|
+
// A function only: `[1, 2].map(defaultBackoff)` passes an index here.
|
|
165
|
+
const r = typeof random === "function" ? random() : Math.random();
|
|
166
|
+
return Math.round(nominal * (0.8 + 0.4 * Math.min(1, Math.max(0, r))));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Turns a send result into an outcome for a job on its `attempt`. */
|
|
170
|
+
export function jobOutcome(
|
|
171
|
+
result: JobResult,
|
|
172
|
+
attempt: number,
|
|
173
|
+
options: {
|
|
174
|
+
now?: number | undefined;
|
|
175
|
+
maxAttempts?: number | undefined;
|
|
176
|
+
backoff?: ((attempt: number) => number) | undefined;
|
|
177
|
+
deferrals?: number | undefined;
|
|
178
|
+
maxDeferrals?: number | undefined;
|
|
179
|
+
} = {},
|
|
180
|
+
): JobOutcome {
|
|
181
|
+
if (result.ok) {
|
|
182
|
+
if (result.notSent) return { status: "skipped", reason: result.notSent.slice(0, 500) };
|
|
183
|
+
return result.alreadyThere ? { status: "sent", alreadyThere: true } : { status: "sent" };
|
|
184
|
+
}
|
|
185
|
+
const http = result.status !== undefined ? { httpStatus: result.status } : {};
|
|
186
|
+
if (result.disable) return { status: "failed", error: result.error, ...http, disable: true };
|
|
187
|
+
if (!result.retryable) return { status: "failed", error: result.error, ...http };
|
|
188
|
+
const now = options.now ?? Date.now();
|
|
189
|
+
if (result.retryAfterSeconds !== undefined && (options.deferrals ?? 0) < (options.maxDeferrals ?? DEFAULT_MAX_DEFERRALS)) {
|
|
190
|
+
const seconds = Math.max(1, Math.ceil(result.retryAfterSeconds));
|
|
191
|
+
return { status: "retry", error: result.error, retryAt: new Date(now + seconds * 1000), retryAfterSeconds: seconds, counted: false, ...http };
|
|
192
|
+
}
|
|
193
|
+
const max = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
194
|
+
if (attempt >= max) return { status: "failed", error: `${result.error} (gave up after ${attempt} attempts)`.slice(0, 500), ...http };
|
|
195
|
+
const seconds = Math.max(1, Math.ceil((options.backoff ?? defaultBackoff)(attempt)));
|
|
196
|
+
return { status: "retry", error: result.error, retryAt: new Date(now + seconds * 1000), retryAfterSeconds: seconds, counted: true, ...http };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A thrown error as a retryable failure with a safe message. */
|
|
200
|
+
export function failureOf(error: unknown): JobFailure {
|
|
201
|
+
const e = error as { name?: unknown; message?: unknown; retryable?: unknown; retryAfterSeconds?: unknown; status?: unknown; disable?: unknown } | null;
|
|
202
|
+
if (e && typeof e === "object" && typeof e.retryable === "boolean" && typeof e.message === "string") {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
retryable: e.disable === true ? false : e.retryable,
|
|
206
|
+
error: e.message.slice(0, 500),
|
|
207
|
+
...(typeof e.retryAfterSeconds === "number" ? { retryAfterSeconds: e.retryAfterSeconds } : {}),
|
|
208
|
+
...(typeof e.status === "number" ? { status: e.status } : {}),
|
|
209
|
+
...(e.disable === true ? { disable: true as const } : {}),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const timeout = e && typeof e === "object" && (e.name === "TimeoutError" || e.name === "AbortError");
|
|
213
|
+
return { ok: false, retryable: true, error: timeout ? "Timed out" : "Unexpected error" };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The runner.
|
|
218
|
+
*
|
|
219
|
+
* @stability Beta
|
|
220
|
+
*/
|
|
221
|
+
export function createRunner<J extends RunnerJob, Filter = undefined>(options: RunnerOptions<J, Filter>): Runner<Filter> {
|
|
222
|
+
const log = (message: string): void => {
|
|
223
|
+
try {
|
|
224
|
+
options.log?.(message);
|
|
225
|
+
} catch {
|
|
226
|
+
// A logger that throws never stops an outcome being recorded.
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
const label = (job: J) => options.label?.(job) ?? `Job ${job.id}`;
|
|
230
|
+
const recordLabel = (job: J) => options.recordLabel?.(job) ?? `job ${job.id}`;
|
|
231
|
+
const now = () => options.now?.() ?? Date.now();
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
async run(runOptions: RunOptions<Filter> = {}): Promise<RunReport> {
|
|
235
|
+
const report: RunReport = { claimed: 0, sent: 0, filtered: 0, skipped: 0, retrying: 0, deferred: 0, failed: 0, stale: 0, unrecorded: 0 };
|
|
236
|
+
const limit = runOptions.limit ?? options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
237
|
+
const claimed = await options.claim({ limit, filter: runOptions.filter });
|
|
238
|
+
report.claimed = claimed.length;
|
|
239
|
+
|
|
240
|
+
const groups = new Map<string, J[]>();
|
|
241
|
+
claimed.forEach((job, i) => {
|
|
242
|
+
const key = options.group ? options.group(job) : `\u0000${i}`;
|
|
243
|
+
const list = groups.get(key) ?? [];
|
|
244
|
+
list.push(job);
|
|
245
|
+
groups.set(key, list);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await Promise.all(
|
|
249
|
+
[...groups.values()].map(async (group) => {
|
|
250
|
+
let results: readonly (JobResult | "filtered")[];
|
|
251
|
+
try {
|
|
252
|
+
results = await options.send(group, { signal: runOptions.signal });
|
|
253
|
+
if (results.length !== group.length) throw new Error("send answered the wrong number of results");
|
|
254
|
+
} catch (error) {
|
|
255
|
+
const failure = failureOf(error);
|
|
256
|
+
results = group.map(() => failure);
|
|
257
|
+
}
|
|
258
|
+
const at = now();
|
|
259
|
+
await Promise.all(
|
|
260
|
+
group.map(async (job, i) => {
|
|
261
|
+
const result = results[i]!;
|
|
262
|
+
const outcome: JobOutcome =
|
|
263
|
+
result === "filtered"
|
|
264
|
+
? { status: "filtered" }
|
|
265
|
+
: jobOutcome(result, job.attempt, {
|
|
266
|
+
now: at,
|
|
267
|
+
maxAttempts: options.maxAttempts,
|
|
268
|
+
backoff: options.backoff,
|
|
269
|
+
deferrals: job.deferrals,
|
|
270
|
+
maxDeferrals: options.maxDeferrals,
|
|
271
|
+
});
|
|
272
|
+
if (outcome.status === "failed" || outcome.status === "retry") {
|
|
273
|
+
log(`${label(job)} ${outcome.status === "failed" ? "failed" : "will be retried"}: ${outcome.error}`);
|
|
274
|
+
} else if (outcome.status === "skipped") {
|
|
275
|
+
log(`${label(job)} skipped: ${outcome.reason}`);
|
|
276
|
+
}
|
|
277
|
+
let recorded: void | boolean;
|
|
278
|
+
try {
|
|
279
|
+
recorded = await options.record(job, outcome);
|
|
280
|
+
} catch {
|
|
281
|
+
report.unrecorded++;
|
|
282
|
+
log(`Recording ${recordLabel(job)} failed`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (recorded === false) report.stale++;
|
|
286
|
+
else if (outcome.status === "sent") report.sent++;
|
|
287
|
+
else if (outcome.status === "filtered") report.filtered++;
|
|
288
|
+
else if (outcome.status === "skipped") report.skipped++;
|
|
289
|
+
else if (outcome.status === "failed") report.failed++;
|
|
290
|
+
else if (outcome.counted) report.retrying++;
|
|
291
|
+
else report.deferred++;
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
return report;
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Small WebCrypto helpers (Node 20+, Deno, browsers, workers). */
|
|
2
|
+
const encoder = new TextEncoder();
|
|
3
|
+
|
|
4
|
+
export function base64url(bytes: Uint8Array): string {
|
|
5
|
+
let s = "";
|
|
6
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
7
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function fromBase64url(text: string): Uint8Array {
|
|
11
|
+
const s = text.replace(/-/g, "+").replace(/_/g, "/");
|
|
12
|
+
const bin = atob(s + "=".repeat((4 - (s.length % 4)) % 4));
|
|
13
|
+
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function fromBase64(text: string): Uint8Array {
|
|
17
|
+
const bin = atob(text);
|
|
18
|
+
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function toBase64(bytes: Uint8Array): string {
|
|
22
|
+
let s = "";
|
|
23
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
24
|
+
return btoa(s);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function hmacSha256(key: Uint8Array | string, message: string): Promise<Uint8Array> {
|
|
28
|
+
const raw = typeof key === "string" ? encoder.encode(key) : key;
|
|
29
|
+
const k = await crypto.subtle.importKey("raw", raw as BufferSource, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
30
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", k, encoder.encode(message)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function sha256Hex(text: string): Promise<string> {
|
|
34
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(text)));
|
|
35
|
+
return [...digest].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Equal bytes, in time that doesn't depend on where they differ. */
|
|
39
|
+
export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
40
|
+
if (a.length !== b.length) return false;
|
|
41
|
+
let diff = 0;
|
|
42
|
+
for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
|
|
43
|
+
return diff === 0;
|
|
44
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is a sender domain the tenant's, and ready to send from? An ownership TXT
|
|
3
|
+
* record holding the tenant's own token (so one tenant can't verify another's
|
|
4
|
+
* domain), SPF on Resend's return-path host, DKIM at Resend's selector, and a
|
|
5
|
+
* DMARC record, read through DNS over HTTPS so it runs anywhere `fetch` does.
|
|
6
|
+
* The app's own shared sending domains (and anything under them) are never a
|
|
7
|
+
* tenant's.
|
|
8
|
+
*/
|
|
9
|
+
import { timedFetch, type HttpDeps } from "./errors";
|
|
10
|
+
|
|
11
|
+
export interface DomainCheck {
|
|
12
|
+
/** `_genesis-notifications.<domain>` holds `genesis-notifications-verify=<token>`. */
|
|
13
|
+
ownership: boolean;
|
|
14
|
+
spf: boolean;
|
|
15
|
+
dkim: boolean;
|
|
16
|
+
dmarc: boolean;
|
|
17
|
+
/** The domain is (or is under) one of the app's own shared domains: never a tenant's. */
|
|
18
|
+
reserved: boolean;
|
|
19
|
+
/** Owned, all three records, and not reserved. */
|
|
20
|
+
ok: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DomainCheckOptions extends HttpDeps {
|
|
24
|
+
/** The sender's `domain_token` (from `notification_senders`; made when the domain was set). */
|
|
25
|
+
token: string;
|
|
26
|
+
/** The app's shared sending domains (`email.domains`): a tenant may never verify one, or one under it. */
|
|
27
|
+
reservedDomains: readonly string[];
|
|
28
|
+
/** Default Cloudflare's JSON resolver. */
|
|
29
|
+
resolver?: string;
|
|
30
|
+
/** Resend's DKIM selector. Default `resend`. */
|
|
31
|
+
dkimSelector?: string;
|
|
32
|
+
/** Where the SPF record lives. Default `send.<domain>` (Resend's return path). */
|
|
33
|
+
spfHost?: string;
|
|
34
|
+
/** What the SPF record includes. Default `amazonses.com` (Resend's). */
|
|
35
|
+
spfInclude?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DOMAIN = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
39
|
+
|
|
40
|
+
/** The ownership record's host. */
|
|
41
|
+
export const SENDER_OWNERSHIP_HOST = "_genesis-notifications";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The TXT record a tenant adds to prove `domain` is theirs: show it beside
|
|
45
|
+
* Resend's SPF and DKIM records in your sender settings.
|
|
46
|
+
*
|
|
47
|
+
* @stability Beta
|
|
48
|
+
*/
|
|
49
|
+
export function senderOwnershipRecord(domain: string, token: string): { type: "TXT"; name: string; value: string } {
|
|
50
|
+
return { type: "TXT", name: `${SENDER_OWNERSHIP_HOST}.${domain}`, value: `genesis-notifications-verify=${token}` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether `domain` is one of `reserved`, or under one. */
|
|
54
|
+
export function isReservedDomain(domain: string, reserved: readonly (string | undefined | null)[]): boolean {
|
|
55
|
+
const d = domain.toLowerCase().replace(/\.$/, "");
|
|
56
|
+
return reserved.some((r) => {
|
|
57
|
+
const x = r?.toLowerCase().replace(/\.$/, "");
|
|
58
|
+
return !!x && (d === x || d.endsWith(`.${x}`));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function txt(options: DomainCheckOptions, name: string): Promise<string[]> {
|
|
63
|
+
const url = new URL(options.resolver ?? "https://cloudflare-dns.com/dns-query");
|
|
64
|
+
url.searchParams.set("name", name);
|
|
65
|
+
url.searchParams.set("type", "TXT");
|
|
66
|
+
const response = await timedFetch(options, url.toString(), { headers: { accept: "application/dns-json" } });
|
|
67
|
+
if (!response.ok) return [];
|
|
68
|
+
const json = (await response.json()) as { Answer?: { type?: number; data?: string }[] };
|
|
69
|
+
return (json.Answer ?? [])
|
|
70
|
+
.filter((a) => a.type === 16 && typeof a.data === "string")
|
|
71
|
+
// A TXT answer comes quoted, maybe in several strings: "v=spf1 " "include:…"
|
|
72
|
+
.map((a) => a.data!.replace(/"\s*"/g, "").replace(/^"|"$/g, ""));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Checks ownership, SPF, DKIM and DMARC for `domain`. Record the result with
|
|
77
|
+
* `genesis_notifications_set_sender_dns(tenant, domain, token, ok ? 'verified' : 'failed', result)`
|
|
78
|
+
* with the same token, so a check made before the tenant changed its domain changes nothing.
|
|
79
|
+
*
|
|
80
|
+
* @stability Beta
|
|
81
|
+
*/
|
|
82
|
+
export async function checkSenderDomain(domain: string, options: DomainCheckOptions): Promise<DomainCheck> {
|
|
83
|
+
if (!DOMAIN.test(domain)) throw new Error("checkSenderDomain: not a domain name");
|
|
84
|
+
if (typeof options?.token !== "string" || !/^[0-9a-f]{32}$/.test(options.token)) throw new Error("checkSenderDomain: token is the sender's domain_token");
|
|
85
|
+
if (!Array.isArray(options.reservedDomains) || options.reservedDomains.length === 0) {
|
|
86
|
+
throw new Error("checkSenderDomain: reservedDomains (the app's shared sending domains) is required");
|
|
87
|
+
}
|
|
88
|
+
const reserved = isReservedDomain(domain, options.reservedDomains);
|
|
89
|
+
if (reserved) return { ownership: false, spf: false, dkim: false, dmarc: false, reserved: true, ok: false };
|
|
90
|
+
const include = options.spfInclude ?? "amazonses.com";
|
|
91
|
+
const proof = senderOwnershipRecord(domain, options.token);
|
|
92
|
+
const [owner, spf, dkim, dmarc] = await Promise.all([
|
|
93
|
+
txt(options, proof.name),
|
|
94
|
+
txt(options, options.spfHost ?? `send.${domain}`),
|
|
95
|
+
txt(options, `${options.dkimSelector ?? "resend"}._domainkey.${domain}`),
|
|
96
|
+
txt(options, `_dmarc.${domain}`),
|
|
97
|
+
]);
|
|
98
|
+
const result = {
|
|
99
|
+
ownership: owner.some((r) => r.trim() === proof.value),
|
|
100
|
+
spf: spf.some((r) => /^v=spf1\b/i.test(r) && r.toLowerCase().includes(`include:${include}`)),
|
|
101
|
+
dkim: dkim.some((r) => /(^|;)\s*p=[A-Za-z0-9+/=]{20,}/.test(r)),
|
|
102
|
+
dmarc: dmarc.some((r) => /^v=DMARC1\b/i.test(r)),
|
|
103
|
+
reserved: false,
|
|
104
|
+
};
|
|
105
|
+
return { ...result, ok: result.ownership && result.spf && result.dkim && result.dmarc };
|
|
106
|
+
}
|