@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
|
@@ -3,7 +3,7 @@ import { lazySync } from "../../_internal/process-sync";
|
|
|
3
3
|
import { logger, trace } from "../logging";
|
|
4
4
|
import { createRuntimeLifecycle, createRuntimeTaskTracker, stopRuntimeJobs } from "../runtime-lifecycle";
|
|
5
5
|
|
|
6
|
-
import { processNotificationDelivery, recoverNotificationDeliveries } from "./dispatcher";
|
|
6
|
+
import { processNotificationDelivery, reconcileNotificationMessages, recoverNotificationDeliveries } from "./dispatcher";
|
|
7
7
|
|
|
8
8
|
type DeliveryMessage = { deliveryId: string };
|
|
9
9
|
|
|
@@ -58,10 +58,10 @@ const handleDelivery = async (context: JobContext<DeliveryMessage>): Promise<voi
|
|
|
58
58
|
spanKey: trace.syncSpanKey("job", "cloud-notification-deliveries", context.jobId),
|
|
59
59
|
attributes: { "cloud.notification.delivery_id": context.input.deliveryId },
|
|
60
60
|
},
|
|
61
|
-
() => processNotificationDelivery(context.input.deliveryId),
|
|
61
|
+
() => processNotificationDelivery(context.input.deliveryId, context.signal),
|
|
62
62
|
);
|
|
63
63
|
if (result.activatedIds?.length) await enqueueNotificationDeliveries(result.activatedIds);
|
|
64
|
-
if (result.status === "retry") context.resubmit({ delayMs: result.retryAfterMs });
|
|
64
|
+
if (result.status === "retry" || result.status === "pending") context.resubmit({ delayMs: result.retryAfterMs });
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
type RuntimeOptions = { concurrency?: number; recoveryIntervalMs?: number };
|
|
@@ -79,6 +79,12 @@ const recover = (): Promise<void> => {
|
|
|
79
79
|
} catch (error) {
|
|
80
80
|
log.error("Delivery recovery failed", { error: error instanceof Error ? error.message : String(error) });
|
|
81
81
|
}
|
|
82
|
+
// Message history must never hold back delivery recovery.
|
|
83
|
+
try {
|
|
84
|
+
await reconcileNotificationMessages();
|
|
85
|
+
} catch (error) {
|
|
86
|
+
log.error("Notification message reconciliation failed", { error: error instanceof Error ? error.message : String(error) });
|
|
87
|
+
}
|
|
82
88
|
}) ?? Promise.resolve()
|
|
83
89
|
).finally(() => {
|
|
84
90
|
recovery = undefined;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import {
|
|
3
|
+
type AdminMailFilter,
|
|
4
|
+
type AdminMailRecord,
|
|
5
|
+
AdminMailRecordSchema,
|
|
6
|
+
type MailPage,
|
|
7
|
+
type MailPageParams,
|
|
8
|
+
} from "../../contracts/outgoing-mail";
|
|
9
|
+
import { audit } from "../audit";
|
|
10
|
+
import { cleanupMailAttachments, publishMailSettled } from "./dispatcher";
|
|
11
|
+
import { adminMessageRecord, type MessageRow, outgoingMailMessages } from "./messages";
|
|
12
|
+
import { type MailAuditContext, OutgoingMailError } from "./store";
|
|
13
|
+
|
|
14
|
+
const get = async (id: string): Promise<AdminMailRecord> => {
|
|
15
|
+
const record = await outgoingMailMessages.metadata(id);
|
|
16
|
+
if (!record) throw new OutgoingMailError("message_unknown", "Outgoing mail message does not exist.", 404);
|
|
17
|
+
return record;
|
|
18
|
+
};
|
|
19
|
+
const cancel = async (id: string, context: MailAuditContext): Promise<AdminMailRecord> => {
|
|
20
|
+
const row = await sql.begin(async (tx) => {
|
|
21
|
+
const [row] = await tx<MessageRow[]>`UPDATE outgoing_mail.messages SET status = 'cancelled', error_code = 'cancelled_by_admin',
|
|
22
|
+
error_message = 'Cancelled by an administrator.', next_attempt_at = NULL, updated_at = now() WHERE id = ${id}::uuid AND status = 'queued'
|
|
23
|
+
RETURNING *, created_at::text AS cursor_created_at`;
|
|
24
|
+
if (!row) {
|
|
25
|
+
if (!(await outgoingMailMessages.read(id, undefined, tx)))
|
|
26
|
+
throw new OutgoingMailError("message_unknown", "Outgoing mail message does not exist.", 404);
|
|
27
|
+
throw new OutgoingMailError("message_not_queued", "Only queued outgoing mail can be cancelled.", 409);
|
|
28
|
+
}
|
|
29
|
+
await audit.record(
|
|
30
|
+
{
|
|
31
|
+
...context,
|
|
32
|
+
action: "outgoing_mail.message.cancel",
|
|
33
|
+
outcome: "allowed",
|
|
34
|
+
target: { type: "outgoing_mail_message", id },
|
|
35
|
+
metadata: { appId: row.app_id, profile: row.profile_key },
|
|
36
|
+
},
|
|
37
|
+
tx,
|
|
38
|
+
);
|
|
39
|
+
return row;
|
|
40
|
+
});
|
|
41
|
+
await cleanupMailAttachments(id).catch(() => {});
|
|
42
|
+
await publishMailSettled(id).catch(() => {});
|
|
43
|
+
return adminMessageRecord(row);
|
|
44
|
+
};
|
|
45
|
+
export const outgoingMailLog = {
|
|
46
|
+
get,
|
|
47
|
+
cancel,
|
|
48
|
+
async cancelBatch(batchId: string, context: MailAuditContext): Promise<{ batchId: string; cancelled: number }> {
|
|
49
|
+
const cancelled = await sql.begin(async (tx) => {
|
|
50
|
+
const apps = await tx<{ app_id: string }[]>`SELECT DISTINCT app_id FROM outgoing_mail.messages WHERE batch_id = ${batchId}::uuid`;
|
|
51
|
+
if (!apps.length) throw new OutgoingMailError("batch_unknown", "Outgoing mail batch does not exist.", 404);
|
|
52
|
+
const rows = await tx<{ id: string }[]>`UPDATE outgoing_mail.messages SET status = 'cancelled', error_code = 'cancelled_by_admin',
|
|
53
|
+
error_message = 'Cancelled by an administrator.', next_attempt_at = NULL, updated_at = now()
|
|
54
|
+
WHERE batch_id = ${batchId}::uuid AND status = 'queued' RETURNING id`;
|
|
55
|
+
await audit.record(
|
|
56
|
+
{
|
|
57
|
+
...context,
|
|
58
|
+
action: "outgoing_mail.batch.cancel",
|
|
59
|
+
outcome: "allowed",
|
|
60
|
+
target: { type: "outgoing_mail_batch", id: batchId },
|
|
61
|
+
metadata: { appIds: apps.map((row) => row.app_id), count: rows.length },
|
|
62
|
+
},
|
|
63
|
+
tx,
|
|
64
|
+
);
|
|
65
|
+
return rows;
|
|
66
|
+
});
|
|
67
|
+
for (const row of cancelled) {
|
|
68
|
+
await cleanupMailAttachments(row.id).catch(() => {});
|
|
69
|
+
await publishMailSettled(row.id).catch(() => {});
|
|
70
|
+
}
|
|
71
|
+
return { batchId, cancelled: cancelled.length };
|
|
72
|
+
},
|
|
73
|
+
content: outgoingMailMessages.content,
|
|
74
|
+
list: async (filter: AdminMailFilter, page: MailPageParams): Promise<MailPage<AdminMailRecord>> => {
|
|
75
|
+
const result = await outgoingMailMessages.list(filter, page, true);
|
|
76
|
+
return {
|
|
77
|
+
...result,
|
|
78
|
+
items: result.items.map((item) => {
|
|
79
|
+
// The metadata mapper deliberately omits content; validate its public boundary.
|
|
80
|
+
return AdminMailRecordSchema.parse(item);
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { type ObjectRef, StoreFullError } from "@k2b/sync";
|
|
3
|
+
import type { MailAttachment, MailRecord } from "../../contracts/outgoing-mail";
|
|
4
|
+
import { OutgoingMailError } from "./store";
|
|
5
|
+
import { mailAttachments } from "./sync";
|
|
6
|
+
|
|
7
|
+
export type UploadedAttachments = { metadata: MailRecord["attachments"]; refs: ObjectRef[] };
|
|
8
|
+
export const MAIL_ATTACHMENT_UPLOAD_MS = 60_000;
|
|
9
|
+
export const cancelMailStreams = (attachments: unknown = []): void => {
|
|
10
|
+
if (!Array.isArray(attachments)) return;
|
|
11
|
+
for (const attachment of attachments) {
|
|
12
|
+
if (
|
|
13
|
+
attachment &&
|
|
14
|
+
typeof attachment === "object" &&
|
|
15
|
+
"content" in attachment &&
|
|
16
|
+
attachment.content instanceof ReadableStream &&
|
|
17
|
+
!attachment.content.locked
|
|
18
|
+
)
|
|
19
|
+
void attachment.content.cancel().catch(() => {});
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
export const deleteMailObjects = async (refs: readonly ObjectRef[]): Promise<void> => {
|
|
23
|
+
// Sequential deletion bounds load even for a large attachment collection.
|
|
24
|
+
for (const ref of refs) await mailAttachments().delete({ tenantId: ref.tenantId, key: ref.key });
|
|
25
|
+
};
|
|
26
|
+
const contentStream = (content: MailAttachment["content"]): ReadableStream<Uint8Array> => {
|
|
27
|
+
if (content instanceof ReadableStream) return content;
|
|
28
|
+
if (content instanceof Blob) return content.stream();
|
|
29
|
+
return new ReadableStream({
|
|
30
|
+
start(controller) {
|
|
31
|
+
controller.enqueue(content);
|
|
32
|
+
controller.close();
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
export const uploadMailAttachments = async (
|
|
37
|
+
id: string,
|
|
38
|
+
attachments: readonly MailAttachment[],
|
|
39
|
+
limit: number,
|
|
40
|
+
uploadDeadline?: number,
|
|
41
|
+
): Promise<UploadedAttachments> => {
|
|
42
|
+
const result: UploadedAttachments = { metadata: [], refs: [] };
|
|
43
|
+
let total = 0;
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
// The caller's signal cancels the wait only. All attachments share one bounded upload.
|
|
46
|
+
if (uploadDeadline !== undefined && uploadDeadline <= Date.now() && attachments.length) {
|
|
47
|
+
cancelMailStreams(attachments);
|
|
48
|
+
throw new OutgoingMailError("mail_unavailable", "Attachment upload timed out.");
|
|
49
|
+
}
|
|
50
|
+
const timer = setTimeout(
|
|
51
|
+
() => controller.abort(new Error("Attachment upload timed out")),
|
|
52
|
+
uploadDeadline === undefined ? MAIL_ATTACHMENT_UPLOAD_MS : Math.max(0, uploadDeadline - Date.now()),
|
|
53
|
+
);
|
|
54
|
+
try {
|
|
55
|
+
for (const [index, attachment] of attachments.entries()) {
|
|
56
|
+
const hash = createHash("sha256");
|
|
57
|
+
let size = 0;
|
|
58
|
+
let exceeded = false;
|
|
59
|
+
const key = `${id}-${index}`;
|
|
60
|
+
try {
|
|
61
|
+
const stream = contentStream(attachment.content).pipeThrough(
|
|
62
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
63
|
+
transform(chunk, output) {
|
|
64
|
+
total += chunk.byteLength;
|
|
65
|
+
size += chunk.byteLength;
|
|
66
|
+
if (total > limit) {
|
|
67
|
+
exceeded = true;
|
|
68
|
+
throw new OutgoingMailError("attachments_too_large", "Attachments exceed the profile's total byte limit.");
|
|
69
|
+
}
|
|
70
|
+
hash.update(chunk);
|
|
71
|
+
output.enqueue(chunk);
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
{ signal: controller.signal },
|
|
75
|
+
);
|
|
76
|
+
const ref = await mailAttachments().put({ tenantId: id, key, body: stream, signal: controller.signal });
|
|
77
|
+
result.refs.push(ref);
|
|
78
|
+
result.metadata.push({ filename: attachment.filename, contentType: attachment.contentType, size, sha256: hash.digest("hex") });
|
|
79
|
+
} catch (error) {
|
|
80
|
+
// A failed put can have written chunks before the final metadata was published.
|
|
81
|
+
await mailAttachments()
|
|
82
|
+
.delete({ tenantId: id, key })
|
|
83
|
+
.catch(() => {});
|
|
84
|
+
if (exceeded) throw new OutgoingMailError("attachments_too_large", "Attachments exceed the profile's total byte limit.");
|
|
85
|
+
if (
|
|
86
|
+
error instanceof StoreFullError ||
|
|
87
|
+
(error instanceof Error &&
|
|
88
|
+
/maximum bytes|maximum storage|insufficient (?:storage|resources)|storage resources|store.*full|resource limits exceeded/i.test(
|
|
89
|
+
error.message,
|
|
90
|
+
))
|
|
91
|
+
)
|
|
92
|
+
throw new OutgoingMailError("attachment_storage_full", "Outgoing mail attachment storage is full.");
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
await deleteMailObjects(result.refs).catch(() => {});
|
|
99
|
+
cancelMailStreams(attachments);
|
|
100
|
+
throw error;
|
|
101
|
+
} finally {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
controller.abort();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Verify before SMTP DATA; open a second, verified stream for nodemailer. */
|
|
108
|
+
export const verifyMailAttachment = async (
|
|
109
|
+
ref: ObjectRef,
|
|
110
|
+
metadata: MailRecord["attachments"][number],
|
|
111
|
+
signal?: AbortSignal,
|
|
112
|
+
): Promise<void> => {
|
|
113
|
+
const object = await mailAttachments().get(ref, { signal });
|
|
114
|
+
if (!object) throw new OutgoingMailError("attachment_lost", "An outgoing mail attachment is missing or changed.");
|
|
115
|
+
const reader = object.body.getReader();
|
|
116
|
+
const hash = createHash("sha256");
|
|
117
|
+
let size = 0;
|
|
118
|
+
try {
|
|
119
|
+
while (true) {
|
|
120
|
+
const chunk = await reader.read();
|
|
121
|
+
if (chunk.done) break;
|
|
122
|
+
size += chunk.value.byteLength;
|
|
123
|
+
if (size > metadata.size) throw new Error("Attachment size mismatch");
|
|
124
|
+
hash.update(chunk.value);
|
|
125
|
+
}
|
|
126
|
+
if (size !== metadata.size || hash.digest("hex") !== metadata.sha256) throw new Error("Attachment digest mismatch");
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (signal?.aborted || (error instanceof Error && /stalled|timeout|timed out|connection/i.test(error.message)))
|
|
129
|
+
throw new Error("Attachment verification was interrupted.");
|
|
130
|
+
throw new OutgoingMailError("attachment_lost", "An outgoing mail attachment is missing or changed.");
|
|
131
|
+
} finally {
|
|
132
|
+
await reader.cancel().catch(() => {});
|
|
133
|
+
reader.releaseLock();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const MAIL_RECOVERY_MS = 30_000;
|
|
2
|
+
export const mailSlotMs = (pacePerMinute: number): number => 60_000 / pacePerMinute;
|
|
3
|
+
export const mailBacklogFull = (queued: number, added: number, pacePerMinute: number): boolean =>
|
|
4
|
+
added > 0 && queued + added > pacePerMinute * 1440;
|
|
5
|
+
export const mailBatchId = (proposed: string, rows: readonly ({ batch_id: string | null } | undefined)[]): string => {
|
|
6
|
+
const previous = rows[0]?.batch_id;
|
|
7
|
+
return previous && rows.every((row) => row?.batch_id === previous) ? previous : proposed;
|
|
8
|
+
};
|
|
9
|
+
// Later submissions join a coalesced continuation, so long backoffs must not hide fresh mail.
|
|
10
|
+
export const mailNextDrainDelay = (now: number, slot: number, due: number): number =>
|
|
11
|
+
Math.min(MAIL_RECOVERY_MS, Math.max(0, Math.max(slot, due) - now));
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Socket } from "node:net";
|
|
3
|
+
import { Readable } from "node:stream";
|
|
4
|
+
import { sql } from "bun";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { listApps } from "../../_internal/registry";
|
|
7
|
+
import { parsePgJsonValue } from "../postgres";
|
|
8
|
+
import { get } from "../settings";
|
|
9
|
+
import { deleteMailObjects, verifyMailAttachment } from "./attachments";
|
|
10
|
+
import { mailBackoffMs, mailEnvelope } from "./message";
|
|
11
|
+
import { allowedMailProfile, type MessageRow, messageAttachmentRefs, messageRecord, outgoingMailMessages } from "./messages";
|
|
12
|
+
import { OutgoingMailError, resolveMailCredentials } from "./store";
|
|
13
|
+
import { mailAttachments, mailSettled } from "./sync";
|
|
14
|
+
import { smtpFailureMessage } from "./test-send";
|
|
15
|
+
import { buildMailTransport } from "./transport";
|
|
16
|
+
|
|
17
|
+
export const cleanupMailAttachments = async (id: string): Promise<void> => {
|
|
18
|
+
const row = await outgoingMailMessages.read(id);
|
|
19
|
+
if (!row || ["queued", "sending"].includes(row.status)) return;
|
|
20
|
+
await deleteMailObjects(messageAttachmentRefs(row));
|
|
21
|
+
await sql`UPDATE outgoing_mail.messages SET attachment_refs = NULL WHERE id = ${id}::uuid AND status NOT IN ('queued','sending')`;
|
|
22
|
+
};
|
|
23
|
+
export const publishMailSettled = async (id: string): Promise<void> => {
|
|
24
|
+
await mailSettled().publish({ tenantId: id, data: id });
|
|
25
|
+
};
|
|
26
|
+
const smtpResponseRetryable = (error: unknown): boolean => {
|
|
27
|
+
const responseCode = error && typeof error === "object" && "responseCode" in error ? Number(error.responseCode) : undefined;
|
|
28
|
+
return responseCode === undefined || !Number.isFinite(responseCode) || responseCode < 500;
|
|
29
|
+
};
|
|
30
|
+
export const smtpRetryable = (error: unknown): boolean => {
|
|
31
|
+
if (error && typeof error === "object" && "rejectedErrors" in error && Array.isArray(error.rejectedErrors) && error.rejectedErrors.length)
|
|
32
|
+
return error.rejectedErrors.some(smtpResponseRetryable);
|
|
33
|
+
return smtpResponseRetryable(error);
|
|
34
|
+
};
|
|
35
|
+
const rejectReason = (error: unknown, fallback: string): string =>
|
|
36
|
+
error && typeof error === "object" && "response" in error && typeof error.response === "string" ? error.response : fallback;
|
|
37
|
+
const cancelled = async (row: MessageRow, code: "profile_removed" | "profile_not_allowed", message?: string) => {
|
|
38
|
+
await sql`UPDATE outgoing_mail.messages SET status = 'cancelled', error_code = ${code}, error_message = ${message ?? (code === "profile_removed" ? "Outgoing mail profile was removed." : "Outgoing mail profile access was revoked.")},
|
|
39
|
+
next_attempt_at = NULL, updated_at = now() WHERE id = ${row.id}::uuid AND status = 'sending' AND attempt_count = ${row.attempt_count}`;
|
|
40
|
+
};
|
|
41
|
+
export const processOutgoingMail = async (id: string, signal?: AbortSignal): Promise<void> => {
|
|
42
|
+
const [row] = await sql<
|
|
43
|
+
MessageRow[]
|
|
44
|
+
>`UPDATE outgoing_mail.messages SET status = 'sending', attempt_count = attempt_count + 1, updated_at = now()
|
|
45
|
+
WHERE id = ${id}::uuid AND status = 'queued' AND lane = 'immediate' AND (next_attempt_at IS NULL OR next_attempt_at <= now() OR deadline_at <= now())
|
|
46
|
+
RETURNING *, created_at::text AS cursor_created_at`;
|
|
47
|
+
if (!row) return;
|
|
48
|
+
await attemptOutgoingMail(row, signal);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Both lanes share policy, attachments, SMTP outcomes and retry handling. */
|
|
52
|
+
export const attemptOutgoingMail = async (row: MessageRow, signal?: AbortSignal): Promise<void> => {
|
|
53
|
+
const id = row.id;
|
|
54
|
+
let credentials: Awaited<ReturnType<typeof resolveMailCredentials>> | undefined;
|
|
55
|
+
try {
|
|
56
|
+
if (new Date(row.deadline_at).getTime() <= Date.now()) {
|
|
57
|
+
await sql`UPDATE outgoing_mail.messages SET status = 'failed', error_code = COALESCE(error_code, 'smtp_failed'), error_message = COALESCE(error_message, 'Outgoing mail delivery deadline expired.'), next_attempt_at = NULL, updated_at = now() WHERE id = ${id}::uuid AND status = 'sending' AND attempt_count = ${row.attempt_count}`;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!row.profile_id) {
|
|
61
|
+
await cancelled(row, "profile_removed");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const apps = await listApps();
|
|
65
|
+
const app = apps.find((app) => app.id === row.app_id);
|
|
66
|
+
if (app && !app.platformPermissions?.includes("mail:send")) {
|
|
67
|
+
await cancelled(row, "profile_not_allowed", "The application no longer declares mail:send.");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// Core mail speaks for the installation, as system email did before it used outgoing mail.
|
|
71
|
+
const senderName = row.app_id === "core" ? await get<string>("app.name") : app?.name || row.app_id;
|
|
72
|
+
try {
|
|
73
|
+
await sql.begin(async (tx) => {
|
|
74
|
+
await tx`SELECT pg_advisory_xact_lock_shared(hashtextextended('outgoing_mail.policy', 0))`;
|
|
75
|
+
const profile = await allowedMailProfile(tx, row.app_id, row.profile_key);
|
|
76
|
+
if (profile.id !== row.profile_id) throw new OutgoingMailError("profile_removed", "Outgoing mail profile was replaced.");
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (
|
|
80
|
+
error instanceof OutgoingMailError &&
|
|
81
|
+
["profile_unknown", "profile_removed", "mail_unavailable", "profile_not_allowed", "profile_required"].includes(error.code)
|
|
82
|
+
) {
|
|
83
|
+
await cancelled(
|
|
84
|
+
row,
|
|
85
|
+
["profile_unknown", "profile_removed", "mail_unavailable"].includes(error.code) ? "profile_removed" : "profile_not_allowed",
|
|
86
|
+
);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
const refs = messageAttachmentRefs(row);
|
|
92
|
+
const metadata = messageRecord(row).attachments;
|
|
93
|
+
if (refs.length !== metadata.length) throw new OutgoingMailError("attachment_lost", "An outgoing mail attachment is missing.");
|
|
94
|
+
const attempt = new AbortController();
|
|
95
|
+
const abort = () => attempt.abort();
|
|
96
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
97
|
+
if (signal?.aborted) abort();
|
|
98
|
+
const timer = setTimeout(abort, 60_000);
|
|
99
|
+
const streams: Readable[] = [];
|
|
100
|
+
try {
|
|
101
|
+
for (const [index, ref] of refs.entries()) await verifyMailAttachment(ref, metadata[index]!, attempt.signal);
|
|
102
|
+
const attachments = [];
|
|
103
|
+
for (const [index, ref] of refs.entries()) {
|
|
104
|
+
const stored = await mailAttachments().get(ref, { signal: attempt.signal });
|
|
105
|
+
if (!stored) throw new OutgoingMailError("attachment_lost", "An outgoing mail attachment is missing.");
|
|
106
|
+
const item = metadata[index]!;
|
|
107
|
+
const stream = Readable.from(
|
|
108
|
+
(async function* () {
|
|
109
|
+
const reader = stored.body.getReader();
|
|
110
|
+
const hash = createHash("sha256");
|
|
111
|
+
let bytes = 0;
|
|
112
|
+
try {
|
|
113
|
+
while (true) {
|
|
114
|
+
const chunk = await reader.read();
|
|
115
|
+
if (chunk.done) break;
|
|
116
|
+
bytes += chunk.value.byteLength;
|
|
117
|
+
if (bytes > item.size) throw new OutgoingMailError("attachment_lost", "Attachment size changed.");
|
|
118
|
+
hash.update(chunk.value);
|
|
119
|
+
yield chunk.value;
|
|
120
|
+
}
|
|
121
|
+
if (bytes !== item.size || hash.digest("hex") !== item.sha256)
|
|
122
|
+
throw new OutgoingMailError("attachment_lost", "Attachment checksum changed.");
|
|
123
|
+
} finally {
|
|
124
|
+
await reader.cancel().catch(() => {});
|
|
125
|
+
reader.releaseLock();
|
|
126
|
+
}
|
|
127
|
+
})(),
|
|
128
|
+
);
|
|
129
|
+
streams.push(stream);
|
|
130
|
+
attachments.push({ filename: item.filename, contentType: item.contentType, content: stream });
|
|
131
|
+
}
|
|
132
|
+
// Load the checked profile by ID: a profile deleted (or recreated under its key) since the check is profile_removed.
|
|
133
|
+
credentials = await resolveMailCredentials({ id: row.profile_id });
|
|
134
|
+
const socket = new Socket();
|
|
135
|
+
// DNS can complete after cancellation; tear down a late connection too.
|
|
136
|
+
socket.once("connect", () => {
|
|
137
|
+
if (attempt.signal.aborted) socket.destroy();
|
|
138
|
+
});
|
|
139
|
+
const transport = buildMailTransport(credentials, socket);
|
|
140
|
+
const stop = () => {
|
|
141
|
+
socket.destroy();
|
|
142
|
+
transport.close();
|
|
143
|
+
for (const stream of streams) stream.destroy();
|
|
144
|
+
};
|
|
145
|
+
attempt.signal.addEventListener("abort", stop, { once: true });
|
|
146
|
+
try {
|
|
147
|
+
if (attempt.signal.aborted) throw new Error("SMTP attempt cancelled");
|
|
148
|
+
const result = await transport.sendMail({
|
|
149
|
+
...mailEnvelope(credentials, senderName, row.from_name),
|
|
150
|
+
envelope: { from: credentials.fromAddress, to: row.to_addresses },
|
|
151
|
+
to: row.to_addresses,
|
|
152
|
+
subject: row.subject,
|
|
153
|
+
text: row.text_body ?? "",
|
|
154
|
+
html: row.html_body ?? undefined,
|
|
155
|
+
replyTo: row.reply_to ?? undefined,
|
|
156
|
+
messageId: row.message_id_header,
|
|
157
|
+
headers: row.headers === null ? undefined : z.record(z.string(), z.string()).parse(parsePgJsonValue(row.headers)),
|
|
158
|
+
attachments,
|
|
159
|
+
disableFileAccess: true,
|
|
160
|
+
disableUrlAccess: true,
|
|
161
|
+
});
|
|
162
|
+
const failures = (result.rejected ?? []).map((recipient: string, index: number) => ({
|
|
163
|
+
recipient,
|
|
164
|
+
reason: rejectReason(result.rejectedErrors?.[index], "Recipient rejected."),
|
|
165
|
+
at: new Date().toISOString(),
|
|
166
|
+
}));
|
|
167
|
+
await sql`UPDATE outgoing_mail.messages SET status = 'sent', sent_at = now(), next_attempt_at = NULL, error_code = NULL, error_message = NULL,
|
|
168
|
+
smtp_response = ${result.response}, failures = ${JSON.stringify(failures)}::text::jsonb, updated_at = now()
|
|
169
|
+
WHERE id = ${id}::uuid AND status = 'sending' AND attempt_count = ${row.attempt_count}`;
|
|
170
|
+
} finally {
|
|
171
|
+
attempt.signal.removeEventListener("abort", stop);
|
|
172
|
+
socket.destroy();
|
|
173
|
+
transport.close();
|
|
174
|
+
}
|
|
175
|
+
} finally {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
signal?.removeEventListener("abort", abort);
|
|
178
|
+
attempt.abort();
|
|
179
|
+
for (const stream of streams) stream.destroy();
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error instanceof OutgoingMailError && ["profile_unknown", "profile_removed"].includes(error.code)) {
|
|
183
|
+
await cancelled(row, "profile_removed");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const secrets = credentials
|
|
187
|
+
? [
|
|
188
|
+
credentials.smtpUser,
|
|
189
|
+
credentials.smtpPassword,
|
|
190
|
+
credentials.smtpUser ? `\0${credentials.smtpUser}\0${credentials.smtpPassword ?? ""}` : null,
|
|
191
|
+
]
|
|
192
|
+
: [];
|
|
193
|
+
const message =
|
|
194
|
+
credentials || error instanceof OutgoingMailError
|
|
195
|
+
? smtpFailureMessage(error, secrets)
|
|
196
|
+
: "Outgoing mail delivery is temporarily unavailable.";
|
|
197
|
+
const lost = error instanceof OutgoingMailError && error.code === "attachment_lost";
|
|
198
|
+
const retry = !lost && smtpRetryable(error) && new Date(row.deadline_at).getTime() > Date.now();
|
|
199
|
+
const next = new Date(Date.now() + mailBackoffMs(row.attempt_count));
|
|
200
|
+
await sql`UPDATE outgoing_mail.messages SET status = ${retry ? "queued" : "failed"}, error_code = ${lost ? "attachment_lost" : "smtp_failed"}, error_message = ${message},
|
|
201
|
+
next_attempt_at = ${retry ? next : null}, updated_at = now() WHERE id = ${id}::uuid AND status = 'sending' AND attempt_count = ${row.attempt_count}`;
|
|
202
|
+
} finally {
|
|
203
|
+
// Cleanup failure must never turn SMTP success into another delivery attempt.
|
|
204
|
+
await cleanupMailAttachments(id).catch(() => {});
|
|
205
|
+
await publishMailSettled(id).catch(() => {});
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
export const recoverOutgoingMail = async (): Promise<string[]> => {
|
|
209
|
+
await sql`WITH stale AS (SELECT id FROM outgoing_mail.messages WHERE status = 'sending' AND updated_at < now() - INTERVAL '5 minutes' LIMIT 1000 FOR UPDATE SKIP LOCKED)
|
|
210
|
+
UPDATE outgoing_mail.messages SET status = 'queued', next_attempt_at = now(), updated_at = now() WHERE id IN (SELECT id FROM stale)`;
|
|
211
|
+
const orphaned = await sql<{ id: string }[]>`WITH orphaned AS (
|
|
212
|
+
SELECT id FROM outgoing_mail.messages WHERE status = 'queued' AND lane = 'bulk' AND profile_id IS NULL
|
|
213
|
+
LIMIT 1000 FOR UPDATE SKIP LOCKED
|
|
214
|
+
) UPDATE outgoing_mail.messages SET status = 'cancelled', error_code = 'profile_removed',
|
|
215
|
+
error_message = 'Outgoing mail profile was removed.', next_attempt_at = NULL, updated_at = now()
|
|
216
|
+
WHERE id IN (SELECT id FROM orphaned) RETURNING id`;
|
|
217
|
+
const expired = await sql<{ id: string }[]>`WITH expired AS (
|
|
218
|
+
SELECT id FROM outgoing_mail.messages WHERE status = 'queued' AND lane = 'bulk' AND deadline_at <= now()
|
|
219
|
+
LIMIT 1000 FOR UPDATE SKIP LOCKED
|
|
220
|
+
) UPDATE outgoing_mail.messages SET status = 'failed', error_code = COALESCE(error_code, 'smtp_failed'),
|
|
221
|
+
error_message = COALESCE(error_message, 'Outgoing mail delivery deadline expired.'), next_attempt_at = NULL, updated_at = now()
|
|
222
|
+
WHERE id IN (SELECT id FROM expired) RETURNING id`;
|
|
223
|
+
for (const row of [...orphaned, ...expired]) await publishMailSettled(row.id).catch(() => {});
|
|
224
|
+
const cleanup = await sql<
|
|
225
|
+
{ id: string }[]
|
|
226
|
+
>`SELECT id FROM outgoing_mail.messages WHERE status NOT IN ('queued','sending') AND attachment_refs IS NOT NULL LIMIT 1000`;
|
|
227
|
+
for (const row of cleanup) await cleanupMailAttachments(row.id).catch(() => {});
|
|
228
|
+
const due = await sql<{ id: string }[]>`SELECT id FROM outgoing_mail.messages WHERE lane = 'immediate' AND status = 'queued'
|
|
229
|
+
AND (next_attempt_at IS NULL OR next_attempt_at <= now() OR deadline_at <= now()) ORDER BY next_attempt_at NULLS FIRST, created_at LIMIT 1000`;
|
|
230
|
+
return due.map((row) => row.id);
|
|
231
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { mailNextDrainDelay } from "./bulk";
|
|
3
|
+
import { attemptOutgoingMail } from "./dispatcher";
|
|
4
|
+
import type { MessageRow } from "./messages";
|
|
5
|
+
|
|
6
|
+
export const MAIL_DRAIN_MS = 60_000;
|
|
7
|
+
export const claimOutgoingBulkMail = async (
|
|
8
|
+
profileId: string,
|
|
9
|
+
): Promise<{ row: MessageRow; slotAt: string; grantedAt: string } | undefined> =>
|
|
10
|
+
sql.begin(async (tx) => {
|
|
11
|
+
// Wait for acceptance's share lock before reading the clock and reserving a slot.
|
|
12
|
+
await tx`SELECT 1 FROM outgoing_mail.profiles WHERE id = ${profileId}::uuid FOR NO KEY UPDATE`;
|
|
13
|
+
const [slot] = await tx<{ slot_at: string; granted_at: string }[]>`UPDATE outgoing_mail.profiles p
|
|
14
|
+
SET next_bulk_slot_at = greatest(p.next_bulk_slot_at, c.at) + (60000.0 / p.pace_per_minute) * INTERVAL '1 millisecond'
|
|
15
|
+
FROM (SELECT clock_timestamp() AS at) c WHERE p.id = ${profileId}::uuid AND p.next_bulk_slot_at <= c.at
|
|
16
|
+
RETURNING p.next_bulk_slot_at::text AS slot_at, c.at::text AS granted_at`;
|
|
17
|
+
if (!slot) return undefined;
|
|
18
|
+
const [row] = await tx<MessageRow[]>`WITH due AS (
|
|
19
|
+
SELECT id FROM outgoing_mail.messages WHERE profile_id = ${profileId}::uuid AND status = 'queued' AND lane = 'bulk'
|
|
20
|
+
AND (next_attempt_at IS NULL OR next_attempt_at <= now()) AND deadline_at > now()
|
|
21
|
+
ORDER BY COALESCE(next_attempt_at, created_at), id LIMIT 1 FOR UPDATE SKIP LOCKED
|
|
22
|
+
) UPDATE outgoing_mail.messages SET status = 'sending', attempt_count = attempt_count + 1, updated_at = now()
|
|
23
|
+
WHERE id IN (SELECT id FROM due) RETURNING *, created_at::text AS cursor_created_at`;
|
|
24
|
+
// An idle claim consumes at most one slot. No refund race or extra state is needed.
|
|
25
|
+
return row ? { row, slotAt: slot.slot_at, grantedAt: slot.granted_at } : undefined;
|
|
26
|
+
});
|
|
27
|
+
export const nextOutgoingBulkDelay = async (profileId: string): Promise<number | undefined> => {
|
|
28
|
+
const [next] = await sql<{ slot: Date; due: Date; current: Date }[]>`SELECT p.next_bulk_slot_at AS slot, now() AS current, m.due
|
|
29
|
+
FROM outgoing_mail.profiles p CROSS JOIN LATERAL (
|
|
30
|
+
SELECT COALESCE(next_attempt_at, created_at) AS due FROM outgoing_mail.messages
|
|
31
|
+
WHERE profile_id = p.id AND lane = 'bulk' AND status = 'queued' AND deadline_at > now()
|
|
32
|
+
ORDER BY COALESCE(next_attempt_at, created_at), id LIMIT 1
|
|
33
|
+
) m WHERE p.id = ${profileId}::uuid`;
|
|
34
|
+
return next
|
|
35
|
+
? mailNextDrainDelay(new Date(next.current).getTime(), new Date(next.slot).getTime(), new Date(next.due).getTime())
|
|
36
|
+
: undefined;
|
|
37
|
+
};
|
|
38
|
+
export const dueOutgoingBulkProfiles = async (): Promise<string[]> => {
|
|
39
|
+
const rows = await sql<{ id: string }[]>`SELECT p.id FROM outgoing_mail.profiles p WHERE EXISTS (
|
|
40
|
+
SELECT 1 FROM outgoing_mail.messages m WHERE m.profile_id = p.id AND m.lane = 'bulk' AND m.status = 'queued'
|
|
41
|
+
AND COALESCE(m.next_attempt_at, m.created_at) <= now() AND m.deadline_at > now()
|
|
42
|
+
) LIMIT 1000`;
|
|
43
|
+
return rows.map((row) => row.id);
|
|
44
|
+
};
|
|
45
|
+
/** Stop at the next closed gate and continue durably instead of occupying a worker while waiting. */
|
|
46
|
+
export const drainOutgoingMail = async (
|
|
47
|
+
profileId: string,
|
|
48
|
+
signal?: AbortSignal,
|
|
49
|
+
heartbeat?: () => Promise<void>,
|
|
50
|
+
): Promise<number | undefined> => {
|
|
51
|
+
const until = Date.now() + MAIL_DRAIN_MS;
|
|
52
|
+
// The run window bounds claims; only shutdown may cancel the current attempt.
|
|
53
|
+
while (!signal?.aborted && Date.now() < until) {
|
|
54
|
+
const claimed = await claimOutgoingBulkMail(profileId);
|
|
55
|
+
if (!claimed) break;
|
|
56
|
+
await attemptOutgoingMail(claimed.row, signal);
|
|
57
|
+
await heartbeat?.();
|
|
58
|
+
}
|
|
59
|
+
return nextOutgoingBulkDelay(profileId);
|
|
60
|
+
};
|