@k2b/cloud 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/define-app.ts +8 -1
  3. package/src/_internal/process-identity.ts +7 -1
  4. package/src/_internal/registry-validation.ts +3 -0
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/ai/admin.ts +1 -0
  7. package/src/ai/browser-code-contracts.ts +46 -64
  8. package/src/ai/browser.ts +9 -1
  9. package/src/ai/capabilities.ts +82 -22
  10. package/src/ai/chat/blocks.tsx +16 -7
  11. package/src/ai/chat/builtin-tools.tsx +31 -23
  12. package/src/ai/chat/file-tools.tsx +4 -1
  13. package/src/ai/chat/live-turn.browser-harness.tsx +15 -5
  14. package/src/ai/chat/message-actions.tsx +123 -101
  15. package/src/ai/chat/message-utils.ts +30 -1
  16. package/src/ai/chat/messages.ts +206 -2
  17. package/src/ai/chat/presentation.tsx +94 -28
  18. package/src/ai/chat/tool-groups.ts +1 -1
  19. package/src/ai/chat/turn-error.ts +21 -0
  20. package/src/ai/chat/turn-view.tsx +102 -16
  21. package/src/ai/chat/user-message.tsx +19 -14
  22. package/src/ai/chat/visual-tools.tsx +1 -1
  23. package/src/ai/client/controller.ts +83 -37
  24. package/src/ai/client/file-source.ts +20 -3
  25. package/src/ai/client/projection.ts +7 -2
  26. package/src/ai/code-mode-skill.ts +32 -36
  27. package/src/ai/code-runtime-tools.ts +14 -1
  28. package/src/ai/code-source-contracts.ts +56 -6
  29. package/src/ai/code-source-tools.ts +10 -3
  30. package/src/ai/credentials.ts +17 -3
  31. package/src/ai/data-analysis-skill.ts +3 -3
  32. package/src/ai/default-tools.ts +15 -16
  33. package/src/ai/executor.ts +281 -141
  34. package/src/ai/file-context.ts +14 -2
  35. package/src/ai/file-tools.ts +17 -3
  36. package/src/ai/files-store.ts +134 -11
  37. package/src/ai/grids-skill.ts +2 -2
  38. package/src/ai/index.ts +9 -0
  39. package/src/ai/memories.ts +14 -0
  40. package/src/ai/migrate.ts +125 -0
  41. package/src/ai/model-request-settings.ts +98 -0
  42. package/src/ai/open-tool-calls.ts +87 -0
  43. package/src/ai/protocol.ts +6 -0
  44. package/src/ai/provider.ts +7 -1
  45. package/src/ai/quota-provider.ts +2 -2
  46. package/src/ai/request-headers.ts +117 -0
  47. package/src/ai/routes.ts +40 -6
  48. package/src/ai/run-timeout.ts +4 -5
  49. package/src/ai/runtime.ts +9 -1
  50. package/src/ai/settings.ts +19 -2
  51. package/src/ai/skill-seeds.ts +35 -7
  52. package/src/ai/skills.ts +26 -0
  53. package/src/ai/solid.ts +1 -1
  54. package/src/ai/store.ts +155 -71
  55. package/src/ai/stream.ts +180 -37
  56. package/src/ai/structured.ts +20 -5
  57. package/src/ai/system-prompt.ts +8 -0
  58. package/src/ai/tool-call-names.ts +45 -0
  59. package/src/ai/turn-failure.ts +100 -0
  60. package/src/ai/turn-policy.ts +248 -0
  61. package/src/ai/types.ts +52 -4
  62. package/src/api/admin-ai-quotas.ts +36 -1
  63. package/src/api/admin-core-settings.ts +16 -23
  64. package/src/api/admin-outgoing-mail.ts +242 -0
  65. package/src/api/index.ts +2 -0
  66. package/src/browser/FileChooser.tsx +11 -0
  67. package/src/browser/file-chooser-messages.ts +2 -0
  68. package/src/cli/admin/ai-quotas.ts +70 -1
  69. package/src/cli/admin/index.ts +8 -0
  70. package/src/cli/admin/notifications.ts +6 -0
  71. package/src/cli/admin/outgoing-mail.ts +215 -0
  72. package/src/contracts/app.ts +2 -0
  73. package/src/contracts/index.ts +1 -0
  74. package/src/contracts/outgoing-mail.ts +265 -0
  75. package/src/contracts/registry.ts +2 -0
  76. package/src/services/help/store.ts +2 -1
  77. package/src/services/index.ts +13 -0
  78. package/src/services/notifications/batches.ts +139 -79
  79. package/src/services/notifications/channels.ts +33 -13
  80. package/src/services/notifications/dispatcher.ts +57 -5
  81. package/src/services/notifications/email-frame.fixture.html +51 -0
  82. package/src/services/notifications/{email.ts → email-frame.ts} +12 -45
  83. package/src/services/notifications/email-mail.ts +101 -0
  84. package/src/services/notifications/index.ts +49 -36
  85. package/src/services/notifications/observability.ts +9 -1
  86. package/src/services/notifications/platform.ts +1 -1
  87. package/src/services/notifications/runtime.ts +9 -3
  88. package/src/services/outgoing-mail/admin.ts +84 -0
  89. package/src/services/outgoing-mail/attachments.ts +135 -0
  90. package/src/services/outgoing-mail/bulk.ts +11 -0
  91. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  92. package/src/services/outgoing-mail/drain.ts +60 -0
  93. package/src/services/outgoing-mail/enqueue.ts +180 -0
  94. package/src/services/outgoing-mail/index.ts +112 -0
  95. package/src/services/outgoing-mail/message.ts +14 -0
  96. package/src/services/outgoing-mail/messages.ts +431 -0
  97. package/src/services/outgoing-mail/retention.ts +48 -0
  98. package/src/services/outgoing-mail/runtime.ts +72 -0
  99. package/src/services/outgoing-mail/send.ts +130 -0
  100. package/src/services/outgoing-mail/store.ts +397 -0
  101. package/src/services/outgoing-mail/sync.ts +39 -0
  102. package/src/services/outgoing-mail/test-send.ts +40 -0
  103. package/src/services/outgoing-mail/transport.ts +17 -0
  104. package/src/services/pdf/markdown.ts +22 -4
  105. package/src/services/postgres.ts +15 -0
  106. package/src/services/settings/core-settings.ts +19 -38
  107. package/src/services/settings/store.ts +5 -1
  108. package/src/shared/ai-model-request-settings.ts +21 -0
  109. package/src/shared/ai-platform-prompt.ts +49 -5
  110. package/src/shared/ai-request-options.ts +185 -0
  111. package/src/shared/markdown/extensions/links.ts +28 -20
  112. package/src/shared/markdown/index.ts +14 -5
  113. package/src/shared/markdown/shared.ts +0 -7
  114. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  115. package/src/ssr/admin-navigation.ts +1 -1
  116. package/src/ssr/platform-messages.ts +3 -1
  117. package/src/ssr/workspace-navigation.ts +7 -1
  118. package/src/styles/effects.css +15 -17
  119. package/src/styles/tokens.css +2 -0
  120. package/src/styles/utilities-markdown-editor.css +4 -39
  121. package/src/styles/utilities-markdown-table.css +14 -17
