@k2b/cloud 0.26.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 (121) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/define-app.ts +8 -1
  3. package/src/_internal/process-identity.ts +7 -1
  4. package/src/_internal/registry-validation.ts +3 -0
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/ai/admin.ts +1 -0
  7. package/src/ai/browser-code-contracts.ts +46 -64
  8. package/src/ai/browser.ts +9 -1
  9. package/src/ai/capabilities.ts +82 -22
  10. package/src/ai/chat/blocks.tsx +16 -7
  11. package/src/ai/chat/builtin-tools.tsx +31 -23
  12. package/src/ai/chat/file-tools.tsx +4 -1
  13. package/src/ai/chat/live-turn.browser-harness.tsx +15 -5
  14. package/src/ai/chat/message-actions.tsx +123 -101
  15. package/src/ai/chat/message-utils.ts +30 -1
  16. package/src/ai/chat/messages.ts +206 -2
  17. package/src/ai/chat/presentation.tsx +94 -28
  18. package/src/ai/chat/tool-groups.ts +1 -1
  19. package/src/ai/chat/turn-error.ts +21 -0
  20. package/src/ai/chat/turn-view.tsx +102 -16
  21. package/src/ai/chat/user-message.tsx +19 -14
  22. package/src/ai/chat/visual-tools.tsx +1 -1
  23. package/src/ai/client/controller.ts +83 -37
  24. package/src/ai/client/file-source.ts +20 -3
  25. package/src/ai/client/projection.ts +7 -2
  26. package/src/ai/code-mode-skill.ts +32 -36
  27. package/src/ai/code-runtime-tools.ts +14 -1
  28. package/src/ai/code-source-contracts.ts +56 -6
  29. package/src/ai/code-source-tools.ts +10 -3
  30. package/src/ai/credentials.ts +17 -3
  31. package/src/ai/data-analysis-skill.ts +3 -3
  32. package/src/ai/default-tools.ts +15 -16
  33. package/src/ai/executor.ts +281 -141
  34. package/src/ai/file-context.ts +14 -2
  35. package/src/ai/file-tools.ts +17 -3
  36. package/src/ai/files-store.ts +134 -11
  37. package/src/ai/grids-skill.ts +2 -2
  38. package/src/ai/index.ts +9 -0
  39. package/src/ai/memories.ts +14 -0
  40. package/src/ai/migrate.ts +125 -0
  41. package/src/ai/model-request-settings.ts +98 -0
  42. package/src/ai/open-tool-calls.ts +87 -0
  43. package/src/ai/protocol.ts +6 -0
  44. package/src/ai/provider.ts +7 -1
  45. package/src/ai/quota-provider.ts +2 -2
  46. package/src/ai/request-headers.ts +117 -0
  47. package/src/ai/routes.ts +40 -6
  48. package/src/ai/run-timeout.ts +4 -5
  49. package/src/ai/runtime.ts +9 -1
  50. package/src/ai/settings.ts +19 -2
  51. package/src/ai/skill-seeds.ts +35 -7
  52. package/src/ai/skills.ts +26 -0
  53. package/src/ai/solid.ts +1 -1
  54. package/src/ai/store.ts +155 -71
  55. package/src/ai/stream.ts +180 -37
  56. package/src/ai/structured.ts +20 -5
  57. package/src/ai/system-prompt.ts +8 -0
  58. package/src/ai/tool-call-names.ts +45 -0
  59. package/src/ai/turn-failure.ts +100 -0
  60. package/src/ai/turn-policy.ts +248 -0
  61. package/src/ai/types.ts +52 -4
  62. package/src/api/admin-ai-quotas.ts +36 -1
  63. package/src/api/admin-core-settings.ts +16 -23
  64. package/src/api/admin-outgoing-mail.ts +242 -0
  65. package/src/api/index.ts +2 -0
  66. package/src/browser/FileChooser.tsx +11 -0
  67. package/src/browser/file-chooser-messages.ts +2 -0
  68. package/src/cli/admin/ai-quotas.ts +70 -1
  69. package/src/cli/admin/index.ts +8 -0
  70. package/src/cli/admin/notifications.ts +6 -0
  71. package/src/cli/admin/outgoing-mail.ts +215 -0
  72. package/src/contracts/app.ts +2 -0
  73. package/src/contracts/index.ts +1 -0
  74. package/src/contracts/outgoing-mail.ts +265 -0
  75. package/src/contracts/registry.ts +2 -0
  76. package/src/services/help/store.ts +2 -1
  77. package/src/services/index.ts +13 -0
  78. package/src/services/notifications/batches.ts +139 -79
  79. package/src/services/notifications/channels.ts +33 -13
  80. package/src/services/notifications/dispatcher.ts +57 -5
  81. package/src/services/notifications/email-frame.fixture.html +51 -0
  82. package/src/services/notifications/{email.ts → email-frame.ts} +12 -45
  83. package/src/services/notifications/email-mail.ts +101 -0
  84. package/src/services/notifications/index.ts +49 -36
  85. package/src/services/notifications/observability.ts +9 -1
  86. package/src/services/notifications/platform.ts +1 -1
  87. package/src/services/notifications/runtime.ts +9 -3
  88. package/src/services/outgoing-mail/admin.ts +84 -0
  89. package/src/services/outgoing-mail/attachments.ts +135 -0
  90. package/src/services/outgoing-mail/bulk.ts +11 -0
  91. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  92. package/src/services/outgoing-mail/drain.ts +60 -0
  93. package/src/services/outgoing-mail/enqueue.ts +180 -0
  94. package/src/services/outgoing-mail/index.ts +112 -0
  95. package/src/services/outgoing-mail/message.ts +14 -0
  96. package/src/services/outgoing-mail/messages.ts +431 -0
  97. package/src/services/outgoing-mail/retention.ts +48 -0
  98. package/src/services/outgoing-mail/runtime.ts +72 -0
  99. package/src/services/outgoing-mail/send.ts +130 -0
  100. package/src/services/outgoing-mail/store.ts +397 -0
  101. package/src/services/outgoing-mail/sync.ts +39 -0
  102. package/src/services/outgoing-mail/test-send.ts +40 -0
  103. package/src/services/outgoing-mail/transport.ts +17 -0
  104. package/src/services/pdf/markdown.ts +22 -4
  105. package/src/services/postgres.ts +15 -0
  106. package/src/services/settings/core-settings.ts +19 -38
  107. package/src/services/settings/store.ts +5 -1
  108. package/src/shared/ai-model-request-settings.ts +21 -0
  109. package/src/shared/ai-platform-prompt.ts +49 -5
  110. package/src/shared/ai-request-options.ts +185 -0
  111. package/src/shared/markdown/extensions/links.ts +28 -20
  112. package/src/shared/markdown/index.ts +14 -5
  113. package/src/shared/markdown/shared.ts +0 -7
  114. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  115. package/src/ssr/admin-navigation.ts +1 -1
  116. package/src/ssr/platform-messages.ts +3 -1
  117. package/src/ssr/workspace-navigation.ts +7 -1
  118. package/src/styles/effects.css +15 -17
  119. package/src/styles/tokens.css +2 -0
  120. package/src/styles/utilities-markdown-editor.css +4 -39
  121. package/src/styles/utilities-markdown-table.css +14 -17
