@k2b/cloud 0.29.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.
@@ -2,7 +2,7 @@ import type { PageParams, Paginated, ServiceError } from "@k2b/stdlib";
2
2
  import { z } from "zod";
3
3
  import { type RequestActor, ServiceAccountSchema, UserSchema } from "./shared";
4
4
 
5
- export const PlatformPermissionSchema = z.enum(["mail:send"]);
5
+ export const PlatformPermissionSchema = z.enum(["mail:send", "mail:read"]);
6
6
  export type PlatformPermission = z.infer<typeof PlatformPermissionSchema>;
7
7
  export const MailRetentionSchema = z.object({ contentDays: z.int().min(1).max(36500), recordDays: z.int().min(1).max(36500) }).strict();
8
8
  export type MailRetention = z.infer<typeof MailRetentionSchema>;
@@ -74,6 +74,7 @@ export type MailProfile = z.infer<typeof MailProfileSchema>;
74
74
  export const MailErrorCodeSchema = z.enum([
75
75
  "bad_input",
76
76
  "profile_not_allowed",
77
+ "app_not_allowed",
77
78
  "profile_required",
78
79
  "quota_exceeded",
79
80
  "backlog_full",
@@ -110,14 +111,31 @@ export const AdminMailProfileSchema = MailProfileInputSchema.omit({ smtpPassword
110
111
  appCount: z.int(),
111
112
  });
112
113
  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
- };
114
+ export const AdminMailAppSchema = z.object({
115
+ appId: z.string(),
116
+ name: z.string(),
117
+ registered: z.boolean(),
118
+ declared: z.boolean(),
119
+ mode: z.enum(["default", "selected"]),
120
+ profiles: z.array(z.string()),
121
+ /** Whether the registered app declares `mail:read`; grants in `logApps` only apply with it. */
122
+ readDeclared: z.boolean(),
123
+ /** Other apps whose send log this app may read, besides its own. */
124
+ logApps: z.array(z.string()),
125
+ });
126
+ export type AdminMailApp = z.infer<typeof AdminMailAppSchema>;
127
+ /** Application IDs as registered, like sender access; Core's own mail is never readable by apps. */
128
+ export const MailLogAppIdSchema = z.string().min(1);
129
+ /** How many other apps' send logs an administrator can let one app read. */
130
+ const MAIL_LOG_GRANT_LIMIT = 100;
131
+ export const MailLogAccessSchema = z
132
+ .object({ apps: z.array(MailLogAppIdSchema).max(MAIL_LOG_GRANT_LIMIT) })
133
+ .strict()
134
+ .refine(
135
+ (value) => new Set(value.apps).size === value.apps.length && !value.apps.includes("core"),
136
+ "Choose distinct apps other than core",
137
+ );
138
+ export type MailLogAccess = z.infer<typeof MailLogAccessSchema>;
121
139
 
122
140
  export const MailStatusSchema = z.enum(["queued", "sending", "sent", "failed", "bounced", "cancelled"]);
123
141
  export type MailStatus = z.infer<typeof MailStatusSchema>;
@@ -218,11 +236,38 @@ export const MailFilterSchema = z
218
236
  batchId: z.uuid().optional(),
219
237
  status: z.array(MailStatusSchema).max(6).optional(),
220
238
  since: z.iso.datetime({ offset: true }).optional(),
239
+ /** Exclusive upper bound on `createdAt`. */
240
+ until: z.iso.datetime({ offset: true }).optional(),
241
+ /**
242
+ * Apps whose records to read; omitted means only the calling app. Others need `mail:read` and an
243
+ * administrator grant. Holds everything `mail.readableApps()` returns: the caller and every grant.
244
+ */
245
+ apps: z
246
+ .array(MailLogAppIdSchema)
247
+ .min(1)
248
+ .max(MAIL_LOG_GRANT_LIMIT + 1)
249
+ .refine((apps) => new Set(apps).size === apps.length, "Choose distinct apps")
250
+ .optional(),
251
+ /**
252
+ * Case-insensitive substring of a recipient address or the subject. Three letters or digits in a row
253
+ * give the trigram index something to look up; without them every record would be a candidate.
254
+ */
255
+ q: z
256
+ .string()
257
+ .refine((value) => !/[\x00-\x1f\x7f]/.test(value), "Search must not contain control characters")
258
+ .trim()
259
+ .max(200)
260
+ .regex(/[\p{L}\p{N}]{3}/u, "Search needs three letters or digits in a row")
261
+ .optional(),
262
+ /** One recipient address, matched exactly but case-insensitively. */
263
+ recipient: z.string().trim().pipe(mailAddress).optional(),
221
264
  })
222
265
  .strict();
223
266
  export type MailFilter = z.infer<typeof MailFilterSchema>;