@@ -1,4 +1,5 @@
1
1
  import type { Sync } from "@k2b/sync";
2
+ import type { PlatformPermission } from "./outgoing-mail";
2
3
  import type { AppRegistryHelpSummary } from "./registry";
3
4
  import type { Role } from "./shared";
4
5
  import type { DashboardWidgetPresentation } from "./widgets";
@@ -97,6 +98,7 @@ export type AppPwaPart = {
97
98
  };
98
99
 
99
100
  export type AppMeta = {
101
+ platformPermissions?: readonly PlatformPermission[];
100
102
  id: string;
101
103
  name: string;
102
104
  icon: string;
@@ -8,6 +8,7 @@ export * from "./commands";
8
8
  export * from "./contact-directory";
9
9
  export * from "./file-provider";
10
10
  export * from "./notification-types";
11
+ export * from "./outgoing-mail";
11
12
  export * from "./posix";
12
13
  export * from "./profile";
13
14
  export * from "./pwa";
@@ -0,0 +1,265 @@
1
+ import type { PageParams, Paginated, ServiceError } from "@k2b/stdlib";
2
+ import { z } from "zod";
3
+ import { type RequestActor, ServiceAccountSchema, UserSchema } from "./shared";
4
+
5
+ export const PlatformPermissionSchema = z.enum(["mail:send"]);
6
+ export type PlatformPermission = z.infer<typeof PlatformPermissionSchema>;
7
+ export const MailRetentionSchema = z.object({ contentDays: z.int().min(1).max(36500), recordDays: z.int().min(1).max(36500) }).strict();
8
+ export type MailRetention = z.infer<typeof MailRetentionSchema>;
9
+
10
+ export const MailProfileKeySchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
11
+ const headerText = z
12
+ .string()
13
+ .trim()
14
+ .min(1)
15
+ .max(120)
16
+ .refine((value) => !/[\r\n\0]/.test(value), "Header must not contain control characters");
17
+ export const MailImapInputSchema = z
18
+ .object({
19
+ host: z
20
+ .string()
21
+ .trim()
22
+ .min(1)
23
+ .max(253)
24
+ .refine((value) => !/[\s/@\0]/.test(value), "Use an IMAP hostname"),
25
+ port: z.int().min(1).max(65535),
26
+ secure: z.boolean(),
27
+ user: z.string().trim().min(1).max(320),
28
+ password: z.string().max(16384).nullable().optional(),
29
+ folder: z
30
+ .string()
31
+ .trim()
32
+ .min(1)
33
+ .max(200)
34
+ .refine((value) => !/[\x00-\x1f\x7f]/.test(value), "Folder must not contain control characters"),
35
+ })
36
+ .strict();
37
+ export const MailProfileInputSchema = z
38
+ .object({
39
+ name: headerText,
40
+ fromAddress: z.email().max(320),
41
+ fromName: headerText.nullable(),
42
+ smtpHost: z
43
+ .string()
44
+ .trim()
45
+ .min(1)
46
+ .max(253)
47
+ .refine((value) => !/[\s/@\0]/.test(value), "Use an SMTP hostname"),
48
+ smtpPort: z.int().min(1).max(65535),
49
+ smtpSecure: z.boolean(),
50
+ smtpUser: z.string().max(320).nullable(),
51
+ smtpPassword: z.string().max(16384).nullable().optional(),
52
+ imap: MailImapInputSchema.nullable().optional(),
53
+ pacePerMinute: z.int().min(1).max(6000),
54
+ dailyRecipientLimit: z.int().min(1).max(2147483647).nullable(),
55
+ maxAttachmentBytes: z.int().min(1).max(26214400),
56
+ revision: z.int().min(1).max(2147483647).optional(),
57
+ })
58
+ .strict();
59
+ export type MailProfileInput = z.infer<typeof MailProfileInputSchema>;
60
+ export const MailAppAccessSchema = z.discriminatedUnion("mode", [
61
+ z.object({ mode: z.literal("default") }).strict(),
62
+ z.object({ mode: z.literal("selected"), profiles: z.array(MailProfileKeySchema) }).strict(),
63
+ ]);
64
+ export type MailAppAccess = z.infer<typeof MailAppAccessSchema>;
65
+ export const MailProfileSchema = z.object({
66
+ key: MailProfileKeySchema,
67
+ name: z.string(),
68
+ from: z.email(),
69
+ default: z.boolean(),
70
+ maxAttachmentBytes: z.int(),
71
+ quota: z.object({ dailyRecipients: z.int().nullable(), usedLast24h: z.int() }),
72
+ });
73
+ export type MailProfile = z.infer<typeof MailProfileSchema>;
74
+ export const MailErrorCodeSchema = z.enum([
75
+ "bad_input",
76
+ "profile_not_allowed",
77
+ "profile_required",
78
+ "quota_exceeded",
79
+ "backlog_full",
80
+ "attachments_too_large",
81
+ "attachment_storage_full",
82
+ "profile_removed",
83
+ "attachment_lost",
84
+ "cancelled_by_admin",
85
+ "message_unknown",
86
+ "batch_unknown",
87
+ "message_not_queued",
88
+ "mail_unavailable",
89
+ "mail_not_declared",
90
+ "profile_unknown",
91
+ "profile_exists",
92
+ "profile_is_default",
93
+ "revision_conflict",
94
+ "invalid_profile",
95
+ "smtp_failed",
96
+ ]);
97
+ export type MailErrorCode = z.infer<typeof MailErrorCodeSchema>;
98
+ export const MailBounceErrorCodeSchema = z.enum(["open_failed", "search_failed", "apply_failed", "save_failed", "interrupted"]);
99
+ export type MailBounceErrorCode = z.infer<typeof MailBounceErrorCodeSchema>;
100
+ export const AdminMailProfileSchema = MailProfileInputSchema.omit({ smtpPassword: true, revision: true, imap: true }).extend({
101
+ key: MailProfileKeySchema,
102
+ hasPassword: z.boolean(),
103
+ imap: MailImapInputSchema.omit({ password: true }).extend({ hasPassword: z.boolean() }).nullable(),
104
+ bounces: z.object({ checkedAt: z.string().nullable(), error: MailBounceErrorCodeSchema.nullable() }).nullable(),
105
+ isDefault: z.boolean(),
106
+ revision: z.int(),
107
+ createdAt: z.string(),
108
+ updatedAt: z.string(),
109
+ updatedBy: z.string().nullable(),
110
+ appCount: z.int(),
111
+ });
112
+ export type AdminMailProfile = z.infer<typeof AdminMailProfileSchema>;
113
+ export type AdminMailApp = {
114
+ appId: string;
115
+ name: string;
116
+ registered: boolean;
117
+ declared: boolean;
118
+ mode: "default" | "selected";
119
+ profiles: string[];
120
+ };
121
+
122
+ export const MailStatusSchema = z.enum(["queued", "sending", "sent", "failed", "bounced", "cancelled"]);
123
+ export type MailStatus = z.infer<typeof MailStatusSchema>;
124
+ export type MailAttachment = {
125
+ filename: string;
126
+ contentType: string;
127
+ content: Uint8Array | Blob | ReadableStream<Uint8Array>;
128
+ };
129
+ const body = z
130
+ .string()
131
+ .refine((value) => !value.includes("\0"), "Body must not contain NUL")
132
+ .max(512 * 1024)
133
+ .refine((value) => new TextEncoder().encode(value).byteLength <= 512 * 1024, "Body exceeds 512 KiB");
134
+ const safeHeader = z.string().refine((value) => !/[\r\n\0]/.test(value), "Header must not contain control characters");
135
+ const allowedHeaders = new Set([
136
+ "list-id",
137
+ "list-unsubscribe",
138
+ "list-unsubscribe-post",
139
+ "in-reply-to",
140
+ "references",
141
+ "auto-submitted",
142
+ "precedence",
143
+ ]);
144
+ export const MailHeadersSchema = z.record(z.string(), safeHeader).refine((headers) => {
145
+ let bytes = 0;
146
+ const seen = new Set<string>();
147
+ for (const [name, value] of Object.entries(headers)) {
148
+ const lower = name.toLowerCase();
149
+ if (!/^[\x21-\x39\x3B-\x7E]+$/.test(name) || seen.has(lower)) return false;
150
+ seen.add(lower);
151
+ if (!(lower.startsWith("x-") && !lower.startsWith("x-cloud-")) && !allowedHeaders.has(lower)) return false;
152
+ bytes += new TextEncoder().encode(`${name}: ${value}\r\n`).byteLength;
153
+ }
154
+ return bytes <= 8192;
155
+ }, "Invalid mail headers or headers exceed 8 KiB");
156
+ const referenceText = z
157
+ .string()
158
+ .min(1)
159
+ .max(200)
160
+ .refine((value) => !value.includes("\0"), "Reference must not contain NUL");
161
+ const ref = z.object({ scope: referenceText, id: referenceText }).strict();
162
+ const mailAddress = z.email({ pattern: z.regexes.html5Email }).max(320);
163
+ export const MailMessageSchema = z
164
+ .object({
165
+ to: z.array(mailAddress).min(1).max(50),
166
+ subject: safeHeader.max(998),
167
+ text: body,
168
+ html: body.optional(),
169
+ fromName: safeHeader.max(998).optional(),
170
+ replyTo: mailAddress.optional(),
171
+ attachments: z
172
+ .array(
173
+ z
174
+ .object({
175
+ filename: safeHeader.min(1).max(998),
176
+ contentType: safeHeader.min(1).max(998),
177
+ content: z.custom<MailAttachment["content"]>(
178
+ (value) => value instanceof Uint8Array || value instanceof Blob || value instanceof ReadableStream,
179
+ ),
180
+ })
181
+ .strict(),
182
+ )
183
+ .max(20)
184
+ .optional(),
185
+ headers: MailHeadersSchema.optional(),
186
+ profile: MailProfileKeySchema.optional(),
187
+ ref: ref.optional(),
188
+ actor: z
189
+ .custom<RequestActor>((value) => {
190
+ if (!value || typeof value !== "object" || !("kind" in value)) return false;
191
+ if (value.kind === "user" && "user" in value) return UserSchema.safeParse(value.user).success;
192
+ return (
193
+ value.kind === "service_account" && "serviceAccount" in value && ServiceAccountSchema.safeParse(value.serviceAccount).success
194
+ );
195
+ })
196
+ .optional(),
197
+ key: z
198
+ .string()
199
+ .min(1)
200
+ .max(200)
201
+ .refine((value) => !value.includes("\0"), "Key must not contain NUL")
202
+ .optional(),
203
+ })
204
+ .strict();
205
+ export type MailMessage = z.infer<typeof MailMessageSchema>;
206
+ export const MailBatchSchema = z
207
+ .array(MailMessageSchema)
208
+ .min(1)
209
+ .max(1000)
210
+ .refine((messages) => {
211
+ const keys = messages.flatMap((message) => (message.key === undefined ? [] : [message.key]));
212
+ return new Set(keys).size === keys.length;
213
+ }, "Duplicate outgoing mail keys in a batch");
214
+ export const MailFilterSchema = z
215
+ .object({
216
+ ref: ref.partial({ id: true }).optional(),
217
+ ids: z.array(z.uuid()).max(100).optional(),
218
+ batchId: z.uuid().optional(),
219
+ status: z.array(MailStatusSchema).max(6).optional(),
220
+ since: z.iso.datetime({ offset: true }).optional(),
221
+ })
222
+ .strict();
223
+ export type MailFilter = z.infer<typeof MailFilterSchema>;
224
+ export const MailRecordSchema = z.object({
225
+ id: z.uuid(),
226
+ batchId: z.uuid().optional(),
227
+ profile: z.string(),
228
+ ref: ref.optional(),
229
+ to: z.array(z.string()),
230
+ subject: z.string(),
231
+ text: z.string().optional(),
232
+ attachments: z.array(z.object({ filename: z.string(), contentType: z.string(), size: z.int().nonnegative(), sha256: z.string() })),
233
+ status: MailStatusSchema,
234
+ error: z.string().optional(),
235
+ failures: z.array(z.object({ recipient: z.string(), reason: z.string(), at: z.string() })),
236
+ attempts: z.int(),
237
+ actor: z.object({ id: z.string(), name: z.string() }).optional(),
238
+ createdAt: z.string(),
239
+ sentAt: z.string().optional(),
240
+ contentPurgedAt: z.string().optional(),
241
+ });
242
+ export type MailRecord = z.infer<typeof MailRecordSchema>;
243
+ export const AdminMailRecordSchema = MailRecordSchema.omit({ text: true }).extend({
244
+ appId: z.string(),
245
+ errorCode: z.string().optional(),
246
+ response: z.string().optional(),
247
+ });
248
+ export type AdminMailRecord = z.infer<typeof AdminMailRecordSchema>;
249
+ /** Extends the shared offset-page shape with stable keyset navigation. */
250
+ export type MailPageParams = PageParams & { cursor?: string };
251
+ export type MailPage<T = MailRecord> = Paginated<T> & { nextCursor?: string };
252
+ export const MailPageParamsSchema = z
253
+ .object({ page: z.int().min(1).optional(), perPage: z.int().min(1).max(100).optional(), cursor: z.string().max(1024).optional() })
254
+ .strict();
255
+ export const AdminMailFilterSchema = MailFilterSchema.extend({
256
+ app: z.string().min(1).optional(),
257
+ profile: MailProfileKeySchema.optional(),
258
+ recipient: z.string().max(320).optional(),
259
+ });
260
+ export type AdminMailFilter = z.infer<typeof AdminMailFilterSchema>;
261
+ export type MailServiceError = ServiceError<MailErrorCode> & {
262
+ limit?: number;
263
+ used?: number;
264
+ requested?: number;
265
+ };
@@ -1,5 +1,6 @@
1
1
  import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPresentationCatalog, AppSearchLink } from "./app";
