@k2b/cloud 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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 +46 -64
- package/src/ai/browser.ts +9 -1
- package/src/ai/capabilities.ts +82 -22
- package/src/ai/chat/blocks.tsx +16 -7
- 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 +15 -5
- package/src/ai/chat/message-actions.tsx +123 -101
- package/src/ai/chat/message-utils.ts +30 -1
- package/src/ai/chat/messages.ts +206 -2
- package/src/ai/chat/presentation.tsx +94 -28
- package/src/ai/chat/tool-groups.ts +1 -1
- package/src/ai/chat/turn-error.ts +21 -0
- package/src/ai/chat/turn-view.tsx +102 -16
- package/src/ai/chat/user-message.tsx +19 -14
- package/src/ai/chat/visual-tools.tsx +1 -1
- package/src/ai/client/controller.ts +83 -37
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +7 -2
- package/src/ai/code-mode-skill.ts +32 -36
- package/src/ai/code-runtime-tools.ts +14 -1
- package/src/ai/code-source-contracts.ts +56 -6
- package/src/ai/code-source-tools.ts +10 -3
- package/src/ai/credentials.ts +17 -3
- package/src/ai/data-analysis-skill.ts +3 -3
- package/src/ai/default-tools.ts +15 -16
- package/src/ai/executor.ts +281 -141
- 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 +9 -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/open-tool-calls.ts +87 -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 +40 -6
- package/src/ai/run-timeout.ts +4 -5
- package/src/ai/runtime.ts +9 -1
- package/src/ai/settings.ts +19 -2
- package/src/ai/skill-seeds.ts +35 -7
- package/src/ai/skills.ts +26 -0
- package/src/ai/solid.ts +1 -1
- package/src/ai/store.ts +155 -71
- package/src/ai/stream.ts +180 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +8 -0
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-failure.ts +100 -0
- package/src/ai/turn-policy.ts +248 -0
- package/src/ai/types.ts +52 -4
- 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 +242 -0
- package/src/api/index.ts +2 -0
- package/src/browser/FileChooser.tsx +11 -0
- package/src/browser/file-chooser-messages.ts +2 -0
- package/src/cli/admin/ai-quotas.ts +70 -1
- package/src/cli/admin/index.ts +8 -0
- package/src/cli/admin/notifications.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +215 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/outgoing-mail.ts +265 -0
- package/src/contracts/registry.ts +2 -0
- package/src/services/help/store.ts +2 -1
- package/src/services/index.ts +13 -0
- package/src/services/notifications/batches.ts +139 -79
- package/src/services/notifications/channels.ts +33 -13
- package/src/services/notifications/dispatcher.ts +57 -5
- package/src/services/notifications/email-frame.fixture.html +51 -0
- package/src/services/notifications/{email.ts → email-frame.ts} +12 -45
- package/src/services/notifications/email-mail.ts +101 -0
- package/src/services/notifications/index.ts +49 -36
- package/src/services/notifications/observability.ts +9 -1
- package/src/services/notifications/platform.ts +1 -1
- package/src/services/notifications/runtime.ts +9 -3
- package/src/services/outgoing-mail/admin.ts +84 -0
- package/src/services/outgoing-mail/attachments.ts +135 -0
- package/src/services/outgoing-mail/bulk.ts +11 -0
- package/src/services/outgoing-mail/dispatcher.ts +231 -0
- package/src/services/outgoing-mail/drain.ts +60 -0
- package/src/services/outgoing-mail/enqueue.ts +180 -0
- package/src/services/outgoing-mail/index.ts +112 -0
- package/src/services/outgoing-mail/message.ts +14 -0
- package/src/services/outgoing-mail/messages.ts +431 -0
- package/src/services/outgoing-mail/retention.ts +48 -0
- package/src/services/outgoing-mail/runtime.ts +72 -0
- package/src/services/outgoing-mail/send.ts +130 -0
- package/src/services/outgoing-mail/store.ts +397 -0
- package/src/services/outgoing-mail/sync.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +40 -0
- package/src/services/outgoing-mail/transport.ts +17 -0
- package/src/services/pdf/markdown.ts +22 -4
- package/src/services/postgres.ts +15 -0
- package/src/services/settings/core-settings.ts +19 -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 +49 -5
- package/src/shared/ai-request-options.ts +185 -0
- package/src/shared/markdown/extensions/links.ts +28 -20
- package/src/shared/markdown/index.ts +14 -5
- package/src/shared/markdown/shared.ts +0 -7
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +3 -1
- package/src/ssr/workspace-navigation.ts +7 -1
- package/src/styles/effects.css +15 -17
- package/src/styles/tokens.css +2 -0
- package/src/styles/utilities-markdown-editor.css +4 -39
- package/src/styles/utilities-markdown-table.css +14 -17
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,265 @@
|
|
|
1
|
+
import type { PageParams, Paginated, ServiceError } from "@k2b/stdlib";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { type RequestActor, ServiceAccountSchema, UserSchema } from "./shared";
|
|
4
|
+
|
|
5
|
+
export const PlatformPermissionSchema = z.enum(["mail:send"]);
|
|
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
|
+
|
|
10
|
+
export const MailProfileKeySchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
|
|
11
|
+
const headerText = z
|
|
12
|
+
.string()
|
|
13
|
+
.trim()
|
|
14
|
+
.min(1)
|
|
15
|
+
.max(120)
|
|
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();
|
|
37
|
+
export const MailProfileInputSchema = z
|
|
38
|
+
.object({
|
|
39
|
+
name: headerText,
|
|
40
|
+
fromAddress: z.email().max(320),
|
|
41
|
+
fromName: headerText.nullable(),
|
|
42
|
+
smtpHost: z
|
|
43
|
+
.string()
|
|
44
|
+
.trim()
|
|
45
|
+
.min(1)
|
|
46
|
+
.max(253)
|
|
47
|
+
.refine((value) => !/[\s/@\0]/.test(value), "Use an SMTP hostname"),
|
|
48
|
+
smtpPort: z.int().min(1).max(65535),
|
|
49
|
+
smtpSecure: z.boolean(),
|
|
50
|
+
smtpUser: z.string().max(320).nullable(),
|
|
51
|
+
smtpPassword: z.string().max(16384).nullable().optional(),
|
|
52
|
+
imap: MailImapInputSchema.nullable().optional(),
|
|
53
|
+
pacePerMinute: z.int().min(1).max(6000),
|
|
54
|
+
dailyRecipientLimit: z.int().min(1).max(2147483647).nullable(),
|
|
55
|
+
maxAttachmentBytes: z.int().min(1).max(26214400),
|
|
56
|
+
revision: z.int().min(1).max(2147483647).optional(),
|
|
57
|
+
})
|
|
58
|
+
.strict();
|
|
59
|
+
export type MailProfileInput = z.infer<typeof MailProfileInputSchema>;
|
|
60
|
+
export const MailAppAccessSchema = z.discriminatedUnion("mode", [
|
|
61
|
+
z.object({ mode: z.literal("default") }).strict(),
|
|
62
|
+
z.object({ mode: z.literal("selected"), profiles: z.array(MailProfileKeySchema) }).strict(),
|
|
63
|
+
]);
|
|
64
|
+
export type MailAppAccess = z.infer<typeof MailAppAccessSchema>;
|
|
65
|
+
export const MailProfileSchema = z.object({
|
|
66
|
+
key: MailProfileKeySchema,
|
|
67
|
+
name: z.string(),
|
|
68
|
+
from: z.email(),
|
|
69
|
+
default: z.boolean(),
|
|
70
|
+
maxAttachmentBytes: z.int(),
|
|
71
|
+
quota: z.object({ dailyRecipients: z.int().nullable(), usedLast24h: z.int() }),
|
|
72
|
+
});
|
|
73
|
+
export type MailProfile = z.infer<typeof MailProfileSchema>;
|
|
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",
|
|
88
|
+
"mail_unavailable",
|
|
89
|
+
"mail_not_declared",
|
|
90
|
+
"profile_unknown",
|
|
91
|
+
"profile_exists",
|
|
92
|
+
"profile_is_default",
|
|
93
|
+
"revision_conflict",
|
|
94
|
+
"invalid_profile",
|
|
95
|
+
"smtp_failed",
|
|
96
|
+
]);
|
|
97
|
+
export type MailErrorCode = z.infer<typeof MailErrorCodeSchema>;
|
|
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({
|
|
101
|
+
key: MailProfileKeySchema,
|
|
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(),
|
|
105
|
+
isDefault: z.boolean(),
|
|
106
|
+
revision: z.int(),
|
|
107
|
+
createdAt: z.string(),
|
|
108
|
+
updatedAt: z.string(),
|
|
109
|
+
updatedBy: z.string().nullable(),
|
|
110
|
+
appCount: z.int(),
|
|
111
|
+
});
|
|
112
|
+
export type AdminMailProfile = z.infer<typeof AdminMailProfileSchema>;
|
|
113
|
+
export type AdminMailApp = {
|
|
114
|
+
appId: string;
|
|
115
|
+
name: string;
|
|
116
|
+
registered: boolean;
|
|
117
|
+
declared: boolean;
|
|
118
|
+
mode: "default" | "selected";
|
|
119
|
+
profiles: string[];
|
|
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
|
+
};
|
|
@@ -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;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
2
|
import { APP_REGISTRY_TTL_MS } from "../../_internal/registry";
|
|
3
|
+
import { ensureSchema } from "../postgres";
|
|
3
4
|
import type { HelpCorpus, HelpMetadata } from "./types";
|
|
4
5
|
|
|
5
6
|
// One expired app lease of grace after an app can no longer be discovered.
|
|
@@ -10,7 +11,7 @@ const searchConfigs = ["english", "german", "simple"] as const;
|
|
|
10
11
|
export const migrateHelp = async (db: SQL = sql): Promise<void> => {
|
|
11
12
|
await db.begin(async (tx) => {
|
|
12
13
|
await tx`SELECT pg_advisory_xact_lock(hashtext('cloud:help:migrate'))`;
|
|
13
|
-
await tx
|
|
14
|
+
await ensureSchema(tx, "help");
|
|
14
15
|
await tx`CREATE TABLE IF NOT EXISTS help.corpora (
|
|
15
16
|
app_id text NOT NULL, manifest_hash text NOT NULL, base_locale text NOT NULL,
|
|
16
17
|
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
package/src/services/index.ts
CHANGED
|
@@ -204,3 +204,16 @@ 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 {
|
|
210
|
+
MailProfile,
|
|
211
|
+
MailAttachment,
|
|
212
|
+
MailMessage,
|
|
213
|
+
MailFilter,
|
|
214
|
+
MailRecord,
|
|
215
|
+
MailStatus,
|
|
216
|
+
MailPageParams,
|
|
217
|
+
MailPage,
|
|
218
|
+
MailServiceError,
|
|
219
|
+
} from "../contracts/outgoing-mail";
|
|
@@ -3,12 +3,15 @@ import { err, fail, ok, type Result } from "@k2b/stdlib";
|
|
|
3
3
|
import type { Worker } from "@k2b/sync";
|
|
4
4
|
import { sql } from "bun";
|
|
5
5
|
import { lazySync } from "../../_internal/process-sync";
|
|
6
|
+
import { type MailMessage, MailMessageSchema, type MailStatus, MailStatusSchema } from "../../contracts/outgoing-mail";
|
|
6
7
|
import { markdown } from "../../shared/markdown";
|
|
7
8
|
import { type AuditActor, audit } from "../audit";
|
|
8
9
|
import { logger, trace } from "../logging";
|
|
10
|
+
import { enqueueMail } from "../outgoing-mail/enqueue";
|
|
11
|
+
import { outgoingMailStore } from "../outgoing-mail/store";
|
|
9
12
|
import { parsePgJsonValue, toPgTextArray, toPgUuidArray } from "../postgres";
|
|
10
|
-
|
|
11
|
-
import {
|
|
13
|
+
import { prepareNotificationEmail } from "./email-frame";
|
|
14
|
+
import { normalizeNotificationMail, notificationMailError, notificationMailValidationError } from "./email-mail";
|
|
12
15
|
|
|
13
16
|
const log = logger("notifications:batches");
|
|
14
17
|
const CHUNK_SIZE = 100;
|
|
@@ -64,6 +67,8 @@ export type NotificationBatchRecipient = {
|
|
|
64
67
|
profile: "user" | "guest";
|
|
65
68
|
status: NotificationBatchRecipientStatus;
|
|
66
69
|
notificationId: string | null;
|
|
70
|
+
outgoingMailId: string | null;
|
|
71
|
+
outgoingMailStatus: MailStatus | null;
|
|
67
72
|
error: string | null;
|
|
68
73
|
attemptCount: number;
|
|
69
74
|
sentAt: string | null;
|
|
@@ -163,6 +168,8 @@ const mapRecipient = (row: BatchRow): NotificationBatchRecipient => ({
|
|
|
163
168
|
profile: row.profile as "user" | "guest",
|
|
164
169
|
status: row.status as NotificationBatchRecipientStatus,
|
|
165
170
|
notificationId: row.notification_id as string | null,
|
|
171
|
+
outgoingMailId: typeof row.outgoing_mail_id === "string" ? row.outgoing_mail_id : null,
|
|
172
|
+
outgoingMailStatus: MailStatusSchema.nullable().parse(row.outgoing_mail_status ?? null),
|
|
166
173
|
error: row.error as string | null,
|
|
167
174
|
attemptCount: Number(row.attempt_count ?? 0),
|
|
168
175
|
sentAt: row.sent_at ? (row.sent_at as Date).toISOString() : null,
|
|
@@ -210,29 +217,6 @@ const resolveCandidates = async (rawSelection: NotificationBatchSelection): Prom
|
|
|
210
217
|
`;
|
|
211
218
|
};
|
|
212
219
|
|
|
213
|
-
const sendBatchEmail = async (params: {
|
|
214
|
-
recipient: string;
|
|
215
|
-
subject: string;
|
|
216
|
-
rawHtml: string;
|
|
217
|
-
sentBy?: string;
|
|
218
|
-
}): Promise<{ id: string; status: "sent" | "error"; error?: string }> => {
|
|
219
|
-
const rows = await sql<BatchRow[]>`
|
|
220
|
-
INSERT INTO notifications.messages (type, recipient, subject, content, sent_by)
|
|
221
|
-
VALUES ('email', ${params.recipient}, ${params.subject}, ${params.rawHtml}, ${params.sentBy ?? null})
|
|
222
|
-
RETURNING id
|
|
223
|
-
`;
|
|
224
|
-
const id = rows[0]!.id as string;
|
|
225
|
-
try {
|
|
226
|
-
await sendEmail(params.recipient, params.subject, { rawHtml: params.rawHtml });
|
|
227
|
-
await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL WHERE id = ${id}::uuid`;
|
|
228
|
-
return { id, status: "sent" };
|
|
229
|
-
} catch (error) {
|
|
230
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
231
|
-
await sql`UPDATE notifications.messages SET error = ${message} WHERE id = ${id}::uuid`;
|
|
232
|
-
return { id, status: "error", error: message };
|
|
233
|
-
}
|
|
234
|
-
};
|
|
235
|
-
|
|
236
220
|
export const preview = async (selection: NotificationBatchSelection): Promise<NotificationBatchPreview> => {
|
|
237
221
|
const candidates = await resolveCandidates(selection);
|
|
238
222
|
return {
|
|
@@ -286,74 +270,147 @@ const refreshBatchCounters = async (batchId: string): Promise<NotificationBatch
|
|
|
286
270
|
};
|
|
287
271
|
|
|
288
272
|
const processBatchChunk = async (batchId: string): Promise<{ processed: number; remaining: number }> => {
|
|
289
|
-
await sql
|
|
273
|
+
const [batchRow] = await sql<BatchRow[]>`
|
|
290
274
|
UPDATE notifications.batches
|
|
291
275
|
SET status = 'running', started_at = COALESCE(started_at, now())
|
|
292
276
|
WHERE id = ${batchId}::uuid AND status IN ('ready', 'running')
|
|
277
|
+
RETURNING *
|
|
293
278
|
`;
|
|
279
|
+
if (!batchRow) return { processed: 0, remaining: 0 };
|
|
280
|
+
const batch = mapBatch(batchRow);
|
|
294
281
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
WHERE batch_id = ${batchId}::uuid
|
|
302
|
-
AND
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
282
|
+
// Settle a bounded page and its legacy message history in the same statement.
|
|
283
|
+
const settled = await sql<{ id: string }[]>`
|
|
284
|
+
WITH terminal AS (
|
|
285
|
+
SELECT r.user_id, r.outgoing_mail_id, m.status, m.sent_at, COALESCE(m.error_message, m.smtp_response, 'Outgoing mail ' || m.status, 'Outgoing mail record is no longer available.') AS error
|
|
286
|
+
FROM notifications.batch_recipients r
|
|
287
|
+
LEFT JOIN outgoing_mail.messages m ON m.id = r.outgoing_mail_id
|
|
288
|
+
WHERE r.batch_id = ${batchId}::uuid AND r.status = 'sending' AND r.outgoing_mail_id IS NOT NULL
|
|
289
|
+
AND (m.status IN ('sent', 'bounced', 'failed', 'cancelled') OR m.id IS NULL)
|
|
290
|
+
ORDER BY r.user_id LIMIT ${CHUNK_SIZE}
|
|
291
|
+
), settled AS (
|
|
292
|
+
UPDATE notifications.batch_recipients r
|
|
293
|
+
SET status = CASE WHEN t.status IN ('sent', 'bounced') THEN 'sent' ELSE 'error' END,
|
|
294
|
+
sent_at = CASE WHEN t.status IN ('sent', 'bounced') THEN COALESCE(t.sent_at, now()) ELSE NULL END,
|
|
295
|
+
error = CASE WHEN t.status IN ('sent', 'bounced') THEN NULL ELSE t.error END, updated_at = now()
|
|
296
|
+
FROM terminal t
|
|
297
|
+
WHERE r.batch_id = ${batchId}::uuid AND r.user_id = t.user_id AND r.status = 'sending'
|
|
298
|
+
AND r.outgoing_mail_id = t.outgoing_mail_id
|
|
299
|
+
RETURNING r.notification_id, r.sent_at, r.error
|
|
310
300
|
)
|
|
311
|
-
|
|
301
|
+
UPDATE notifications.messages m SET sent_at = s.sent_at, error = s.error
|
|
302
|
+
FROM settled s WHERE m.id = s.notification_id AND m.mail_generation = 0 RETURNING m.id
|
|
312
303
|
`;
|
|
313
304
|
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
305
|
+
const profile = (await outgoingMailStore.profilesForApp("core")).find((profile) => profile.default);
|
|
306
|
+
const claimSize =
|
|
307
|
+
profile?.quota.dailyRecipients == null
|
|
308
|
+
? CHUNK_SIZE
|
|
309
|
+
: Math.max(0, Math.min(CHUNK_SIZE, profile.quota.dailyRecipients - profile.quota.usedLast24h));
|
|
310
|
+
|
|
311
|
+
type ClaimedRecipient = { user_id: string; recipient: string; notification_id: string | null };
|
|
312
|
+
// Persist a send generation before any external effect. Reclaim and worker
|
|
313
|
+
// retries reuse it; operator retries clear it and create a new generation.
|
|
314
|
+
const recipients = await sql.begin(async (tx) => {
|
|
315
|
+
const rows = await tx<ClaimedRecipient[]>`
|
|
316
|
+
UPDATE notifications.batch_recipients r
|
|
317
|
+
SET status = 'sending', attempt_count = attempt_count + 1, updated_at = now()
|
|
318
|
+
WHERE (r.batch_id, r.user_id) IN (
|
|
319
|
+
SELECT batch_id, user_id FROM notifications.batch_recipients
|
|
320
|
+
WHERE batch_id = ${batchId}::uuid AND recipient IS NOT NULL AND outgoing_mail_id IS NULL
|
|
321
|
+
AND (status = 'pending' OR (status = 'sending' AND updated_at < now() - interval '5 minutes'))
|
|
322
|
+
ORDER BY updated_at ASC LIMIT ${claimSize} FOR UPDATE SKIP LOCKED
|
|
323
|
+
)
|
|
324
|
+
RETURNING r.user_id, r.recipient, r.notification_id
|
|
325
|
+
`;
|
|
326
|
+
for (const row of rows) {
|
|
327
|
+
if (row.notification_id) continue;
|
|
328
|
+
const [message] = await tx<{ id: string }[]>`
|
|
329
|
+
INSERT INTO notifications.messages (type, recipient, subject, content, sent_by)
|
|
330
|
+
VALUES ('email', ${row.recipient}, ${batch.subject}, ${batch.bodyHtml}, ${batch.finalizedBy ?? batch.createdBy}::uuid)
|
|
331
|
+
RETURNING id
|
|
332
|
+
`;
|
|
333
|
+
if (!message) throw new Error("Notification batch message insert returned no row");
|
|
334
|
+
row.notification_id = message.id;
|
|
335
|
+
await tx`UPDATE notifications.batch_recipients SET notification_id = ${message.id}::uuid
|
|
336
|
+
WHERE batch_id = ${batchId}::uuid AND user_id = ${row.user_id}::uuid`;
|
|
337
|
+
}
|
|
338
|
+
return rows;
|
|
339
|
+
});
|
|
317
340
|
|
|
318
|
-
|
|
341
|
+
const failRecipients = async (rows: ClaimedRecipient[], failure: ReturnType<typeof notificationMailError>) => {
|
|
342
|
+
const generations = toPgUuidArray(rows.flatMap((recipient) => (recipient.notification_id ? [recipient.notification_id] : [])));
|
|
343
|
+
await sql`
|
|
344
|
+
WITH changed AS (
|
|
345
|
+
UPDATE notifications.batch_recipients
|
|
346
|
+
SET status = ${failure.retryable ? "pending" : "error"}, error = ${failure.retryable ? null : failure.message}, updated_at = now()
|
|
347
|
+
WHERE batch_id = ${batchId}::uuid AND notification_id = ANY(${generations}::uuid[])
|
|
348
|
+
AND status = 'sending' AND outgoing_mail_id IS NULL
|
|
349
|
+
RETURNING notification_id, error
|
|
350
|
+
)
|
|
351
|
+
UPDATE notifications.messages m SET error = c.error FROM changed c WHERE m.id = c.notification_id
|
|
352
|
+
`;
|
|
353
|
+
};
|
|
354
|
+
let invalidCount = 0;
|
|
355
|
+
if (recipients.length) {
|
|
319
356
|
try {
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
subject: batch.subject,
|
|
323
|
-
|
|
324
|
-
sentBy: batch.finalizedBy ?? batch.createdBy ?? undefined,
|
|
357
|
+
const frame = await prepareNotificationEmail({ rawHtml: batch.bodyHtml });
|
|
358
|
+
const shared = MailMessageSchema.pick({ subject: true, text: true, html: true }).safeParse({
|
|
359
|
+
subject: normalizeNotificationMail("", batch.subject).subject,
|
|
360
|
+
...frame,
|
|
325
361
|
});
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
362
|
+
if (!shared.success) throw notificationMailValidationError(shared.error);
|
|
363
|
+
const valid: { recipient: ClaimedRecipient; message: MailMessage }[] = [];
|
|
364
|
+
for (const recipient of recipients) {
|
|
365
|
+
const parsed = MailMessageSchema.safeParse({
|
|
366
|
+
...normalizeNotificationMail(recipient.recipient, shared.data.subject),
|
|
367
|
+
...shared.data,
|
|
368
|
+
key: `notification-message:${recipient.notification_id}`,
|
|
369
|
+
});
|
|
370
|
+
if (!parsed.success) {
|
|
371
|
+
await failRecipients([recipient], notificationMailError(notificationMailValidationError(parsed.error)));
|
|
372
|
+
invalidCount++;
|
|
373
|
+
} else valid.push({ recipient, message: parsed.data });
|
|
374
|
+
}
|
|
375
|
+
if (valid.length) {
|
|
376
|
+
const accepted = await enqueueMail(
|
|
377
|
+
"core",
|
|
378
|
+
valid.map(({ message }) => message),
|
|
379
|
+
{ trustedHtml: true },
|
|
380
|
+
);
|
|
381
|
+
for (const [index, { recipient }] of valid.entries()) {
|
|
382
|
+
await sql`
|
|
383
|
+
WITH linked AS (
|
|
384
|
+
UPDATE notifications.batch_recipients SET outgoing_mail_id = ${accepted.ids[index]}::uuid, updated_at = now()
|
|
385
|
+
WHERE batch_id = ${batchId}::uuid AND user_id = ${recipient.user_id}::uuid
|
|
386
|
+
AND notification_id = ${recipient.notification_id}::uuid AND status = 'sending' AND outgoing_mail_id IS NULL
|
|
387
|
+
RETURNING notification_id, outgoing_mail_id
|
|
388
|
+
)
|
|
389
|
+
UPDATE notifications.messages m SET outgoing_mail_id = l.outgoing_mail_id
|
|
390
|
+
FROM linked l WHERE m.id = l.notification_id AND m.mail_generation = 0
|
|
391
|
+
`;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
336
394
|
} catch (error) {
|
|
337
|
-
const
|
|
338
|
-
log.error("
|
|
339
|
-
await
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
`;
|
|
395
|
+
const failure = notificationMailError(error);
|
|
396
|
+
log.error("Notification batch enqueue failed", { batchId, code: failure.code, error: failure.message });
|
|
397
|
+
await failRecipients(recipients, failure);
|
|
398
|
+
// Backlog/quota/storage failures are not terminal recipient failures.
|
|
399
|
+
await refreshBatchCounters(batchId);
|
|
400
|
+
return { processed: invalidCount + settled.length, remaining: await remainingBatchRecipients(batchId) };
|
|
344
401
|
}
|
|
345
402
|
}
|
|
403
|
+
const remaining = await remainingBatchRecipients(batchId);
|
|
404
|
+
await refreshBatchCounters(batchId);
|
|
405
|
+
return { processed: recipients.length + settled.length, remaining };
|
|
406
|
+
};
|
|
346
407
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
FROM notifications.batch_recipients
|
|
350
|
-
WHERE batch_id = ${batchId}::uuid
|
|
351
|
-
AND recipient IS NOT NULL
|
|
352
|
-
AND status IN ('pending', 'sending')
|
|
408
|
+
const remainingBatchRecipients = async (batchId: string): Promise<number> => {
|
|
409
|
+
const [row] = await sql<{ count: number }[]>`
|
|
410
|
+
SELECT COUNT(*)::int AS count FROM notifications.batch_recipients
|
|
411
|
+
WHERE batch_id = ${batchId}::uuid AND recipient IS NOT NULL AND status IN ('pending', 'sending')
|
|
353
412
|
`;
|
|
354
|
-
|
|
355
|
-
await refreshBatchCounters(batchId);
|
|
356
|
-
return { processed: recipients.length, remaining };
|
|
413
|
+
return row?.count ?? 0;
|
|
357
414
|
};
|
|
358
415
|
|
|
359
416
|
const batchJob = lazySync((sync) => {
|
|
@@ -492,6 +549,8 @@ export const listRecipients = async (params: {
|
|
|
492
549
|
r.profile,
|
|
493
550
|
r.status,
|
|
494
551
|
r.notification_id,
|
|
552
|
+
r.outgoing_mail_id,
|
|
553
|
+
mail.status AS outgoing_mail_status,
|
|
495
554
|
COALESCE(m.error, r.error) AS error,
|
|
496
555
|
r.attempt_count,
|
|
497
556
|
r.sent_at,
|
|
@@ -499,6 +558,7 @@ export const listRecipients = async (params: {
|
|
|
499
558
|
COUNT(*) OVER() AS total
|
|
500
559
|
FROM notifications.batch_recipients r
|
|
501
560
|
LEFT JOIN notifications.messages m ON m.id = r.notification_id
|
|
561
|
+
LEFT JOIN outgoing_mail.messages mail ON mail.id = r.outgoing_mail_id
|
|
502
562
|
LEFT JOIN auth.users u ON u.id = r.user_id
|
|
503
563
|
WHERE r.batch_id = ${params.batchId}::uuid
|
|
504
564
|
AND (${params.status ?? null}::text IS NULL OR r.status = ${params.status ?? null})
|
|
@@ -620,7 +680,7 @@ export const retryFailed = async (params: {
|
|
|
620
680
|
const result = await sql.begin(async (tx) => {
|
|
621
681
|
const updated = await tx<BatchRow[]>`
|
|
622
682
|
UPDATE notifications.batch_recipients
|
|
623
|
-
SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
|
|
683
|
+
SET status = 'pending', error = NULL, notification_id = NULL, outgoing_mail_id = NULL, sent_at = NULL, updated_at = now()
|
|
624
684
|
WHERE batch_id = ${params.id}::uuid AND status = 'error' AND recipient IS NOT NULL
|
|
625
685
|
RETURNING user_id
|
|
626
686
|
`;
|
|
@@ -667,7 +727,7 @@ export const retryRecipient = async (params: {
|
|
|
667
727
|
const result = await sql.begin(async (tx) => {
|
|
668
728
|
const updated = await tx<BatchRow[]>`
|
|
669
729
|
UPDATE notifications.batch_recipients
|
|
670
|
-
SET status = 'pending', error = NULL, notification_id = NULL, sent_at = NULL, updated_at = now()
|
|
730
|
+
SET status = 'pending', error = NULL, notification_id = NULL, outgoing_mail_id = NULL, sent_at = NULL, updated_at = now()
|
|
671
731
|
WHERE batch_id = ${params.id}::uuid
|
|
672
732
|
AND user_id = ${params.userId}::uuid
|
|
673
733
|
AND status = 'error'
|