@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.
Files changed (155) hide show
  1. package/package.json +7 -5
  2. package/src/_internal/cli-plugins.ts +18 -0
  3. package/src/_internal/define-app.ts +21 -5
  4. package/src/_internal/registry-validation.ts +30 -1
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/access/PermissionEditor.tsx +11 -4
  7. package/src/ai/assistant-models.ts +25 -4
  8. package/src/ai/audio-tool.ts +3 -10
  9. package/src/ai/browser-code-contracts.ts +33 -63
  10. package/src/ai/browser.ts +1 -0
  11. package/src/ai/capabilities.ts +35 -9
  12. package/src/ai/chat/blocks.tsx +113 -24
  13. package/src/ai/chat/builtin-tools.tsx +3 -1
  14. package/src/ai/chat/composer-adapter.ts +8 -0
  15. package/src/ai/chat/live-turn.browser-harness.tsx +15 -3
  16. package/src/ai/chat/message-actions.tsx +124 -102
  17. package/src/ai/chat/message-utils.ts +57 -6
  18. package/src/ai/chat/messages.ts +214 -0
  19. package/src/ai/chat/presentation.tsx +67 -28
  20. package/src/ai/chat/tool-groups.ts +1 -1
  21. package/src/ai/chat/tool-names.ts +157 -0
  22. package/src/ai/chat/turn-error.ts +21 -0
  23. package/src/ai/chat/turn-layout.ts +12 -0
  24. package/src/ai/chat/turn-view.tsx +69 -10
  25. package/src/ai/chat/user-message.tsx +19 -14
  26. package/src/ai/chat/visual-tools.tsx +1 -1
  27. package/src/ai/chat-tasks.ts +16 -3
  28. package/src/ai/client/controller.ts +23 -6
  29. package/src/ai/code-mode-skill.ts +21 -25
  30. package/src/ai/code-runtime-tools.ts +5 -1
  31. package/src/ai/code-source-contracts.ts +2 -2
  32. package/src/ai/compaction.ts +22 -3
  33. package/src/ai/data-analysis-skill.ts +3 -3
  34. package/src/ai/default-tools.ts +23 -17
  35. package/src/ai/executor.ts +262 -99
  36. package/src/ai/file-media-type.ts +4 -0
  37. package/src/ai/files-store.ts +17 -10
  38. package/src/ai/http.ts +11 -10
  39. package/src/ai/index.ts +3 -0
  40. package/src/ai/memories.ts +2 -0
  41. package/src/ai/memory-learning.ts +59 -5
  42. package/src/ai/memory-workflow-evidence.ts +35 -10
  43. package/src/ai/open-tool-calls.ts +87 -0
  44. package/src/ai/projects-routes.ts +5 -1
  45. package/src/ai/projects.ts +5 -1
  46. package/src/ai/protocol.ts +34 -1
  47. package/src/ai/provider.ts +16 -1
  48. package/src/ai/quota-provider.ts +4 -1
  49. package/src/ai/routes.ts +26 -10
  50. package/src/ai/run-timeout.ts +4 -5
  51. package/src/ai/runtime.ts +8 -0
  52. package/src/ai/settings.ts +54 -17
  53. package/src/ai/skill-seeds.ts +14 -6
  54. package/src/ai/store.ts +102 -34
  55. package/src/ai/structured.ts +2 -1
  56. package/src/ai/system-prompt.ts +9 -22
  57. package/src/ai/transcription.ts +58 -13
  58. package/src/ai/turn-failure.ts +104 -0
  59. package/src/ai/turn-guidance.ts +505 -0
  60. package/src/ai/turn-policy.ts +2 -1
  61. package/src/ai/types.ts +29 -1
  62. package/src/ai/ui.tsx +1 -0
  63. package/src/api/admin-core-settings.ts +16 -1
  64. package/src/api/admin-outgoing-mail.ts +185 -5
  65. package/src/api/me.ts +42 -1
  66. package/src/browser/FileChooser.tsx +82 -295
  67. package/src/browser/FileSaver.tsx +407 -0
  68. package/src/browser/app-badges.ts +16 -0
  69. package/src/browser/choose-files.browser-harness.tsx +22 -6
  70. package/src/browser/choose-files.tsx +23 -36
  71. package/src/browser/file-chooser-messages.ts +66 -0
  72. package/src/browser/file-providers.ts +258 -8
  73. package/src/browser/files.tsx +26 -3
  74. package/src/browser/notifications.ts +37 -1
  75. package/src/browser/provider-folder.tsx +301 -0
  76. package/src/browser/provider-list.ts +39 -0
  77. package/src/browser/save-files.browser-harness.tsx +35 -0
  78. package/src/browser/save-files.tsx +107 -0
  79. package/src/cli/admin/index.ts +3 -1
  80. package/src/cli/admin/notifications.ts +6 -0
  81. package/src/cli/admin/outgoing-mail.ts +104 -7
  82. package/src/contracts/app.ts +10 -0
  83. package/src/contracts/commands.ts +3 -1
  84. package/src/contracts/notification-group.ts +6 -0
  85. package/src/contracts/notification-types.ts +6 -0
  86. package/src/contracts/outgoing-mail.ts +189 -1
  87. package/src/contracts/registry.ts +1 -0
  88. package/src/contracts/user-notifications.ts +74 -0
  89. package/src/server/middleware/request-logger.ts +11 -2
  90. package/src/services/accounts/avatar.ts +5 -2
  91. package/src/services/help/store.ts +2 -1
  92. package/src/services/image-metadata.ts +438 -0
  93. package/src/services/index.ts +12 -1
  94. package/src/services/logging/redaction.ts +50 -0
  95. package/src/services/mandates/index.ts +4 -2
  96. package/src/services/mandates/policy.ts +20 -0
  97. package/src/services/notifications/batches.ts +139 -79
  98. package/src/services/notifications/browser.ts +48 -15
  99. package/src/services/notifications/channels.ts +33 -13
  100. package/src/services/notifications/dispatcher.ts +88 -6
  101. package/src/services/notifications/email-frame.fixture.html +51 -0
  102. package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
  103. package/src/services/notifications/email-mail.ts +101 -0
  104. package/src/services/notifications/index.ts +49 -36
  105. package/src/services/notifications/observability.ts +9 -1
  106. package/src/services/notifications/platform.ts +33 -5
  107. package/src/services/notifications/preview.ts +23 -0
  108. package/src/services/notifications/quiet.ts +153 -0
  109. package/src/services/notifications/runtime.ts +9 -3
  110. package/src/services/notifications/user.ts +6 -0
  111. package/src/services/outgoing-mail/admin.ts +84 -0
  112. package/src/services/outgoing-mail/attachments.ts +135 -0
  113. package/src/services/outgoing-mail/bulk.ts +11 -0
  114. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  115. package/src/services/outgoing-mail/drain.ts +60 -0
  116. package/src/services/outgoing-mail/enqueue.ts +180 -0
  117. package/src/services/outgoing-mail/index.ts +103 -10
  118. package/src/services/outgoing-mail/message.ts +14 -0
  119. package/src/services/outgoing-mail/messages.ts +431 -0
  120. package/src/services/outgoing-mail/retention.ts +48 -0
  121. package/src/services/outgoing-mail/runtime.ts +72 -0
  122. package/src/services/outgoing-mail/send.ts +130 -0
  123. package/src/services/outgoing-mail/store.ts +115 -4
  124. package/src/services/outgoing-mail/sync.ts +39 -0
  125. package/src/services/outgoing-mail/transport.ts +5 -1
  126. package/src/services/pdf/markdown.ts +22 -4
  127. package/src/services/pdf/template-preview.ts +2 -1
  128. package/src/services/postgres.ts +15 -0
  129. package/src/services/settings/app.ts +3 -0
  130. package/src/services/settings/core-settings.ts +18 -0
  131. package/src/services/settings/store.ts +4 -1
  132. package/src/services/weather/geo.ts +6 -2
  133. package/src/shared/ai-platform-prompt.ts +48 -4
  134. package/src/shared/markdown/extensions/links.ts +28 -20
  135. package/src/shared/markdown/index.ts +14 -5
  136. package/src/shared/markdown/shared.ts +0 -7
  137. package/src/shared/redirect.ts +2 -1
  138. package/src/shared/template-rendering.ts +120 -7
  139. package/src/ssr/AppBadge.tsx +30 -0
  140. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  141. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  142. package/src/ssr/IslandError.tsx +33 -0
  143. package/src/ssr/Layout.tsx +3 -0
  144. package/src/ssr/LayoutHelp.tsx +4 -2
  145. package/src/ssr/RailApps.island.tsx +9 -2
  146. package/src/ssr/app-badges.ts +93 -0
  147. package/src/ssr/platform-messages.ts +1 -1
  148. package/src/ssr/rail-messages.ts +4 -0
  149. package/src/ssr/rail-navigation.ts +2 -0
  150. package/src/styles/effects.css +38 -17
  151. package/src/styles/file-chooser.css +25 -0
  152. package/src/styles/tokens.css +2 -0
  153. package/src/styles/utilities-markdown-editor.css +4 -39
  154. package/src/styles/utilities-markdown-table.css +14 -17
  155. package/src/styles/utilities-navigation.css +41 -0