2
2
  import type { CapabilityManifest, CapabilityPresentationCatalog } from "./capabilities";
3
+ import type { PlatformPermission } from "./outgoing-mail";
3
4
  import type { Role } from "./shared";
4
5
  import type { DashboardWidgetPresentation } from "./widgets";
5
6
 
@@ -65,6 +66,7 @@ export type AppRuntimeMetadata = {
65
66
  };
66
67
 
67
68
  export type AppRegistryEntry = {
69
+ platformPermissions?: readonly PlatformPermission[];
68
70
  id: string;
69
71
  name: string;
70
72
  icon: string;
@@ -1,5 +1,6 @@
1
1
  import { type SQL, sql } from "bun";
2
2
  import { APP_REGISTRY_TTL_MS } from "../../_internal/registry";
3
+ import { ensureSchema } from "../postgres";
3
4
  import type { HelpCorpus, HelpMetadata } from "./types";
4
5
 
5
6
  // One expired app lease of grace after an app can no longer be discovered.
@@ -10,7 +11,7 @@ const searchConfigs = ["english", "german", "simple"] as const;
10
11
  export const migrateHelp = async (db: SQL = sql): Promise<void> => {
11
12
  await db.begin(async (tx) => {
12
13
  await tx`SELECT pg_advisory_xact_lock(hashtext('cloud:help:migrate'))`;
13
- await tx`CREATE SCHEMA IF NOT EXISTS help`.simple();
14
+ await ensureSchema(tx, "help");
14
15
  await tx`CREATE TABLE IF NOT EXISTS help.corpora (
15
16
  app_id text NOT NULL, manifest_hash text NOT NULL, base_locale text NOT NULL,
16
17
  last_seen_at timestamptz NOT NULL DEFAULT now(),
@@ -204,3 +204,16 @@ export { legalConsent } from "./legal-consent";
204
204
  export { railShortcuts } from "./rail-shortcuts";
205
205
 
206
206
  export type { WebVitalsOverview, WebVitalsQuery, WebVitalMetric, WebVitalName } from "./logging/web-vitals";
207
+
208
+ export { mail } from "./outgoing-mail";
209
+ export type {
210
+ MailProfile,
211
+ MailAttachment,
212
+ MailMessage,
213
+ MailFilter,
214
+ MailRecord,
215
+ MailStatus,
216
+ MailPageParams,
217
+ MailPage,
218
+ MailServiceError,
219
+ } from "../contracts/outgoing-mail";
@@ -3,12 +3,15 @@ import { err, fail, ok, type Result } from "@k2b/stdlib";
3
3
  import type { Worker } from "@k2b/sync";
4
4
  import { sql } from "bun";
5
5
  import { lazySync } from "../../_internal/process-sync";
6
+ import { type MailMessage, MailMessageSchema, type MailStatus, MailStatusSchema } from "../../contracts/outgoing-mail";
6
7
  import { markdown } from "../../shared/markdown";
7
8
  import { type AuditActor, audit } from "../audit";
8
9
  import { logger, trace } from "../logging";
10
+ import { enqueueMail } from "../outgoing-mail/enqueue";
11
+ import { outgoingMailStore } from "../outgoing-mail/store";
9
12
  import { parsePgJsonValue, toPgTextArray, toPgUuidArray } from "../postgres";
10
-
11
- import { sendEmail } from "./email";
13
+ import { prepareNotificationEmail } from "./email-frame";
14
+ import { normalizeNotificationMail, notificationMailError, notificationMailValidationError } from "./email-mail";
12
15
 
13
16
  const log = logger("notifications:batches");
14
17
  const CHUNK_SIZE = 100;
@@ -64,6 +67,8 @@ export type NotificationBatchRecipient = {
64
67
  profile: "user" | "guest";
65
68
  status: NotificationBatchRecipientStatus;
66
69
  notificationId: string | null;
70
+ outgoingMailId: string | null;
71
+ outgoingMailStatus: MailStatus | null;
67
72
  error: string | null;
68
73
  attemptCount: number;
69
74
  sentAt: string | null;
@@ -163,6 +168,8 @@ const mapRecipient = (row: BatchRow): NotificationBatchRecipient => ({
163
168
  profile: row.profile as "user" | "guest",
164
169
  status: row.status as NotificationBatchRecipientStatus,
165
170
  notificationId: row.notification_id as string | null,
171
+ outgoingMailId: typeof row.outgoing_mail_id === "string" ? row.outgoing_mail_id : null,
172
+ outgoingMailStatus: MailStatusSchema.nullable().parse(row.outgoing_mail_status ?? null),
166
173
  error: row.error as string | null,
167
174
  attemptCount: Number(row.attempt_count ?? 0),
168
175
  sentAt: row.sent_at ? (row.sent_at as Date).toISOString() : null,
@@ -210,29 +217,6 @@ const resolveCandidates = async (rawSelection: NotificationBatchSelection): Prom
210
217
  `;
211
218
  };
212
219
 
213
- const sendBatchEmail = async (params: {
214
- recipient: string;
215
- subject: string;
216
- rawHtml: string;
217
- sentBy?: string;
218
- }): Promise<{ id: string; status: "sent" | "error"; error?: string }> => {
219
- const rows = await sql<BatchRow[]>`
220
- INSERT INTO notifications.messages (type, recipient, subject, content, sent_by)
221
- VALUES ('email', ${params.recipient}, ${params.subject}, ${params.rawHtml}, ${params.sentBy ?? null})
222
- RETURNING id
223
- `;
224
- const id = rows[0]!.id as string;
225
- try {
226
- await sendEmail(params.recipient, params.subject, { rawHtml: params.rawHtml });
227
- await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL WHERE id = ${id}::uuid`;
228
- return { id, status: "sent" };
229
- } catch (error) {
230
- const message = error instanceof Error ? error.message : String(error);
231
- await sql`UPDATE notifications.messages SET error = ${message} WHERE id = ${id}::uuid`;
232
- return { id, status: "error", error: message };
233
- }
234
- };
235
-
236
220
  export const preview = async (selection: NotificationBatchSelection): Promise<NotificationBatchPreview> => {
237
221
  const candidates = await resolveCandidates(selection);
238
222
  return {
@@ -286,74 +270,147 @@ const refreshBatchCounters = async (batchId: string): Promise<NotificationBatch
286
270
  };
287
271
 
288
272
  const processBatchChunk = async (batchId: string): Promise<{ processed: number; remaining: number }> => {
289
- await sql`
273
+ const [batchRow] = await sql<BatchRow[]>`
290
274
  UPDATE notifications.batches
291
275
  SET status = 'running', started_at = COALESCE(started_at, now())
292
276
  WHERE id = ${batchId}::uuid AND status IN ('ready', 'running')
277
+ RETURNING *
293
278
  `;
279
+ if (!batchRow) return { processed: 0, remaining: 0 };
280
+ const batch = mapBatch(batchRow);
294
281
 
295
- const recipients = await sql<BatchRow[]>`
296
- UPDATE notifications.batch_recipients r
297
- SET status = 'sending', attempt_count = attempt_count + 1, updated_at = now()
298
- WHERE (r.batch_id, r.user_id) IN (
299
- SELECT batch_id, user_id
300
- FROM notifications.batch_recipients
301
- WHERE batch_id = ${batchId}::uuid
302
- AND recipient IS NOT NULL
303
- AND (
304
- status = 'pending'
305
- OR (status = 'sending' AND updated_at < now() - interval '5 minutes')
306
- )
307
- ORDER BY updated_at ASC
308
- LIMIT ${CHUNK_SIZE}
309
- FOR UPDATE SKIP LOCKED
282
+ // Settle a bounded page and its legacy message history in the same statement.
283
+ const settled = await sql<{ id: string }[]>`
284
+ WITH terminal AS (
285
+ SELECT r.user_id, r.outgoing_mail_id, m.status, m.sent_at, COALESCE(m.error_message, m.smtp_response, 'Outgoing mail ' || m.status, 'Outgoing mail record is no longer available.') AS error
286
+ FROM notifications.batch_recipients r
287
+ LEFT JOIN outgoing_mail.messages m ON m.id = r.outgoing_mail_id
288
+ WHERE r.batch_id = ${batchId}::uuid AND r.status = 'sending' AND r.outgoing_mail_id IS NOT NULL
289
+ AND (m.status IN ('sent', 'bounced', 'failed', 'cancelled') OR m.id IS NULL)
290
+ ORDER BY r.user_id LIMIT ${CHUNK_SIZE}
291
+ ), settled AS (
292
+ UPDATE notifications.batch_recipients r
293
+ SET status = CASE WHEN t.status IN ('sent', 'bounced') THEN 'sent' ELSE 'error' END,
294
+ sent_at = CASE WHEN t.status IN ('sent', 'bounced') THEN COALESCE(t.sent_at, now()) ELSE NULL END,
295
+ error = CASE WHEN t.status IN ('sent', 'bounced') THEN NULL ELSE t.error END, updated_at = now()
296
+ FROM terminal t
297
+ WHERE r.batch_id = ${batchId}::uuid AND r.user_id = t.user_id AND r.status = 'sending'
298
+ AND r.outgoing_mail_id = t.outgoing_mail_id
299
+ RETURNING r.notification_id, r.sent_at, r.error
310
300
  )
311
- RETURNING r.*
301
+ UPDATE notifications.messages m SET sent_at = s.sent_at, error = s.error
302
+ FROM settled s WHERE m.id = s.notification_id AND m.mail_generation = 0 RETURNING m.id
312
303
  `;
313
304
 
314
- const [batchRow] = await sql<BatchRow[]>`SELECT * FROM notifications.batches WHERE id = ${batchId}::uuid`;
315
- if (!batchRow || batchRow.status === "cancelled") return { processed: 0, remaining: 0 };
316
- const batch = mapBatch(batchRow);
305
+ const profile = (await outgoingMailStore.profilesForApp("core")).find((profile) => profile.default);
306
+ const claimSize =
307
+ profile?.quota.dailyRecipients == null
308
+ ? CHUNK_SIZE
309
+ : Math.max(0, Math.min(CHUNK_SIZE, profile.quota.dailyRecipients - profile.quota.usedLast24h));
310
+
311
+ type ClaimedRecipient = { user_id: string; recipient: string; notification_id: string | null };
312
+ // Persist a send generation before any external effect. Reclaim and worker
313
+ // retries reuse it; operator retries clear it and create a new generation.
314
+ const recipients = await sql.begin(async (tx) => {
315
+ const rows = await tx<ClaimedRecipient[]>`
316
+ UPDATE notifications.batch_recipients r
317
+ SET status = 'sending', attempt_count = attempt_count + 1, updated_at = now()
318
+ WHERE (r.batch_id, r.user_id) IN (
319
+ SELECT batch_id, user_id FROM notifications.batch_recipients
320
+ WHERE batch_id = ${batchId}::uuid AND recipient IS NOT NULL AND outgoing_mail_id IS NULL
321
+ AND (status = 'pending' OR (status = 'sending' AND updated_at < now() - interval '5 minutes'))
322
+ ORDER BY updated_at ASC LIMIT ${claimSize} FOR UPDATE SKIP LOCKED
323
+ )
324
+ RETURNING r.user_id, r.recipient, r.notification_id
325
+ `;
326
+ for (const row of rows) {
327
+ if (row.notification_id) continue;
328
+ const [message] = await tx<{ id: string }[]>`
329
+ INSERT INTO notifications.messages (type, recipient, subject, content, sent_by)
330
+ VALUES ('email', ${row.recipient}, ${batch.subject}, ${batch.bodyHtml}, ${batch.finalizedBy ?? batch.createdBy}::uuid)
331
+ RETURNING id
332
+ `;
333
+ if (!message) throw new Error("Notification batch message insert returned no row");
334
+ row.notification_id = message.id;
335
+ await tx`UPDATE notifications.batch_recipients SET notification_id = ${message.id}::uuid
336
+ WHERE batch_id = ${batchId}::uuid AND user_id = ${row.user_id}::uuid`;
337
+ }
338
+ return rows;
339
+ });
317
340
 
318
- for (const recipient of recipients) {
341
+ const failRecipients = async (rows: ClaimedRecipient[], failure: ReturnType<typeof notificationMailError>) => {
342
+ const generations = toPgUuidArray(rows.flatMap((recipient) => (recipient.notification_id ? [recipient.notification_id] : [])));
343
+ await sql`
344
+ WITH changed AS (
345
+ UPDATE notifications.batch_recipients
346
+ SET status = ${failure.retryable ? "pending" : "error"}, error = ${failure.retryable ? null : failure.message}, updated_at = now()
347
+ WHERE batch_id = ${batchId}::uuid AND notification_id = ANY(${generations}::uuid[])
348
+ AND status = 'sending' AND outgoing_mail_id IS NULL
349
+ RETURNING notification_id, error
350
+ )
351
+ UPDATE notifications.messages m SET error = c.error FROM changed c WHERE m.id = c.notification_id
352
+ `;
353
+ };
354
+ let invalidCount = 0;
355
+ if (recipients.length) {
319
356
  try {
320
- const result = await sendBatchEmail({
321
- recipient: recipient.recipient as string,
322
- subject: batch.subject,
323
- rawHtml: batch.bodyHtml,
324
- sentBy: batch.finalizedBy ?? batch.createdBy ?? undefined,
357
+ const frame = await prepareNotificationEmail({ rawHtml: batch.bodyHtml });
358
+ const shared = MailMessageSchema.pick({ subject: true, text: true, html: true }).safeParse({
359
+ subject: normalizeNotificationMail("", batch.subject).subject,
360
+ ...frame,
325
361
  });
326
- await sql`
327
- UPDATE notifications.batch_recipients
328
- SET
329
- status = ${result.status === "sent" ? "sent" : "error"},
330
- notification_id = ${result.id}::uuid,
331
- error = NULL,
332
- sent_at = ${result.status === "sent" ? sql`now()` : null},
333
- updated_at = now()
334
- WHERE batch_id = ${batchId}::uuid AND user_id = ${recipient.user_id}::uuid
335
- `;
362
+ if (!shared.success) throw notificationMailValidationError(shared.error);
363
+ const valid: { recipient: ClaimedRecipient; message: MailMessage }[] = [];
364
+ for (const recipient of recipients) {
365
+ const parsed = MailMessageSchema.safeParse({
366
+ ...normalizeNotificationMail(recipient.recipient, shared.data.subject),
367
+ ...shared.data,
368
+ key: `notification-message:${recipient.notification_id}`,
369
+ });
370
+ if (!parsed.success) {
371
+ await failRecipients([recipient], notificationMailError(notificationMailValidationError(parsed.error)));
372
+ invalidCount++;
373
+ } else valid.push({ recipient, message: parsed.data });
374
+ }
375
+ if (valid.length) {
376
+ const accepted = await enqueueMail(
377
+ "core",
378
+ valid.map(({ message }) => message),
379
+ { trustedHtml: true },
380
+ );
381
+ for (const [index, { recipient }] of valid.entries()) {
382
+ await sql`
383
+ WITH linked AS (
384
+ UPDATE notifications.batch_recipients SET outgoing_mail_id = ${accepted.ids[index]}::uuid, updated_at = now()
385
+ WHERE batch_id = ${batchId}::uuid AND user_id = ${recipient.user_id}::uuid
386
+ AND notification_id = ${recipient.notification_id}::uuid AND status = 'sending' AND outgoing_mail_id IS NULL
387
+ RETURNING notification_id, outgoing_mail_id
388
+ )
389
+ UPDATE notifications.messages m SET outgoing_mail_id = l.outgoing_mail_id
390
+ FROM linked l WHERE m.id = l.notification_id AND m.mail_generation = 0
391
+ `;
392
+ }
393
+ }
336
394
  } catch (error) {
337
- const message = error instanceof Error ? error.message : String(error);
338
- log.error("Batch recipient send failed", { batchId, userId: recipient.user_id, error: message });
339
- await sql`
340
- UPDATE notifications.batch_recipients
341
- SET status = 'error', error = ${message}, updated_at = now()
342
- WHERE batch_id = ${batchId}::uuid AND user_id = ${recipient.user_id}::uuid
343
- `;
395
+ const failure = notificationMailError(error);
396
+ log.error("Notification batch enqueue failed", { batchId, code: failure.code, error: failure.message });
397
+ await failRecipients(recipients, failure);
398
+ // Backlog/quota/storage failures are not terminal recipient failures.
399
+ await refreshBatchCounters(batchId);
400
+ return { processed: invalidCount + settled.length, remaining: await remainingBatchRecipients(batchId) };
344
401
  }
345
402
  }
403
+ const remaining = await remainingBatchRecipients(batchId);
404
+ await refreshBatchCounters(batchId);
405
+ return { processed: recipients.length + settled.length, remaining };
406
+ };
346
407
 
347
- const [pendingRow] = await sql<BatchRow[]>`
348
- SELECT COUNT(*)::int AS count
349
- FROM notifications.batch_recipients
350
- WHERE batch_id = ${batchId}::uuid
351
- AND recipient IS NOT NULL
352
- AND status IN ('pending', 'sending')
408
+ const remainingBatchRecipients = async (batchId: string): Promise<number> => {
409
+ const [row] = await sql<{ count: number }[]>`
410
+ SELECT COUNT(*)::int AS count FROM notifications.batch_recipients
411
+ WHERE batch_id = ${batchId}::uuid AND recipient IS NOT NULL AND status IN ('pending', 'sending')
353
412
  `;
354
- const remaining = Number(pendingRow?.count ?? 0);
355
- await refreshBatchCounters(batchId);
356
- return { processed: recipients.length, remaining };
413
+ return row?.count ?? 0;
357
414
  };
358
415
 
359
416
  const batchJob = lazySync((sync) => {
@@ -492,6 +549,8 @@ export const listRecipients = async (params: {
492
549
  r.profile,
493
550
  r.status,
494
551
  r.notification_id,
552
+ r.outgoing_mail_id,
553
+ mail.status AS outgoing_mail_status,
495
554
  COALESCE(m.error, r.error) AS error,
496
555
  r.attempt_count,
497
556
  r.sent_at,
@@ -499,6 +558,7 @@ export const listRecipients = async (params: {
499
558
  COUNT(*) OVER() AS total
500
559
  FROM notifications.batch_recipients r
501
560
  LEFT JOIN notifications.messages m ON m.id = r.notification_id
561
+ LEFT JOIN outgoing_mail.messages mail ON mail.id = r.outgoing_mail_id
502
562
  LEFT JOIN auth.users u ON u.id = r.user_id
503
563
  WHERE r.batch_id = ${params.batchId}::uuid
504
564
  AND (${params.status ?? null}::text IS NULL OR r.status = ${params.status ?? null})
@@ -620,7 +680,7 @@ export const retryFailed = async (params: {
620
680
  const result = await sql.begin(async (tx) => {
621
681
  const updated = await tx<BatchRow[]>`
622
682
  UPDATE notifications.batch_recipients
623
- SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
683
+ SET status = 'pending', error = NULL, notification_id = NULL, outgoing_mail_id = NULL, sent_at = NULL, updated_at = now()
624
684
  WHERE batch_id = ${params.id}::uuid AND status = 'error' AND recipient IS NOT NULL
625
685
  RETURNING user_id
626
686
  `;
@@ -667,7 +727,7 @@ export const retryRecipient = async (params: {
667
727
  const result = await sql.begin(async (tx) => {
668
728
  const updated = await tx<BatchRow[]>`
669
729
  UPDATE notifications.batch_recipients
670
- SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
730
+ SET status = 'pending', error = NULL, notification_id = NULL, outgoing_mail_id = NULL, sent_at = NULL, updated_at = now()
671
731
  WHERE batch_id = ${params.id}::uuid
672
732
  AND user_id = ${params.userId}::uuid
673
733
  AND status = 'error'