@k2b/cloud 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/package.json +7 -5
  2. package/src/_internal/cli-plugins.ts +18 -0
  3. package/src/_internal/define-app.ts +21 -5
  4. package/src/_internal/registry-validation.ts +30 -1
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/access/PermissionEditor.tsx +11 -4
  7. package/src/ai/assistant-models.ts +25 -4
  8. package/src/ai/audio-tool.ts +3 -10
  9. package/src/ai/browser-code-contracts.ts +33 -63
  10. package/src/ai/browser.ts +1 -0
  11. package/src/ai/capabilities.ts +35 -9
  12. package/src/ai/chat/blocks.tsx +113 -24
  13. package/src/ai/chat/builtin-tools.tsx +3 -1
  14. package/src/ai/chat/composer-adapter.ts +8 -0
  15. package/src/ai/chat/live-turn.browser-harness.tsx +15 -3
  16. package/src/ai/chat/message-actions.tsx +124 -102
  17. package/src/ai/chat/message-utils.ts +57 -6
  18. package/src/ai/chat/messages.ts +214 -0
  19. package/src/ai/chat/presentation.tsx +67 -28
  20. package/src/ai/chat/tool-groups.ts +1 -1
  21. package/src/ai/chat/tool-names.ts +157 -0
  22. package/src/ai/chat/turn-error.ts +21 -0
  23. package/src/ai/chat/turn-layout.ts +12 -0
  24. package/src/ai/chat/turn-view.tsx +69 -10
  25. package/src/ai/chat/user-message.tsx +19 -14
  26. package/src/ai/chat/visual-tools.tsx +1 -1
  27. package/src/ai/chat-tasks.ts +16 -3
  28. package/src/ai/client/controller.ts +23 -6
  29. package/src/ai/code-mode-skill.ts +21 -25
  30. package/src/ai/code-runtime-tools.ts +5 -1
  31. package/src/ai/code-source-contracts.ts +2 -2
  32. package/src/ai/compaction.ts +22 -3
  33. package/src/ai/data-analysis-skill.ts +3 -3
  34. package/src/ai/default-tools.ts +23 -17
  35. package/src/ai/executor.ts +262 -99
  36. package/src/ai/file-media-type.ts +4 -0
  37. package/src/ai/files-store.ts +17 -10
  38. package/src/ai/http.ts +11 -10
  39. package/src/ai/index.ts +3 -0
  40. package/src/ai/memories.ts +2 -0
  41. package/src/ai/memory-learning.ts +59 -5
  42. package/src/ai/memory-workflow-evidence.ts +35 -10
  43. package/src/ai/open-tool-calls.ts +87 -0
  44. package/src/ai/projects-routes.ts +5 -1
  45. package/src/ai/projects.ts +5 -1
  46. package/src/ai/protocol.ts +34 -1
  47. package/src/ai/provider.ts +16 -1
  48. package/src/ai/quota-provider.ts +4 -1
  49. package/src/ai/routes.ts +26 -10
  50. package/src/ai/run-timeout.ts +4 -5
  51. package/src/ai/runtime.ts +8 -0
  52. package/src/ai/settings.ts +54 -17
  53. package/src/ai/skill-seeds.ts +14 -6
  54. package/src/ai/store.ts +102 -34
  55. package/src/ai/structured.ts +2 -1
  56. package/src/ai/system-prompt.ts +9 -22
  57. package/src/ai/transcription.ts +58 -13
  58. package/src/ai/turn-failure.ts +104 -0
  59. package/src/ai/turn-guidance.ts +505 -0
  60. package/src/ai/turn-policy.ts +2 -1
  61. package/src/ai/types.ts +29 -1
  62. package/src/ai/ui.tsx +1 -0
  63. package/src/api/admin-core-settings.ts +16 -1
  64. package/src/api/admin-outgoing-mail.ts +185 -5
  65. package/src/api/me.ts +42 -1
  66. package/src/browser/FileChooser.tsx +82 -295
  67. package/src/browser/FileSaver.tsx +407 -0
  68. package/src/browser/app-badges.ts +16 -0
  69. package/src/browser/choose-files.browser-harness.tsx +22 -6
  70. package/src/browser/choose-files.tsx +23 -36
  71. package/src/browser/file-chooser-messages.ts +66 -0
  72. package/src/browser/file-providers.ts +258 -8
  73. package/src/browser/files.tsx +26 -3
  74. package/src/browser/notifications.ts +37 -1
  75. package/src/browser/provider-folder.tsx +301 -0
  76. package/src/browser/provider-list.ts +39 -0
  77. package/src/browser/save-files.browser-harness.tsx +35 -0
  78. package/src/browser/save-files.tsx +107 -0
  79. package/src/cli/admin/index.ts +3 -1
  80. package/src/cli/admin/notifications.ts +6 -0
  81. package/src/cli/admin/outgoing-mail.ts +104 -7
  82. package/src/contracts/app.ts +10 -0
  83. package/src/contracts/commands.ts +3 -1
  84. package/src/contracts/notification-group.ts +6 -0
  85. package/src/contracts/notification-types.ts +6 -0
  86. package/src/contracts/outgoing-mail.ts +189 -1
  87. package/src/contracts/registry.ts +1 -0
  88. package/src/contracts/user-notifications.ts +74 -0
  89. package/src/server/middleware/request-logger.ts +11 -2
  90. package/src/services/accounts/avatar.ts +5 -2
  91. package/src/services/help/store.ts +2 -1
  92. package/src/services/image-metadata.ts +438 -0
  93. package/src/services/index.ts +12 -1
  94. package/src/services/logging/redaction.ts +50 -0
  95. package/src/services/mandates/index.ts +4 -2
  96. package/src/services/mandates/policy.ts +20 -0
  97. package/src/services/notifications/batches.ts +139 -79
  98. package/src/services/notifications/browser.ts +48 -15
  99. package/src/services/notifications/channels.ts +33 -13
  100. package/src/services/notifications/dispatcher.ts +88 -6
  101. package/src/services/notifications/email-frame.fixture.html +51 -0
  102. package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
  103. package/src/services/notifications/email-mail.ts +101 -0
  104. package/src/services/notifications/index.ts +49 -36
  105. package/src/services/notifications/observability.ts +9 -1
  106. package/src/services/notifications/platform.ts +33 -5
  107. package/src/services/notifications/preview.ts +23 -0
  108. package/src/services/notifications/quiet.ts +153 -0
  109. package/src/services/notifications/runtime.ts +9 -3
  110. package/src/services/notifications/user.ts +6 -0
  111. package/src/services/outgoing-mail/admin.ts +84 -0
  112. package/src/services/outgoing-mail/attachments.ts +135 -0
  113. package/src/services/outgoing-mail/bulk.ts +11 -0
  114. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  115. package/src/services/outgoing-mail/drain.ts +60 -0
  116. package/src/services/outgoing-mail/enqueue.ts +180 -0
  117. package/src/services/outgoing-mail/index.ts +103 -10
  118. package/src/services/outgoing-mail/message.ts +14 -0
  119. package/src/services/outgoing-mail/messages.ts +431 -0
  120. package/src/services/outgoing-mail/retention.ts +48 -0
  121. package/src/services/outgoing-mail/runtime.ts +72 -0
  122. package/src/services/outgoing-mail/send.ts +130 -0
  123. package/src/services/outgoing-mail/store.ts +115 -4
  124. package/src/services/outgoing-mail/sync.ts +39 -0
  125. package/src/services/outgoing-mail/transport.ts +5 -1
  126. package/src/services/pdf/markdown.ts +22 -4
  127. package/src/services/pdf/template-preview.ts +2 -1
  128. package/src/services/postgres.ts +15 -0
  129. package/src/services/settings/app.ts +3 -0
  130. package/src/services/settings/core-settings.ts +18 -0
  131. package/src/services/settings/store.ts +4 -1
  132. package/src/services/weather/geo.ts +6 -2
  133. package/src/shared/ai-platform-prompt.ts +48 -4
  134. package/src/shared/markdown/extensions/links.ts +28 -20
  135. package/src/shared/markdown/index.ts +14 -5
  136. package/src/shared/markdown/shared.ts +0 -7
  137. package/src/shared/redirect.ts +2 -1
  138. package/src/shared/template-rendering.ts +120 -7
  139. package/src/ssr/AppBadge.tsx +30 -0
  140. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  141. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  142. package/src/ssr/IslandError.tsx +33 -0
  143. package/src/ssr/Layout.tsx +3 -0
  144. package/src/ssr/LayoutHelp.tsx +4 -2
  145. package/src/ssr/RailApps.island.tsx +9 -2
  146. package/src/ssr/app-badges.ts +93 -0
  147. package/src/ssr/platform-messages.ts +1 -1
  148. package/src/ssr/rail-messages.ts +4 -0
  149. package/src/ssr/rail-navigation.ts +2 -0
  150. package/src/styles/effects.css +38 -17
  151. package/src/styles/file-chooser.css +25 -0
  152. package/src/styles/tokens.css +2 -0
  153. package/src/styles/utilities-markdown-editor.css +4 -39
  154. package/src/styles/utilities-markdown-table.css +14 -17
  155. package/src/styles/utilities-navigation.css +41 -0