@@ -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
  /**
@@ -0,0 +1,101 @@
1
+ import { domainToASCII } from "node:url";
2
+ import type { ZodError } from "zod";
3
+ import { type MailMessage, MailMessageSchema, type MailRecord } from "../../contracts/outgoing-mail";
4
+ import { messageRecord, outgoingMailMessages } from "../outgoing-mail/messages";
5
+ import { sendMail } from "../outgoing-mail/send";
6
+ import { OutgoingMailError } from "../outgoing-mail/store";
7
+ import { prepareNotificationEmail } from "./email-frame";
8
+
9
+ export const notificationMailError = (error: unknown): Error & { code: string; retryable: boolean } => {
10
+ const code = error instanceof OutgoingMailError ? error.code : "mail_unavailable";
11
+ return Object.assign(new Error(error instanceof Error ? error.message : "Outgoing mail is unavailable."), {
12
+ code,
13
+ retryable: ["mail_unavailable", "backlog_full", "quota_exceeded", "attachment_storage_full"].includes(code),
14
+ });
15
+ };
16
+
17
+ export const normalizeNotificationMail = (to: string, subject: string) => {
18
+ const separator = to.lastIndexOf("@");
19
+ const domain = separator < 0 ? "" : to.slice(separator + 1);
20
+ // URL conversion can discard invalid address characters; leave those for validation.
21
+ const ascii = domain && !/[^\p{L}\p{N}\p{M}.-]/u.test(domain) ? domainToASCII(domain) : "";
22
+ return {
23
+ to: [ascii ? `${to.slice(0, separator + 1)}${ascii}` : to],
24
+ subject: subject
25
+ .replace(/[\r\n]+/g, " ")
26
+ .replace(/\0/g, "")
27
+ .trim()
28
+ .slice(0, 998),
29
+ };
30
+ };
31
+
32
+ export const notificationMailValidationError = (error: ZodError) =>
33
+ new OutgoingMailError(
34
+ "bad_input",
35
+ `Invalid notification email: ${error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`,
36
+ 400,
37
+ );
38
+
39
+ const notificationMailMessage = async (
40
+ to: string,
41
+ subject: string,
42
+ body: { content?: string; rawHtml?: string },
43
+ key?: string,
44
+ ): Promise<MailMessage> => {
45
+ const parsed = MailMessageSchema.safeParse({
46
+ ...normalizeNotificationMail(to, subject),
47
+ ...(await prepareNotificationEmail(body)),
48
+ ...(key ? { key } : {}),
49
+ });
50
+ if (!parsed.success) throw notificationMailValidationError(parsed.error);
51
+ return parsed.data;
52
+ };
53
+
54
+ export const sendNotificationMail = async (
55
+ to: string,
56
+ subject: string,
57
+ body: { content?: string; rawHtml?: string },
58
+ key?: string,
59
+ signal?: AbortSignal,
60
+ ): Promise<MailRecord> => {
61
+ try {
62
+ return await sendMail("core", await notificationMailMessage(to, subject, body, key), signal, { trustedHtml: true });
63
+ } catch (error) {
64
+ throw notificationMailError(error);
65
+ }
66
+ };
67
+
68
+ export const notificationMailOutcome = async (record: MailRecord) => {
69
+ const row = await outgoingMailMessages.read(record.id, "core").catch(() => undefined);
70
+ const status = row?.status ?? record.status;
71
+ const errorCode = row?.error_code ?? (record.error ? "smtp_failed" : null);
72
+ const errorMessage = row?.error_message ?? row?.smtp_response ?? record.error ?? null;
73
+ if (status === "sent" || status === "bounced") return { status: "delivered" as const, outgoingMailId: record.id };
74
+ if (status === "failed" || status === "cancelled") {
75
+ throw Object.assign(new Error(errorMessage ?? `Outgoing mail ${status}.`), {
76
+ code: errorCode ?? "smtp_failed",
77
+ retryable: false,
78
+ outgoingMailId: record.id,
79
+ });
80
+ }
81
+ const next = row?.next_attempt_at ? new Date(row.next_attempt_at).getTime() - Date.now() : 60_000;
82
+ // Keep polling within the notification retry interval bounds.
83
+ const retryAfterMs = Number.isFinite(next) ? Math.min(5 * 60_000, Math.max(2_000, next)) : 60_000;
84
+ return { status: "pending" as const, outgoingMailId: record.id, retryAfterMs, errorMessage };
85
+ };
86
+
87
+ export const notificationMailOutcomeById = async (id: string) => {
88
+ const row = await outgoingMailMessages.read(id, "core").catch((error: unknown) => {
89
+ throw notificationMailError(error);
90
+ });
91
+ if (!row) {
92
+ // Retention may remove a settled mail before notification recovery. Its
93
+ // stored identity must never turn that recovery into another SMTP send.
94
+ throw Object.assign(new Error("Outgoing mail record is no longer available."), {
95
+ code: "smtp_failed",
96
+ retryable: false,
97
+ outgoingMailId: id,
98
+ });
99
+ }
100
+ return notificationMailOutcome(messageRecord(row));
101
+ };
@@ -4,7 +4,7 @@ import type { BoundNotificationDefinition, NotificationRecipientKind, Notificati
4
4
  import type { PaginationParams } from "../../contracts/shared";
5
5
  import { logger } from "../logging";
6
6
  import { escapeLikePattern } from "../postgres";
7
- import { sendEmail } from "./email";
7
+ import { notificationMailOutcome, sendNotificationMail } from "./email-mail";
8
8
  import "./browser";
9
9
  import { notificationObservability } from "./observability";
10
10
  import { sendTypedNotification, type TypedNotificationSendResult } from "./platform";
@@ -107,6 +107,26 @@ type DbNotificationRow = {
107
107
  sent_by_name: string | null;
108
108
  };
109
109
 
110
+ type LegacyMailRow = { id: string; type: NotificationType; recipient: string; subject: string; content: string; mail_generation: number };
111
+
112
+ const deliverLegacyMail = async (row: LegacyMailRow, body: { content?: string; rawHtml?: string }) => {
113
+ const key = `notification-message:${row.id}${row.mail_generation ? `:${row.mail_generation}` : ""}`;
114
+ try {
115
+ const record = await sendNotificationMail(row.recipient, row.subject, body, key);
116
+ await sql`UPDATE notifications.messages SET outgoing_mail_id = ${record.id}::uuid, sent_at = NULL, error = NULL
117
+ WHERE id = ${row.id}::uuid AND mail_generation = ${row.mail_generation}`;
118
+ const outcome = await notificationMailOutcome(record);
119
+ if (outcome.status === "pending") return "pending" as const;
120
+ await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL
121
+ WHERE id = ${row.id}::uuid AND mail_generation = ${row.mail_generation}`;
122
+ return "sent" as const;
123
+ } catch (error) {
124
+ await sql`UPDATE notifications.messages SET sent_at = NULL, error = ${error instanceof Error ? error.message : String(error)}
125
+ WHERE id = ${row.id}::uuid AND mail_generation = ${row.mail_generation}`;
126
+ throw error;
127
+ }
128
+ };
129
+
110
130
  /**
111
131
  * Send a notification. Persists to DB, attempts delivery (if autoSend=true), updates sent_at/error.
112
132
  */
