@k2b/cloud 0.27.0 → 0.29.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/package.json +7 -5
- package/src/_internal/cli-plugins.ts +18 -0
- package/src/_internal/define-app.ts +21 -5
- package/src/_internal/registry-validation.ts +30 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.tsx +11 -4
- package/src/ai/assistant-models.ts +25 -4
- package/src/ai/audio-tool.ts +3 -10
- package/src/ai/browser-code-contracts.ts +33 -63
- package/src/ai/browser.ts +1 -0
- package/src/ai/capabilities.ts +35 -9
- package/src/ai/chat/blocks.tsx +113 -24
- package/src/ai/chat/builtin-tools.tsx +3 -1
- package/src/ai/chat/composer-adapter.ts +8 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +15 -3
- package/src/ai/chat/message-actions.tsx +124 -102
- package/src/ai/chat/message-utils.ts +57 -6
- package/src/ai/chat/messages.ts +214 -0
- package/src/ai/chat/presentation.tsx +67 -28
- package/src/ai/chat/tool-groups.ts +1 -1
- package/src/ai/chat/tool-names.ts +157 -0
- package/src/ai/chat/turn-error.ts +21 -0
- package/src/ai/chat/turn-layout.ts +12 -0
- package/src/ai/chat/turn-view.tsx +69 -10
- package/src/ai/chat/user-message.tsx +19 -14
- package/src/ai/chat/visual-tools.tsx +1 -1
- package/src/ai/chat-tasks.ts +16 -3
- package/src/ai/client/controller.ts +23 -6
- package/src/ai/code-mode-skill.ts +21 -25
- package/src/ai/code-runtime-tools.ts +5 -1
- package/src/ai/code-source-contracts.ts +2 -2
- package/src/ai/compaction.ts +22 -3
- package/src/ai/data-analysis-skill.ts +3 -3
- package/src/ai/default-tools.ts +23 -17
- package/src/ai/executor.ts +262 -99
- package/src/ai/file-media-type.ts +4 -0
- package/src/ai/files-store.ts +17 -10
- package/src/ai/http.ts +11 -10
- package/src/ai/index.ts +3 -0
- package/src/ai/memories.ts +2 -0
- package/src/ai/memory-learning.ts +59 -5
- package/src/ai/memory-workflow-evidence.ts +35 -10
- package/src/ai/open-tool-calls.ts +87 -0
- package/src/ai/projects-routes.ts +5 -1
- package/src/ai/projects.ts +5 -1
- package/src/ai/protocol.ts +34 -1
- package/src/ai/provider.ts +16 -1
- package/src/ai/quota-provider.ts +4 -1
- package/src/ai/routes.ts +26 -10
- package/src/ai/run-timeout.ts +4 -5
- package/src/ai/runtime.ts +8 -0
- package/src/ai/settings.ts +54 -17
- package/src/ai/skill-seeds.ts +14 -6
- package/src/ai/store.ts +102 -34
- package/src/ai/structured.ts +2 -1
- package/src/ai/system-prompt.ts +9 -22
- package/src/ai/transcription.ts +58 -13
- package/src/ai/turn-failure.ts +104 -0
- package/src/ai/turn-guidance.ts +505 -0
- package/src/ai/turn-policy.ts +2 -1
- package/src/ai/types.ts +29 -1
- package/src/ai/ui.tsx +1 -0
- package/src/api/admin-core-settings.ts +16 -1
- package/src/api/admin-outgoing-mail.ts +185 -5
- package/src/api/me.ts +42 -1
- package/src/browser/FileChooser.tsx +82 -295
- package/src/browser/FileSaver.tsx +407 -0
- package/src/browser/app-badges.ts +16 -0
- package/src/browser/choose-files.browser-harness.tsx +22 -6
- package/src/browser/choose-files.tsx +23 -36
- package/src/browser/file-chooser-messages.ts +66 -0
- package/src/browser/file-providers.ts +258 -8
- package/src/browser/files.tsx +26 -3
- package/src/browser/notifications.ts +37 -1
- package/src/browser/provider-folder.tsx +301 -0
- package/src/browser/provider-list.ts +39 -0
- package/src/browser/save-files.browser-harness.tsx +35 -0
- package/src/browser/save-files.tsx +107 -0
- package/src/cli/admin/index.ts +3 -1
- package/src/cli/admin/notifications.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +104 -7
- package/src/contracts/app.ts +10 -0
- package/src/contracts/commands.ts +3 -1
- package/src/contracts/notification-group.ts +6 -0
- package/src/contracts/notification-types.ts +6 -0
- package/src/contracts/outgoing-mail.ts +189 -1
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/user-notifications.ts +74 -0
- package/src/server/middleware/request-logger.ts +11 -2
- package/src/services/accounts/avatar.ts +5 -2
- package/src/services/help/store.ts +2 -1
- package/src/services/image-metadata.ts +438 -0
- package/src/services/index.ts +12 -1
- package/src/services/logging/redaction.ts +50 -0
- package/src/services/mandates/index.ts +4 -2
- package/src/services/mandates/policy.ts +20 -0
- package/src/services/notifications/batches.ts +139 -79
- package/src/services/notifications/browser.ts +48 -15
- package/src/services/notifications/channels.ts +33 -13
- package/src/services/notifications/dispatcher.ts +88 -6
- package/src/services/notifications/email-frame.fixture.html +51 -0
- package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
- package/src/services/notifications/email-mail.ts +101 -0
- package/src/services/notifications/index.ts +49 -36
- package/src/services/notifications/observability.ts +9 -1
- package/src/services/notifications/platform.ts +33 -5
- package/src/services/notifications/preview.ts +23 -0
- package/src/services/notifications/quiet.ts +153 -0
- package/src/services/notifications/runtime.ts +9 -3
- package/src/services/notifications/user.ts +6 -0
- package/src/services/outgoing-mail/admin.ts +84 -0
- package/src/services/outgoing-mail/attachments.ts +135 -0
- package/src/services/outgoing-mail/bulk.ts +11 -0
- package/src/services/outgoing-mail/dispatcher.ts +231 -0
- package/src/services/outgoing-mail/drain.ts +60 -0
- package/src/services/outgoing-mail/enqueue.ts +180 -0
- package/src/services/outgoing-mail/index.ts +103 -10
- package/src/services/outgoing-mail/message.ts +14 -0
- package/src/services/outgoing-mail/messages.ts +431 -0
- package/src/services/outgoing-mail/retention.ts +48 -0
- package/src/services/outgoing-mail/runtime.ts +72 -0
- package/src/services/outgoing-mail/send.ts +130 -0
- package/src/services/outgoing-mail/store.ts +115 -4
- package/src/services/outgoing-mail/sync.ts +39 -0
- package/src/services/outgoing-mail/transport.ts +5 -1
- package/src/services/pdf/markdown.ts +22 -4
- package/src/services/pdf/template-preview.ts +2 -1
- package/src/services/postgres.ts +15 -0
- package/src/services/settings/app.ts +3 -0
- package/src/services/settings/core-settings.ts +18 -0
- package/src/services/settings/store.ts +4 -1
- package/src/services/weather/geo.ts +6 -2
- package/src/shared/ai-platform-prompt.ts +48 -4
- package/src/shared/markdown/extensions/links.ts +28 -20
- package/src/shared/markdown/index.ts +14 -5
- package/src/shared/markdown/shared.ts +0 -7
- package/src/shared/redirect.ts +2 -1
- package/src/shared/template-rendering.ts +120 -7
- package/src/ssr/AppBadge.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +47 -26
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/IslandError.tsx +33 -0
- package/src/ssr/Layout.tsx +3 -0
- package/src/ssr/LayoutHelp.tsx +4 -2
- package/src/ssr/RailApps.island.tsx +9 -2
- package/src/ssr/app-badges.ts +93 -0
- package/src/ssr/platform-messages.ts +1 -1
- package/src/ssr/rail-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +2 -0
- package/src/styles/effects.css +38 -17
- package/src/styles/file-chooser.css +25 -0
- package/src/styles/tokens.css +2 -0
- package/src/styles/utilities-markdown-editor.css +4 -39
- package/src/styles/utilities-markdown-table.css +14 -17
- package/src/styles/utilities-navigation.css +41 -0
|
@@ -3,12 +3,15 @@ import { err, fail, ok, type Result } from "@k2b/stdlib";
|
|
|
3
3
|
import type { Worker } from "@k2b/sync";
|
|
4
4
|
import { sql } from "bun";
|
|
5
5
|
import { lazySync } from "../../_internal/process-sync";
|
|
6
|
+
import { type MailMessage, MailMessageSchema, type MailStatus, MailStatusSchema } from "../../contracts/outgoing-mail";
|
|
6
7
|
import { markdown } from "../../shared/markdown";
|
|
7
8
|
import { type AuditActor, audit } from "../audit";
|
|
8
9
|
import { logger, trace } from "../logging";
|
|
10
|
+
import { enqueueMail } from "../outgoing-mail/enqueue";
|
|
11
|
+
import { outgoingMailStore } from "../outgoing-mail/store";
|
|
9
12
|
import { parsePgJsonValue, toPgTextArray, toPgUuidArray } from "../postgres";
|
|
10
|
-
|
|
11
|
-
import {
|
|
13
|
+
import { prepareNotificationEmail } from "./email-frame";
|
|
14
|
+
import { normalizeNotificationMail, notificationMailError, notificationMailValidationError } from "./email-mail";
|
|
12
15
|
|
|
13
16
|
const log = logger("notifications:batches");
|
|
14
17
|
const CHUNK_SIZE = 100;
|
|
@@ -64,6 +67,8 @@ export type NotificationBatchRecipient = {
|
|
|
64
67
|
profile: "user" | "guest";
|
|
65
68
|
status: NotificationBatchRecipientStatus;
|
|
66
69
|
notificationId: string | null;
|
|
70
|
+
outgoingMailId: string | null;
|
|
71
|
+
outgoingMailStatus: MailStatus | null;
|
|
67
72
|
error: string | null;
|
|
68
73
|
attemptCount: number;
|
|
69
74
|
sentAt: string | null;
|
|
@@ -163,6 +168,8 @@ const mapRecipient = (row: BatchRow): NotificationBatchRecipient => ({
|
|
|
163
168
|
profile: row.profile as "user" | "guest",
|
|
164
169
|
status: row.status as NotificationBatchRecipientStatus,
|
|
165
170
|
notificationId: row.notification_id as string | null,
|
|
171
|
+
outgoingMailId: typeof row.outgoing_mail_id === "string" ? row.outgoing_mail_id : null,
|
|
172
|
+
outgoingMailStatus: MailStatusSchema.nullable().parse(row.outgoing_mail_status ?? null),
|
|
166
173
|
error: row.error as string | null,
|
|
167
174
|
attemptCount: Number(row.attempt_count ?? 0),
|
|
168
175
|
sentAt: row.sent_at ? (row.sent_at as Date).toISOString() : null,
|
|
@@ -210,29 +217,6 @@ const resolveCandidates = async (rawSelection: NotificationBatchSelection): Prom
|
|
|
210
217
|
`;
|
|
211
218
|
};
|
|
212
219
|
|
|
213
|
-
const sendBatchEmail = async (params: {
|
|
214
|
-
recipient: string;
|
|
215
|
-
subject: string;
|
|
216
|
-
rawHtml: string;
|
|
217
|
-
sentBy?: string;
|
|
218
|
-
}): Promise<{ id: string; status: "sent" | "error"; error?: string }> => {
|
|
219
|
-
const rows = await sql<BatchRow[]>`
|
|
220
|
-
INSERT INTO notifications.messages (type, recipient, subject, content, sent_by)
|
|
221
|
-
VALUES ('email', ${params.recipient}, ${params.subject}, ${params.rawHtml}, ${params.sentBy ?? null})
|
|
222
|
-
RETURNING id
|
|
223
|
-
`;
|
|
224
|
-
const id = rows[0]!.id as string;
|
|
225
|
-
try {
|
|
226
|
-
await sendEmail(params.recipient, params.subject, { rawHtml: params.rawHtml });
|
|
227
|
-
await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL WHERE id = ${id}::uuid`;
|
|
228
|
-
return { id, status: "sent" };
|
|
229
|
-
} catch (error) {
|
|
230
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
231
|
-
await sql`UPDATE notifications.messages SET error = ${message} WHERE id = ${id}::uuid`;
|
|
232
|
-
return { id, status: "error", error: message };
|
|
233
|
-
}
|
|
234
|
-
};
|
|
235
|
-
|
|
236
220
|
export const preview = async (selection: NotificationBatchSelection): Promise<NotificationBatchPreview> => {
|
|
237
221
|
const candidates = await resolveCandidates(selection);
|
|
238
222
|
return {
|
|
@@ -286,74 +270,147 @@ const refreshBatchCounters = async (batchId: string): Promise<NotificationBatch
|
|
|
286
270
|
};
|
|
287
271
|
|
|
288
272
|
const processBatchChunk = async (batchId: string): Promise<{ processed: number; remaining: number }> => {
|
|
289
|
-
await sql
|
|
273
|
+
const [batchRow] = await sql<BatchRow[]>`
|
|
290
274
|
UPDATE notifications.batches
|
|
291
275
|
SET status = 'running', started_at = COALESCE(started_at, now())
|
|
292
276
|
WHERE id = ${batchId}::uuid AND status IN ('ready', 'running')
|
|
277
|
+
RETURNING *
|
|
293
278
|
`;
|
|
279
|
+
if (!batchRow) return { processed: 0, remaining: 0 };
|
|
280
|
+
const batch = mapBatch(batchRow);
|
|
294
281
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
WHERE batch_id = ${batchId}::uuid
|
|
302
|
-
AND
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
282
|
+
// Settle a bounded page and its legacy message history in the same statement.
|
|
283
|
+
const settled = await sql<{ id: string }[]>`
|
|
284
|
+
WITH terminal AS (
|
|
285
|
+
SELECT r.user_id, r.outgoing_mail_id, m.status, m.sent_at, COALESCE(m.error_message, m.smtp_response, 'Outgoing mail ' || m.status, 'Outgoing mail record is no longer available.') AS error
|
|
286
|
+
FROM notifications.batch_recipients r
|
|
287
|
+
LEFT JOIN outgoing_mail.messages m ON m.id = r.outgoing_mail_id
|
|
288
|
+
WHERE r.batch_id = ${batchId}::uuid AND r.status = 'sending' AND r.outgoing_mail_id IS NOT NULL
|
|
289
|
+
AND (m.status IN ('sent', 'bounced', 'failed', 'cancelled') OR m.id IS NULL)
|
|
290
|
+
ORDER BY r.user_id LIMIT ${CHUNK_SIZE}
|
|
291
|
+
), settled AS (
|
|
292
|
+
UPDATE notifications.batch_recipients r
|
|
293
|
+
SET status = CASE WHEN t.status IN ('sent', 'bounced') THEN 'sent' ELSE 'error' END,
|
|
294
|
+
sent_at = CASE WHEN t.status IN ('sent', 'bounced') THEN COALESCE(t.sent_at, now()) ELSE NULL END,
|
|
295
|
+
error = CASE WHEN t.status IN ('sent', 'bounced') THEN NULL ELSE t.error END, updated_at = now()
|
|
296
|
+
FROM terminal t
|
|
297
|
+
WHERE r.batch_id = ${batchId}::uuid AND r.user_id = t.user_id AND r.status = 'sending'
|
|
298
|
+
AND r.outgoing_mail_id = t.outgoing_mail_id
|
|
299
|
+
RETURNING r.notification_id, r.sent_at, r.error
|
|
310
300
|
)
|
|
311
|
-
|
|
301
|
+
UPDATE notifications.messages m SET sent_at = s.sent_at, error = s.error
|
|
302
|
+
FROM settled s WHERE m.id = s.notification_id AND m.mail_generation = 0 RETURNING m.id
|
|
312
303
|
`;
|
|
313
304
|
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
305
|
+
const profile = (await outgoingMailStore.profilesForApp("core")).find((profile) => profile.default);
|
|
306
|
+
const claimSize =
|
|
307
|
+
profile?.quota.dailyRecipients == null
|
|
308
|
+
? CHUNK_SIZE
|
|
309
|
+
: Math.max(0, Math.min(CHUNK_SIZE, profile.quota.dailyRecipients - profile.quota.usedLast24h));
|
|
310
|
+
|
|
311
|
+
type ClaimedRecipient = { user_id: string; recipient: string; notification_id: string | null };
|
|
312
|
+
// Persist a send generation before any external effect. Reclaim and worker
|
|
313
|
+
// retries reuse it; operator retries clear it and create a new generation.
|
|
314
|
+
const recipients = await sql.begin(async (tx) => {
|
|
315
|
+
const rows = await tx<ClaimedRecipient[]>`
|
|
316
|
+
UPDATE notifications.batch_recipients r
|
|
317
|
+
SET status = 'sending', attempt_count = attempt_count + 1, updated_at = now()
|
|
318
|
+
WHERE (r.batch_id, r.user_id) IN (
|
|
319
|
+
SELECT batch_id, user_id FROM notifications.batch_recipients
|
|
320
|
+
WHERE batch_id = ${batchId}::uuid AND recipient IS NOT NULL AND outgoing_mail_id IS NULL
|
|
321
|
+
AND (status = 'pending' OR (status = 'sending' AND updated_at < now() - interval '5 minutes'))
|
|
322
|
+
ORDER BY updated_at ASC LIMIT ${claimSize} FOR UPDATE SKIP LOCKED
|
|
323
|
+
)
|
|
324
|
+
RETURNING r.user_id, r.recipient, r.notification_id
|
|
325
|
+
`;
|
|
326
|
+
for (const row of rows) {
|
|
327
|
+
if (row.notification_id) continue;
|
|
328
|
+
const [message] = await tx<{ id: string }[]>`
|
|
329
|
+
INSERT INTO notifications.messages (type, recipient, subject, content, sent_by)
|
|
330
|
+
VALUES ('email', ${row.recipient}, ${batch.subject}, ${batch.bodyHtml}, ${batch.finalizedBy ?? batch.createdBy}::uuid)
|
|
331
|
+
RETURNING id
|
|
332
|
+
`;
|
|
333
|
+
if (!message) throw new Error("Notification batch message insert returned no row");
|
|
334
|
+
row.notification_id = message.id;
|
|
335
|
+
await tx`UPDATE notifications.batch_recipients SET notification_id = ${message.id}::uuid
|
|
336
|
+
WHERE batch_id = ${batchId}::uuid AND user_id = ${row.user_id}::uuid`;
|
|
337
|
+
}
|
|
338
|
+
return rows;
|
|
339
|
+
});
|
|
317
340
|
|
|
318
|
-
|
|
341
|
+
const failRecipients = async (rows: ClaimedRecipient[], failure: ReturnType<typeof notificationMailError>) => {
|
|
342
|
+
const generations = toPgUuidArray(rows.flatMap((recipient) => (recipient.notification_id ? [recipient.notification_id] : [])));
|
|
343
|
+
await sql`
|
|
344
|
+
WITH changed AS (
|
|
345
|
+
UPDATE notifications.batch_recipients
|
|
346
|
+
SET status = ${failure.retryable ? "pending" : "error"}, error = ${failure.retryable ? null : failure.message}, updated_at = now()
|
|
347
|
+
WHERE batch_id = ${batchId}::uuid AND notification_id = ANY(${generations}::uuid[])
|
|
348
|
+
AND status = 'sending' AND outgoing_mail_id IS NULL
|
|
349
|
+
RETURNING notification_id, error
|
|
350
|
+
)
|
|
351
|
+
UPDATE notifications.messages m SET error = c.error FROM changed c WHERE m.id = c.notification_id
|
|
352
|
+
`;
|
|
353
|
+
};
|
|
354
|
+
let invalidCount = 0;
|
|
355
|
+
if (recipients.length) {
|
|
319
356
|
try {
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
subject: batch.subject,
|
|
323
|
-
|
|
324
|
-
sentBy: batch.finalizedBy ?? batch.createdBy ?? undefined,
|
|
357
|
+
const frame = await prepareNotificationEmail({ rawHtml: batch.bodyHtml });
|
|
358
|
+
const shared = MailMessageSchema.pick({ subject: true, text: true, html: true }).safeParse({
|
|
359
|
+
subject: normalizeNotificationMail("", batch.subject).subject,
|
|
360
|
+
...frame,
|
|
325
361
|
});
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
362
|
+
if (!shared.success) throw notificationMailValidationError(shared.error);
|
|
363
|
+
const valid: { recipient: ClaimedRecipient; message: MailMessage }[] = [];
|
|
364
|
+
for (const recipient of recipients) {
|
|
365
|
+
const parsed = MailMessageSchema.safeParse({
|
|
366
|
+
...normalizeNotificationMail(recipient.recipient, shared.data.subject),
|
|
367
|
+
...shared.data,
|
|
368
|
+
key: `notification-message:${recipient.notification_id}`,
|
|
369
|
+
});
|
|
370
|
+
if (!parsed.success) {
|
|
371
|
+
await failRecipients([recipient], notificationMailError(notificationMailValidationError(parsed.error)));
|
|
372
|
+
invalidCount++;
|
|
373
|
+
} else valid.push({ recipient, message: parsed.data });
|
|
374
|
+
}
|
|
375
|
+
if (valid.length) {
|
|
376
|
+
const accepted = await enqueueMail(
|
|
377
|
+
"core",
|
|
378
|
+
valid.map(({ message }) => message),
|
|
379
|
+
{ trustedHtml: true },
|
|
380
|
+
);
|
|
381
|
+
for (const [index, { recipient }] of valid.entries()) {
|
|
382
|
+
await sql`
|
|
383
|
+
WITH linked AS (
|
|
384
|
+
UPDATE notifications.batch_recipients SET outgoing_mail_id = ${accepted.ids[index]}::uuid, updated_at = now()
|
|
385
|
+
WHERE batch_id = ${batchId}::uuid AND user_id = ${recipient.user_id}::uuid
|
|
386
|
+
AND notification_id = ${recipient.notification_id}::uuid AND status = 'sending' AND outgoing_mail_id IS NULL
|
|
387
|
+
RETURNING notification_id, outgoing_mail_id
|
|
388
|
+
)
|
|
389
|
+
UPDATE notifications.messages m SET outgoing_mail_id = l.outgoing_mail_id
|
|
390
|
+
FROM linked l WHERE m.id = l.notification_id AND m.mail_generation = 0
|
|
391
|
+
`;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
336
394
|
} catch (error) {
|
|
337
|
-
const
|
|
338
|
-
log.error("
|
|
339
|
-
await
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
`;
|
|
395
|
+
const failure = notificationMailError(error);
|
|
396
|
+
log.error("Notification batch enqueue failed", { batchId, code: failure.code, error: failure.message });
|
|
397
|
+
await failRecipients(recipients, failure);
|
|
398
|
+
// Backlog/quota/storage failures are not terminal recipient failures.
|
|
399
|
+
await refreshBatchCounters(batchId);
|
|
400
|
+
return { processed: invalidCount + settled.length, remaining: await remainingBatchRecipients(batchId) };
|
|
344
401
|
}
|
|
345
402
|
}
|
|
403
|
+
const remaining = await remainingBatchRecipients(batchId);
|
|
404
|
+
await refreshBatchCounters(batchId);
|
|
405
|
+
return { processed: recipients.length + settled.length, remaining };
|
|
406
|
+
};
|
|
346
407
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
FROM notifications.batch_recipients
|
|
350
|
-
WHERE batch_id = ${batchId}::uuid
|
|
351
|
-
AND recipient IS NOT NULL
|
|
352
|
-
AND status IN ('pending', 'sending')
|
|
408
|
+
const remainingBatchRecipients = async (batchId: string): Promise<number> => {
|
|
409
|
+
const [row] = await sql<{ count: number }[]>`
|
|
410
|
+
SELECT COUNT(*)::int AS count FROM notifications.batch_recipients
|
|
411
|
+
WHERE batch_id = ${batchId}::uuid AND recipient IS NOT NULL AND status IN ('pending', 'sending')
|
|
353
412
|
`;
|
|
354
|
-
|
|
355
|
-
await refreshBatchCounters(batchId);
|
|
356
|
-
return { processed: recipients.length, remaining };
|
|
413
|
+
return row?.count ?? 0;
|
|
357
414
|
};
|
|
358
415
|
|
|
359
416
|
const batchJob = lazySync((sync) => {
|
|
@@ -492,6 +549,8 @@ export const listRecipients = async (params: {
|
|
|
492
549
|
r.profile,
|
|
493
550
|
r.status,
|
|
494
551
|
r.notification_id,
|
|
552
|
+
r.outgoing_mail_id,
|
|
553
|
+
mail.status AS outgoing_mail_status,
|
|
495
554
|
COALESCE(m.error, r.error) AS error,
|
|
496
555
|
r.attempt_count,
|
|
497
556
|
r.sent_at,
|
|
@@ -499,6 +558,7 @@ export const listRecipients = async (params: {
|
|
|
499
558
|
COUNT(*) OVER() AS total
|
|
500
559
|
FROM notifications.batch_recipients r
|
|
501
560
|
LEFT JOIN notifications.messages m ON m.id = r.notification_id
|
|
561
|
+
LEFT JOIN outgoing_mail.messages mail ON mail.id = r.outgoing_mail_id
|
|
502
562
|
LEFT JOIN auth.users u ON u.id = r.user_id
|
|
503
563
|
WHERE r.batch_id = ${params.batchId}::uuid
|
|
504
564
|
AND (${params.status ?? null}::text IS NULL OR r.status = ${params.status ?? null})
|
|
@@ -620,7 +680,7 @@ export const retryFailed = async (params: {
|
|
|
620
680
|
const result = await sql.begin(async (tx) => {
|
|
621
681
|
const updated = await tx<BatchRow[]>`
|
|
622
682
|
UPDATE notifications.batch_recipients
|
|
623
|
-
SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
|
|
683
|
+
SET status = 'pending', error = NULL, notification_id = NULL, outgoing_mail_id = NULL, sent_at = NULL, updated_at = now()
|
|
624
684
|
WHERE batch_id = ${params.id}::uuid AND status = 'error' AND recipient IS NOT NULL
|
|
625
685
|
RETURNING user_id
|
|
626
686
|
`;
|
|
@@ -667,7 +727,7 @@ export const retryRecipient = async (params: {
|
|
|
667
727
|
const result = await sql.begin(async (tx) => {
|
|
668
728
|
const updated = await tx<BatchRow[]>`
|
|
669
729
|
UPDATE notifications.batch_recipients
|
|
670
|
-
SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
|
|
730
|
+
SET status = 'pending', error = NULL, notification_id = NULL, outgoing_mail_id = NULL, sent_at = NULL, updated_at = now()
|
|
671
731
|
WHERE batch_id = ${params.id}::uuid
|
|
672
732
|
AND user_id = ${params.userId}::uuid
|
|
673
733
|
AND status = 'error'
|
|
@@ -2,11 +2,13 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { sql } from "bun";
|
|
3
3
|
import webpush from "web-push";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { notificationGroupTag } from "../../contracts/notification-group";
|
|
5
6
|
import { isSafeNotificationTargetHref } from "../../contracts/notification-types";
|
|
6
7
|
import { type BrowserPushSubscription, BrowserPushSubscriptionSchema } from "../../contracts/user-notifications";
|
|
7
8
|
import { decryptSecret, encryptSecret } from "../secrets";
|
|
8
9
|
import { coreSettings } from "../settings/api";
|
|
9
10
|
import { type NotificationDestination, registerNotificationChannel } from "./channels";
|
|
11
|
+
import { NOTIFICATION_PREVIEW_LIMIT } from "./preview";
|
|
10
12
|
import { sendPinnedWebPush } from "./web-push-transport";
|
|
11
13
|
|
|
12
14
|
type EndpointRow = {
|
|
@@ -21,15 +23,41 @@ type BrowserDeliveryPayload = {
|
|
|
21
23
|
subscription: BrowserPushSubscription;
|
|
22
24
|
eventId: string;
|
|
23
25
|
title: string;
|
|
26
|
+
preview?: string;
|
|
24
27
|
targetHref?: string;
|
|
28
|
+
group?: string;
|
|
29
|
+
badge?: number;
|
|
30
|
+
createdAt?: number;
|
|
25
31
|
};
|
|
26
32
|
|
|
33
|
+
// RFC 8030 section 7.2 guarantees 4,096 bytes; aes128gcm adds a header, tag, and padding delimiter.
|
|
34
|
+
const WEB_PUSH_PLAINTEXT_BUDGET = 4_096 - 86 - 16 - 1;
|
|
35
|
+
|
|
36
|
+
const browserPushMessage = (payload: BrowserDeliveryPayload): string =>
|
|
37
|
+
JSON.stringify({
|
|
38
|
+
type: "cloud-notification",
|
|
39
|
+
eventId: payload.eventId,
|
|
40
|
+
title: payload.title,
|
|
41
|
+
...(payload.preview !== undefined ? { preview: payload.preview } : {}),
|
|
42
|
+
targetHref: payload.targetHref,
|
|
43
|
+
...(payload.group !== undefined ? { group: payload.group } : {}),
|
|
44
|
+
...(payload.badge !== undefined ? { badge: payload.badge } : {}),
|
|
45
|
+
...(payload.createdAt !== undefined ? { createdAt: payload.createdAt } : {}),
|
|
46
|
+
});
|
|
47
|
+
|
|
27
48
|
const BrowserDeliveryPayloadSchema = z.object({
|
|
28
49
|
endpointId: z.uuid(),
|
|
29
50
|
subscription: BrowserPushSubscriptionSchema,
|
|
30
51
|
eventId: z.uuid(),
|
|
31
52
|
title: z.string().min(1).max(200),
|
|
53
|
+
preview: z.string().min(1).max(NOTIFICATION_PREVIEW_LIMIT).optional(),
|
|
32
54
|
targetHref: z.string().max(4_000).refine(isSafeNotificationTargetHref).optional(),
|
|
55
|
+
group: z
|
|
56
|
+
.string()
|
|
57
|
+
.regex(/^[A-Za-z0-9._:-]+$/)
|
|
58
|
+
.optional(),
|
|
59
|
+
badge: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional(),
|
|
60
|
+
createdAt: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional(),
|
|
33
61
|
});
|
|
34
62
|
|
|
35
63
|
const BrowserDestinationContextSchema = z.object({
|
|
@@ -142,13 +170,27 @@ const browserDriver = {
|
|
|
142
170
|
},
|
|
143
171
|
createPayload: ({ presentation, destination, event }) => {
|
|
144
172
|
const context = BrowserDestinationContextSchema.parse(destination.context);
|
|
145
|
-
|
|
173
|
+
const group =
|
|
174
|
+
presentation.group === undefined
|
|
175
|
+
? undefined
|
|
176
|
+
: notificationGroupTag(event.definitionId.slice(0, event.definitionId.lastIndexOf(".")), presentation.group);
|
|
177
|
+
if (group === null) throw new Error("Invalid notification group tag");
|
|
178
|
+
const payload = BrowserDeliveryPayloadSchema.parse({
|
|
146
179
|
endpointId: context.endpointId,
|
|
147
180
|
subscription: context.subscription,
|
|
148
181
|
eventId: event.id,
|
|
149
182
|
title: presentation.title,
|
|
183
|
+
...(presentation.preview !== undefined ? { preview: presentation.preview } : {}),
|
|
150
184
|
targetHref: presentation.targetHref,
|
|
185
|
+
...(group !== undefined ? { group } : {}),
|
|
186
|
+
...(presentation.badge !== undefined ? { badge: presentation.badge } : {}),
|
|
187
|
+
...(presentation.group !== undefined || presentation.badge !== undefined ? { createdAt: Date.now() } : {}),
|
|
151
188
|
});
|
|
189
|
+
if (payload.preview !== undefined && Buffer.byteLength(browserPushMessage(payload), "utf8") > WEB_PUSH_PLAINTEXT_BUDGET) {
|
|
190
|
+
const { preview: _preview, ...titleOnlyPayload } = payload;
|
|
191
|
+
return titleOnlyPayload;
|
|
192
|
+
}
|
|
193
|
+
return payload;
|
|
152
194
|
},
|
|
153
195
|
deliver: async (value: unknown) => {
|
|
154
196
|
const payload: BrowserDeliveryPayload = BrowserDeliveryPayloadSchema.parse(value);
|
|
@@ -166,20 +208,11 @@ const browserDriver = {
|
|
|
166
208
|
}
|
|
167
209
|
await ensureWebPushConfigured();
|
|
168
210
|
try {
|
|
169
|
-
await sendPinnedWebPush(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
title: payload.title,
|
|
175
|
-
targetHref: payload.targetHref,
|
|
176
|
-
}),
|
|
177
|
-
{
|
|
178
|
-
TTL: 24 * 60 * 60,
|
|
179
|
-
urgency: "normal",
|
|
180
|
-
topic: createHash("sha256").update(payload.eventId).digest("base64url").slice(0, 32),
|
|
181
|
-
},
|
|
182
|
-
);
|
|
211
|
+
await sendPinnedWebPush(payload.subscription, browserPushMessage(payload), {
|
|
212
|
+
TTL: 24 * 60 * 60,
|
|
213
|
+
urgency: "normal",
|
|
214
|
+
topic: createHash("sha256").update(payload.eventId).digest("base64url").slice(0, 32),
|
|
215
|
+
});
|
|
183
216
|
} catch (error) {
|
|
184
217
|
const statusCode = error && typeof error === "object" && "statusCode" in error ? Number(error.statusCode) : null;
|
|
185
218
|
if (statusCode === 404 || statusCode === 410) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { EmailNotificationPresentation, NotificationChannelId, NotificationPresentation } from "../../contracts/notification-types";
|
|
3
|
-
import {
|
|
3
|
+
import { notificationMailOutcome, notificationMailOutcomeById, sendNotificationMail } from "./email-mail";
|
|
4
4
|
|
|
5
5
|
export type ResolvedNotificationRecipient = {
|
|
6
6
|
userId: string | null;
|
|
@@ -23,7 +23,22 @@ export type NotificationChannelDriver = {
|
|
|
23
23
|
destination: NotificationDestination;
|
|
24
24
|
event: { id: string; definitionId: string };
|
|
25
25
|
}) => unknown;
|
|
26
|
-
deliver: (
|
|
26
|
+
deliver: (
|
|
27
|
+
payload: unknown,
|
|
28
|
+
context?: { deliveryId: string; signal?: AbortSignal; outgoingMailId?: string },
|
|
29
|
+
) => Promise<
|
|
30
|
+
| void
|
|
31
|
+
| {
|
|
32
|
+
status: "delivered";
|
|
33
|
+
outgoingMailId?: string;
|
|
34
|
+
}
|
|
35
|
+
| {
|
|
36
|
+
status: "pending";
|
|
37
|
+
retryAfterMs: number;
|
|
38
|
+
outgoingMailId?: string;
|
|
39
|
+
errorMessage?: string | null;
|
|
40
|
+
}
|
|
41
|
+
>;
|
|
27
42
|
};
|
|
28
43
|
|
|
29
44
|
const drivers = new Map<string, NotificationChannelDriver>();
|
|
@@ -58,7 +73,6 @@ type EmailPayload = {
|
|
|
58
73
|
subject: string;
|
|
59
74
|
content?: string;
|
|
60
75
|
rawHtml?: string;
|
|
61
|
-
messageId?: string;
|
|
62
76
|
};
|
|
63
77
|
|
|
64
78
|
const parseEmailPayload = (value: unknown): EmailPayload => {
|
|
@@ -69,8 +83,7 @@ const parseEmailPayload = (value: unknown): EmailPayload => {
|
|
|
69
83
|
}
|
|
70
84
|
if (payload.content !== undefined && typeof payload.content !== "string") throw new Error("Invalid email notification payload");
|
|
71
85
|
if (payload.rawHtml !== undefined && typeof payload.rawHtml !== "string") throw new Error("Invalid email notification payload");
|
|
72
|
-
|
|
73
|
-
return { to: payload.to, subject: payload.subject, content: payload.content, rawHtml: payload.rawHtml, messageId: payload.messageId };
|
|
86
|
+
return { to: payload.to, subject: payload.subject, content: payload.content, rawHtml: payload.rawHtml };
|
|
74
87
|
};
|
|
75
88
|
|
|
76
89
|
const emailDriver: NotificationChannelDriver = {
|
|
@@ -80,7 +93,7 @@ const emailDriver: NotificationChannelDriver = {
|
|
|
80
93
|
const email = normalizeEmail(recipient.email);
|
|
81
94
|
return [{ key: emailKey(email), label: emailLabel(email), context: { email } }];
|
|
82
95
|
},
|
|
83
|
-
createPayload: ({ presentation, email, destination
|
|
96
|
+
createPayload: ({ presentation, email, destination }) => {
|
|
84
97
|
const context = destination.context as { email?: unknown };
|
|
85
98
|
if (typeof context.email !== "string") throw new Error("Email destination is missing an address");
|
|
86
99
|
return {
|
|
@@ -88,16 +101,23 @@ const emailDriver: NotificationChannelDriver = {
|
|
|
88
101
|
subject: email?.subject ?? presentation.title,
|
|
89
102
|
content: email?.content ?? presentation.body,
|
|
90
103
|
rawHtml: email?.rawHtml,
|
|
91
|
-
messageId: `<cloud-notification-${event.id}@cloud.invalid>`,
|
|
92
104
|
} satisfies EmailPayload;
|
|
93
105
|
},
|
|
94
|
-
deliver: async (value) => {
|
|
106
|
+
deliver: async (value, context) => {
|
|
107
|
+
if (context?.outgoingMailId) return notificationMailOutcomeById(context.outgoingMailId);
|
|
95
108
|
const payload = parseEmailPayload(value);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
109
|
+
return notificationMailOutcome(
|
|
110
|
+
await sendNotificationMail(
|
|
111
|
+
payload.to,
|
|
112
|
+
payload.subject,
|
|
113
|
+
{
|
|
114
|
+
content: payload.content,
|
|
115
|
+
rawHtml: payload.rawHtml,
|
|
116
|
+
},
|
|
117
|
+
context ? `notification-delivery:${context.deliveryId}` : undefined,
|
|
118
|
+
context?.signal,
|
|
119
|
+
),
|
|
120
|
+
);
|
|
101
121
|
},
|
|
102
122
|
};
|
|
103
123
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import { decryptSecret } from "../secrets";
|
|
3
4
|
import { getNotificationChannel } from "./channels";
|
|
5
|
+
import { quietStateForEvent } from "./quiet";
|
|
4
6
|
|
|
5
7
|
const MAX_DELIVERY_ATTEMPTS = 5;
|
|
6
8
|
const BASE_RETRY_MS = 2_000;
|
|
@@ -14,14 +16,44 @@ type DeliveryRow = {
|
|
|
14
16
|
required: boolean;
|
|
15
17
|
route_priority: number | null;
|
|
16
18
|
attempt_count: number;
|
|
19
|
+
outgoing_mail_id: string | null;
|
|
17
20
|
};
|
|
18
21
|
|
|
19
22
|
export type DeliveryAttemptResult =
|
|
20
|
-
| { status: "skipped" | "delivered" | "failed" }
|
|
23
|
+
| { status: "skipped" | "delivered" | "suppressed" | "failed" }
|
|
24
|
+
| { status: "pending"; retryAfterMs: number }
|
|
21
25
|
| { status: "retry"; retryAfterMs: number; error: string };
|
|
22
26
|
|
|
23
27
|
const retryDelay = (attempt: number): number => Math.min(BASE_RETRY_MS * 2 ** Math.max(0, attempt - 1), MAX_RETRY_MS);
|
|
24
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Do not disturb and quiet hours hold back recommended browser notifications when they would go out. The
|
|
31
|
+
* notification is dropped, not postponed, so nothing arrives in a burst when the quiet time ends, and it is not
|
|
32
|
+
* rerouted: the event's waiting fallbacks end with it. Required deliveries are part of a protocol and still go.
|
|
33
|
+
*/
|
|
34
|
+
const holdBackForQuietTime = async (delivery: DeliveryRow): Promise<boolean> => {
|
|
35
|
+
if (delivery.channel !== "browser" || delivery.required) return false;
|
|
36
|
+
const quiet = await quietStateForEvent(delivery.event_id);
|
|
37
|
+
if (!quiet?.active) return false;
|
|
38
|
+
const code = quiet.reason === "doNotDisturb" ? "do_not_disturb" : "quiet_hours";
|
|
39
|
+
const message = quiet.reason === "doNotDisturb" ? "Held back by do not disturb." : "Held back during quiet hours.";
|
|
40
|
+
// One transaction: a crash in between must not leave fallbacks waiting, with their payload, behind a dropped push.
|
|
41
|
+
await sql.begin(async (tx) => {
|
|
42
|
+
await tx`
|
|
43
|
+
UPDATE notifications.deliveries
|
|
44
|
+
SET status = 'suppressed', attempt_count = attempt_count - 1, next_attempt_at = NULL,
|
|
45
|
+
error_code = ${code}, error_message = ${message}, payload_encrypted = NULL, updated_at = now()
|
|
46
|
+
WHERE id = ${delivery.id}::uuid AND status = 'sending'
|
|
47
|
+
`;
|
|
48
|
+
await tx`
|
|
49
|
+
UPDATE notifications.deliveries
|
|
50
|
+
SET status = 'suppressed', error_code = ${code}, error_message = ${message}, payload_encrypted = NULL, updated_at = now()
|
|
51
|
+
WHERE event_id = ${delivery.event_id}::uuid AND required = false AND status = 'deferred'
|
|
52
|
+
`;
|
|
53
|
+
});
|
|
54
|
+
return true;
|
|
55
|
+
};
|
|
56
|
+
|
|
25
57
|
const activateNextFallback = async (eventId: string): Promise<string[]> => {
|
|
26
58
|
const delivered = await sql<{ exists: boolean }[]>`
|
|
27
59
|
SELECT EXISTS (
|
|
@@ -65,7 +97,10 @@ const activateNextFallback = async (eventId: string): Promise<string[]> => {
|
|
|
65
97
|
return rows.map((row) => row.id);
|
|
66
98
|
};
|
|
67
99
|
|
|
68
|
-
export const processNotificationDelivery = async (
|
|
100
|
+
export const processNotificationDelivery = async (
|
|
101
|
+
deliveryId: string,
|
|
102
|
+
signal?: AbortSignal,
|
|
103
|
+
): Promise<DeliveryAttemptResult & { activatedIds?: string[] }> => {
|
|
69
104
|
const rows = await sql<DeliveryRow[]>`
|
|
70
105
|
UPDATE notifications.deliveries
|
|
71
106
|
SET status = 'sending', attempt_count = attempt_count + 1,
|
|
@@ -73,12 +108,13 @@ export const processNotificationDelivery = async (deliveryId: string): Promise<D
|
|
|
73
108
|
WHERE id = ${deliveryId}::uuid
|
|
74
109
|
AND status = 'pending'
|
|
75
110
|
AND (next_attempt_at IS NULL OR next_attempt_at <= now())
|
|
76
|
-
RETURNING id, event_id, channel, payload_encrypted, required, route_priority, attempt_count
|
|
111
|
+
RETURNING id, event_id, channel, payload_encrypted, required, route_priority, attempt_count, outgoing_mail_id
|
|
77
112
|
`;
|
|
78
113
|
const delivery = rows[0];
|
|
79
114
|
if (!delivery) return { status: "skipped" };
|
|
80
115
|
|
|
81
116
|
try {
|
|
117
|
+
if (await holdBackForQuietTime(delivery)) return { status: "suppressed" };
|
|
82
118
|
const driver = getNotificationChannel(delivery.channel);
|
|
83
119
|
if (!driver)
|
|
84
120
|
throw Object.assign(new Error(`Notification channel "${delivery.channel}" is unavailable`), { code: "channel_unavailable" });
|
|
@@ -86,10 +122,34 @@ export const processNotificationDelivery = async (deliveryId: string): Promise<D
|
|
|
86
122
|
throw Object.assign(new Error("Notification delivery payload is unavailable"), { code: "payload_missing", retryable: false });
|
|
87
123
|
}
|
|
88
124
|
const payload = await decryptSecret(delivery.payload_encrypted);
|
|
89
|
-
await driver.deliver(payload
|
|
125
|
+
const outcome = await driver.deliver(payload, {
|
|
126
|
+
deliveryId: delivery.id,
|
|
127
|
+
...(signal ? { signal } : {}),
|
|
128
|
+
...(delivery.outgoing_mail_id ? { outgoingMailId: delivery.outgoing_mail_id } : {}),
|
|
129
|
+
});
|
|
130
|
+
// Third-party drivers may return provider bodies; only a UUID can name an outgoing mail.
|
|
131
|
+
const mailId = z.uuid().safeParse(outcome?.outgoingMailId).data ?? null;
|
|
132
|
+
if (
|
|
133
|
+
outcome?.status === "pending" &&
|
|
134
|
+
typeof outcome.retryAfterMs === "number" &&
|
|
135
|
+
Number.isFinite(outcome.retryAfterMs) &&
|
|
136
|
+
outcome.retryAfterMs > 0
|
|
137
|
+
) {
|
|
138
|
+
const retryAfterMs = Math.round(Math.min(MAX_RETRY_MS, Math.max(BASE_RETRY_MS, outcome.retryAfterMs)));
|
|
139
|
+
await sql`
|
|
140
|
+
UPDATE notifications.deliveries
|
|
141
|
+
SET status = 'pending', attempt_count = attempt_count - 1,
|
|
142
|
+
next_attempt_at = now() + (${retryAfterMs}::int * INTERVAL '1 millisecond'),
|
|
143
|
+
outgoing_mail_id = COALESCE(${mailId}::uuid, outgoing_mail_id),
|
|
144
|
+
error_code = NULL, error_message = ${typeof outcome.errorMessage === "string" ? outcome.errorMessage : null}, updated_at = now()
|
|
145
|
+
WHERE id = ${delivery.id}::uuid
|
|
146
|
+
`;
|
|
147
|
+
return { status: "pending", retryAfterMs };
|
|
148
|
+
}
|
|
90
149
|
await sql`
|
|
91
150
|
UPDATE notifications.deliveries
|
|
92
|
-
SET
|
|
151
|
+
SET outgoing_mail_id = COALESCE(${mailId}::uuid, outgoing_mail_id),
|
|
152
|
+
status = 'delivered', delivered_at = now(), next_attempt_at = NULL,
|
|
93
153
|
error_code = NULL, error_message = NULL, payload_encrypted = NULL, updated_at = now()
|
|
94
154
|
WHERE id = ${delivery.id}::uuid
|
|
95
155
|
`;
|
|
@@ -98,6 +158,8 @@ export const processNotificationDelivery = async (deliveryId: string): Promise<D
|
|
|
98
158
|
} catch (error) {
|
|
99
159
|
const message = error instanceof Error ? error.message : "Notification delivery failed";
|
|
100
160
|
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "provider_error";
|
|
161
|
+
const outgoingMailId =
|
|
162
|
+
error && typeof error === "object" && "outgoingMailId" in error ? (z.uuid().safeParse(error.outgoingMailId).data ?? null) : null;
|
|
101
163
|
const retryable = !(error && typeof error === "object" && "retryable" in error && error.retryable === false);
|
|
102
164
|
if (retryable && delivery.attempt_count < MAX_DELIVERY_ATTEMPTS) {
|
|
103
165
|
const retryAfterMs = retryDelay(delivery.attempt_count);
|
|
@@ -112,7 +174,8 @@ export const processNotificationDelivery = async (deliveryId: string): Promise<D
|
|
|
112
174
|
|
|
113
175
|
await sql`
|
|
114
176
|
UPDATE notifications.deliveries
|
|
115
|
-
SET
|
|
177
|
+
SET outgoing_mail_id = COALESCE(${outgoingMailId}::uuid, outgoing_mail_id),
|
|
178
|
+
status = 'failed', next_attempt_at = NULL, error_code = ${code},
|
|
116
179
|
error_message = ${message}, payload_encrypted = NULL, updated_at = now()
|
|
117
180
|
WHERE id = ${delivery.id}::uuid
|
|
118
181
|
`;
|
|
@@ -137,3 +200,22 @@ export const recoverNotificationDeliveries = async (): Promise<string[]> => {
|
|
|
137
200
|
`;
|
|
138
201
|
return rows.map((row) => row.id);
|
|
139
202
|
};
|
|
203
|
+
|
|
204
|
+
export const reconcileNotificationMessages = async (): Promise<void> => {
|
|
205
|
+
await sql`
|
|
206
|
+
WITH terminal AS (
|
|
207
|
+
SELECT m.id, m.outgoing_mail_id, mail.status, mail.sent_at,
|
|
208
|
+
COALESCE(mail.error_message, mail.smtp_response, 'Outgoing mail record is no longer available.') AS error
|
|
209
|
+
FROM notifications.messages m
|
|
210
|
+
LEFT JOIN outgoing_mail.messages mail ON mail.id = m.outgoing_mail_id
|
|
211
|
+
WHERE m.outgoing_mail_id IS NOT NULL AND m.sent_at IS NULL AND m.error IS NULL
|
|
212
|
+
AND (mail.status IN ('sent', 'bounced', 'failed', 'cancelled') OR mail.id IS NULL)
|
|
213
|
+
ORDER BY m.outgoing_mail_id, m.id LIMIT 500
|
|
214
|
+
)
|
|
215
|
+
UPDATE notifications.messages m
|
|
216
|
+
SET sent_at = CASE WHEN t.status IN ('sent', 'bounced') THEN t.sent_at ELSE NULL END,
|
|
217
|
+
error = CASE WHEN t.status IN ('sent', 'bounced') THEN NULL ELSE t.error END
|
|
218
|
+
FROM terminal t
|
|
219
|
+
WHERE m.id = t.id AND m.outgoing_mail_id = t.outgoing_mail_id AND m.sent_at IS NULL AND m.error IS NULL
|
|
220
|
+
`;
|
|
221
|
+
};
|