@k2b/cloud 0.25.0 → 0.27.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 +3 -3
- package/src/_internal/capabilities.ts +12 -0
- package/src/_internal/define-app.ts +8 -1
- package/src/_internal/process-identity.ts +7 -1
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/registry.ts +1 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/GroupCoverage.tsx +175 -0
- package/src/access/PermissionEditor.tsx +119 -99
- package/src/access/messages.ts +30 -0
- package/src/ai/admin.ts +1 -0
- package/src/ai/approval-routes.ts +5 -5
- package/src/ai/browser-code-contracts.ts +14 -2
- package/src/ai/browser.ts +8 -1
- package/src/ai/capabilities.ts +115 -33
- package/src/ai/chat/blocks.tsx +86 -179
- package/src/ai/chat/builtin-tools.tsx +83 -48
- package/src/ai/chat/file-tools.tsx +4 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +44 -0
- package/src/ai/chat/message-actions.tsx +6 -2
- package/src/ai/chat/message-utils.ts +17 -14
- package/src/ai/chat/messages.ts +330 -2
- package/src/ai/chat/presentation.tsx +278 -104
- package/src/ai/chat/tool-groups.ts +55 -35
- package/src/ai/chat/turn-layout.ts +141 -0
- package/src/ai/chat/turn-view.tsx +644 -0
- package/src/ai/client/controller.ts +60 -31
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +42 -6
- package/src/ai/code-mode-skill.ts +27 -27
- package/src/ai/code-runtime-tools.ts +10 -1
- package/src/ai/code-source-contracts.ts +54 -4
- package/src/ai/code-source-tools.ts +10 -3
- package/src/ai/credentials.ts +17 -3
- package/src/ai/data-analysis-skill.ts +2 -2
- package/src/ai/default-tools.ts +2 -2
- package/src/ai/executor.ts +177 -80
- package/src/ai/file-context.ts +14 -2
- package/src/ai/file-tools.ts +17 -3
- package/src/ai/files-store.ts +134 -11
- package/src/ai/grids-skill.ts +2 -2
- package/src/ai/index.ts +7 -0
- package/src/ai/memories.ts +14 -0
- package/src/ai/migrate.ts +125 -0
- package/src/ai/model-request-settings.ts +98 -0
- package/src/ai/protocol.ts +26 -4
- package/src/ai/provider-fetch.ts +67 -15
- package/src/ai/provider-retry.ts +105 -0
- package/src/ai/provider.ts +7 -1
- package/src/ai/quota-provider.ts +16 -7
- package/src/ai/request-headers.ts +117 -0
- package/src/ai/routes.ts +34 -6
- package/src/ai/runtime.ts +1 -1
- package/src/ai/settings.ts +19 -2
- package/src/ai/skill-seeds.ts +31 -3
- package/src/ai/skills.ts +26 -0
- package/src/ai/solid.ts +1 -1
- package/src/ai/store.ts +202 -59
- package/src/ai/stream.ts +182 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +25 -0
- package/src/ai/timeline.ts +9 -11
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-policy.ts +247 -0
- package/src/ai/turn-timing.ts +31 -3
- package/src/ai/types.ts +36 -5
- package/src/api/admin-ai-quotas.ts +36 -1
- package/src/api/admin-core-settings.ts +16 -23
- package/src/api/admin-outgoing-mail.ts +62 -0
- package/src/api/index.ts +2 -0
- package/src/cli/admin/ai-quotas.ts +70 -1
- package/src/cli/admin/index.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +118 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/outgoing-mail.ts +77 -0
- package/src/contracts/registry.ts +4 -0
- package/src/services/index.ts +3 -0
- package/src/services/notifications/email.ts +16 -26
- package/src/services/outgoing-mail/index.ts +19 -0
- package/src/services/outgoing-mail/store.ts +286 -0
- package/src/services/outgoing-mail/test-send.ts +40 -0
- package/src/services/outgoing-mail/transport.ts +13 -0
- package/src/services/settings/core-settings.ts +1 -38
- package/src/services/settings/store.ts +5 -1
- package/src/shared/ai-model-request-settings.ts +21 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-request-options.ts +185 -0
- package/src/shared/app-presentation.ts +10 -2
- package/src/ssr/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/workspace-navigation.ts +7 -1
- package/src/styles/effects.css +69 -0
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { type AiModelPricing, AiModelPricingSchema } from "../../shared/ai-costs";
|
|
2
|
+
import {
|
|
3
|
+
type AiModelRequestSettings,
|
|
4
|
+
AiModelRequestSettingsSchema,
|
|
5
|
+
AiModelRequestSettingsUpdateSchema,
|
|
6
|
+
} from "../../shared/ai-model-request-settings";
|
|
2
7
|
import {
|
|
3
8
|
type AiQuotaConfig,
|
|
4
9
|
AiQuotaConfigSchema,
|
|
@@ -7,7 +12,7 @@ import {
|
|
|
7
12
|
AiQuotaReportQuerySchema,
|
|
8
13
|
AiQuotaResetSchema,
|
|
9
14
|
} from "../../shared/ai-quotas";
|
|
10
|
-
import { type CloudCliContext, cliText, command, confirmFlag, flag, printRows, printStructured } from "../index";
|
|
15
|
+
import { type CloudCliContext, cliText, command, confirmFlag, flag, printRows, printStructured, readCliInput } from "../index";
|
|
11
16
|
import { apiGet, apiJson, queryString, readJsonInput } from "./shared";
|
|
12
17
|
|
|
13
18
|
const path = "/api/admin/core/ai-quotas";
|
|
@@ -23,6 +28,70 @@ const confirm = (ctx: CloudCliContext, yes: boolean) => {
|
|
|
23
28
|
};
|
|
24
29
|
|
|
25
30
|
export const aiQuotaCommands = [
|
|
31
|
+
command("ai models settings get", {
|
|
32
|
+
summary: "Read model thinking level, extra parameters and configured header names",
|
|
33
|
+
flags: { id: flag.string({ required: true, description: "Model profile ID" }) },
|
|
34
|
+
async run({ ctx, flags }) {
|
|
35
|
+
print(ctx, AiModelRequestSettingsSchema.parse(await apiGet(ctx, `${path}/models/${encodeURIComponent(flags.id!)}/settings`)));
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
command("ai models settings set", {
|
|
39
|
+
summary: "Patch one model's request settings; header values are write-only",
|
|
40
|
+
flags: {
|
|
41
|
+
id: flag.string({ required: true, description: "Model profile ID" }),
|
|
42
|
+
thinkingLevel: flag.string({ name: "thinking-level", description: "Provider-specific thinking level" }),
|
|
43
|
+
clearThinkingLevel: flag.boolean({ name: "clear-thinking-level", description: "Use the model's default thinking level" }),
|
|
44
|
+
extraBody: flag.input({ name: "extra-body", description: "Extra parameters JSON: --extra-body, --extra-body-file or --stdin" }),
|
|
45
|
+
clearExtraBody: flag.boolean({ name: "clear-extra-body", description: "Remove extra parameters" }),
|
|
46
|
+
headers: flag.input({
|
|
47
|
+
stdinName: "headers-stdin",
|
|
48
|
+
description: "Header patch JSON; use --headers-file or --headers-stdin for secrets",
|
|
49
|
+
}),
|
|
50
|
+
clearHeaders: flag.boolean({ name: "clear-headers", description: "Remove all configured extra headers before applying the patch" }),
|
|
51
|
+
yes: confirmFlag("Confirm model request settings change"),
|
|
52
|
+
},
|
|
53
|
+
async run({ ctx, flags }) {
|
|
54
|
+
if (!flags.yes) throw new Error("Model request settings changes require --yes.");
|
|
55
|
+
if (flags.thinkingLevel !== undefined && flags.clearThinkingLevel)
|
|
56
|
+
throw new Error("Choose --thinking-level or --clear-thinking-level.");
|
|
57
|
+
if (flags.extraBody.provided && flags.clearExtraBody) throw new Error("Choose --extra-body or --clear-extra-body.");
|
|
58
|
+
if (
|
|
59
|
+
!flags.extraBody.provided &&
|
|
60
|
+
!flags.headers.provided &&
|
|
61
|
+
flags.thinkingLevel === undefined &&
|
|
62
|
+
!flags.clearThinkingLevel &&
|
|
63
|
+
!flags.clearExtraBody &&
|
|
64
|
+
!flags.clearHeaders
|
|
65
|
+
)
|
|
66
|
+
throw new Error("Select at least one model request setting to change.");
|
|
67
|
+
if (flags.extraBody.source === "stdin" && flags.headers.source === "stdin")
|
|
68
|
+
throw new Error("Read one JSON input from stdin and supply the other from a file.");
|
|
69
|
+
const parseInput = async (input: typeof flags.headers, label: string): Promise<unknown> => {
|
|
70
|
+
const raw = await readCliInput(input, { required: true, label });
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(raw!);
|
|
73
|
+
} catch {
|
|
74
|
+
throw new Error(`Invalid ${label} JSON.`);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const patch: Record<string, unknown> = {};
|
|
78
|
+
if (flags.thinkingLevel !== undefined || flags.clearThinkingLevel)
|
|
79
|
+
patch.reasoningEffort = flags.clearThinkingLevel ? null : flags.thinkingLevel;
|
|
80
|
+
if (flags.extraBody.provided || flags.clearExtraBody)
|
|
81
|
+
patch.extraBody = flags.clearExtraBody ? null : await parseInput(flags.extraBody, "extra parameters");
|
|
82
|
+
if (flags.headers.provided) patch.requestHeaders = await parseInput(flags.headers, "extra headers");
|
|
83
|
+
if (flags.clearHeaders) patch.clearRequestHeaders = true;
|
|
84
|
+
// Validate locally before reading the current revision or sending secrets.
|
|
85
|
+
const valid = AiModelRequestSettingsUpdateSchema.parse({ ...patch, expected: "pending" });
|
|
86
|
+
const current = await apiGet<AiModelRequestSettings>(ctx, `${path}/models/${encodeURIComponent(flags.id!)}/settings`);
|
|
87
|
+
print(
|
|
88
|
+
ctx,
|
|
89
|
+
AiModelRequestSettingsSchema.parse(
|
|
90
|
+
await apiJson(ctx, "PUT", `${path}/models/${encodeURIComponent(flags.id!)}/settings`, { ...valid, expected: current.revision }),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
26
95
|
command("ai models pricing get", {
|
|
27
96
|
summary: "List configured model input/output reference prices per million tokens",
|
|
28
97
|
async run({ ctx }) {
|
package/src/cli/admin/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { logCommands } from "./logs";
|
|
|
25
25
|
import { metricsCommands } from "./metrics";
|
|
26
26
|
import { natsCommands } from "./nats";
|
|
27
27
|
import { notificationCommands } from "./notifications";
|
|
28
|
+
import { outgoingMailCommands } from "./outgoing-mail";
|
|
28
29
|
import { syncCommands } from "./sync";
|
|
29
30
|
import { telemetryCommands } from "./telemetry";
|
|
30
31
|
import { webhookCommands } from "./webhooks";
|
|
@@ -38,6 +39,7 @@ export default defineCliCommands({
|
|
|
38
39
|
ai: "Manage AI models, quotas, Skills, and usage.",
|
|
39
40
|
"ai models": "Manage AI model configuration.",
|
|
40
41
|
"ai models pricing": "Manage model reference prices.",
|
|
42
|
+
"ai models settings": "Manage model thinking levels, extra parameters, and extra headers.",
|
|
41
43
|
"ai quotas background": "Inspect and release the background emergency stop.",
|
|
42
44
|
"ai skills": "Inspect Assistant Skills and trusted templates.",
|
|
43
45
|
"ai usage": "Inspect AI runs and usage facets.",
|
|
@@ -55,6 +57,9 @@ export default defineCliCommands({
|
|
|
55
57
|
"nats consumers": "Inspect NATS consumers.",
|
|
56
58
|
"nats streams": "Find NATS streams and buckets.",
|
|
57
59
|
"notification-batches": "Manage account notification batches.",
|
|
60
|
+
"outgoing-mail": "Manage outgoing mail sender profiles and application access.",
|
|
61
|
+
"outgoing-mail profiles": "Manage outgoing mail sender profiles.",
|
|
62
|
+
"outgoing-mail apps": "Manage application outgoing mail access.",
|
|
58
63
|
notifications: "Inspect and resend email notifications.",
|
|
59
64
|
postgres: "Inspect Postgres diagnostics.",
|
|
60
65
|
redis: "Inspect Valkey diagnostics.",
|
|
@@ -96,6 +101,7 @@ export default defineCliCommands({
|
|
|
96
101
|
...workflowCommands,
|
|
97
102
|
...dataCommands,
|
|
98
103
|
...notificationCommands,
|
|
104
|
+
...outgoingMailCommands,
|
|
99
105
|
...webhookCommands,
|
|
100
106
|
...metricsCommands,
|
|
101
107
|
...natsCommands,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { type AdminMailApp, type AdminMailProfile, MailProfileInputSchema } from "../../contracts/outgoing-mail";
|
|
2
|
+
import { arg, type CloudCliContext, command, confirmFlag, flag, readCliInput } from "../index";
|
|
3
|
+
import { apiGet, apiJson, printJsonOrTable } from "./shared";
|
|
4
|
+
|
|
5
|
+
const root = "/api/admin/core/outgoing-mail";
|
|
6
|
+
const profilePath = (key: string) => `${root}/profiles/${encodeURIComponent(key)}`;
|
|
7
|
+
const keyArgs = { key: arg.required({ valueLabel: "key" }) };
|
|
8
|
+
const print = (ctx: CloudCliContext, value: unknown) => {
|
|
9
|
+
if (ctx.options.output === "json") ctx.json(value);
|
|
10
|
+
else ctx.print(JSON.stringify(value, null, 2));
|
|
11
|
+
};
|
|
12
|
+
const confirmed = (yes: boolean) => {
|
|
13
|
+
if (!yes) throw new Error("Outgoing mail policy changes require --yes.");
|
|
14
|
+
};
|
|
15
|
+
export const outgoingMailCommands = [
|
|
16
|
+
command("outgoing-mail profiles list", {
|
|
17
|
+
summary: "List outgoing mail sender profiles",
|
|
18
|
+
async run({ ctx }) {
|
|
19
|
+
const result = await apiGet<{ items: AdminMailProfile[] }>(ctx, `${root}/profiles`);
|
|
20
|
+
printJsonOrTable(
|
|
21
|
+
ctx,
|
|
22
|
+
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" }],
|
|
25
|
+
);
|
|
26
|
+
},
|
|
27
|
+
}),
|
|
28
|
+
command("outgoing-mail profiles get", {
|
|
29
|
+
summary: "Read one outgoing mail sender profile",
|
|
30
|
+
args: keyArgs,
|
|
31
|
+
async run({ ctx, args }) {
|
|
32
|
+
print(ctx, await apiGet(ctx, profilePath(args.key)));
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
command("outgoing-mail profiles put", {
|
|
36
|
+
summary: "Create or replace a sender profile with its current revision",
|
|
37
|
+
args: keyArgs,
|
|
38
|
+
flags: { config: flag.input({ required: true, description: "Profile JSON; SMTP passwords only via --config-file or --stdin" }) },
|
|
39
|
+
async run({ ctx, args, flags }) {
|
|
40
|
+
const input = await readCliInput(flags.config, { label: "outgoing mail profile", required: true });
|
|
41
|
+
let raw: unknown;
|
|
42
|
+
try {
|
|
43
|
+
raw = JSON.parse(input ?? "");
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error("Invalid outgoing mail profile JSON.");
|
|
46
|
+
}
|
|
47
|
+
if (flags.config.source === "value" && raw && typeof raw === "object" && "smtpPassword" in raw)
|
|
48
|
+
throw new Error("Pass smtpPassword only through --config-file or --stdin.");
|
|
49
|
+
print(ctx, await apiJson(ctx, "PUT", profilePath(args.key), MailProfileInputSchema.parse(raw)));
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
command("outgoing-mail profiles set-default", {
|
|
53
|
+
summary: "Choose the default outgoing mail sender",
|
|
54
|
+
args: keyArgs,
|
|
55
|
+
flags: { yes: confirmFlag("Confirm changing the default sender") },
|
|
56
|
+
async run({ ctx, args, flags }) {
|
|
57
|
+
confirmed(flags.yes);
|
|
58
|
+
print(ctx, await apiJson(ctx, "POST", `${profilePath(args.key)}/default`));
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
command("outgoing-mail profiles delete", {
|
|
62
|
+
summary: "Delete a sender profile that is not the default",
|
|
63
|
+
args: keyArgs,
|
|
64
|
+
flags: { yes: confirmFlag("Confirm deleting the sender profile") },
|
|
65
|
+
async run({ ctx, args, flags }) {
|
|
66
|
+
confirmed(flags.yes);
|
|
67
|
+
const response = await ctx.fetch(profilePath(args.key), { method: "DELETE" });
|
|
68
|
+
if (!response.ok) await ctx.readJson(response);
|
|
69
|
+
print(ctx, { deleted: args.key });
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
command("outgoing-mail profiles test", {
|
|
73
|
+
summary: "Send a test email through a sender profile",
|
|
74
|
+
args: keyArgs,
|
|
75
|
+
flags: { to: flag.string({ required: true, description: "Recipient email address" }) },
|
|
76
|
+
async run({ ctx, args, flags }) {
|
|
77
|
+
print(ctx, await apiJson(ctx, "POST", `${profilePath(args.key)}/test`, { recipient: flags.to }));
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
command("outgoing-mail apps list", {
|
|
81
|
+
summary: "List application outgoing mail permissions and profile access",
|
|
82
|
+
async run({ ctx }) {
|
|
83
|
+
const result = await apiGet<{ defaultProfile: string | null; items: AdminMailApp[] }>(ctx, `${root}/apps`);
|
|
84
|
+
printJsonOrTable(
|
|
85
|
+
ctx,
|
|
86
|
+
result,
|
|
87
|
+
result.items.map((app) => ({ ...app, profiles: app.profiles.join(",") })),
|
|
88
|
+
[{ key: "appId" }, { key: "name" }, { key: "declared" }, { key: "mode" }, { key: "profiles" }],
|
|
89
|
+
);
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
command("outgoing-mail apps set", {
|
|
93
|
+
summary: "Set default, selected, or blocked outgoing mail access",
|
|
94
|
+
args: { app: arg.required({ valueLabel: "app" }) },
|
|
95
|
+
flags: {
|
|
96
|
+
default: flag.boolean({ description: "Use the default sender" }),
|
|
97
|
+
profiles: flag.string({ description: "Comma-separated profile keys" }),
|
|
98
|
+
none: flag.boolean({ description: "Block sending with an empty selected list" }),
|
|
99
|
+
yes: confirmFlag("Confirm application outgoing mail access change"),
|
|
100
|
+
},
|
|
101
|
+
async run({ ctx, args, flags }) {
|
|
102
|
+
confirmed(flags.yes);
|
|
103
|
+
if ([flags.default, flags.none, flags.profiles !== undefined].filter(Boolean).length !== 1)
|
|
104
|
+
throw new Error("Choose exactly one of --default, --profiles or --none.");
|
|
105
|
+
const profiles = flags.profiles?.split(",").map((key) => key.trim());
|
|
106
|
+
if (profiles?.some((key) => !key)) throw new Error("--profiles requires non-empty profile keys; use --none to block sending.");
|
|
107
|
+
print(
|
|
108
|
+
ctx,
|
|
109
|
+
await apiJson(
|
|
110
|
+
ctx,
|
|
111
|
+
"PUT",
|
|
112
|
+
`${root}/apps/${encodeURIComponent(args.app)}`,
|
|
113
|
+
flags.default ? { mode: "default" } : { mode: "selected", profiles: profiles ?? [] },
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
];
|
package/src/contracts/app.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Sync } from "@k2b/sync";
|
|
2
|
+
import type { PlatformPermission } from "./outgoing-mail";
|
|
2
3
|
import type { AppRegistryHelpSummary } from "./registry";
|
|
3
4
|
import type { Role } from "./shared";
|
|
4
5
|
import type { DashboardWidgetPresentation } from "./widgets";
|
|
@@ -97,6 +98,7 @@ export type AppPwaPart = {
|
|
|
97
98
|
};
|
|
98
99
|
|
|
99
100
|
export type AppMeta = {
|
|
101
|
+
platformPermissions?: readonly PlatformPermission[];
|
|
100
102
|
id: string;
|
|
101
103
|
name: string;
|
|
102
104
|
icon: string;
|
package/src/contracts/index.ts
CHANGED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const PlatformPermissionSchema = z.enum(["mail:send"]);
|
|
4
|
+
export type PlatformPermission = z.infer<typeof PlatformPermissionSchema>;
|
|
5
|
+
export const MailProfileKeySchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
|
|
6
|
+
const headerText = z
|
|
7
|
+
.string()
|
|
8
|
+
.trim()
|
|
9
|
+
.min(1)
|
|
10
|
+
.max(120)
|
|
11
|
+
.refine((value) => !/[\r\n\0]/.test(value), "Header must not contain control characters");
|
|
12
|
+
export const MailProfileInputSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
name: headerText,
|
|
15
|
+
fromAddress: z.email().max(320),
|
|
16
|
+
fromName: headerText.nullable(),
|
|
17
|
+
smtpHost: z
|
|
18
|
+
.string()
|
|
19
|
+
.trim()
|
|
20
|
+
.min(1)
|
|
21
|
+
.max(253)
|
|
22
|
+
.refine((value) => !/[\s/@\0]/.test(value), "Use an SMTP hostname"),
|
|
23
|
+
smtpPort: z.int().min(1).max(65535),
|
|
24
|
+
smtpSecure: z.boolean(),
|
|
25
|
+
smtpUser: z.string().max(320).nullable(),
|
|
26
|
+
smtpPassword: z.string().max(16384).nullable().optional(),
|
|
27
|
+
pacePerMinute: z.int().min(1).max(6000),
|
|
28
|
+
dailyRecipientLimit: z.int().min(1).max(2147483647).nullable(),
|
|
29
|
+
maxAttachmentBytes: z.int().min(1).max(26214400),
|
|
30
|
+
revision: z.int().min(1).max(2147483647).optional(),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
export type MailProfileInput = z.infer<typeof MailProfileInputSchema>;
|
|
34
|
+
export const MailAppAccessSchema = z.discriminatedUnion("mode", [
|
|
35
|
+
z.object({ mode: z.literal("default") }).strict(),
|
|
36
|
+
z.object({ mode: z.literal("selected"), profiles: z.array(MailProfileKeySchema) }).strict(),
|
|
37
|
+
]);
|
|
38
|
+
export type MailAppAccess = z.infer<typeof MailAppAccessSchema>;
|
|
39
|
+
export const MailProfileSchema = z.object({
|
|
40
|
+
key: MailProfileKeySchema,
|
|
41
|
+
name: z.string(),
|
|
42
|
+
from: z.email(),
|
|
43
|
+
default: z.boolean(),
|
|
44
|
+
maxAttachmentBytes: z.int(),
|
|
45
|
+
quota: z.object({ dailyRecipients: z.int().nullable(), usedLast24h: z.int() }),
|
|
46
|
+
});
|
|
47
|
+
export type MailProfile = z.infer<typeof MailProfileSchema>;
|
|
48
|
+
export const MailErrorCodeSchema = z.enum([
|
|
49
|
+
"mail_unavailable",
|
|
50
|
+
"mail_not_declared",
|
|
51
|
+
"profile_unknown",
|
|
52
|
+
"profile_exists",
|
|
53
|
+
"profile_is_default",
|
|
54
|
+
"revision_conflict",
|
|
55
|
+
"invalid_profile",
|
|
56
|
+
"smtp_failed",
|
|
57
|
+
]);
|
|
58
|
+
export type MailErrorCode = z.infer<typeof MailErrorCodeSchema>;
|
|
59
|
+
export const AdminMailProfileSchema = MailProfileInputSchema.omit({ smtpPassword: true, revision: true }).extend({
|
|
60
|
+
key: MailProfileKeySchema,
|
|
61
|
+
hasPassword: z.boolean(),
|
|
62
|
+
isDefault: z.boolean(),
|
|
63
|
+
revision: z.int(),
|
|
64
|
+
createdAt: z.string(),
|
|
65
|
+
updatedAt: z.string(),
|
|
66
|
+
updatedBy: z.string().nullable(),
|
|
67
|
+
appCount: z.int(),
|
|
68
|
+
});
|
|
69
|
+
export type AdminMailProfile = z.infer<typeof AdminMailProfileSchema>;
|
|
70
|
+
export type AdminMailApp = {
|
|
71
|
+
appId: string;
|
|
72
|
+
name: string;
|
|
73
|
+
registered: boolean;
|
|
74
|
+
declared: boolean;
|
|
75
|
+
mode: "default" | "selected";
|
|
76
|
+
profiles: string[];
|
|
77
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPresentationCatalog, AppSearchLink } from "./app";
|
|
2
2
|
import type { CapabilityManifest, CapabilityPresentationCatalog } from "./capabilities";
|
|
3
|
+
import type { PlatformPermission } from "./outgoing-mail";
|
|
3
4
|
import type { Role } from "./shared";
|
|
4
5
|
import type { DashboardWidgetPresentation } from "./widgets";
|
|
5
6
|
|
|
@@ -38,6 +39,8 @@ export type CapabilityRegistryEntry = {
|
|
|
38
39
|
endpoint: string;
|
|
39
40
|
manifest: CapabilityManifest;
|
|
40
41
|
presentation?: CapabilityPresentationCatalog;
|
|
42
|
+
/** The app's own name and description overlays, so capability consumers can present the app in the reader's locale. */
|
|
43
|
+
appPresentation?: AppPresentationCatalog;
|
|
41
44
|
};
|
|
42
45
|
|
|
43
46
|
export type AppRegistryHelpSummary = { manifestHash: string; pageBase: string; baseLocale: string };
|
|
@@ -63,6 +66,7 @@ export type AppRuntimeMetadata = {
|
|
|
63
66
|
};
|
|
64
67
|
|
|
65
68
|
export type AppRegistryEntry = {
|
|
69
|
+
platformPermissions?: readonly PlatformPermission[];
|
|
66
70
|
id: string;
|
|
67
71
|
name: string;
|
|
68
72
|
icon: string;
|
package/src/services/index.ts
CHANGED
|
@@ -204,3 +204,6 @@ export { legalConsent } from "./legal-consent";
|
|
|
204
204
|
export { railShortcuts } from "./rail-shortcuts";
|
|
205
205
|
|
|
206
206
|
export type { WebVitalsOverview, WebVitalsQuery, WebVitalMetric, WebVitalName } from "./logging/web-vitals";
|
|
207
|
+
|
|
208
|
+
export { mail } from "./outgoing-mail";
|
|
209
|
+
export type { MailProfile } from "../contracts/outgoing-mail";
|
|
@@ -1,24 +1,10 @@
|
|
|
1
|
-
import { createTransport, type Transporter } from "nodemailer";
|
|
2
1
|
import sanitizeHtml from "sanitize-html";
|
|
3
2
|
import { sanitizeEmailHtml } from "../../shared";
|
|
3
|
+
import { resolveMailCredentials } from "../outgoing-mail/store";
|
|
4
|
+
import { buildMailTransport } from "../outgoing-mail/transport";
|
|
4
5
|
import * as settings from "../settings";
|
|
5
6
|
import { coreSettings } from "../settings/api";
|
|
6
7
|
|
|
7
|
-
/** Lazily-created transporter — uses current settings on each send. */
|
|
8
|
-
const getTransporter = async (): Promise<Transporter> => {
|
|
9
|
-
const host = await settings.get<string>("mail.noreply.smtp_host");
|
|
10
|
-
const port = await settings.get<number>("mail.noreply.smtp_port");
|
|
11
|
-
const user = await settings.get<string>("mail.noreply.user");
|
|
12
|
-
const pass = await settings.get<string>("mail.noreply.password");
|
|
13
|
-
|
|
14
|
-
return createTransport({
|
|
15
|
-
host,
|
|
16
|
-
port,
|
|
17
|
-
secure: port === 465,
|
|
18
|
-
auth: { user, pass },
|
|
19
|
-
});
|
|
20
|
-
};
|
|
21
|
-
|
|
22
8
|
/** Sanitize plain text content (no HTML allowed). */
|
|
23
9
|
const sanitizeContent = (content: string): string => sanitizeHtml(content, { allowedTags: [], allowedAttributes: {} });
|
|
24
10
|
|
|
@@ -31,7 +17,7 @@ export const sendEmail = async (
|
|
|
31
17
|
const rawAppUrl = await settings.get<string>("app.url");
|
|
32
18
|
const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
|
|
33
19
|
const appName = await settings.get<string>("app.name");
|
|
34
|
-
const
|
|
20
|
+
const profile = await resolveMailCredentials();
|
|
35
21
|
|
|
36
22
|
let body = "";
|
|
37
23
|
if (opts.rawHtml) {
|
|
@@ -42,16 +28,20 @@ export const sendEmail = async (
|
|
|
42
28
|
const text = opts.content ? sanitizeContent(opts.content) : undefined;
|
|
43
29
|
|
|
44
30
|
const html = await buildHtml(appUrl, appName, body);
|
|
45
|
-
const transporter =
|
|
31
|
+
const transporter = buildMailTransport(profile);
|
|
46
32
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
33
|
+
try {
|
|
34
|
+
await transporter.sendMail({
|
|
35
|
+
from: { address: profile.fromAddress, name: profile.fromName ?? appName },
|
|
36
|
+
to,
|
|
37
|
+
subject,
|
|
38
|
+
...(opts.messageId ? { messageId: opts.messageId } : {}),
|
|
39
|
+
html,
|
|
40
|
+
...(text ? { text } : {}),
|
|
41
|
+
});
|
|
42
|
+
} finally {
|
|
43
|
+
transporter.close();
|
|
44
|
+
}
|
|
55
45
|
};
|
|
56
46
|
|
|
57
47
|
/**
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { fail, ok, type Result } from "@k2b/stdlib";
|
|
2
|
+
import { getProcessApplicationId, getProcessPlatformPermissions } from "../../_internal/process-identity";
|
|
3
|
+
import type { MailProfile } from "../../contracts/outgoing-mail";
|
|
4
|
+
import { outgoingMailStore } from "./store";
|
|
5
|
+
|
|
6
|
+
export const mail = {
|
|
7
|
+
async profiles(): Promise<Result<MailProfile[]>> {
|
|
8
|
+
const appId = getProcessApplicationId();
|
|
9
|
+
if (!appId)
|
|
10
|
+
return fail({ code: "mail_unavailable", message: "Start the application before reading outgoing mail profiles.", status: 500 });
|
|
11
|
+
if (!getProcessPlatformPermissions().includes("mail:send"))
|
|
12
|
+
return fail({ code: "mail_not_declared", message: 'Declare platformPermissions: ["mail:send"].', status: 403 });
|
|
13
|
+
try {
|
|
14
|
+
return ok(await outgoingMailStore.profilesForApp(appId));
|
|
15
|
+
} catch {
|
|
16
|
+
return fail({ code: "mail_unavailable", message: "Outgoing mail profiles are unavailable.", status: 500 });
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
};
|