@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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { type SQL, sql } from "bun";
|
|
2
|
+
import type { MailMessage } from "../../contracts/outgoing-mail";
|
|
3
|
+
import {
|
|
4
|
+
cancelMailStreams,
|
|
5
|
+
deleteMailObjects,
|
|
6
|
+
MAIL_ATTACHMENT_UPLOAD_MS,
|
|
7
|
+
type UploadedAttachments,
|
|
8
|
+
uploadMailAttachments,
|
|
9
|
+
} from "./attachments";
|
|
10
|
+
import { mailBacklogFull, mailBatchId } from "./bulk";
|
|
11
|
+
import {
|
|
12
|
+
type AcceptedProfile,
|
|
13
|
+
allowedMailProfile,
|
|
14
|
+
insertMailMessage,
|
|
15
|
+
type MailAcceptanceOptions,
|
|
16
|
+
MailQuotaError,
|
|
17
|
+
type MessageRow,
|
|
18
|
+
mailQuotaUsed,
|
|
19
|
+
outgoingMailMessages,
|
|
20
|
+
recordMailBatch,
|
|
21
|
+
} from "./messages";
|
|
22
|
+
import { OutgoingMailError } from "./store";
|
|
23
|
+
import { mailAttachments, mailDrainJob, mailSettled, submitMailDrain } from "./sync";
|
|
24
|
+
|
|
25
|
+
type BatchItem = { id: string; message: MailMessage; existing?: MessageRow; uploaded: UploadedAttachments };
|
|
26
|
+
type AcceptedBatch = { batchId: string; rows: MessageRow[]; created: MessageRow[] };
|
|
27
|
+
const lockBatchApp = async (db: SQL, appId: string) => {
|
|
28
|
+
// One app lock serializes batches with batches and keyed sends without per-key locks.
|
|
29
|
+
// Lock order: policy -> app -> key (send only) -> quota -> backlog -> call.
|
|
30
|
+
await db`SELECT pg_advisory_xact_lock_shared(hashtextextended('outgoing_mail.policy', 0))`;
|
|
31
|
+
await db`SELECT pg_advisory_xact_lock(hashtextextended(${appId}, 5))`;
|
|
32
|
+
};
|
|
33
|
+
/** Retain uploads when a lost COMMIT response cannot be resolved by a recovery read. */
|
|
34
|
+
export class MailAcceptanceUnknown extends OutgoingMailError {
|
|
35
|
+
constructor() {
|
|
36
|
+
super("mail_unavailable", "Outgoing mail acceptance could not be determined. Retry with the same keys.", 502);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const checkBatch = async (db: SQL, appId: string, items: readonly BatchItem[]) => {
|
|
40
|
+
const profiles = new Map<string, AcceptedProfile>();
|
|
41
|
+
// Stable order for batches that request multiple profiles.
|
|
42
|
+
for (const key of [...new Set(items.map((item) => item.message.profile))].sort())
|
|
43
|
+
profiles.set(key ?? "", await allowedMailProfile(db, appId, key));
|
|
44
|
+
const totals = new Map<string, { profile: AcceptedProfile; recipients: number; count: number }>();
|
|
45
|
+
for (const item of items) {
|
|
46
|
+
const profile = profiles.get(item.message.profile ?? "")!;
|
|
47
|
+
if (item.uploaded.metadata.reduce((sum, attachment) => sum + attachment.size, 0) > profile.max_attachment_bytes)
|
|
48
|
+
throw new OutgoingMailError("attachments_too_large", "Attachments exceed the profile's total byte limit.");
|
|
49
|
+
const total = totals.get(profile.id) ?? { profile, recipients: 0, count: 0 };
|
|
50
|
+
total.recipients += item.message.to.length;
|
|
51
|
+
total.count++;
|
|
52
|
+
totals.set(profile.id, total);
|
|
53
|
+
}
|
|
54
|
+
for (const id of [...totals.keys()].sort()) {
|
|
55
|
+
const { profile, recipients, count } = totals.get(id)!;
|
|
56
|
+
// Same quota lock as immediate acceptance, plus a profile-wide backlog lock across apps.
|
|
57
|
+
await db`SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([appId, id])}, 2))`;
|
|
58
|
+
await db`SELECT pg_advisory_xact_lock(hashtextextended(${id}, 3))`;
|
|
59
|
+
const used = await mailQuotaUsed(db, appId, id);
|
|
60
|
+
if (profile.daily_recipient_limit !== null && used + recipients > profile.daily_recipient_limit)
|
|
61
|
+
throw new MailQuotaError(profile.daily_recipient_limit, used, recipients);
|
|
62
|
+
const [backlog] = await db<{ queued: number }[]>`SELECT count(*)::int AS queued FROM outgoing_mail.messages
|
|
63
|
+
WHERE profile_id = ${id}::uuid AND lane = 'bulk' AND status = 'queued'`;
|
|
64
|
+
if (mailBacklogFull(backlog?.queued ?? 0, count, profile.pace_per_minute))
|
|
65
|
+
throw new OutgoingMailError("backlog_full", "The profile's bulk queue exceeds 24 hours of delivery capacity.", 409);
|
|
66
|
+
}
|
|
67
|
+
return profiles;
|
|
68
|
+
};
|
|
69
|
+
export const outgoingMailBatches = {
|
|
70
|
+
async accept(appId: string, proposed: string, items: readonly BatchItem[], options?: MailAcceptanceOptions): Promise<AcceptedBatch> {
|
|
71
|
+
let result: AcceptedBatch | undefined;
|
|
72
|
+
try {
|
|
73
|
+
return await sql.begin(async (tx) => {
|
|
74
|
+
await lockBatchApp(tx, appId);
|
|
75
|
+
const known: (MessageRow | undefined)[] = [];
|
|
76
|
+
for (const item of items) known.push(await outgoingMailMessages.known(appId, item.message.key, tx));
|
|
77
|
+
const fresh = items.filter((_, index) => !known[index]);
|
|
78
|
+
if (fresh.some((item) => item.existing))
|
|
79
|
+
throw new OutgoingMailError(
|
|
80
|
+
"mail_unavailable",
|
|
81
|
+
"An idempotent mail record expired during acceptance. Retry with fresh attachments.",
|
|
82
|
+
);
|
|
83
|
+
const profiles = await checkBatch(tx, appId, fresh);
|
|
84
|
+
await tx`SELECT pg_advisory_xact_lock(hashtextextended(${proposed}, 4))`;
|
|
85
|
+
let batchId = mailBatchId(proposed, known);
|
|
86
|
+
const rows: MessageRow[] = [];
|
|
87
|
+
const created: MessageRow[] = [];
|
|
88
|
+
for (const [index, item] of items.entries()) {
|
|
89
|
+
let row = known[index];
|
|
90
|
+
if (!row) {
|
|
91
|
+
row = await insertMailMessage(
|
|
92
|
+
tx,
|
|
93
|
+
appId,
|
|
94
|
+
item.id,
|
|
95
|
+
item.message,
|
|
96
|
+
item.uploaded,
|
|
97
|
+
profiles.get(item.message.profile ?? "")!,
|
|
98
|
+
batchId,
|
|
99
|
+
options,
|
|
100
|
+
);
|
|
101
|
+
if (row) created.push(row);
|
|
102
|
+
else row = await outgoingMailMessages.known(appId, item.message.key, tx);
|
|
103
|
+
}
|
|
104
|
+
if (!row) throw new Error("Outgoing mail batch insert returned no row");
|
|
105
|
+
rows.push(row);
|
|
106
|
+
}
|
|
107
|
+
if (!created.length) batchId = mailBatchId(proposed, rows);
|
|
108
|
+
await recordMailBatch(
|
|
109
|
+
appId,
|
|
110
|
+
items.map((item) => item.message),
|
|
111
|
+
rows,
|
|
112
|
+
batchId,
|
|
113
|
+
tx,
|
|
114
|
+
);
|
|
115
|
+
result = { batchId, rows, created };
|
|
116
|
+
return result;
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (result?.created.length) {
|
|
120
|
+
// A callback completed but COMMIT may have lost its response. The call lock
|
|
121
|
+
// waits for that transaction to finish before deciding whether it recorded rows.
|
|
122
|
+
try {
|
|
123
|
+
const committed = await sql.begin(async (tx) => {
|
|
124
|
+
await tx`SELECT pg_advisory_xact_lock(hashtextextended(${proposed}, 4))`;
|
|
125
|
+
return !!(await outgoingMailMessages.read(result!.created[0]!.id, appId, tx));
|
|
126
|
+
});
|
|
127
|
+
if (committed) return result;
|
|
128
|
+
} catch {
|
|
129
|
+
throw new MailAcceptanceUnknown();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
export const enqueueMail = async (
|
|
137
|
+
appId: string,
|
|
138
|
+
messages: readonly MailMessage[],
|
|
139
|
+
options?: MailAcceptanceOptions,
|
|
140
|
+
): Promise<{ batchId: string; ids: string[] }> => {
|
|
141
|
+
const items: BatchItem[] = [];
|
|
142
|
+
try {
|
|
143
|
+
for (const message of messages) {
|
|
144
|
+
items.push({ id: crypto.randomUUID(), message, uploaded: { metadata: [], refs: [] } });
|
|
145
|
+
}
|
|
146
|
+
const profiles = await sql.begin(async (tx) => {
|
|
147
|
+
await lockBatchApp(tx, appId);
|
|
148
|
+
for (const item of items) {
|
|
149
|
+
item.existing = await outgoingMailMessages.known(appId, item.message.key, tx);
|
|
150
|
+
if (item.existing) cancelMailStreams(item.message.attachments);
|
|
151
|
+
}
|
|
152
|
+
return checkBatch(
|
|
153
|
+
tx,
|
|
154
|
+
appId,
|
|
155
|
+
items.filter((item) => !item.existing),
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
const fresh = items.filter((item) => !item.existing);
|
|
159
|
+
if (fresh.length) await Promise.all([mailDrainJob().ready(), mailSettled().ready(), mailAttachments().ready()]);
|
|
160
|
+
const deadline = Date.now() + MAIL_ATTACHMENT_UPLOAD_MS;
|
|
161
|
+
for (const item of fresh)
|
|
162
|
+
item.uploaded = await uploadMailAttachments(
|
|
163
|
+
item.id,
|
|
164
|
+
item.message.attachments ?? [],
|
|
165
|
+
profiles.get(item.message.profile ?? "")!.max_attachment_bytes,
|
|
166
|
+
deadline,
|
|
167
|
+
);
|
|
168
|
+
const accepted = await outgoingMailBatches.accept(appId, crypto.randomUUID(), items, options);
|
|
169
|
+
const createdIds = new Set(accepted.created.map((row) => row.id));
|
|
170
|
+
for (const item of items) if (!createdIds.has(item.id)) await deleteMailObjects(item.uploaded.refs).catch(() => {});
|
|
171
|
+
for (const id of new Set(accepted.created.flatMap((row) => (row.profile_id ? [row.profile_id] : []))))
|
|
172
|
+
void submitMailDrain(id).catch(() => {});
|
|
173
|
+
return { batchId: accepted.batchId, ids: accepted.rows.map((row) => row.id) };
|
|
174
|
+
} catch (error) {
|
|
175
|
+
for (const message of messages) cancelMailStreams(message.attachments);
|
|
176
|
+
// Only an indeterminate COMMIT retains objects for the 48-hour expiry.
|
|
177
|
+
if (!(error instanceof MailAcceptanceUnknown)) for (const item of items) await deleteMailObjects(item.uploaded.refs).catch(() => {});
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { fail, ok, type Result } from "@k2b/stdlib";
|
|
2
|
+
import { getProcessApplicationId, getProcessPlatformPermissions } from "../../_internal/process-identity";
|
|
3
|
+
import {
|
|
4
|
+
MailBatchSchema,
|
|
5
|
+
type MailFilter,
|
|
6
|
+
MailFilterSchema,
|
|
7
|
+
type MailMessage,
|
|
8
|
+
MailMessageSchema,
|
|
9
|
+
type MailPage,
|
|
10
|
+
type MailPageParams,
|
|
11
|
+
MailPageParamsSchema,
|
|
12
|
+
type MailProfile,
|
|
13
|
+
type MailRecord,
|
|
14
|
+
type MailServiceError,
|
|
15
|
+
} from "../../contracts/outgoing-mail";
|
|
16
|
+
import { cancelMailStreams } from "./attachments";
|
|
17
|
+
import { enqueueMail } from "./enqueue";
|
|
18
|
+
import { outgoingMailMessages, recordMailBatch, recordMailSend } from "./messages";
|
|
19
|
+
import { sendMail } from "./send";
|
|
20
|
+
import { OutgoingMailError, outgoingMailStore } from "./store";
|
|
21
|
+
|
|
22
|
+
const identity = (): Result<string, MailServiceError> => {
|
|
23
|
+
const appId = getProcessApplicationId();
|
|
24
|
+
if (!appId) return fail({ code: "mail_unavailable", message: "Start the application before using outgoing mail.", status: 500 });
|
|
25
|
+
if (!getProcessPlatformPermissions().includes("mail:send"))
|
|
26
|
+
return fail({ code: "mail_not_declared", message: 'Declare platformPermissions: ["mail:send"].', status: 403 });
|
|
27
|
+
return ok(appId);
|
|
28
|
+
};
|
|
29
|
+
const failure = (error: unknown): MailServiceError => {
|
|
30
|
+
if (!(error instanceof OutgoingMailError))
|
|
31
|
+
return { code: "mail_unavailable", message: "Outgoing mail storage is unavailable.", status: 500 };
|
|
32
|
+
return {
|
|
33
|
+
code: error.code,
|
|
34
|
+
message: error.message,
|
|
35
|
+
status: error.code === "mail_unavailable" || error.status === 502 ? 500 : error.status,
|
|
36
|
+
...("limit" in error && typeof error.limit === "number" ? { limit: error.limit } : {}),
|
|
37
|
+
...("used" in error && typeof error.used === "number" ? { used: error.used } : {}),
|
|
38
|
+
...("requested" in error && typeof error.requested === "number" ? { requested: error.requested } : {}),
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
export const mail = {
|
|
42
|
+
async enqueue(messages: MailMessage[]): Promise<Result<{ batchId: string; ids: string[] }, MailServiceError>> {
|
|
43
|
+
const caller = identity();
|
|
44
|
+
const parsed = caller.ok ? MailBatchSchema.safeParse(messages) : undefined;
|
|
45
|
+
const cancel = () => {
|
|
46
|
+
if (Array.isArray(messages)) for (const message of messages) cancelMailStreams(message?.attachments);
|
|
47
|
+
};
|
|
48
|
+
if (!caller.ok || !parsed?.success) {
|
|
49
|
+
cancel();
|
|
50
|
+
const appId = getProcessApplicationId();
|
|
51
|
+
const issue: MailServiceError = caller.ok
|
|
52
|
+
? { code: "bad_input", message: "Provide 1–1000 valid messages with distinct keys.", status: 400 }
|
|
53
|
+
: caller.error;
|
|
54
|
+
if (appId)
|
|
55
|
+
await recordMailBatch(appId, Array.isArray(messages) ? messages : [], [], undefined, undefined, issue.code).catch(() => {});
|
|
56
|
+
return fail(issue);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return ok(await enqueueMail(caller.data, parsed.data));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
cancel();
|
|
62
|
+
const issue = failure(error);
|
|
63
|
+
await recordMailBatch(caller.data, parsed.data, [], undefined, undefined, issue.code).catch(() => {});
|
|
64
|
+
return fail(issue);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
async profiles(): Promise<Result<MailProfile[], MailServiceError>> {
|
|
68
|
+
const caller = identity();
|
|
69
|
+
if (!caller.ok) return caller;
|
|
70
|
+
try {
|
|
71
|
+
return ok(await outgoingMailStore.profilesForApp(caller.data));
|
|
72
|
+
} catch {
|
|
73
|
+
return fail(failure(undefined));
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
async send(message: MailMessage, options?: { signal?: AbortSignal }): Promise<Result<MailRecord, MailServiceError>> {
|
|
77
|
+
const caller = identity();
|
|
78
|
+
if (!caller.ok) {
|
|
79
|
+
cancelMailStreams(message?.attachments);
|
|
80
|
+
const appId = getProcessApplicationId();
|
|
81
|
+
if (appId) await recordMailSend(appId, message, undefined, undefined, caller.error.code).catch(() => {});
|
|
82
|
+
return caller;
|
|
83
|
+
}
|
|
84
|
+
const parsed = MailMessageSchema.safeParse(message);
|
|
85
|
+
if (!parsed.success) {
|
|
86
|
+
cancelMailStreams(message?.attachments);
|
|
87
|
+
await recordMailSend(caller.data, message, undefined, undefined, "bad_input").catch(() => {});
|
|
88
|
+
return fail({ code: "bad_input", message: "Invalid outgoing mail message.", status: 400 });
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
return ok(await sendMail(caller.data, parsed.data, options?.signal));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
cancelMailStreams(message?.attachments);
|
|
94
|
+
const issue = failure(error);
|
|
95
|
+
await recordMailSend(caller.data, parsed.data, undefined, undefined, issue.code).catch(() => {});
|
|
96
|
+
return fail(issue);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
async list(filter: MailFilter = {}, page: MailPageParams = {}): Promise<Result<MailPage, MailServiceError>> {
|
|
100
|
+
const caller = identity();
|
|
101
|
+
if (!caller.ok) return caller;
|
|
102
|
+
const parsedFilter = MailFilterSchema.safeParse(filter);
|
|
103
|
+
const parsedPage = MailPageParamsSchema.safeParse(page);
|
|
104
|
+
if (!parsedFilter.success || !parsedPage.success)
|
|
105
|
+
return fail({ code: "bad_input", message: "Invalid outgoing mail filter or page.", status: 400 });
|
|
106
|
+
try {
|
|
107
|
+
return ok(await outgoingMailMessages.list({ ...parsedFilter.data, app: caller.data }, parsedPage.data));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return fail(failure(error));
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RequestActor } from "../../contracts/shared";
|
|
2
|
+
|
|
3
|
+
export const mailBackoffMs = (attempt: number): number => Math.min(60_000 * 2 ** Math.min(Math.max(attempt - 1, 0), 6), 60 * 60_000);
|
|
4
|
+
export const mailMessageId = (id: string, address: string): string => `<${id}@${address.slice(address.lastIndexOf("@") + 1)}>`;
|
|
5
|
+
export const mailEnvelope = (profile: { fromAddress: string; fromName: string | null }, appName: string, fromName?: string | null) => ({
|
|
6
|
+
from: { address: profile.fromAddress, name: fromName ?? profile.fromName ?? appName },
|
|
7
|
+
envelope: { from: profile.fromAddress },
|
|
8
|
+
});
|
|
9
|
+
export const mailActorSnapshot = (actor?: RequestActor) => {
|
|
10
|
+
if (!actor) return undefined;
|
|
11
|
+
return actor.kind === "user"
|
|
12
|
+
? { type: actor.kind, id: actor.user.id, name: actor.user.displayName || actor.user.uid }
|
|
13
|
+
: { type: actor.kind, id: actor.serviceAccount.id, name: actor.serviceAccount.name };
|
|
14
|
+
};
|