224
267
  export const MailRecordSchema = z.object({
225
268
  id: z.uuid(),
269
+ /** The app that sent the record. */
270
+ appId: z.string(),
226
271
  batchId: z.uuid().optional(),
227
272
  profile: z.string(),
228
273
  ref: ref.optional(),
@@ -241,7 +286,6 @@ export const MailRecordSchema = z.object({
241
286
  });
242
287
  export type MailRecord = z.infer<typeof MailRecordSchema>;
243
288
  export const AdminMailRecordSchema = MailRecordSchema.omit({ text: true }).extend({
244
- appId: z.string(),
245
289
  errorCode: z.string().optional(),
246
290
  response: z.string().optional(),
247
291
  });
@@ -252,9 +296,10 @@ export type MailPage<T = MailRecord> = Paginated<T> & { nextCursor?: string };
252
296
  export const MailPageParamsSchema = z
253
297
  .object({ page: z.int().min(1).optional(), perPage: z.int().min(1).max(100).optional(), cursor: z.string().max(1024).optional() })
254
298
  .strict();
255
- export const AdminMailFilterSchema = MailFilterSchema.extend({
299
+ export const AdminMailFilterSchema = MailFilterSchema.omit({ apps: true }).extend({
256
300
  app: z.string().min(1).optional(),
257
301
  profile: MailProfileKeySchema.optional(),
302
+ /** Administrators search a recipient substring. */
258
303
  recipient: z.string().max(320).optional(),
259
304
  });
260
305
  export type AdminMailFilter = z.infer<typeof AdminMailFilterSchema>;
@@ -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
  ? {
@@ -8,6 +8,9 @@ import {
8
8
  MailAppAccessSchema,
9
9
  type MailBounceErrorCode,
10
10
  type MailErrorCode,
11
+ type MailLogAccess,
12
+ MailLogAccessSchema,
13
+ MailLogAppIdSchema,
11
14
  type MailProfile,
12
15
  type MailProfileInput,
13
16
  MailProfileInputSchema,
@@ -22,7 +25,7 @@ export class OutgoingMailError extends Error {
22
25
  constructor(
23
26
  public readonly code: MailErrorCode,
24
27
  message: string,
25
- public readonly status: 400 | 404 | 409 | 502 = 400,
28
+ public readonly status: 400 | 403 | 404 | 409 | 502 = 400,
26
29
  ) {
27
30
  super(message);
28
31
  }
@@ -75,11 +78,12 @@ const locked = async <T>(run: (tx: SQL) => Promise<T>): Promise<T> =>
75
78
  await tx`SELECT pg_advisory_xact_lock(hashtextextended('outgoing_mail.policy', 0))`;
76
79
  return run(tx);
77
80
  });
78
- const record = (db: SQL, context: MailAuditContext, action: string, target: string) =>
81
+ const record = (db: SQL, context: MailAuditContext, action: string, target: string, metadata?: Record<string, unknown>) =>
79
82
  audit.record(
80
83
  {
81
84
  ...context,
82
85
  action: `outgoing_mail.${action}`,
86
+ ...(metadata ? { metadata } : {}),
83
87
  outcome: "allowed",
84
88
  target: { type: action.startsWith("profile.") ? "outgoing_mail_profile" : "outgoing_mail_app", id: target },
85
89
  },
@@ -241,7 +245,9 @@ const appsState = async (
241
245
  const registered = registeredApps ?? (await listApps());
242
246
  const { access, grants } = await accessRows(db);
243
247
  const [profile] = await db<{ key: string }[]>`SELECT key FROM outgoing_mail.profiles WHERE is_default`;
244
- const ids = new Set([...registered.map((app) => app.id), ...access.map((a) => a.app_id)]);
248
+ const logGrants = await db<{ app_id: string; source_app_id: string }[]>`
249
+ SELECT app_id, source_app_id FROM outgoing_mail.app_log_access ORDER BY source_app_id`;
250
+ const ids = new Set([...registered.map((app) => app.id), ...access.map((a) => a.app_id), ...logGrants.map((g) => g.app_id)]);
245
251
  const items = [...ids]
246
252
  .map((appId): AdminMailApp => {
247
253
  const app = registered.find((a) => a.id === appId);
@@ -251,6 +257,13 @@ const appsState = async (
251
257
  name: app?.name ?? appId,
252
258
  registered: !!app,
253
259
  declared: !!app?.platformPermissions?.includes("mail:send"),
260
+ readDeclared: appId !== "core" && !!app?.platformPermissions?.includes("mail:read"),
261
+ logApps:
262
+ appId === "core"
263
+ ? []
264
+ : logGrants
265
+ .filter((g) => g.app_id === appId && g.source_app_id !== "core" && g.source_app_id !== appId)
266
+ .map((g) => g.source_app_id),
254
267
  mode,
255
268
  profiles: mode === "selected" ? grants.filter((g) => g.app_id === appId).map((g) => g.key) : [],
256
269
  };
@@ -285,6 +298,41 @@ const setAppAccess = async (appId: string, value: MailAppAccess, context: MailAu
285
298
  name: appId,
286
299
  registered: false,
287
300
  declared: false,
301
+ readDeclared: false,
302
+ logApps: [],
303
+ mode: "default",
304
+ profiles: [],
305
+ }
306
+ );
307
+ });
308
+ };
309
+ const logAppsFor = async (appId: string): Promise<string[]> => {
310
+ if (appId === "core") return [];
311
+ const rows = await sql<{ source_app_id: string }[]>`SELECT source_app_id FROM outgoing_mail.app_log_access
312
+ WHERE app_id = ${appId} AND source_app_id <> 'core' AND source_app_id <> app_id ORDER BY source_app_id`;
313
+ return rows.map((row) => row.source_app_id);
314
+ };
315
+ const setAppLogAccess = async (appId: string, value: MailLogAccess, context: MailAuditContext): Promise<AdminMailApp> => {
316
+ const reader = MailLogAppIdSchema.safeParse(appId);
317
+ const parsed = MailLogAccessSchema.safeParse(value);
318
+ if (!reader.success || appId === "core" || !parsed.success || parsed.data.apps.includes(appId))
319
+ throw new OutgoingMailError("bad_input", "Choose a valid reader and distinct source apps other than Core or the reader itself.");
320
+ const apps = [...parsed.data.apps].sort();
321
+ const registered = await listApps();
322
+ return locked(async (tx) => {
323
+ await tx`DELETE FROM outgoing_mail.app_log_access WHERE app_id = ${appId}`;
324
+ await tx`INSERT INTO outgoing_mail.app_log_access(app_id, source_app_id, created_by)
325
+ SELECT ${appId}, source_app_id, ${context.actor.userId ?? null}
326
+ FROM unnest(${toPgTextArray(apps)}::text[]) AS source_app_id`;
327
+ await record(tx, context, "app_log_access.update", appId, { apps });
328
+ return (
329
+ (await appsState(tx, registered)).items.find((app) => app.appId === appId) ?? {
330
+ appId,
331
+ name: appId,
332
+ registered: false,
333
+ declared: false,
334
+ readDeclared: false,
335
+ logApps: [],
288
336
  mode: "default",
289
337
  profiles: [],
290
338
  }
@@ -319,7 +367,18 @@ const profilesForApp = async (appId: string): Promise<MailProfile[]> => {
319
367
  quota: { dailyRecipients: row.daily_recipient_limit, usedLast24h: row.used },
320
368
  }));
321
369
  };
322
- export const outgoingMailStore = { list, get, put, setDefault, delete: remove, apps: appsState, setAppAccess, profilesForApp };
370
+ export const outgoingMailStore = {
371
+ list,
372
+ get,
373
+ put,
374
+ setDefault,
375
+ delete: remove,
376
+ apps: appsState,
377
+ setAppAccess,
378
+ setAppLogAccess,
379
+ logAppsFor,
380
+ profilesForApp,
381
+ };
323
382
 
324
383
  /** Platform email send path only. Never use this helper to shape API or CLI responses. */
325
384
  export const resolveMailCredentials = async (key?: string | { id: string }) => {
@@ -65,6 +65,7 @@ const UNSAFE_IPV6_RANGES: ReadonlyArray<readonly [bigint, number]> = [
65
65
  [ipv6ToBigInt("2001:2::")!, 48],
66
66
  [ipv6ToBigInt("2001:db8::")!, 32],
67
67
  [ipv6ToBigInt("2002::")!, 16],
68
+ [ipv6ToBigInt("3fff::")!, 20],
68
69
  [ipv6ToBigInt("fc00::")!, 7],
69
70
  [ipv6ToBigInt("fe80::")!, 10],
70
71
  [ipv6ToBigInt("fec0::")!, 10],
@@ -96,6 +96,148 @@
96
96
  outline-offset: var(--k2b-focus-offset);
97
97
  }
98
98
 
99
+ /* A pending approval: a calm tinted card in place 4 that says what will happen. Fields stack on phones and the
100
+ decision buttons share the width there. */
101
+ .k2b-ui .ai-approval {
102
+ display: grid;
103
+ min-width: 0;
104
+ gap: 0.75rem;
105
+ padding: 1rem 1.125rem;
106
+ /* Transparent, so forced colours still draw an outline where the tint disappears. */
107
+ border: 1px solid transparent;
108
+ border-radius: calc(var(--k2b-radius-surface) + 0.25rem);
109
+ color: var(--k2b-text-secondary);
110
+ background: color-mix(in srgb, var(--k2b-warning-surface) 70%, var(--k2b-surface-canvas));
111
+ font-size: 0.8125rem;
112
+ line-height: 1.5;
113
+ }
114
+
115
+ .k2b-ui.k2b-dark .ai-approval,
116
+ .dark .k2b-ui .ai-approval {
117
+ background: color-mix(in srgb, var(--k2b-warning-surface) 55%, var(--k2b-surface-canvas));
118
+ }
119
+
120
+ .k2b-ui .ai-approval__head {
121
+ display: flex;
122
+ min-width: 0;
123
+ align-items: center;
124
+ gap: 0.75rem;
125
+ }
126
+
127
+ .k2b-ui .ai-approval__icon {
128
+ flex: none;
129
+ color: var(--k2b-warning-text);
130
+ font-size: 1.125rem;
131
+ }
132
+
133
+ /* The title wraps, so it reads in full where the decision button has to shorten it. */
134
+ .k2b-ui .ai-approval__title {
135
+ margin: 0;
136
+ color: var(--k2b-text);
137
+ font-size: 0.875rem;
138
+ font-weight: 600;
139
+ overflow-wrap: anywhere;
140
+ }
141
+
142
+ .k2b-ui .ai-approval__sub,
143
+ .k2b-ui .ai-approval__state {
144
+ margin: 0;
145
+ color: var(--k2b-text-muted);
146
+ font-size: 0.75rem;
147
+ }
148
+
149
+ .k2b-ui .ai-approval__state {
150
+ display: inline-flex;
151
+ align-items: center;
152
+ gap: 0.25rem;
153
+ font-weight: 500;
154
+ }
155
+
156
+ .k2b-ui .ai-approval__text {
157
+ min-width: 0;
158
+ color: var(--k2b-text-secondary);
159
+ font-size: 0.75rem;
160
+ }
161
+
162
+ .k2b-ui .ai-approval__label,
163
+ .k2b-ui .ai-approval__fields dt {
164
+ color: var(--k2b-text);
165
+ font-size: 0.75rem;
166
+ font-weight: 600;
167
+ }
168
+
169
+ .k2b-ui .ai-approval__fields {
170
+ display: grid;
171
+ margin: 0;
172
+ gap: 0.3rem 1rem;
173
+ grid-template-columns: max-content minmax(0, 1fr);
174
+ font-size: 0.75rem;
175
+ }
176
+
177
+ .k2b-ui .ai-approval__fields dd {
178
+ min-width: 0;
179
+ margin: 0;
180
+ color: var(--k2b-text-secondary);
181
+ overflow-wrap: anywhere;
182
+ }
183
+
184
+ .k2b-ui .ai-approval__preview {
185
+ max-height: 12rem;
186
+ margin: 0;
187
+ overflow: auto;
188
+ color: var(--k2b-text-secondary);
189
+ font-size: 0.75rem;
190
+ line-height: 1.55;
191
+ }
192
+
193
+ .k2b-ui .ai-approval__foot {
194
+ display: flex;
195
+ min-width: 0;
196
+ flex-wrap: wrap;
197
+ align-items: center;
198
+ gap: 0.5rem;
199
+ }
200
+
201
+ .k2b-ui .ai-approval__actions {
202
+ display: flex;
203
+ min-width: 0;
204
+ align-items: center;
205
+ gap: 0.375rem;
206
+ margin-left: auto;
207
+ }
208
+
209
+ @media (max-width: 40rem) {
210
+ .k2b-ui .ai-approval__fields {
211
+ grid-template-columns: minmax(0, 1fr);
212
+ gap: 0;
213
+ }
214
+
215
+ .k2b-ui .ai-approval__fields dd {
216
+ margin-bottom: 0.35rem;
217
+ }
218
+
219
+ .k2b-ui .ai-approval__actions {
220
+ width: 100%;
221
+ margin-left: 0;
222
+ }
223
+
224
+ /* The buttons share the width and may shrink below their labels: a long action title ends in an ellipsis instead
225
+ of pushing the menu out of the card. The title above shows it in full. */
226
+ .k2b-ui .ai-approval__actions > *,
227
+ .k2b-ui .ai-approval__actions .k2b-split-button__primary {
228
+ min-width: 0;
229
+ flex: 1;
230
+ justify-content: center;
231
+ }
232
+
233
+ .k2b-ui .ai-approval__actions .k2b-split-button__primary > .k2b-button__label {
234
+ display: block;
235
+ min-width: 0;
236
+ overflow: hidden;
237
+ text-overflow: ellipsis;
238
+ }
239
+ }
240
+
99
241
  /* Result links sit inside an already padded activity row. Keep them inline so
100
242
  they do not make linked capability results taller than neighboring rows. */
101
243
  .k2b-ui .k2b-button.ai-chat-result-link[data-size="xs"] {