@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.
Files changed (79) hide show
  1. package/package.json +2 -2
  2. package/src/ai/browser-code-contracts.ts +33 -63
  3. package/src/ai/browser.ts +1 -0
  4. package/src/ai/chat/blocks.tsx +16 -7
  5. package/src/ai/chat/live-turn.browser-harness.tsx +6 -3
  6. package/src/ai/chat/message-actions.tsx +123 -101
  7. package/src/ai/chat/message-utils.ts +30 -1
  8. package/src/ai/chat/messages.ts +198 -0
  9. package/src/ai/chat/presentation.tsx +66 -20
  10. package/src/ai/chat/tool-groups.ts +1 -1
  11. package/src/ai/chat/turn-error.ts +21 -0
  12. package/src/ai/chat/turn-view.tsx +55 -4
  13. package/src/ai/chat/user-message.tsx +19 -14
  14. package/src/ai/chat/visual-tools.tsx +1 -1
  15. package/src/ai/client/controller.ts +23 -6
  16. package/src/ai/code-mode-skill.ts +21 -25
  17. package/src/ai/code-runtime-tools.ts +5 -1
  18. package/src/ai/code-source-contracts.ts +2 -2
  19. package/src/ai/data-analysis-skill.ts +3 -3
  20. package/src/ai/default-tools.ts +15 -16
  21. package/src/ai/executor.ts +135 -59
  22. package/src/ai/index.ts +2 -0
  23. package/src/ai/open-tool-calls.ts +87 -0
  24. package/src/ai/routes.ts +6 -0
  25. package/src/ai/run-timeout.ts +4 -5
  26. package/src/ai/runtime.ts +8 -0
  27. package/src/ai/skill-seeds.ts +4 -4
  28. package/src/ai/store.ts +46 -14
  29. package/src/ai/system-prompt.ts +4 -21
  30. package/src/ai/turn-failure.ts +100 -0
  31. package/src/ai/turn-policy.ts +2 -1
  32. package/src/ai/types.ts +28 -1
  33. package/src/api/admin-outgoing-mail.ts +185 -5
  34. package/src/browser/FileChooser.tsx +11 -0
  35. package/src/browser/file-chooser-messages.ts +2 -0
  36. package/src/cli/admin/index.ts +3 -1
  37. package/src/cli/admin/notifications.ts +6 -0
  38. package/src/cli/admin/outgoing-mail.ts +104 -7
  39. package/src/contracts/outgoing-mail.ts +189 -1
  40. package/src/services/help/store.ts +2 -1
  41. package/src/services/index.ts +11 -1
  42. package/src/services/notifications/batches.ts +139 -79
  43. package/src/services/notifications/channels.ts +33 -13
  44. package/src/services/notifications/dispatcher.ts +57 -5
  45. package/src/services/notifications/email-frame.fixture.html +51 -0
  46. package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
  47. package/src/services/notifications/email-mail.ts +101 -0
  48. package/src/services/notifications/index.ts +49 -36
  49. package/src/services/notifications/observability.ts +9 -1
  50. package/src/services/notifications/platform.ts +1 -1
  51. package/src/services/notifications/runtime.ts +9 -3
  52. package/src/services/outgoing-mail/admin.ts +84 -0
  53. package/src/services/outgoing-mail/attachments.ts +135 -0
  54. package/src/services/outgoing-mail/bulk.ts +11 -0
  55. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  56. package/src/services/outgoing-mail/drain.ts +60 -0
  57. package/src/services/outgoing-mail/enqueue.ts +180 -0
  58. package/src/services/outgoing-mail/index.ts +103 -10
  59. package/src/services/outgoing-mail/message.ts +14 -0
  60. package/src/services/outgoing-mail/messages.ts +431 -0
  61. package/src/services/outgoing-mail/retention.ts +48 -0
  62. package/src/services/outgoing-mail/runtime.ts +72 -0
  63. package/src/services/outgoing-mail/send.ts +130 -0
  64. package/src/services/outgoing-mail/store.ts +115 -4
  65. package/src/services/outgoing-mail/sync.ts +39 -0
  66. package/src/services/outgoing-mail/transport.ts +5 -1
  67. package/src/services/pdf/markdown.ts +22 -4
  68. package/src/services/postgres.ts +15 -0
  69. package/src/services/settings/core-settings.ts +18 -0
  70. package/src/shared/ai-platform-prompt.ts +48 -4
  71. package/src/shared/markdown/extensions/links.ts +28 -20
  72. package/src/shared/markdown/index.ts +14 -5
  73. package/src/shared/markdown/shared.ts +0 -7
  74. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  75. package/src/ssr/platform-messages.ts +1 -1
  76. package/src/styles/effects.css +15 -17
  77. package/src/styles/tokens.css +2 -0
  78. package/src/styles/utilities-markdown-editor.css +4 -39
  79. 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 { MailAppAccessSchema, MailProfileInputSchema, MailProfileKeySchema } from "../contracts/outgoing-mail";
5
- import { type AuthContext, auth } from "../server";
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 validate = <Target extends "param" | "json", Schema extends z.ZodType>(target: Target, schema: Schema) =>
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: "invalid_profile" as const,
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",
@@ -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 application access.",
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 { type AdminMailApp, type AdminMailProfile, MailProfileInputSchema } from "../../contracts/outgoing-mail";
2
- import { arg, type CloudCliContext, command, confirmFlag, flag, readCliInput } from "../index";
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) => ({ key: p.key, name: p.name, from: p.fromAddress, default: p.isDefault, revision: p.revision })),
24
- [{ key: "key" }, { key: "name" }, { key: "from" }, { key: "default" }, { key: "revision" }],
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
- print(ctx, await apiGet(ctx, profilePath(args.key)));
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: { config: flag.input({ required: true, description: "Profile JSON; SMTP passwords only via --config-file or --stdin" }) },
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
- print(ctx, await apiJson(ctx, "PUT", profilePath(args.key), MailProfileInputSchema.parse(raw)));
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 AdminMailProfileSchema = MailProfileInputSchema.omit({ smtpPassword: true, revision: true }).extend({
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
+ };