@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { EmailNotificationPresentation, NotificationChannelId, NotificationPresentation } from "../../contracts/notification-types";
|
|
3
|
-
import {
|
|
3
|
+
import { notificationMailOutcome, notificationMailOutcomeById, sendNotificationMail } from "./email-mail";
|
|
4
4
|
|
|
5
5
|
export type ResolvedNotificationRecipient = {
|
|
6
6
|
userId: string | null;
|
|
@@ -23,7 +23,22 @@ export type NotificationChannelDriver = {
|
|
|
23
23
|
destination: NotificationDestination;
|
|
24
24
|
event: { id: string; definitionId: string };
|
|
25
25
|
}) => unknown;
|
|
26
|
-
deliver: (
|
|
26
|
+
deliver: (
|
|
27
|
+
payload: unknown,
|
|
28
|
+
context?: { deliveryId: string; signal?: AbortSignal; outgoingMailId?: string },
|
|
29
|
+
) => Promise<
|
|
30
|
+
| void
|
|
31
|
+
| {
|
|
32
|
+
status: "delivered";
|
|
33
|
+
outgoingMailId?: string;
|
|
34
|
+
}
|
|
35
|
+
| {
|
|
36
|
+
status: "pending";
|
|
37
|
+
retryAfterMs: number;
|
|
38
|
+
outgoingMailId?: string;
|
|
39
|
+
errorMessage?: string | null;
|
|
40
|
+
}
|
|
41
|
+
>;
|
|
27
42
|
};
|
|
28
43
|
|
|
29
44
|
const drivers = new Map<string, NotificationChannelDriver>();
|
|
@@ -58,7 +73,6 @@ type EmailPayload = {
|
|
|
58
73
|
subject: string;
|
|
59
74
|
content?: string;
|
|
60
75
|
rawHtml?: string;
|
|
61
|
-
messageId?: string;
|
|
62
76
|
};
|
|
63
77
|
|
|
64
78
|
const parseEmailPayload = (value: unknown): EmailPayload => {
|
|
@@ -69,8 +83,7 @@ const parseEmailPayload = (value: unknown): EmailPayload => {
|
|
|
69
83
|
}
|
|
70
84
|
if (payload.content !== undefined && typeof payload.content !== "string") throw new Error("Invalid email notification payload");
|
|
71
85
|
if (payload.rawHtml !== undefined && typeof payload.rawHtml !== "string") throw new Error("Invalid email notification payload");
|
|
72
|
-
|
|
73
|
-
return { to: payload.to, subject: payload.subject, content: payload.content, rawHtml: payload.rawHtml, messageId: payload.messageId };
|
|
86
|
+
return { to: payload.to, subject: payload.subject, content: payload.content, rawHtml: payload.rawHtml };
|
|
74
87
|
};
|
|
75
88
|
|
|
76
89
|
const emailDriver: NotificationChannelDriver = {
|
|
@@ -80,7 +93,7 @@ const emailDriver: NotificationChannelDriver = {
|
|
|
80
93
|
const email = normalizeEmail(recipient.email);
|
|
81
94
|
return [{ key: emailKey(email), label: emailLabel(email), context: { email } }];
|
|
82
95
|
},
|
|
83
|
-
createPayload: ({ presentation, email, destination
|
|
96
|
+
createPayload: ({ presentation, email, destination }) => {
|
|
84
97
|
const context = destination.context as { email?: unknown };
|
|
85
98
|
if (typeof context.email !== "string") throw new Error("Email destination is missing an address");
|
|
86
99
|
return {
|
|
@@ -88,16 +101,23 @@ const emailDriver: NotificationChannelDriver = {
|
|
|
88
101
|
subject: email?.subject ?? presentation.title,
|
|
89
102
|
content: email?.content ?? presentation.body,
|
|
90
103
|
rawHtml: email?.rawHtml,
|
|
91
|
-
messageId: `<cloud-notification-${event.id}@cloud.invalid>`,
|
|
92
104
|
} satisfies EmailPayload;
|
|
93
105
|
},
|
|
94
|
-
deliver: async (value) => {
|
|
106
|
+
deliver: async (value, context) => {
|
|
107
|
+
if (context?.outgoingMailId) return notificationMailOutcomeById(context.outgoingMailId);
|
|
95
108
|
const payload = parseEmailPayload(value);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
109
|
+
return notificationMailOutcome(
|
|
110
|
+
await sendNotificationMail(
|
|
111
|
+
payload.to,
|
|
112
|
+
payload.subject,
|
|
113
|
+
{
|
|
114
|
+
content: payload.content,
|
|
115
|
+
rawHtml: payload.rawHtml,
|
|
116
|
+
},
|
|
117
|
+
context ? `notification-delivery:${context.deliveryId}` : undefined,
|
|
118
|
+
context?.signal,
|
|
119
|
+
),
|
|
120
|
+
);
|
|
101
121
|
},
|
|
102
122
|
};
|
|
103
123
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { sql } from "bun";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import { decryptSecret } from "../secrets";
|
|
3
4
|
import { getNotificationChannel } from "./channels";
|
|
4
5
|
|
|
@@ -14,10 +15,12 @@ type DeliveryRow = {
|
|
|
14
15
|
required: boolean;
|
|
15
16
|
route_priority: number | null;
|
|
16
17
|
attempt_count: number;
|
|
18
|
+
outgoing_mail_id: string | null;
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
export type DeliveryAttemptResult =
|
|
20
22
|
| { status: "skipped" | "delivered" | "failed" }
|
|
23
|
+
| { status: "pending"; retryAfterMs: number }
|
|
21
24
|
| { status: "retry"; retryAfterMs: number; error: string };
|
|
22
25
|
|
|
23
26
|
const retryDelay = (attempt: number): number => Math.min(BASE_RETRY_MS * 2 ** Math.max(0, attempt - 1), MAX_RETRY_MS);
|
|
@@ -65,7 +68,10 @@ const activateNextFallback = async (eventId: string): Promise<string[]> => {
|
|
|
65
68
|
return rows.map((row) => row.id);
|
|
66
69
|
};
|
|
67
70
|
|
|
68
|
-
export const processNotificationDelivery = async (
|
|
71
|
+
export const processNotificationDelivery = async (
|
|
72
|
+
deliveryId: string,
|
|
73
|
+
signal?: AbortSignal,
|
|
74
|
+
): Promise<DeliveryAttemptResult & { activatedIds?: string[] }> => {
|
|
69
75
|
const rows = await sql<DeliveryRow[]>`
|
|
70
76
|
UPDATE notifications.deliveries
|
|
71
77
|
SET status = 'sending', attempt_count = attempt_count + 1,
|
|
@@ -73,7 +79,7 @@ export const processNotificationDelivery = async (deliveryId: string): Promise<D
|
|
|
73
79
|
WHERE id = ${deliveryId}::uuid
|
|
74
80
|
AND status = 'pending'
|
|
75
81
|
AND (next_attempt_at IS NULL OR next_attempt_at <= now())
|
|
76
|
-
RETURNING id, event_id, channel, payload_encrypted, required, route_priority, attempt_count
|
|
82
|
+
RETURNING id, event_id, channel, payload_encrypted, required, route_priority, attempt_count, outgoing_mail_id
|
|
77
83
|
`;
|
|
78
84
|
const delivery = rows[0];
|
|
79
85
|
if (!delivery) return { status: "skipped" };
|
|
@@ -86,10 +92,34 @@ export const processNotificationDelivery = async (deliveryId: string): Promise<D
|
|
|
86
92
|
throw Object.assign(new Error("Notification delivery payload is unavailable"), { code: "payload_missing", retryable: false });
|
|
87
93
|
}
|
|
88
94
|
const payload = await decryptSecret(delivery.payload_encrypted);
|
|
89
|
-
await driver.deliver(payload
|
|
95
|
+
const outcome = await driver.deliver(payload, {
|
|
96
|
+
deliveryId: delivery.id,
|
|
97
|
+
...(signal ? { signal } : {}),
|
|
98
|
+
...(delivery.outgoing_mail_id ? { outgoingMailId: delivery.outgoing_mail_id } : {}),
|
|
99
|
+
});
|
|
100
|
+
// Third-party drivers may return provider bodies; only a UUID can name an outgoing mail.
|
|
101
|
+
const mailId = z.uuid().safeParse(outcome?.outgoingMailId).data ?? null;
|
|
102
|
+
if (
|
|
103
|
+
outcome?.status === "pending" &&
|
|
104
|
+
typeof outcome.retryAfterMs === "number" &&
|
|
105
|
+
Number.isFinite(outcome.retryAfterMs) &&
|
|
106
|
+
outcome.retryAfterMs > 0
|
|
107
|
+
) {
|
|
108
|
+
const retryAfterMs = Math.round(Math.min(MAX_RETRY_MS, Math.max(BASE_RETRY_MS, outcome.retryAfterMs)));
|
|
109
|
+
await sql`
|
|
110
|
+
UPDATE notifications.deliveries
|
|
111
|
+
SET status = 'pending', attempt_count = attempt_count - 1,
|
|
112
|
+
next_attempt_at = now() + (${retryAfterMs}::int * INTERVAL '1 millisecond'),
|
|
113
|
+
outgoing_mail_id = COALESCE(${mailId}::uuid, outgoing_mail_id),
|
|
114
|
+
error_code = NULL, error_message = ${typeof outcome.errorMessage === "string" ? outcome.errorMessage : null}, updated_at = now()
|
|
115
|
+
WHERE id = ${delivery.id}::uuid
|
|
116
|
+
`;
|
|
117
|
+
return { status: "pending", retryAfterMs };
|
|
118
|
+
}
|
|
90
119
|
await sql`
|
|
91
120
|
UPDATE notifications.deliveries
|
|
92
|
-
SET
|
|
121
|
+
SET outgoing_mail_id = COALESCE(${mailId}::uuid, outgoing_mail_id),
|
|
122
|
+
status = 'delivered', delivered_at = now(), next_attempt_at = NULL,
|
|
93
123
|
error_code = NULL, error_message = NULL, payload_encrypted = NULL, updated_at = now()
|
|
94
124
|
WHERE id = ${delivery.id}::uuid
|
|
95
125
|
`;
|
|
@@ -98,6 +128,8 @@ export const processNotificationDelivery = async (deliveryId: string): Promise<D
|
|
|
98
128
|
} catch (error) {
|
|
99
129
|
const message = error instanceof Error ? error.message : "Notification delivery failed";
|
|
100
130
|
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "provider_error";
|
|
131
|
+
const outgoingMailId =
|
|
132
|
+
error && typeof error === "object" && "outgoingMailId" in error ? (z.uuid().safeParse(error.outgoingMailId).data ?? null) : null;
|
|
101
133
|
const retryable = !(error && typeof error === "object" && "retryable" in error && error.retryable === false);
|
|
102
134
|
if (retryable && delivery.attempt_count < MAX_DELIVERY_ATTEMPTS) {
|
|
103
135
|
const retryAfterMs = retryDelay(delivery.attempt_count);
|
|
@@ -112,7 +144,8 @@ export const processNotificationDelivery = async (deliveryId: string): Promise<D
|
|
|
112
144
|
|
|
113
145
|
await sql`
|
|
114
146
|
UPDATE notifications.deliveries
|
|
115
|
-
SET
|
|
147
|
+
SET outgoing_mail_id = COALESCE(${outgoingMailId}::uuid, outgoing_mail_id),
|
|
148
|
+
status = 'failed', next_attempt_at = NULL, error_code = ${code},
|
|
116
149
|
error_message = ${message}, payload_encrypted = NULL, updated_at = now()
|
|
117
150
|
WHERE id = ${delivery.id}::uuid
|
|
118
151
|
`;
|
|
@@ -137,3 +170,22 @@ export const recoverNotificationDeliveries = async (): Promise<string[]> => {
|
|
|
137
170
|
`;
|
|
138
171
|
return rows.map((row) => row.id);
|
|
139
172
|
};
|
|
173
|
+
|
|
174
|
+
export const reconcileNotificationMessages = async (): Promise<void> => {
|
|
175
|
+
await sql`
|
|
176
|
+
WITH terminal AS (
|
|
177
|
+
SELECT m.id, m.outgoing_mail_id, mail.status, mail.sent_at,
|
|
178
|
+
COALESCE(mail.error_message, mail.smtp_response, 'Outgoing mail record is no longer available.') AS error
|
|
179
|
+
FROM notifications.messages m
|
|
180
|
+
LEFT JOIN outgoing_mail.messages mail ON mail.id = m.outgoing_mail_id
|
|
181
|
+
WHERE m.outgoing_mail_id IS NOT NULL AND m.sent_at IS NULL AND m.error IS NULL
|
|
182
|
+
AND (mail.status IN ('sent', 'bounced', 'failed', 'cancelled') OR mail.id IS NULL)
|
|
183
|
+
ORDER BY m.outgoing_mail_id, m.id LIMIT 500
|
|
184
|
+
)
|
|
185
|
+
UPDATE notifications.messages m
|
|
186
|
+
SET sent_at = CASE WHEN t.status IN ('sent', 'bounced') THEN t.sent_at ELSE NULL END,
|
|
187
|
+
error = CASE WHEN t.status IN ('sent', 'bounced') THEN NULL ELSE t.error END
|
|
188
|
+
FROM terminal t
|
|
189
|
+
WHERE m.id = t.id AND m.outgoing_mail_id = t.outgoing_mail_id AND m.sent_at IS NULL AND m.error IS NULL
|
|
190
|
+
`;
|
|
191
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
|
|
2
|
+
<!DOCTYPE html>
|
|
3
|
+
<html>
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
</head>
|
|
8
|
+
<body style="margin:0;padding:0;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
|
9
|
+
<table width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px;">
|
|
10
|
+
<tr><td align="center">
|
|
11
|
+
<table width="100%" style="max-width:520px;" cellpadding="0" cellspacing="0">
|
|
12
|
+
|
|
13
|
+
<!-- Header -->
|
|
14
|
+
<tr><td style="background:#ffffff;padding:20px 24px;border-radius:12px 12px 0 0;border:1px solid #e4e4e7;border-bottom:none;">
|
|
15
|
+
<table cellpadding="0" cellspacing="0"><tr>
|
|
16
|
+
<td style="padding-right:12px;vertical-align:middle;">
|
|
17
|
+
<img src="https://example.test/logo.svg" alt="Logo" width="28" height="28" style="display:block;">
|
|
18
|
+
</td>
|
|
19
|
+
<td style="vertical-align:middle;">
|
|
20
|
+
<span style="font-size:16px;font-weight:600;color:#18181b;">Test Cloud</span>
|
|
21
|
+
</td>
|
|
22
|
+
</tr></table>
|
|
23
|
+
</td></tr>
|
|
24
|
+
|
|
25
|
+
<!-- Content -->
|
|
26
|
+
<tr><td style="background:#ffffff;padding:28px 24px;border-left:1px solid #e4e4e7;border-right:1px solid #e4e4e7;">
|
|
27
|
+
<div style="font-size:14px;line-height:1.6;color:#27272a;">
|
|
28
|
+
{{body}}
|
|
29
|
+
</div>
|
|
30
|
+
</td></tr>
|
|
31
|
+
|
|
32
|
+
<!-- Footer -->
|
|
33
|
+
<tr><td style="background:#fafafa;padding:16px 24px;border-radius:0 0 12px 12px;border:1px solid #e4e4e7;border-top:none;">
|
|
34
|
+
<p style="margin:0 0 8px;font-size:11px;color:#71717a;text-align:center;">
|
|
35
|
+
<a href="https://example.test/impressum" style="color:#71717a;text-decoration:underline;">Imprint</a>
|
|
36
|
+
·
|
|
37
|
+
<a href="https://example.test/legal/terms" style="color:#71717a;text-decoration:underline;">Terms</a>
|
|
38
|
+
·
|
|
39
|
+
<a href="https://example.test/legal/privacy" style="color:#71717a;text-decoration:underline;">Privacy</a>
|
|
40
|
+
</p>
|
|
41
|
+
<p style="margin:0;font-size:11px;color:#a1a1aa;text-align:center;">
|
|
42
|
+
This message was sent automatically. Please do not reply to this email.
|
|
43
|
+
</p>
|
|
44
|
+
</td></tr>
|
|
45
|
+
|
|
46
|
+
</table>
|
|
47
|
+
</td></tr>
|
|
48
|
+
</table>
|
|
49
|
+
</body>
|
|
50
|
+
<!-- the answer is 42 ;D -->
|
|
51
|
+
</html>
|
|
@@ -1,57 +1,24 @@
|
|
|
1
|
-
import { createTransport, type Transporter } from "nodemailer";
|
|
2
1
|
import sanitizeHtml from "sanitize-html";
|
|
3
|
-
import { sanitizeEmailHtml } from "../../shared";
|
|
2
|
+
import { sanitizeEmailHtml } from "../../shared/email-html";
|
|
4
3
|
import * as settings from "../settings";
|
|
5
4
|
import { coreSettings } from "../settings/api";
|
|
6
5
|
|
|
7
|
-
/** Lazily-created transporter — uses current settings on each send. */
|
|
8
|
-
const getTransporter = async (): Promise<Transporter> => {
|
|
9
|
-
const host = await settings.get<string>("mail.noreply.smtp_host");
|
|
10
|
-
const port = await settings.get<number>("mail.noreply.smtp_port");
|
|
11
|
-
const user = await settings.get<string>("mail.noreply.user");
|
|
12
|
-
const pass = await settings.get<string>("mail.noreply.password");
|
|
13
|
-
|
|
14
|
-
return createTransport({
|
|
15
|
-
host,
|
|
16
|
-
port,
|
|
17
|
-
secure: port === 465,
|
|
18
|
-
auth: { user, pass },
|
|
19
|
-
});
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
/** Sanitize plain text content (no HTML allowed). */
|
|
23
6
|
const sanitizeContent = (content: string): string => sanitizeHtml(content, { allowedTags: [], allowedAttributes: {} });
|
|
24
7
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
8
|
+
// sanitize-html decodes entities before escaping &, < and > again. Decode only
|
|
9
|
+
// those escapes once, so literal entity text is not recursively interpreted.
|
|
10
|
+
const htmlText = (html: string): string =>
|
|
11
|
+
sanitizeContent(html.replace(/<br\b[^>]*>|<\/(?:p|div|h[1-6]|li|tr|td|th|ul|ol|table)>/gi, " "))
|
|
12
|
+
.replace(/&(amp|lt|gt);/g, (entity) => (entity === "&" ? "&" : entity === "<" ? "<" : ">"))
|
|
13
|
+
.replace(/\s+/g, " ")
|
|
14
|
+
.trim();
|
|
15
|
+
|
|
16
|
+
export const prepareNotificationEmail = async (opts: { content?: string; rawHtml?: string }): Promise<{ html: string; text: string }> => {
|
|
31
17
|
const rawAppUrl = await settings.get<string>("app.url");
|
|
32
18
|
const appUrl = rawAppUrl.startsWith("http") ? rawAppUrl : `https://${rawAppUrl}`;
|
|
33
19
|
const appName = await settings.get<string>("app.name");
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
let body = "";
|
|
37
|
-
if (opts.rawHtml) {
|
|
38
|
-
body = sanitizeEmailHtml(opts.rawHtml);
|
|
39
|
-
} else if (opts.content) {
|
|
40
|
-
body = `<p>${sanitizeContent(opts.content)}</p>`;
|
|
41
|
-
}
|
|
42
|
-
const text = opts.content ? sanitizeContent(opts.content) : undefined;
|
|
43
|
-
|
|
44
|
-
const html = await buildHtml(appUrl, appName, body);
|
|
45
|
-
const transporter = await getTransporter();
|
|
46
|
-
|
|
47
|
-
await transporter.sendMail({
|
|
48
|
-
from: `"${appName}" <${emailFrom}>`,
|
|
49
|
-
to,
|
|
50
|
-
subject,
|
|
51
|
-
...(opts.messageId ? { messageId: opts.messageId } : {}),
|
|
52
|
-
html,
|
|
53
|
-
...(text ? { text } : {}),
|
|
54
|
-
});
|
|
20
|
+
const body = opts.rawHtml ? sanitizeEmailHtml(opts.rawHtml) : opts.content ? `<p>${sanitizeContent(opts.content)}</p>` : "";
|
|
21
|
+
return { html: await buildHtml(appUrl, appName, body), text: opts.content ? sanitizeContent(opts.content) : htmlText(body) };
|
|
55
22
|
};
|
|
56
23
|
|
|
57
24
|
/**
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { domainToASCII } from "node:url";
|
|
2
|
+
import type { ZodError } from "zod";
|
|
3
|
+
import { type MailMessage, MailMessageSchema, type MailRecord } from "../../contracts/outgoing-mail";
|
|
4
|
+
import { messageRecord, outgoingMailMessages } from "../outgoing-mail/messages";
|
|
5
|
+
import { sendMail } from "../outgoing-mail/send";
|
|
6
|
+
import { OutgoingMailError } from "../outgoing-mail/store";
|
|
7
|
+
import { prepareNotificationEmail } from "./email-frame";
|
|
8
|
+
|
|
9
|
+
export const notificationMailError = (error: unknown): Error & { code: string; retryable: boolean } => {
|
|
10
|
+
const code = error instanceof OutgoingMailError ? error.code : "mail_unavailable";
|
|
11
|
+
return Object.assign(new Error(error instanceof Error ? error.message : "Outgoing mail is unavailable."), {
|
|
12
|
+
code,
|
|
13
|
+
retryable: ["mail_unavailable", "backlog_full", "quota_exceeded", "attachment_storage_full"].includes(code),
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const normalizeNotificationMail = (to: string, subject: string) => {
|
|
18
|
+
const separator = to.lastIndexOf("@");
|
|
19
|
+
const domain = separator < 0 ? "" : to.slice(separator + 1);
|
|
20
|
+
// URL conversion can discard invalid address characters; leave those for validation.
|
|
21
|
+
const ascii = domain && !/[^\p{L}\p{N}\p{M}.-]/u.test(domain) ? domainToASCII(domain) : "";
|
|
22
|
+
return {
|
|
23
|
+
to: [ascii ? `${to.slice(0, separator + 1)}${ascii}` : to],
|
|
24
|
+
subject: subject
|
|
25
|
+
.replace(/[\r\n]+/g, " ")
|
|
26
|
+
.replace(/\0/g, "")
|
|
27
|
+
.trim()
|
|
28
|
+
.slice(0, 998),
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const notificationMailValidationError = (error: ZodError) =>
|
|
33
|
+
new OutgoingMailError(
|
|
34
|
+
"bad_input",
|
|
35
|
+
`Invalid notification email: ${error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`,
|
|
36
|
+
400,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const notificationMailMessage = async (
|
|
40
|
+
to: string,
|
|
41
|
+
subject: string,
|
|
42
|
+
body: { content?: string; rawHtml?: string },
|
|
43
|
+
key?: string,
|
|
44
|
+
): Promise<MailMessage> => {
|
|
45
|
+
const parsed = MailMessageSchema.safeParse({
|
|
46
|
+
...normalizeNotificationMail(to, subject),
|
|
47
|
+
...(await prepareNotificationEmail(body)),
|
|
48
|
+
...(key ? { key } : {}),
|
|
49
|
+
});
|
|
50
|
+
if (!parsed.success) throw notificationMailValidationError(parsed.error);
|
|
51
|
+
return parsed.data;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const sendNotificationMail = async (
|
|
55
|
+
to: string,
|
|
56
|
+
subject: string,
|
|
57
|
+
body: { content?: string; rawHtml?: string },
|
|
58
|
+
key?: string,
|
|
59
|
+
signal?: AbortSignal,
|
|
60
|
+
): Promise<MailRecord> => {
|
|
61
|
+
try {
|
|
62
|
+
return await sendMail("core", await notificationMailMessage(to, subject, body, key), signal, { trustedHtml: true });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw notificationMailError(error);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const notificationMailOutcome = async (record: MailRecord) => {
|
|
69
|
+
const row = await outgoingMailMessages.read(record.id, "core").catch(() => undefined);
|
|
70
|
+
const status = row?.status ?? record.status;
|
|
71
|
+
const errorCode = row?.error_code ?? (record.error ? "smtp_failed" : null);
|
|
72
|
+
const errorMessage = row?.error_message ?? row?.smtp_response ?? record.error ?? null;
|
|
73
|
+
if (status === "sent" || status === "bounced") return { status: "delivered" as const, outgoingMailId: record.id };
|
|
74
|
+
if (status === "failed" || status === "cancelled") {
|
|
75
|
+
throw Object.assign(new Error(errorMessage ?? `Outgoing mail ${status}.`), {
|
|
76
|
+
code: errorCode ?? "smtp_failed",
|
|
77
|
+
retryable: false,
|
|
78
|
+
outgoingMailId: record.id,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const next = row?.next_attempt_at ? new Date(row.next_attempt_at).getTime() - Date.now() : 60_000;
|
|
82
|
+
// Keep polling within the notification retry interval bounds.
|
|
83
|
+
const retryAfterMs = Number.isFinite(next) ? Math.min(5 * 60_000, Math.max(2_000, next)) : 60_000;
|
|
84
|
+
return { status: "pending" as const, outgoingMailId: record.id, retryAfterMs, errorMessage };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const notificationMailOutcomeById = async (id: string) => {
|
|
88
|
+
const row = await outgoingMailMessages.read(id, "core").catch((error: unknown) => {
|
|
89
|
+
throw notificationMailError(error);
|
|
90
|
+
});
|
|
91
|
+
if (!row) {
|
|
92
|
+
// Retention may remove a settled mail before notification recovery. Its
|
|
93
|
+
// stored identity must never turn that recovery into another SMTP send.
|
|
94
|
+
throw Object.assign(new Error("Outgoing mail record is no longer available."), {
|
|
95
|
+
code: "smtp_failed",
|
|
96
|
+
retryable: false,
|
|
97
|
+
outgoingMailId: id,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return notificationMailOutcome(messageRecord(row));
|
|
101
|
+
};
|
|
@@ -4,7 +4,7 @@ import type { BoundNotificationDefinition, NotificationRecipientKind, Notificati
|
|
|
4
4
|
import type { PaginationParams } from "../../contracts/shared";
|
|
5
5
|
import { logger } from "../logging";
|
|
6
6
|
import { escapeLikePattern } from "../postgres";
|
|
7
|
-
import {
|
|
7
|
+
import { notificationMailOutcome, sendNotificationMail } from "./email-mail";
|
|
8
8
|
import "./browser";
|
|
9
9
|
import { notificationObservability } from "./observability";
|
|
10
10
|
import { sendTypedNotification, type TypedNotificationSendResult } from "./platform";
|
|
@@ -107,6 +107,26 @@ type DbNotificationRow = {
|
|
|
107
107
|
sent_by_name: string | null;
|
|
108
108
|
};
|
|
109
109
|
|
|
110
|
+
type LegacyMailRow = { id: string; type: NotificationType; recipient: string; subject: string; content: string; mail_generation: number };
|
|
111
|
+
|
|
112
|
+
const deliverLegacyMail = async (row: LegacyMailRow, body: { content?: string; rawHtml?: string }) => {
|
|
113
|
+
const key = `notification-message:${row.id}${row.mail_generation ? `:${row.mail_generation}` : ""}`;
|
|
114
|
+
try {
|
|
115
|
+
const record = await sendNotificationMail(row.recipient, row.subject, body, key);
|
|
116
|
+
await sql`UPDATE notifications.messages SET outgoing_mail_id = ${record.id}::uuid, sent_at = NULL, error = NULL
|
|
117
|
+
WHERE id = ${row.id}::uuid AND mail_generation = ${row.mail_generation}`;
|
|
118
|
+
const outcome = await notificationMailOutcome(record);
|
|
119
|
+
if (outcome.status === "pending") return "pending" as const;
|
|
120
|
+
await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL
|
|
121
|
+
WHERE id = ${row.id}::uuid AND mail_generation = ${row.mail_generation}`;
|
|
122
|
+
return "sent" as const;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
await sql`UPDATE notifications.messages SET sent_at = NULL, error = ${error instanceof Error ? error.message : String(error)}
|
|
125
|
+
WHERE id = ${row.id}::uuid AND mail_generation = ${row.mail_generation}`;
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
110
130
|
/**
|
|
111
131
|
* Send a notification. Persists to DB, attempts delivery (if autoSend=true), updates sent_at/error.
|
|
112
132
|
*/
|
|
@@ -130,15 +150,11 @@ const sendLegacy = async (params: SendNotificationParams): Promise<SendNotificat
|
|
|
130
150
|
|
|
131
151
|
// Attempt delivery
|
|
132
152
|
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL WHERE id = ${id}`;
|
|
137
|
-
return { id, status: "sent" };
|
|
153
|
+
const status = await deliverLegacyMail({ id, type, recipient, subject, content: dbContent, mail_generation: 0 }, { content, rawHtml });
|
|
154
|
+
return { id, status };
|
|
138
155
|
} catch (e) {
|
|
139
156
|
const error = e instanceof Error ? e.message : String(e);
|
|
140
157
|
log.error("Failed to send", { type, recipient, error });
|
|
141
|
-
await sql`UPDATE notifications.messages SET error = ${error} WHERE id = ${id}`;
|
|
142
158
|
return { id, status: "error", error };
|
|
143
159
|
}
|
|
144
160
|
};
|
|
@@ -482,23 +498,26 @@ export const getById = async (id: string): Promise<NotificationMessage | null> =
|
|
|
482
498
|
* Resend a notification (retry delivery).
|
|
483
499
|
*/
|
|
484
500
|
export const resend = async (id: string): Promise<{ ok: true } | { ok: false; code: "not_found" | "delivery_failed"; error: string }> => {
|
|
485
|
-
const notification = await
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
501
|
+
const notification = await sql.begin(async (tx): Promise<(LegacyMailRow & { current_mail_status: string | null }) | undefined> => {
|
|
502
|
+
const [row] = await tx<(LegacyMailRow & { outgoing_mail_id: string | null; current_mail_status: string | null })[]>`
|
|
503
|
+
SELECT m.*, mail.status AS current_mail_status FROM notifications.messages m
|
|
504
|
+
LEFT JOIN outgoing_mail.messages mail ON mail.id = m.outgoing_mail_id
|
|
505
|
+
WHERE m.id = ${id}::uuid FOR UPDATE OF m
|
|
506
|
+
`;
|
|
507
|
+
if (!row || row.current_mail_status === "queued" || row.current_mail_status === "sending" || !row.outgoing_mail_id) return row;
|
|
508
|
+
const [updated] = await tx<LegacyMailRow[]>`
|
|
509
|
+
UPDATE notifications.messages SET mail_generation = mail_generation + 1, outgoing_mail_id = NULL, sent_at = NULL, error = NULL
|
|
510
|
+
WHERE id = ${id}::uuid RETURNING *
|
|
511
|
+
`;
|
|
512
|
+
return updated ? { ...updated, current_mail_status: null } : undefined;
|
|
513
|
+
});
|
|
514
|
+
if (!notification) return { ok: false, code: "not_found", error: "Notification not found" };
|
|
515
|
+
if (["queued", "sending"].includes(notification.current_mail_status ?? "")) return { ok: true };
|
|
490
516
|
try {
|
|
491
|
-
|
|
492
|
-
await sendEmail(notification.recipient, notification.subject, {
|
|
493
|
-
rawHtml: notification.content,
|
|
494
|
-
});
|
|
495
|
-
}
|
|
496
|
-
await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL WHERE id = ${id}`;
|
|
517
|
+
await deliverLegacyMail(notification, { rawHtml: notification.content });
|
|
497
518
|
return { ok: true };
|
|
498
519
|
} catch (e) {
|
|
499
|
-
|
|
500
|
-
await sql`UPDATE notifications.messages SET error = ${error} WHERE id = ${id}`;
|
|
501
|
-
return { ok: false, code: "delivery_failed", error };
|
|
520
|
+
return { ok: false, code: "delivery_failed", error: e instanceof Error ? e.message : String(e) };
|
|
502
521
|
}
|
|
503
522
|
};
|
|
504
523
|
|
|
@@ -535,7 +554,9 @@ export const update = async (
|
|
|
535
554
|
subject = COALESCE(${data.subject ?? null}, subject),
|
|
536
555
|
content = COALESCE(${data.content ?? null}, content),
|
|
537
556
|
recipient = COALESCE(${data.recipient ?? null}, recipient),
|
|
538
|
-
error = CASE WHEN ${clearError} THEN NULL ELSE error END
|
|
557
|
+
error = CASE WHEN ${clearError} THEN NULL ELSE error END,
|
|
558
|
+
mail_generation = CASE WHEN ${clearError} THEN mail_generation + 1 ELSE mail_generation END,
|
|
559
|
+
outgoing_mail_id = CASE WHEN ${clearError} THEN NULL ELSE outgoing_mail_id END
|
|
539
560
|
WHERE id = ${id}
|
|
540
561
|
`;
|
|
541
562
|
|
|
@@ -548,7 +569,7 @@ export const update = async (
|
|
|
548
569
|
export const getPendingSystemCount = async (): Promise<number> => {
|
|
549
570
|
const rows = await sql`
|
|
550
571
|
SELECT COUNT(*)::int as count FROM notifications.messages
|
|
551
|
-
WHERE sent_at IS NULL AND error IS NULL AND sent_by IS NULL
|
|
572
|
+
WHERE sent_at IS NULL AND error IS NULL AND sent_by IS NULL AND outgoing_mail_id IS NULL
|
|
552
573
|
`;
|
|
553
574
|
return rows[0]?.count ?? 0;
|
|
554
575
|
};
|
|
@@ -563,10 +584,10 @@ export const sendAllPendingSystem = async (): Promise<{
|
|
|
563
584
|
errors: { id: string; recipient: string; error: string }[];
|
|
564
585
|
}> => {
|
|
565
586
|
// Get all pending system notifications
|
|
566
|
-
const rows = await sql
|
|
567
|
-
SELECT id, type, recipient, subject, content
|
|
587
|
+
const rows = await sql<LegacyMailRow[]>`
|
|
588
|
+
SELECT id, type, recipient, subject, content, mail_generation
|
|
568
589
|
FROM notifications.messages
|
|
569
|
-
WHERE sent_at IS NULL AND error IS NULL AND sent_by IS NULL
|
|
590
|
+
WHERE sent_at IS NULL AND error IS NULL AND sent_by IS NULL AND outgoing_mail_id IS NULL
|
|
570
591
|
ORDER BY created_at ASC
|
|
571
592
|
`;
|
|
572
593
|
|
|
@@ -575,21 +596,13 @@ export const sendAllPendingSystem = async (): Promise<{
|
|
|
575
596
|
const errors: { id: string; recipient: string; error: string }[] = [];
|
|
576
597
|
|
|
577
598
|
for (const row of rows) {
|
|
578
|
-
const id = row
|
|
579
|
-
const type = row.type as NotificationType;
|
|
580
|
-
const recipient = row.recipient as string;
|
|
581
|
-
const subject = row.subject as string;
|
|
582
|
-
const content = row.content as string;
|
|
599
|
+
const { id, recipient, content } = row;
|
|
583
600
|
|
|
584
601
|
try {
|
|
585
|
-
if (
|
|
586
|
-
await sendEmail(recipient, subject, { rawHtml: content });
|
|
587
|
-
}
|
|
588
|
-
await sql`UPDATE notifications.messages SET sent_at = now(), error = NULL WHERE id = ${id}`;
|
|
602
|
+
if ((await deliverLegacyMail(row, { rawHtml: content })) === "pending") continue;
|
|
589
603
|
sent++;
|
|
590
604
|
} catch (e) {
|
|
591
605
|
const error = e instanceof Error ? e.message : String(e);
|
|
592
|
-
await sql`UPDATE notifications.messages SET error = ${error} WHERE id = ${id}`;
|
|
593
606
|
failed++;
|
|
594
607
|
errors.push({ id, recipient, error });
|
|
595
608
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
|
+
import type { MailStatus } from "../../contracts/outgoing-mail";
|
|
2
3
|
import type { NotificationDeliveryStatus } from "../../contracts/user-notifications";
|
|
3
4
|
import { escapeLikePattern, toPgTextArray } from "../postgres";
|
|
4
5
|
|
|
@@ -20,6 +21,8 @@ export type NotificationDeliveryObservabilityItem = {
|
|
|
20
21
|
required: boolean;
|
|
21
22
|
routePriority: number | null;
|
|
22
23
|
status: NotificationDeliveryStatus;
|
|
24
|
+
outgoingMailId: string | null;
|
|
25
|
+
outgoingMailStatus: MailStatus | null;
|
|
23
26
|
attemptCount: number;
|
|
24
27
|
errorCode: string | null;
|
|
25
28
|
errorMessage: string | null;
|
|
@@ -79,6 +82,8 @@ type DeliveryRow = {
|
|
|
79
82
|
required: boolean;
|
|
80
83
|
route_priority: number | null;
|
|
81
84
|
status: NotificationDeliveryStatus;
|
|
85
|
+
outgoing_mail_id: string | null;
|
|
86
|
+
outgoing_mail_status: MailStatus | null;
|
|
82
87
|
attempt_count: number;
|
|
83
88
|
error_code: string | null;
|
|
84
89
|
error_message: string | null;
|
|
@@ -168,11 +173,12 @@ const listDeliveries = async (input: {
|
|
|
168
173
|
COALESCE(NULLIF(u.display_name, ''), NULLIF(u.uid, ''), e.recipient_email, e.recipient_key) AS recipient_label,
|
|
169
174
|
COALESCE(NULLIF(u.mail, ''), NULLIF(u.uid, ''), e.recipient_email, e.recipient_key) AS recipient_reference,
|
|
170
175
|
d.channel, d.destination_label, d.required, d.route_priority, d.status,
|
|
171
|
-
d.attempt_count, d.error_code, d.error_message, d.created_at, d.updated_at, d.delivered_at
|
|
176
|
+
d.outgoing_mail_id, mail.status AS outgoing_mail_status, d.attempt_count, d.error_code, d.error_message, d.created_at, d.updated_at, d.delivered_at
|
|
172
177
|
FROM notifications.deliveries d
|
|
173
178
|
JOIN notifications.events e ON e.id = d.event_id
|
|
174
179
|
JOIN notifications.definitions n ON n.id = e.definition_id
|
|
175
180
|
LEFT JOIN auth.users u ON u.id = e.recipient_user_id
|
|
181
|
+
LEFT JOIN outgoing_mail.messages mail ON mail.id = d.outgoing_mail_id
|
|
176
182
|
WHERE ${where}
|
|
177
183
|
ORDER BY d.created_at DESC, d.id DESC
|
|
178
184
|
LIMIT ${perPage} OFFSET ${offset}
|
|
@@ -196,6 +202,8 @@ const listDeliveries = async (input: {
|
|
|
196
202
|
required: row.required,
|
|
197
203
|
routePriority: row.route_priority,
|
|
198
204
|
status: row.status,
|
|
205
|
+
outgoingMailId: row.outgoing_mail_id,
|
|
206
|
+
outgoingMailStatus: row.outgoing_mail_status,
|
|
199
207
|
attemptCount: row.attempt_count,
|
|
200
208
|
errorCode: row.error_code,
|
|
201
209
|
errorMessage: row.error_message,
|
|
@@ -407,7 +407,7 @@ export const sendTypedNotification = async <
|
|
|
407
407
|
if (persisted.prepared) {
|
|
408
408
|
for (const deliveryId of persisted.requiredIds) {
|
|
409
409
|
const result = await processNotificationDelivery(deliveryId);
|
|
410
|
-
if (result.status === "retry") await enqueueNotificationDelivery(deliveryId, result.retryAfterMs);
|
|
410
|
+
if (result.status === "retry" || result.status === "pending") await enqueueNotificationDelivery(deliveryId, result.retryAfterMs);
|
|
411
411
|
if (result.activatedIds?.length) await enqueueNotificationDeliveries(result.activatedIds);
|
|
412
412
|
}
|
|
413
413
|
await enqueueNotificationDeliveries(persisted.pendingIds);
|