@k2b/cloud 0.26.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/define-app.ts +8 -1
- package/src/_internal/process-identity.ts +7 -1
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/ai/admin.ts +1 -0
- package/src/ai/browser-code-contracts.ts +14 -2
- package/src/ai/browser.ts +8 -1
- package/src/ai/capabilities.ts +82 -22
- package/src/ai/chat/builtin-tools.tsx +31 -23
- package/src/ai/chat/file-tools.tsx +4 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +11 -4
- package/src/ai/chat/messages.ts +8 -2
- package/src/ai/chat/presentation.tsx +29 -9
- package/src/ai/chat/turn-view.tsx +47 -12
- package/src/ai/client/controller.ts +60 -31
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +7 -2
- 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 +148 -84
- 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 +6 -0
- package/src/ai/provider.ts +7 -1
- package/src/ai/quota-provider.ts +2 -2
- 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 +109 -57
- package/src/ai/stream.ts +180 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +25 -0
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-policy.ts +247 -0
- package/src/ai/types.ts +24 -3
- 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 +2 -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/ssr/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/workspace-navigation.ts +7 -1
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
|
|
|
@@ -65,6 +66,7 @@ export type AppRuntimeMetadata = {
|
|
|
65
66
|
};
|
|
66
67
|
|
|
67
68
|
export type AppRegistryEntry = {
|
|
69
|
+
platformPermissions?: readonly PlatformPermission[];
|
|
68
70
|
id: string;
|
|
69
71
|
name: string;
|
|
70
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
|
+
};
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { type SQL, sql } from "bun";
|
|
2
|
+
import { listApps } from "../../_internal/registry";
|
|
3
|
+
import {
|
|
4
|
+
type AdminMailApp,
|
|
5
|
+
type AdminMailProfile,
|
|
6
|
+
type MailAppAccess,
|
|
7
|
+
MailAppAccessSchema,
|
|
8
|
+
type MailErrorCode,
|
|
9
|
+
type MailProfile,
|
|
10
|
+
type MailProfileInput,
|
|
11
|
+
MailProfileInputSchema,
|
|
12
|
+
MailProfileKeySchema,
|
|
13
|
+
} from "../../contracts/outgoing-mail";
|
|
14
|
+
import type { AppRegistryEntry } from "../../contracts/registry";
|
|
15
|
+
import { type AuditActor, audit } from "../audit";
|
|
16
|
+
import { toPgTextArray } from "../postgres";
|
|
17
|
+
import { decryptValue, encryptValue } from "../settings/crypto";
|
|
18
|
+
|
|
19
|
+
export class OutgoingMailError extends Error {
|
|
20
|
+
constructor(
|
|
21
|
+
public readonly code: MailErrorCode,
|
|
22
|
+
message: string,
|
|
23
|
+
public readonly status: 400 | 404 | 409 | 502 = 400,
|
|
24
|
+
) {
|
|
25
|
+
super(message);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export type MailAuditContext = { actor: AuditActor; requestId?: string };
|
|
29
|
+
type ProfileRow = {
|
|
30
|
+
id: string;
|
|
31
|
+
key: string;
|
|
32
|
+
name: string;
|
|
33
|
+
from_address: string;
|
|
34
|
+
from_name: string | null;
|
|
35
|
+
smtp_host: string;
|
|
36
|
+
smtp_port: number;
|
|
37
|
+
smtp_secure: boolean;
|
|
38
|
+
smtp_user: string | null;
|
|
39
|
+
has_password: boolean;
|
|
40
|
+
pace_per_minute: number;
|
|
41
|
+
daily_recipient_limit: number | null;
|
|
42
|
+
max_attachment_bytes: number;
|
|
43
|
+
is_default: boolean;
|
|
44
|
+
revision: number;
|
|
45
|
+
created_at: Date | string;
|
|
46
|
+
updated_at: Date | string;
|
|
47
|
+
updated_by: string | null;
|
|
48
|
+
};
|
|
49
|
+
const columns = (db: SQL) => db`id, key, name, from_address, from_name, smtp_host, smtp_port, smtp_secure, smtp_user,
|
|
50
|
+
(smtp_password_encrypted IS NOT NULL) AS has_password, pace_per_minute, daily_recipient_limit, max_attachment_bytes,
|
|
51
|
+
is_default, revision, created_at, updated_at, updated_by`;
|
|
52
|
+
const timestamp = (value: Date | string) => new Date(value).toISOString();
|
|
53
|
+
const keyValue = (key: string) => {
|
|
54
|
+
const result = MailProfileKeySchema.safeParse(key);
|
|
55
|
+
if (!result.success) throw new OutgoingMailError("invalid_profile", "Invalid profile key.");
|
|
56
|
+
return result.data;
|
|
57
|
+
};
|
|
58
|
+
const unknown = (): never => {
|
|
59
|
+
throw new OutgoingMailError("profile_unknown", "Outgoing mail profile does not exist.", 404);
|
|
60
|
+
};
|
|
61
|
+
const locked = async <T>(run: (tx: SQL) => Promise<T>): Promise<T> =>
|
|
62
|
+
sql.begin(async (tx) => {
|
|
63
|
+
await tx`SELECT pg_advisory_xact_lock(hashtextextended('outgoing_mail.policy', 0))`;
|
|
64
|
+
return run(tx);
|
|
65
|
+
});
|
|
66
|
+
const record = (db: SQL, context: MailAuditContext, action: string, target: string) =>
|
|
67
|
+
audit.record(
|
|
68
|
+
{
|
|
69
|
+
...context,
|
|
70
|
+
action: `outgoing_mail.${action}`,
|
|
71
|
+
outcome: "allowed",
|
|
72
|
+
target: { type: action.startsWith("profile.") ? "outgoing_mail_profile" : "outgoing_mail_app", id: target },
|
|
73
|
+
},
|
|
74
|
+
db,
|
|
75
|
+
);
|
|
76
|
+
const accessRows = async (db: SQL) => {
|
|
77
|
+
const access = await db<{ app_id: string; mode: "default" | "selected" }[]>`SELECT app_id, mode FROM outgoing_mail.app_access`;
|
|
78
|
+
const grants = await db<
|
|
79
|
+
{ app_id: string; key: string }[]
|
|
80
|
+
>`SELECT ap.app_id, p.key FROM outgoing_mail.app_profiles ap JOIN outgoing_mail.profiles p ON p.id = ap.profile_id ORDER BY p.key`;
|
|
81
|
+
return { access, grants };
|
|
82
|
+
};
|
|
83
|
+
const mapProfile = (row: ProfileRow, appCount: number): AdminMailProfile => ({
|
|
84
|
+
key: row.key,
|
|
85
|
+
name: row.name,
|
|
86
|
+
fromAddress: row.from_address,
|
|
87
|
+
fromName: row.from_name,
|
|
88
|
+
smtpHost: row.smtp_host,
|
|
89
|
+
smtpPort: row.smtp_port,
|
|
90
|
+
smtpSecure: row.smtp_secure,
|
|
91
|
+
smtpUser: row.smtp_user,
|
|
92
|
+
hasPassword: row.has_password,
|
|
93
|
+
pacePerMinute: row.pace_per_minute,
|
|
94
|
+
dailyRecipientLimit: row.daily_recipient_limit,
|
|
95
|
+
maxAttachmentBytes: row.max_attachment_bytes,
|
|
96
|
+
isDefault: row.is_default,
|
|
97
|
+
revision: row.revision,
|
|
98
|
+
createdAt: timestamp(row.created_at),
|
|
99
|
+
updatedAt: timestamp(row.updated_at),
|
|
100
|
+
updatedBy: row.updated_by,
|
|
101
|
+
appCount,
|
|
102
|
+
});
|
|
103
|
+
const list = async (db: SQL = sql, registeredApps?: readonly AppRegistryEntry[]): Promise<AdminMailProfile[]> => {
|
|
104
|
+
const apps = registeredApps ?? (await listApps());
|
|
105
|
+
const rows = await db<ProfileRow[]>`SELECT ${columns(db)} FROM outgoing_mail.profiles ORDER BY is_default DESC, name, key`;
|
|
106
|
+
const { access, grants } = await accessRows(db);
|
|
107
|
+
const selected = new Set(access.filter((a) => a.mode === "selected").map((a) => a.app_id));
|
|
108
|
+
const defaults = apps.filter((app) => app.platformPermissions?.includes("mail:send") && !selected.has(app.id));
|
|
109
|
+
return rows.map((row) =>
|
|
110
|
+
mapProfile(row, grants.filter((g) => g.key === row.key && selected.has(g.app_id)).length + (row.is_default ? defaults.length : 0)),
|
|
111
|
+
);
|
|
112
|
+
};
|
|
113
|
+
const get = async (key: string): Promise<AdminMailProfile> => {
|
|
114
|
+
keyValue(key);
|
|
115
|
+
return (await list()).find((profile) => profile.key === key) ?? unknown();
|
|
116
|
+
};
|
|
117
|
+
const put = async (
|
|
118
|
+
key: string,
|
|
119
|
+
value: MailProfileInput,
|
|
120
|
+
context: MailAuditContext,
|
|
121
|
+
): Promise<{ profile: AdminMailProfile; created: boolean }> => {
|
|
122
|
+
keyValue(key);
|
|
123
|
+
const parsed = MailProfileInputSchema.safeParse(value);
|
|
124
|
+
if (!parsed.success) throw new OutgoingMailError("invalid_profile", "Invalid outgoing mail profile configuration.");
|
|
125
|
+
const input = parsed.data;
|
|
126
|
+
const apps = await listApps();
|
|
127
|
+
return locked(async (tx) => {
|
|
128
|
+
const [before] = await tx<ProfileRow[]>`SELECT ${columns(tx)} FROM outgoing_mail.profiles WHERE key = ${key}`;
|
|
129
|
+
if (before && input.revision === undefined)
|
|
130
|
+
throw new OutgoingMailError("profile_exists", "Profile exists; provide its current revision.", 409);
|
|
131
|
+
if (input.revision !== undefined && (!before || input.revision !== before.revision))
|
|
132
|
+
throw new OutgoingMailError("revision_conflict", "Profile revision changed; reload before replacing it.", 409);
|
|
133
|
+
if (
|
|
134
|
+
before?.has_password &&
|
|
135
|
+
input.smtpPassword === undefined &&
|
|
136
|
+
before.smtp_host.trim().toLowerCase() !== input.smtpHost.trim().toLowerCase()
|
|
137
|
+
)
|
|
138
|
+
throw new OutgoingMailError("invalid_profile", "Enter the SMTP password again, or remove it, when you change the SMTP host.");
|
|
139
|
+
const password =
|
|
140
|
+
input.smtpPassword === undefined ? undefined : input.smtpPassword === null ? null : await encryptValue(input.smtpPassword);
|
|
141
|
+
if (before) {
|
|
142
|
+
await tx`UPDATE outgoing_mail.profiles SET name = ${input.name}, from_address = ${input.fromAddress}, from_name = ${input.fromName},
|
|
143
|
+
smtp_host = ${input.smtpHost}, smtp_port = ${input.smtpPort}, smtp_secure = ${input.smtpSecure}, smtp_user = ${input.smtpUser},
|
|
144
|
+
smtp_password_encrypted = CASE WHEN ${password !== undefined} THEN ${password ?? null} ELSE smtp_password_encrypted END,
|
|
145
|
+
pace_per_minute = ${input.pacePerMinute}, daily_recipient_limit = ${input.dailyRecipientLimit}, max_attachment_bytes = ${input.maxAttachmentBytes},
|
|
146
|
+
revision = revision + 1, updated_at = now(), updated_by = ${context.actor.userId ?? null} WHERE key = ${key}`;
|
|
147
|
+
} else {
|
|
148
|
+
await tx`INSERT INTO outgoing_mail.profiles(id, key, name, from_address, from_name, smtp_host, smtp_port, smtp_secure, smtp_user,
|
|
149
|
+
smtp_password_encrypted, pace_per_minute, daily_recipient_limit, max_attachment_bytes, is_default, updated_by)
|
|
150
|
+
VALUES (${crypto.randomUUID()}::uuid, ${key}, ${input.name}, ${input.fromAddress}, ${input.fromName}, ${input.smtpHost}, ${input.smtpPort},
|
|
151
|
+
${input.smtpSecure}, ${input.smtpUser}, ${password ?? null}, ${input.pacePerMinute}, ${input.dailyRecipientLimit}, ${input.maxAttachmentBytes},
|
|
152
|
+
NOT EXISTS(SELECT 1 FROM outgoing_mail.profiles), ${context.actor.userId ?? null})`;
|
|
153
|
+
}
|
|
154
|
+
await record(tx, context, before ? "profile.update" : "profile.create", key);
|
|
155
|
+
return { profile: (await list(tx, apps)).find((p) => p.key === key)!, created: !before };
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
const setDefault = async (key: string, context: MailAuditContext): Promise<AdminMailProfile> => {
|
|
159
|
+
keyValue(key);
|
|
160
|
+
const apps = await listApps();
|
|
161
|
+
return locked(async (tx) => {
|
|
162
|
+
const [target] = await tx<{ key: string }[]>`SELECT key FROM outgoing_mail.profiles WHERE key = ${key}`;
|
|
163
|
+
if (!target) unknown();
|
|
164
|
+
await tx`UPDATE outgoing_mail.profiles SET is_default = false, revision = revision + 1, updated_at = now(), updated_by = ${context.actor.userId ?? null} WHERE is_default AND key <> ${key}`;
|
|
165
|
+
await tx`UPDATE outgoing_mail.profiles SET is_default = true, revision = revision + 1, updated_at = now(), updated_by = ${context.actor.userId ?? null} WHERE key = ${key} AND NOT is_default`;
|
|
166
|
+
await record(tx, context, "profile.set_default", key);
|
|
167
|
+
return (await list(tx, apps)).find((p) => p.key === key)!;
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
const remove = async (key: string, context: MailAuditContext): Promise<void> => {
|
|
171
|
+
keyValue(key);
|
|
172
|
+
await locked(async (tx) => {
|
|
173
|
+
const [before] = await tx<{ is_default: boolean }[]>`SELECT is_default FROM outgoing_mail.profiles WHERE key = ${key}`;
|
|
174
|
+
if (!before) return unknown();
|
|
175
|
+
if (before.is_default)
|
|
176
|
+
throw new OutgoingMailError("profile_is_default", "Choose another default profile before deleting this one.", 409);
|
|
177
|
+
await tx`DELETE FROM outgoing_mail.profiles WHERE key = ${key}`;
|
|
178
|
+
await record(tx, context, "profile.delete", key);
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
const appsState = async (
|
|
182
|
+
db: SQL = sql,
|
|
183
|
+
registeredApps?: readonly AppRegistryEntry[],
|
|
184
|
+
): Promise<{ defaultProfile: string | null; items: AdminMailApp[] }> => {
|
|
185
|
+
const registered = registeredApps ?? (await listApps());
|
|
186
|
+
const { access, grants } = await accessRows(db);
|
|
187
|
+
const [profile] = await db<{ key: string }[]>`SELECT key FROM outgoing_mail.profiles WHERE is_default`;
|
|
188
|
+
const ids = new Set([...registered.map((app) => app.id), ...access.map((a) => a.app_id)]);
|
|
189
|
+
const items = [...ids]
|
|
190
|
+
.map((appId): AdminMailApp => {
|
|
191
|
+
const app = registered.find((a) => a.id === appId);
|
|
192
|
+
const mode = access.find((a) => a.app_id === appId)?.mode ?? "default";
|
|
193
|
+
return {
|
|
194
|
+
appId,
|
|
195
|
+
name: app?.name ?? appId,
|
|
196
|
+
registered: !!app,
|
|
197
|
+
declared: !!app?.platformPermissions?.includes("mail:send"),
|
|
198
|
+
mode,
|
|
199
|
+
profiles: mode === "selected" ? grants.filter((g) => g.app_id === appId).map((g) => g.key) : [],
|
|
200
|
+
};
|
|
201
|
+
})
|
|
202
|
+
.sort((a, b) => a.name.localeCompare(b.name) || a.appId.localeCompare(b.appId));
|
|
203
|
+
return { defaultProfile: profile?.key ?? null, items };
|
|
204
|
+
};
|
|
205
|
+
const setAppAccess = async (appId: string, value: MailAppAccess, context: MailAuditContext): Promise<AdminMailApp> => {
|
|
206
|
+
if (appId === "core") throw new OutgoingMailError("invalid_profile", "Core's system email always uses the default profile.");
|
|
207
|
+
if (!appId.trim()) throw new OutgoingMailError("invalid_profile", "Application id is required.");
|
|
208
|
+
const parsed = MailAppAccessSchema.safeParse(value);
|
|
209
|
+
if (!parsed.success) throw new OutgoingMailError("invalid_profile", "Invalid application access policy.");
|
|
210
|
+
const input = parsed.data;
|
|
211
|
+
const registered = await listApps();
|
|
212
|
+
return locked(async (tx) => {
|
|
213
|
+
const keys = input.mode === "selected" ? [...new Set(input.profiles)] : [];
|
|
214
|
+
const rows = await tx<
|
|
215
|
+
{ id: string; key: string }[]
|
|
216
|
+
>`SELECT id, key FROM outgoing_mail.profiles WHERE key = ANY(${toPgTextArray(keys)}::text[])`;
|
|
217
|
+
if (rows.length !== keys.length) throw new OutgoingMailError("profile_unknown", "An outgoing mail profile does not exist.");
|
|
218
|
+
await tx`DELETE FROM outgoing_mail.app_profiles WHERE app_id = ${appId}`;
|
|
219
|
+
if (input.mode === "default") await tx`DELETE FROM outgoing_mail.app_access WHERE app_id = ${appId}`;
|
|
220
|
+
else {
|
|
221
|
+
await tx`INSERT INTO outgoing_mail.app_access(app_id, mode, updated_by) VALUES (${appId}, 'selected', ${context.actor.userId ?? null})
|
|
222
|
+
ON CONFLICT(app_id) DO UPDATE SET mode = 'selected', updated_at = now(), updated_by = EXCLUDED.updated_by`;
|
|
223
|
+
for (const row of rows) await tx`INSERT INTO outgoing_mail.app_profiles(app_id, profile_id) VALUES (${appId}, ${row.id}::uuid)`;
|
|
224
|
+
}
|
|
225
|
+
await record(tx, context, "app_access.update", appId);
|
|
226
|
+
return (
|
|
227
|
+
(await appsState(tx, registered)).items.find((app) => app.appId === appId) ?? {
|
|
228
|
+
appId,
|
|
229
|
+
name: appId,
|
|
230
|
+
registered: false,
|
|
231
|
+
declared: false,
|
|
232
|
+
mode: "default",
|
|
233
|
+
profiles: [],
|
|
234
|
+
}
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
const profilesForApp = async (appId: string): Promise<MailProfile[]> => {
|
|
239
|
+
const rows = await sql<
|
|
240
|
+
{
|
|
241
|
+
key: string;
|
|
242
|
+
name: string;
|
|
243
|
+
from_address: string;
|
|
244
|
+
is_default: boolean;
|
|
245
|
+
max_attachment_bytes: number;
|
|
246
|
+
daily_recipient_limit: number | null;
|
|
247
|
+
}[]
|
|
248
|
+
>`
|
|
249
|
+
SELECT p.key, p.name, p.from_address, p.is_default, p.max_attachment_bytes, p.daily_recipient_limit
|
|
250
|
+
FROM outgoing_mail.profiles p WHERE
|
|
251
|
+
CASE WHEN EXISTS(SELECT 1 FROM outgoing_mail.app_access WHERE app_id = ${appId} AND mode = 'selected')
|
|
252
|
+
THEN EXISTS(SELECT 1 FROM outgoing_mail.app_profiles WHERE app_id = ${appId} AND profile_id = p.id)
|
|
253
|
+
ELSE p.is_default END ORDER BY p.is_default DESC, p.name, p.key`;
|
|
254
|
+
return rows.map((row) => ({
|
|
255
|
+
key: row.key,
|
|
256
|
+
name: row.name,
|
|
257
|
+
from: row.from_address,
|
|
258
|
+
default: row.is_default,
|
|
259
|
+
maxAttachmentBytes: row.max_attachment_bytes,
|
|
260
|
+
quota: { dailyRecipients: row.daily_recipient_limit, usedLast24h: 0 },
|
|
261
|
+
}));
|
|
262
|
+
};
|
|
263
|
+
export const outgoingMailStore = { list, get, put, setDefault, delete: remove, apps: appsState, setAppAccess, profilesForApp };
|
|
264
|
+
|
|
265
|
+
/** Platform email send path only. Never use this helper to shape API or CLI responses. */
|
|
266
|
+
export const resolveMailCredentials = async (key?: string) => {
|
|
267
|
+
const [row] = await sql<(ProfileRow & { smtp_password_encrypted: string | null })[]>`
|
|
268
|
+
SELECT ${columns(sql)}, smtp_password_encrypted FROM outgoing_mail.profiles WHERE ${key === undefined ? sql`is_default` : sql`key = ${keyValue(key)}`}`;
|
|
269
|
+
if (!row)
|
|
270
|
+
throw new OutgoingMailError(
|
|
271
|
+
"profile_unknown",
|
|
272
|
+
key ? "Outgoing mail profile does not exist." : "Configure a default outgoing mail profile before sending email.",
|
|
273
|
+
404,
|
|
274
|
+
);
|
|
275
|
+
const password = row.smtp_password_encrypted === null ? null : await decryptValue(row.smtp_password_encrypted);
|
|
276
|
+
if (password !== null && typeof password !== "string") throw new Error("Outgoing mail SMTP password is invalid.");
|
|
277
|
+
return {
|
|
278
|
+
fromAddress: row.from_address,
|
|
279
|
+
fromName: row.from_name,
|
|
280
|
+
smtpHost: row.smtp_host,
|
|
281
|
+
smtpPort: row.smtp_port,
|
|
282
|
+
smtpSecure: row.smtp_secure,
|
|
283
|
+
smtpUser: row.smtp_user,
|
|
284
|
+
smtpPassword: password,
|
|
285
|
+
};
|
|
286
|
+
};
|