@k2b/cloud 0.27.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.
- package/package.json +2 -2
- package/src/ai/browser-code-contracts.ts +33 -63
- package/src/ai/browser.ts +1 -0
- package/src/ai/chat/blocks.tsx +16 -7
- package/src/ai/chat/live-turn.browser-harness.tsx +6 -3
- package/src/ai/chat/message-actions.tsx +123 -101
- package/src/ai/chat/message-utils.ts +30 -1
- package/src/ai/chat/messages.ts +198 -0
- package/src/ai/chat/presentation.tsx +66 -20
- package/src/ai/chat/tool-groups.ts +1 -1
- package/src/ai/chat/turn-error.ts +21 -0
- package/src/ai/chat/turn-view.tsx +55 -4
- package/src/ai/chat/user-message.tsx +19 -14
- package/src/ai/chat/visual-tools.tsx +1 -1
- package/src/ai/client/controller.ts +23 -6
- package/src/ai/code-mode-skill.ts +21 -25
- package/src/ai/code-runtime-tools.ts +5 -1
- package/src/ai/code-source-contracts.ts +2 -2
- package/src/ai/data-analysis-skill.ts +3 -3
- package/src/ai/default-tools.ts +15 -16
- package/src/ai/executor.ts +135 -59
- package/src/ai/index.ts +2 -0
- package/src/ai/open-tool-calls.ts +87 -0
- package/src/ai/routes.ts +6 -0
- package/src/ai/run-timeout.ts +4 -5
- package/src/ai/runtime.ts +8 -0
- package/src/ai/skill-seeds.ts +4 -4
- package/src/ai/store.ts +46 -14
- package/src/ai/system-prompt.ts +4 -21
- package/src/ai/turn-failure.ts +100 -0
- package/src/ai/turn-policy.ts +2 -1
- package/src/ai/types.ts +28 -1
- package/src/api/admin-outgoing-mail.ts +185 -5
- package/src/browser/FileChooser.tsx +11 -0
- package/src/browser/file-chooser-messages.ts +2 -0
- package/src/cli/admin/index.ts +3 -1
- package/src/cli/admin/notifications.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +104 -7
- package/src/contracts/outgoing-mail.ts +189 -1
- package/src/services/help/store.ts +2 -1
- package/src/services/index.ts +11 -1
- package/src/services/notifications/batches.ts +139 -79
- package/src/services/notifications/channels.ts +33 -13
- package/src/services/notifications/dispatcher.ts +57 -5
- package/src/services/notifications/email-frame.fixture.html +51 -0
- package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
- package/src/services/notifications/email-mail.ts +101 -0
- package/src/services/notifications/index.ts +49 -36
- package/src/services/notifications/observability.ts +9 -1
- package/src/services/notifications/platform.ts +1 -1
- package/src/services/notifications/runtime.ts +9 -3
- package/src/services/outgoing-mail/admin.ts +84 -0
- package/src/services/outgoing-mail/attachments.ts +135 -0
- package/src/services/outgoing-mail/bulk.ts +11 -0
- package/src/services/outgoing-mail/dispatcher.ts +231 -0
- package/src/services/outgoing-mail/drain.ts +60 -0
- package/src/services/outgoing-mail/enqueue.ts +180 -0
- package/src/services/outgoing-mail/index.ts +103 -10
- package/src/services/outgoing-mail/message.ts +14 -0
- package/src/services/outgoing-mail/messages.ts +431 -0
- package/src/services/outgoing-mail/retention.ts +48 -0
- package/src/services/outgoing-mail/runtime.ts +72 -0
- package/src/services/outgoing-mail/send.ts +130 -0
- package/src/services/outgoing-mail/store.ts +115 -4
- package/src/services/outgoing-mail/sync.ts +39 -0
- package/src/services/outgoing-mail/transport.ts +5 -1
- package/src/services/pdf/markdown.ts +22 -4
- package/src/services/postgres.ts +15 -0
- package/src/services/settings/core-settings.ts +18 -0
- package/src/shared/ai-platform-prompt.ts +48 -4
- package/src/shared/markdown/extensions/links.ts +28 -20
- package/src/shared/markdown/index.ts +14 -5
- package/src/shared/markdown/shared.ts +0 -7
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/platform-messages.ts +1 -1
- package/src/styles/effects.css +15 -17
- package/src/styles/tokens.css +2 -0
- package/src/styles/utilities-markdown-editor.css +4 -39
- package/src/styles/utilities-markdown-table.css +14 -17
|
@@ -1,19 +1,40 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
1
2
|
import { type Context, Hono, type MiddlewareHandler } from "hono";
|
|
2
|
-
import { validator } from "hono-openapi";
|
|
3
|
+
import { describeRoute, validator } from "hono-openapi";
|
|
3
4
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
5
|
-
|
|
5
|
+
import {
|
|
6
|
+
AdminMailRecordSchema,
|
|
7
|
+
MailAppAccessSchema,
|
|
8
|
+
MailProfileInputSchema,
|
|
9
|
+
MailProfileKeySchema,
|
|
10
|
+
MailRetentionSchema,
|
|
11
|
+
MailStatusSchema,
|
|
12
|
+
} from "../contracts/outgoing-mail";
|
|
13
|
+
import { type AuthContext, auth, jsonResponse, requiresAdmin } from "../server";
|
|
14
|
+
import { audit } from "../services/audit";
|
|
15
|
+
import { outgoingMailLog } from "../services/outgoing-mail/admin";
|
|
16
|
+
import { MAIL_RETENTION_KEYS, readMailRetention } from "../services/outgoing-mail/retention";
|
|
6
17
|
import { type MailAuditContext, OutgoingMailError, outgoingMailStore } from "../services/outgoing-mail/store";
|
|
7
18
|
import { outgoingMailTest } from "../services/outgoing-mail/test-send";
|
|
19
|
+
import * as settings from "../services/settings";
|
|
8
20
|
|
|
9
|
-
const
|
|
21
|
+
const retentionInput = MailRetentionSchema.refine((value) => value.recordDays >= value.contentDays, {
|
|
22
|
+
message: "Record retention must be at least content retention.",
|
|
23
|
+
path: ["recordDays"],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const validate = <Target extends "param" | "json" | "query", Schema extends z.ZodType>(
|
|
27
|
+
target: Target,
|
|
28
|
+
schema: Schema,
|
|
29
|
+
code: "invalid_profile" | "bad_input" = "invalid_profile",
|
|
30
|
+
) =>
|
|
10
31
|
validator(target, schema, (result, c) => {
|
|
11
32
|
if (!result.success) {
|
|
12
33
|
const issue = result.error?.[0];
|
|
13
34
|
const path = issue?.path?.map((part) => (typeof part === "object" && "key" in part ? String(part.key) : String(part))).join(".");
|
|
14
35
|
return c.json(
|
|
15
36
|
{
|
|
16
|
-
code
|
|
37
|
+
code,
|
|
17
38
|
message: issue ? `${path ? `${path}: ` : ""}${issue.message}` : "Invalid outgoing mail configuration.",
|
|
18
39
|
},
|
|
19
40
|
400,
|
|
@@ -24,6 +45,52 @@ const context = (c: Context<AuthContext>): MailAuditContext => ({
|
|
|
24
45
|
actor: { userId: c.get("user").id, uid: c.get("user").uid, provider: c.get("user").provider, roles: c.get("user").roles },
|
|
25
46
|
requestId: c.req.header("x-request-id"),
|
|
26
47
|
});
|
|
48
|
+
const idParam = z.object({ id: z.uuid() });
|
|
49
|
+
const logQuery = z
|
|
50
|
+
.object({
|
|
51
|
+
app: z.string().min(1).optional(),
|
|
52
|
+
profile: MailProfileKeySchema.optional(),
|
|
53
|
+
status: z
|
|
54
|
+
.union([z.string(), z.array(z.string())])
|
|
55
|
+
.optional()
|
|
56
|
+
.transform((value) =>
|
|
57
|
+
value === undefined ? undefined : typeof value === "string" ? value.split(",") : value.flatMap((part) => part.split(",")),
|
|
58
|
+
)
|
|
59
|
+
.pipe(z.array(MailStatusSchema).max(6).optional()),
|
|
60
|
+
since: z.iso.datetime({ offset: true }).optional(),
|
|
61
|
+
ref: z
|
|
62
|
+
.string()
|
|
63
|
+
.min(1)
|
|
64
|
+
.max(200)
|
|
65
|
+
.optional()
|
|
66
|
+
.transform((value) => {
|
|
67
|
+
if (value === undefined) return undefined;
|
|
68
|
+
const colon = value.indexOf(":");
|
|
69
|
+
return colon < 0 ? { scope: value } : { scope: value.slice(0, colon), id: value.slice(colon + 1) };
|
|
70
|
+
})
|
|
71
|
+
.pipe(z.object({ scope: z.string().min(1).max(200), id: z.string().min(1).max(200).optional() }).optional()),
|
|
72
|
+
recipient: z.string().max(320).optional(),
|
|
73
|
+
cursor: z.string().max(1024).optional(),
|
|
74
|
+
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
75
|
+
})
|
|
76
|
+
.strict();
|
|
77
|
+
const logPage = z.object({
|
|
78
|
+
items: z.array(AdminMailRecordSchema),
|
|
79
|
+
page: z.int(),
|
|
80
|
+
perPage: z.int(),
|
|
81
|
+
total: z.int(),
|
|
82
|
+
hasNext: z.boolean(),
|
|
83
|
+
nextCursor: z.string().optional(),
|
|
84
|
+
});
|
|
85
|
+
const contentResponse = z.union([
|
|
86
|
+
z.object({ purged: z.literal(true), contentPurgedAt: z.string() }),
|
|
87
|
+
z.object({
|
|
88
|
+
purged: z.literal(false),
|
|
89
|
+
text: z.string().nullable(),
|
|
90
|
+
html: z.string().nullable(),
|
|
91
|
+
headers: z.record(z.string(), z.string()).nullable(),
|
|
92
|
+
}),
|
|
93
|
+
]);
|
|
27
94
|
export const createAdminOutgoingMailRoutes = (authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin")) =>
|
|
28
95
|
new Hono<AuthContext>()
|
|
29
96
|
.use("*", authenticate)
|
|
@@ -35,7 +102,120 @@ export const createAdminOutgoingMailRoutes = (authenticate: MiddlewareHandler<Au
|
|
|
35
102
|
if (error instanceof OutgoingMailError) return c.json({ error: error.code, code: error.code, message: error.message }, error.status);
|
|
36
103
|
throw error;
|
|
37
104
|
})
|
|
105
|
+
.get(
|
|
106
|
+
"/retention",
|
|
107
|
+
describeRoute({
|
|
108
|
+
summary: "Read outgoing mail retention",
|
|
109
|
+
tags: ["Outgoing mail"],
|
|
110
|
+
...requiresAdmin,
|
|
111
|
+
responses: { 200: jsonResponse(MailRetentionSchema, "Effective retention in days") },
|
|
112
|
+
}),
|
|
113
|
+
async (c) => c.json(await readMailRetention()),
|
|
114
|
+
)
|
|
115
|
+
.put(
|
|
116
|
+
"/retention",
|
|
117
|
+
describeRoute({
|
|
118
|
+
summary: "Update outgoing mail retention",
|
|
119
|
+
tags: ["Outgoing mail"],
|
|
120
|
+
...requiresAdmin,
|
|
121
|
+
responses: {
|
|
122
|
+
200: jsonResponse(MailRetentionSchema, "Updated retention in days"),
|
|
123
|
+
400: jsonResponse(z.object({ code: z.literal("bad_input"), message: z.string() }), "Invalid retention"),
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
validate("json", retentionInput, "bad_input"),
|
|
127
|
+
async (c) => {
|
|
128
|
+
const next = c.req.valid("json");
|
|
129
|
+
const old = await readMailRetention();
|
|
130
|
+
await sql.begin(async (tx) => {
|
|
131
|
+
await settings.set(MAIL_RETENTION_KEYS[0], next.contentDays, tx);
|
|
132
|
+
await settings.set(MAIL_RETENTION_KEYS[1], next.recordDays, tx);
|
|
133
|
+
await audit.record(
|
|
134
|
+
{
|
|
135
|
+
...context(c),
|
|
136
|
+
action: "outgoing_mail.retention.update",
|
|
137
|
+
outcome: "allowed",
|
|
138
|
+
target: { type: "outgoing_mail_retention" },
|
|
139
|
+
metadata: { old, new: next },
|
|
140
|
+
},
|
|
141
|
+
tx,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
await settings.invalidateSettingsCache(MAIL_RETENTION_KEYS);
|
|
145
|
+
return c.json(next);
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
.get(
|
|
149
|
+
"/messages",
|
|
150
|
+
describeRoute({
|
|
151
|
+
summary: "List outgoing mail metadata",
|
|
152
|
+
tags: ["Outgoing mail"],
|
|
153
|
+
...requiresAdmin,
|
|
154
|
+
responses: { 200: jsonResponse(logPage, "Send log") },
|
|
155
|
+
}),
|
|
156
|
+
validate("query", logQuery, "bad_input"),
|
|
157
|
+
async (c) => {
|
|
158
|
+
const { cursor, limit, ...filter } = c.req.valid("query");
|
|
159
|
+
return c.json(await outgoingMailLog.list(filter, { cursor, perPage: limit }));
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
.get(
|
|
163
|
+
"/messages/:id",
|
|
164
|
+
describeRoute({
|
|
165
|
+
summary: "Read outgoing mail metadata",
|
|
166
|
+
tags: ["Outgoing mail"],
|
|
167
|
+
...requiresAdmin,
|
|
168
|
+
responses: { 200: jsonResponse(AdminMailRecordSchema, "Message metadata") },
|
|
169
|
+
}),
|
|
170
|
+
validate("param", idParam, "bad_input"),
|
|
171
|
+
async (c) => c.json(await outgoingMailLog.get(c.req.valid("param").id)),
|
|
172
|
+
)
|
|
173
|
+
.get(
|
|
174
|
+
"/messages/:id/content",
|
|
175
|
+
describeRoute({
|
|
176
|
+
summary: "Read outgoing mail content with audit",
|
|
177
|
+
tags: ["Outgoing mail"],
|
|
178
|
+
...requiresAdmin,
|
|
179
|
+
responses: { 200: jsonResponse(contentResponse, "Message content or purge marker") },
|
|
180
|
+
}),
|
|
181
|
+
validate("param", idParam, "bad_input"),
|
|
182
|
+
async (c) => c.json(await outgoingMailLog.content(c.req.valid("param").id, context(c))),
|
|
183
|
+
)
|
|
184
|
+
.post(
|
|
185
|
+
"/messages/:id/cancel",
|
|
186
|
+
describeRoute({
|
|
187
|
+
summary: "Cancel queued outgoing mail",
|
|
188
|
+
tags: ["Outgoing mail"],
|
|
189
|
+
...requiresAdmin,
|
|
190
|
+
responses: {
|
|
191
|
+
200: jsonResponse(AdminMailRecordSchema, "Cancelled message"),
|
|
192
|
+
409: jsonResponse(
|
|
193
|
+
z.object({ code: z.literal("message_not_queued"), error: z.literal("message_not_queued"), message: z.string() }),
|
|
194
|
+
"Only queued mail can be cancelled",
|
|
195
|
+
),
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
validate("param", idParam, "bad_input"),
|
|
199
|
+
async (c) => c.json(await outgoingMailLog.cancel(c.req.valid("param").id, context(c))),
|
|
200
|
+
)
|
|
38
201
|
.get("/profiles", async (c) => c.json({ items: await outgoingMailStore.list() }))
|
|
202
|
+
.post(
|
|
203
|
+
"/batches/:batchId/cancel",
|
|
204
|
+
describeRoute({
|
|
205
|
+
summary: "Cancel queued outgoing mail in a batch",
|
|
206
|
+
tags: ["Outgoing mail"],
|
|
207
|
+
...requiresAdmin,
|
|
208
|
+
responses: {
|
|
209
|
+
200: jsonResponse(z.object({ batchId: z.uuid(), cancelled: z.int().nonnegative() }), "Cancelled queued messages"),
|
|
210
|
+
404: jsonResponse(
|
|
211
|
+
z.object({ code: z.literal("batch_unknown"), error: z.literal("batch_unknown"), message: z.string() }),
|
|
212
|
+
"Unknown batch",
|
|
213
|
+
),
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
validate("param", z.object({ batchId: z.uuid() }), "bad_input"),
|
|
217
|
+
async (c) => c.json(await outgoingMailLog.cancelBatch(c.req.valid("param").batchId, context(c))),
|
|
218
|
+
)
|
|
39
219
|
.get("/profiles/:key", async (c) => c.json(await outgoingMailStore.get(c.req.param("key"))))
|
|
40
220
|
.put(
|
|
41
221
|
"/profiles/:key",
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
announce,
|
|
5
5
|
Button,
|
|
6
6
|
createCollectionSelection,
|
|
7
|
+
FileDropTarget,
|
|
7
8
|
FileGrid,
|
|
8
9
|
Format,
|
|
9
10
|
IconButton,
|
|
@@ -324,6 +325,16 @@ export function FileChooser(props: {
|
|
|
324
325
|
{/* `display: contents`, so header, body, and footer stay the panel's own rows. */}
|
|
325
326
|
<div ref={root} class="cloud-file-chooser">
|
|
326
327
|
<PanelDialog.Header title={t().title({ multiple })} close={() => props.close([])} />
|
|
328
|
+
{/* Files dropped anywhere on the open chooser are taken like files picked from this device. */}
|
|
329
|
+
<FileDropTarget
|
|
330
|
+
label={t().dropToChoose({ multiple })}
|
|
331
|
+
accept={props.options.accept}
|
|
332
|
+
multiple={multiple}
|
|
333
|
+
disabled={downloading()}
|
|
334
|
+
onDrop={(files) => {
|
|
335
|
+
if (files.length > 0) props.close(files);
|
|
336
|
+
}}
|
|
337
|
+
/>
|
|
327
338
|
<div class="cloud-file-chooser__toolbar">
|
|
328
339
|
<nav class="cloud-file-chooser__crumbs" aria-label={t().breadcrumbs}>
|
|
329
340
|
<IconButton size="sm" variant="ghost" label={t().up} disabled={!location() || downloading()} onClick={up}>
|
|
@@ -8,6 +8,7 @@ export const fileChooserMessages = i18n.define({
|
|
|
8
8
|
sources: "Sources",
|
|
9
9
|
sourceList: "Where the files come from",
|
|
10
10
|
device: "This device",
|
|
11
|
+
dropToChoose: ({ multiple }: { multiple: boolean }): string => (multiple ? "Drop to add these files" : "Drop to add this file"),
|
|
11
12
|
providersLoading: "Looking for apps with files…",
|
|
12
13
|
providersFailed: "Apps with files could not be loaded.",
|
|
13
14
|
retry: "Try again",
|
|
@@ -48,6 +49,7 @@ export const fileChooserMessages = i18n.define({
|
|
|
48
49
|
sources: "Quellen",
|
|
49
50
|
sourceList: "Woher die Dateien kommen",
|
|
50
51
|
device: "Dieses Gerät",
|
|
52
|
+
dropToChoose: ({ multiple }) => (multiple ? "Ablegen, um diese Dateien hinzuzufügen" : "Ablegen, um diese Datei hinzuzufügen"),
|
|
51
53
|
providersLoading: "Apps mit Dateien werden gesucht…",
|
|
52
54
|
providersFailed: "Apps mit Dateien konnten nicht geladen werden.",
|
|
53
55
|
retry: "Erneut versuchen",
|
package/src/cli/admin/index.ts
CHANGED
|
@@ -57,9 +57,11 @@ export default defineCliCommands({
|
|
|
57
57
|
"nats consumers": "Inspect NATS consumers.",
|
|
58
58
|
"nats streams": "Find NATS streams and buckets.",
|
|
59
59
|
"notification-batches": "Manage account notification batches.",
|
|
60
|
-
"outgoing-mail": "Manage outgoing mail sender profiles and
|
|
60
|
+
"outgoing-mail": "Manage outgoing mail sender profiles, application access, and the send log.",
|
|
61
61
|
"outgoing-mail profiles": "Manage outgoing mail sender profiles.",
|
|
62
62
|
"outgoing-mail apps": "Manage application outgoing mail access.",
|
|
63
|
+
"outgoing-mail log": "Read and cancel application mail in the send log.",
|
|
64
|
+
"outgoing-mail retention": "Show or change how long the send log keeps mail.",
|
|
63
65
|
notifications: "Inspect and resend email notifications.",
|
|
64
66
|
postgres: "Inspect Postgres diagnostics.",
|
|
65
67
|
redis: "Inspect Valkey diagnostics.",
|
|
@@ -54,6 +54,8 @@ export type NotificationBatchRecipient = {
|
|
|
54
54
|
profile: "user" | "guest";
|
|
55
55
|
status: NotificationRecipientStatus;
|
|
56
56
|
notificationId: string | null;
|
|
57
|
+
outgoingMailId: string | null;
|
|
58
|
+
outgoingMailStatus: string | null;
|
|
57
59
|
error: string | null;
|
|
58
60
|
attemptCount: number;
|
|
59
61
|
sentAt: string | null;
|
|
@@ -370,6 +372,8 @@ export const notificationCommands = [
|
|
|
370
372
|
provider: recipient.provider,
|
|
371
373
|
profile: recipient.profile,
|
|
372
374
|
attempts: recipient.attemptCount,
|
|
375
|
+
mailId: recipient.outgoingMailId ?? "",
|
|
376
|
+
mailStatus: recipient.outgoingMailStatus ?? "",
|
|
373
377
|
error: truncate(recipient.error, 60),
|
|
374
378
|
userId: recipient.userId,
|
|
375
379
|
}));
|
|
@@ -381,6 +385,8 @@ export const notificationCommands = [
|
|
|
381
385
|
{ key: "provider" },
|
|
382
386
|
{ key: "profile" },
|
|
383
387
|
{ key: "attempts" },
|
|
388
|
+
{ key: "mailId" },
|
|
389
|
+
{ key: "mailStatus" },
|
|
384
390
|
{ key: "error" },
|
|
385
391
|
{ key: "userId" },
|
|
386
392
|
]);
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
type AdminMailApp,
|
|
3
|
+
type AdminMailProfile,
|
|
4
|
+
type AdminMailRecord,
|
|
5
|
+
type MailPage,
|
|
6
|
+
MailProfileInputSchema,
|
|
7
|
+
type MailRetention,
|
|
8
|
+
MailRetentionSchema,
|
|
9
|
+
} from "../../contracts/outgoing-mail";
|
|
10
|
+
import { arg, type CloudCliContext, command, confirmFlag, flag, printStructured, readCliInput } from "../index";
|
|
3
11
|
import { apiGet, apiJson, printJsonOrTable } from "./shared";
|
|
4
12
|
|
|
5
13
|
const root = "/api/admin/core/outgoing-mail";
|
|
@@ -13,6 +21,78 @@ const confirmed = (yes: boolean) => {
|
|
|
13
21
|
if (!yes) throw new Error("Outgoing mail policy changes require --yes.");
|
|
14
22
|
};
|
|
15
23
|
export const outgoingMailCommands = [
|
|
24
|
+
command("outgoing-mail retention show", {
|
|
25
|
+
summary: "Show outgoing mail retention in days",
|
|
26
|
+
async run({ ctx }) {
|
|
27
|
+
const result = await apiGet<MailRetention>(ctx, `${root}/retention`);
|
|
28
|
+
if (!printStructured(ctx, result)) print(ctx, result);
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
command("outgoing-mail retention set", {
|
|
32
|
+
summary: "Set outgoing mail content and record retention",
|
|
33
|
+
flags: {
|
|
34
|
+
contentDays: flag.string({ required: true, description: "Content retention in days" }),
|
|
35
|
+
recordDays: flag.string({ required: true, description: "Record retention in days (at least content retention)" }),
|
|
36
|
+
yes: confirmFlag("Confirm changing outgoing mail retention"),
|
|
37
|
+
},
|
|
38
|
+
async run({ ctx, flags }) {
|
|
39
|
+
confirmed(flags.yes);
|
|
40
|
+
const retention = MailRetentionSchema.parse({ contentDays: Number(flags.contentDays), recordDays: Number(flags.recordDays) });
|
|
41
|
+
if (retention.recordDays < retention.contentDays) throw new Error("Record retention must be at least content retention.");
|
|
42
|
+
const result = await apiJson<MailRetention>(ctx, "PUT", `${root}/retention`, retention);
|
|
43
|
+
if (!printStructured(ctx, result)) print(ctx, result);
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
command("outgoing-mail log list", {
|
|
47
|
+
summary: "List outgoing mail metadata",
|
|
48
|
+
flags: {
|
|
49
|
+
app: flag.string({ description: "Application ID" }),
|
|
50
|
+
profile: flag.string({ description: "Sender profile key" }),
|
|
51
|
+
status: flag.string({ description: "Comma-separated statuses" }),
|
|
52
|
+
since: flag.string({ description: "ISO timestamp" }),
|
|
53
|
+
ref: flag.string({ description: "Reference scope[:id]" }),
|
|
54
|
+
recipient: flag.string({ description: "Recipient substring" }),
|
|
55
|
+
cursor: flag.string({ description: "Next-page cursor" }),
|
|
56
|
+
limit: flag.int({ description: "Page size (1–100)" }),
|
|
57
|
+
},
|
|
58
|
+
async run({ ctx, flags }) {
|
|
59
|
+
if (flags.limit !== undefined && (!Number.isInteger(flags.limit) || flags.limit < 1 || flags.limit > 100))
|
|
60
|
+
throw new Error("--limit must be a whole number from 1 to 100.");
|
|
61
|
+
const query = new URLSearchParams();
|
|
62
|
+
for (const [key, value] of Object.entries(flags)) if (value !== undefined) query.set(key, String(value));
|
|
63
|
+
const result = await apiGet<MailPage<AdminMailRecord>>(ctx, `${root}/messages${query.size ? `?${query}` : ""}`);
|
|
64
|
+
printJsonOrTable(ctx, result, result.items, [
|
|
65
|
+
{ key: "id" },
|
|
66
|
+
{ key: "appId" },
|
|
67
|
+
{ key: "profile" },
|
|
68
|
+
{ key: "status" },
|
|
69
|
+
{ key: "createdAt" },
|
|
70
|
+
]);
|
|
71
|
+
if (ctx.options.output === "text" && result.nextCursor) ctx.print(`Next cursor: ${result.nextCursor}`);
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
command("outgoing-mail log show", {
|
|
75
|
+
summary: "Read outgoing mail metadata, or audited content",
|
|
76
|
+
args: { id: arg.required({ valueLabel: "id" }) },
|
|
77
|
+
flags: { content: flag.boolean({ description: "Read message content (audited)" }) },
|
|
78
|
+
async run({ ctx, args, flags }) {
|
|
79
|
+
print(ctx, await apiGet(ctx, `${root}/messages/${encodeURIComponent(args.id)}${flags.content ? "/content" : ""}`));
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
command("outgoing-mail log cancel", {
|
|
83
|
+
summary: "Cancel queued outgoing mail",
|
|
84
|
+
args: { id: arg.optional({ valueLabel: "id" }) },
|
|
85
|
+
flags: {
|
|
86
|
+
batch: flag.string({ description: "Cancel queued messages in this batch instead of one ID" }),
|
|
87
|
+
yes: confirmFlag("Confirm cancelling queued outgoing mail"),
|
|
88
|
+
},
|
|
89
|
+
async run({ ctx, args, flags }) {
|
|
90
|
+
if (!flags.yes) throw new Error("Cancelling outgoing mail requires --yes.");
|
|
91
|
+
if ((args.id !== undefined) === (flags.batch !== undefined)) throw new Error("Choose exactly one message ID or --batch <batchId>.");
|
|
92
|
+
const path = flags.batch !== undefined ? `batches/${encodeURIComponent(flags.batch)}` : `messages/${encodeURIComponent(args.id!)}`;
|
|
93
|
+
print(ctx, await apiJson(ctx, "POST", `${root}/${path}/cancel`));
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
16
96
|
command("outgoing-mail profiles list", {
|
|
17
97
|
summary: "List outgoing mail sender profiles",
|
|
18
98
|
async run({ ctx }) {
|
|
@@ -20,8 +100,16 @@ export const outgoingMailCommands = [
|
|
|
20
100
|
printJsonOrTable(
|
|
21
101
|
ctx,
|
|
22
102
|
result,
|
|
23
|
-
result.items.map((p) => ({
|
|
24
|
-
|
|
103
|
+
result.items.map((p) => ({
|
|
104
|
+
key: p.key,
|
|
105
|
+
name: p.name,
|
|
106
|
+
from: p.fromAddress,
|
|
107
|
+
default: p.isDefault,
|
|
108
|
+
revision: p.revision,
|
|
109
|
+
imap: p.imap ? `${p.imap.host}/${p.imap.folder}` : "off",
|
|
110
|
+
bounces: p.bounces ? (p.bounces.error ?? p.bounces.checkedAt ?? "pending") : "off",
|
|
111
|
+
})),
|
|
112
|
+
[{ key: "key" }, { key: "name" }, { key: "from" }, { key: "default" }, { key: "revision" }, { key: "imap" }, { key: "bounces" }],
|
|
25
113
|
);
|
|
26
114
|
},
|
|
27
115
|
}),
|
|
@@ -29,13 +117,18 @@ export const outgoingMailCommands = [
|
|
|
29
117
|
summary: "Read one outgoing mail sender profile",
|
|
30
118
|
args: keyArgs,
|
|
31
119
|
async run({ ctx, args }) {
|
|
32
|
-
|
|
120
|
+
const profile = await apiGet<AdminMailProfile>(ctx, profilePath(args.key));
|
|
121
|
+
print(ctx, profile);
|
|
122
|
+
if (ctx.options.output === "text")
|
|
123
|
+
ctx.print(`Bounces: ${profile.bounces ? (profile.bounces.error ?? profile.bounces.checkedAt ?? "pending") : "off"}`);
|
|
33
124
|
},
|
|
34
125
|
}),
|
|
35
126
|
command("outgoing-mail profiles put", {
|
|
36
127
|
summary: "Create or replace a sender profile with its current revision",
|
|
37
128
|
args: keyArgs,
|
|
38
|
-
flags: {
|
|
129
|
+
flags: {
|
|
130
|
+
config: flag.input({ required: true, description: "Profile JSON; SMTP and IMAP passwords only via --config-file or --stdin" }),
|
|
131
|
+
},
|
|
39
132
|
async run({ ctx, args, flags }) {
|
|
40
133
|
const input = await readCliInput(flags.config, { label: "outgoing mail profile", required: true });
|
|
41
134
|
let raw: unknown;
|
|
@@ -46,7 +139,11 @@ export const outgoingMailCommands = [
|
|
|
46
139
|
}
|
|
47
140
|
if (flags.config.source === "value" && raw && typeof raw === "object" && "smtpPassword" in raw)
|
|
48
141
|
throw new Error("Pass smtpPassword only through --config-file or --stdin.");
|
|
49
|
-
|
|
142
|
+
const parsed = MailProfileInputSchema.safeParse(raw);
|
|
143
|
+
if (!parsed.success) throw new Error("Invalid outgoing mail profile configuration.");
|
|
144
|
+
if (flags.config.source === "value" && parsed.data.imap && "password" in parsed.data.imap)
|
|
145
|
+
throw new Error("Pass imap.password only through --config-file or --stdin.");
|
|
146
|
+
print(ctx, await apiJson(ctx, "PUT", profilePath(args.key), parsed.data));
|
|
50
147
|
},
|
|
51
148
|
}),
|
|
52
149
|
command("outgoing-mail profiles set-default", {
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import type { PageParams, Paginated, ServiceError } from "@k2b/stdlib";
|
|
1
2
|
import { z } from "zod";
|
|
3
|
+
import { type RequestActor, ServiceAccountSchema, UserSchema } from "./shared";
|
|
2
4
|
|
|
3
5
|
export const PlatformPermissionSchema = z.enum(["mail:send"]);
|
|
4
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
|
+
|
|
5
10
|
export const MailProfileKeySchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
|
|
6
11
|
const headerText = z
|
|
7
12
|
.string()
|
|
@@ -9,6 +14,26 @@ const headerText = z
|
|
|
9
14
|
.min(1)
|
|
10
15
|
.max(120)
|
|
11
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();
|
|
12
37
|
export const MailProfileInputSchema = z
|
|
13
38
|
.object({
|
|
14
39
|
name: headerText,
|
|
@@ -24,6 +49,7 @@ export const MailProfileInputSchema = z
|
|
|
24
49
|
smtpSecure: z.boolean(),
|
|
25
50
|
smtpUser: z.string().max(320).nullable(),
|
|
26
51
|
smtpPassword: z.string().max(16384).nullable().optional(),
|
|
52
|
+
imap: MailImapInputSchema.nullable().optional(),
|
|
27
53
|
pacePerMinute: z.int().min(1).max(6000),
|
|
28
54
|
dailyRecipientLimit: z.int().min(1).max(2147483647).nullable(),
|
|
29
55
|
maxAttachmentBytes: z.int().min(1).max(26214400),
|
|
@@ -46,6 +72,19 @@ export const MailProfileSchema = z.object({
|
|
|
46
72
|
});
|
|
47
73
|
export type MailProfile = z.infer<typeof MailProfileSchema>;
|
|
48
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",
|
|
49
88
|
"mail_unavailable",
|
|
50
89
|
"mail_not_declared",
|
|
51
90
|
"profile_unknown",
|
|
@@ -56,9 +95,13 @@ export const MailErrorCodeSchema = z.enum([
|
|
|
56
95
|
"smtp_failed",
|
|
57
96
|
]);
|
|
58
97
|
export type MailErrorCode = z.infer<typeof MailErrorCodeSchema>;
|
|
59
|
-
export const
|
|
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({
|
|
60
101
|
key: MailProfileKeySchema,
|
|
61
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(),
|
|
62
105
|
isDefault: z.boolean(),
|
|
63
106
|
revision: z.int(),
|
|
64
107
|
createdAt: z.string(),
|
|
@@ -75,3 +118,148 @@ export type AdminMailApp = {
|
|
|
75
118
|
mode: "default" | "selected";
|
|
76
119
|
profiles: string[];
|
|
77
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
|
+
};
|