@k2b/cloud 0.28.0 → 0.29.1

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 (112) hide show
  1. package/package.json +9 -6
  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/capabilities.ts +35 -9
  10. package/src/ai/chat/blocks.tsx +259 -197
  11. package/src/ai/chat/builtin-tools.tsx +3 -1
  12. package/src/ai/chat/composer-adapter.ts +8 -0
  13. package/src/ai/chat/live-turn.browser-harness.tsx +16 -3
  14. package/src/ai/chat/message-actions.tsx +1 -1
  15. package/src/ai/chat/message-utils.ts +28 -6
  16. package/src/ai/chat/messages.ts +20 -24
  17. package/src/ai/chat/presentation.tsx +1 -8
  18. package/src/ai/chat/tool-disclosure.tsx +21 -14
  19. package/src/ai/chat/tool-groups.ts +64 -37
  20. package/src/ai/chat/tool-names.ts +157 -0
  21. package/src/ai/chat/turn-layout.ts +14 -2
  22. package/src/ai/chat/turn-view.tsx +132 -70
  23. package/src/ai/chat-tasks.ts +16 -3
  24. package/src/ai/code-mode-skill.ts +4 -4
  25. package/src/ai/compaction.ts +22 -3
  26. package/src/ai/default-tools.ts +9 -2
  27. package/src/ai/executor.ts +127 -40
  28. package/src/ai/file-media-type.ts +4 -0
  29. package/src/ai/files-store.ts +17 -10
  30. package/src/ai/http.ts +11 -10
  31. package/src/ai/index.ts +1 -0
  32. package/src/ai/memories.ts +2 -0
  33. package/src/ai/memory-learning.ts +59 -5
  34. package/src/ai/memory-workflow-evidence.ts +35 -10
  35. package/src/ai/projects-routes.ts +5 -1
  36. package/src/ai/projects.ts +5 -1
  37. package/src/ai/protocol.ts +43 -5
  38. package/src/ai/provider.ts +16 -1
  39. package/src/ai/quota-provider.ts +4 -1
  40. package/src/ai/routes.ts +20 -10
  41. package/src/ai/settings.ts +54 -17
  42. package/src/ai/skill-seeds.ts +11 -3
  43. package/src/ai/store.ts +76 -33
  44. package/src/ai/structured.ts +2 -1
  45. package/src/ai/system-prompt.ts +5 -1
  46. package/src/ai/transcription.ts +58 -13
  47. package/src/ai/turn-failure.ts +5 -1
  48. package/src/ai/turn-guidance.ts +505 -0
  49. package/src/ai/types.ts +3 -1
  50. package/src/ai/ui.tsx +1 -0
  51. package/src/api/admin-core-settings.ts +16 -1
  52. package/src/api/admin-outgoing-mail.ts +18 -0
  53. package/src/api/me.ts +42 -1
  54. package/src/browser/FileChooser.tsx +71 -295
  55. package/src/browser/FileSaver.tsx +407 -0
  56. package/src/browser/app-badges.ts +16 -0
  57. package/src/browser/choose-files.browser-harness.tsx +22 -6
  58. package/src/browser/choose-files.tsx +23 -36
  59. package/src/browser/file-chooser-messages.ts +64 -0
  60. package/src/browser/file-providers.ts +258 -8
  61. package/src/browser/files.tsx +26 -3
  62. package/src/browser/notifications.ts +37 -1
  63. package/src/browser/provider-folder.tsx +301 -0
  64. package/src/browser/provider-list.ts +39 -0
  65. package/src/browser/save-files.browser-harness.tsx +35 -0
  66. package/src/browser/save-files.tsx +107 -0
  67. package/src/cli/admin/outgoing-mail.ts +30 -2
  68. package/src/contracts/app.ts +10 -0
  69. package/src/contracts/commands.ts +3 -1
  70. package/src/contracts/notification-group.ts +6 -0
  71. package/src/contracts/notification-types.ts +6 -0
  72. package/src/contracts/outgoing-mail.ts +56 -11
  73. package/src/contracts/registry.ts +1 -0
  74. package/src/contracts/user-notifications.ts +74 -0
  75. package/src/server/middleware/request-logger.ts +11 -2
  76. package/src/services/accounts/avatar.ts +5 -2
  77. package/src/services/image-metadata.ts +438 -0
  78. package/src/services/index.ts +1 -0
  79. package/src/services/logging/redaction.ts +50 -0
  80. package/src/services/mandates/index.ts +4 -2
  81. package/src/services/mandates/policy.ts +20 -0
  82. package/src/services/notifications/browser.ts +48 -15
  83. package/src/services/notifications/dispatcher.ts +31 -1
  84. package/src/services/notifications/platform.ts +32 -4
  85. package/src/services/notifications/preview.ts +23 -0
  86. package/src/services/notifications/quiet.ts +153 -0
  87. package/src/services/notifications/user.ts +6 -0
  88. package/src/services/outbox.ts +24 -9
  89. package/src/services/outgoing-mail/index.ts +33 -5
  90. package/src/services/outgoing-mail/messages.ts +60 -19
  91. package/src/services/outgoing-mail/store.ts +63 -4
  92. package/src/services/pdf/template-preview.ts +2 -1
  93. package/src/services/settings/app.ts +3 -0
  94. package/src/services/settings/store.ts +4 -1
  95. package/src/services/weather/geo.ts +6 -2
  96. package/src/shared/ai-platform-prompt.ts +2 -2
  97. package/src/shared/network-address.ts +1 -0
  98. package/src/shared/redirect.ts +2 -1
  99. package/src/shared/template-rendering.ts +120 -7
  100. package/src/ssr/AppBadge.tsx +30 -0
  101. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  102. package/src/ssr/IslandError.tsx +33 -0
  103. package/src/ssr/Layout.tsx +3 -0
  104. package/src/ssr/LayoutHelp.tsx +4 -2
  105. package/src/ssr/RailApps.island.tsx +9 -2
  106. package/src/ssr/app-badges.ts +93 -0
  107. package/src/ssr/rail-messages.ts +4 -0
  108. package/src/ssr/rail-navigation.ts +2 -0
  109. package/src/styles/effects.css +165 -0
  110. package/src/styles/file-chooser.css +25 -0
  111. package/src/styles/utilities-markdown-table.css +5 -1
  112. package/src/styles/utilities-navigation.css +41 -0
