@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
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
import { a as Channel, p as NotificationTypeRow, R as Recipient, l as NotificationRegistry, r as NotifyInput, s as NotifyResult } from './types-BilguCPW.js';
|
|
2
|
+
import { RunReport } from './runner.js';
|
|
3
|
+
import { a as EmailTemplate } from './email-render-CID_pXDy.js';
|
|
4
|
+
export { b as EmailContent, E as EmailTemplateContext, c as canonicalJson, d as defaultEmailTemplate, e as displayName, f as escapeHtml, h as headerSafe, i as isSafeAddress } from './email-render-CID_pXDy.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The database half, through the service role: what the runner and the
|
|
8
|
+
* handler call. `supabaseNotificationStore(admin)` wraps migration 0001's
|
|
9
|
+
* functions; tests (and an app with another queue) pass their own.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type RpcResult = {
|
|
13
|
+
data: unknown;
|
|
14
|
+
error: {
|
|
15
|
+
message?: string;
|
|
16
|
+
code?: string | null;
|
|
17
|
+
} | null;
|
|
18
|
+
};
|
|
19
|
+
/** The part of a Supabase client the store uses, signed in with the service role (`SUPABASE_SECRET_KEY`). */
|
|
20
|
+
interface RpcClient {
|
|
21
|
+
rpc: (fn: any, args?: any) => PromiseLike<RpcResult>;
|
|
22
|
+
}
|
|
23
|
+
/** A database refusal, with its SQLSTATE; never the row it refused. */
|
|
24
|
+
declare class NotificationStoreError extends Error {
|
|
25
|
+
readonly operation: string;
|
|
26
|
+
readonly code: string | null;
|
|
27
|
+
constructor(operation: string, code: string | null);
|
|
28
|
+
}
|
|
29
|
+
interface ClaimedDevice {
|
|
30
|
+
id: string;
|
|
31
|
+
token: string;
|
|
32
|
+
platform: string;
|
|
33
|
+
}
|
|
34
|
+
interface SenderIdentity {
|
|
35
|
+
from_name: string | null;
|
|
36
|
+
reply_to: string | null;
|
|
37
|
+
domain: string | null;
|
|
38
|
+
domain_verified: boolean;
|
|
39
|
+
}
|
|
40
|
+
/** One claimed send job, as genesis_notifications_claim hands it over. */
|
|
41
|
+
interface ClaimedJob {
|
|
42
|
+
id: string;
|
|
43
|
+
claim_id: string;
|
|
44
|
+
notification_id: string;
|
|
45
|
+
channel: Exclude<Channel, "in_app">;
|
|
46
|
+
attempt: number;
|
|
47
|
+
deferrals: number;
|
|
48
|
+
redelivery: boolean;
|
|
49
|
+
type: string;
|
|
50
|
+
category: string;
|
|
51
|
+
tenant_id: string | null;
|
|
52
|
+
user_id: string | null;
|
|
53
|
+
email: string | null;
|
|
54
|
+
recipient_name: string | null;
|
|
55
|
+
data: Record<string, unknown>;
|
|
56
|
+
thread_key: string | null;
|
|
57
|
+
collapse_key: string | null;
|
|
58
|
+
badge: number | null;
|
|
59
|
+
brand: Record<string, unknown> | null;
|
|
60
|
+
transactional: boolean;
|
|
61
|
+
/** Preferences can't turn it off, so its email has no unsubscribe link. */
|
|
62
|
+
critical: boolean;
|
|
63
|
+
push_privacy: "default" | "generic";
|
|
64
|
+
ios_category: string | null;
|
|
65
|
+
hide_previews: boolean;
|
|
66
|
+
devices: ClaimedDevice[] | null;
|
|
67
|
+
sender: SenderIdentity | null;
|
|
68
|
+
rendered: RenderedEmail | null;
|
|
69
|
+
rendered_hash: string | null;
|
|
70
|
+
secret: string | null;
|
|
71
|
+
}
|
|
72
|
+
/** An email as first rendered: what every retry sends. */
|
|
73
|
+
interface RenderedEmail {
|
|
74
|
+
from: string;
|
|
75
|
+
to: string[];
|
|
76
|
+
reply_to?: string;
|
|
77
|
+
subject: string;
|
|
78
|
+
html: string;
|
|
79
|
+
text: string;
|
|
80
|
+
headers?: Record<string, string>;
|
|
81
|
+
tags?: {
|
|
82
|
+
name: string;
|
|
83
|
+
value: string;
|
|
84
|
+
}[];
|
|
85
|
+
}
|
|
86
|
+
type SettleOutcome = {
|
|
87
|
+
status: "sent";
|
|
88
|
+
provider_id?: string | null;
|
|
89
|
+
} | {
|
|
90
|
+
status: "skipped";
|
|
91
|
+
reason: string;
|
|
92
|
+
} | {
|
|
93
|
+
status: "filtered";
|
|
94
|
+
} | {
|
|
95
|
+
status: "failed";
|
|
96
|
+
error: string;
|
|
97
|
+
provider_id?: string | null;
|
|
98
|
+
} | {
|
|
99
|
+
status: "retry";
|
|
100
|
+
error: string;
|
|
101
|
+
retry_at: string;
|
|
102
|
+
counted: boolean;
|
|
103
|
+
provider_id?: string | null;
|
|
104
|
+
};
|
|
105
|
+
interface PushTicketRow {
|
|
106
|
+
device_id: string;
|
|
107
|
+
ticket_id: string | null;
|
|
108
|
+
status: "sent" | "failed";
|
|
109
|
+
error: string | null;
|
|
110
|
+
}
|
|
111
|
+
interface NotificationStore {
|
|
112
|
+
/** `maxAttempts`: a job whose lease ran out that many times fails (`lease_expired`) instead of coming back. */
|
|
113
|
+
claim(channel: "push" | "email", limit: number, leaseSeconds: number, maxAttempts?: number): Promise<ClaimedJob[]>;
|
|
114
|
+
storeRendered(id: string, claimId: string, rendered: RenderedEmail, hash: string): Promise<{
|
|
115
|
+
rendered: RenderedEmail;
|
|
116
|
+
hash: string;
|
|
117
|
+
} | null>;
|
|
118
|
+
settle(id: string, claimId: string, outcome: SettleOutcome): Promise<boolean>;
|
|
119
|
+
/** Keeps the provider's id the moment a send returns, before it settles, so an early webhook finds the job. False when the claim is gone. */
|
|
120
|
+
recordProviderId(id: string, claimId: string, providerId: string): Promise<boolean>;
|
|
121
|
+
pushTickets(deliveryId: string, tickets: PushTicketRow[]): Promise<void>;
|
|
122
|
+
receiptsClaim(limit: number): Promise<{
|
|
123
|
+
id: number;
|
|
124
|
+
ticket_id: string;
|
|
125
|
+
}[]>;
|
|
126
|
+
receiptsSettle(results: {
|
|
127
|
+
id: number;
|
|
128
|
+
status: "delivered" | "failed";
|
|
129
|
+
error: string | null;
|
|
130
|
+
}[]): Promise<void>;
|
|
131
|
+
emailEvent(providerId: string, event: "delivered" | "bounced" | "complained", permanent: boolean): Promise<boolean>;
|
|
132
|
+
/** For a login (`userId`), or someone with none by the SHA-256 of their address; for one tenant's mail (`tenantId`), or mail sent for none. */
|
|
133
|
+
unsubscribe(userId: string | null, emailHash: string | null, category: string, tenantId: string | null): Promise<boolean>;
|
|
134
|
+
syncTypes(rows: NotificationTypeRow[]): Promise<number>;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The store over migration 0001's functions.
|
|
138
|
+
*
|
|
139
|
+
* @stability Beta
|
|
140
|
+
*/
|
|
141
|
+
declare function supabaseNotificationStore(client: RpcClient): NotificationStore;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* `notify()`: what a domain calls, from its server. It checks the input
|
|
145
|
+
* (the registry knows the type, the recipients are well formed, `data`
|
|
146
|
+
* holds no secret) and calls `genesis_notify`, which decides channels from
|
|
147
|
+
* preferences and queues the sends. A domain's own SQL can call
|
|
148
|
+
* `genesis_notify` directly with the same arguments.
|
|
149
|
+
*/
|
|
150
|
+
|
|
151
|
+
interface NotifyOptions {
|
|
152
|
+
/** Checks the type against the registry before the database does. */
|
|
153
|
+
registry?: NotificationRegistry;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Queue a notification for each recipient. Resolves one result per recipient
|
|
157
|
+
* that exists (a deleted login is left out); the same idempotency key again
|
|
158
|
+
* (for the same type and tenant) resolves `created: false` and sends nothing more.
|
|
159
|
+
*
|
|
160
|
+
* @stability Beta
|
|
161
|
+
*/
|
|
162
|
+
declare function notify<K extends string>(client: RpcClient, input: NotifyInput<K>, options?: NotifyOptions): Promise<NotifyResult[]>;
|
|
163
|
+
interface CancelInput {
|
|
164
|
+
/** The type the notifications were made with: a key is a domain's, per type. */
|
|
165
|
+
type: string;
|
|
166
|
+
/** The key the notifications were made with. */
|
|
167
|
+
idempotencyKey: string;
|
|
168
|
+
/** The tenant they were made for. Default: those made with no tenant (never another tenant's). */
|
|
169
|
+
tenantId?: string | null;
|
|
170
|
+
/** Only these recipients (as `notify()` takes them). Default: everyone the key reached. */
|
|
171
|
+
recipients?: readonly Recipient[];
|
|
172
|
+
/** Archive the feed rows too. Default false. */
|
|
173
|
+
archive?: boolean;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Stop what hasn't gone out for a type, tenant and idempotency key (a release
|
|
177
|
+
* rolled back, an event undone): every pending send job is marked cancelled
|
|
178
|
+
* and never claimed. A job already sent is left alone; one being sent right
|
|
179
|
+
* now ends cancelled unless that attempt goes through (it is never retried).
|
|
180
|
+
* With `archive`, the feed rows are archived too.
|
|
181
|
+
*
|
|
182
|
+
* @stability Beta
|
|
183
|
+
*/
|
|
184
|
+
declare function cancelNotification(client: RpcClient, input: CancelInput): Promise<{
|
|
185
|
+
cancelled: number;
|
|
186
|
+
archived: number;
|
|
187
|
+
}>;
|
|
188
|
+
/**
|
|
189
|
+
* Mirror the registry into `notification_types` (run it on deploy, or the
|
|
190
|
+
* server's first request): types left out are retired, never deleted.
|
|
191
|
+
*
|
|
192
|
+
* @stability Beta
|
|
193
|
+
*/
|
|
194
|
+
declare function syncNotificationTypes(client: RpcClient, registry: NotificationRegistry): Promise<number>;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A send failure with a message that is safe to store and log (a status and
|
|
198
|
+
* a provider's error code, never content, an address or a secret). The
|
|
199
|
+
* runner turns it into a retry or a failure.
|
|
200
|
+
*/
|
|
201
|
+
declare class SendError extends Error {
|
|
202
|
+
name: string;
|
|
203
|
+
readonly retryable: boolean;
|
|
204
|
+
readonly retryAfterSeconds: number | undefined;
|
|
205
|
+
readonly status: number | undefined;
|
|
206
|
+
constructor(message: string, options?: {
|
|
207
|
+
retryable?: boolean;
|
|
208
|
+
retryAfterSeconds?: number;
|
|
209
|
+
status?: number;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/** Whether an HTTP status is worth retrying unchanged: 408, 425, 429 and 5xx. */
|
|
213
|
+
declare function isRetryableStatus(status: number): boolean;
|
|
214
|
+
/** Seconds from a Retry-After header (delta-seconds or an HTTP date), else `fallback`. 1 s to a day. */
|
|
215
|
+
declare function retryAfterSeconds(header: string | null | undefined, fallback?: number, now?: number): number;
|
|
216
|
+
/** Every request gives up after this long. */
|
|
217
|
+
declare const REQUEST_TIMEOUT_MS = 10000;
|
|
218
|
+
interface HttpDeps {
|
|
219
|
+
/** Default the global `fetch`. */
|
|
220
|
+
fetch?: typeof fetch;
|
|
221
|
+
timeoutMs?: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Expo's push service: https://docs.expo.dev/push-notifications/sending-notifications/
|
|
226
|
+
*
|
|
227
|
+
* POST /--/api/v2/push/send up to 100 messages; one ticket each, in order
|
|
228
|
+
* POST /--/api/v2/push/getReceipts up to 1000 ticket ids; a receipt for each one ready
|
|
229
|
+
*
|
|
230
|
+
* Nothing here logs; an error names a status or Expo's error code, never a
|
|
231
|
+
* token or a message.
|
|
232
|
+
*/
|
|
233
|
+
|
|
234
|
+
declare const EXPO_SEND_URL = "https://exp.host/--/api/v2/push/send";
|
|
235
|
+
declare const EXPO_RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
|
|
236
|
+
/** Expo takes up to 100 messages per send. */
|
|
237
|
+
declare const EXPO_SEND_BATCH = 100;
|
|
238
|
+
/** And up to 1000 ticket ids per receipts request. */
|
|
239
|
+
declare const EXPO_RECEIPTS_BATCH = 1000;
|
|
240
|
+
/** One message for Expo. */
|
|
241
|
+
interface PushMessage {
|
|
242
|
+
to: string;
|
|
243
|
+
title?: string;
|
|
244
|
+
subtitle?: string;
|
|
245
|
+
body?: string;
|
|
246
|
+
/** Reaches the app (`notification.request.content.data`): ids and the link, never content. */
|
|
247
|
+
data?: Record<string, string | number | boolean | null>;
|
|
248
|
+
sound?: "default" | null;
|
|
249
|
+
badge?: number;
|
|
250
|
+
/** Android: the notification channel (one per category). */
|
|
251
|
+
channelId?: string;
|
|
252
|
+
/** The device category whose actions it shows (Reply, Mark as read). */
|
|
253
|
+
categoryId?: string;
|
|
254
|
+
/** iOS: groups notifications on the lock screen (a conversation). */
|
|
255
|
+
threadId?: string;
|
|
256
|
+
/** Replaces an earlier one with the same id, in transit (and on screen on iOS). */
|
|
257
|
+
collapseId?: string;
|
|
258
|
+
/** Android: replaces one already on screen with the same tag. */
|
|
259
|
+
tag?: string;
|
|
260
|
+
ttl?: number;
|
|
261
|
+
priority?: "default" | "normal" | "high";
|
|
262
|
+
/** iOS: lets the app's Notification Service Extension change it (rich push). */
|
|
263
|
+
mutableContent?: boolean;
|
|
264
|
+
}
|
|
265
|
+
type PushTicket = {
|
|
266
|
+
status: "ok";
|
|
267
|
+
id: string;
|
|
268
|
+
} | {
|
|
269
|
+
status: "error";
|
|
270
|
+
code: string;
|
|
271
|
+
};
|
|
272
|
+
type PushReceipt = {
|
|
273
|
+
status: "ok";
|
|
274
|
+
} | {
|
|
275
|
+
status: "error";
|
|
276
|
+
code: string;
|
|
277
|
+
};
|
|
278
|
+
/** Sends pushes and reads receipts; `expoPushProvider` is the built-in one. */
|
|
279
|
+
interface PushProvider {
|
|
280
|
+
readonly name: string;
|
|
281
|
+
/** At most `EXPO_SEND_BATCH` messages; one ticket per message, in order. Throws a `SendError` when the request failed. */
|
|
282
|
+
send(messages: readonly PushMessage[], options?: {
|
|
283
|
+
signal?: AbortSignal;
|
|
284
|
+
}): Promise<PushTicket[]>;
|
|
285
|
+
/** Receipts for at most `EXPO_RECEIPTS_BATCH` tickets; ids with none yet are left out. */
|
|
286
|
+
receipts(ids: readonly string[], options?: {
|
|
287
|
+
signal?: AbortSignal;
|
|
288
|
+
}): Promise<Record<string, PushReceipt>>;
|
|
289
|
+
}
|
|
290
|
+
interface ExpoOptions extends HttpDeps {
|
|
291
|
+
/** Expo's access token, for "Enhanced Security for Push Notifications" (turn it on). */
|
|
292
|
+
accessToken?: string;
|
|
293
|
+
sendUrl?: string;
|
|
294
|
+
receiptsUrl?: string;
|
|
295
|
+
}
|
|
296
|
+
/** An Expo error code (`DeviceNotRegistered`), safe to store; anything else is `Unknown`. */
|
|
297
|
+
declare function expoErrorCode(value: {
|
|
298
|
+
details?: {
|
|
299
|
+
error?: unknown;
|
|
300
|
+
};
|
|
301
|
+
} | undefined): string;
|
|
302
|
+
/** Expo refused the request itself (a 4xx other than 429): resend its messages one by one. */
|
|
303
|
+
declare function refusedOutright(error: unknown): boolean;
|
|
304
|
+
/**
|
|
305
|
+
* Expo's push service as a `PushProvider`.
|
|
306
|
+
*
|
|
307
|
+
* @stability Beta
|
|
308
|
+
*/
|
|
309
|
+
declare function expoPushProvider(options?: ExpoOptions): PushProvider;
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Resend's REST API as an `EmailProvider`. One key per app (`RESEND_API_KEY`);
|
|
313
|
+
* each tenant gets its own sender identity (`senderFor`), not its own key.
|
|
314
|
+
*
|
|
315
|
+
* Retries are byte-identical (the runner stores the first render), and every
|
|
316
|
+
* send carries an idempotency key, so Resend sends once:
|
|
317
|
+
* - 409 `invalid_idempotent_request`: the key was used with another body. The
|
|
318
|
+
* stored render never changes, so the body differed in what is built at send
|
|
319
|
+
* time: a secret link builder that isn't deterministic (it must give the same
|
|
320
|
+
* link for the same notification every time). The job fails loudly
|
|
321
|
+
* (`idempotency_body_changed`); it is never counted as sent.
|
|
322
|
+
* - 409 `concurrent_idempotent_requests`: another attempt is in flight: retry soon.
|
|
323
|
+
*
|
|
324
|
+
* Requests are paced (`perSecond`, default 5) under Resend's default limit of
|
|
325
|
+
* 10 requests a second per team, which every key of the team shares.
|
|
326
|
+
*/
|
|
327
|
+
|
|
328
|
+
/** One email, ready to send. Addresses and headers are already checked. */
|
|
329
|
+
interface EmailMessage {
|
|
330
|
+
from: string;
|
|
331
|
+
to: string[];
|
|
332
|
+
replyTo?: string;
|
|
333
|
+
subject: string;
|
|
334
|
+
html: string;
|
|
335
|
+
text: string;
|
|
336
|
+
/** Extra headers (`List-Unsubscribe`, `List-Unsubscribe-Post`). */
|
|
337
|
+
headers?: Record<string, string>;
|
|
338
|
+
/** Provider tags: name and value are letters, digits, "_" or "-". */
|
|
339
|
+
tags?: {
|
|
340
|
+
name: string;
|
|
341
|
+
value: string;
|
|
342
|
+
}[];
|
|
343
|
+
/** Stable across retries of one send job. */
|
|
344
|
+
idempotencyKey: string;
|
|
345
|
+
}
|
|
346
|
+
interface EmailSendResult {
|
|
347
|
+
/** The provider's id for the email (what its webhooks name). */
|
|
348
|
+
id: string | null;
|
|
349
|
+
/** The provider says it was sent before (the same idempotency key). */
|
|
350
|
+
alreadySent?: boolean;
|
|
351
|
+
}
|
|
352
|
+
/** Sends one email. Throws a `SendError` on failure. */
|
|
353
|
+
interface EmailProvider {
|
|
354
|
+
readonly name: string;
|
|
355
|
+
send(message: EmailMessage, options?: {
|
|
356
|
+
signal?: AbortSignal;
|
|
357
|
+
}): Promise<EmailSendResult>;
|
|
358
|
+
}
|
|
359
|
+
declare const RESEND_API = "https://api.resend.com/emails";
|
|
360
|
+
/** Requests a second the provider starts by default: half Resend's default team limit (10). */
|
|
361
|
+
declare const RESEND_DEFAULT_PER_SECOND = 5;
|
|
362
|
+
interface ResendOptions extends HttpDeps {
|
|
363
|
+
apiKey: string;
|
|
364
|
+
endpoint?: string;
|
|
365
|
+
/** Requests started per second, at most. Default `RESEND_DEFAULT_PER_SECOND` (5). Raise it only if Resend raised your team's limit. */
|
|
366
|
+
perSecond?: number;
|
|
367
|
+
/** For tests: the clock and the wait the pacer uses. */
|
|
368
|
+
now?: () => number;
|
|
369
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Resend as an `EmailProvider`.
|
|
373
|
+
*
|
|
374
|
+
* @stability Beta
|
|
375
|
+
*/
|
|
376
|
+
declare function resendProvider(options: ResendOptions): EmailProvider;
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Secret links: a link whose query is a credential (a one-time sign-in, an
|
|
380
|
+
* invite, a response's edit link). They never go in `notifications.data` or
|
|
381
|
+
* a push. The domain stores a reference (`{ response_id }`) and, for a token
|
|
382
|
+
* that exists only hashed, passes the plaintext as `notify({ secret })`; the
|
|
383
|
+
* runner builds the link at send time, for the email only.
|
|
384
|
+
*/
|
|
385
|
+
/** What a secret link is built from. */
|
|
386
|
+
interface SecretLinkRef {
|
|
387
|
+
/** The notification's `data` (references, never secrets). */
|
|
388
|
+
data: Record<string, unknown>;
|
|
389
|
+
/** The plaintext from `notify({ secret })`, decrypted for this send only. */
|
|
390
|
+
secret: string | null;
|
|
391
|
+
notificationId: string;
|
|
392
|
+
tenantId: string | null;
|
|
393
|
+
}
|
|
394
|
+
/** Type key → how its link is built. A builder signs the link with its own key, or puts `secret` in it. */
|
|
395
|
+
type SecretLinks = Readonly<Record<string, (ref: SecretLinkRef) => string | Promise<string>>>;
|
|
396
|
+
/** No builder for a type whose emails need one, or a builder that gave back something that isn't an https link. */
|
|
397
|
+
declare class SecretLinkError extends Error {
|
|
398
|
+
constructor(message: string);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The secret link for one notification, built server-side at send time.
|
|
402
|
+
* Throws a `SecretLinkError` when there is no builder for `type` or it gives
|
|
403
|
+
* something other than an `https:` URL (`http:` only for localhost).
|
|
404
|
+
*
|
|
405
|
+
* @stability Beta
|
|
406
|
+
*/
|
|
407
|
+
declare function resolveSecretLink(links: SecretLinks, type: string, ref: SecretLinkRef): Promise<string>;
|
|
408
|
+
/** What a template puts where the secret link goes; the runner swaps it in at send time, so the stored render never holds it. */
|
|
409
|
+
declare const SECRET_LINK_PLACEHOLDER = "https://genesis-secret-link.invalid/";
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* `createNotificationRunner`: sends what `notify()` queued, on the shared
|
|
413
|
+
* runner (`@marktiderman/genesis-notifications/runner`). Run `run()` every
|
|
414
|
+
* minute and `checkReceipts()` every 15 minutes (pg_cron with pg_net calling
|
|
415
|
+
* the handler, or any scheduler).
|
|
416
|
+
*
|
|
417
|
+
* Push: the claimed jobs' devices in Expo batches of 100 (a job's devices
|
|
418
|
+
* never split across two), each batch sent, its tickets recorded and its jobs
|
|
419
|
+
* settled on its own, and no batch started once its lease is about to run
|
|
420
|
+
* out (those jobs come straight back), with `threadId`,
|
|
421
|
+
* `categoryId`, `badge`, a `collapseId` (the collapse key, else the
|
|
422
|
+
* notification id, so a resend after a timeout replaces rather than repeats),
|
|
423
|
+
* and neutral text when the person hides previews or the type says so. The
|
|
424
|
+
* payload's `data` is the link, the notification id and the type, never the
|
|
425
|
+
* notification's own `data`. Tickets are recorded per device; a
|
|
426
|
+
* `DeviceNotRegistered` forgets the device.
|
|
427
|
+
*
|
|
428
|
+
* Email: rendered once at first claim and stored, so every retry sends the
|
|
429
|
+
* same bytes with the same idempotency key. From the tenant's verified
|
|
430
|
+
* domain, or the app's shared one with the tenant's name and reply-to;
|
|
431
|
+
* transactional and other mail from separate domains; RFC 8058 unsubscribe
|
|
432
|
+
* headers on everything that isn't transactional; a secret link swapped in
|
|
433
|
+
* at send time. The provider's id is kept the moment a send returns, before
|
|
434
|
+
* the job settles, so a webhook that comes first finds it.
|
|
435
|
+
*
|
|
436
|
+
* Links (a type's `render().url`) must be paths on the app's own origin
|
|
437
|
+
* (`/messages/42`): anything else (another host, `//host`, `javascript:`) is
|
|
438
|
+
* left out of the push and the email.
|
|
439
|
+
*/
|
|
440
|
+
|
|
441
|
+
interface EmailOptions {
|
|
442
|
+
provider: EmailProvider;
|
|
443
|
+
/** The from name when a tenant has none ("Example"). */
|
|
444
|
+
appName: string;
|
|
445
|
+
/**
|
|
446
|
+
* The app's shared sending domains, verified in Resend: transactional mail
|
|
447
|
+
* and other mail go from separate ones, so one's reputation never holds up
|
|
448
|
+
* the other. `other` defaults to `transactional`.
|
|
449
|
+
*/
|
|
450
|
+
domains: {
|
|
451
|
+
transactional: string;
|
|
452
|
+
other?: string;
|
|
453
|
+
};
|
|
454
|
+
/** The address's local part. Default `notifications`. */
|
|
455
|
+
localPart?: string;
|
|
456
|
+
/** Type key → template. A type without one gets `defaultEmailTemplate`. */
|
|
457
|
+
templates?: Readonly<Record<string, EmailTemplate>>;
|
|
458
|
+
defaultTemplate?: EmailTemplate;
|
|
459
|
+
/** How each secret-link type's link is built (`resolveSecretLink`). */
|
|
460
|
+
secretLinks?: SecretLinks;
|
|
461
|
+
/** Required when any type sends email that isn't transactional. */
|
|
462
|
+
unsubscribe?: {
|
|
463
|
+
url: string;
|
|
464
|
+
secret: string;
|
|
465
|
+
};
|
|
466
|
+
/**
|
|
467
|
+
* The provider for one job. Default the app's `provider`. The seam for a
|
|
468
|
+
* tenant-owned key later (read from Vault by id); not built in v1.
|
|
469
|
+
*/
|
|
470
|
+
providerFor?: (job: ClaimedJob) => EmailProvider;
|
|
471
|
+
}
|
|
472
|
+
interface NotificationRunnerOptions {
|
|
473
|
+
store: NotificationStore;
|
|
474
|
+
registry: NotificationRegistry;
|
|
475
|
+
push?: {
|
|
476
|
+
provider: PushProvider;
|
|
477
|
+
};
|
|
478
|
+
email?: EmailOptions;
|
|
479
|
+
/** The app's origin, for absolute links in emails (`https://app.example.com`). */
|
|
480
|
+
appUrl?: string;
|
|
481
|
+
/** Jobs claimed per channel per run. Default 100. */
|
|
482
|
+
batchSize?: number;
|
|
483
|
+
/** How long a claim holds a job. Default 300 seconds. */
|
|
484
|
+
leaseSeconds?: number;
|
|
485
|
+
/**
|
|
486
|
+
* How long one `run()` may take (your function's wall-clock limit, less a
|
|
487
|
+
* margin). With it, a run claims again while the last claim was full, and
|
|
488
|
+
* stops claiming once the time left is under the lease. Without it, a run
|
|
489
|
+
* claims once per channel. At least `leaseSeconds`.
|
|
490
|
+
*/
|
|
491
|
+
runBudgetMs?: number;
|
|
492
|
+
maxAttempts?: number;
|
|
493
|
+
maxDeferrals?: number;
|
|
494
|
+
/** Safe lines only (ids, statuses, error codes). */
|
|
495
|
+
log?: (message: string) => void;
|
|
496
|
+
now?: () => number;
|
|
497
|
+
}
|
|
498
|
+
interface NotificationRunner {
|
|
499
|
+
/** Sends what is due on every configured channel. */
|
|
500
|
+
run(options?: {
|
|
501
|
+
signal?: AbortSignal;
|
|
502
|
+
}): Promise<{
|
|
503
|
+
push: RunReport | null;
|
|
504
|
+
email: RunReport | null;
|
|
505
|
+
}>;
|
|
506
|
+
runPush(options?: {
|
|
507
|
+
signal?: AbortSignal;
|
|
508
|
+
}): Promise<RunReport>;
|
|
509
|
+
runEmail(options?: {
|
|
510
|
+
signal?: AbortSignal;
|
|
511
|
+
}): Promise<RunReport>;
|
|
512
|
+
/** Asks Expo for the receipts that are due and records them. */
|
|
513
|
+
checkReceipts(options?: {
|
|
514
|
+
signal?: AbortSignal;
|
|
515
|
+
budgetMs?: number;
|
|
516
|
+
}): Promise<{
|
|
517
|
+
checked: number;
|
|
518
|
+
settled: number;
|
|
519
|
+
}>;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* The runner.
|
|
523
|
+
*
|
|
524
|
+
* @stability Beta
|
|
525
|
+
*/
|
|
526
|
+
declare function createNotificationRunner(options: NotificationRunnerOptions): NotificationRunner;
|
|
527
|
+
|
|
528
|
+
interface NotificationsHandlerOptions {
|
|
529
|
+
runner: NotificationRunner;
|
|
530
|
+
store: NotificationStore;
|
|
531
|
+
/** The bearer secret a scheduler sends to /run and /receipts (32 characters or more). */
|
|
532
|
+
cronSecret: string;
|
|
533
|
+
/** The unsubscribe signing secret (the runner's `email.unsubscribe.secret`). */
|
|
534
|
+
unsubscribeSecret?: string;
|
|
535
|
+
/** Resend's webhook signing secret (`whsec_…`). */
|
|
536
|
+
resendWebhookSecret?: string;
|
|
537
|
+
/** Words on the unsubscribe page. */
|
|
538
|
+
copy?: Partial<{
|
|
539
|
+
title: string;
|
|
540
|
+
confirm: string;
|
|
541
|
+
button: string;
|
|
542
|
+
done: string;
|
|
543
|
+
invalid: string;
|
|
544
|
+
}>;
|
|
545
|
+
log?: (message: string) => void;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* The handler.
|
|
549
|
+
*
|
|
550
|
+
* @stability Beta
|
|
551
|
+
*/
|
|
552
|
+
declare function createNotificationsHandler(options: NotificationsHandlerOptions): (request: Request) => Promise<Response>;
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* A request pacer: at most `perSecond` requests start in any second, spaced
|
|
556
|
+
* evenly, however many callers wait at once. Each provider instance has its
|
|
557
|
+
* own; two runners running at once each keep to it, so set it for that.
|
|
558
|
+
*/
|
|
559
|
+
interface RateLimiter {
|
|
560
|
+
/** Resolves when the caller may start its request; rejects if `signal` aborts first. */
|
|
561
|
+
acquire(signal?: AbortSignal): Promise<void>;
|
|
562
|
+
}
|
|
563
|
+
interface RateLimiterOptions {
|
|
564
|
+
perSecond: number;
|
|
565
|
+
now?: () => number;
|
|
566
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
567
|
+
}
|
|
568
|
+
/** @stability Beta */
|
|
569
|
+
declare function createRateLimiter(options: RateLimiterOptions): RateLimiter;
|
|
570
|
+
|
|
571
|
+
interface UnsubscribeClaims {
|
|
572
|
+
/** The login, when the recipient has one. */
|
|
573
|
+
userId?: string;
|
|
574
|
+
/** SHA-256 (hex) of the lower-cased, trimmed address, when they have none (`emailHash`). */
|
|
575
|
+
emailHash?: string;
|
|
576
|
+
category: string;
|
|
577
|
+
/** The tenant the email came from; the unsubscribe holds for it only. */
|
|
578
|
+
tenantId?: string;
|
|
579
|
+
}
|
|
580
|
+
/** What the suppression list keys an address by: SHA-256 of it lower-cased and trimmed (as `genesis_notifications_email_hash`). */
|
|
581
|
+
declare function emailHash(email: string): Promise<string>;
|
|
582
|
+
/** A signed, URL-safe token for `claims`. */
|
|
583
|
+
declare function createUnsubscribeToken(secret: string, claims: UnsubscribeClaims): Promise<string>;
|
|
584
|
+
/** The claims of a token this secret signed, or null for anything else. */
|
|
585
|
+
declare function verifyUnsubscribeToken(secret: string, token: string): Promise<UnsubscribeClaims | null>;
|
|
586
|
+
/** The RFC 8058 headers for one email. `url` is your unsubscribe endpoint; the token goes in its query. */
|
|
587
|
+
declare function unsubscribeHeaders(url: string, token: string): Record<string, string>;
|
|
588
|
+
|
|
589
|
+
/** How far a webhook's timestamp may be from now: five minutes, as Svix checks. */
|
|
590
|
+
declare const WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
591
|
+
/** Whether `body` was signed with `secret`; false for anything stale, unsigned or malformed. */
|
|
592
|
+
declare function verifyResendWebhook(secret: string, headers: Headers, body: string, now?: number): Promise<boolean>;
|
|
593
|
+
/** What the notifier does with an email event; null for the events it doesn't use. */
|
|
594
|
+
declare function emailEventOf(payload: unknown): {
|
|
595
|
+
providerId: string;
|
|
596
|
+
event: "delivered" | "bounced" | "complained";
|
|
597
|
+
permanent: boolean;
|
|
598
|
+
} | null;
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Is a sender domain the tenant's, and ready to send from? An ownership TXT
|
|
602
|
+
* record holding the tenant's own token (so one tenant can't verify another's
|
|
603
|
+
* domain), SPF on Resend's return-path host, DKIM at Resend's selector, and a
|
|
604
|
+
* DMARC record, read through DNS over HTTPS so it runs anywhere `fetch` does.
|
|
605
|
+
* The app's own shared sending domains (and anything under them) are never a
|
|
606
|
+
* tenant's.
|
|
607
|
+
*/
|
|
608
|
+
|
|
609
|
+
interface DomainCheck {
|
|
610
|
+
/** `_genesis-notifications.<domain>` holds `genesis-notifications-verify=<token>`. */
|
|
611
|
+
ownership: boolean;
|
|
612
|
+
spf: boolean;
|
|
613
|
+
dkim: boolean;
|
|
614
|
+
dmarc: boolean;
|
|
615
|
+
/** The domain is (or is under) one of the app's own shared domains: never a tenant's. */
|
|
616
|
+
reserved: boolean;
|
|
617
|
+
/** Owned, all three records, and not reserved. */
|
|
618
|
+
ok: boolean;
|
|
619
|
+
}
|
|
620
|
+
interface DomainCheckOptions extends HttpDeps {
|
|
621
|
+
/** The sender's `domain_token` (from `notification_senders`; made when the domain was set). */
|
|
622
|
+
token: string;
|
|
623
|
+
/** The app's shared sending domains (`email.domains`): a tenant may never verify one, or one under it. */
|
|
624
|
+
reservedDomains: readonly string[];
|
|
625
|
+
/** Default Cloudflare's JSON resolver. */
|
|
626
|
+
resolver?: string;
|
|
627
|
+
/** Resend's DKIM selector. Default `resend`. */
|
|
628
|
+
dkimSelector?: string;
|
|
629
|
+
/** Where the SPF record lives. Default `send.<domain>` (Resend's return path). */
|
|
630
|
+
spfHost?: string;
|
|
631
|
+
/** What the SPF record includes. Default `amazonses.com` (Resend's). */
|
|
632
|
+
spfInclude?: string;
|
|
633
|
+
}
|
|
634
|
+
/** The ownership record's host. */
|
|
635
|
+
declare const SENDER_OWNERSHIP_HOST = "_genesis-notifications";
|
|
636
|
+
/**
|
|
637
|
+
* The TXT record a tenant adds to prove `domain` is theirs: show it beside
|
|
638
|
+
* Resend's SPF and DKIM records in your sender settings.
|
|
639
|
+
*
|
|
640
|
+
* @stability Beta
|
|
641
|
+
*/
|
|
642
|
+
declare function senderOwnershipRecord(domain: string, token: string): {
|
|
643
|
+
type: "TXT";
|
|
644
|
+
name: string;
|
|
645
|
+
value: string;
|
|
646
|
+
};
|
|
647
|
+
/** Whether `domain` is one of `reserved`, or under one. */
|
|
648
|
+
declare function isReservedDomain(domain: string, reserved: readonly (string | undefined | null)[]): boolean;
|
|
649
|
+
/**
|
|
650
|
+
* Checks ownership, SPF, DKIM and DMARC for `domain`. Record the result with
|
|
651
|
+
* `genesis_notifications_set_sender_dns(tenant, domain, token, ok ? 'verified' : 'failed', result)`
|
|
652
|
+
* with the same token, so a check made before the tenant changed its domain changes nothing.
|
|
653
|
+
*
|
|
654
|
+
* @stability Beta
|
|
655
|
+
*/
|
|
656
|
+
declare function checkSenderDomain(domain: string, options: DomainCheckOptions): Promise<DomainCheck>;
|
|
657
|
+
|
|
658
|
+
export { type CancelInput, type ClaimedDevice, type ClaimedJob, type DomainCheck, type DomainCheckOptions, EXPO_RECEIPTS_BATCH, EXPO_RECEIPTS_URL, EXPO_SEND_BATCH, EXPO_SEND_URL, type EmailMessage, type EmailOptions, type EmailProvider, type EmailSendResult, EmailTemplate, type ExpoOptions, type HttpDeps, type NotificationRunner, type NotificationRunnerOptions, type NotificationStore, NotificationStoreError, type NotificationsHandlerOptions, type NotifyOptions, type PushMessage, type PushProvider, type PushReceipt, type PushTicket, type PushTicketRow, REQUEST_TIMEOUT_MS, RESEND_API, RESEND_DEFAULT_PER_SECOND, type RateLimiter, type RateLimiterOptions, type RenderedEmail, type ResendOptions, type RpcClient, SECRET_LINK_PLACEHOLDER, SENDER_OWNERSHIP_HOST, SecretLinkError, type SecretLinkRef, type SecretLinks, SendError, type SenderIdentity, type SettleOutcome, type UnsubscribeClaims, WEBHOOK_TOLERANCE_SECONDS, cancelNotification, checkSenderDomain, createNotificationRunner, createNotificationsHandler, createRateLimiter, createUnsubscribeToken, emailEventOf, emailHash, expoErrorCode, expoPushProvider, isReservedDomain, isRetryableStatus, notify, refusedOutright, resendProvider, resolveSecretLink, retryAfterSeconds, senderOwnershipRecord, supabaseNotificationStore, syncNotificationTypes, unsubscribeHeaders, verifyResendWebhook, verifyUnsubscribeToken };
|