@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
@@ -0,0 +1,431 @@
1
+ import type { ObjectRef } from "@k2b/sync";
2
+ import { type SQL, sql } from "bun";
3
+ import { z } from "zod";
4
+ import {
5
+ type AdminMailFilter,
6
+ type AdminMailRecord,
7
+ type MailMessage,
8
+ type MailPage,
9
+ type MailPageParams,
10
+ type MailRecord,
11
+ MailRecordSchema,
12
+ type MailStatus,
13
+ } from "../../contracts/outgoing-mail";
14
+ import { sanitizeEmailHtml } from "../../shared/email-html";
15
+ import { audit } from "../audit";
16
+ import { escapeLikePattern, parsePgJsonValue, toPgTextArray, toPgUuidArray } from "../postgres";
17
+ import type { UploadedAttachments } from "./attachments";
18
+ import { mailActorSnapshot, mailMessageId } from "./message";
19
+ import { type MailAuditContext, OutgoingMailError } from "./store";
20
+
21
+ export type MessageRow = {
22
+ id: string;
23
+ app_id: string;
24
+ profile_id: string | null;
25
+ profile_key: string;
26
+ batch_id: string | null;
27
+ ref_scope: string | null;
28
+ ref_id: string | null;
29
+ to_addresses: string[];
30
+ recipient_count: number;
31
+ subject: string;
32
+ text_body: string | null;
33
+ html_body: string | null;
34
+ headers: unknown;
35
+ from_name: string | null;
36
+ reply_to: string | null;
37
+ message_id_header: string;
38
+ attachments: unknown;
39
+ attachment_refs: unknown;
40
+ status: MailStatus;
41
+ error_code: string | null;
42
+ error_message: string | null;
43
+ smtp_response: string | null;
44
+ failures: unknown;
45
+ attempt_count: number;
46
+ next_attempt_at: Date | string | null;
47
+ deadline_at: Date | string;
48
+ actor_type: string | null;
49
+ actor_id: string | null;
50
+ actor_name: string | null;
51
+ created_at: Date | string;
52
+ cursor_created_at: string;
53
+ sent_at: Date | string | null;
54
+ content_purged_at: Date | string | null;
55
+ };
56
+ export type AcceptedProfile = {
57
+ id: string;
58
+ key: string;
59
+ from_address: string;
60
+ max_attachment_bytes: number;
61
+ daily_recipient_limit: number | null;
62
+ pace_per_minute: number;
63
+ };
64
+ type LogRow = Pick<
65
+ MessageRow,
66
+ | "id"
67
+ | "app_id"
68
+ | "profile_key"
69
+ | "batch_id"
70
+ | "ref_scope"
71
+ | "ref_id"
72
+ | "to_addresses"
73
+ | "subject"
74
+ | "text_body"
75
+ | "attachments"
76
+ | "status"
77
+ | "error_code"
78
+ | "error_message"
79
+ | "smtp_response"
80
+ | "failures"
81
+ | "attempt_count"
82
+ | "actor_id"
83
+ | "actor_name"
84
+ | "created_at"
85
+ | "cursor_created_at"
86
+ | "sent_at"
87
+ | "content_purged_at"
88
+ >;
89
+ const logColumns = (db: SQL, includeText: boolean) => db`id, app_id, profile_key, batch_id, ref_scope, ref_id, to_addresses, subject,
90
+ ${includeText ? db`text_body` : db`NULL::text AS text_body`}, attachments, status, error_code, error_message, smtp_response,
91
+ failures, attempt_count, actor_id, actor_name, created_at, created_at::text AS cursor_created_at, sent_at, content_purged_at`;
92
+ const metadata = async (id: string): Promise<AdminMailRecord | undefined> => {
93
+ const [row] = await sql<LogRow[]>`SELECT ${logColumns(sql, false)} FROM outgoing_mail.messages WHERE id = ${id}::uuid`;
94
+ return row ? adminMessageRecord(row) : undefined;
95
+ };
96
+ const date = (value: Date | string) => new Date(value).toISOString();
97
+ export const messageRecord = (row: LogRow): MailRecord => ({
98
+ id: row.id,
99
+ profile: row.profile_key,
100
+ to: row.to_addresses,
101
+ subject: row.subject,
102
+ ...(row.batch_id ? { batchId: row.batch_id } : {}),
103
+ ...(row.ref_scope !== null && row.ref_id !== null ? { ref: { scope: row.ref_scope, id: row.ref_id } } : {}),
104
+ ...(row.text_body !== null ? { text: row.text_body } : {}),
105
+ attachments: MailRecordSchema.shape.attachments.parse(parsePgJsonValue(row.attachments)),
106
+ status: row.status,
107
+ ...(row.error_message ? { error: row.error_message } : {}),
108
+ failures: MailRecordSchema.shape.failures.parse(parsePgJsonValue(row.failures)),
109
+ attempts: row.attempt_count,
110
+ ...(row.actor_id ? { actor: { id: row.actor_id, name: row.actor_name ?? row.actor_id } } : {}),
111
+ createdAt: date(row.created_at),
112
+ ...(row.sent_at ? { sentAt: date(row.sent_at) } : {}),
113
+ ...(row.content_purged_at ? { contentPurgedAt: date(row.content_purged_at) } : {}),
114
+ });
115
+ export const adminMessageRecord = (row: LogRow): AdminMailRecord => {
116
+ const { text: _text, ...record } = messageRecord(row);
117
+ return {
118
+ ...record,
119
+ appId: row.app_id,
120
+ ...(row.error_code ? { errorCode: row.error_code } : {}),
121
+ ...(row.smtp_response ? { response: row.smtp_response } : {}),
122
+ };
123
+ };
124
+ export const messageAttachmentRefs = (row: Pick<MessageRow, "attachment_refs">): ObjectRef[] =>
125
+ z
126
+ .array(
127
+ z.object({
128
+ storeId: z.literal("cloud-outgoing-mail-attachments"),
129
+ tenantId: z.string(),
130
+ key: z.string(),
131
+ size: z.int().nonnegative(),
132
+ digest: z.string(),
133
+ }),
134
+ )
135
+ .parse(parsePgJsonValue(row.attachment_refs) ?? []);
136
+ const read = async (id: string, appId?: string, db: SQL = sql): Promise<MessageRow | undefined> => {
137
+ const [row] = await db<MessageRow[]>`SELECT *, created_at::text AS cursor_created_at FROM outgoing_mail.messages
138
+ WHERE id = ${id}::uuid ${appId === undefined ? db`` : db`AND app_id = ${appId}`}`;
139
+ return row;
140
+ };
141
+ const known = async (appId: string, key?: string, db: SQL = sql): Promise<MessageRow | undefined> => {
142
+ if (key === undefined) return undefined;
143
+ const [row] = await db<
144
+ MessageRow[]
145
+ >`SELECT *, created_at::text AS cursor_created_at FROM outgoing_mail.messages WHERE app_id = ${appId} AND idempotency_key = ${key}`;
146
+ return row;
147
+ };
148
+ export const allowedMailProfile = async (db: SQL, appId: string, key?: string): Promise<AcceptedProfile> => {
149
+ const [configured] = await db<{ exists: boolean }[]>`SELECT EXISTS(SELECT 1 FROM outgoing_mail.profiles) AS exists`;
150
+ if (!configured?.exists) throw new OutgoingMailError("mail_unavailable", "No outgoing mail profile is configured.");
151
+ const [profile] = await db<
152
+ AcceptedProfile[]
153
+ >`SELECT id, key, from_address, max_attachment_bytes, daily_recipient_limit, pace_per_minute FROM outgoing_mail.profiles
154
+ WHERE ${key === undefined ? db`is_default` : db`key = ${key}`} FOR SHARE`;
155
+ if (!profile)
156
+ throw new OutgoingMailError(key === undefined ? "profile_required" : "profile_unknown", "Choose an available outgoing mail profile.");
157
+ const [access] = await db<{ allowed: boolean }[]>`SELECT CASE
158
+ WHEN EXISTS(SELECT 1 FROM outgoing_mail.app_access WHERE app_id = ${appId} AND mode = 'selected')
159
+ THEN EXISTS(SELECT 1 FROM outgoing_mail.app_profiles WHERE app_id = ${appId} AND profile_id = ${profile.id}::uuid)
160
+ ELSE EXISTS(SELECT 1 FROM outgoing_mail.profiles WHERE id = ${profile.id}::uuid AND is_default) END AS allowed`;
161
+ if (!access?.allowed)
162
+ throw new OutgoingMailError(
163
+ key === undefined ? "profile_required" : "profile_not_allowed",
164
+ "This application may not use the outgoing mail profile.",
165
+ );
166
+ return profile;
167
+ };
168
+ const recordMailAcceptance = async (
169
+ appId: string,
170
+ actor: ReturnType<typeof mailActorSnapshot>,
171
+ target: { type: string; id?: string },
172
+ metadata: Record<string, unknown>,
173
+ db: SQL,
174
+ allowed: boolean,
175
+ errorCode?: string,
176
+ ): Promise<void> => {
177
+ await audit.record(
178
+ {
179
+ action: "outgoing_mail.send",
180
+ outcome: allowed ? "allowed" : "denied",
181
+ actor: actor ? { ...(actor.type === "user" ? { userId: actor.id } : {}), uid: actor.name, provider: actor.type } : { uid: appId },
182
+ target,
183
+ ...(errorCode ? { error: { code: errorCode } } : {}),
184
+ metadata,
185
+ },
186
+ db,
187
+ );
188
+ };
189
+ export const recordMailBatch = async (
190
+ appId: string,
191
+ messages: readonly MailMessage[],
192
+ rows: readonly MessageRow[] = [],
193
+ batchId?: string,
194
+ db: SQL = sql,
195
+ errorCode?: string,
196
+ ): Promise<void> => {
197
+ const actors = messages.flatMap((message) => {
198
+ const actor = mailActorSnapshot(message?.actor);
199
+ return actor ? [actor] : [];
200
+ });
201
+ await recordMailAcceptance(
202
+ appId,
203
+ actors[0],
204
+ { type: "outgoing_mail_batch", id: batchId },
205
+ {
206
+ appId,
207
+ batchId: batchId ?? null,
208
+ count: messages.length,
209
+ ids: rows.map((row) => row.id),
210
+ profiles: [...new Set(rows.length ? rows.map((row) => row.profile_key) : messages.map((message) => message?.profile ?? null))],
211
+ recipientCount: rows.length
212
+ ? rows.reduce((sum, row) => sum + row.recipient_count, 0)
213
+ : messages.reduce((sum, message) => sum + (message?.to?.length ?? 0), 0),
214
+ actors,
215
+ },
216
+ db,
217
+ batchId !== undefined,
218
+ errorCode,
219
+ );
220
+ };
221
+ export const recordMailSend = async (
222
+ appId: string,
223
+ message: MailMessage,
224
+ row?: MessageRow,
225
+ db: SQL = sql,
226
+ errorCode?: string,
227
+ ): Promise<void> => {
228
+ const actor = mailActorSnapshot(message.actor);
229
+ await recordMailAcceptance(
230
+ appId,
231
+ actor,
232
+ { type: "outgoing_mail_message", id: row?.id },
233
+ {
234
+ appId,
235
+ profile: row?.profile_key ?? message.profile ?? null,
236
+ recipientCount: row?.recipient_count ?? message.to.length,
237
+ ...(actor ? { actor } : {}),
238
+ id: row?.id ?? null,
239
+ },
240
+ db,
241
+ !!row,
242
+ errorCode,
243
+ );
244
+ };
245
+ export class MailQuotaError extends OutgoingMailError {
246
+ constructor(
247
+ public readonly limit: number,
248
+ public readonly used: number,
249
+ public readonly requested: number,
250
+ ) {
251
+ super("quota_exceeded", "The profile's rolling 24-hour recipient quota is exhausted.");
252
+ }
253
+ }
254
+ export const mailQuotaUsed = async (db: SQL, appId: string, profileId: string): Promise<number> => {
255
+ const [usage] = await db<{ used: number }[]>`SELECT COALESCE(sum(recipient_count), 0)::int AS used FROM outgoing_mail.messages
256
+ WHERE app_id = ${appId} AND profile_id = ${profileId}::uuid AND status <> 'cancelled' AND created_at > now() - INTERVAL '24 hours'`;
257
+ return usage?.used ?? 0;
258
+ };
259
+ /** Internal acceptance metadata; never accepted by the public mail schemas. */
260
+ export type MailAcceptanceOptions = { trustedHtml?: boolean };
261
+
262
+ export const insertMailMessage = async (
263
+ db: SQL,
264
+ appId: string,
265
+ id: string,
266
+ message: MailMessage,
267
+ uploaded: UploadedAttachments,
268
+ profile: AcceptedProfile,
269
+ batchId?: string,
270
+ options?: MailAcceptanceOptions,
271
+ ): Promise<MessageRow | undefined> => {
272
+ const actor = mailActorSnapshot(message.actor);
273
+ const created = new Date();
274
+ const [row] = await db<MessageRow[]>`INSERT INTO outgoing_mail.messages (
275
+ id, app_id, profile_id, profile_key, lane, batch_id, idempotency_key, ref_scope, ref_id, to_addresses, recipient_count,
276
+ subject, text_body, html_body, headers, from_name, reply_to, message_id_header, attachments, attachment_refs, status,
277
+ deadline_at, actor_type, actor_id, actor_name, created_at
278
+ ) VALUES (${id}::uuid, ${appId}, ${profile.id}::uuid, ${profile.key}, ${batchId ? "bulk" : "immediate"}, ${batchId ?? null}::uuid, ${message.key ?? null},
279
+ ${message.ref?.scope ?? null}, ${message.ref?.id ?? null}, ${toPgTextArray(message.to)}::text[], ${message.to.length},
280
+ ${message.subject}, ${message.text}, ${message.html === undefined ? null : options?.trustedHtml ? message.html : sanitizeEmailHtml(message.html)},
281
+ ${message.headers ? JSON.stringify(message.headers) : null}::text::jsonb, ${message.fromName ?? null}, ${message.replyTo ?? null},
282
+ ${mailMessageId(id, profile.from_address)}, ${JSON.stringify(uploaded.metadata)}::text::jsonb, ${JSON.stringify(uploaded.refs)}::text::jsonb, 'queued',
283
+ ${new Date(created.getTime() + 24 * 60 * 60_000)}, ${actor?.type ?? null}, ${actor?.id ?? null}, ${actor?.name ?? null}, ${created})
284
+ ON CONFLICT (app_id, idempotency_key) DO NOTHING RETURNING *, created_at::text AS cursor_created_at`;
285
+ return row;
286
+ };
287
+
288
+ const accept = async (
289
+ appId: string,
290
+ id: string,
291
+ message: MailMessage,
292
+ uploaded: UploadedAttachments,
293
+ options?: MailAcceptanceOptions,
294
+ ): Promise<{ row: MessageRow; created: boolean }> =>
295
+ sql.begin(async (tx) => {
296
+ // Policy writers take this exclusively; batches take the app lock exclusively.
297
+ // Lock order: policy -> app -> key -> quota. Sends still serialize per key.
298
+ await tx`SELECT pg_advisory_xact_lock_shared(hashtextextended('outgoing_mail.policy', 0))`;
299
+ if (message.key !== undefined) {
300
+ await tx`SELECT pg_advisory_xact_lock_shared(hashtextextended(${appId}, 5))`;
301
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([appId, message.key])}, 1))`;
302
+ }
303
+ const existing = await known(appId, message.key, tx);
304
+ if (existing) {
305
+ return { row: existing, created: false };
306
+ }
307
+ const profile = await allowedMailProfile(tx, appId, message.profile);
308
+ if (uploaded.metadata.reduce((bytes, item) => bytes + item.size, 0) > profile.max_attachment_bytes)
309
+ throw new OutgoingMailError("attachments_too_large", "Attachments exceed the profile's total byte limit.");
310
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([appId, profile.id])}, 2))`;
311
+ const used = await mailQuotaUsed(tx, appId, profile.id);
312
+ if (profile.daily_recipient_limit !== null && used + message.to.length > profile.daily_recipient_limit)
313
+ throw new MailQuotaError(profile.daily_recipient_limit, used, message.to.length);
314
+ const row = await insertMailMessage(tx, appId, id, message, uploaded, profile, undefined, options);
315
+ if (row) await recordMailSend(appId, message, row, tx);
316
+ const winner = row ?? (await known(appId, message.key, tx));
317
+ if (!winner) throw new Error("Outgoing mail insert returned no row");
318
+ return { row: winner, created: !!row };
319
+ });
320
+
321
+ const cursorSchema = z
322
+ .object({ createdAt: z.string().refine((value) => Number.isFinite(Date.parse(value))), id: z.uuid(), page: z.int().min(1) })
323
+ .strict();
324
+ export const decodeMailCursor = (cursor?: string) => {
325
+ if (cursor === undefined) return undefined;
326
+ try {
327
+ return cursorSchema.parse(JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")));
328
+ } catch {
329
+ throw new OutgoingMailError("bad_input", "Invalid outgoing mail cursor.");
330
+ }
331
+ };
332
+ const list = async (
333
+ filter: AdminMailFilter,
334
+ page: MailPageParams = {},
335
+ metadata = false,
336
+ ): Promise<MailPage<MailRecord | AdminMailRecord>> => {
337
+ const cursor = decodeMailCursor(page.cursor);
338
+ const perPage = page.perPage ?? 50;
339
+ const pageNumber = cursor?.page ?? page.page ?? 1;
340
+ const where = sql`WHERE TRUE
341
+ ${filter.app === undefined ? sql`` : sql`AND app_id = ${filter.app}`}
342
+ ${filter.profile === undefined ? sql`` : sql`AND profile_key = ${filter.profile}`}
343
+ ${filter.ids === undefined ? sql`` : sql`AND id = ANY(${toPgUuidArray(filter.ids)}::uuid[])`}
344
+ ${filter.batchId === undefined ? sql`` : sql`AND batch_id = ${filter.batchId}::uuid`}
345
+ ${filter.status === undefined ? sql`` : sql`AND status = ANY(${toPgTextArray(filter.status)}::text[])`}
346
+ ${filter.since === undefined ? sql`` : sql`AND created_at >= ${filter.since}::timestamptz`}
347
+ ${filter.ref === undefined ? sql`` : sql`AND ref_scope = ${filter.ref.scope}`}
348
+ ${filter.ref?.id === undefined ? sql`` : sql`AND ref_id = ${filter.ref.id}`}
349
+ ${filter.recipient === undefined ? sql`` : sql`AND EXISTS(SELECT 1 FROM unnest(to_addresses) AS address WHERE address ILIKE ${`%${escapeLikePattern(filter.recipient)}%`})`}`;
350
+ const [count] = await sql<{ total: number }[]>`SELECT count(*)::int AS total FROM outgoing_mail.messages ${where}`;
351
+ const rows = await sql<LogRow[]>`SELECT ${logColumns(sql, !metadata)} FROM outgoing_mail.messages ${where}
352
+ ${cursor ? sql`AND (created_at < ${cursor.createdAt}::timestamptz OR (created_at = ${cursor.createdAt}::timestamptz AND id > ${cursor.id}::uuid))` : sql``}
353
+ ORDER BY created_at DESC, id LIMIT ${perPage + 1} OFFSET ${cursor ? 0 : (pageNumber - 1) * perPage}`;
354
+ const hasNext = rows.length > perPage;
355
+ const items = rows.slice(0, perPage);
356
+ const last = items.at(-1);
357
+ return {
358
+ items: items.map(metadata ? adminMessageRecord : messageRecord),
359
+ page: pageNumber,
360
+ perPage,
361
+ total: count?.total ?? 0,
362
+ hasNext,
363
+ ...(hasNext && last
364
+ ? {
365
+ nextCursor: Buffer.from(JSON.stringify({ createdAt: last.cursor_created_at, id: last.id, page: pageNumber + 1 })).toString(
366
+ "base64url",
367
+ ),
368
+ }
369
+ : {}),
370
+ };
371
+ };
372
+ const content = async (id: string, context: MailAuditContext) =>
373
+ sql.begin(async (tx) => {
374
+ const row = await read(id, undefined, tx);
375
+ if (!row) throw new OutgoingMailError("message_unknown", "Outgoing mail message does not exist.", 404);
376
+ await audit.record(
377
+ {
378
+ ...context,
379
+ action: "outgoing_mail.message.read",
380
+ outcome: "allowed",
381
+ target: { type: "outgoing_mail_message", id },
382
+ metadata: { appId: row.app_id },
383
+ },
384
+ tx,
385
+ );
386
+ return row.content_purged_at
387
+ ? { purged: true as const, contentPurgedAt: date(row.content_purged_at) }
388
+ : {
389
+ purged: false as const,
390
+ text: row.text_body,
391
+ html: row.html_body,
392
+ headers: z.record(z.string(), z.string()).nullable().parse(parsePgJsonValue(row.headers)),
393
+ };
394
+ });
395
+ export const outgoingMailMessages = { read, metadata, known, accept, list, content };
396
+
397
+ /** Serialize updates to preserve failures from overlapping reports and replicas. */
398
+ export const applyOutgoingMailBounce = async (
399
+ profileId: string,
400
+ id: string,
401
+ messageId: string,
402
+ failures: readonly { recipient: string; originalRecipient?: string; reason: string }[],
403
+ db: SQL = sql,
404
+ ): Promise<void> => {
405
+ if (!failures.length) return;
406
+ await db.begin(async (tx) => {
407
+ const [row] = await tx<
408
+ { id: string; to_addresses: string[]; failures: unknown }[]
409
+ >`SELECT id, to_addresses, failures FROM outgoing_mail.messages
410
+ WHERE id = ${id}::uuid AND profile_id = ${profileId}::uuid AND lower(message_id_header) = lower(${messageId})
411
+ AND status IN ('sent', 'bounced') FOR UPDATE`;
412
+ if (!row) return;
413
+ const matched = failures.flatMap((failure) => {
414
+ const recipient =
415
+ row.to_addresses.find((address) => address.toLowerCase() === failure.recipient.toLowerCase()) ??
416
+ row.to_addresses.find((address) => address.toLowerCase() === failure.originalRecipient?.toLowerCase());
417
+ return recipient ? [{ recipient, reason: failure.reason }] : [];
418
+ });
419
+ if (!matched.length) return;
420
+ const stored = MailRecordSchema.shape.failures.parse(parsePgJsonValue(row.failures));
421
+ const at = new Date().toISOString();
422
+ for (const failure of matched) {
423
+ if (stored.length >= 100) break;
424
+ const reason = failure.reason.slice(0, 1000);
425
+ if (!stored.some((item) => item.recipient === failure.recipient && item.reason === reason))
426
+ stored.push({ recipient: failure.recipient, reason, at });
427
+ }
428
+ await tx`UPDATE outgoing_mail.messages SET status = 'bounced', bounced_at = COALESCE(bounced_at, now()),
429
+ failures = ${JSON.stringify(stored)}::text::jsonb, updated_at = now() WHERE id = ${row.id}::uuid`;
430
+ });
431
+ };
@@ -0,0 +1,48 @@
1
+ import { sql } from "bun";
2
+ import type { MailRetention } from "../../contracts/outgoing-mail";
3
+ import { toPgUuidArray } from "../postgres";
4
+ import { get } from "../settings";
5
+ import { deleteMailObjects } from "./attachments";
6
+ import { messageAttachmentRefs } from "./messages";
7
+
8
+ /** One bounded batch, callable without starting a scheduler. */
9
+ export const retainOutgoingMailBatch = async (contentDays: number, recordDays: number) => {
10
+ if (
11
+ !Number.isSafeInteger(contentDays) ||
12
+ contentDays < 1 ||
13
+ contentDays > 36500 ||
14
+ !Number.isSafeInteger(recordDays) ||
15
+ recordDays < 1 ||
16
+ recordDays > 36500
17
+ )
18
+ throw new RangeError("Mail content and record retention must both be whole days from 1 to 36500.");
19
+ return sql.begin(async (tx) => {
20
+ // Compare created_at with a constant so the created_at index bounds every batch.
21
+ const rows = await tx<{ id: string; attachment_refs: unknown; expired: boolean }[]>`SELECT id, attachment_refs,
22
+ created_at < now() - make_interval(days => ${recordDays}::int) AS expired
23
+ FROM outgoing_mail.messages WHERE created_at < now() - make_interval(days => ${Math.min(contentDays, recordDays)}::int)
24
+ AND (created_at < now() - make_interval(days => ${recordDays}::int) OR content_purged_at IS NULL)
25
+ ORDER BY created_at LIMIT 1000 FOR UPDATE SKIP LOCKED`;
26
+ const expired = rows.filter((row) => row.expired);
27
+ const content = rows.filter((row) => !row.expired);
28
+ for (const row of expired) await deleteMailObjects(messageAttachmentRefs(row));
29
+ if (expired.length) await tx`DELETE FROM outgoing_mail.messages WHERE id = ANY(${toPgUuidArray(expired.map((row) => row.id))}::uuid[])`;
30
+ if (content.length)
31
+ await tx`UPDATE outgoing_mail.messages SET text_body = NULL, html_body = NULL, headers = NULL,
32
+ content_purged_at = now(), updated_at = now() WHERE id = ANY(${toPgUuidArray(content.map((row) => row.id))}::uuid[])`;
33
+ return { deleted: expired.length, purged: content.length };
34
+ });
35
+ };
36
+ export const MAIL_RETENTION_KEYS = ["outgoing_mail.content_retention_days", "outgoing_mail.record_retention_days"] as const;
37
+ export const readMailRetention = async (): Promise<MailRetention> => ({
38
+ contentDays: await get<number>(MAIL_RETENTION_KEYS[0]),
39
+ recordDays: await get<number>(MAIL_RETENTION_KEYS[1]),
40
+ });
41
+ export const retainOutgoingMail = async (signal?: AbortSignal): Promise<void> => {
42
+ const { contentDays, recordDays } = await readMailRetention();
43
+ const deadline = Date.now() + 5 * 60_000;
44
+ while (!signal?.aborted && Date.now() < deadline) {
45
+ const result = await retainOutgoingMailBatch(contentDays, recordDays);
46
+ if (result.deleted + result.purged < 1000) return;
47
+ }
48
+ };
@@ -0,0 +1,72 @@
1
+ import type { Worker } from "@k2b/sync";
2
+ import { lazySync } from "../../_internal/process-sync";
3
+ import { logger } from "../logging";
4
+ import { createRuntimeLifecycle, createRuntimeTaskTracker, stopRuntimeJobs } from "../runtime-lifecycle";
5
+ import { MAIL_RECOVERY_MS } from "./bulk";
6
+ import { processOutgoingMail, recoverOutgoingMail } from "./dispatcher";
7
+ import { drainOutgoingMail, dueOutgoingBulkProfiles } from "./drain";
8
+ import { retainOutgoingMail } from "./retention";
9
+ import { mailAttachments, mailDrainJob, mailSendJob, mailSettled, submitMail, submitMailDrain } from "./sync";
10
+
11
+ const log = logger("outgoing-mail");
12
+ const scheduler = lazySync((sync) =>
13
+ sync.scheduler({ id: "cloud-outgoing-mail-retention", owner: "core", delivery: { maxAttempts: 2, backoffMs: [30_000] } }),
14
+ );
15
+ const tasks = createRuntimeTaskTracker();
16
+ let worker: Worker | undefined;
17
+ let drainWorker: Worker | undefined;
18
+ let retentionWorker: Worker | undefined;
19
+ let timer: ReturnType<typeof setInterval> | undefined;
20
+ let recovering: Promise<void> | undefined;
21
+ const recover = (): Promise<void> => {
22
+ recovering ??= (
23
+ tasks.run(async () => {
24
+ try {
25
+ for (const id of await recoverOutgoingMail()) await submitMail(id);
26
+ for (const id of await dueOutgoingBulkProfiles()) await submitMailDrain(id);
27
+ } catch {
28
+ log.error("Outgoing mail recovery failed; the next scan will retry.");
29
+ }
30
+ }) ?? Promise.resolve()
31
+ ).finally(() => {
32
+ recovering = undefined;
33
+ });
34
+ return recovering;
35
+ };
36
+ const lifecycle = createRuntimeLifecycle({
37
+ start: async () => {
38
+ tasks.open();
39
+ await Promise.all([mailAttachments().ready(), mailSettled().ready()]);
40
+ worker = await mailSendJob().process({ concurrency: 8 }, ({ input, signal }) => processOutgoingMail(input.id, signal));
41
+ // Heartbeats cover the last attempt beyond the 60-second claim window. Sync owns the per-key claim;
42
+ // PostgreSQL also gates slots and claims rows if a stale run is redelivered.
43
+ drainWorker = await mailDrainJob().process({ concurrency: 4 }, async ({ input, signal, heartbeat, resubmit }) => {
44
+ const delayMs = await drainOutgoingMail(input.profileId, signal, heartbeat);
45
+ if (delayMs !== undefined) resubmit({ delayMs });
46
+ });
47
+ await scheduler().create({
48
+ id: "daily",
49
+ cron: "0 0 * * *",
50
+ timezone: "UTC",
51
+ misfire: "latest",
52
+ process: async ({ signal }) => retainOutgoingMail(signal),
53
+ });
54
+ retentionWorker = await scheduler().process({ concurrency: 1 });
55
+ await recover();
56
+ timer = setInterval(() => void recover(), MAIL_RECOVERY_MS);
57
+ timer.unref();
58
+ },
59
+ stop: async () => {
60
+ clearInterval(timer);
61
+ timer = undefined;
62
+ await stopRuntimeJobs(
63
+ tasks,
64
+ [worker, drainWorker, retentionWorker].filter((item): item is Worker => item !== undefined),
65
+ );
66
+ worker = undefined;
67
+ drainWorker = undefined;
68
+ retentionWorker = undefined;
69
+ },
70
+ });
71
+ export const startOutgoingMailRuntime = lifecycle.start;
72
+ export const stopOutgoingMailRuntime = lifecycle.stop;
@@ -0,0 +1,130 @@
1
+ import { sql } from "bun";
2
+ import type { MailMessage, MailRecord } from "../../contracts/outgoing-mail";
3
+ import { cancelMailStreams, deleteMailObjects, uploadMailAttachments } from "./attachments";
4
+ import {
5
+ allowedMailProfile,
6
+ type MailAcceptanceOptions,
7
+ MailQuotaError,
8
+ mailQuotaUsed,
9
+ messageRecord,
10
+ outgoingMailMessages,
11
+ recordMailSend,
12
+ } from "./messages";
13
+ import { OutgoingMailError } from "./store";
14
+ import { mailAttachments, mailSendJob, mailSettled, submitMail } from "./sync";
15
+
16
+ export const MAIL_WAIT_MS = 30_000;
17
+ export const waitForMail = async (appId: string, initial: MailRecord, signal?: AbortSignal): Promise<MailRecord> => {
18
+ if (signal?.aborted || !["queued", "sending"].includes(initial.status)) return initial;
19
+ const controller = new AbortController();
20
+ let wake: (() => void) | undefined;
21
+ const abort = () => {
22
+ controller.abort();
23
+ wake?.();
24
+ };
25
+ signal?.addEventListener("abort", abort, { once: true });
26
+ const deadline = setTimeout(abort, MAIL_WAIT_MS);
27
+ // A lost wakeup cannot lose a result: Postgres is polled independently.
28
+ const follower = (async () => {
29
+ try {
30
+ for await (const _event of mailSettled().follow({ tenantId: initial.id, signal: controller.signal })) wake?.();
31
+ } catch {
32
+ /* Postgres polling remains authoritative while Sync is unavailable. */
33
+ }
34
+ })();
35
+ const read = async () => {
36
+ if (controller.signal.aborted) return undefined;
37
+ let release: (() => void) | undefined;
38
+ const stopped = new Promise<undefined>((resolve) => {
39
+ release = () => resolve(undefined);
40
+ controller.signal.addEventListener("abort", release, { once: true });
41
+ });
42
+ try {
43
+ return await Promise.race([outgoingMailMessages.read(initial.id, appId).catch(() => undefined), stopped]);
44
+ } finally {
45
+ if (release) controller.signal.removeEventListener("abort", release);
46
+ }
47
+ };
48
+ let current = initial;
49
+ try {
50
+ while (!controller.signal.aborted) {
51
+ const row = await read();
52
+ if (row) current = messageRecord(row);
53
+ if (!row || (current.status !== "sending" && (current.status !== "queued" || row.attempt_count > 0))) return current;
54
+ await new Promise<void>((resolve) => {
55
+ const finish = () => {
56
+ clearTimeout(timer);
57
+ wake = undefined;
58
+ resolve();
59
+ };
60
+ const timer = setTimeout(finish, 2000);
61
+ wake = finish;
62
+ if (controller.signal.aborted) finish();
63
+ });
64
+ }
65
+ return current;
66
+ } finally {
67
+ clearTimeout(deadline);
68
+ signal?.removeEventListener("abort", abort);
69
+ controller.abort();
70
+ void follower;
71
+ }
72
+ };
73
+ export const sendMail = async (
74
+ appId: string,
75
+ message: MailMessage,
76
+ signal?: AbortSignal,
77
+ options?: MailAcceptanceOptions,
78
+ ): Promise<MailRecord> => {
79
+ const existing = await outgoingMailMessages.known(appId, message.key);
80
+ if (existing) {
81
+ cancelMailStreams(message.attachments);
82
+ await recordMailSend(appId, message, existing).catch(() => {});
83
+ return messageRecord(existing);
84
+ }
85
+ // Ensure coordination storage is reachable before accepting durable mail.
86
+ await Promise.all([mailSendJob().ready(), mailSettled().ready(), mailAttachments().ready()]);
87
+ const profile = await sql.begin(async (tx) => {
88
+ const profile = await allowedMailProfile(tx, appId, message.profile);
89
+ if (profile.daily_recipient_limit !== null) {
90
+ const used = await mailQuotaUsed(tx, appId, profile.id);
91
+ if (used + message.to.length > profile.daily_recipient_limit)
92
+ throw new MailQuotaError(profile.daily_recipient_limit, used, message.to.length);
93
+ }
94
+ return profile;
95
+ });
96
+ const id = crypto.randomUUID();
97
+ const uploaded = await uploadMailAttachments(id, message.attachments ?? [], profile.max_attachment_bytes);
98
+ let accepted: Awaited<ReturnType<typeof outgoingMailMessages.accept>>;
99
+ try {
100
+ accepted = await outgoingMailMessages.accept(appId, id, message, uploaded, options);
101
+ } catch (error) {
102
+ // If the commit reply was lost, or another call won while this transaction
103
+ // failed, an existing record still owns the outcome and its objects.
104
+ const recovered =
105
+ (await outgoingMailMessages.read(id, appId).catch(() => undefined)) ??
106
+ (await outgoingMailMessages.known(appId, message.key).catch(() => undefined));
107
+ if (recovered) {
108
+ if (recovered.id !== id) {
109
+ await deleteMailObjects(uploaded.refs).catch(() => {});
110
+ await recordMailSend(appId, message, recovered).catch(() => {});
111
+ }
112
+ const record = messageRecord(recovered);
113
+ void submitMail(recovered.id).catch(() => {});
114
+ return waitForMail(appId, record, signal).catch(() => record);
115
+ }
116
+ // Policy/quota errors roll back before acceptance. An unavailable database
117
+ // cannot prove that COMMIT failed; retain uncertain objects until their TTL.
118
+ if (error instanceof OutgoingMailError) await deleteMailObjects(uploaded.refs).catch(() => {});
119
+ throw error;
120
+ }
121
+ if (!accepted.created) {
122
+ await recordMailSend(appId, message, accepted.row).catch(() => {});
123
+ await deleteMailObjects(uploaded.refs).catch(() => {});
124
+ return messageRecord(accepted.row);
125
+ }
126
+ const record = messageRecord(accepted.row);
127
+ // From this point a row exists. A failed wakeup is recovered by Core.
128
+ void submitMail(id).catch(() => {});
129
+ return waitForMail(appId, record, signal).catch(() => record);
130
+ };