@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.
Files changed (79) hide show
  1. package/package.json +2 -2
  2. package/src/ai/browser-code-contracts.ts +33 -63
  3. package/src/ai/browser.ts +1 -0
  4. package/src/ai/chat/blocks.tsx +16 -7
  5. package/src/ai/chat/live-turn.browser-harness.tsx +6 -3
  6. package/src/ai/chat/message-actions.tsx +123 -101
  7. package/src/ai/chat/message-utils.ts +30 -1
  8. package/src/ai/chat/messages.ts +198 -0
  9. package/src/ai/chat/presentation.tsx +66 -20
  10. package/src/ai/chat/tool-groups.ts +1 -1
  11. package/src/ai/chat/turn-error.ts +21 -0
  12. package/src/ai/chat/turn-view.tsx +55 -4
  13. package/src/ai/chat/user-message.tsx +19 -14
  14. package/src/ai/chat/visual-tools.tsx +1 -1
  15. package/src/ai/client/controller.ts +23 -6
  16. package/src/ai/code-mode-skill.ts +21 -25
  17. package/src/ai/code-runtime-tools.ts +5 -1
  18. package/src/ai/code-source-contracts.ts +2 -2
  19. package/src/ai/data-analysis-skill.ts +3 -3
  20. package/src/ai/default-tools.ts +15 -16
  21. package/src/ai/executor.ts +135 -59
  22. package/src/ai/index.ts +2 -0
  23. package/src/ai/open-tool-calls.ts +87 -0
  24. package/src/ai/routes.ts +6 -0
  25. package/src/ai/run-timeout.ts +4 -5
  26. package/src/ai/runtime.ts +8 -0
  27. package/src/ai/skill-seeds.ts +4 -4
  28. package/src/ai/store.ts +46 -14
  29. package/src/ai/system-prompt.ts +4 -21
  30. package/src/ai/turn-failure.ts +100 -0
  31. package/src/ai/turn-policy.ts +2 -1
  32. package/src/ai/types.ts +28 -1
  33. package/src/api/admin-outgoing-mail.ts +185 -5
  34. package/src/browser/FileChooser.tsx +11 -0
  35. package/src/browser/file-chooser-messages.ts +2 -0
  36. package/src/cli/admin/index.ts +3 -1
  37. package/src/cli/admin/notifications.ts +6 -0
  38. package/src/cli/admin/outgoing-mail.ts +104 -7
  39. package/src/contracts/outgoing-mail.ts +189 -1
  40. package/src/services/help/store.ts +2 -1
  41. package/src/services/index.ts +11 -1
  42. package/src/services/notifications/batches.ts +139 -79
  43. package/src/services/notifications/channels.ts +33 -13
  44. package/src/services/notifications/dispatcher.ts +57 -5
  45. package/src/services/notifications/email-frame.fixture.html +51 -0
  46. package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
  47. package/src/services/notifications/email-mail.ts +101 -0
  48. package/src/services/notifications/index.ts +49 -36
  49. package/src/services/notifications/observability.ts +9 -1
  50. package/src/services/notifications/platform.ts +1 -1
  51. package/src/services/notifications/runtime.ts +9 -3
  52. package/src/services/outgoing-mail/admin.ts +84 -0
  53. package/src/services/outgoing-mail/attachments.ts +135 -0
  54. package/src/services/outgoing-mail/bulk.ts +11 -0
  55. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  56. package/src/services/outgoing-mail/drain.ts +60 -0
  57. package/src/services/outgoing-mail/enqueue.ts +180 -0
  58. package/src/services/outgoing-mail/index.ts +103 -10
  59. package/src/services/outgoing-mail/message.ts +14 -0
  60. package/src/services/outgoing-mail/messages.ts +431 -0
  61. package/src/services/outgoing-mail/retention.ts +48 -0
  62. package/src/services/outgoing-mail/runtime.ts +72 -0
  63. package/src/services/outgoing-mail/send.ts +130 -0
  64. package/src/services/outgoing-mail/store.ts +115 -4
  65. package/src/services/outgoing-mail/sync.ts +39 -0
  66. package/src/services/outgoing-mail/transport.ts +5 -1
  67. package/src/services/pdf/markdown.ts +22 -4
  68. package/src/services/postgres.ts +15 -0
  69. package/src/services/settings/core-settings.ts +18 -0
  70. package/src/shared/ai-platform-prompt.ts +48 -4
  71. package/src/shared/markdown/extensions/links.ts +28 -20
  72. package/src/shared/markdown/index.ts +14 -5
  73. package/src/shared/markdown/shared.ts +0 -7
  74. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  75. package/src/ssr/platform-messages.ts +1 -1
  76. package/src/styles/effects.css +15 -17
  77. package/src/styles/tokens.css +2 -0
  78. package/src/styles/utilities-markdown-editor.css +4 -39
  79. 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`CREATE SCHEMA IF NOT EXISTS help`.simple();
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(),
@@ -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 { MailProfile } from "../contracts/outgoing-mail";
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 { sendEmail } from "./email";
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
- const recipients = await sql<BatchRow[]>`
296
- UPDATE notifications.batch_recipients r
297
- SET status = 'sending', attempt_count = attempt_count + 1, updated_at = now()
298
- WHERE (r.batch_id, r.user_id) IN (
299
- SELECT batch_id, user_id
300
- FROM notifications.batch_recipients
301
- WHERE batch_id = ${batchId}::uuid
302
- AND recipient IS NOT NULL
303
- AND (
304
- status = 'pending'
305
- OR (status = 'sending' AND updated_at < now() - interval '5 minutes')
306
- )
307
- ORDER BY updated_at ASC
308
- LIMIT ${CHUNK_SIZE}
309
- FOR UPDATE SKIP LOCKED
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
- RETURNING r.*
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 [batchRow] = await sql<BatchRow[]>`SELECT * FROM notifications.batches WHERE id = ${batchId}::uuid`;
315
- if (!batchRow || batchRow.status === "cancelled") return { processed: 0, remaining: 0 };
316
- const batch = mapBatch(batchRow);
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
- for (const recipient of recipients) {
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 result = await sendBatchEmail({
321
- recipient: recipient.recipient as string,
322
- subject: batch.subject,
323
- rawHtml: batch.bodyHtml,
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
- await sql`
327
- UPDATE notifications.batch_recipients
328
- SET
329
- status = ${result.status === "sent" ? "sent" : "error"},
330
- notification_id = ${result.id}::uuid,
331
- error = NULL,
332
- sent_at = ${result.status === "sent" ? sql`now()` : null},
333
- updated_at = now()
334
- WHERE batch_id = ${batchId}::uuid AND user_id = ${recipient.user_id}::uuid
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 message = error instanceof Error ? error.message : String(error);
338
- log.error("Batch recipient send failed", { batchId, userId: recipient.user_id, error: message });
339
- await sql`
340
- UPDATE notifications.batch_recipients
341
- SET status = 'error', error = ${message}, updated_at = now()
342
- WHERE batch_id = ${batchId}::uuid AND user_id = ${recipient.user_id}::uuid
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
- const [pendingRow] = await sql<BatchRow[]>`
348
- SELECT COUNT(*)::int AS count
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
- const remaining = Number(pendingRow?.count ?? 0);
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 { sendEmail } from "./email";
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: (payload: unknown) => Promise<void>;
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
- if (payload.messageId !== undefined && typeof payload.messageId !== "string") throw new Error("Invalid email notification payload");
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, event }) => {
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
- await sendEmail(payload.to, payload.subject, {
97
- content: payload.content,
98
- rawHtml: payload.rawHtml,
99
- messageId: payload.messageId,
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 (deliveryId: string): Promise<DeliveryAttemptResult & { activatedIds?: string[] }> => {
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 status = 'delivered', delivered_at = now(), next_attempt_at = NULL,
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 status = 'failed', next_attempt_at = NULL, error_code = ${code},
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
+ &nbsp;&middot;&nbsp;
37
+ <a href="https://example.test/legal/terms" style="color:#71717a;text-decoration:underline;">Terms</a>
38
+ &nbsp;&middot;&nbsp;
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
- /** Send an email with the standard HTML template. */
12
- export const sendEmail = async (
13
- to: string,
14
- subject: string,
15
- opts: { content?: string; rawHtml?: string; messageId?: string },
16
- ): Promise<void> => {
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 === "&amp;" ? "&" : entity === "&lt;" ? "<" : ">"))
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 profile = await resolveMailCredentials();
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
  /**