@k2b/cloud 0.27.0 → 0.28.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 +2 -2
- package/src/ai/browser-code-contracts.ts +33 -63
- package/src/ai/browser.ts +1 -0
- package/src/ai/chat/blocks.tsx +16 -7
- package/src/ai/chat/live-turn.browser-harness.tsx +6 -3
- package/src/ai/chat/message-actions.tsx +123 -101
- package/src/ai/chat/message-utils.ts +30 -1
- package/src/ai/chat/messages.ts +198 -0
- package/src/ai/chat/presentation.tsx +66 -20
- package/src/ai/chat/tool-groups.ts +1 -1
- package/src/ai/chat/turn-error.ts +21 -0
- package/src/ai/chat/turn-view.tsx +55 -4
- package/src/ai/chat/user-message.tsx +19 -14
- package/src/ai/chat/visual-tools.tsx +1 -1
- 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/data-analysis-skill.ts +3 -3
- package/src/ai/default-tools.ts +15 -16
- package/src/ai/executor.ts +135 -59
- package/src/ai/index.ts +2 -0
- package/src/ai/open-tool-calls.ts +87 -0
- package/src/ai/routes.ts +6 -0
- package/src/ai/run-timeout.ts +4 -5
- package/src/ai/runtime.ts +8 -0
- package/src/ai/skill-seeds.ts +4 -4
- package/src/ai/store.ts +46 -14
- package/src/ai/system-prompt.ts +4 -21
- package/src/ai/turn-failure.ts +100 -0
- package/src/ai/turn-policy.ts +2 -1
- package/src/ai/types.ts +28 -1
- package/src/api/admin-outgoing-mail.ts +185 -5
- package/src/browser/FileChooser.tsx +11 -0
- package/src/browser/file-chooser-messages.ts +2 -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/outgoing-mail.ts +189 -1
- package/src/services/help/store.ts +2 -1
- package/src/services/index.ts +11 -1
- package/src/services/notifications/batches.ts +139 -79
- package/src/services/notifications/channels.ts +33 -13
- package/src/services/notifications/dispatcher.ts +57 -5
- 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 +1 -1
- package/src/services/notifications/runtime.ts +9 -3
- 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/postgres.ts +15 -0
- package/src/services/settings/core-settings.ts +18 -0
- 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/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/platform-messages.ts +1 -1
- package/src/styles/effects.css +15 -17
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
2
|
import { APP_REGISTRY_TTL_MS } from "../../_internal/registry";
|
|
3
|
+
import { ensureSchema } from "../postgres";
|
|
3
4
|
import type { HelpCorpus, HelpMetadata } from "./types";
|
|
4
5
|
|
|
5
6
|
// One expired app lease of grace after an app can no longer be discovered.
|
|
@@ -10,7 +11,7 @@ const searchConfigs = ["english", "german", "simple"] as const;
|
|
|
10
11
|
export const migrateHelp = async (db: SQL = sql): Promise<void> => {
|
|
11
12
|
await db.begin(async (tx) => {
|
|
12
13
|
await tx`SELECT pg_advisory_xact_lock(hashtext('cloud:help:migrate'))`;
|
|
13
|
-
await tx
|
|
14
|
+
await ensureSchema(tx, "help");
|
|
14
15
|
await tx`CREATE TABLE IF NOT EXISTS help.corpora (
|
|
15
16
|
app_id text NOT NULL, manifest_hash text NOT NULL, base_locale text NOT NULL,
|
|
16
17
|
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
package/src/services/index.ts
CHANGED
|
@@ -206,4 +206,14 @@ export { railShortcuts } from "./rail-shortcuts";
|
|
|
206
206
|
export type { WebVitalsOverview, WebVitalsQuery, WebVitalMetric, WebVitalName } from "./logging/web-vitals";
|
|
207
207
|
|
|
208
208
|
export { mail } from "./outgoing-mail";
|
|
209
|
-
export type {
|
|
209
|
+
export type {
|
|
210
|
+
MailProfile,
|
|
211
|
+
MailAttachment,
|
|
212
|
+
MailMessage,
|
|
213
|
+
MailFilter,
|
|
214
|
+
MailRecord,
|
|
215
|
+
MailStatus,
|
|
216
|
+
MailPageParams,
|
|
217
|
+
MailPage,
|
|
218
|
+
MailServiceError,
|
|
219
|
+
} from "../contracts/outgoing-mail";
|
|
@@ -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'
|
|
@@ -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,4 +1,5 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import { decryptSecret } from "../secrets";
|
|
3
4
|
import { getNotificationChannel } from "./channels";
|
|
4
5
|
|
|
@@ -14,10 +15,12 @@ type DeliveryRow = {
|
|
|
14
15
|
required: boolean;
|
|
15
16
|
route_priority: number | null;
|
|
16
17
|
attempt_count: number;
|
|
18
|
+
outgoing_mail_id: string | null;
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
export type DeliveryAttemptResult =
|
|
20
22
|
| { status: "skipped" | "delivered" | "failed" }
|
|
23
|
+
| { status: "pending"; retryAfterMs: number }
|
|
21
24
|
| { status: "retry"; retryAfterMs: number; error: string };
|
|
22
25
|
|
|
23
26
|
const retryDelay = (attempt: number): number => Math.min(BASE_RETRY_MS * 2 ** Math.max(0, attempt - 1), MAX_RETRY_MS);
|
|
@@ -65,7 +68,10 @@ const activateNextFallback = async (eventId: string): Promise<string[]> => {
|
|
|
65
68
|
return rows.map((row) => row.id);
|
|
66
69
|
};
|
|
67
70
|
|
|
68
|
-
export const processNotificationDelivery = async (
|
|
71
|
+
export const processNotificationDelivery = async (
|
|
72
|
+
deliveryId: string,
|
|
73
|
+
signal?: AbortSignal,
|
|
74
|
+
): Promise<DeliveryAttemptResult & { activatedIds?: string[] }> => {
|
|
69
75
|
const rows = await sql<DeliveryRow[]>`
|
|
70
76
|
UPDATE notifications.deliveries
|
|
71
77
|
SET status = 'sending', attempt_count = attempt_count + 1,
|
|
@@ -73,7 +79,7 @@ export const processNotificationDelivery = async (deliveryId: string): Promise<D
|
|
|
73
79
|
WHERE id = ${deliveryId}::uuid
|
|
74
80
|
AND status = 'pending'
|
|
75
81
|
AND (next_attempt_at IS NULL OR next_attempt_at <= now())
|
|
76
|
-
RETURNING id, event_id, channel, payload_encrypted, required, route_priority, attempt_count
|
|
82
|
+
RETURNING id, event_id, channel, payload_encrypted, required, route_priority, attempt_count, outgoing_mail_id
|
|
77
83
|
`;
|
|
78
84
|
const delivery = rows[0];
|
|
79
85
|
if (!delivery) return { status: "skipped" };
|
|
@@ -86,10 +92,34 @@ export const processNotificationDelivery = async (deliveryId: string): Promise<D
|
|
|
86
92
|
throw Object.assign(new Error("Notification delivery payload is unavailable"), { code: "payload_missing", retryable: false });
|
|
87
93
|
}
|
|
88
94
|
const payload = await decryptSecret(delivery.payload_encrypted);
|
|
89
|
-
await driver.deliver(payload
|
|
95
|
+
const outcome = await driver.deliver(payload, {
|
|
96
|
+
deliveryId: delivery.id,
|
|
97
|
+
...(signal ? { signal } : {}),
|
|
98
|
+
...(delivery.outgoing_mail_id ? { outgoingMailId: delivery.outgoing_mail_id } : {}),
|
|
99
|
+
});
|
|
100
|
+
// Third-party drivers may return provider bodies; only a UUID can name an outgoing mail.
|
|
101
|
+
const mailId = z.uuid().safeParse(outcome?.outgoingMailId).data ?? null;
|
|
102
|
+
if (
|
|
103
|
+
outcome?.status === "pending" &&
|
|
104
|
+
typeof outcome.retryAfterMs === "number" &&
|
|
105
|
+
Number.isFinite(outcome.retryAfterMs) &&
|
|
106
|
+
outcome.retryAfterMs > 0
|
|
107
|
+
) {
|
|
108
|
+
const retryAfterMs = Math.round(Math.min(MAX_RETRY_MS, Math.max(BASE_RETRY_MS, outcome.retryAfterMs)));
|
|
109
|
+
await sql`
|
|
110
|
+
UPDATE notifications.deliveries
|
|
111
|
+
SET status = 'pending', attempt_count = attempt_count - 1,
|
|
112
|
+
next_attempt_at = now() + (${retryAfterMs}::int * INTERVAL '1 millisecond'),
|
|
113
|
+
outgoing_mail_id = COALESCE(${mailId}::uuid, outgoing_mail_id),
|
|
114
|
+
error_code = NULL, error_message = ${typeof outcome.errorMessage === "string" ? outcome.errorMessage : null}, updated_at = now()
|
|
115
|
+
WHERE id = ${delivery.id}::uuid
|
|
116
|
+
`;
|
|
117
|
+
return { status: "pending", retryAfterMs };
|
|
118
|
+
}
|
|
90
119
|
await sql`
|
|
91
120
|
UPDATE notifications.deliveries
|
|
92
|
-
SET
|
|
121
|
+
SET outgoing_mail_id = COALESCE(${mailId}::uuid, outgoing_mail_id),
|
|
122
|
+
status = 'delivered', delivered_at = now(), next_attempt_at = NULL,
|
|
93
123
|
error_code = NULL, error_message = NULL, payload_encrypted = NULL, updated_at = now()
|
|
94
124
|
WHERE id = ${delivery.id}::uuid
|
|
95
125
|
`;
|
|
@@ -98,6 +128,8 @@ export const processNotificationDelivery = async (deliveryId: string): Promise<D
|
|
|
98
128
|
} catch (error) {
|
|
99
129
|
const message = error instanceof Error ? error.message : "Notification delivery failed";
|
|
100
130
|
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "provider_error";
|
|
131
|
+
const outgoingMailId =
|
|
132
|
+
error && typeof error === "object" && "outgoingMailId" in error ? (z.uuid().safeParse(error.outgoingMailId).data ?? null) : null;
|
|
101
133
|
const retryable = !(error && typeof error === "object" && "retryable" in error && error.retryable === false);
|
|
102
134
|
if (retryable && delivery.attempt_count < MAX_DELIVERY_ATTEMPTS) {
|
|
103
135
|
const retryAfterMs = retryDelay(delivery.attempt_count);
|
|
@@ -112,7 +144,8 @@ export const processNotificationDelivery = async (deliveryId: string): Promise<D
|
|
|
112
144
|
|
|
113
145
|
await sql`
|
|
114
146
|
UPDATE notifications.deliveries
|
|
115
|
-
SET
|
|
147
|
+
SET outgoing_mail_id = COALESCE(${outgoingMailId}::uuid, outgoing_mail_id),
|
|
148
|
+
status = 'failed', next_attempt_at = NULL, error_code = ${code},
|
|
116
149
|
error_message = ${message}, payload_encrypted = NULL, updated_at = now()
|
|
117
150
|
WHERE id = ${delivery.id}::uuid
|
|
118
151
|
`;
|
|
@@ -137,3 +170,22 @@ export const recoverNotificationDeliveries = async (): Promise<string[]> => {
|
|
|
137
170
|
`;
|
|
138
171
|
return rows.map((row) => row.id);
|
|
139
172
|
};
|
|
173
|
+
|
|
174
|
+
export const reconcileNotificationMessages = async (): Promise<void> => {
|
|
175
|
+
await sql`
|
|
176
|
+
WITH terminal AS (
|
|
177
|
+
SELECT m.id, m.outgoing_mail_id, mail.status, mail.sent_at,
|
|
178
|
+
COALESCE(mail.error_message, mail.smtp_response, 'Outgoing mail record is no longer available.') AS error
|
|
179
|
+
FROM notifications.messages m
|
|
180
|
+
LEFT JOIN outgoing_mail.messages mail ON mail.id = m.outgoing_mail_id
|
|
181
|
+
WHERE m.outgoing_mail_id IS NOT NULL AND m.sent_at IS NULL AND m.error IS NULL
|
|
182
|
+
AND (mail.status IN ('sent', 'bounced', 'failed', 'cancelled') OR mail.id IS NULL)
|
|
183
|
+
ORDER BY m.outgoing_mail_id, m.id LIMIT 500
|
|
184
|
+
)
|
|
185
|
+
UPDATE notifications.messages m
|
|
186
|
+
SET sent_at = CASE WHEN t.status IN ('sent', 'bounced') THEN t.sent_at ELSE NULL END,
|
|
187
|
+
error = CASE WHEN t.status IN ('sent', 'bounced') THEN NULL ELSE t.error END
|
|
188
|
+
FROM terminal t
|
|
189
|
+
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
|
|
190
|
+
`;
|
|
191
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
|
|
2
|
+
<!DOCTYPE html>
|
|
3
|
+
<html>
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
</head>
|
|
8
|
+
<body style="margin:0;padding:0;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
|
9
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px;">
|
|
10
|
+
<tr><td align="center">
|
|
11
|
+
<table width="100%" style="max-width:520px;" cellpadding="0" cellspacing="0">
|
|
12
|
+
|
|
13
|
+
<!-- Header -->
|
|
14
|
+
<tr><td style="background:#ffffff;padding:20px 24px;border-radius:12px 12px 0 0;border:1px solid #e4e4e7;border-bottom:none;">
|
|
15
|
+
<table cellpadding="0" cellspacing="0"><tr>
|
|
16
|
+
<td style="padding-right:12px;vertical-align:middle;">
|
|
17
|
+
<img src="https://example.test/logo.svg" alt="Logo" width="28" height="28" style="display:block;">
|
|
18
|
+
</td>
|
|
19
|
+
<td style="vertical-align:middle;">
|
|
20
|
+
<span style="font-size:16px;font-weight:600;color:#18181b;">Test Cloud</span>
|
|
21
|
+
</td>
|
|
22
|
+
</tr></table>
|
|
23
|
+
</td></tr>
|
|
24
|
+
|
|
25
|
+
<!-- Content -->
|
|
26
|
+
<tr><td style="background:#ffffff;padding:28px 24px;border-left:1px solid #e4e4e7;border-right:1px solid #e4e4e7;">
|
|
27
|
+
<div style="font-size:14px;line-height:1.6;color:#27272a;">
|
|
28
|
+
{{body}}
|
|
29
|
+
</div>
|
|
30
|
+
</td></tr>
|
|
31
|
+
|
|
32
|
+
<!-- Footer -->
|
|
33
|
+
<tr><td style="background:#fafafa;padding:16px 24px;border-radius:0 0 12px 12px;border:1px solid #e4e4e7;border-top:none;">
|
|
34
|
+
<p style="margin:0 0 8px;font-size:11px;color:#71717a;text-align:center;">
|
|
35
|
+
<a href="https://example.test/impressum" style="color:#71717a;text-decoration:underline;">Imprint</a>
|
|
36
|
+
·
|
|
37
|
+
<a href="https://example.test/legal/terms" style="color:#71717a;text-decoration:underline;">Terms</a>
|
|
38
|
+
·
|
|
39
|
+
<a href="https://example.test/legal/privacy" style="color:#71717a;text-decoration:underline;">Privacy</a>
|
|
40
|
+
</p>
|
|
41
|
+
<p style="margin:0;font-size:11px;color:#a1a1aa;text-align:center;">
|
|
42
|
+
This message was sent automatically. Please do not reply to this email.
|
|
43
|
+
</p>
|
|
44
|
+
</td></tr>
|
|
45
|
+
|
|
46
|
+
</table>
|
|
47
|
+
</td></tr>
|
|
48
|
+
</table>
|
|
49
|
+
</body>
|
|
50
|
+
<!-- the answer is 42 ;D -->
|
|
51
|
+
</html>
|
|
@@ -1,47 +1,24 @@
|
|
|
1
1
|
import sanitizeHtml from "sanitize-html";
|
|
2
|
-
import { sanitizeEmailHtml } from "../../shared";
|
|
3
|
-
import { resolveMailCredentials } from "../outgoing-mail/store";
|
|
4
|
-
import { buildMailTransport } from "../outgoing-mail/transport";
|
|
2
|
+
import { sanitizeEmailHtml } from "../../shared/email-html";
|
|
5
3
|
import * as settings from "../settings";
|
|
6
4
|
import { coreSettings } from "../settings/api";
|
|
7
5
|
|
|
8
|
-
/** Sanitize plain text content (no HTML allowed). */
|
|
9
6
|
const sanitizeContent = (content: string): string => sanitizeHtml(content, { allowedTags: [], allowedAttributes: {} });
|
|
10
7
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
// sanitize-html decodes entities before escaping &, < and > again. Decode only
|
|
9
|
+
// those escapes once, so literal entity text is not recursively interpreted.
|
|
10
|
+
const htmlText = (html: string): string =>
|
|
11
|
+
sanitizeContent(html.replace(/<br\b[^>]*>|<\/(?:p|div|h[1-6]|li|tr|td|th|ul|ol|table)>/gi, " "))
|
|
12
|
+
.replace(/&(amp|lt|gt);/g, (entity) => (entity === "&" ? "&" : entity === "<" ? "<" : ">"))
|
|
13
|
+
.replace(/\s+/g, " ")
|
|
14
|
+
.trim();
|
|
15
|
+
|
|
16
|
+
export const prepareNotificationEmail = async (opts: { content?: string; rawHtml?: string }): Promise<{ html: string; text: string }> => {
|
|
17
17
|
const rawAppUrl = await settings.get<string>("app.url");
|
|
18
18
|
const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
|
|
19
19
|
const appName = await settings.get<string>("app.name");
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
let body = "";
|
|
23
|
-
if (opts.rawHtml) {
|
|
24
|
-
body = sanitizeEmailHtml(opts.rawHtml);
|
|
25
|
-
} else if (opts.content) {
|
|
26
|
-
body = `<p>${sanitizeContent(opts.content)}</p>`;
|
|
27
|
-
}
|
|
28
|
-
const text = opts.content ? sanitizeContent(opts.content) : undefined;
|
|
29
|
-
|
|
30
|
-
const html = await buildHtml(appUrl, appName, body);
|
|
31
|
-
const transporter = buildMailTransport(profile);
|
|
32
|
-
|
|
33
|
-
try {
|
|
34
|
-
await transporter.sendMail({
|
|
35
|
-
from: { address: profile.fromAddress, name: profile.fromName ?? appName },
|
|
36
|
-
to,
|
|
37
|
-
subject,
|
|
38
|
-
...(opts.messageId ? { messageId: opts.messageId } : {}),
|
|
39
|
-
html,
|
|
40
|
-
...(text ? { text } : {}),
|
|
41
|
-
});
|
|
42
|
-
} finally {
|
|
43
|
-
transporter.close();
|
|
44
|
-
}
|
|
20
|
+
const body = opts.rawHtml ? sanitizeEmailHtml(opts.rawHtml) : opts.content ? `<p>${sanitizeContent(opts.content)}</p>` : "";
|
|
21
|
+
return { html: await buildHtml(appUrl, appName, body), text: opts.content ? sanitizeContent(opts.content) : htmlText(body) };
|
|
45
22
|
};
|
|
46
23
|
|
|
47
24
|
/**
|