@@ -0,0 +1,153 @@
1
+ import { dates } from "@k2b/stdlib";
2
+ import { sql } from "bun";
3
+ import type { MutationResult } from "../../contracts/shared";
4
+ import {
5
+ NOTIFICATION_DO_NOT_DISTURB_MAX_DAYS,
6
+ type NotificationQuietHours,
7
+ NotificationQuietHoursSchema,
8
+ type NotificationQuietSettings,
9
+ type NotificationQuietState,
10
+ type UpdateNotificationQuietSettings,
11
+ } from "../../contracts/user-notifications";
12
+ import { normalizeTimeZone } from "../../shared/time";
13
+
14
+ const DAY_MS = 24 * 60 * 60_000;
15
+ /** How far ahead `until` looks. A schedule that stays quiet for longer has no end worth naming. */
16
+ const LOOKAHEAD_DAYS = 8;
17
+
18
+ type QuietInput = { doNotDisturbUntil: Date | null; quietHours: NotificationQuietHours };
19
+ type Interval = { start: number; end: number };
20
+
21
+ const pad2 = (value: number): string => String(value).padStart(2, "0");
22
+
23
+ /**
24
+ * The quiet-hours intervals that touch `[from - 1 day, from + days]`, as instants. Each period belongs to the
25
+ * local day it starts on; wall-clock times are converted per date, so a period keeps its local times across
26
+ * daylight-saving changes. A time inside a skipped hour moves forward like the clock does. In a repeated hour a
27
+ * start takes the first occurrence and an end the second, so the period covers every instant the clock shows a
28
+ * time inside it.
29
+ */
30
+ const quietHourIntervals = (hours: NotificationQuietHours, from: number, days: number): Interval[] => {
31
+ if (hours.periods.length === 0) return [];
32
+ const timeZone = normalizeTimeZone(hours.timeZone);
33
+ const [localDate = ""] = dates.instantToZonedInput(new Date(from), timeZone).split("T");
34
+ const [year, month, day] = localDate.split("-").map(Number) as [number, number, number];
35
+ const instant = (offset: number, time: string, disambiguation: "compatible" | "later"): number => {
36
+ const date = new Date(Date.UTC(year, month - 1, day + offset));
37
+ const wallClock = `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}T${time}`;
38
+ return Date.parse(dates.zonedDateTimeToInstant(wallClock, timeZone, { disambiguation }));
39
+ };
40
+ const intervals: Interval[] = [];
41
+ for (let offset = -1; offset <= days; offset += 1) {
42
+ const isoWeekday = new Date(Date.UTC(year, month - 1, day + offset)).getUTCDay() || 7;
43
+ for (const period of hours.periods) {
44
+ if (!period.days.includes(isoWeekday)) continue;
45
+ const start = instant(offset, period.start, "compatible");
46
+ const end = instant(period.end > period.start ? offset : offset + 1, period.end, "later");
47
+ if (end > start) intervals.push({ start, end });
48
+ }
49
+ }
50
+ return intervals;
51
+ };
52
+
53
+ /**
54
+ * Follow quiet hours from `from` across overlapping and adjoining periods. Returns `from` when it is not quiet
55
+ * and null when the quiet time does not end within the lookahead, for example with every day fully quiet.
56
+ */
57
+ const quietHoursEnd = (hours: NotificationQuietHours, from: number): number | null => {
58
+ const intervals = quietHourIntervals(hours, from, LOOKAHEAD_DAYS);
59
+ const horizon = from + LOOKAHEAD_DAYS * DAY_MS;
60
+ let until = from;
61
+ for (;;) {
62
+ const ends = intervals.filter((interval) => interval.start <= until && until < interval.end).map((interval) => interval.end);
63
+ if (ends.length === 0) return until;
64
+ until = Math.max(...ends);
65
+ if (until > horizon) return null;
66
+ }
67
+ };
68
+
69
+ /**
70
+ * Whether browser notifications are held back at `now`, why, and until when. Do not disturb names the reason
71
+ * while it lasts; `until` also covers quiet hours that continue when it ends.
72
+ */
73
+ export const notificationQuietState = (input: QuietInput, now: Date = new Date()): NotificationQuietState => {
74
+ const at = now.getTime();
75
+ const iso = (instant: number | null) => (instant === null ? null : new Date(instant).toISOString());
76
+ const doNotDisturbUntil = input.doNotDisturbUntil?.getTime() ?? null;
77
+ if (doNotDisturbUntil !== null && doNotDisturbUntil > at) {
78
+ return { active: true, reason: "doNotDisturb", until: iso(quietHoursEnd(input.quietHours, doNotDisturbUntil)), nextStart: null };
79
+ }
80
+ const until = quietHoursEnd(input.quietHours, at);
81
+ if (until !== at) return { active: true, reason: "quietHours", until: iso(until), nextStart: null };
82
+ const starts = quietHourIntervals(input.quietHours, at, LOOKAHEAD_DAYS)
83
+ .map((interval) => interval.start)
84
+ .filter((start) => start > at && start <= at + LOOKAHEAD_DAYS * DAY_MS);
85
+ return { active: false, reason: null, until: null, nextStart: iso(starts.length > 0 ? Math.min(...starts) : null) };
86
+ };
87
+
88
+ type QuietRow = { do_not_disturb_until: Date | null; time_zone: string; periods: unknown };
89
+
90
+ /** Parse a stored row defensively: a schedule that no longer validates silences nothing. */
91
+ export const quietInputFromRow = (row: QuietRow | undefined, fallbackTimeZone = "UTC"): QuietInput => {
92
+ const parsed = row ? NotificationQuietHoursSchema.safeParse({ timeZone: row.time_zone, periods: row.periods }) : null;
93
+ return {
94
+ doNotDisturbUntil: row?.do_not_disturb_until ?? null,
95
+ quietHours: parsed?.success ? parsed.data : { timeZone: normalizeTimeZone(row?.time_zone ?? fallbackTimeZone), periods: [] },
96
+ };
97
+ };
98
+
99
+ const toSettings = (input: QuietInput, now = new Date()): NotificationQuietSettings => ({
100
+ doNotDisturbUntil:
101
+ input.doNotDisturbUntil && input.doNotDisturbUntil.getTime() > now.getTime() ? input.doNotDisturbUntil.toISOString() : null,
102
+ quietHours: input.quietHours,
103
+ state: notificationQuietState(input, now),
104
+ });
105
+
106
+ /** The person's quiet settings and current state. Without saved quiet hours, `fallbackTimeZone` is offered. */
107
+ const getQuietSettings = async (userId: string, fallbackTimeZone = "UTC"): Promise<NotificationQuietSettings> => {
108
+ const [row] = await sql<QuietRow[]>`
109
+ SELECT do_not_disturb_until, time_zone, periods FROM notifications.quiet_times WHERE user_id = ${userId}::uuid
110
+ `;
111
+ return toSettings(quietInputFromRow(row, fallbackTimeZone));
112
+ };
113
+
114
+ const updateQuietSettings = async (config: {
115
+ userId: string;
116
+ update: UpdateNotificationQuietSettings;
117
+ fallbackTimeZone?: string;
118
+ }): Promise<MutationResult<NotificationQuietSettings>> => {
119
+ const { doNotDisturbUntil, quietHours } = config.update;
120
+ const until = doNotDisturbUntil ? new Date(doNotDisturbUntil) : null;
121
+ if (until && until.getTime() <= Date.now()) {
122
+ return { ok: false, error: "Do not disturb must end in the future", status: 400 };
123
+ }
124
+ if (until && until.getTime() > Date.now() + NOTIFICATION_DO_NOT_DISTURB_MAX_DAYS * DAY_MS) {
125
+ return { ok: false, error: "Do not disturb must end within a year", status: 400 };
126
+ }
127
+ const timeZone = normalizeTimeZone(quietHours?.timeZone ?? config.fallbackTimeZone);
128
+ const periods = quietHours ? JSON.stringify(quietHours.periods) : "[]";
129
+ await sql`
130
+ INSERT INTO notifications.quiet_times (user_id, do_not_disturb_until, time_zone, periods, updated_at)
131
+ VALUES (${config.userId}::uuid, ${until}::timestamptz, ${timeZone}, (${periods}::text)::jsonb, now())
132
+ ON CONFLICT (user_id) DO UPDATE SET
133
+ do_not_disturb_until = CASE WHEN ${doNotDisturbUntil !== undefined} THEN EXCLUDED.do_not_disturb_until
134
+ ELSE notifications.quiet_times.do_not_disturb_until END,
135
+ time_zone = CASE WHEN ${quietHours !== undefined} THEN EXCLUDED.time_zone ELSE notifications.quiet_times.time_zone END,
136
+ periods = CASE WHEN ${quietHours !== undefined} THEN EXCLUDED.periods ELSE notifications.quiet_times.periods END,
137
+ updated_at = now()
138
+ `;
139
+ return { ok: true, data: await getQuietSettings(config.userId, config.fallbackTimeZone) };
140
+ };
141
+
142
+ /** The quiet state of an event's recipient, or null when the recipient is not a Cloud user. */
143
+ export const quietStateForEvent = async (eventId: string, now = new Date()): Promise<NotificationQuietState | null> => {
144
+ const [row] = await sql<QuietRow[]>`
145
+ SELECT q.do_not_disturb_until, q.time_zone, q.periods
146
+ FROM notifications.events e
147
+ JOIN notifications.quiet_times q ON q.user_id = e.recipient_user_id
148
+ WHERE e.id = ${eventId}::uuid
149
+ `;
150
+ return row ? notificationQuietState(quietInputFromRow(row), now) : null;
151
+ };
152
+
153
+ export const userQuietTimes = { get: getQuietSettings, update: updateQuietSettings } as const;
@@ -3,7 +3,7 @@ import { lazySync } from "../../_internal/process-sync";
3
3
  import { logger, trace } from "../logging";