@@ -0,0 +1,397 @@
1
+ import { type SQL, sql } from "bun";
2
+ import { listApps } from "../../_internal/registry";
3
+ import {
4
+ type AdminMailApp,
5
+ type AdminMailProfile,
6
+ AdminMailProfileSchema,
7
+ type MailAppAccess,
8
+ MailAppAccessSchema,
9
+ type MailBounceErrorCode,
10
+ type MailErrorCode,
11
+ type MailProfile,
12
+ type MailProfileInput,
13
+ MailProfileInputSchema,
14
+ MailProfileKeySchema,
15
+ } from "../../contracts/outgoing-mail";
16
+ import type { AppRegistryEntry } from "../../contracts/registry";
17
+ import { type AuditActor, audit } from "../audit";
18
+ import { toPgTextArray } from "../postgres";
19
+ import { decryptValue, encryptValue } from "../settings/crypto";
20
+
21
+ export class OutgoingMailError extends Error {
22
+ constructor(
23
+ public readonly code: MailErrorCode,
24
+ message: string,
25
+ public readonly status: 400 | 404 | 409 | 502 = 400,
26
+ ) {
27
+ super(message);
28
+ }
29
+ }
30
+ export type MailAuditContext = { actor: AuditActor; requestId?: string };
31
+ type ProfileRow = {
32
+ id: string;
33
+ key: string;
34
+ name: string;
35
+ from_address: string;
36
+ from_name: string | null;
37
+ smtp_host: string;
38
+ smtp_port: number;
39
+ smtp_secure: boolean;
40
+ smtp_user: string | null;
41
+ has_password: boolean;
42
+ imap_host: string | null;
43
+ imap_port: number | null;
44
+ imap_secure: boolean | null;
45
+ imap_user: string | null;
46
+ imap_folder: string | null;
47
+ imap_has_password: boolean;
48
+ imap_checked_at: Date | string | null;
49
+ imap_error: MailBounceErrorCode | null;
50
+ pace_per_minute: number;
51
+ daily_recipient_limit: number | null;
52
+ max_attachment_bytes: number;
53
+ is_default: boolean;
54
+ revision: number;
55
+ created_at: Date | string;
56
+ updated_at: Date | string;
57
+ updated_by: string | null;
58
+ };
59
+ const columns = (db: SQL) => db`id, key, name, from_address, from_name, smtp_host, smtp_port, smtp_secure, smtp_user,
60
+ (smtp_password_encrypted IS NOT NULL) AS has_password, pace_per_minute, daily_recipient_limit, max_attachment_bytes,
61
+ imap_host, imap_port, imap_secure, imap_user, imap_folder,
62
+ (imap_password_encrypted IS NOT NULL) AS imap_has_password, imap_checked_at, imap_error,
63
+ is_default, revision, created_at, updated_at, updated_by`;
64
+ const timestamp = (value: Date | string) => new Date(value).toISOString();
65
+ const keyValue = (key: string) => {
66
+ const result = MailProfileKeySchema.safeParse(key);
67
+ if (!result.success) throw new OutgoingMailError("invalid_profile", "Invalid profile key.");
68
+ return result.data;
69
+ };
70
+ const unknown = (): never => {
71
+ throw new OutgoingMailError("profile_unknown", "Outgoing mail profile does not exist.", 404);
72
+ };
73
+ const locked = async <T>(run: (tx: SQL) => Promise<T>): Promise<T> =>
74
+ sql.begin(async (tx) => {
75
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended('outgoing_mail.policy', 0))`;
76
+ return run(tx);
77
+ });
78
+ const record = (db: SQL, context: MailAuditContext, action: string, target: string) =>
79
+ audit.record(
80
+ {
81
+ ...context,
82
+ action: `outgoing_mail.${action}`,
83
+ outcome: "allowed",
84
+ target: { type: action.startsWith("profile.") ? "outgoing_mail_profile" : "outgoing_mail_app", id: target },
85
+ },
86
+ db,
87
+ );
88
+ const accessRows = async (db: SQL) => {
89
+ const access = await db<{ app_id: string; mode: "default" | "selected" }[]>`SELECT app_id, mode FROM outgoing_mail.app_access`;
90
+ const grants = await db<
91
+ { app_id: string; key: string }[]
92
+ >`SELECT ap.app_id, p.key FROM outgoing_mail.app_profiles ap JOIN outgoing_mail.profiles p ON p.id = ap.profile_id ORDER BY p.key`;
93
+ return { access, grants };
94
+ };
95
+ const mapProfile = (row: ProfileRow, appCount: number): AdminMailProfile => ({
96
+ key: row.key,
97
+ name: row.name,
98
+ fromAddress: row.from_address,
99
+ fromName: row.from_name,
100
+ smtpHost: row.smtp_host,
101
+ smtpPort: row.smtp_port,
102
+ smtpSecure: row.smtp_secure,
103
+ smtpUser: row.smtp_user,
104
+ hasPassword: row.has_password,
105
+ imap:
106
+ row.imap_host === null
107
+ ? null
108
+ : AdminMailProfileSchema.shape.imap.parse({
109
+ host: row.imap_host,
110
+ port: row.imap_port,
111
+ secure: row.imap_secure,
112
+ user: row.imap_user,
113
+ folder: row.imap_folder,
114
+ hasPassword: row.imap_has_password,
115
+ }),
116
+ bounces:
117
+ row.imap_host === null
118
+ ? null
119
+ : {
120
+ checkedAt: row.imap_checked_at === null ? null : timestamp(row.imap_checked_at),
121
+ error: row.imap_error,
122
+ },
123
+ pacePerMinute: row.pace_per_minute,
124
+ dailyRecipientLimit: row.daily_recipient_limit,
125
+ maxAttachmentBytes: row.max_attachment_bytes,
126
+ isDefault: row.is_default,
127
+ revision: row.revision,
128
+ createdAt: timestamp(row.created_at),
129
+ updatedAt: timestamp(row.updated_at),
130
+ updatedBy: row.updated_by,
131
+ appCount,
132
+ });
133
+ const list = async (db: SQL = sql, registeredApps?: readonly AppRegistryEntry[]): Promise<AdminMailProfile[]> => {
134
+ const apps = registeredApps ?? (await listApps());
135
+ const rows = await db<ProfileRow[]>`SELECT ${columns(db)} FROM outgoing_mail.profiles ORDER BY is_default DESC, name, key`;
136
+ const { access, grants } = await accessRows(db);
137
+ const selected = new Set(access.filter((a) => a.mode === "selected").map((a) => a.app_id));
138
+ const defaults = apps.filter((app) => app.platformPermissions?.includes("mail:send") && !selected.has(app.id));
139
+ return rows.map((row) =>
140
+ mapProfile(row, grants.filter((g) => g.key === row.key && selected.has(g.app_id)).length + (row.is_default ? defaults.length : 0)),
141
+ );
142
+ };
143
+ const get = async (key: string): Promise<AdminMailProfile> => {
144
+ keyValue(key);
145
+ return (await list()).find((profile) => profile.key === key) ?? unknown();
146
+ };
147
+ const put = async (
148
+ key: string,
149
+ value: MailProfileInput,
150
+ context: MailAuditContext,
151
+ ): Promise<{ profile: AdminMailProfile; created: boolean }> => {
152
+ keyValue(key);
153
+ const parsed = MailProfileInputSchema.safeParse(value);
154
+ if (!parsed.success) throw new OutgoingMailError("invalid_profile", "Invalid outgoing mail profile configuration.");
155
+ const input = parsed.data;
156
+ const apps = await listApps();
157
+ return locked(async (tx) => {
158
+ const [before] = await tx<ProfileRow[]>`SELECT ${columns(tx)} FROM outgoing_mail.profiles WHERE key = ${key}`;
159
+ if (before && input.revision === undefined)
160
+ throw new OutgoingMailError("profile_exists", "Profile exists; provide its current revision.", 409);
161
+ if (input.revision !== undefined && (!before || input.revision !== before.revision))
162
+ throw new OutgoingMailError("revision_conflict", "Profile revision changed; reload before replacing it.", 409);
163
+ if (
164
+ before?.has_password &&
165
+ input.smtpPassword === undefined &&
166
+ before.smtp_host.trim().toLowerCase() !== input.smtpHost.trim().toLowerCase()
167
+ )
168
+ throw new OutgoingMailError("invalid_profile", "Enter the SMTP password again, or remove it, when you change the SMTP host.");
169
+ const imap = input.imap ?? null;
170
+ if (
171
+ before?.imap_has_password &&
172
+ imap &&
173
+ imap.password === undefined &&
174
+ before.imap_host?.trim().toLowerCase() !== imap.host.trim().toLowerCase()
175
+ )
176
+ throw new OutgoingMailError("invalid_profile", "Enter the IMAP password again, or remove it, when you change the IMAP host.");
177
+ const imapPassword =
178
+ !imap || imap.password === null ? null : imap.password === undefined ? undefined : await encryptValue(imap.password);
179
+ const resetImap =
180
+ !imap ||
181
+ !before ||
182
+ before.imap_host?.toLowerCase() !== imap.host.toLowerCase() ||
183
+ before.imap_port !== imap.port ||
184
+ before.imap_user !== imap.user ||
185
+ before.imap_folder !== imap.folder;
186
+ const password =
187
+ input.smtpPassword === undefined ? undefined : input.smtpPassword === null ? null : await encryptValue(input.smtpPassword);
188
+ if (before) {
189
+ await tx`UPDATE outgoing_mail.profiles SET name = ${input.name}, from_address = ${input.fromAddress}, from_name = ${input.fromName},
190
+ smtp_host = ${input.smtpHost}, smtp_port = ${input.smtpPort}, smtp_secure = ${input.smtpSecure}, smtp_user = ${input.smtpUser},
191
+ smtp_password_encrypted = CASE WHEN ${password !== undefined} THEN ${password ?? null} ELSE smtp_password_encrypted END,
192
+ pace_per_minute = ${input.pacePerMinute}, daily_recipient_limit = ${input.dailyRecipientLimit}, max_attachment_bytes = ${input.maxAttachmentBytes},
193
+ revision = revision + 1, updated_at = now(), updated_by = ${context.actor.userId ?? null} WHERE key = ${key}`;
194
+ } else {
195
+ await tx`INSERT INTO outgoing_mail.profiles(id, key, name, from_address, from_name, smtp_host, smtp_port, smtp_secure, smtp_user,
196
+ smtp_password_encrypted, pace_per_minute, daily_recipient_limit, max_attachment_bytes, is_default, updated_by)
197
+ VALUES (${crypto.randomUUID()}::uuid, ${key}, ${input.name}, ${input.fromAddress}, ${input.fromName}, ${input.smtpHost}, ${input.smtpPort},
198
+ ${input.smtpSecure}, ${input.smtpUser}, ${password ?? null}, ${input.pacePerMinute}, ${input.dailyRecipientLimit}, ${input.maxAttachmentBytes},
199
+ NOT EXISTS(SELECT 1 FROM outgoing_mail.profiles), ${context.actor.userId ?? null})`;
200
+ }
201
+ await tx`UPDATE outgoing_mail.profiles SET
202
+ imap_host = ${imap?.host ?? null}, imap_port = ${imap?.port ?? null}, imap_secure = ${imap?.secure ?? null},
203
+ imap_user = ${imap?.user ?? null}, imap_folder = ${imap?.folder ?? null},
204
+ imap_password_encrypted = CASE WHEN ${imapPassword !== undefined} THEN ${imapPassword ?? null} ELSE imap_password_encrypted END,
205
+ imap_uid_validity = CASE WHEN ${resetImap} THEN NULL ELSE imap_uid_validity END,
206
+ imap_last_uid = CASE WHEN ${resetImap} THEN NULL ELSE imap_last_uid END,
207
+ imap_error = CASE WHEN ${resetImap} THEN NULL ELSE imap_error END,
208
+ imap_checked_at = CASE WHEN ${resetImap} THEN NULL ELSE imap_checked_at END
209
+ WHERE key = ${key}`;
210
+ await record(tx, context, before ? "profile.update" : "profile.create", key);
211
+ return { profile: (await list(tx, apps)).find((p) => p.key === key)!, created: !before };
212
+ });
213
+ };
214
+ const setDefault = async (key: string, context: MailAuditContext): Promise<AdminMailProfile> => {
215
+ keyValue(key);
216
+ const apps = await listApps();
217
+ return locked(async (tx) => {
218
+ const [target] = await tx<{ key: string }[]>`SELECT key FROM outgoing_mail.profiles WHERE key = ${key}`;
219
+ if (!target) unknown();
220
+ await tx`UPDATE outgoing_mail.profiles SET is_default = false, revision = revision + 1, updated_at = now(), updated_by = ${context.actor.userId ?? null} WHERE is_default AND key <> ${key}`;
221
+ await tx`UPDATE outgoing_mail.profiles SET is_default = true, revision = revision + 1, updated_at = now(), updated_by = ${context.actor.userId ?? null} WHERE key = ${key} AND NOT is_default`;
222
+ await record(tx, context, "profile.set_default", key);
223
+ return (await list(tx, apps)).find((p) => p.key === key)!;
224
+ });
225
+ };
226
+ const remove = async (key: string, context: MailAuditContext): Promise<void> => {
227
+ keyValue(key);
228
+ await locked(async (tx) => {
229
+ const [before] = await tx<{ is_default: boolean }[]>`SELECT is_default FROM outgoing_mail.profiles WHERE key = ${key}`;
230
+ if (!before) return unknown();
231
+ if (before.is_default)
232
+ throw new OutgoingMailError("profile_is_default", "Choose another default profile before deleting this one.", 409);
233
+ await tx`DELETE FROM outgoing_mail.profiles WHERE key = ${key}`;
234
+ await record(tx, context, "profile.delete", key);
235
+ });
236
+ };
237
+ const appsState = async (
238
+ db: SQL = sql,
239
+ registeredApps?: readonly AppRegistryEntry[],
240
+ ): Promise<{ defaultProfile: string | null; items: AdminMailApp[] }> => {
241
+ const registered = registeredApps ?? (await listApps());
242
+ const { access, grants } = await accessRows(db);
243
+ const [profile] = await db<{ key: string }[]>`SELECT key FROM outgoing_mail.profiles WHERE is_default`;
244
+ const ids = new Set([...registered.map((app) => app.id), ...access.map((a) => a.app_id)]);
245
+ const items = [...ids]
246
+ .map((appId): AdminMailApp => {
247
+ const app = registered.find((a) => a.id === appId);
248
+ const mode = access.find((a) => a.app_id === appId)?.mode ?? "default";
249
+ return {
250
+ appId,
251
+ name: app?.name ?? appId,
252
+ registered: !!app,
253
+ declared: !!app?.platformPermissions?.includes("mail:send"),
254
+ mode,
255
+ profiles: mode === "selected" ? grants.filter((g) => g.app_id === appId).map((g) => g.key) : [],
256
+ };
257
+ })
258
+ .sort((a, b) => a.name.localeCompare(b.name) || a.appId.localeCompare(b.appId));
259
+ return { defaultProfile: profile?.key ?? null, items };
260
+ };
261
+ const setAppAccess = async (appId: string, value: MailAppAccess, context: MailAuditContext): Promise<AdminMailApp> => {
262
+ if (appId === "core") throw new OutgoingMailError("invalid_profile", "Core's system email always uses the default profile.");
263
+ if (!appId.trim()) throw new OutgoingMailError("invalid_profile", "Application id is required.");
264
+ const parsed = MailAppAccessSchema.safeParse(value);
265
+ if (!parsed.success) throw new OutgoingMailError("invalid_profile", "Invalid application access policy.");
266
+ const input = parsed.data;
267
+ const registered = await listApps();
268
+ return locked(async (tx) => {
269
+ const keys = input.mode === "selected" ? [...new Set(input.profiles)] : [];
270
+ const rows = await tx<
271
+ { id: string; key: string }[]
272
+ >`SELECT id, key FROM outgoing_mail.profiles WHERE key = ANY(${toPgTextArray(keys)}::text[])`;
273
+ if (rows.length !== keys.length) throw new OutgoingMailError("profile_unknown", "An outgoing mail profile does not exist.");
274
+ await tx`DELETE FROM outgoing_mail.app_profiles WHERE app_id = ${appId}`;
275
+ if (input.mode === "default") await tx`DELETE FROM outgoing_mail.app_access WHERE app_id = ${appId}`;
276
+ else {
277
+ await tx`INSERT INTO outgoing_mail.app_access(app_id, mode, updated_by) VALUES (${appId}, 'selected', ${context.actor.userId ?? null})
278
+ ON CONFLICT(app_id) DO UPDATE SET mode = 'selected', updated_at = now(), updated_by = EXCLUDED.updated_by`;
279
+ for (const row of rows) await tx`INSERT INTO outgoing_mail.app_profiles(app_id, profile_id) VALUES (${appId}, ${row.id}::uuid)`;
280
+ }
281
+ await record(tx, context, "app_access.update", appId);
282
+ return (
283
+ (await appsState(tx, registered)).items.find((app) => app.appId === appId) ?? {
284
+ appId,
285
+ name: appId,
286
+ registered: false,
287
+ declared: false,
288
+ mode: "default",
289
+ profiles: [],
290
+ }
291
+ );
292
+ });
293
+ };
294
+ const profilesForApp = async (appId: string): Promise<MailProfile[]> => {
295
+ const rows = await sql<
296
+ {
297
+ key: string;
298
+ name: string;
299
+ from_address: string;
300
+ is_default: boolean;
301
+ max_attachment_bytes: number;
302
+ daily_recipient_limit: number | null;
303
+ used: number;
304
+ }[]
305
+ >`
306
+ SELECT p.key, p.name, p.from_address, p.is_default, p.max_attachment_bytes, p.daily_recipient_limit,
307
+ (SELECT COALESCE(sum(m.recipient_count), 0)::int FROM outgoing_mail.messages m WHERE m.app_id = ${appId}
308
+ AND m.profile_id = p.id AND m.status <> 'cancelled' AND m.created_at > now() - INTERVAL '24 hours') AS used
309
+ FROM outgoing_mail.profiles p WHERE
310
+ CASE WHEN EXISTS(SELECT 1 FROM outgoing_mail.app_access WHERE app_id = ${appId} AND mode = 'selected')
311
+ THEN EXISTS(SELECT 1 FROM outgoing_mail.app_profiles WHERE app_id = ${appId} AND profile_id = p.id)
312
+ ELSE p.is_default END ORDER BY p.is_default DESC, p.name, p.key`;
313
+ return rows.map((row) => ({
314
+ key: row.key,
315
+ name: row.name,
316
+ from: row.from_address,
317
+ default: row.is_default,
318
+ maxAttachmentBytes: row.max_attachment_bytes,
319
+ quota: { dailyRecipients: row.daily_recipient_limit, usedLast24h: row.used },
320
+ }));
321
+ };
322
+ export const outgoingMailStore = { list, get, put, setDefault, delete: remove, apps: appsState, setAppAccess, profilesForApp };
323
+
324
+ /** Platform email send path only. Never use this helper to shape API or CLI responses. */
325
+ export const resolveMailCredentials = async (key?: string | { id: string }) => {
326
+ const [row] = await sql<(ProfileRow & { smtp_password_encrypted: string | null })[]>`
327
+ SELECT ${columns(sql)}, smtp_password_encrypted FROM outgoing_mail.profiles WHERE ${
328
+ key === undefined ? sql`is_default` : typeof key === "string" ? sql`key = ${keyValue(key)}` : sql`id = ${key.id}::uuid`
329
+ }`;
330
+ if (!row)
331
+ throw new OutgoingMailError(
332
+ "profile_unknown",
333
+ key ? "Outgoing mail profile does not exist." : "Configure a default outgoing mail profile before sending email.",
334
+ 404,
335
+ );
336
+ const password = row.smtp_password_encrypted === null ? null : await decryptValue(row.smtp_password_encrypted);
337
+ if (password !== null && typeof password !== "string") throw new Error("Outgoing mail SMTP password is invalid.");
338
+ return {
339
+ fromAddress: row.from_address,
340
+ fromName: row.from_name,
341
+ smtpHost: row.smtp_host,
342
+ smtpPort: row.smtp_port,
343
+ smtpSecure: row.smtp_secure,
344
+ smtpUser: row.smtp_user,
345
+ smtpPassword: password,
346
+ };
347
+ };
348
+
349
+ export type ImapMailProfile = {
350
+ id: string;
351
+ key: string;
352
+ revision: number;
353
+ fromAddress: string;
354
+ imap: NonNullable<AdminMailProfile["imap"]>;
355
+ uidValidity: string | null;
356
+ lastUid: number | null;
357
+ };
358
+ /** Core's bounce poller only; listing profiles does not decrypt credentials. */
359
+ export const listImapMailProfiles = async (): Promise<ImapMailProfile[]> => {
360
+ const rows = await sql<(ProfileRow & { uid_validity: string | null; last_uid: string | null })[]>`
361
+ SELECT ${columns(sql)}, imap_uid_validity::text AS uid_validity, imap_last_uid::text AS last_uid
362
+ FROM outgoing_mail.profiles WHERE imap_host IS NOT NULL ORDER BY imap_checked_at NULLS FIRST, key`;
363
+ return rows.map((row) => {
364
+ const imap = mapProfile(row, 0).imap;
365
+ if (!imap) throw new Error("Outgoing mail IMAP configuration is invalid.");
366
+ return {
367
+ id: row.id,
368
+ key: row.key,
369
+ revision: row.revision,
370
+ fromAddress: row.from_address,
371
+ imap,
372
+ uidValidity: row.uid_validity,
373
+ lastUid: row.last_uid === null ? null : Number(row.last_uid),
374
+ };
375
+ });
376
+ };
377
+ export const resolveImapMailCredentials = async (profile: ImapMailProfile) => {
378
+ const [row] = await sql<{ imap_password_encrypted: string | null }[]>`
379
+ SELECT imap_password_encrypted FROM outgoing_mail.profiles
380
+ WHERE id = ${profile.id}::uuid AND revision = ${profile.revision} AND imap_host IS NOT NULL`;
381
+ if (!row) throw new Error("Outgoing mail IMAP profile changed.");
382
+ const password = row.imap_password_encrypted === null ? null : await decryptValue(row.imap_password_encrypted);
383
+ if (password !== null && typeof password !== "string") throw new Error("Outgoing mail IMAP password is invalid.");
384
+ return password;
385
+ };
386
+ export const saveImapMailCheck = async (
387
+ profile: ImapMailProfile,
388
+ result: { uidValidity: string; lastUid: number | null; error?: MailBounceErrorCode } | { error: MailBounceErrorCode },
389
+ ) => {
390
+ await sql`UPDATE outgoing_mail.profiles SET imap_checked_at = now(),
391
+ imap_error = ${result.error ?? null},
392
+ imap_uid_validity = ${"uidValidity" in result ? result.uidValidity : profile.uidValidity}::bigint,
393
+ imap_last_uid = ${"lastUid" in result ? result.lastUid : profile.lastUid}::bigint
394
+ WHERE id = ${profile.id}::uuid AND revision = ${profile.revision} AND imap_host IS NOT NULL
395
+ AND imap_uid_validity IS NOT DISTINCT FROM ${profile.uidValidity}::bigint
396
+ AND imap_last_uid IS NOT DISTINCT FROM ${profile.lastUid}::bigint`;
397
+ };
@@ -0,0 +1,39 @@
1
+ import { lazySync } from "../../_internal/process-sync";
2
+
3
+ export const mailAttachments = lazySync((sync) =>
4
+ sync.objectStore({
5
+ id: "cloud-outgoing-mail-attachments",
6
+ owner: "core",
7
+ maxObjectBytes: 25 * 1024 * 1024,
8
+ retention: { maxAgeMs: 48 * 60 * 60_000, maxBytes: 2 * 1024 * 1024 * 1024 },
9
+ }),
10
+ );
11
+ export const mailSendJob = lazySync((sync) =>
12
+ sync.job<{ id: string }>({
13
+ id: "cloud-outgoing-mail-send",
14
+ owner: "core",
15
+ delivery: { ackWaitMs: 120_000, maxAttempts: 3, backoffMs: [5000, 30000] },
16
+ }),
17
+ );
18
+ export const mailSettled = lazySync((sync) =>
19
+ sync.topic<string>({
20
+ id: "cloud-outgoing-mail-settled",
21
+ owner: "core",
22
+ // 256 minimal wakeups per worker in flight, following Sync's default queue budget.
23
+ maxPayloadBytes: 1024,
24
+ retention: { maxAgeMs: 5 * 60_000, maxBytes: 8 * 256 * 1024 },
25
+ }),
26
+ );
27
+ export const mailDrainJob = lazySync((sync) =>
28
+ sync.job<{ profileId: string }>({
29
+ id: "cloud-outgoing-mail-drain",
30
+ owner: "core",
31
+ delivery: { ackWaitMs: 120_000, maxAttempts: 3, backoffMs: [5000, 30000] },
32
+ }),
33
+ );
34
+ export const submitMailDrain = async (profileId: string): Promise<void> => {
35
+ await mailDrainJob().submit({ key: `profile:${profileId}`, input: { profileId }, coalesce: true });
36
+ };
37
+ export const submitMail = async (id: string): Promise<void> => {
38
+ await mailSendJob().submit({ key: id, input: { id }, coalesce: true });
39
+ };
@@ -0,0 +1,40 @@
1
+ import { audit } from "../audit";
2
+ import { get } from "../settings";
3
+ import { type MailAuditContext, OutgoingMailError, resolveMailCredentials } from "./store";
4
+ import { buildMailTransport } from "./transport";
5
+
6
+ export const smtpFailureMessage = (error: unknown, secrets: readonly (string | null)[]): string => {
7
+ let message = error instanceof Error ? error.message : "SMTP delivery failed.";
8
+ for (const secret of secrets.filter((s): s is string => !!s)) {
9
+ for (const representation of [secret, Buffer.from(secret).toString("base64")])
10
+ message = message.split(representation).join("[REDACTED]");
11
+ }
12
+ return message;
13
+ };
14
+ export const outgoingMailTest = {
15
+ async send(key: string, recipient: string, context: MailAuditContext): Promise<void> {
16
+ const profile = await resolveMailCredentials(key);
17
+ const event = { ...context, action: "outgoing_mail.profile.test", target: { type: "outgoing_mail_profile", id: key } };
18
+ // Fail before the external effect if audit storage is unavailable.
19
+ await audit.record({ ...event, outcome: "allowed" });
20
+ const transport = buildMailTransport(profile);
21
+ try {
22
+ await transport.sendMail({
23
+ from: { address: profile.fromAddress, name: profile.fromName ?? (await get<string>("app.name")) },
24
+ to: recipient,
25
+ subject: "Cloud test email",
26
+ text: "This is a test email from Cloud. SMTP delivery is configured correctly if you received it.",
27
+ });
28
+ } catch (error) {
29
+ const message = smtpFailureMessage(error, [
30
+ profile.smtpPassword,
31
+ profile.smtpUser,
32
+ profile.smtpUser ? `\0${profile.smtpUser}\0${profile.smtpPassword ?? ""}` : null,
33
+ ]);
34
+ await audit.recordResultAfterSideEffect({ ...event, result: { ok: false, error: { code: "smtp_failed", message, status: 500 } } });
35
+ throw new OutgoingMailError("smtp_failed", message, 502);
36
+ } finally {
37
+ transport.close();
38
+ }
39
+ },
40
+ };
@@ -0,0 +1,17 @@
1
+ import type { Socket } from "node:net";
2
+ import { createTransport } from "nodemailer";
3
+ import type { resolveMailCredentials } from "./store";
4
+
5
+ export const buildMailTransport = (profile: Awaited<ReturnType<typeof resolveMailCredentials>>, socket?: Socket) =>
6
+ createTransport({
7
+ // Workers own the raw socket so timeout/shutdown can destroy it. Nodemailer
8
+ // still owns SMTP and upgrades this socket for implicit TLS or STARTTLS.
9
+ ...(socket ? { socket } : {}),
10
+ host: profile.smtpHost,
11
+ port: profile.smtpPort,
12
+ secure: profile.smtpSecure,
13
+ ...(profile.smtpUser ? { auth: { user: profile.smtpUser, pass: profile.smtpPassword ?? "" } } : {}),
14
+ connectionTimeout: 10000,
15
+ greetingTimeout: 10000,
16
+ socketTimeout: 60000,
17
+ });
@@ -1,4 +1,4 @@
1
- import { markdownInfoBlocks } from "@k2b/ui";
1
+ import { markdownInfoBlocks, markdownLinkReference, renderMarkdownLink } from "@k2b/ui";
2
2
  import { Marked, Renderer, type Tokens } from "marked";
3
3
  import postcss from "postcss";
4
4
  import {
@@ -70,6 +70,7 @@ h1, h2, h3, h4, h5, h6 { color: #111827; line-height: 1.2; margin: 1.4em 0 .55em
70
70
  h1 { margin-top: 0; font-size: 2em; } h2 { font-size: 1.5em; } h3 { font-size: 1.2em; }
71
71
  p, ul, ol, blockquote, pre, table { margin: 0 0 1em; }
72
72
  a { color: #2563eb; text-decoration: underline; }
73
+ :root { --link-accent: #2563eb; }
73
74
  blockquote { margin-left: 0; padding-left: 1em; border-left: 3px solid #cbd5e1; color: #475569; }
74
75
  code { border-radius: 3px; background: #f1f5f9; padding: .08em .28em; font: .9em/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
75
76
  pre { overflow-wrap: anywhere; white-space: pre-wrap; break-inside: avoid; border-radius: 6px; background: #f8fafc; padding: 1em; }
@@ -91,6 +92,7 @@ h2 { margin: 1.6em 0 .6em; padding-bottom: .2em; border-bottom: 1px solid #b8c7d
91
92
  h3 { margin: 1.35em 0 .5em; font-size: 1.15em; }
92
93
  p, ul, ol, blockquote, pre, table { margin: 0 0 1em; }
93
94
  a { color: #245a86; }
95
+ :root { --link-accent: #245a86; }
94
96
  blockquote { margin-left: 0; padding: .7em 1em; border-left: 4px solid #4f799d; background: #f4f7fa; color: #41566c; }
95
97
  code { border-radius: 3px; background: #edf2f7; padding: .08em .28em; font: .88em/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
96
98
  pre { overflow-wrap: anywhere; white-space: pre-wrap; break-inside: avoid; background: #f4f7fa; padding: 1em; }
@@ -111,6 +113,7 @@ h1 { margin-top: 0; font-size: 1.65em; } h2 { font-size: 1.3em; } h3 { font-size
111
113
  p, ul, ol, blockquote, pre, table { margin: 0 0 .58em; }
112
114
  ul, ol { padding-left: 1.5em; }
113
115
  a { color: #1d4ed8; }
116
+ :root { --link-accent: #1d4ed8; }
114
117
  blockquote { margin-left: 0; padding-left: .7em; border-left: 2px solid #9ca3af; color: #4b5563; }
115
118
  code { background: #f3f4f6; padding: .05em .2em; font: .88em/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; }
116
119
  pre { overflow-wrap: anywhere; white-space: pre-wrap; break-inside: avoid; background: #f7f7f8; padding: .65em; }
@@ -142,6 +145,22 @@ const INFO_BLOCK_PRINT_CSS = `
142
145
  .k2b-notice-card__body > :last-child { margin-bottom: 0; }
143
146
  `;
144
147
 
148
+ /**
149
+ * Links print as Cloud's calm links: a web link is text with a thin underline
150
+ * in the preset's link colour and a small ↗, a mail link has no arrow, and a
151
+ * reference to Cloud content is a light grey pill with text in the preset's
152
+ * colour. Print has no hover, and PDFs load no icon font, so the type icons
153
+ * and the screen arrow give way to the printed ↗.
154
+ */
155
+ const LINK_PRINT_CSS = `
156
+ a.k2b-text-link { color: inherit; text-decoration: underline; text-decoration-thickness: .06em; text-underline-offset: .2em; text-decoration-color: var(--link-accent, #2563eb); }
157
+ a.k2b-text-link[data-link="web"]::after { content: "\\2197"; display: inline-block; margin-left: .12em; color: var(--link-accent, #2563eb); font-size: .75em; text-decoration: none; }
158
+ a.k2b-reference { padding: .05em .3em; border-radius: .3em; background: #eeeff2; color: inherit; font-weight: 500; text-decoration: none; box-decoration-break: clone; -webkit-box-decoration-break: clone; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
159
+ .k2b-reference__document, .k2b-reference__size { color: #555c68; font-weight: 400; }
160
+ .k2b-reference__size { margin-left: .35em; font-size: .85em; }
161
+ .k2b-reference__icon, .k2b-text-link__external { display: none; }
162
+ `;
163
+
145
164
  const byteLength = (value: string): number => new TextEncoder().encode(value).byteLength;
146
165
 
147
166
  const escapeHtml = (value: string): string =>
@@ -172,8 +191,7 @@ const renderMarkdown = (source: string): string => {
172
191
  const body = this.parser.parseInline(tokens);
173
192
  const safeHref = safeLink(href);
174
193
  if (!safeHref) return body;
175
- const titleAttribute = title ? ` title="${escapeHtml(title)}"` : "";
176
- return `<a href="${escapeHtml(safeHref)}"${titleAttribute}>${body}</a>`;
194
+ return renderMarkdownLink({ href: safeHref, html: body, title: title ?? undefined, reference: markdownLinkReference(safeHref) });
177
195
  };
178
196
 
179
197
  const parser = new Marked({ breaks: true, gfm: true, renderer }, markdownInfoBlocks());
@@ -236,7 +254,7 @@ export const buildPresetPdfHtml = (input: BuildPresetPdfHtmlInput): string => {
236
254
  const suppliedCustomCss = input.customCss?.trim() ?? "";
237
255
  const customCss = suppliedCustomCss ? validateCustomCss(input.customCss ?? "") : "";
238
256
  const presetCss = templateId ? TEMPLATE_CSS[templateId] : customCss ? "" : TEMPLATE_CSS.document;
239
- const baseCss = [presetCss, INFO_BLOCK_PRINT_CSS, input.css ? styleText(input.css) : ""].filter(Boolean).join("\n");
257
+ const baseCss = [presetCss, INFO_BLOCK_PRINT_CSS, LINK_PRINT_CSS, input.css ? styleText(input.css) : ""].filter(Boolean).join("\n");
240
258
  const stylesheet = `${baseCss}${baseCss && customCss ? `\n/* Custom CSS overrides */\n` : ""}${customCss}`;
241
259
  return `<!doctype html>
242
260
  <html lang="en">
@@ -1,3 +1,18 @@
1
+ import type { TransactionSQL } from "bun";
2
+
3
+ /**
4
+ * Call inside the migration transaction directly after its advisory lock, as
5
+ * its first statement reading pg_namespace (including through views like pg_tables).
6
+ * The fresh relation lock applies catalog invalidations the advisory lock does not,
7
+ * so this check and all later DDL see schemas other migrators committed while it waited.
8
+ * The schema name must be a code constant and a plain lowercase Postgres identifier.
9
+ */
10
+ export const ensureSchema = async (db: TransactionSQL, name: string): Promise<void> => {
11
+ if (!/^[a-z_][a-z0-9_]{0,62}$/.test(name)) throw new Error("Schema name must be a plain lowercase Postgres identifier");
12
+ const [schema] = await db<{ exists: boolean }[]>`SELECT EXISTS(SELECT FROM pg_namespace WHERE nspname = ${name}) AS exists`;
13
+ if (!schema?.exists) await db.unsafe(`CREATE SCHEMA ${name}`).simple();
14
+ };
15
+
1
16
  /** Convert a JS string array to a Postgres TEXT[] literal (Bun sql can't serialize empty arrays). */
2
17
  export const toPgTextArray = (values: string[] | null | undefined): string => {
3
18
  if (!Array.isArray(values) || values.length === 0) return "{}";
@@ -29,6 +29,24 @@ const IPA_ACCOUNT_TRANSITION_OPTIONS = [
29
29
  ] as const;
30
30
 
31
31
  export const CORE_SETTINGS = {
32
+ "outgoing_mail.content_retention_days": {
33
+ kind: "number",
34
+ label: "Mail content retention (days)",
35
+ description: "Purge message bodies and custom headers after this many days.",
36
+ default: 90,
37
+ min: 1,
38
+ max: 36500,
39
+ integer: true,
40
+ },
41
+ "outgoing_mail.record_retention_days": {
42
+ kind: "number",
43
+ label: "Mail record retention (days)",
44
+ description: "Delete send log records after this many days.",
45
+ default: 365,
46
+ min: 1,
47
+ max: 36500,
48
+ integer: true,
49
+ },
32
50
  "observability.web_vitals.enabled": {
33
51
  kind: "boolean",
34
52
  label: "Record browser page performance",
@@ -292,7 +310,7 @@ export const CORE_SETTINGS = {
292
310
  default: 30,
293
311
  min: 0,
294
312
  description:
295
- "Maximum running time per AI turn. Zero or an empty field disables this limit. Existing turns keep their original budget; request and tool timeouts still apply.",
313
+ "Maximum running time per AI turn. In the last tenth, the Assistant stops using tools and answers. Zero or an empty field disables this limit. Existing turns keep their original budget; request and tool timeouts still apply.",
296
314
  },
297
315
  "ai.max_tool_result_chars": {
298
316
  kind: "number",
@@ -672,43 +690,6 @@ export const CORE_SETTINGS = {
672
690
  description: "Account request denial email template (HTML). Subject: Account Request Update",
673
691
  templateVars: ["FIRST_NAME", "REASON", "CONTACT_EMAIL", "APP_NAME"] as readonly string[],
674
692
  },
675
- "mail.noreply.smtp_host": {
676
- kind: "string",
677
- label: "SMTP Host",
678
- default: "",
679
- description: "SMTP server hostname",
680
- placeholder: "e.g. smtp.example.org",
681
- },
682
- "mail.noreply.smtp_port": {
683
- kind: "number",
684
- label: "SMTP Port",
685
- default: 587,
686
- min: 1,
687
- max: 65535,
688
- description: "SMTP server port (587 for STARTTLS, 465 for SSL)",
689
- },
690
- "mail.noreply.from": {
691
- kind: "email",
692
- label: "From Address",
693
- default: "",
694
- description: "From email address",
695
- placeholder: "e.g. noreply@example.org",
696
- },
697
- "mail.noreply.user": {
698
- kind: "string",
699
- label: "SMTP User",
700
- default: "",
701
- description: "SMTP username",
702
- placeholder: "e.g. noreply@example.org",
703
- },
704
- "mail.noreply.password": {
705
- kind: "secret",
706
- label: "SMTP Password",
707
- default: "",
708
- description: "SMTP password",
709
- placeholder: "SMTP password",
710
- },
711
-
712
693
  // ── Security ────────────────────────────────────────────────────────────
713
694
  "security.rate_limit_per_second": {
714
695
  kind: "number",