@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.
- package/package.json +4 -3
- package/src/ai/chat/blocks.tsx +162 -180
- package/src/ai/chat/live-turn.browser-harness.tsx +5 -1
- package/src/ai/chat/messages.ts +4 -24
- package/src/ai/chat/tool-disclosure.tsx +21 -14
- package/src/ai/chat/tool-groups.ts +64 -37
- package/src/ai/chat/turn-layout.ts +2 -2
- package/src/ai/chat/turn-view.tsx +119 -65
- package/src/ai/protocol.ts +9 -4
- package/src/ai/store.ts +20 -13
- package/src/ai/types.ts +2 -1
- package/src/api/admin-outgoing-mail.ts +18 -0
- package/src/cli/admin/outgoing-mail.ts +30 -2
- package/src/contracts/outgoing-mail.ts +56 -11
- package/src/services/outbox.ts +24 -9
- package/src/services/outgoing-mail/index.ts +33 -5
- package/src/services/outgoing-mail/messages.ts +60 -19
- package/src/services/outgoing-mail/store.ts +63 -4
- package/src/shared/network-address.ts +1 -0
- package/src/styles/effects.css +142 -0
- package/src/styles/utilities-markdown-table.css +5 -1
|
@@ -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
|
|
114
|
-
appId: string
|
|
115
|
-
name: string
|
|
116
|
-
registered: boolean
|
|
117
|
-
declared: boolean
|
|
118
|
-
mode: "default"
|
|
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>;
|
package/src/services/outbox.ts
CHANGED
|
@@ -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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
155
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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,
|
|
90
|
-
${
|
|
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
|
|
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:
|
|
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 =
|
|
341
|
-
${filter.
|
|
342
|
-
${filter.
|
|
343
|
-
${filter.
|
|
344
|
-
${filter.
|
|
345
|
-
${filter.
|
|
346
|
-
${filter.
|
|
347
|
-
${filter.
|
|
348
|
-
${filter.
|
|
349
|
-
${filter.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
${
|
|
353
|
-
|
|
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
|
|
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
|
|
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 = {
|
|
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],
|
package/src/styles/effects.css
CHANGED
|
@@ -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"] {
|