4
4
  import { createRuntimeLifecycle, createRuntimeTaskTracker, stopRuntimeJobs } from "../runtime-lifecycle";
5
5
 
6
- import { processNotificationDelivery, recoverNotificationDeliveries } from "./dispatcher";
6
+ import { processNotificationDelivery, reconcileNotificationMessages, recoverNotificationDeliveries } from "./dispatcher";
7
7
 
8
8
  type DeliveryMessage = { deliveryId: string };
9
9
 
@@ -58,10 +58,10 @@ const handleDelivery = async (context: JobContext<DeliveryMessage>): Promise<voi
58
58
  spanKey: trace.syncSpanKey("job", "cloud-notification-deliveries", context.jobId),
59
59
  attributes: { "cloud.notification.delivery_id": context.input.deliveryId },
60
60
  },
61
- () => processNotificationDelivery(context.input.deliveryId),
61
+ () => processNotificationDelivery(context.input.deliveryId, context.signal),
62
62
  );
63
63
  if (result.activatedIds?.length) await enqueueNotificationDeliveries(result.activatedIds);
64
- if (result.status === "retry") context.resubmit({ delayMs: result.retryAfterMs });
64
+ if (result.status === "retry" || result.status === "pending") context.resubmit({ delayMs: result.retryAfterMs });
65
65
  };
