@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,231 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Socket } from "node:net";
3
+ import { Readable } from "node:stream";
4
+ import { sql } from "bun";
5
+ import { z } from "zod";
6
+ import { listApps } from "../../_internal/registry";
7
+ import { parsePgJsonValue } from "../postgres";
8
+ import { get } from "../settings";
9
+ import { deleteMailObjects, verifyMailAttachment } from "./attachments";
10
+ import { mailBackoffMs, mailEnvelope } from "./message";
11
+ import { allowedMailProfile, type MessageRow, messageAttachmentRefs, messageRecord, outgoingMailMessages } from "./messages";
12
+ import { OutgoingMailError, resolveMailCredentials } from "./store";
13
+ import { mailAttachments, mailSettled } from "./sync";
14
+ import { smtpFailureMessage } from "./test-send";
15
+ import { buildMailTransport } from "./transport";
16
+
17
+ export const cleanupMailAttachments = async (id: string): Promise<void> => {
18
+ const row = await outgoingMailMessages.read(id);
19
+ if (!row || ["queued", "sending"].includes(row.status)) return;
20
+ await deleteMailObjects(messageAttachmentRefs(row));
21
+ await sql`UPDATE outgoing_mail.messages SET attachment_refs = NULL WHERE id = ${id}::uuid AND status NOT IN ('queued','sending')`;
22
+ };
23
+ export const publishMailSettled = async (id: string): Promise<void> => {
24
+ await mailSettled().publish({ tenantId: id, data: id });
25
+ };
26
+ const smtpResponseRetryable = (error: unknown): boolean => {
27
+ const responseCode = error && typeof error === "object" && "responseCode" in error ? Number(error.responseCode) : undefined;
28
+ return responseCode === undefined || !Number.isFinite(responseCode) || responseCode < 500;
29
+ };
30
+ export const smtpRetryable = (error: unknown): boolean => {
31
+ if (error && typeof error === "object" && "rejectedErrors" in error && Array.isArray(error.rejectedErrors) && error.rejectedErrors.length)
32
+ return error.rejectedErrors.some(smtpResponseRetryable);
33
+ return smtpResponseRetryable(error);
34
+ };
35
+ const rejectReason = (error: unknown, fallback: string): string =>
36
+ error && typeof error === "object" && "response" in error && typeof error.response === "string" ? error.response : fallback;
37
+ const cancelled = async (row: MessageRow, code: "profile_removed" | "profile_not_allowed", message?: string) => {
38
+ await sql`UPDATE outgoing_mail.messages SET status = 'cancelled', error_code = ${code}, error_message = ${message ?? (code === "profile_removed" ? "Outgoing mail profile was removed." : "Outgoing mail profile access was revoked.")},
39
+ next_attempt_at = NULL, updated_at = now() WHERE id = ${row.id}::uuid AND status = 'sending' AND attempt_count = ${row.attempt_count}`;
40
+ };
41
+ export const processOutgoingMail = async (id: string, signal?: AbortSignal): Promise<void> => {
42
+ const [row] = await sql<
43
+ MessageRow[]
44
+ >`UPDATE outgoing_mail.messages SET status = 'sending', attempt_count = attempt_count + 1, updated_at = now()
45
+ WHERE id = ${id}::uuid AND status = 'queued' AND lane = 'immediate' AND (next_attempt_at IS NULL OR next_attempt_at <= now() OR deadline_at <= now())
46
+ RETURNING *, created_at::text AS cursor_created_at`;
47
+ if (!row) return;
48
+ await attemptOutgoingMail(row, signal);
49
+ };
50
+
51
+ /** Both lanes share policy, attachments, SMTP outcomes and retry handling. */
52
+ export const attemptOutgoingMail = async (row: MessageRow, signal?: AbortSignal): Promise<void> => {
53
+ const id = row.id;
54
+ let credentials: Awaited<ReturnType<typeof resolveMailCredentials>> | undefined;
55
+ try {
56
+ if (new Date(row.deadline_at).getTime() <= Date.now()) {
57
+ await sql`UPDATE outgoing_mail.messages SET status = 'failed', error_code = COALESCE(error_code, 'smtp_failed'), error_message = COALESCE(error_message, 'Outgoing mail delivery deadline expired.'), next_attempt_at = NULL, updated_at = now() WHERE id = ${id}::uuid AND status = 'sending' AND attempt_count = ${row.attempt_count}`;
58
+ return;
59
+ }
60
+ if (!row.profile_id) {
61
+ await cancelled(row, "profile_removed");
62
+ return;
63
+ }
64
+ const apps = await listApps();
65
+ const app = apps.find((app) => app.id === row.app_id);
66
+ if (app && !app.platformPermissions?.includes("mail:send")) {
67
+ await cancelled(row, "profile_not_allowed", "The application no longer declares mail:send.");
68
+ return;
69
+ }
70
+ // Core mail speaks for the installation, as system email did before it used outgoing mail.
71
+ const senderName = row.app_id === "core" ? await get<string>("app.name") : app?.name || row.app_id;
72
+ try {
73
+ await sql.begin(async (tx) => {
74
+ await tx`SELECT pg_advisory_xact_lock_shared(hashtextextended('outgoing_mail.policy', 0))`;
75
+ const profile = await allowedMailProfile(tx, row.app_id, row.profile_key);
76
+ if (profile.id !== row.profile_id) throw new OutgoingMailError("profile_removed", "Outgoing mail profile was replaced.");
77
+ });
78
+ } catch (error) {
79
+ if (
80
+ error instanceof OutgoingMailError &&
81
+ ["profile_unknown", "profile_removed", "mail_unavailable", "profile_not_allowed", "profile_required"].includes(error.code)
82
+ ) {
83
+ await cancelled(
84
+ row,
85
+ ["profile_unknown", "profile_removed", "mail_unavailable"].includes(error.code) ? "profile_removed" : "profile_not_allowed",
86
+ );
87
+ return;
88
+ }
89
+ throw error;
90
+ }
91
+ const refs = messageAttachmentRefs(row);
92
+ const metadata = messageRecord(row).attachments;
93
+ if (refs.length !== metadata.length) throw new OutgoingMailError("attachment_lost", "An outgoing mail attachment is missing.");
94
+ const attempt = new AbortController();
95
+ const abort = () => attempt.abort();
96
+ signal?.addEventListener("abort", abort, { once: true });
97
+ if (signal?.aborted) abort();
98
+ const timer = setTimeout(abort, 60_000);
99
+ const streams: Readable[] = [];
100
+ try {
101
+ for (const [index, ref] of refs.entries()) await verifyMailAttachment(ref, metadata[index]!, attempt.signal);
102
+ const attachments = [];
103
+ for (const [index, ref] of refs.entries()) {
104
+ const stored = await mailAttachments().get(ref, { signal: attempt.signal });
105
+ if (!stored) throw new OutgoingMailError("attachment_lost", "An outgoing mail attachment is missing.");
106
+ const item = metadata[index]!;
107
+ const stream = Readable.from(
108
+ (async function* () {
109
+ const reader = stored.body.getReader();
110
+ const hash = createHash("sha256");
111
+ let bytes = 0;
112
+ try {
113
+ while (true) {
114
+ const chunk = await reader.read();
115
+ if (chunk.done) break;
116
+ bytes += chunk.value.byteLength;
117
+ if (bytes > item.size) throw new OutgoingMailError("attachment_lost", "Attachment size changed.");
118
+ hash.update(chunk.value);
119
+ yield chunk.value;
120
+ }
121
+ if (bytes !== item.size || hash.digest("hex") !== item.sha256)
122
+ throw new OutgoingMailError("attachment_lost", "Attachment checksum changed.");
123
+ } finally {
124
+ await reader.cancel().catch(() => {});
125
+ reader.releaseLock();
126
+ }
127
+ })(),
128
+ );
129
+ streams.push(stream);
130
+ attachments.push({ filename: item.filename, contentType: item.contentType, content: stream });
131
+ }
132
+ // Load the checked profile by ID: a profile deleted (or recreated under its key) since the check is profile_removed.
133
+ credentials = await resolveMailCredentials({ id: row.profile_id });
134
+ const socket = new Socket();
135
+ // DNS can complete after cancellation; tear down a late connection too.
136
+ socket.once("connect", () => {
137
+ if (attempt.signal.aborted) socket.destroy();
138
+ });
139
+ const transport = buildMailTransport(credentials, socket);
140
+ const stop = () => {
141
+ socket.destroy();
142
+ transport.close();
143
+ for (const stream of streams) stream.destroy();
144
+ };
145
+ attempt.signal.addEventListener("abort", stop, { once: true });
146
+ try {
147
+ if (attempt.signal.aborted) throw new Error("SMTP attempt cancelled");
148
+ const result = await transport.sendMail({
149
+ ...mailEnvelope(credentials, senderName, row.from_name),
150
+ envelope: { from: credentials.fromAddress, to: row.to_addresses },
151
+ to: row.to_addresses,
152
+ subject: row.subject,
153
+ text: row.text_body ?? "",
154
+ html: row.html_body ?? undefined,
155
+ replyTo: row.reply_to ?? undefined,
156
+ messageId: row.message_id_header,
157
+ headers: row.headers === null ? undefined : z.record(z.string(), z.string()).parse(parsePgJsonValue(row.headers)),
158
+ attachments,
159
+ disableFileAccess: true,
160
+ disableUrlAccess: true,
161
+ });
162
+ const failures = (result.rejected ?? []).map((recipient: string, index: number) => ({
163
+ recipient,
164
+ reason: rejectReason(result.rejectedErrors?.[index], "Recipient rejected."),
165
+ at: new Date().toISOString(),
166
+ }));
167
+ await sql`UPDATE outgoing_mail.messages SET status = 'sent', sent_at = now(), next_attempt_at = NULL, error_code = NULL, error_message = NULL,
168
+ smtp_response = ${result.response}, failures = ${JSON.stringify(failures)}::text::jsonb, updated_at = now()
169
+ WHERE id = ${id}::uuid AND status = 'sending' AND attempt_count = ${row.attempt_count}`;
170
+ } finally {
171
+ attempt.signal.removeEventListener("abort", stop);
172
+ socket.destroy();
173
+ transport.close();
174
+ }
175
+ } finally {
176
+ clearTimeout(timer);
177
+ signal?.removeEventListener("abort", abort);
178
+ attempt.abort();
179
+ for (const stream of streams) stream.destroy();
180
+ }
181
+ } catch (error) {
182
+ if (error instanceof OutgoingMailError && ["profile_unknown", "profile_removed"].includes(error.code)) {
183
+ await cancelled(row, "profile_removed");
184
+ return;
185
+ }
186
+ const secrets = credentials
187
+ ? [
188
+ credentials.smtpUser,
189
+ credentials.smtpPassword,
190
+ credentials.smtpUser ? `\0${credentials.smtpUser}\0${credentials.smtpPassword ?? ""}` : null,
191
+ ]
192
+ : [];
193
+ const message =
194
+ credentials || error instanceof OutgoingMailError
195
+ ? smtpFailureMessage(error, secrets)
196
+ : "Outgoing mail delivery is temporarily unavailable.";
197
+ const lost = error instanceof OutgoingMailError && error.code === "attachment_lost";
198
+ const retry = !lost && smtpRetryable(error) && new Date(row.deadline_at).getTime() > Date.now();
199
+ const next = new Date(Date.now() + mailBackoffMs(row.attempt_count));
200
+ await sql`UPDATE outgoing_mail.messages SET status = ${retry ? "queued" : "failed"}, error_code = ${lost ? "attachment_lost" : "smtp_failed"}, error_message = ${message},
201
+ next_attempt_at = ${retry ? next : null}, updated_at = now() WHERE id = ${id}::uuid AND status = 'sending' AND attempt_count = ${row.attempt_count}`;
202
+ } finally {
203
+ // Cleanup failure must never turn SMTP success into another delivery attempt.
204
+ await cleanupMailAttachments(id).catch(() => {});
205
+ await publishMailSettled(id).catch(() => {});
206
+ }
207
+ };
208
+ export const recoverOutgoingMail = async (): Promise<string[]> => {
209
+ await sql`WITH stale AS (SELECT id FROM outgoing_mail.messages WHERE status = 'sending' AND updated_at < now() - INTERVAL '5 minutes' LIMIT 1000 FOR UPDATE SKIP LOCKED)
210
+ UPDATE outgoing_mail.messages SET status = 'queued', next_attempt_at = now(), updated_at = now() WHERE id IN (SELECT id FROM stale)`;
211
+ const orphaned = await sql<{ id: string }[]>`WITH orphaned AS (
212
+ SELECT id FROM outgoing_mail.messages WHERE status = 'queued' AND lane = 'bulk' AND profile_id IS NULL
213
+ LIMIT 1000 FOR UPDATE SKIP LOCKED
214
+ ) UPDATE outgoing_mail.messages SET status = 'cancelled', error_code = 'profile_removed',
215
+ error_message = 'Outgoing mail profile was removed.', next_attempt_at = NULL, updated_at = now()
216
+ WHERE id IN (SELECT id FROM orphaned) RETURNING id`;
217
+ const expired = await sql<{ id: string }[]>`WITH expired AS (
218
+ SELECT id FROM outgoing_mail.messages WHERE status = 'queued' AND lane = 'bulk' AND deadline_at <= now()
219
+ LIMIT 1000 FOR UPDATE SKIP LOCKED
220
+ ) UPDATE outgoing_mail.messages SET status = 'failed', error_code = COALESCE(error_code, 'smtp_failed'),
221
+ error_message = COALESCE(error_message, 'Outgoing mail delivery deadline expired.'), next_attempt_at = NULL, updated_at = now()
222
+ WHERE id IN (SELECT id FROM expired) RETURNING id`;
223
+ for (const row of [...orphaned, ...expired]) await publishMailSettled(row.id).catch(() => {});
224
+ const cleanup = await sql<
225
+ { id: string }[]
226
+ >`SELECT id FROM outgoing_mail.messages WHERE status NOT IN ('queued','sending') AND attachment_refs IS NOT NULL LIMIT 1000`;
227
+ for (const row of cleanup) await cleanupMailAttachments(row.id).catch(() => {});
228
+ const due = await sql<{ id: string }[]>`SELECT id FROM outgoing_mail.messages WHERE lane = 'immediate' AND status = 'queued'
229
+ AND (next_attempt_at IS NULL OR next_attempt_at <= now() OR deadline_at <= now()) ORDER BY next_attempt_at NULLS FIRST, created_at LIMIT 1000`;
230
+ return due.map((row) => row.id);
231
+ };
@@ -0,0 +1,60 @@
1
+ import { sql } from "bun";
2
+ import { mailNextDrainDelay } from "./bulk";
3
+ import { attemptOutgoingMail } from "./dispatcher";
4
+ import type { MessageRow } from "./messages";
5
+
6
+ export const MAIL_DRAIN_MS = 60_000;
7
+ export const claimOutgoingBulkMail = async (
8
+ profileId: string,
9
+ ): Promise<{ row: MessageRow; slotAt: string; grantedAt: string } | undefined> =>
10
+ sql.begin(async (tx) => {
11
+ // Wait for acceptance's share lock before reading the clock and reserving a slot.
12
+ await tx`SELECT 1 FROM outgoing_mail.profiles WHERE id = ${profileId}::uuid FOR NO KEY UPDATE`;
13
+ const [slot] = await tx<{ slot_at: string; granted_at: string }[]>`UPDATE outgoing_mail.profiles p
14
+ SET next_bulk_slot_at = greatest(p.next_bulk_slot_at, c.at) + (60000.0 / p.pace_per_minute) * INTERVAL '1 millisecond'
15
+ FROM (SELECT clock_timestamp() AS at) c WHERE p.id = ${profileId}::uuid AND p.next_bulk_slot_at <= c.at
16
+ RETURNING p.next_bulk_slot_at::text AS slot_at, c.at::text AS granted_at`;
17
+ if (!slot) return undefined;
18
+ const [row] = await tx<MessageRow[]>`WITH due AS (
19
+ SELECT id FROM outgoing_mail.messages WHERE profile_id = ${profileId}::uuid AND status = 'queued' AND lane = 'bulk'
20
+ AND (next_attempt_at IS NULL OR next_attempt_at <= now()) AND deadline_at > now()
21
+ ORDER BY COALESCE(next_attempt_at, created_at), id LIMIT 1 FOR UPDATE SKIP LOCKED
22
+ ) UPDATE outgoing_mail.messages SET status = 'sending', attempt_count = attempt_count + 1, updated_at = now()
23
+ WHERE id IN (SELECT id FROM due) RETURNING *, created_at::text AS cursor_created_at`;
24
+ // An idle claim consumes at most one slot. No refund race or extra state is needed.
25
+ return row ? { row, slotAt: slot.slot_at, grantedAt: slot.granted_at } : undefined;
26
+ });
27
+ export const nextOutgoingBulkDelay = async (profileId: string): Promise<number | undefined> => {
28
+ const [next] = await sql<{ slot: Date; due: Date; current: Date }[]>`SELECT p.next_bulk_slot_at AS slot, now() AS current, m.due
29
+ FROM outgoing_mail.profiles p CROSS JOIN LATERAL (
30
+ SELECT COALESCE(next_attempt_at, created_at) AS due FROM outgoing_mail.messages
31
+ WHERE profile_id = p.id AND lane = 'bulk' AND status = 'queued' AND deadline_at > now()
32
+ ORDER BY COALESCE(next_attempt_at, created_at), id LIMIT 1
33
+ ) m WHERE p.id = ${profileId}::uuid`;
34
+ return next
35
+ ? mailNextDrainDelay(new Date(next.current).getTime(), new Date(next.slot).getTime(), new Date(next.due).getTime())
36
+ : undefined;
37
+ };
38
+ export const dueOutgoingBulkProfiles = async (): Promise<string[]> => {
39
+ const rows = await sql<{ id: string }[]>`SELECT p.id FROM outgoing_mail.profiles p WHERE EXISTS (
40
+ SELECT 1 FROM outgoing_mail.messages m WHERE m.profile_id = p.id AND m.lane = 'bulk' AND m.status = 'queued'
41
+ AND COALESCE(m.next_attempt_at, m.created_at) <= now() AND m.deadline_at > now()
42
+ ) LIMIT 1000`;
43
+ return rows.map((row) => row.id);
44
+ };
45
+ /** Stop at the next closed gate and continue durably instead of occupying a worker while waiting. */
46
+ export const drainOutgoingMail = async (
47
+ profileId: string,
48
+ signal?: AbortSignal,
49
+ heartbeat?: () => Promise<void>,
50
+ ): Promise<number | undefined> => {
51
+ const until = Date.now() + MAIL_DRAIN_MS;
52
+ // The run window bounds claims; only shutdown may cancel the current attempt.
53
+ while (!signal?.aborted && Date.now() < until) {
54
+ const claimed = await claimOutgoingBulkMail(profileId);
55
+ if (!claimed) break;
56
+ await attemptOutgoingMail(claimed.row, signal);
57
+ await heartbeat?.();
58
+ }
59
+ return nextOutgoingBulkDelay(profileId);
60
+ };
@@ -0,0 +1,180 @@
1
+ import { type SQL, sql } from "bun";
2
+ import type { MailMessage } from "../../contracts/outgoing-mail";
3
+ import {
4
+ cancelMailStreams,
5
+ deleteMailObjects,
6
+ MAIL_ATTACHMENT_UPLOAD_MS,
7
+ type UploadedAttachments,
8
+ uploadMailAttachments,
9
+ } from "./attachments";
10
+ import { mailBacklogFull, mailBatchId } from "./bulk";
11
+ import {
12
+ type AcceptedProfile,
13
+ allowedMailProfile,
14
+ insertMailMessage,
15
+ type MailAcceptanceOptions,
16
+ MailQuotaError,
17
+ type MessageRow,
18
+ mailQuotaUsed,
19
+ outgoingMailMessages,
20
+ recordMailBatch,
21
+ } from "./messages";
22
+ import { OutgoingMailError } from "./store";
23
+ import { mailAttachments, mailDrainJob, mailSettled, submitMailDrain } from "./sync";
24
+
25
+ type BatchItem = { id: string; message: MailMessage; existing?: MessageRow; uploaded: UploadedAttachments };
26
+ type AcceptedBatch = { batchId: string; rows: MessageRow[]; created: MessageRow[] };
27
+ const lockBatchApp = async (db: SQL, appId: string) => {
28
+ // One app lock serializes batches with batches and keyed sends without per-key locks.
29
+ // Lock order: policy -> app -> key (send only) -> quota -> backlog -> call.
30
+ await db`SELECT pg_advisory_xact_lock_shared(hashtextextended('outgoing_mail.policy', 0))`;
31
+ await db`SELECT pg_advisory_xact_lock(hashtextextended(${appId}, 5))`;
32
+ };
33
+ /** Retain uploads when a lost COMMIT response cannot be resolved by a recovery read. */
34
+ export class MailAcceptanceUnknown extends OutgoingMailError {
35
+ constructor() {
36
+ super("mail_unavailable", "Outgoing mail acceptance could not be determined. Retry with the same keys.", 502);
37
+ }
38
+ }
39
+ const checkBatch = async (db: SQL, appId: string, items: readonly BatchItem[]) => {
40
+ const profiles = new Map<string, AcceptedProfile>();
41
+ // Stable order for batches that request multiple profiles.
42
+ for (const key of [...new Set(items.map((item) => item.message.profile))].sort())
43
+ profiles.set(key ?? "", await allowedMailProfile(db, appId, key));
44
+ const totals = new Map<string, { profile: AcceptedProfile; recipients: number; count: number }>();
45
+ for (const item of items) {
46
+ const profile = profiles.get(item.message.profile ?? "")!;
47
+ if (item.uploaded.metadata.reduce((sum, attachment) => sum + attachment.size, 0) > profile.max_attachment_bytes)
48
+ throw new OutgoingMailError("attachments_too_large", "Attachments exceed the profile's total byte limit.");
49
+ const total = totals.get(profile.id) ?? { profile, recipients: 0, count: 0 };
50
+ total.recipients += item.message.to.length;
51
+ total.count++;
52
+ totals.set(profile.id, total);
53
+ }
54
+ for (const id of [...totals.keys()].sort()) {
55
+ const { profile, recipients, count } = totals.get(id)!;
56
+ // Same quota lock as immediate acceptance, plus a profile-wide backlog lock across apps.
57
+ await db`SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([appId, id])}, 2))`;
58
+ await db`SELECT pg_advisory_xact_lock(hashtextextended(${id}, 3))`;
59
+ const used = await mailQuotaUsed(db, appId, id);
60
+ if (profile.daily_recipient_limit !== null && used + recipients > profile.daily_recipient_limit)
61
+ throw new MailQuotaError(profile.daily_recipient_limit, used, recipients);
62
+ const [backlog] = await db<{ queued: number }[]>`SELECT count(*)::int AS queued FROM outgoing_mail.messages
63
+ WHERE profile_id = ${id}::uuid AND lane = 'bulk' AND status = 'queued'`;
64
+ if (mailBacklogFull(backlog?.queued ?? 0, count, profile.pace_per_minute))
65
+ throw new OutgoingMailError("backlog_full", "The profile's bulk queue exceeds 24 hours of delivery capacity.", 409);
66
+ }
67
+ return profiles;
68
+ };
69
+ export const outgoingMailBatches = {
70
+ async accept(appId: string, proposed: string, items: readonly BatchItem[], options?: MailAcceptanceOptions): Promise<AcceptedBatch> {
71
+ let result: AcceptedBatch | undefined;
72
+ try {
73
+ return await sql.begin(async (tx) => {
74
+ await lockBatchApp(tx, appId);
75
+ const known: (MessageRow | undefined)[] = [];
76
+ for (const item of items) known.push(await outgoingMailMessages.known(appId, item.message.key, tx));
77
+ const fresh = items.filter((_, index) => !known[index]);
78
+ if (fresh.some((item) => item.existing))
79
+ throw new OutgoingMailError(
80
+ "mail_unavailable",
81
+ "An idempotent mail record expired during acceptance. Retry with fresh attachments.",
82
+ );
83
+ const profiles = await checkBatch(tx, appId, fresh);
84
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended(${proposed}, 4))`;
85
+ let batchId = mailBatchId(proposed, known);
86
+ const rows: MessageRow[] = [];
87
+ const created: MessageRow[] = [];
88
+ for (const [index, item] of items.entries()) {
89
+ let row = known[index];
90
+ if (!row) {
91
+ row = await insertMailMessage(
92
+ tx,
93
+ appId,
94
+ item.id,
95
+ item.message,
96
+ item.uploaded,
97
+ profiles.get(item.message.profile ?? "")!,
98
+ batchId,
99
+ options,
100
+ );
101
+ if (row) created.push(row);
102
+ else row = await outgoingMailMessages.known(appId, item.message.key, tx);
103
+ }
104
+ if (!row) throw new Error("Outgoing mail batch insert returned no row");
105
+ rows.push(row);
106
+ }
107
+ if (!created.length) batchId = mailBatchId(proposed, rows);
108
+ await recordMailBatch(
109
+ appId,
110
+ items.map((item) => item.message),
111
+ rows,
112
+ batchId,
113
+ tx,
114
+ );
115
+ result = { batchId, rows, created };
116
+ return result;
117
+ });
118
+ } catch (error) {
119
+ if (result?.created.length) {
120
+ // A callback completed but COMMIT may have lost its response. The call lock
121
+ // waits for that transaction to finish before deciding whether it recorded rows.
122
+ try {
123
+ const committed = await sql.begin(async (tx) => {
124
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended(${proposed}, 4))`;
125
+ return !!(await outgoingMailMessages.read(result!.created[0]!.id, appId, tx));
126
+ });
127
+ if (committed) return result;
128
+ } catch {
129
+ throw new MailAcceptanceUnknown();
130
+ }
131
+ }
132
+ throw error;
133
+ }
134
+ },
135
+ };
136
+ export const enqueueMail = async (
137
+ appId: string,
138
+ messages: readonly MailMessage[],
139
+ options?: MailAcceptanceOptions,
140
+ ): Promise<{ batchId: string; ids: string[] }> => {
141
+ const items: BatchItem[] = [];
142
+ try {
143
+ for (const message of messages) {
144
+ items.push({ id: crypto.randomUUID(), message, uploaded: { metadata: [], refs: [] } });
145
+ }
146
+ const profiles = await sql.begin(async (tx) => {
147
+ await lockBatchApp(tx, appId);
148
+ for (const item of items) {
149
+ item.existing = await outgoingMailMessages.known(appId, item.message.key, tx);
150
+ if (item.existing) cancelMailStreams(item.message.attachments);
151
+ }
152
+ return checkBatch(
153
+ tx,
154
+ appId,
155
+ items.filter((item) => !item.existing),
156
+ );
157
+ });
158
+ const fresh = items.filter((item) => !item.existing);
159
+ if (fresh.length) await Promise.all([mailDrainJob().ready(), mailSettled().ready(), mailAttachments().ready()]);
160
+ const deadline = Date.now() + MAIL_ATTACHMENT_UPLOAD_MS;
161
+ for (const item of fresh)
162
+ item.uploaded = await uploadMailAttachments(
163
+ item.id,
164
+ item.message.attachments ?? [],
165
+ profiles.get(item.message.profile ?? "")!.max_attachment_bytes,
166
+ deadline,
167
+ );
168
+ const accepted = await outgoingMailBatches.accept(appId, crypto.randomUUID(), items, options);
169
+ const createdIds = new Set(accepted.created.map((row) => row.id));
170
+ for (const item of items) if (!createdIds.has(item.id)) await deleteMailObjects(item.uploaded.refs).catch(() => {});
171
+ for (const id of new Set(accepted.created.flatMap((row) => (row.profile_id ? [row.profile_id] : []))))
172
+ void submitMailDrain(id).catch(() => {});
173
+ return { batchId: accepted.batchId, ids: accepted.rows.map((row) => row.id) };
174
+ } catch (error) {
175
+ for (const message of messages) cancelMailStreams(message.attachments);
176
+ // Only an indeterminate COMMIT retains objects for the 48-hour expiry.
177
+ if (!(error instanceof MailAcceptanceUnknown)) for (const item of items) await deleteMailObjects(item.uploaded.refs).catch(() => {});
178
+ throw error;
179
+ }
180
+ };
@@ -1,19 +1,112 @@
1
1
  import { fail, ok, type Result } from "@k2b/stdlib";