@@ -130,15 +150,11 @@ const sendLegacy = async (params: SendNotificationParams): Promise<SendNotificat
130
150
 
131
151
  // Attempt delivery
132
152
  try {
133
- if (type === "email") {
134
- await sendEmail(recipient, subject, { content, rawHtml });
135
- }
136
- await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL WHERE id = ${id}`;
137
- return { id, status: "sent" };
153
+ const status = await deliverLegacyMail({ id, type, recipient, subject, content: dbContent, mail_generation: 0 }, { content, rawHtml });
154
+ return { id, status };
138
155
  } catch (e) {
139
156
  const error = e instanceof Error ? e.message : String(e);
140
157
  log.error("Failed to send", { type, recipient, error });
141
- await sql`UPDATE notifications.messages SET error = ${error} WHERE id = ${id}`;
142
158
  return { id, status: "error", error };
143
159
  }
144
160
  };
@@ -482,23 +498,26 @@ export const getById = async (id: string): Promise<NotificationMessage | null> =
482
498
  * Resend a notification (retry delivery).
483
499
  */
484
500
  export const resend = async (id: string): Promise<{ ok: true } | { ok: false; code: "not_found" | "delivery_failed"; error: string }> => {
485
- const notification = await getById(id);
486
- if (!notification) {
487
- return { ok: false, code: "not_found", error: "Notification not found" };
488
- }
489
-
501
+ const notification = await sql.begin(async (tx): Promise<(LegacyMailRow & { current_mail_status: string | null }) | undefined> => {
502
+ const [row] = await tx<(LegacyMailRow & { outgoing_mail_id: string | null; current_mail_status: string | null })[]>`
503
+ SELECT m.*, mail.status AS current_mail_status FROM notifications.messages m
504
+ LEFT JOIN outgoing_mail.messages mail ON mail.id = m.outgoing_mail_id
505
+ WHERE m.id = ${id}::uuid FOR UPDATE OF m
506
+ `;
507
+ if (!row || row.current_mail_status === "queued" || row.current_mail_status === "sending" || !row.outgoing_mail_id) return row;
508
+ const [updated] = await tx<LegacyMailRow[]>`
509
+ UPDATE notifications.messages SET mail_generation = mail_generation + 1, outgoing_mail_id = NULL, sent_at = NULL, error = NULL
510
+ WHERE id = ${id}::uuid RETURNING *
511
+ `;
512
+ return updated ? { ...updated, current_mail_status: null } : undefined;
513
+ });
514
+ if (!notification) return { ok: false, code: "not_found", error: "Notification not found" };
515
+ if (["queued", "sending"].includes(notification.current_mail_status ?? "")) return { ok: true };
490
516
  try {
491
- if (notification.type === "email") {
492
- await sendEmail(notification.recipient, notification.subject, {
493
- rawHtml: notification.content,
494
- });
495
- }
496
- await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL WHERE id = ${id}`;
517
+ await deliverLegacyMail(notification, { rawHtml: notification.content });
497
518
  return { ok: true };
498
519
  } catch (e) {
499
- const error = e instanceof Error ? e.message : String(e);
500
- await sql`UPDATE notifications.messages SET error = ${error} WHERE id = ${id}`;
501
- return { ok: false, code: "delivery_failed", error };
520
+ return { ok: false, code: "delivery_failed", error: e instanceof Error ? e.message : String(e) };
502
521
  }