@@ -2,6 +2,7 @@ import { sql } from "bun";
2
2
  import { z } from "zod";
3
3
  import { decryptSecret } from "../secrets";
4
4
  import { getNotificationChannel } from "./channels";
5
+ import { quietStateForEvent } from "./quiet";
5
6
 
6
7
  const MAX_DELIVERY_ATTEMPTS = 5;
7
8
  const BASE_RETRY_MS = 2_000;
@@ -19,12 +20,40 @@ type DeliveryRow = {
19
20
  };
20
21
 
21
22
  export type DeliveryAttemptResult =
22
- | { status: "skipped" | "delivered" | "failed" }
23
+ | { status: "skipped" | "delivered" | "suppressed" | "failed" }
23
24
  | { status: "pending"; retryAfterMs: number }
24
25
  | { status: "retry"; retryAfterMs: number; error: string };
25
26
 
26
27
  const retryDelay = (attempt: number): number => Math.min(BASE_RETRY_MS * 2 ** Math.max(0, attempt - 1), MAX_RETRY_MS);
27
28
 
29
+ /**
30
+ * Do not disturb and quiet hours hold back recommended browser notifications when they would go out. The
31
+ * notification is dropped, not postponed, so nothing arrives in a burst when the quiet time ends, and it is not
32
+ * rerouted: the event's waiting fallbacks end with it. Required deliveries are part of a protocol and still go.
33
+ */
34
+ const holdBackForQuietTime = async (delivery: DeliveryRow): Promise<boolean> => {
35
+ if (delivery.channel !== "browser" || delivery.required) return false;
36
+ const quiet = await quietStateForEvent(delivery.event_id);
37
+ if (!quiet?.active) return false;
38
+ const code = quiet.reason === "doNotDisturb" ? "do_not_disturb" : "quiet_hours";
39
+ const message = quiet.reason === "doNotDisturb" ? "Held back by do not disturb." : "Held back during quiet hours.";
40
+ // One transaction: a crash in between must not leave fallbacks waiting, with their payload, behind a dropped push.
41
+ await sql.begin(async (tx) => {
42
+ await tx`
43
+ UPDATE notifications.deliveries
44
+ SET status = 'suppressed', attempt_count = attempt_count - 1, next_attempt_at = NULL,
45
+ error_code = ${code}, error_message = ${message}, payload_encrypted = NULL, updated_at = now()
46
+ WHERE id = ${delivery.id}::uuid AND status = 'sending'
47
+ `;
48
+ await tx`
49
+ UPDATE notifications.deliveries
50
+ SET status = 'suppressed', error_code = ${code}, error_message = ${message}, payload_encrypted = NULL, updated_at = now()
51
+ WHERE event_id = ${delivery.event_id}::uuid AND required = false AND status = 'deferred'
52
+ `;
53
+ });
54
+ return true;
55
+ };
56
+
28
57
  const activateNextFallback = async (eventId: string): Promise<string[]> => {
29
58
  const delivered = await sql<{ exists: boolean }[]>`
30
59
  SELECT EXISTS (
@@ -85,6 +114,7 @@ export const processNotificationDelivery = async (
85
114
  if (!delivery) return { status: "skipped" };
86
115
 
87
116
  try {
117
+ if (await holdBackForQuietTime(delivery)) return { status: "suppressed" };
88
118
  const driver = getNotificationChannel(delivery.channel);
89
119
  if (!driver)
90
120
  throw Object.assign(new Error(`Notification channel "${delivery.channel}" is unavailable`), { code: "channel_unavailable" });
@@ -1,5 +1,6 @@
1
1
  import { sql } from "bun";
2
2
  import type { output, ZodType } from "zod";
3
+ import { isNotificationGroup, notificationGroupTag } from "../../contracts/notification-group";
3
4
  import type {
4
5
  BoundNotificationDefinition,
5
6
  EmailNotificationPresentation,
@@ -15,6 +16,7 @@ import { encryptSecret } from "../secrets";
15
16
  import { ensureNotificationDefinition } from "./catalog";
16
17
  import { getNotificationChannel, type NotificationDestination, type ResolvedNotificationRecipient } from "./channels";
17
18
  import { processNotificationDelivery } from "./dispatcher";
19
+ import { normalizeNotificationPreview } from "./preview";
18
20
  import { enqueueNotificationDeliveries, enqueueNotificationDelivery } from "./runtime";
19
21
 
20
22
  export type TypedNotificationDeliveryStatus = "deferred" | "pending" | "sending" | "delivered" | "suppressed" | "failed";
@@ -69,14 +71,38 @@ const preparationFailure = (input: { channel: string; required: boolean; routePr
69
71
  errorMessage: "Notification delivery could not be prepared.",
70
72
  });
71
73
 
72
- const validatePresentation = (presentation: NotificationPresentation): NotificationPresentation => {
74
+ const validatePresentation = (presentation: NotificationPresentation, appId: string): NotificationPresentation => {
73
75
  const title = presentation.title.trim();
74
76
  const body = presentation.body?.trim();
77
+ if (presentation.preview !== undefined && typeof presentation.preview !== "string") {
78
+ throw new Error("Notification preview must be a string");
79
+ }
80
+ const preview = presentation.preview === undefined ? undefined : normalizeNotificationPreview(presentation.preview);
75
81
  if (!title) throw new Error("Notification title is required");
76
82
  if (title.length > 200) throw new Error("Notification title must not exceed 200 characters");
77
83
  if (body && body.length > 4_000) throw new Error("Notification body must not exceed 4000 characters");
78
84
  const targetHref = presentation.targetHref ? validateNotificationTargetHref(presentation.targetHref) : undefined;
79
- return { title, ...(body ? { body } : {}), ...(targetHref ? { targetHref } : {}) };
85
+ if (presentation.group !== undefined && !isNotificationGroup(presentation.group)) {
86
+ throw new Error(
87
+ "Notification group must contain 1 to 128 characters using only letters, digits, dots, underscores, colons, or hyphens",
88
+ );
89
+ }
90
+ if (presentation.group !== undefined && notificationGroupTag(appId, presentation.group) === null) {
91
+ throw new Error(
92
+ "Notification group requires an app ID that starts with a lowercase letter and contains only lowercase letters, digits, and hyphens",
93
+ );
94
+ }
95
+ if (presentation.badge !== undefined && (!Number.isSafeInteger(presentation.badge) || presentation.badge < 0)) {
96
+ throw new Error("Notification badge must be a non-negative safe integer");
97
+ }
98
+ return {
99
+ title,
100
+ ...(body ? { body } : {}),
101
+ ...(preview ? { preview } : {}),
102
+ ...(targetHref ? { targetHref } : {}),
103
+ ...(presentation.group !== undefined ? { group: presentation.group } : {}),
104
+ ...(presentation.badge !== undefined ? { badge: presentation.badge } : {}),
105
+ };
80
106
  };
81
107
 
82
108
  const resolveRecipient = async (
@@ -172,6 +198,8 @@ const prepareChannel = async (input: {
172
198
 
173
199
  try {
174
200
  const emailPresentation = input.channel === "email" ? await input.emailPresentation?.() : undefined;
201
+ const { preview: _preview, ...otherPresentation } = input.presentation;
202
+ const presentation = input.channel === "browser" ? input.presentation : otherPresentation;
175
203
  return await Promise.all(
176
204
  destinations.map(async (destination) => ({
177
205
  channel: input.channel,
@@ -179,7 +207,7 @@ const prepareChannel = async (input: {
179
207
  destinationKey: destination.key,
180
208
  destinationLabel: destination.label,
181
209
  payloadEncrypted: await encryptSecret(
182
- driver.createPayload({ presentation: input.presentation, email: emailPresentation, destination, event: input.event }),
210
+ driver.createPayload({ presentation, email: emailPresentation, destination, event: input.event }),
183
211
  ),
184
212
  required: input.required,
185
213
  routePriority: input.routePriority,
@@ -241,7 +269,7 @@ export const sendTypedNotification = async <
241
269
 
242
270
  const data: output<S> = definition.data.parse(input.data);
243
271
  const renderContext = { locale: normalizeLocale(input.locale) };
244
- const presentation = validatePresentation(await definition.render(data, renderContext));
272
+ const presentation = validatePresentation(await definition.render(data, renderContext), definition.appId);
245
273
  const resolved = await resolveRecipient(input.recipient);
246
274
  const candidateEventId = crypto.randomUUID();
247
275
  await ensureNotificationDefinition(definition);
@@ -0,0 +1,23 @@
1
+ export const NOTIFICATION_PREVIEW_LIMIT = 200;
2
+ const PREVIEW_INPUT_LIMIT = 1_000;
3
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
4
+
5
+ export const normalizeNotificationPreview = (value: string): string | undefined => {
6
+ let prefix = value.slice(0, PREVIEW_INPUT_LIMIT);
7
+ const clipped = value.length > prefix.length;
8
+ if (clipped) {
9
+ // A boundary at or before the bound depends on at most one following code point (two UTF-16 units).
10
+ const last = segmenter.segment(value.slice(0, PREVIEW_INPUT_LIMIT + 2)).containing(PREVIEW_INPUT_LIMIT - 1);
11
+ if (last && last.index + last.segment.length > PREVIEW_INPUT_LIMIT) prefix = prefix.slice(0, last.index);
12
+ }
13
+ const text = prefix.replace(/[\s\p{Cc}]+/gu, " ").trim();
14
+ if (!text) return undefined;
15
+ if (!clipped && text.length <= NOTIFICATION_PREVIEW_LIMIT) return text;
16
+
17
+ let end = 0;
18
+ for (const { segment, index } of segmenter.segment(text)) {
19
+ if (index + segment.length > NOTIFICATION_PREVIEW_LIMIT - 1) break;
20
+ end = index + segment.length;
21
+ }
22
+ return `${text.slice(0, end).trimEnd()}…`;
23
+ };
@@ -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;
@@ -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;
@@ -12,10 +12,11 @@ import { logger } from "./logging";
12
12
  * It works in batches: claims of one outbox (table and `where`) take turns, a
13
13
  * claim takes rows in `sequence` order with several rows per ordering key, and
14
14
  * the dispatcher publishes keys concurrently and the rows of one key in order,
15
- * then completes the batch in one statement. A row that never failed is due at
16
- * once. Claims stay proportional to the batch with an index on the `where`
17
- * columns and `sequence`, and one on the `where` columns and `orderBy` limited
18
- * to `claimed_until IS NOT NULL OR attempts > 0`.
15
+ * then completes the batch in one statement. A batch starts no publish after
16
+ * its claim has ended, because the next claim takes its rows. A row that never
17
+ * failed is due at once. Claims stay proportional to the batch with an index on
18
+ * the `where` columns and `sequence`, and one on the `where` columns and
19
+ * `orderBy` limited to `claimed_until IS NOT NULL OR attempts > 0`.
19
20
  *
20
21
  * Required columns: `id uuid`, `attempts int`, `next_attempt_at timestamptz`,
21
22
  * `claimed_until timestamptz`, `last_error text`, and the `orderBy` and
@@ -52,7 +53,9 @@ export type PgOutboxConfig<Row extends OutboxRow> = {
52
53
  };
53
54
 
54
55
  export type PgOutbox<Row extends OutboxRow> = {
56
+ /** @internal Test seam; applications use `reconcile`, `notify`, `start`, and `stop`. */
55
57
  claim(limit?: number): Promise<Row[]>;
58
+ /** @internal Test seam; it publishes one row outside the order of its key. */
56
59
  dispatch(row: Row, publish?: (row: Row) => Promise<unknown>): Promise<void>;
57
60
  /** Drain every claimable row; concurrent calls join the active pass. */
58
61
  reconcile(): Promise<number>;
@@ -78,7 +81,10 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
78
81
  const filters = Object.entries(config.where ?? {}).map(([column, value]) => ({ column: identifier(column, "filter column"), value }));
79
82
  const matches = (alias: "current" | "busy") =>
80
83
  filters.reduce((fragment, { column, value }) => sql`${fragment} AND ${sql.unsafe(alias)}.${column} = ${value}`, sql``);
81
- const claimLock = `${config.table}:${JSON.stringify(config.where ?? {})}`;
84
+ // Sorted, so the order of the `where` entries cannot give one outbox two locks. Descending keeps the
85
+ // lock that live outboxes (`kind`, `app_id`) always took, so replicas of two releases share it.
86
+ const where = Object.entries(config.where ?? {}).sort(([left], [right]) => (left < right ? 1 : -1));
87
+ const claimLock = `${config.table}:${JSON.stringify(Object.fromEntries(where))}`;
82
88
 
83
89
  /**
84
90
  * Claims rows in sequence order, several per key, and never a row of a key
@@ -151,19 +157,26 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
151
157
  }
152
158
  };
153
159
 
154
- /** Publishes a claimed batch: keys concurrently, the rows of one key in order. A failed row releases the rest of its key. */
155
- const dispatchBatch = async (rows: Row[]) => {
160
+ /**
161
+ * Publishes a claimed batch: keys concurrently, the rows of one key in order. A failed row releases the rest of its key.
162
+ * A run starts no publish once the claim has ended (`claimEnds`, monotonic): another replica may own the rest by then.
163
+ */
164
+ const dispatchBatch = async (rows: Row[], claimEnds: number) => {
156
165
  const delivered: string[] = [];
157
166
  const runs = Map.groupBy(rows, (row) => row[runKey]);
158
167
  const results = await Promise.allSettled(
159
168
  [...runs.values()].map(async (run) => {
160
169
  for (const [index, row] of run.entries()) {
170
+ if (performance.now() >= claimEnds) return;
161
171
  try {
162
172
  await config.publish(row);
163
173
  } catch (error) {
164
174
  await retryLater(row, error);
165
175
  const rest = run.slice(index + 1).map((later) => later.id);
166
- if (rest.length > 0) await sql`UPDATE ${table} SET claimed_until = NULL WHERE id = ANY(${sql.array(rest, "uuid")})`;
176
+ // Past the claim, the rest is free already, and releasing it could end another replica's claim.
177
+ if (rest.length > 0 && performance.now() < claimEnds) {
178
+ await sql`UPDATE ${table} SET claimed_until = NULL WHERE id = ANY(${sql.array(rest, "uuid")})`;
179
+ }
167
180
  return;
168
181
  }
169
182
  delivered.push(row.id);
@@ -185,8 +198,10 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
185
198
  let rows: Row[];
186
199
  do {
187
200
  reconcileRequested = false;
201
+ // Taken before the claim, so it ends no later than the `claimed_until` the claim sets.
202
+ const claimEnds = performance.now() + claimMs;
188
203
  rows = await claim();
189
- await dispatchBatch(rows);
204
+ await dispatchBatch(rows, claimEnds);
190
205
  processed += rows.length;
191
206
  } while (reconcileRequested || rows.length > 0);
192
207
  return processed;
@@ -19,11 +19,15 @@ import { outgoingMailMessages, recordMailBatch, recordMailSend } from "./message
19
19
  import { sendMail } from "./send";
20
20
  import { OutgoingMailError, outgoingMailStore } from "./store";
21
21
 
22
- const identity = (): Result<string, MailServiceError> => {
22
+ const identity = (read = false): Result<string, MailServiceError> => {
23
23
  const appId = getProcessApplicationId();
24
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 });
25
+ if (!getProcessPlatformPermissions().includes("mail:send") && !(read && getProcessPlatformPermissions().includes("mail:read")))
26
+ return fail({
27
+ code: "mail_not_declared",
28
+ message: read ? 'Declare "mail:send" or "mail:read" in platformPermissions.' : 'Declare platformPermissions: ["mail:send"].',
29
+ status: 403,
30
+ });
27
31
  return ok(appId);
28
32
  };
29
33
  const failure = (error: unknown): MailServiceError => {
@@ -39,6 +43,16 @@ const failure = (error: unknown): MailServiceError => {
39
43
  };
40
44
  };
41
45
  export const mail = {
46
+ async readableApps(): Promise<Result<string[], MailServiceError>> {
47
+ const caller = identity(true);
48
+ if (!caller.ok) return caller;
49
+ try {
50
+ const granted = getProcessPlatformPermissions().includes("mail:read") ? await outgoingMailStore.logAppsFor(caller.data) : [];
51
+ return ok([caller.data, ...granted.filter((appId) => appId !== caller.data && appId !== "core").sort()]);
52
+ } catch (error) {
53
+ return fail(failure(error));
54
+ }
55
+ },
42
56
  async enqueue(messages: MailMessage[]): Promise<Result<{ batchId: string; ids: string[] }, MailServiceError>> {
43
57
  const caller = identity();
44
58
  const parsed = caller.ok ? MailBatchSchema.safeParse(messages) : undefined;
@@ -97,14 +111,28 @@ export const mail = {
97
111
  }
98
112
  },
99
113
  async list(filter: MailFilter = {}, page: MailPageParams = {}): Promise<Result<MailPage, MailServiceError>> {
100
- const caller = identity();
114
+ const caller = identity(true);
101
115
  if (!caller.ok) return caller;
102
116
  const parsedFilter = MailFilterSchema.safeParse(filter);
103
117
  const parsedPage = MailPageParamsSchema.safeParse(page);
104
118
  if (!parsedFilter.success || !parsedPage.success)
105
119
  return fail({ code: "bad_input", message: "Invalid outgoing mail filter or page.", status: 400 });
106
120
  try {
107
- return ok(await outgoingMailMessages.list({ ...parsedFilter.data, app: caller.data }, parsedPage.data));
121
+ const { apps = [caller.data], recipient, ...filter } = parsedFilter.data;
122
+ const otherApps = apps.filter((appId) => appId !== caller.data);
123
+ if (otherApps.length) {
124
+ if (!getProcessPlatformPermissions().includes("mail:read"))
125
+ return fail({
126
+ code: "mail_not_declared",
127
+ message: 'Declare "mail:read" in platformPermissions to read other apps’ mail logs.',
128
+ status: 403,
129
+ });
130
+ const granted = await outgoingMailStore.logAppsFor(caller.data);
131
+ const denied = otherApps.find((appId) => !granted.includes(appId));
132
+ if (denied)
133
+ return fail({ code: "app_not_allowed", message: `This application may not read the mail log of "${denied}".`, status: 403 });
134
+ }
135
+ return ok(await outgoingMailMessages.list({ ...filter, apps, recipientExact: recipient }, parsedPage.data, false, caller.data));
108
136
  } catch (error) {
109
137
  return fail(failure(error));
110
138
  }
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import {
5
5
  type AdminMailFilter,
6
6
  type AdminMailRecord,
7
+ type MailFilter,
7
8
  type MailMessage,
8
9
  type MailPage,
9
10
  type MailPageParams,
@@ -86,16 +87,17 @@ type LogRow = Pick<
86
87
  | "sent_at"
87
88
  | "content_purged_at"
88
89
  >;
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,
90
+ const logColumns = (db: SQL, ownApp?: string) => db`id, app_id, profile_key, batch_id, ref_scope, ref_id, to_addresses, subject,
91
+ ${ownApp === undefined ? db`NULL::text AS text_body` : db`CASE WHEN app_id = ${ownApp} THEN text_body END AS text_body`}, attachments, status, error_code, error_message, smtp_response,
91
92
  failures, attempt_count, actor_id, actor_name, created_at, created_at::text AS cursor_created_at, sent_at, content_purged_at`;
92
93
  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
+ const [row] = await sql<LogRow[]>`SELECT ${logColumns(sql)} FROM outgoing_mail.messages WHERE id = ${id}::uuid`;
94
95
  return row ? adminMessageRecord(row) : undefined;
95
96
  };
96
97
  const date = (value: Date | string) => new Date(value).toISOString();
97
98
  export const messageRecord = (row: LogRow): MailRecord => ({
98
99
  id: row.id,
100
+ appId: row.app_id,
99
101
  profile: row.profile_key,
100
102
  to: row.to_addresses,
101
103
  subject: row.subject,
@@ -329,28 +331,67 @@ export const decodeMailCursor = (cursor?: string) => {
329
331
  throw new OutgoingMailError("bad_input", "Invalid outgoing mail cursor.");
330
332
  }
331
333
  };
334
+ type LogFilter = AdminMailFilter & Pick<MailFilter, "apps"> & { recipientExact?: string };
335
+ /**
336
+ * The longest one search of the send log may run, the same budget as a Mail search; its statements share
337
+ * it. A broad search on a large log, or one that runs before Core has built the search indexes, could
338
+ * otherwise read every record on every page.
339
+ */
340
+ const SEARCH_BUDGET_MS = 5_000;
341
+ const searchTooBroad = () =>
342
+ new OutgoingMailError("bad_input", "This search took too long. Narrow it with a longer term, fewer apps, or a shorter time range.");
343
+ const isStatementTimeout = (error: unknown) => {
344
+ const candidate = error as { code?: unknown; errno?: unknown } | null;
345
+ return candidate?.errno === "57014" || candidate?.code === "57014";
346
+ };
347
+ const withinSearchBudget = async <T>(read: (tx: SQL, limit: () => Promise<void>) => Promise<T>): Promise<T> => {
348
+ const deadline = Date.now() + SEARCH_BUDGET_MS;
349
+ try {
350
+ return await sql.begin((tx) =>
351
+ read(tx, async () => {
352
+ const remainingMs = Math.floor(deadline - Date.now());
353
+ if (remainingMs < 1) throw searchTooBroad();
354
+ await tx`SELECT set_config('statement_timeout', ${`${remainingMs}ms`}, true)`;
355
+ }),
356
+ );
357
+ } catch (error) {
358
+ throw isStatementTimeout(error) ? searchTooBroad() : error;
359
+ }
360
+ };
332
361
  const list = async (
333
- filter: AdminMailFilter,
362
+ filter: LogFilter,
334
363
  page: MailPageParams = {},
335
364
  metadata = false,
365
+ ownApp?: string,
336
366
  ): Promise<MailPage<MailRecord | AdminMailRecord>> => {
337
367
  const cursor = decodeMailCursor(page.cursor);
338
368
  const perPage = page.perPage ?? 50;
339
369
  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}`;
370
+ const where = (db: SQL) => db`WHERE TRUE
371
+ ${filter.apps === undefined ? db`` : db`AND app_id = ANY(${toPgTextArray(filter.apps)}::text[])`}
372
+ ${filter.app === undefined ? db`` : db`AND app_id = ${filter.app}`}
373
+ ${filter.profile === undefined ? db`` : db`AND profile_key = ${filter.profile}`}
374
+ ${filter.ids === undefined ? db`` : db`AND id = ANY(${toPgUuidArray(filter.ids)}::uuid[])`}
375
+ ${filter.batchId === undefined ? db`` : db`AND batch_id = ${filter.batchId}::uuid`}
376
+ ${filter.status === undefined ? db`` : db`AND status = ANY(${toPgTextArray(filter.status)}::text[])`}
377
+ ${filter.since === undefined ? db`` : db`AND created_at >= ${filter.since}::timestamptz`}
378
+ ${filter.until === undefined ? db`` : db`AND created_at < ${filter.until}::timestamptz`}
379
+ ${filter.q === undefined ? db`` : db`AND outgoing_mail.search_text(subject, to_addresses) ILIKE ${`%${escapeLikePattern(filter.q.toLowerCase())}%`}`}
380
+ ${filter.recipientExact === undefined ? db`` : db`AND outgoing_mail.lower_addresses(to_addresses) @> ARRAY[pg_catalog.lower(${filter.recipientExact})]`}
381
+ ${filter.ref === undefined ? db`` : db`AND ref_scope = ${filter.ref.scope}`}
382
+ ${filter.ref?.id === undefined ? db`` : db`AND ref_id = ${filter.ref.id}`}
383
+ ${filter.recipient === undefined ? db`` : db`AND EXISTS(SELECT 1 FROM unnest(to_addresses) AS address WHERE address ILIKE ${`%${escapeLikePattern(filter.recipient)}%`})`}`;
384
+ const read = async (db: SQL, limit: () => Promise<void>) => {
385
+ await limit();
386
+ const [count] = await db<{ total: number }[]>`SELECT count(*)::int AS total FROM outgoing_mail.messages ${where(db)}`;
387
+ await limit();
388
+ const rows = await db<LogRow[]>`SELECT ${logColumns(db, metadata ? undefined : ownApp)} FROM outgoing_mail.messages ${where(db)}
389
+ ${cursor ? db`AND (created_at < ${cursor.createdAt}::timestamptz OR (created_at = ${cursor.createdAt}::timestamptz AND id > ${cursor.id}::uuid))` : db``}
390
+ ORDER BY created_at DESC, id LIMIT ${perPage + 1} OFFSET ${cursor ? 0 : (pageNumber - 1) * perPage}`;
391
+ return { total: count?.total ?? 0, rows };
392
+ };
393
+ const { total, rows } =
394
+ filter.q === undefined && filter.recipientExact === undefined ? await read(sql, async () => {}) : await withinSearchBudget(read);
354
395
  const hasNext = rows.length > perPage;
355
396
  const items = rows.slice(0, perPage);
356
397
  const last = items.at(-1);
@@ -358,7 +399,7 @@ const list = async (
358
399
  items: items.map(metadata ? adminMessageRecord : messageRecord),
359
400
  page: pageNumber,
360
401
  perPage,
361
- total: count?.total ?? 0,
402
+ total,
362
403
  hasNext,
363
404
  ...(hasNext && last
364
405
  ? {