2
2
  import { getProcessApplicationId, getProcessPlatformPermissions } from "../../_internal/process-identity";
3
- import type { MailProfile } from "../../contracts/outgoing-mail";
4
- import { outgoingMailStore } from "./store";
3
+ import {
4
+ MailBatchSchema,
5
+ type MailFilter,
6
+ MailFilterSchema,
7
+ type MailMessage,
8
+ MailMessageSchema,
9
+ type MailPage,
10
+ type MailPageParams,
11
+ MailPageParamsSchema,
12
+ type MailProfile,
13
+ type MailRecord,
14
+ type MailServiceError,
15
+ } from "../../contracts/outgoing-mail";
16
+ import { cancelMailStreams } from "./attachments";
17
+ import { enqueueMail } from "./enqueue";
18
+ import { outgoingMailMessages, recordMailBatch, recordMailSend } from "./messages";
19
+ import { sendMail } from "./send";
20
+ import { OutgoingMailError, outgoingMailStore } from "./store";
5
21
 
22
+ const identity = (): Result<string, MailServiceError> => {
23
+ const appId = getProcessApplicationId();
24
+ if (!appId) return fail({ code: "mail_unavailable", message: "Start the application before using outgoing mail.", status: 500 });
25
+ if (!getProcessPlatformPermissions().includes("mail:send"))
26
+ return fail({ code: "mail_not_declared", message: 'Declare platformPermissions: ["mail:send"].', status: 403 });
27
+ return ok(appId);
28
+ };
29
+ const failure = (error: unknown): MailServiceError => {
30
+ if (!(error instanceof OutgoingMailError))
31
+ return { code: "mail_unavailable", message: "Outgoing mail storage is unavailable.", status: 500 };
32
+ return {
33
+ code: error.code,
34
+ message: error.message,
35
+ status: error.code === "mail_unavailable" || error.status === 502 ? 500 : error.status,
36
+ ...("limit" in error && typeof error.limit === "number" ? { limit: error.limit } : {}),
37
+ ...("used" in error && typeof error.used === "number" ? { used: error.used } : {}),
38
+ ...("requested" in error && typeof error.requested === "number" ? { requested: error.requested } : {}),
39
+ };
40
+ };
6
41
  export const mail = {
7
- async profiles(): Promise<Result<MailProfile[]>> {
8
- const appId = getProcessApplicationId();
9
- if (!appId)
10
- return fail({ code: "mail_unavailable", message: "Start the application before reading outgoing mail profiles.", status: 500 });
11
- if (!getProcessPlatformPermissions().includes("mail:send"))
12
- return fail({ code: "mail_not_declared", message: 'Declare platformPermissions: ["mail:send"].', status: 403 });
42
+ async enqueue(messages: MailMessage[]): Promise<Result<{ batchId: string; ids: string[] }, MailServiceError>> {
43
+ const caller = identity();
44
+ const parsed = caller.ok ? MailBatchSchema.safeParse(messages) : undefined;
45
+ const cancel = () => {
46
+ if (Array.isArray(messages)) for (const message of messages) cancelMailStreams(message?.attachments);
47
+ };
48
+ if (!caller.ok || !parsed?.success) {
49
+ cancel();
50
+ const appId = getProcessApplicationId();
51
+ const issue: MailServiceError = caller.ok
52
+ ? { code: "bad_input", message: "Provide 1–1000 valid messages with distinct keys.", status: 400 }
53
+ : caller.error;
54
+ if (appId)
55
+ await recordMailBatch(appId, Array.isArray(messages) ? messages : [], [], undefined, undefined, issue.code).catch(() => {});
56
+ return fail(issue);
57
+ }
13
58
  try {
14
- return ok(await outgoingMailStore.profilesForApp(appId));
59
+ return ok(await enqueueMail(caller.data, parsed.data));
60
+ } catch (error) {
61
+ cancel();
62
+ const issue = failure(error);
63
+ await recordMailBatch(caller.data, parsed.data, [], undefined, undefined, issue.code).catch(() => {});
64
+ return fail(issue);
65
+ }
66
+ },
67
+ async profiles(): Promise<Result<MailProfile[], MailServiceError>> {
68
+ const caller = identity();
69
+ if (!caller.ok) return caller;
70
+ try {
71
+ return ok(await outgoingMailStore.profilesForApp(caller.data));
15
72
  } catch {
16
- return fail({ code: "mail_unavailable", message: "Outgoing mail profiles are unavailable.", status: 500 });
73
+ return fail(failure(undefined));
74
+ }
75
+ },
76
+ async send(message: MailMessage, options?: { signal?: AbortSignal }): Promise<Result<MailRecord, MailServiceError>> {
77
+ const caller = identity();
78
+ if (!caller.ok) {
79
+ cancelMailStreams(message?.attachments);
80
+ const appId = getProcessApplicationId();
81
+ if (appId) await recordMailSend(appId, message, undefined, undefined, caller.error.code).catch(() => {});
82
+ return caller;
83
+ }
84
+ const parsed = MailMessageSchema.safeParse(message);
85
+ if (!parsed.success) {
86
+ cancelMailStreams(message?.attachments);
87
+ await recordMailSend(caller.data, message, undefined, undefined, "bad_input").catch(() => {});
88
+ return fail({ code: "bad_input", message: "Invalid outgoing mail message.", status: 400 });
89
+ }
90
+ try {
91
+ return ok(await sendMail(caller.data, parsed.data, options?.signal));
92
+ } catch (error) {
93
+ cancelMailStreams(message?.attachments);
94
+ const issue = failure(error);
95
+ await recordMailSend(caller.data, parsed.data, undefined, undefined, issue.code).catch(() => {});
96
+ return fail(issue);
97
+ }
98
+ },
99
+ async list(filter: MailFilter = {}, page: MailPageParams = {}): Promise<Result<MailPage, MailServiceError>> {
100
+ const caller = identity();
101
+ if (!caller.ok) return caller;
102
+ const parsedFilter = MailFilterSchema.safeParse(filter);
103
+ const parsedPage = MailPageParamsSchema.safeParse(page);
104
+ if (!parsedFilter.success || !parsedPage.success)
105
+ return fail({ code: "bad_input", message: "Invalid outgoing mail filter or page.", status: 400 });
106
+ try {
107
+ return ok(await outgoingMailMessages.list({ ...parsedFilter.data, app: caller.data }, parsedPage.data));
108
+ } catch (error) {
109
+ return fail(failure(error));
17
110
  }
18
111
  },
19
112
  };
@@ -0,0 +1,14 @@
1
+ import type { RequestActor } from "../../contracts/shared";
2
+
3
+ export const mailBackoffMs = (attempt: number): number => Math.min(60_000 * 2 ** Math.min(Math.max(attempt - 1, 0), 6), 60 * 60_000);
4
+ export const mailMessageId = (id: string, address: string): string => `<${id}@${address.slice(address.lastIndexOf("@") + 1)}>`;
5
+ export const mailEnvelope = (profile: { fromAddress: string; fromName: string | null }, appName: string, fromName?: string | null) => ({
6
+ from: { address: profile.fromAddress, name: fromName ?? profile.fromName ?? appName },
7
+ envelope: { from: profile.fromAddress },
8
+ });
9
+ export const mailActorSnapshot = (actor?: RequestActor) => {
10
+ if (!actor) return undefined;
11
+ return actor.kind === "user"
12
+ ? { type: actor.kind, id: actor.user.id, name: actor.user.displayName || actor.user.uid }
13
+ : { type: actor.kind, id: actor.serviceAccount.id, name: actor.serviceAccount.name };
14
+ };