503
522
  };
504
523
 
@@ -535,7 +554,9 @@ export const update = async (
535
554
  subject = COALESCE(${data.subject ?? null}, subject),
536
555
  content = COALESCE(${data.content ?? null}, content),
537
556
  recipient = COALESCE(${data.recipient ?? null}, recipient),
538
- error = CASE WHEN ${clearError} THEN NULL ELSE error END
557
+ error = CASE WHEN ${clearError} THEN NULL ELSE error END,
558
+ mail_generation = CASE WHEN ${clearError} THEN mail_generation + 1 ELSE mail_generation END,
559
+ outgoing_mail_id = CASE WHEN ${clearError} THEN NULL ELSE outgoing_mail_id END
539
560
  WHERE id = ${id}
540
561
  `;
541
562
 
@@ -548,7 +569,7 @@ export const update = async (
548
569
  export const getPendingSystemCount = async (): Promise<number> => {
549
570
  const rows = await sql`
550
571
  SELECT COUNT(*)::int as count FROM notifications.messages
551
- WHERE sent_at IS NULL AND error IS NULL AND sent_by IS NULL
572
+ WHERE sent_at IS NULL AND error IS NULL AND sent_by IS NULL AND outgoing_mail_id IS NULL
552
573
  `;
553
574
  return rows[0]?.count ?? 0;
554
575
  };
@@ -563,10 +584,10 @@ export const sendAllPendingSystem = async (): Promise<{
563
584
  errors: { id: string; recipient: string; error: string }[];
564
585
  }> => {
565
586
  // Get all pending system notifications
566
- const rows = await sql`
567
- SELECT id, type, recipient, subject, content
587
+ const rows = await sql<LegacyMailRow[]>`
588
+ SELECT id, type, recipient, subject, content, mail_generation
568
589
  FROM notifications.messages
569
- WHERE sent_at IS NULL AND error IS NULL AND sent_by IS NULL
590
+ WHERE sent_at IS NULL AND error IS NULL AND sent_by IS NULL AND outgoing_mail_id IS NULL
570
591
  ORDER BY created_at ASC
571
592
  `;
572
593
 
@@ -575,21 +596,13 @@ export const sendAllPendingSystem = async (): Promise<{
575
596
  const errors: { id: string; recipient: string; error: string }[] = [];
576
597
 
577
598
  for (const row of rows) {
578
- const id = row.id as string;
579
- const type = row.type as NotificationType;
580
- const recipient = row.recipient as string;
581
- const subject = row.subject as string;
582
- const content = row.content as string;
599
+ const { id, recipient, content } = row;
583
600
 
584
601
  try {
585
- if (type === "email") {
586
- await sendEmail(recipient, subject, { rawHtml: content });
587
- }
588
- await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL WHERE id = ${id}`;
602
+ if ((await deliverLegacyMail(row, { rawHtml: content })) === "pending") continue;
589
603
  sent++;
590
604
  } catch (e) {
591
605
  const error = e instanceof Error ? e.message : String(e);
592
- await sql`UPDATE notifications.messages SET error = ${error} WHERE id = ${id}`;
593
606
  failed++;
594
607
  errors.push({ id, recipient, error });
595
608
  }
@@ -1,4 +1,5 @@
1
1
  import { type SQL, sql } from "bun";
2
+ import type { MailStatus } from "../../contracts/outgoing-mail";
2
3
  import type { NotificationDeliveryStatus } from "../../contracts/user-notifications";
3
4
  import { escapeLikePattern, toPgTextArray } from "../postgres";
4
5
 
@@ -20,6 +21,8 @@ export type NotificationDeliveryObservabilityItem = {
20
21
  required: boolean;
21
22
  routePriority: number | null;
22
23
  status: NotificationDeliveryStatus;
24
+ outgoingMailId: string | null;
25
+ outgoingMailStatus: MailStatus | null;
23
26
  attemptCount: number;
24
27
  errorCode: string | null;
25
28
  errorMessage: string | null;
@@ -79,6 +82,8 @@ type DeliveryRow = {
79
82
  required: boolean;
80
83
  route_priority: number | null;
81
84
  status: NotificationDeliveryStatus;
85
+ outgoing_mail_id: string | null;
86
+ outgoing_mail_status: MailStatus | null;
82
87
  attempt_count: number;
83
88
  error_code: string | null;
84
89
  error_message: string | null;
@@ -168,11 +173,12 @@ const listDeliveries = async (input: {
168
173
  COALESCE(NULLIF(u.display_name, ''), NULLIF(u.uid, ''), e.recipient_email, e.recipient_key) AS recipient_label,
169
174
  COALESCE(NULLIF(u.mail, ''), NULLIF(u.uid, ''), e.recipient_email, e.recipient_key) AS recipient_reference,
170
175
  d.channel, d.destination_label, d.required, d.route_priority, d.status,
171
- d.attempt_count, d.error_code, d.error_message, d.created_at, d.updated_at, d.delivered_at
176
+ d.outgoing_mail_id, mail.status AS outgoing_mail_status, d.attempt_count, d.error_code, d.error_message, d.created_at, d.updated_at, d.delivered_at
172
177
  FROM notifications.deliveries d
173
178
  JOIN notifications.events e ON e.id = d.event_id
174
179
  JOIN notifications.definitions n ON n.id = e.definition_id
175
180
  LEFT JOIN auth.users u ON u.id = e.recipient_user_id
181
+ LEFT JOIN outgoing_mail.messages mail ON mail.id = d.outgoing_mail_id
176
182
  WHERE ${where}
177
183
  ORDER BY d.created_at DESC, d.id DESC
178
184
  LIMIT ${perPage} OFFSET ${offset}
@@ -196,6 +202,8 @@ const listDeliveries = async (input: {
196
202
  required: row.required,
197
203
  routePriority: row.route_priority,
198
204
  status: row.status,
205
+ outgoingMailId: row.outgoing_mail_id,
206
+ outgoingMailStatus: row.outgoing_mail_status,
199
207
  attemptCount: row.attempt_count,
200
208
  errorCode: row.error_code,
201
209
  errorMessage: row.error_message,
@@ -1,5 +1,6 @@
1
1
  import { sql } from "bun";
2
2
  import type { output, ZodType } from "zod";
3
+ import { isNotificationGroup, notificationGroupTag } from "../../contracts/notification-group";
3
4
  import type {
4
5
  BoundNotificationDefinition,
5
6
  EmailNotificationPresentation,
@@ -15,6 +16,7 @@ import { encryptSecret } from "../secrets";
15
16
  import { ensureNotificationDefinition } from "./catalog";
16
17
  import { getNotificationChannel, type NotificationDestination, type ResolvedNotificationRecipient } from "./channels";
17
18
  import { processNotificationDelivery } from "./dispatcher";
19
+ import { normalizeNotificationPreview } from "./preview";
18
20
  import { enqueueNotificationDeliveries, enqueueNotificationDelivery } from "./runtime";
19
21
 
20
22
  export type TypedNotificationDeliveryStatus = "deferred" | "pending" | "sending" | "delivered" | "suppressed" | "failed";
@@ -69,14 +71,38 @@ const preparationFailure = (input: { channel: string; required: boolean; routePr
69
71
  errorMessage: "Notification delivery could not be prepared.",
70
72
  });
71
73
 
72
- const validatePresentation = (presentation: NotificationPresentation): NotificationPresentation => {
74
+ const validatePresentation = (presentation: NotificationPresentation, appId: string): NotificationPresentation => {
73
75
  const title = presentation.title.trim();
74
76
  const body = presentation.body?.trim();
77
+ if (presentation.preview !== undefined && typeof presentation.preview !== "string") {
78
+ throw new Error("Notification preview must be a string");
79
+ }
80
+ const preview = presentation.preview === undefined ? undefined : normalizeNotificationPreview(presentation.preview);
75
81
  if (!title) throw new Error("Notification title is required");
76
82
  if (title.length > 200) throw new Error("Notification title must not exceed 200 characters");
77
83
  if (body && body.length > 4_000) throw new Error("Notification body must not exceed 4000 characters");
78
84
  const targetHref = presentation.targetHref ? validateNotificationTargetHref(presentation.targetHref) : undefined;
79
- return { title, ...(body ? { body } : {}), ...(targetHref ? { targetHref } : {}) };
85
+ if (presentation.group !== undefined && !isNotificationGroup(presentation.group)) {
86
+ throw new Error(
87
+ "Notification group must contain 1 to 128 characters using only letters, digits, dots, underscores, colons, or hyphens",
88
+ );
89
+ }
90
+ if (presentation.group !== undefined && notificationGroupTag(appId, presentation.group) === null) {
91
+ throw new Error(
92
+ "Notification group requires an app ID that starts with a lowercase letter and contains only lowercase letters, digits, and hyphens",
93
+ );
94
+ }
95
+ if (presentation.badge !== undefined && (!Number.isSafeInteger(presentation.badge) || presentation.badge < 0)) {
96
+ throw new Error("Notification badge must be a non-negative safe integer");
97
+ }
98
+ return {
99
+ title,
100
+ ...(body ? { body } : {}),
101
+ ...(preview ? { preview } : {}),
102
+ ...(targetHref ? { targetHref } : {}),
103
+ ...(presentation.group !== undefined ? { group: presentation.group } : {}),
104
+ ...(presentation.badge !== undefined ? { badge: presentation.badge } : {}),
105
+ };
80
106
  };
81
107
 
82
108
  const resolveRecipient = async (
@@ -172,6 +198,8 @@ const prepareChannel = async (input: {
172
198
 
173
199
  try {
174
200
  const emailPresentation = input.channel === "email" ? await input.emailPresentation?.() : undefined;
201
+ const { preview: _preview, ...otherPresentation } = input.presentation;
202
+ const presentation = input.channel === "browser" ? input.presentation : otherPresentation;
175
203
  return await Promise.all(
176
204
  destinations.map(async (destination) => ({
177
205
  channel: input.channel,
@@ -179,7 +207,7 @@ const prepareChannel = async (input: {
179
207
  destinationKey: destination.key,
180
208
  destinationLabel: destination.label,
181
209
  payloadEncrypted: await encryptSecret(
182
- driver.createPayload({ presentation: input.presentation, email: emailPresentation, destination, event: input.event }),
210
+ driver.createPayload({ presentation, email: emailPresentation, destination, event: input.event }),
183
211
  ),
184
212
  required: input.required,
185
213
  routePriority: input.routePriority,
@@ -241,7 +269,7 @@ export const sendTypedNotification = async <
241
269
 
242
270
  const data: output<S> = definition.data.parse(input.data);
243
271
  const renderContext = { locale: normalizeLocale(input.locale) };
244
- const presentation = validatePresentation(await definition.render(data, renderContext));
272
+ const presentation = validatePresentation(await definition.render(data, renderContext), definition.appId);
245
273
  const resolved = await resolveRecipient(input.recipient);
246
274
  const candidateEventId = crypto.randomUUID();
247
275
  await ensureNotificationDefinition(definition);
@@ -407,7 +435,7 @@ export const sendTypedNotification = async <
407
435
  if (persisted.prepared) {
408
436
  for (const deliveryId of persisted.requiredIds) {
409
437
  const result = await processNotificationDelivery(deliveryId);
410
- if (result.status === "retry") await enqueueNotificationDelivery(deliveryId, result.retryAfterMs);
438
+ if (result.status === "retry" || result.status === "pending") await enqueueNotificationDelivery(deliveryId, result.retryAfterMs);
411
439
  if (result.activatedIds?.length) await enqueueNotificationDeliveries(result.activatedIds);
412
440
  }
413
441
  await enqueueNotificationDeliveries(persisted.pendingIds);
@@ -0,0 +1,23 @@
1
+ export const NOTIFICATION_PREVIEW_LIMIT = 200;
2
+ const PREVIEW_INPUT_LIMIT = 1_000;
3
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
4
+
5
+ export const normalizeNotificationPreview = (value: string): string | undefined => {
6
+ let prefix = value.slice(0, PREVIEW_INPUT_LIMIT);
7
+ const clipped = value.length > prefix.length;
8
+ if (clipped) {
9
+ // A boundary at or before the bound depends on at most one following code point (two UTF-16 units).
10
+ const last = segmenter.segment(value.slice(0, PREVIEW_INPUT_LIMIT + 2)).containing(PREVIEW_INPUT_LIMIT - 1);
11
+ if (last && last.index + last.segment.length > PREVIEW_INPUT_LIMIT) prefix = prefix.slice(0, last.index);
12
+ }
13
+ const text = prefix.replace(/[\s\p{Cc}]+/gu, " ").trim();
14
+ if (!text) return undefined;
15
+ if (!clipped && text.length <= NOTIFICATION_PREVIEW_LIMIT) return text;
16
+
17
+ let end = 0;
18
+ for (const { segment, index } of segmenter.segment(text)) {
19
+ if (index + segment.length > NOTIFICATION_PREVIEW_LIMIT - 1) break;
20
+ end = index + segment.length;
21
+ }
22
+ return `${text.slice(0, end).trimEnd()}…`;
23
+ };