66
66
 
67
67
  type RuntimeOptions = { concurrency?: number; recoveryIntervalMs?: number };
@@ -79,6 +79,12 @@ const recover = (): Promise<void> => {
79
79
  } catch (error) {
80
80
  log.error("Delivery recovery failed", { error: error instanceof Error ? error.message : String(error) });
81
81
  }
82
+ // Message history must never hold back delivery recovery.
83
+ try {
84
+ await reconcileNotificationMessages();
85
+ } catch (error) {
86
+ log.error("Notification message reconciliation failed", { error: error instanceof Error ? error.message : String(error) });
87
+ }
82
88
  }) ?? Promise.resolve()
83
89
  ).finally(() => {
84
90
  recovery = undefined;
@@ -14,6 +14,7 @@ import type {
14
14
  import { normalizeLocale } from "../../shared/locale";
15
15
  import { toPgTextArray } from "../postgres";
16
16
  import { listNotificationChannels } from "./channels";
17
+ import { userQuietTimes } from "./quiet";
17
18
 
18
19
  type PreferenceRow = {
19
20
  id: string;
@@ -164,6 +165,10 @@ const publicDeliveryError = (code: string | null, status: NotificationDeliverySt
164
165
  return { code, message: "This delivery channel is currently unavailable." };
165
166
  case "no_endpoint":
166
167
  return { code, message: "No registered destination is available for this channel." };
168
+ case "do_not_disturb":
169
+ return { code, message: "Held back because do not disturb was on." };
170
+ case "quiet_hours":
171
+ return { code, message: "Held back during your quiet hours." };
167
172
  case "fallback_not_needed":
168
173
  return { code, message: "Another preferred channel delivered this notification." };
169
174
  case "lease_recovered":
@@ -245,4 +250,5 @@ const listHistory = async (config: {
245
250
  export const userNotifications = {
246
251
  preferences: { list: listPreferences, set: setPreference, reset: resetPreference },
247
252
  history: { list: listHistory },
253
+ quiet: userQuietTimes,
248
254
  } as const;
@@ -0,0 +1,84 @@
1
+ import { sql } from "bun";
2
+ import {
3
+ type AdminMailFilter,
4
+ type AdminMailRecord,
5
+ AdminMailRecordSchema,
6
+ type MailPage,
7
+ type MailPageParams,
8
+ } from "../../contracts/outgoing-mail";
9
+ import { audit } from "../audit";
10
+ import { cleanupMailAttachments, publishMailSettled } from "./dispatcher";
11
+ import { adminMessageRecord, type MessageRow, outgoingMailMessages } from "./messages";
12
+ import { type MailAuditContext, OutgoingMailError } from "./store";
13
+
14
+ const get = async (id: string): Promise<AdminMailRecord> => {
15
+ const record = await outgoingMailMessages.metadata(id);
16
+ if (!record) throw new OutgoingMailError("message_unknown", "Outgoing mail message does not exist.", 404);
17
+ return record;
18
+ };
19
+ const cancel = async (id: string, context: MailAuditContext): Promise<AdminMailRecord> => {
20
+ const row = await sql.begin(async (tx) => {
21
+ const [row] = await tx<MessageRow[]>`UPDATE outgoing_mail.messages SET status = 'cancelled', error_code = 'cancelled_by_admin',
22
+ error_message = 'Cancelled by an administrator.', next_attempt_at = NULL, updated_at = now() WHERE id = ${id}::uuid AND status = 'queued'
23
+ RETURNING *, created_at::text AS cursor_created_at`;
24
+ if (!row) {
25
+ if (!(await outgoingMailMessages.read(id, undefined, tx)))
26
+ throw new OutgoingMailError("message_unknown", "Outgoing mail message does not exist.", 404);
27
+ throw new OutgoingMailError("message_not_queued", "Only queued outgoing mail can be cancelled.", 409);
28
+ }
29
+ await audit.record(
30
+ {
31
+ ...context,
32
+ action: "outgoing_mail.message.cancel",
33
+ outcome: "allowed",
34
+ target: { type: "outgoing_mail_message", id },
35
+ metadata: { appId: row.app_id, profile: row.profile_key },
36
+ },
37
+ tx,
38
+ );
39
+ return row;
40
+ });
41
+ await cleanupMailAttachments(id).catch(() => {});
42
+ await publishMailSettled(id).catch(() => {});
43
+ return adminMessageRecord(row);
44
+ };
45
+ export const outgoingMailLog = {
46
+ get,
47
+ cancel,
48
+ async cancelBatch(batchId: string, context: MailAuditContext): Promise<{ batchId: string; cancelled: number }> {
49
+ const cancelled = await sql.begin(async (tx) => {
50
+ const apps = await tx<{ app_id: string }[]>`SELECT DISTINCT app_id FROM outgoing_mail.messages WHERE batch_id = ${batchId}::uuid`;
51
+ if (!apps.length) throw new OutgoingMailError("batch_unknown", "Outgoing mail batch does not exist.", 404);
52
+ const rows = await tx<{ id: string }[]>`UPDATE outgoing_mail.messages SET status = 'cancelled', error_code = 'cancelled_by_admin',
53
+ error_message = 'Cancelled by an administrator.', next_attempt_at = NULL, updated_at = now()
54
+ WHERE batch_id = ${batchId}::uuid AND status = 'queued' RETURNING id`;
55
+ await audit.record(
56
+ {
57
+ ...context,
58
+ action: "outgoing_mail.batch.cancel",
59
+ outcome: "allowed",
60
+ target: { type: "outgoing_mail_batch", id: batchId },
61
+ metadata: { appIds: apps.map((row) => row.app_id), count: rows.length },
62
+ },
63
+ tx,
64
+ );
65
+ return rows;
66
+ });
67
+ for (const row of cancelled) {
68
+ await cleanupMailAttachments(row.id).catch(() => {});
69
+ await publishMailSettled(row.id).catch(() => {});
70
+ }
71
+ return { batchId, cancelled: cancelled.length };
72
+ },
73
+ content: outgoingMailMessages.content,
74
+ list: async (filter: AdminMailFilter, page: MailPageParams): Promise<MailPage<AdminMailRecord>> => {
75
+ const result = await outgoingMailMessages.list(filter, page, true);
76
+ return {
77
+ ...result,
78
+ items: result.items.map((item) => {
79
+ // The metadata mapper deliberately omits content; validate its public boundary.
80
+ return AdminMailRecordSchema.parse(item);
81
+ }),
82
+ };
83
+ },
84
+ };
@@ -0,0 +1,135 @@
1
+ import { createHash } from "node:crypto";
2
+ import { type ObjectRef, StoreFullError } from "@k2b/sync";
3
+ import type { MailAttachment, MailRecord } from "../../contracts/outgoing-mail";
4
+ import { OutgoingMailError } from "./store";
5
+ import { mailAttachments } from "./sync";
6
+
7
+ export type UploadedAttachments = { metadata: MailRecord["attachments"]; refs: ObjectRef[] };
8
+ export const MAIL_ATTACHMENT_UPLOAD_MS = 60_000;
9
+ export const cancelMailStreams = (attachments: unknown = []): void => {
10
+ if (!Array.isArray(attachments)) return;
11
+ for (const attachment of attachments) {
12
+ if (
13
+ attachment &&
14
+ typeof attachment === "object" &&
15
+ "content" in attachment &&
16
+ attachment.content instanceof ReadableStream &&
17
+ !attachment.content.locked
18
+ )
19
+ void attachment.content.cancel().catch(() => {});
20
+ }
21
+ };
22
+ export const deleteMailObjects = async (refs: readonly ObjectRef[]): Promise<void> => {
23
+ // Sequential deletion bounds load even for a large attachment collection.
24
+ for (const ref of refs) await mailAttachments().delete({ tenantId: ref.tenantId, key: ref.key });
25
+ };
26
+ const contentStream = (content: MailAttachment["content"]): ReadableStream<Uint8Array> => {
27
+ if (content instanceof ReadableStream) return content;
28
+ if (content instanceof Blob) return content.stream();
29
+ return new ReadableStream({
30
+ start(controller) {
31
+ controller.enqueue(content);
32
+ controller.close();
33
+ },
34
+ });
35
+ };
36
+ export const uploadMailAttachments = async (
37
+ id: string,
38
+ attachments: readonly MailAttachment[],
39
+ limit: number,
40
+ uploadDeadline?: number,
41
+ ): Promise<UploadedAttachments> => {
42
+ const result: UploadedAttachments = { metadata: [], refs: [] };
43
+ let total = 0;
44
+ const controller = new AbortController();
45
+ // The caller's signal cancels the wait only. All attachments share one bounded upload.
46
+ if (uploadDeadline !== undefined && uploadDeadline <= Date.now() && attachments.length) {
47
+ cancelMailStreams(attachments);
48
+ throw new OutgoingMailError("mail_unavailable", "Attachment upload timed out.");
49
+ }
50
+ const timer = setTimeout(
51
+ () => controller.abort(new Error("Attachment upload timed out")),
52
+ uploadDeadline === undefined ? MAIL_ATTACHMENT_UPLOAD_MS : Math.max(0, uploadDeadline - Date.now()),
53
+ );
54
+ try {
55
+ for (const [index, attachment] of attachments.entries()) {
56
+ const hash = createHash("sha256");
57
+ let size = 0;
58
+ let exceeded = false;
59
+ const key = `${id}-${index}`;
60
+ try {
61
+ const stream = contentStream(attachment.content).pipeThrough(
62
+ new TransformStream<Uint8Array, Uint8Array>({
63
+ transform(chunk, output) {
64
+ total += chunk.byteLength;
65
+ size += chunk.byteLength;
66
+ if (total > limit) {
67
+ exceeded = true;
68
+ throw new OutgoingMailError("attachments_too_large", "Attachments exceed the profile's total byte limit.");
69
+ }
70
+ hash.update(chunk);
71
+ output.enqueue(chunk);
72
+ },
73
+ }),
74
+ { signal: controller.signal },
75
+ );
76
+ const ref = await mailAttachments().put({ tenantId: id, key, body: stream, signal: controller.signal });
77
+ result.refs.push(ref);
78
+ result.metadata.push({ filename: attachment.filename, contentType: attachment.contentType, size, sha256: hash.digest("hex") });
79
+ } catch (error) {
80
+ // A failed put can have written chunks before the final metadata was published.
81
+ await mailAttachments()
82
+ .delete({ tenantId: id, key })
83
+ .catch(() => {});
84
+ if (exceeded) throw new OutgoingMailError("attachments_too_large", "Attachments exceed the profile's total byte limit.");
85
+ if (
86
+ error instanceof StoreFullError ||
87
+ (error instanceof Error &&
88
+ /maximum bytes|maximum storage|insufficient (?:storage|resources)|storage resources|store.*full|resource limits exceeded/i.test(
89
+ error.message,
90
+ ))
91
+ )
92
+ throw new OutgoingMailError("attachment_storage_full", "Outgoing mail attachment storage is full.");
93
+ throw error;
94
+ }
95
+ }
96
+ return result;
97
+ } catch (error) {
98
+ await deleteMailObjects(result.refs).catch(() => {});
99
+ cancelMailStreams(attachments);
100
+ throw error;
101
+ } finally {
102
+ clearTimeout(timer);
103
+ controller.abort();
104
+ }
105
+ };
106
+
107
+ /** Verify before SMTP DATA; open a second, verified stream for nodemailer. */
108
+ export const verifyMailAttachment = async (
109
+ ref: ObjectRef,
110
+ metadata: MailRecord["attachments"][number],
111
+ signal?: AbortSignal,
112
+ ): Promise<void> => {
113
+ const object = await mailAttachments().get(ref, { signal });
114
+ if (!object) throw new OutgoingMailError("attachment_lost", "An outgoing mail attachment is missing or changed.");
115
+ const reader = object.body.getReader();
116
+ const hash = createHash("sha256");
117
+ let size = 0;
118
+ try {
119
+ while (true) {
120
+ const chunk = await reader.read();
121
+ if (chunk.done) break;
122
+ size += chunk.value.byteLength;
123
+ if (size > metadata.size) throw new Error("Attachment size mismatch");
124
+ hash.update(chunk.value);
125
+ }
126
+ if (size !== metadata.size || hash.digest("hex") !== metadata.sha256) throw new Error("Attachment digest mismatch");
127
+ } catch (error) {
128
+ if (signal?.aborted || (error instanceof Error && /stalled|timeout|timed out|connection/i.test(error.message)))
129
+ throw new Error("Attachment verification was interrupted.");
130
+ throw new OutgoingMailError("attachment_lost", "An outgoing mail attachment is missing or changed.");
131
+ } finally {
132
+ await reader.cancel().catch(() => {});
133
+ reader.releaseLock();
134
+ }
135
+ };
@@ -0,0 +1,11 @@
1
+ export const MAIL_RECOVERY_MS = 30_000;
2
+ export const mailSlotMs = (pacePerMinute: number): number => 60_000 / pacePerMinute;
3
+ export const mailBacklogFull = (queued: number, added: number, pacePerMinute: number): boolean =>
4
+ added > 0 && queued + added > pacePerMinute * 1440;
5
+ export const mailBatchId = (proposed: string, rows: readonly ({ batch_id: string | null } | undefined)[]): string => {
6
+ const previous = rows[0]?.batch_id;
7
+ return previous && rows.every((row) => row?.batch_id === previous) ? previous : proposed;
8
+ };
9
+ // Later submissions join a coalesced continuation, so long backoffs must not hide fresh mail.
10
+ export const mailNextDrainDelay = (now: number, slot: number, due: number): number =>
11
+ Math.min(MAIL_RECOVERY_MS, Math.max(0, Math.max(slot, due) - now));