@cosmicdrift/kumiko-samples 0.392.2 → 0.394.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 +1 -1
- package/packages/bundled-features/package.json +15 -5
- package/packages/bundled-features/src/account-security/README.md +1 -1
- package/packages/bundled-features/src/account-security/changes.json +6 -0
- package/packages/bundled-features/src/account-security/feature.ts +9 -1
- package/packages/bundled-features/src/audit/event-classification.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -52
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -4
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +3 -12
- package/packages/bundled-features/src/auth-email-password/web/index.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/web/login-failure.ts +54 -0
- package/packages/bundled-features/src/auth-email-password/web/login-url.ts +19 -0
- package/packages/bundled-features/src/auth-email-password/web/static-login.ts +183 -0
- package/packages/bundled-features/src/auth-mfa/web/mfa-client.ts +7 -63
- package/packages/bundled-features/src/billing-foundation/changes.json +30 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +8 -16
- package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +9 -2
- package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/one-off-checkout-gate.ts +44 -0
- package/packages/bundled-features/src/billing-foundation/one-off-customer.ts +17 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +26 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +8 -2
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-recurring.ts +23 -0
- package/packages/bundled-features/src/billing-foundation/webhook-handler.ts +1 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +3 -3
- package/packages/bundled-features/src/cap-overview/types.ts +2 -0
- package/packages/bundled-features/src/compliance-profiles/web/compliance-profile-catalog.tsx +10 -1
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +12 -3
- package/packages/bundled-features/src/files/changes.json +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-health.ts +146 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-poll-schedule.ts +74 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-poll.ts +238 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +2 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-wake.ts +21 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +155 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-callback-verify.ts +65 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-route-responses.ts +26 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +93 -62
- package/packages/bundled-features/src/inbound-mail-foundation/constants.ts +66 -3
- package/packages/bundled-features/src/inbound-mail-foundation/content-refetch.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/describe-error.ts +5 -0
- package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +45 -2
- package/packages/bundled-features/src/inbound-mail-foundation/events.ts +69 -2
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +133 -118
- package/packages/bundled-features/src/inbound-mail-foundation/file-storage-failure.ts +23 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/account-address.ts +79 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/acknowledge-quarantine.write.ts +55 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/attach-message-content.write.ts +184 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +137 -84
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +65 -45
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-prepare.ts +177 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-schema.ts +42 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +79 -160
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +93 -30
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +46 -30
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-quarantine.query.ts +106 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/mark-credential-stored.write.ts +47 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.query.ts +99 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.ts +229 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-detail.query.ts +34 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/reactivate-account.write.ts +129 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/retry-quarantined-message.write.ts +49 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +48 -9
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/set-trusted-authserv-id.write.ts +66 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +136 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/trusted-authserv-id.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +82 -60
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +80 -1
- package/packages/bundled-features/src/inbound-mail-foundation/inbound-oauth-flow.ts +48 -0
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +94 -15
- package/packages/bundled-features/src/inbound-mail-foundation/ingest-failures.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/ingest-payload.ts +46 -0
- package/packages/bundled-features/src/inbound-mail-foundation/mail-roles.ts +132 -0
- package/packages/bundled-features/src/inbound-mail-foundation/normalize-inbound-message.ts +119 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +26 -8
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachment-access.ts +28 -0
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachments.ts +111 -0
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +19 -1
- package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +2 -1
- package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +33 -2
- package/packages/bundled-features/src/inbound-mail-foundation/provider-errors.ts +74 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-entities.ts +56 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-header-import.ts +63 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-retry.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-screens.ts +66 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine.ts +72 -0
- package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +78 -0
- package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +43 -8
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +239 -79
- package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +24 -5
- package/packages/bundled-features/src/inbound-mail-foundation/sender-auth.ts +215 -0
- package/packages/bundled-features/src/inbound-mail-foundation/supervisor-ingest.ts +311 -0
- package/packages/bundled-features/src/inbound-mail-foundation/sync-account-now.ts +61 -0
- package/packages/bundled-features/src/inbound-mail-foundation/sync-cursor-store.ts +79 -0
- package/packages/bundled-features/src/inbound-mail-foundation/syncable-accounts.ts +123 -0
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -1
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +76 -105
- package/packages/bundled-features/src/inbound-mail-foundation/wake-consumer.ts +33 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-catch-up.ts +68 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +124 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +87 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +143 -433
- package/packages/bundled-features/src/inbound-mail-foundation/web/client-plugin.ts +73 -0
- package/packages/bundled-features/src/inbound-mail-foundation/web/constants.ts +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/web/index.ts +5 -0
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +42 -0
- package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +155 -63
- package/packages/bundled-features/src/inbound-provider-imap/connect-my-mailbox.ts +45 -0
- package/packages/bundled-features/src/inbound-provider-imap/constants.ts +14 -1
- package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +2 -1
- package/packages/bundled-features/src/inbound-provider-imap/download-batch.ts +38 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +75 -44
- package/packages/bundled-features/src/inbound-provider-imap/fetch-content.ts +84 -0
- package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +28 -2
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +35 -84
- package/packages/bundled-features/src/inbound-provider-imap/index.ts +1 -2
- package/packages/bundled-features/src/inbound-provider-imap/mime-content.ts +85 -0
- package/packages/bundled-features/src/inbound-provider-imap/parse-message.ts +149 -0
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-account.ts +45 -0
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +103 -0
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +49 -0
- package/packages/bundled-features/src/inbound-provider-imap/screens.ts +209 -42
- package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +11 -2
- package/packages/bundled-features/src/inbound-provider-imap/sender-auth-headers.ts +36 -0
- package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +75 -3
- package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
- package/packages/bundled-features/src/jobs/changes.json +18 -0
- package/packages/bundled-features/src/managed-pages/web/i18n.ts +1 -0
- package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
- package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
- package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
- package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
- package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
- package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
- package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
- package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
- package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
- package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
- package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
- package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
- package/packages/bundled-features/src/template-resolver/changes.json +12 -0
- package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
- package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +25 -0
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +218 -117
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +1 -2
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +89 -39
- package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/package.json +1 -0
- package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
- package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
- package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
- package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
- package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
- package/samples/apps/use-all-bundled/src/app/seed.ts +4 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +7 -5
- package/samples/recipes/cross-feature-events/README.md +10 -0
- package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
- package/samples/recipes/dashboard-stats/README.md +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
- package/samples/recipes/env-composition/README.md +2 -2
- package/samples/recipes/env-composition/src/env.ts +2 -2
- package/samples/recipes/inbound-mail-triage/README.md +12 -0
- package/samples/recipes/pdf-audit-export/src/feature.ts +71 -48
- package/packages/bundled-features/src/subscription-dodo/changes.json +0 -14
- package/packages/bundled-features/src/subscription-dodo/client.ts +0 -45
- package/packages/bundled-features/src/subscription-dodo/constants.ts +0 -24
- package/packages/bundled-features/src/subscription-dodo/feature.ts +0 -207
- package/packages/bundled-features/src/subscription-dodo/index.ts +0 -16
- package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +0 -345
- package/packages/bundled-features/src/subscription-dodo/runtime.ts +0 -126
- package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +0 -262
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Provider-agnostic hardening of a fetched message before it is dispatched to
|
|
2
|
+
// ingest-message. The ingest schema rejects oversized or empty fields; one such
|
|
3
|
+
// mail would otherwise block the cursor of its mailbox forever.
|
|
4
|
+
|
|
5
|
+
import type { RawInboundAttachment, RawInboundMessage, SkippedInboundAttachment } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export const INBOUND_MISSING_SENDER = "(no sender)";
|
|
8
|
+
export const INBOUND_BODY_TEXT_MAX_CHARS = 32_000;
|
|
9
|
+
export const INBOUND_BODY_HTML_MAX_CHARS = 5_000_000;
|
|
10
|
+
export const INBOUND_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
|
|
11
|
+
export const INBOUND_MAIL_ATTACHMENTS_MAX_BYTES = 50 * 1024 * 1024;
|
|
12
|
+
// The .eml holds every attachment again, so it shares the per-mail byte budget.
|
|
13
|
+
export const INBOUND_RAW_MIME_MAX_BYTES = INBOUND_MAIL_ATTACHMENTS_MAX_BYTES;
|
|
14
|
+
export const INBOUND_ATTACHMENTS_MAX_COUNT = 50;
|
|
15
|
+
export const INBOUND_SKIPPED_ATTACHMENTS_MAX_COUNT = 200;
|
|
16
|
+
|
|
17
|
+
const FROM_MAX = 2000;
|
|
18
|
+
const ADDRESS_MAX = 2000;
|
|
19
|
+
const ADDRESS_LIST_MAX = 200;
|
|
20
|
+
const REFERENCE_MAX = 500;
|
|
21
|
+
const REFERENCES_MAX = 200;
|
|
22
|
+
const SUBJECT_MAX = 4000;
|
|
23
|
+
const SNIPPET_MAX = 4000;
|
|
24
|
+
const MESSAGE_ID_HEADER_MAX = 500;
|
|
25
|
+
|
|
26
|
+
export type NormalizedInboundMessage = Omit<
|
|
27
|
+
RawInboundMessage,
|
|
28
|
+
"text" | "html" | "attachments" | "skippedAttachments"
|
|
29
|
+
> & {
|
|
30
|
+
readonly bodyText: string;
|
|
31
|
+
readonly bodyTextTruncated: boolean;
|
|
32
|
+
readonly bodyHtml: string | null;
|
|
33
|
+
readonly attachments: readonly RawInboundAttachment[];
|
|
34
|
+
readonly skippedAttachments: readonly SkippedInboundAttachment[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function isHighSurrogate(code: number): boolean {
|
|
38
|
+
return code >= 0xd800 && code <= 0xdbff;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Cuts at UTF-16 units like the zod max, but never leaves half a surrogate pair behind.
|
|
42
|
+
function clip(value: string, max: number): string {
|
|
43
|
+
if (value.length <= max) return value;
|
|
44
|
+
const cut = isHighSurrogate(value.charCodeAt(max - 1)) ? max - 1 : max;
|
|
45
|
+
return value.slice(0, cut);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function limitReferences(references: readonly string[]): readonly string[] {
|
|
49
|
+
const usable = references.filter((r) => r.length > 0 && r.length <= REFERENCE_MAX);
|
|
50
|
+
if (usable.length <= REFERENCES_MAX) return usable;
|
|
51
|
+
// The root keeps the thread anchor, the newest entries keep the reply chain.
|
|
52
|
+
return [...usable.slice(0, 1), ...usable.slice(usable.length - (REFERENCES_MAX - 1))];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type AttachmentSplit = {
|
|
56
|
+
readonly stored: readonly RawInboundAttachment[];
|
|
57
|
+
readonly skipped: readonly SkippedInboundAttachment[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function splitAttachmentsByLimits(attachments: readonly RawInboundAttachment[]): AttachmentSplit {
|
|
61
|
+
const stored: RawInboundAttachment[] = [];
|
|
62
|
+
const skipped: SkippedInboundAttachment[] = [];
|
|
63
|
+
let totalBytes = 0;
|
|
64
|
+
for (const attachment of attachments) {
|
|
65
|
+
const size = attachment.data.byteLength;
|
|
66
|
+
if (size > INBOUND_ATTACHMENT_MAX_BYTES) {
|
|
67
|
+
skipped.push({ mimeType: attachment.mimeType, size, reason: "too_large" });
|
|
68
|
+
} else if (
|
|
69
|
+
totalBytes + size > INBOUND_MAIL_ATTACHMENTS_MAX_BYTES ||
|
|
70
|
+
stored.length >= INBOUND_ATTACHMENTS_MAX_COUNT
|
|
71
|
+
) {
|
|
72
|
+
skipped.push({ mimeType: attachment.mimeType, size, reason: "mail_too_large" });
|
|
73
|
+
} else {
|
|
74
|
+
totalBytes += size;
|
|
75
|
+
stored.push({
|
|
76
|
+
fileName: clip(attachment.fileName, 255),
|
|
77
|
+
mimeType: clip(attachment.mimeType, 255),
|
|
78
|
+
data: attachment.data,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { stored, skipped };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function normalizeRawInboundMessage(msg: RawInboundMessage): NormalizedInboundMessage {
|
|
86
|
+
const { text, html, attachments, skippedAttachments, ...envelope } = msg;
|
|
87
|
+
const from = msg.from.trim();
|
|
88
|
+
const fullText = text ?? "";
|
|
89
|
+
const split = splitAttachmentsByLimits(attachments ?? []);
|
|
90
|
+
const bodyHtml = html ?? null;
|
|
91
|
+
const rawMimeTooLarge =
|
|
92
|
+
msg.rawMime !== null && msg.rawMime.byteLength > INBOUND_RAW_MIME_MAX_BYTES;
|
|
93
|
+
const skippedRawMime: readonly SkippedInboundAttachment[] =
|
|
94
|
+
rawMimeTooLarge && msg.rawMime !== null
|
|
95
|
+
? [{ mimeType: "message/rfc822", size: msg.rawMime.byteLength, reason: "too_large" }]
|
|
96
|
+
: [];
|
|
97
|
+
return {
|
|
98
|
+
...envelope,
|
|
99
|
+
rawMime: rawMimeTooLarge ? null : msg.rawMime,
|
|
100
|
+
messageIdHeader:
|
|
101
|
+
msg.messageIdHeader !== null && msg.messageIdHeader.length <= MESSAGE_ID_HEADER_MAX
|
|
102
|
+
? msg.messageIdHeader
|
|
103
|
+
: null,
|
|
104
|
+
references: limitReferences(msg.references),
|
|
105
|
+
from: from.length === 0 ? INBOUND_MISSING_SENDER : clip(from, FROM_MAX),
|
|
106
|
+
to: msg.to.slice(0, ADDRESS_LIST_MAX).map((a) => clip(a, ADDRESS_MAX)),
|
|
107
|
+
cc: msg.cc.slice(0, ADDRESS_LIST_MAX).map((a) => clip(a, ADDRESS_MAX)),
|
|
108
|
+
subject: clip(msg.subject, SUBJECT_MAX),
|
|
109
|
+
snippet: clip(msg.snippet, SNIPPET_MAX),
|
|
110
|
+
bodyText: clip(fullText, INBOUND_BODY_TEXT_MAX_CHARS),
|
|
111
|
+
bodyTextTruncated: fullText.length > INBOUND_BODY_TEXT_MAX_CHARS,
|
|
112
|
+
bodyHtml: bodyHtml !== null && bodyHtml.length <= INBOUND_BODY_HTML_MAX_CHARS ? bodyHtml : null,
|
|
113
|
+
attachments: split.stored,
|
|
114
|
+
skippedAttachments: [...(skippedAttachments ?? []), ...split.skipped, ...skippedRawMime].slice(
|
|
115
|
+
0,
|
|
116
|
+
INBOUND_SKIPPED_ATTACHMENTS_MAX_COUNT,
|
|
117
|
+
),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -24,13 +24,9 @@ import {
|
|
|
24
24
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
25
25
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
26
26
|
import { InboundMailAuthMethods, inboundCredentialSecretKey } from "./constants.ts";
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
type InboundMailProviderPlugin,
|
|
31
|
-
type MailAccountRecord,
|
|
32
|
-
type OAuthTokenSet,
|
|
33
|
-
} from "./types.ts";
|
|
27
|
+
import type { OAuthTokenSet } from "./inbound-oauth-flow.ts";
|
|
28
|
+
import { InboundAuthError, InboundCredentialNotStoredError } from "./provider-errors.ts";
|
|
29
|
+
import type { InboundMailContext, InboundMailProviderPlugin, MailAccountRecord } from "./types.ts";
|
|
34
30
|
|
|
35
31
|
// 'inoa' as ASCII — disjoint from the other fixed advisory-lock namespaces.
|
|
36
32
|
const OAUTH_REFRESH_LOCK_NAMESPACE = 0x696e6f61;
|
|
@@ -148,7 +144,7 @@ export function createOAuthAccessTokenManager(
|
|
|
148
144
|
const secrets = deps.secrets.withRunner(tx);
|
|
149
145
|
const stored = await secrets.get(account.tenantId, secretKey);
|
|
150
146
|
if (!stored) {
|
|
151
|
-
throw new
|
|
147
|
+
throw new InboundCredentialNotStoredError("no refresh token stored for account");
|
|
152
148
|
}
|
|
153
149
|
const currentRefreshToken = stored.reveal();
|
|
154
150
|
const tokens = await refreshWithTimeout(plugin, account, currentRefreshToken);
|
|
@@ -207,3 +203,25 @@ export function createOAuthAccessTokenManager(
|
|
|
207
203
|
},
|
|
208
204
|
};
|
|
209
205
|
}
|
|
206
|
+
|
|
207
|
+
// Refresh token into the per-account secret slot (slot = accountId); access tokens (~1h) are never
|
|
208
|
+
// persisted. A shared manager writes under the account's refresh lock and primes its cache.
|
|
209
|
+
export async function storeOAuthCallbackCredential(
|
|
210
|
+
manager: OAuthAccessTokenManager | undefined,
|
|
211
|
+
secrets: SecretsContext,
|
|
212
|
+
account: Pick<MailAccountRecord, "id" | "tenantId">,
|
|
213
|
+
tokens: OAuthTokenSet,
|
|
214
|
+
refreshToken: string,
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
if (manager) {
|
|
217
|
+
await manager.storeRefreshToken(account, refreshToken);
|
|
218
|
+
manager.primeFromExchange(account, tokens);
|
|
219
|
+
} else {
|
|
220
|
+
await secrets.set(
|
|
221
|
+
account.tenantId,
|
|
222
|
+
inboundCredentialSecretKey(account.id),
|
|
223
|
+
refreshToken,
|
|
224
|
+
inboundRefreshTokenSecretOptions(account.id),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Who may attach a stored file to an outgoing mail: exactly who may download it. Attachments of
|
|
2
|
+
// imported mails are bound to the mail, so the file guard answers with the mail's visibility.
|
|
3
|
+
|
|
4
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
5
|
+
import { isUuid } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import {
|
|
7
|
+
type CanReadFileRefContext,
|
|
8
|
+
type FileRef,
|
|
9
|
+
fileRefsTable,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
11
|
+
import type { ResolveOutboundAttachmentsContext } from "./outbound-attachments.ts";
|
|
12
|
+
|
|
13
|
+
export type OutboundAttachmentAccessContext = ResolveOutboundAttachmentsContext &
|
|
14
|
+
CanReadFileRefContext;
|
|
15
|
+
|
|
16
|
+
/** The file of the sender's tenant, or null: another tenant's and a deleted file read like a missing one. */
|
|
17
|
+
export async function loadAttachableFileRef(
|
|
18
|
+
ctx: OutboundAttachmentAccessContext,
|
|
19
|
+
fileRefId: string,
|
|
20
|
+
): Promise<FileRef | null> {
|
|
21
|
+
if (!isUuid(fileRefId)) return null;
|
|
22
|
+
const [fileRef] = await selectMany<FileRef>(ctx.db, fileRefsTable, {
|
|
23
|
+
id: fileRefId,
|
|
24
|
+
tenantId: ctx.user.tenantId,
|
|
25
|
+
isDeleted: false,
|
|
26
|
+
});
|
|
27
|
+
return fileRef ?? null;
|
|
28
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Turns the file references of an outgoing mail into bytes. Only the sender's own tenant is
|
|
2
|
+
// searched, so another tenant's file reads exactly like a missing one; a file the caller may not
|
|
3
|
+
// read counts as missing too (outbound-attachment-access.ts).
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
collectPiiSubjectFields,
|
|
7
|
+
configuredPiiSubjectKms,
|
|
8
|
+
decryptPiiFieldValues,
|
|
9
|
+
isPiiCiphertext,
|
|
10
|
+
PII_ERASED_SENTINEL,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
12
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import { canCallerReadFileRef, fileRefEntity } from "@cosmicdrift/kumiko-framework/files";
|
|
14
|
+
import {
|
|
15
|
+
FileRefTooLargeError,
|
|
16
|
+
fileContextForTenant,
|
|
17
|
+
readFileRefContent,
|
|
18
|
+
} from "../file-foundation/index.ts";
|
|
19
|
+
import { loadAttachableFileRef } from "./outbound-attachment-access.ts";
|
|
20
|
+
import { OUTBOUND_ATTACHMENTS_MAX_BYTES, type OutboundAttachment } from "./outbound-mail.ts";
|
|
21
|
+
|
|
22
|
+
export type ResolveOutboundAttachmentsContext = Pick<
|
|
23
|
+
HandlerContext,
|
|
24
|
+
"db" | "user" | "services" | "obs" | "registry"
|
|
25
|
+
> & { readonly dispatch: Pick<HandlerContext["dispatch"], "query"> };
|
|
26
|
+
|
|
27
|
+
export type ResolvedOutboundAttachments =
|
|
28
|
+
| {
|
|
29
|
+
readonly isSuccess: true;
|
|
30
|
+
readonly attachments: readonly OutboundAttachment[];
|
|
31
|
+
readonly fileIds: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
| { readonly isSuccess: false; readonly failure: "attachmentInvalid" | "attachmentsTooLarge" };
|
|
34
|
+
|
|
35
|
+
const FALLBACK_ATTACHMENT_NAME = "attachment";
|
|
36
|
+
const PII_SUBJECT_FIELDS = collectPiiSubjectFields(fileRefEntity);
|
|
37
|
+
|
|
38
|
+
// The stored name is personal data and may be ciphertext; a name that cannot be read must not
|
|
39
|
+
// stop the mail, and ciphertext or the erased marker never goes into a header.
|
|
40
|
+
export async function readableFileName(file: {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly fileName: string;
|
|
43
|
+
}): Promise<string> {
|
|
44
|
+
const storedName = file.fileName;
|
|
45
|
+
if (!isPiiCiphertext(storedName)) return storedName;
|
|
46
|
+
const kms = configuredPiiSubjectKms();
|
|
47
|
+
if (!kms) return FALLBACK_ATTACHMENT_NAME;
|
|
48
|
+
try {
|
|
49
|
+
const decrypted = await decryptPiiFieldValues(
|
|
50
|
+
{ fileName: storedName },
|
|
51
|
+
PII_SUBJECT_FIELDS,
|
|
52
|
+
kms,
|
|
53
|
+
{ requestId: `outbound-attachment:${file.id}` },
|
|
54
|
+
);
|
|
55
|
+
const name = decrypted["fileName"];
|
|
56
|
+
if (typeof name !== "string" || isPiiCiphertext(name) || name === PII_ERASED_SENTINEL) {
|
|
57
|
+
return FALLBACK_ATTACHMENT_NAME;
|
|
58
|
+
}
|
|
59
|
+
return name;
|
|
60
|
+
} catch {
|
|
61
|
+
// skip: broken crypto metadata must not block the mail; the attachment keeps a neutral name.
|
|
62
|
+
return FALLBACK_ATTACHMENT_NAME;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function resolveOutboundAttachments(
|
|
67
|
+
ctx: ResolveOutboundAttachmentsContext,
|
|
68
|
+
fileIds: readonly string[],
|
|
69
|
+
): Promise<ResolvedOutboundAttachments> {
|
|
70
|
+
const uniqueIds = [...new Set(fileIds)];
|
|
71
|
+
const attachments: OutboundAttachment[] = [];
|
|
72
|
+
let remainingBytes = OUTBOUND_ATTACHMENTS_MAX_BYTES;
|
|
73
|
+
const tenantId = ctx.user.tenantId;
|
|
74
|
+
for (const fileRefId of uniqueIds) {
|
|
75
|
+
// A file the caller may not read answers like an unknown id, so ids cannot be probed.
|
|
76
|
+
const fileRef = await loadAttachableFileRef(ctx, fileRefId);
|
|
77
|
+
if (fileRef === null || !(await canCallerReadFileRef(ctx, fileRef))) {
|
|
78
|
+
return { isSuccess: false, failure: "attachmentInvalid" };
|
|
79
|
+
}
|
|
80
|
+
let found: Awaited<ReturnType<typeof readFileRefContent>>;
|
|
81
|
+
try {
|
|
82
|
+
found = await readFileRefContent({
|
|
83
|
+
db: ctx.db,
|
|
84
|
+
filesForTenant: (forTenantId) => fileContextForTenant(ctx, tenantId, forTenantId),
|
|
85
|
+
fileRefId,
|
|
86
|
+
allowedTenantIds: [tenantId],
|
|
87
|
+
maxBytes: remainingBytes,
|
|
88
|
+
});
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err instanceof FileRefTooLargeError) {
|
|
91
|
+
return { isSuccess: false, failure: "attachmentsTooLarge" };
|
|
92
|
+
}
|
|
93
|
+
ctx.obs.log.warn("outbound attachment could not be read", {
|
|
94
|
+
fileRefId,
|
|
95
|
+
errorName: err instanceof Error ? err.name : typeof err,
|
|
96
|
+
});
|
|
97
|
+
return { isSuccess: false, failure: "attachmentInvalid" };
|
|
98
|
+
}
|
|
99
|
+
if (found === null) return { isSuccess: false, failure: "attachmentInvalid" };
|
|
100
|
+
if (found.content.byteLength > remainingBytes) {
|
|
101
|
+
return { isSuccess: false, failure: "attachmentsTooLarge" };
|
|
102
|
+
}
|
|
103
|
+
remainingBytes -= found.content.byteLength;
|
|
104
|
+
attachments.push({
|
|
105
|
+
fileName: await readableFileName({ id: fileRefId, fileName: found.fileName }),
|
|
106
|
+
mimeType: found.contentType,
|
|
107
|
+
data: found.content,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return { isSuccess: true, attachments, fileIds: uniqueIds };
|
|
111
|
+
}
|
|
@@ -5,6 +5,10 @@ import * as z from "zod";
|
|
|
5
5
|
|
|
6
6
|
const MAX_RECIPIENTS = 50;
|
|
7
7
|
const MAX_REFERENCES = 100;
|
|
8
|
+
const MAX_ATTACHMENTS = 10;
|
|
9
|
+
|
|
10
|
+
/** Raw bytes, not the encoded size: base64 grows them by a third, which stays under the usual 25 MB SMTP limit. */
|
|
11
|
+
export const OUTBOUND_ATTACHMENTS_MAX_BYTES = 18 * 1024 * 1024;
|
|
8
12
|
|
|
9
13
|
// Message ids travel without angle brackets, like RawInboundMessage.references. Anything that
|
|
10
14
|
// could close the bracket or start a new header is refused instead of being encoded.
|
|
@@ -26,17 +30,31 @@ export const outboundMailMessageSchema = z
|
|
|
26
30
|
html: z.string().optional(),
|
|
27
31
|
inReplyTo: messageIdSchema.optional(),
|
|
28
32
|
references: z.array(messageIdSchema).max(MAX_REFERENCES).optional(),
|
|
33
|
+
attachmentFileIds: z.array(z.uuid()).max(MAX_ATTACHMENTS).optional(),
|
|
29
34
|
})
|
|
30
35
|
.refine((message) => message.text !== undefined || message.html !== undefined, {
|
|
31
36
|
message: "needs text or html",
|
|
32
37
|
});
|
|
33
38
|
export type OutboundMailMessage = z.infer<typeof outboundMailMessageSchema>;
|
|
34
39
|
|
|
35
|
-
|
|
40
|
+
/** An attachment whose file reference was resolved to bytes before the provider sends. */
|
|
41
|
+
export type OutboundAttachment = {
|
|
42
|
+
readonly fileName: string;
|
|
43
|
+
readonly mimeType: string;
|
|
44
|
+
readonly data: Uint8Array;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** What a provider plugin reports about a mail it sent. */
|
|
48
|
+
export type ProviderSentMail = {
|
|
36
49
|
/** Message-ID header of the sent mail, without angle brackets. */
|
|
37
50
|
readonly messageIdHeader: string;
|
|
38
51
|
};
|
|
39
52
|
|
|
53
|
+
export type OutboundMailSent = ProviderSentMail & {
|
|
54
|
+
/** The file references that went out as attachments, for the caller's own record of the mail. */
|
|
55
|
+
readonly attachmentFileIds: readonly string[];
|
|
56
|
+
};
|
|
57
|
+
|
|
40
58
|
// Same `kind` discriminant as the inbound error classes (see types.ts).
|
|
41
59
|
|
|
42
60
|
/** The mailbox has no outgoing server stored, for example a credential document from before
|
|
@@ -9,9 +9,10 @@ import { type MailAccountRow, toMailAccountRecord } from "./account-record.ts";
|
|
|
9
9
|
import { InboundMailAccountStatuses } from "./constants.ts";
|
|
10
10
|
import { INBOUND_PROBE_KEYS } from "./probe-keys.ts";
|
|
11
11
|
import { mailAccountsProjectionTable } from "./projection.ts";
|
|
12
|
+
import { isInboundAuthError } from "./provider-errors.ts";
|
|
12
13
|
import { resolveInboundProviderForAccount } from "./provider-factory.ts";
|
|
13
14
|
import { MAIL_ACCOUNT_LIST_SCREEN_QN } from "./screens.ts";
|
|
14
|
-
import {
|
|
15
|
+
import type { InboundMailContext } from "./types.ts";
|
|
15
16
|
|
|
16
17
|
const SHORT_ID_LENGTH = 8;
|
|
17
18
|
|
|
@@ -23,11 +23,13 @@ import {
|
|
|
23
23
|
type EntityTableMeta,
|
|
24
24
|
insertOnConflictDoNothing,
|
|
25
25
|
requireEntityTableMeta,
|
|
26
|
+
updateMany,
|
|
26
27
|
upsertOnConflict,
|
|
27
28
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
28
29
|
import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
|
|
29
30
|
import { inboundMessageEntity, mailAccountEntity, mailThreadEntity } from "./entities.ts";
|
|
30
31
|
import type {
|
|
32
|
+
InboundMessageContentFetchedEventPayload,
|
|
31
33
|
InboundMessageEventPayload,
|
|
32
34
|
MailAccountEventPayload,
|
|
33
35
|
MailThreadEventPayload,
|
|
@@ -74,6 +76,8 @@ const applyMailAccountUpsert = defineApply<MailAccountEventPayload>(async (event
|
|
|
74
76
|
address: p.address,
|
|
75
77
|
status: p.status,
|
|
76
78
|
watchState: p.watchState,
|
|
79
|
+
lastError: p.lastError,
|
|
80
|
+
trustedAuthservId: p.trustedAuthservId,
|
|
77
81
|
};
|
|
78
82
|
await upsertOnConflict(
|
|
79
83
|
tx,
|
|
@@ -101,8 +105,8 @@ export const applyMailAccountDisconnected = applyMailAccountUpsert;
|
|
|
101
105
|
|
|
102
106
|
// =============================================================================
|
|
103
107
|
// inbound-message — exactly one received event per stream (deterministic
|
|
104
|
-
// aggregateId). INSERT-ignore makes replays no-ops on the primary key;
|
|
105
|
-
//
|
|
108
|
+
// aggregateId). INSERT-ignore makes replays no-ops on the primary key; the
|
|
109
|
+
// only later change is the content-fetched event for snippet-only imports.
|
|
106
110
|
// =============================================================================
|
|
107
111
|
|
|
108
112
|
/** inbound-message-received → INSERT ... ON CONFLICT DO NOTHING. */
|
|
@@ -124,10 +128,37 @@ export const applyInboundMessageReceived = defineApply<InboundMessageEventPayloa
|
|
|
124
128
|
receivedAt: p.receivedAtIso,
|
|
125
129
|
bodyRef: p.bodyRef,
|
|
126
130
|
scope: p.scope,
|
|
131
|
+
bodyText: p.bodyText,
|
|
132
|
+
bodyTextTruncated: p.bodyTextTruncated,
|
|
133
|
+
attachmentFileIds: p.attachmentFileIds,
|
|
134
|
+
skippedAttachments: JSON.stringify(p.skippedAttachments),
|
|
135
|
+
senderAuthentication: JSON.stringify(p.senderAuthentication),
|
|
136
|
+
contentState: p.contentState,
|
|
137
|
+
contentUnavailableReason: p.contentUnavailableReason,
|
|
127
138
|
});
|
|
128
139
|
},
|
|
129
140
|
);
|
|
130
141
|
|
|
142
|
+
/** inbound-message-content-fetched → fills the content columns of the already projected row. */
|
|
143
|
+
export const applyInboundMessageContentFetched =
|
|
144
|
+
defineApply<InboundMessageContentFetchedEventPayload>(async (event, tx) => {
|
|
145
|
+
const p = event.payload;
|
|
146
|
+
await updateMany(
|
|
147
|
+
tx,
|
|
148
|
+
inboundMessagesProjectionMeta,
|
|
149
|
+
{
|
|
150
|
+
bodyRef: p.bodyRef,
|
|
151
|
+
bodyText: p.bodyText,
|
|
152
|
+
bodyTextTruncated: p.bodyTextTruncated,
|
|
153
|
+
attachmentFileIds: p.attachmentFileIds,
|
|
154
|
+
skippedAttachments: JSON.stringify(p.skippedAttachments),
|
|
155
|
+
contentState: p.contentState,
|
|
156
|
+
contentUnavailableReason: p.contentUnavailableReason,
|
|
157
|
+
},
|
|
158
|
+
{ id: event.aggregateId },
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
131
162
|
// =============================================================================
|
|
132
163
|
// mail-thread — rollup per (tenantId, threadKey). The write handler computes
|
|
133
164
|
// messageCount/lastMessageAt and appends the updated event with the NEW state.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Typed errors handled uniformly by the Foundation during polling:
|
|
3
|
+
// InboundAuthError → status=auth_error, no retry
|
|
4
|
+
// InboundRateLimitError → abort poll, wait for the next cron tick
|
|
5
|
+
// InboundCursorInvalidError → reset cursor + full resync in the backfill
|
|
6
|
+
// window; deduplication handles duplicates
|
|
7
|
+
// InboundTransientError → job retry (retries: 3, exponential)
|
|
8
|
+
// =============================================================================
|
|
9
|
+
//
|
|
10
|
+
// Use a `kind` discriminant instead of instanceof: plugins and the Foundation
|
|
11
|
+
// may run in separate bundles (dual-package-hazard), and instanceof is
|
|
12
|
+
// unreliable across realm or package-copy boundaries.
|
|
13
|
+
|
|
14
|
+
export class InboundAuthError extends Error {
|
|
15
|
+
readonly kind = "inbound-auth-error" as const;
|
|
16
|
+
constructor(message: string) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "InboundAuthError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The credential secret is not there (yet). Still an auth error for a regular poll; a sync woken
|
|
23
|
+
* right after connect treats it as "not ready" because the secret is written after the account. */
|
|
24
|
+
export class InboundCredentialNotStoredError extends InboundAuthError {
|
|
25
|
+
readonly credentialNotStored = true as const;
|
|
26
|
+
constructor(message: string) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "InboundCredentialNotStoredError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class InboundRateLimitError extends Error {
|
|
33
|
+
readonly kind = "inbound-rate-limit-error" as const;
|
|
34
|
+
readonly retryAfterMs: number;
|
|
35
|
+
constructor(message: string, retryAfterMs: number) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "InboundRateLimitError";
|
|
38
|
+
this.retryAfterMs = retryAfterMs;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class InboundCursorInvalidError extends Error {
|
|
43
|
+
readonly kind = "inbound-cursor-invalid-error" as const;
|
|
44
|
+
constructor(message: string) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "InboundCursorInvalidError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class InboundTransientError extends Error {
|
|
51
|
+
readonly kind = "inbound-transient-error" as const;
|
|
52
|
+
constructor(message: string) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "InboundTransientError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isInboundAuthError(e: unknown): e is InboundAuthError {
|
|
59
|
+
return e instanceof Error && "kind" in e && e.kind === "inbound-auth-error";
|
|
60
|
+
}
|
|
61
|
+
export function isInboundCredentialNotStoredError(
|
|
62
|
+
e: unknown,
|
|
63
|
+
): e is InboundCredentialNotStoredError {
|
|
64
|
+
return isInboundAuthError(e) && "credentialNotStored" in e && e.credentialNotStored === true;
|
|
65
|
+
}
|
|
66
|
+
export function isInboundRateLimitError(e: unknown): e is InboundRateLimitError {
|
|
67
|
+
return e instanceof Error && "kind" in e && e.kind === "inbound-rate-limit-error";
|
|
68
|
+
}
|
|
69
|
+
export function isInboundCursorInvalidError(e: unknown): e is InboundCursorInvalidError {
|
|
70
|
+
return e instanceof Error && "kind" in e && e.kind === "inbound-cursor-invalid-error";
|
|
71
|
+
}
|
|
72
|
+
export function isInboundTransientError(e: unknown): e is InboundTransientError {
|
|
73
|
+
return e instanceof Error && "kind" in e && e.kind === "inbound-transient-error";
|
|
74
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
createEntity,
|
|
5
|
+
createTextField,
|
|
6
|
+
createTimestampField,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
|
|
9
|
+
export const QUARANTINE_REASON_CODES = ["unparseable", "invalid_message", "ingest_failed"] as const;
|
|
10
|
+
export type QuarantineReasonCode = (typeof QUARANTINE_REASON_CODES)[number];
|
|
11
|
+
|
|
12
|
+
/** A provider message the ingest cannot take. No subject or sender, so nothing here is personal data;
|
|
13
|
+
* the UID is enough for the operator to find the mail in the mailbox. */
|
|
14
|
+
export const quarantineEntity = createEntity({
|
|
15
|
+
table: "store_mail_quarantine",
|
|
16
|
+
softDelete: false,
|
|
17
|
+
indexes: [{ unique: true, columns: ["tenantId", "accountId", "providerMessageId"] }],
|
|
18
|
+
fields: {
|
|
19
|
+
accountId: createTextField({
|
|
20
|
+
required: true,
|
|
21
|
+
maxLength: 36,
|
|
22
|
+
access: { write: access.privileged },
|
|
23
|
+
personal: false,
|
|
24
|
+
reason: "technical_reference",
|
|
25
|
+
}),
|
|
26
|
+
providerMessageId: createTextField({
|
|
27
|
+
required: true,
|
|
28
|
+
maxLength: 500,
|
|
29
|
+
access: { write: access.privileged },
|
|
30
|
+
personal: false,
|
|
31
|
+
reason: "technical_reference",
|
|
32
|
+
}),
|
|
33
|
+
reasonCode: createTextField({
|
|
34
|
+
required: true,
|
|
35
|
+
maxLength: 30,
|
|
36
|
+
access: { write: access.privileged },
|
|
37
|
+
personal: false,
|
|
38
|
+
reason: "technical_reference",
|
|
39
|
+
}),
|
|
40
|
+
quarantinedAt: createTimestampField({
|
|
41
|
+
required: true,
|
|
42
|
+
access: { write: access.privileged },
|
|
43
|
+
}),
|
|
44
|
+
acknowledgedAt: createTimestampField({
|
|
45
|
+
access: { write: access.privileged },
|
|
46
|
+
}),
|
|
47
|
+
// Set by retry-quarantined-message; the next successful sync of the mailbox reloads the mail and clears the row.
|
|
48
|
+
retryRequestedAt: createTimestampField({
|
|
49
|
+
access: { write: access.privileged },
|
|
50
|
+
}),
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export const quarantineTable = deriveEntityTableMeta("mail-quarantine", quarantineEntity, {
|
|
55
|
+
source: "unmanaged",
|
|
56
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// A quarantined mail stays visible: its headers (and the original, when file storage is there) are
|
|
2
|
+
// imported as a message without content, marked "unavailable / quarantined". The import is best
|
|
3
|
+
// effort: the quarantine row already moves the cursor on, and a retry can still load the content.
|
|
4
|
+
|
|
5
|
+
import { InboundMailFoundationHandlers } from "./constants.ts";
|
|
6
|
+
import { ingestPayloadOf } from "./ingest-payload.ts";
|
|
7
|
+
import { INBOUND_MISSING_SENDER, normalizeRawInboundMessage } from "./normalize-inbound-message.ts";
|
|
8
|
+
import type { DispatchWrite } from "./supervisor-ingest.ts";
|
|
9
|
+
import type { MailAccountRecord, RawInboundMessage, RejectedInboundMessage } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
const EPOCH_ISO = "1970-01-01T00:00:00Z";
|
|
12
|
+
|
|
13
|
+
export function headerOnlyOf(msg: RawInboundMessage): RawInboundMessage {
|
|
14
|
+
const { text: _text, html: _html, attachments: _a, skippedAttachments: _s, ...envelope } = msg;
|
|
15
|
+
return { ...envelope, text: "", html: null, attachments: [], skippedAttachments: [] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Null when the provider reported no headers for the mail: nothing meaningful to show. */
|
|
19
|
+
export function headerOnlyOfRejected(entry: RejectedInboundMessage): RawInboundMessage | null {
|
|
20
|
+
const { headers } = entry;
|
|
21
|
+
if (!headers) return null;
|
|
22
|
+
return {
|
|
23
|
+
providerMessageId: entry.providerMessageId,
|
|
24
|
+
messageIdHeader: headers.messageIdHeader,
|
|
25
|
+
providerThreadId: null,
|
|
26
|
+
references: [],
|
|
27
|
+
from: headers.from === "" ? INBOUND_MISSING_SENDER : headers.from,
|
|
28
|
+
to: headers.to,
|
|
29
|
+
cc: headers.cc,
|
|
30
|
+
subject: headers.subject,
|
|
31
|
+
snippet: "",
|
|
32
|
+
receivedAtIso: headers.receivedAtIso === "" ? EPOCH_ISO : headers.receivedAtIso,
|
|
33
|
+
rawMime: entry.rawMime ?? null,
|
|
34
|
+
scope: "inbox",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function importQuarantinedHeaders(
|
|
39
|
+
deps: { readonly dispatchWrite: DispatchWrite; readonly log: (line: string) => void },
|
|
40
|
+
account: MailAccountRecord,
|
|
41
|
+
headerOnly: RawInboundMessage | null,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
// skip: no header data to import for this mail
|
|
44
|
+
if (!headerOnly) return;
|
|
45
|
+
try {
|
|
46
|
+
const result = await deps.dispatchWrite({
|
|
47
|
+
handlerQn: InboundMailFoundationHandlers.ingestMessage,
|
|
48
|
+
tenantId: account.tenantId,
|
|
49
|
+
payload: ingestPayloadOf(account, normalizeRawInboundMessage(headerOnly), {
|
|
50
|
+
providerCursor: "quarantine",
|
|
51
|
+
bodyRef: "",
|
|
52
|
+
contentUnavailableReason: "quarantined",
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
if (!result.isSuccess) {
|
|
56
|
+
deps.log(
|
|
57
|
+
`inbound-mail: account ${account.id} header import of a quarantined mail was rejected`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
deps.log(`inbound-mail: account ${account.id} header import of a quarantined mail failed`);
|
|
62
|
+
}
|
|
63
|
+
}
|