@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
|
@@ -25,33 +25,43 @@
|
|
|
25
25
|
|
|
26
26
|
import { mailAllowedPrivateHostsEnvSchema } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
|
|
27
27
|
import {
|
|
28
|
+
assertRolesMatchFoundation,
|
|
28
29
|
INBOUND_MAIL_PROVIDER_EXTENSION,
|
|
29
30
|
type InboundFetchResult,
|
|
30
31
|
type InboundMailContext,
|
|
31
32
|
type InboundMailProviderPlugin,
|
|
33
|
+
type InboundMailRoleOptions,
|
|
32
34
|
type MailAccountRecord,
|
|
33
35
|
type RawInboundMessage,
|
|
36
|
+
type RejectedInboundMessage,
|
|
37
|
+
resolveInboundMailRoles,
|
|
38
|
+
roleDedupeOptions,
|
|
34
39
|
} from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
35
40
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
36
41
|
import { instantToLegacyDate } from "@cosmicdrift/kumiko-framework/time";
|
|
37
42
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
38
43
|
import type { ImapFlow, MailboxObject } from "imapflow";
|
|
39
|
-
import {
|
|
44
|
+
import { createConnectMailboxHandler } from "./connect-mailbox.ts";
|
|
45
|
+
import { createConnectMyMailboxHandler } from "./connect-my-mailbox.ts";
|
|
40
46
|
import { FEATURE_NAME, IMAP_PROVIDER_KEY } from "./constants.ts";
|
|
41
47
|
import { readCredentialDocument } from "./credential-document.ts";
|
|
48
|
+
import { downloadBatch } from "./download-batch.ts";
|
|
49
|
+
import { fetchMessageContent } from "./fetch-content.ts";
|
|
42
50
|
import { INBOUND_PROVIDER_IMAP_I18N } from "./i18n.ts";
|
|
43
51
|
import {
|
|
44
52
|
assertUidValidity,
|
|
45
|
-
|
|
53
|
+
closeOnAbort,
|
|
46
54
|
connectOnce,
|
|
47
55
|
createImapClient,
|
|
48
56
|
IMAP_MAILBOX,
|
|
49
57
|
imapMailHostGuard,
|
|
50
58
|
mapImapError,
|
|
51
59
|
parseImapCursor,
|
|
52
|
-
toRawInboundMessage,
|
|
53
60
|
} from "./imap-client.ts";
|
|
54
|
-
import {
|
|
61
|
+
import { buildProviderMessageId, coerceDate, parseOrReject } from "./parse-message.ts";
|
|
62
|
+
import { createReconnectMailboxHandler } from "./reconnect-mailbox.ts";
|
|
63
|
+
import { createReconnectPrefillQuery } from "./reconnect-prefill.query.ts";
|
|
64
|
+
import { createImapScreens } from "./screens.ts";
|
|
55
65
|
import { sendFromImapAccount } from "./send-mail.ts";
|
|
56
66
|
|
|
57
67
|
export { IMAP_PROVIDER_KEY } from "./constants.ts";
|
|
@@ -62,14 +72,31 @@ export { setImapMailHostLookup } from "./imap-client.ts";
|
|
|
62
72
|
// fetch — inkrementeller UID-Sync
|
|
63
73
|
// =============================================================================
|
|
64
74
|
|
|
75
|
+
async function unknownUidsOf(
|
|
76
|
+
uids: readonly number[],
|
|
77
|
+
uidValidity: string,
|
|
78
|
+
filterUnknownMessageIds: (ids: readonly string[]) => Promise<ReadonlySet<string>>,
|
|
79
|
+
): Promise<number[]> {
|
|
80
|
+
const unknownIds = await filterUnknownMessageIds(
|
|
81
|
+
uids.map((uid) => buildProviderMessageId(uidValidity, uid)),
|
|
82
|
+
);
|
|
83
|
+
return uids.filter((uid) => unknownIds.has(buildProviderMessageId(uidValidity, uid)));
|
|
84
|
+
}
|
|
85
|
+
|
|
65
86
|
async function fetchMessages(
|
|
66
87
|
ctx: InboundMailContext,
|
|
67
88
|
account: MailAccountRecord,
|
|
68
89
|
cursor: Parameters<InboundMailProviderPlugin["fetch"]>[2],
|
|
69
|
-
opts: {
|
|
90
|
+
opts: {
|
|
91
|
+
readonly backfillWindowDays: number;
|
|
92
|
+
readonly maxMessages: number;
|
|
93
|
+
readonly filterUnknownMessageIds?: (ids: readonly string[]) => Promise<ReadonlySet<string>>;
|
|
94
|
+
readonly signal?: AbortSignal;
|
|
95
|
+
},
|
|
70
96
|
): Promise<InboundFetchResult> {
|
|
71
97
|
const doc = await readCredentialDocument(ctx, account);
|
|
72
98
|
const client = await createImapClient(doc, imapMailHostGuard());
|
|
99
|
+
const stopWatchingAbort = closeOnAbort(client, opts.signal);
|
|
73
100
|
let lock: Awaited<ReturnType<typeof client.getMailboxLock>> | undefined;
|
|
74
101
|
try {
|
|
75
102
|
await client.connect();
|
|
@@ -94,38 +121,31 @@ async function fetchMessages(
|
|
|
94
121
|
}
|
|
95
122
|
uids.sort((a, b) => a - b);
|
|
96
123
|
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
source: msg.source,
|
|
112
|
-
uid: msg.uid,
|
|
113
|
-
uidValidity: serverUidValidity,
|
|
114
|
-
internalDate: coerceDate(msg.internalDate ?? undefined),
|
|
115
|
-
}),
|
|
116
|
-
);
|
|
117
|
-
if (msg.uid > maxUid) maxUid = msg.uid;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
124
|
+
// Known mails (ingested or quarantined) are not downloaded again; the cursor still moves past them.
|
|
125
|
+
const unknown = opts.filterUnknownMessageIds
|
|
126
|
+
? await unknownUidsOf(uids, serverUidValidity, opts.filterUnknownMessageIds)
|
|
127
|
+
: uids;
|
|
128
|
+
const batch = unknown.slice(0, opts.maxMessages);
|
|
129
|
+
const hasMore = unknown.length > batch.length;
|
|
130
|
+
const { messages, rejected } = await downloadBatch(
|
|
131
|
+
client,
|
|
132
|
+
batch,
|
|
133
|
+
serverUidValidity,
|
|
134
|
+
account.trustedAuthservId ?? null,
|
|
135
|
+
);
|
|
136
|
+
// From the UIDs, not from what was downloaded: a known or skipped mail must not hold the cursor back.
|
|
137
|
+
const lastUid = Math.max(parsedCursor?.lastUid ?? 0, ...(hasMore ? batch : uids));
|
|
120
138
|
|
|
121
139
|
return {
|
|
122
140
|
messages,
|
|
123
|
-
|
|
141
|
+
...(rejected.length > 0 && { rejected }),
|
|
142
|
+
nextCursor: { uidValidity: serverUidValidity, lastUid },
|
|
124
143
|
hasMore,
|
|
125
144
|
};
|
|
126
145
|
} catch (err) {
|
|
127
146
|
throw err instanceof Error && "kind" in err ? err : mapImapError(err, doc.host);
|
|
128
147
|
} finally {
|
|
148
|
+
stopWatchingAbort();
|
|
129
149
|
lock?.release();
|
|
130
150
|
await client.logout().catch(() => {});
|
|
131
151
|
}
|
|
@@ -140,7 +160,10 @@ async function watchMailbox(
|
|
|
140
160
|
ctx: InboundMailContext,
|
|
141
161
|
account: MailAccountRecord,
|
|
142
162
|
handlers: {
|
|
143
|
-
readonly onMessages: (
|
|
163
|
+
readonly onMessages: (
|
|
164
|
+
msgs: readonly RawInboundMessage[],
|
|
165
|
+
rejected?: readonly RejectedInboundMessage[],
|
|
166
|
+
) => Promise<void>;
|
|
144
167
|
readonly onError: (err: unknown) => void;
|
|
145
168
|
},
|
|
146
169
|
): Promise<() => Promise<void>> {
|
|
@@ -158,6 +181,7 @@ async function watchMailbox(
|
|
|
158
181
|
|
|
159
182
|
const drainNew = async () => {
|
|
160
183
|
const messages: RawInboundMessage[] = [];
|
|
184
|
+
const rejected: RejectedInboundMessage[] = [];
|
|
161
185
|
let maxSeen = nextUid - 1;
|
|
162
186
|
for await (const msg of client.fetch(
|
|
163
187
|
`${nextUid}:*`,
|
|
@@ -166,18 +190,19 @@ async function watchMailbox(
|
|
|
166
190
|
)) {
|
|
167
191
|
// `N:*` gotcha (see fetch): filter out the previous last message.
|
|
168
192
|
if (msg.uid < nextUid || !msg.source) continue;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
);
|
|
193
|
+
const parsed = await parseOrReject({
|
|
194
|
+
source: msg.source,
|
|
195
|
+
uid: msg.uid,
|
|
196
|
+
uidValidity,
|
|
197
|
+
internalDate: coerceDate(msg.internalDate ?? undefined),
|
|
198
|
+
trustedAuthservId: account.trustedAuthservId ?? null,
|
|
199
|
+
});
|
|
200
|
+
if ("message" in parsed) messages.push(parsed.message);
|
|
201
|
+
else rejected.push(parsed.rejected);
|
|
177
202
|
if (msg.uid > maxSeen) maxSeen = msg.uid;
|
|
178
203
|
}
|
|
179
204
|
nextUid = maxSeen + 1;
|
|
180
|
-
if (messages.length > 0) await handlers.onMessages(messages);
|
|
205
|
+
if (messages.length > 0 || rejected.length > 0) await handlers.onMessages(messages, rejected);
|
|
181
206
|
};
|
|
182
207
|
|
|
183
208
|
client.on("exists", () => {
|
|
@@ -236,6 +261,7 @@ export const imapInboundMailPlugin: InboundMailProviderPlugin = {
|
|
|
236
261
|
verify: async (ctx, account) => connectOnce(await readCredentialDocument(ctx, account), false),
|
|
237
262
|
probe: async (ctx, account) => connectOnce(await readCredentialDocument(ctx, account), true),
|
|
238
263
|
fetch: fetchMessages,
|
|
264
|
+
fetchContent: fetchMessageContent,
|
|
239
265
|
watch: watchMailbox,
|
|
240
266
|
send: sendFromImapAccount,
|
|
241
267
|
// No oauth block: password authentication runs through the credential
|
|
@@ -243,12 +269,13 @@ export const imapInboundMailPlugin: InboundMailProviderPlugin = {
|
|
|
243
269
|
// the OAuth providers (M365/Gmail).
|
|
244
270
|
};
|
|
245
271
|
|
|
246
|
-
export function createInboundProviderImapFeature() {
|
|
272
|
+
export function createInboundProviderImapFeature(options: InboundMailRoleOptions = {}) {
|
|
273
|
+
const roles = resolveInboundMailRoles(options);
|
|
247
274
|
return defineFeature(
|
|
248
275
|
FEATURE_NAME,
|
|
249
276
|
(r) => {
|
|
250
277
|
r.describe(
|
|
251
|
-
'Registers the `"imap"` provider for `inbound-mail-foundation` using imapflow — password/app-password auth covers classic IMAP hosts without any OAuth (plus an XOAUTH2 fallback consuming an access token from the secret slot). Incremental sync via a UIDVALIDITY:lastUid cursor (UIDVALIDITY change triggers a cursor-invalid full resync), initial backfill bounded by the foundation backfill window, and live push via IMAP IDLE for the watch supervisor. Tenant admins connect a mailbox through the "Connect mailbox" form on the mail accounts list (`connect-mailbox`
|
|
278
|
+
'Registers the `"imap"` provider for `inbound-mail-foundation` using imapflow — password/app-password auth covers classic IMAP hosts without any OAuth (plus an XOAUTH2 fallback consuming an access token from the secret slot). Incremental sync via a UIDVALIDITY:lastUid cursor (UIDVALIDITY change triggers a cursor-invalid full resync), initial backfill bounded by the foundation backfill window, and live push via IMAP IDLE for the watch supervisor. Tenant admins connect a shared mailbox through the "Connect mailbox" form on the mail accounts list and members a personal one through the same form on "My mail accounts" (`connect-mailbox` and `connect-my-mailbox` check the IMAP login and the SMTP login of the outgoing server first, then creates the account and its credential secret in one transaction; `reconnect-mailbox` does the same check for an existing mailbox with a sign-in error and overwrites its credential secret); programmatic setups store the per-account connection JSON (host/port/secure/user/password, plus smtpHost/smtpPort for sending) in the account credential secret slot before the first sync. Implements the provider `send`: `sendMailFromAccount` sends over the SMTP server of the mailbox itself (implicit TLS, same login) with the mailbox address as From and stores a copy in its sent folder.',
|
|
252
279
|
);
|
|
253
280
|
r.uiHints({
|
|
254
281
|
displayLabel: "Inbound Mail · IMAP",
|
|
@@ -259,10 +286,14 @@ export function createInboundProviderImapFeature() {
|
|
|
259
286
|
r.requires({ features: ["secrets"] });
|
|
260
287
|
r.envSchema(mailAllowedPrivateHostsEnvSchema);
|
|
261
288
|
r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, IMAP_PROVIDER_KEY, imapInboundMailPlugin);
|
|
262
|
-
r.
|
|
263
|
-
r.
|
|
289
|
+
r.bootCheck(({ features }) => assertRolesMatchFoundation(FEATURE_NAME, roles, features));
|
|
290
|
+
r.writeHandler(createConnectMailboxHandler(roles));
|
|
291
|
+
r.writeHandler(createConnectMyMailboxHandler(roles));
|
|
292
|
+
r.writeHandler(createReconnectMailboxHandler(roles));
|
|
293
|
+
r.queryHandler(createReconnectPrefillQuery(roles));
|
|
294
|
+
for (const screen of createImapScreens(roles)) r.screen(screen);
|
|
264
295
|
r.translations({ keys: INBOUND_PROVIDER_IMAP_I18N });
|
|
265
296
|
},
|
|
266
|
-
{ dedupeOptions:
|
|
297
|
+
{ dedupeOptions: roleDedupeOptions(roles) },
|
|
267
298
|
);
|
|
268
299
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Re-downloads already imported mails whose content was stored as snippet only. A provider message
|
|
2
|
+
// id is "<uidValidity>:<uid>"; ids from another UIDVALIDITY or gone from the server are "missing"
|
|
3
|
+
// (nothing to reload, ever), mails the parser cannot read are "rejected".
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
InboundContentFetchResult,
|
|
7
|
+
InboundMailContext,
|
|
8
|
+
MailAccountRecord,
|
|
9
|
+
RawInboundMessage,
|
|
10
|
+
RejectedInboundMessage,
|
|
11
|
+
} from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
12
|
+
import { InboundTransientError } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
13
|
+
import { readCredentialDocument } from "./credential-document.ts";
|
|
14
|
+
import {
|
|
15
|
+
closeOnAbort,
|
|
16
|
+
createImapClient,
|
|
17
|
+
IMAP_MAILBOX,
|
|
18
|
+
imapMailHostGuard,
|
|
19
|
+
mapImapError,
|
|
20
|
+
} from "./imap-client.ts";
|
|
21
|
+
import { coerceDate, parseOrReject } from "./parse-message.ts";
|
|
22
|
+
|
|
23
|
+
function uidOf(providerMessageId: string, serverUidValidity: string): number | null {
|
|
24
|
+
const separator = providerMessageId.lastIndexOf(":");
|
|
25
|
+
if (separator < 0 || providerMessageId.slice(0, separator) !== serverUidValidity) return null;
|
|
26
|
+
const uid = Number(providerMessageId.slice(separator + 1));
|
|
27
|
+
return Number.isInteger(uid) && uid > 0 ? uid : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function fetchMessageContent(
|
|
31
|
+
ctx: InboundMailContext,
|
|
32
|
+
account: MailAccountRecord,
|
|
33
|
+
providerMessageIds: readonly string[],
|
|
34
|
+
opts?: { readonly signal?: AbortSignal },
|
|
35
|
+
): Promise<InboundContentFetchResult> {
|
|
36
|
+
const doc = await readCredentialDocument(ctx, account);
|
|
37
|
+
const client = await createImapClient(doc, imapMailHostGuard());
|
|
38
|
+
const stopWatchingAbort = closeOnAbort(client, opts?.signal);
|
|
39
|
+
let lock: Awaited<ReturnType<typeof client.getMailboxLock>> | undefined;
|
|
40
|
+
try {
|
|
41
|
+
await client.connect();
|
|
42
|
+
lock = await client.getMailboxLock(IMAP_MAILBOX);
|
|
43
|
+
const mailbox = client.mailbox;
|
|
44
|
+
if (typeof mailbox === "boolean") throw new InboundTransientError("mailbox not open");
|
|
45
|
+
const serverUidValidity = String(mailbox.uidValidity);
|
|
46
|
+
const idByUid = new Map<number, string>();
|
|
47
|
+
const missing: string[] = [];
|
|
48
|
+
for (const id of providerMessageIds) {
|
|
49
|
+
const uid = uidOf(id, serverUidValidity);
|
|
50
|
+
if (uid === null) missing.push(id);
|
|
51
|
+
else idByUid.set(uid, id);
|
|
52
|
+
}
|
|
53
|
+
const messages: RawInboundMessage[] = [];
|
|
54
|
+
const rejected: RejectedInboundMessage[] = [];
|
|
55
|
+
if (idByUid.size > 0) {
|
|
56
|
+
for await (const msg of client.fetch(
|
|
57
|
+
[...idByUid.keys()].join(","),
|
|
58
|
+
{ uid: true, internalDate: true, source: true, envelope: true },
|
|
59
|
+
{ uid: true },
|
|
60
|
+
)) {
|
|
61
|
+
if (!msg.source) continue;
|
|
62
|
+
const parsed = await parseOrReject({
|
|
63
|
+
source: msg.source,
|
|
64
|
+
uid: msg.uid,
|
|
65
|
+
uidValidity: serverUidValidity,
|
|
66
|
+
internalDate: coerceDate(msg.internalDate ?? undefined),
|
|
67
|
+
envelope: msg.envelope,
|
|
68
|
+
});
|
|
69
|
+
idByUid.delete(msg.uid);
|
|
70
|
+
if ("message" in parsed) messages.push(parsed.message);
|
|
71
|
+
else rejected.push(parsed.rejected);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// What the server did not return is gone (expunged since the import).
|
|
75
|
+
missing.push(...idByUid.values());
|
|
76
|
+
return { messages, missing, rejected };
|
|
77
|
+
} catch (err) {
|
|
78
|
+
throw err instanceof Error && "kind" in err ? err : mapImapError(err, doc.host);
|
|
79
|
+
} finally {
|
|
80
|
+
stopWatchingAbort();
|
|
81
|
+
lock?.release();
|
|
82
|
+
await client.logout().catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
IMAP_CONNECT_SCREEN_ID,
|
|
3
|
+
IMAP_RECONNECT_SCREEN_ID,
|
|
4
|
+
MY_IMAP_CONNECT_SCREEN_ID,
|
|
5
|
+
MY_IMAP_RECONNECT_SCREEN_ID,
|
|
6
|
+
} from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
2
7
|
import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
8
|
import { fieldLabelKey } from "@cosmicdrift/kumiko-renderer/headless";
|
|
4
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
FEATURE_NAME,
|
|
11
|
+
IMAP_CONNECT_ERRORS,
|
|
12
|
+
IMAP_RECONNECT_ERRORS,
|
|
13
|
+
SMTP_CONNECT_ERRORS,
|
|
14
|
+
} from "./constants.ts";
|
|
5
15
|
|
|
6
16
|
const formFieldKey = (field: string): string =>
|
|
7
17
|
fieldLabelKey(FEATURE_NAME, ACTION_FORM_ENTITY, field);
|
|
8
18
|
|
|
9
19
|
export const INBOUND_PROVIDER_IMAP_I18N: Readonly<Record<string, { readonly en: string }>> = {
|
|
10
20
|
[`screen:${IMAP_CONNECT_SCREEN_ID}.title`]: { en: "Connect mailbox" },
|
|
21
|
+
[`screen:${IMAP_RECONNECT_SCREEN_ID}.title`]: { en: "Reconnect mailbox" },
|
|
22
|
+
[`screen:${MY_IMAP_CONNECT_SCREEN_ID}.title`]: { en: "Connect mailbox" },
|
|
23
|
+
[`screen:${MY_IMAP_RECONNECT_SCREEN_ID}.title`]: { en: "Reconnect mailbox" },
|
|
24
|
+
[formFieldKey("accountId")]: { en: "Mailbox" },
|
|
11
25
|
[formFieldKey("displayName")]: { en: "Name" },
|
|
12
26
|
[formFieldKey("address")]: { en: "Email address" },
|
|
13
27
|
[formFieldKey("host")]: { en: "IMAP server" },
|
|
@@ -21,7 +35,19 @@ export const INBOUND_PROVIDER_IMAP_I18N: Readonly<Record<string, { readonly en:
|
|
|
21
35
|
"inboundMail.imap.connect.outgoingHint": {
|
|
22
36
|
en: "Replies go out from this address over your own mail server, with the same user name and password. Leave the field empty if the server is called like the IMAP server, with smtp. instead of imap. Usually 587, even when your provider lists 465/SSL.",
|
|
23
37
|
},
|
|
38
|
+
[formFieldKey("trustedAuthservId")]: { en: "Mail server ID" },
|
|
39
|
+
"inboundMail.imap.connect.senderCheck": { en: "Sender check" },
|
|
40
|
+
"inboundMail.imap.connect.senderCheckHint": {
|
|
41
|
+
en: "Identifier of the mail server from the Authentication-Results header, for example mx.example.com. Only with it the system checks whether a sender is genuine. Leave empty if unknown.",
|
|
42
|
+
},
|
|
24
43
|
"inboundMail.imap.connect.submit": { en: "Check and connect" },
|
|
44
|
+
"inboundMail.imap.reconnect.hint": {
|
|
45
|
+
en: "The sign-in is checked before saving. Then the system fetches the mail again.",
|
|
46
|
+
},
|
|
47
|
+
"inboundMail.imap.reconnect.submit": { en: "Check and reconnect" },
|
|
48
|
+
[IMAP_RECONNECT_ERRORS.notPasswordImap.i18nKey]: {
|
|
49
|
+
en: "Only password IMAP mailboxes can be reconnected here.",
|
|
50
|
+
},
|
|
25
51
|
[IMAP_CONNECT_ERRORS.hostUnreachable.i18nKey]: {
|
|
26
52
|
en: "The IMAP server cannot be reached or is not allowed. Check the server name.",
|
|
27
53
|
},
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Reference implementation: /Users/marc/code/doc-o-mat (HEINZ), but uses an
|
|
3
3
|
// incremental UIDVALIDITY:lastUid cursor instead of a '1:*' full scan.
|
|
4
4
|
|
|
5
|
+
import { isIP } from "node:net";
|
|
5
6
|
import {
|
|
6
7
|
BlockedHostError,
|
|
7
8
|
type MailHostGuardOptions,
|
|
@@ -12,22 +13,24 @@ import {
|
|
|
12
13
|
InboundAuthError,
|
|
13
14
|
InboundCursorInvalidError,
|
|
14
15
|
InboundTransientError,
|
|
15
|
-
type RawInboundMessage,
|
|
16
16
|
type SyncCursorPayload,
|
|
17
17
|
} from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
18
18
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
19
|
-
import { legacyDateToInstant } from "@cosmicdrift/kumiko-framework/time";
|
|
20
|
-
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
21
19
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
22
20
|
import { ImapFlow } from "imapflow";
|
|
23
|
-
import { type AddressObject, type ParsedMail, simpleParser } from "mailparser";
|
|
24
21
|
import type { ImapCredentialDocument } from "./credential-document.ts";
|
|
25
22
|
|
|
26
23
|
export const IMAP_MAILBOX = "INBOX";
|
|
27
|
-
const SNIPPET_MAX = 300;
|
|
28
24
|
|
|
29
25
|
const log = createFallbackLogger("inbound-provider-imap");
|
|
30
26
|
|
|
27
|
+
// Explicit instead of imapflow's defaults (90 s connect, 16 s greeting, 5 min idle socket): a
|
|
28
|
+
// mailbox that stops answering must fail within a poll, not minutes later. The socket timeout
|
|
29
|
+
// stays at the library's value because IDLE connections rely on it between server keep-alives.
|
|
30
|
+
const IMAP_CONNECTION_TIMEOUT_MS = 20_000;
|
|
31
|
+
const IMAP_GREETING_TIMEOUT_MS = 16_000;
|
|
32
|
+
const IMAP_SOCKET_TIMEOUT_MS = 5 * 60 * 1000;
|
|
33
|
+
|
|
31
34
|
// Tenant-visible for both branches below — must not reveal whether the host
|
|
32
35
|
// was blocked (private/reserved range) or merely failed to resolve, and
|
|
33
36
|
// must never include the host or the underlying error text. Server-side
|
|
@@ -80,17 +83,41 @@ export async function createImapClient(
|
|
|
80
83
|
log.warn("host resolution failed", { host: doc.host, reason });
|
|
81
84
|
throw new InboundTransientError(IMAP_HOST_UNREACHABLE_MESSAGE);
|
|
82
85
|
}
|
|
83
|
-
return new ImapFlow(
|
|
86
|
+
return new ImapFlow(imapClientOptions(doc, target));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// For an IP-literal host imapflow itself sets `servername: false`, which Bun's tls.connect rejects
|
|
90
|
+
// ("servername argument must be an string"). Overriding it through `tls` with undefined makes the
|
|
91
|
+
// socket connect without SNI; the certificate is still verified against the IP. Not setting the
|
|
92
|
+
// option at all (as for SMTP, where nodemailer omits a falsy servername) does not help here.
|
|
93
|
+
export function imapClientOptions(
|
|
94
|
+
doc: ImapCredentialDocument,
|
|
95
|
+
target: Awaited<ReturnType<typeof resolveMailConnectTarget>>,
|
|
96
|
+
) {
|
|
97
|
+
return {
|
|
84
98
|
host: target.host,
|
|
85
99
|
port: doc.port,
|
|
86
100
|
secure: doc.secure,
|
|
87
101
|
...(target.servername && { servername: target.servername }),
|
|
102
|
+
...(isIP(target.host) !== 0 && { tls: { servername: undefined } }),
|
|
88
103
|
auth: doc.password
|
|
89
104
|
? { user: doc.user, pass: doc.password }
|
|
90
105
|
: { user: doc.user, accessToken: doc.accessToken as string },
|
|
91
|
-
|
|
106
|
+
connectionTimeout: IMAP_CONNECTION_TIMEOUT_MS,
|
|
107
|
+
greetingTimeout: IMAP_GREETING_TIMEOUT_MS,
|
|
108
|
+
socketTimeout: IMAP_SOCKET_TIMEOUT_MS,
|
|
109
|
+
logger: false as const,
|
|
92
110
|
emitLogs: false,
|
|
93
|
-
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Drops the connection when the supervisor abandons the sync, so a hung command does not keep the socket open. */
|
|
115
|
+
export function closeOnAbort(client: ImapFlow, signal: AbortSignal | undefined): () => void {
|
|
116
|
+
if (!signal) return () => {};
|
|
117
|
+
const close = () => client.close();
|
|
118
|
+
if (signal.aborted) close();
|
|
119
|
+
signal.addEventListener("abort", close, { once: true });
|
|
120
|
+
return () => signal.removeEventListener("abort", close);
|
|
94
121
|
}
|
|
95
122
|
|
|
96
123
|
/** Server-Fehler → typisierte Foundation-Fehlerklassen (Plan §2). */
|
|
@@ -155,79 +182,3 @@ export function assertUidValidity(cursor: ImapCursor | null, serverUidValidity:
|
|
|
155
182
|
);
|
|
156
183
|
}
|
|
157
184
|
}
|
|
158
|
-
|
|
159
|
-
// =============================================================================
|
|
160
|
-
// Message-Normalisierung → RawInboundMessage
|
|
161
|
-
// =============================================================================
|
|
162
|
-
|
|
163
|
-
function stripAngles(id: string): string {
|
|
164
|
-
return id.replace(/^<|>$/g, "").trim();
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Normalizes the References chain: mailparser returns string | string[]
|
|
168
|
-
* | undefined; when References is missing, In-Reply-To provides the thread
|
|
169
|
-
* anchor. */
|
|
170
|
-
export function normalizeReferences(parsed: {
|
|
171
|
-
references?: string | string[];
|
|
172
|
-
inReplyTo?: string;
|
|
173
|
-
}): string[] {
|
|
174
|
-
const raw = parsed.references;
|
|
175
|
-
const list = raw === undefined ? [] : Array.isArray(raw) ? raw : raw.split(/\s+/);
|
|
176
|
-
const refs = list.map(stripAngles).filter((r) => r.length > 0);
|
|
177
|
-
if (refs.length === 0 && parsed.inReplyTo) {
|
|
178
|
-
const inReplyTo = stripAngles(parsed.inReplyTo);
|
|
179
|
-
if (inReplyTo) return [inReplyTo];
|
|
180
|
-
}
|
|
181
|
-
return refs;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function addressList(a: AddressObject | AddressObject[] | undefined): string[] {
|
|
185
|
-
if (!a) return [];
|
|
186
|
-
const arr = Array.isArray(a) ? a : [a];
|
|
187
|
-
return arr.map((x) => x.text).filter((t): t is string => Boolean(t));
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** imapflow provides internalDate as either a Date or string depending on
|
|
191
|
-
* the code path. The string form is deliberately discarded (no date parsing
|
|
192
|
-
* in feature code, no-date-api); toRawInboundMessage then falls back to the
|
|
193
|
-
* mailparser Date header, which is always a real Date. */
|
|
194
|
-
export function coerceDate(d: Date | string | undefined): Date | undefined {
|
|
195
|
-
return d instanceof Date ? d : undefined;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** providerMessageId: a UID is unique only within a UIDVALIDITY; together,
|
|
199
|
-
* they form the stable idempotency anchor. */
|
|
200
|
-
export function buildProviderMessageId(uidValidity: string, uid: number): string {
|
|
201
|
-
return `${uidValidity}:${uid}`;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export async function toRawInboundMessage(args: {
|
|
205
|
-
readonly source: Uint8Array;
|
|
206
|
-
readonly uid: number;
|
|
207
|
-
readonly uidValidity: string;
|
|
208
|
-
readonly internalDate: Date | undefined;
|
|
209
|
-
}): Promise<RawInboundMessage> {
|
|
210
|
-
const parsed: ParsedMail = await simpleParser(Buffer.from(args.source));
|
|
211
|
-
const messageIdHeader = parsed.messageId ? stripAngles(parsed.messageId) : null;
|
|
212
|
-
const receivedAt = args.internalDate ?? parsed.date;
|
|
213
|
-
const text = parsed.text?.trim() ?? "";
|
|
214
|
-
return {
|
|
215
|
-
providerMessageId: buildProviderMessageId(args.uidValidity, args.uid),
|
|
216
|
-
messageIdHeader: messageIdHeader && messageIdHeader.length > 0 ? messageIdHeader : null,
|
|
217
|
-
providerThreadId: null,
|
|
218
|
-
references: normalizeReferences(parsed),
|
|
219
|
-
from: parsed.from?.text ?? "",
|
|
220
|
-
to: addressList(parsed.to),
|
|
221
|
-
cc: addressList(parsed.cc),
|
|
222
|
-
subject: parsed.subject ?? "",
|
|
223
|
-
snippet: text.slice(0, SNIPPET_MAX),
|
|
224
|
-
// Lib-boundary bridge (Date from imapflow/mailparser) → Temporal;
|
|
225
|
-
// when no date exists, Epoch is the honest marker.
|
|
226
|
-
receivedAtIso: (receivedAt
|
|
227
|
-
? legacyDateToInstant(receivedAt)
|
|
228
|
-
: Temporal.Instant.fromEpochMilliseconds(0)
|
|
229
|
-
).toString(),
|
|
230
|
-
rawMime: args.source,
|
|
231
|
-
scope: "inbox",
|
|
232
|
-
};
|
|
233
|
-
}
|
|
@@ -13,9 +13,8 @@ export {
|
|
|
13
13
|
} from "./feature.ts";
|
|
14
14
|
export {
|
|
15
15
|
assertUidValidity,
|
|
16
|
-
buildProviderMessageId,
|
|
17
16
|
type ImapCursor,
|
|
18
17
|
mapImapError,
|
|
19
|
-
normalizeReferences,
|
|
20
18
|
parseImapCursor,
|
|
21
19
|
} from "./imap-client.ts";
|
|
20
|
+
export { buildProviderMessageId, normalizeReferences } from "./parse-message.ts";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Turns a parsed MIME tree into the content the foundation stores: full text, HTML, real
|
|
2
|
+
// attachments, and the attachments it deliberately leaves out. Pure; the parsing itself stays in
|
|
3
|
+
// imap-client.ts.
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
RawInboundAttachment,
|
|
7
|
+
SkippedInboundAttachment,
|
|
8
|
+
} from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
|
|
9
|
+
import { type Attachment, type ParsedMail, simpleParser } from "mailparser";
|
|
10
|
+
|
|
11
|
+
/** A forwarded mail inside a forwarded mail inside a forwarded mail is still read; deeper is skipped. */
|
|
12
|
+
export const MIME_FORWARD_MAX_DEPTH = 3;
|
|
13
|
+
|
|
14
|
+
const FORWARDED_MESSAGE_MIME = "message/rfc822";
|
|
15
|
+
const FALLBACK_ATTACHMENT_NAME = "attachment";
|
|
16
|
+
const FALLBACK_FORWARDED_NAME = "forwarded.eml";
|
|
17
|
+
|
|
18
|
+
export type MailContent = {
|
|
19
|
+
readonly text: string;
|
|
20
|
+
readonly html: string | null;
|
|
21
|
+
readonly attachments: readonly RawInboundAttachment[];
|
|
22
|
+
readonly skipped: readonly SkippedInboundAttachment[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Signature logos and tracking pixels travel as inline images; a PDF with a Content-Id (Apple Mail
|
|
26
|
+
// inlines PDFs that way) is a real document and must survive.
|
|
27
|
+
function isInlineImage(attachment: Attachment): boolean {
|
|
28
|
+
if (!attachment.contentType.toLowerCase().startsWith("image/")) return false;
|
|
29
|
+
const referencedByBody = attachment.related === true;
|
|
30
|
+
const inlineWithContentId =
|
|
31
|
+
attachment.contentDisposition === "inline" && Boolean(attachment.contentId);
|
|
32
|
+
return referencedByBody || inlineWithContentId;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function forwardedHeader(inner: ParsedMail): string {
|
|
36
|
+
return `\n\n---------- Forwarded message ----------\nFrom: ${inner.from?.text ?? ""}\nSubject: ${inner.subject ?? ""}\n\n`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readForwardedMail(
|
|
40
|
+
source: Buffer,
|
|
41
|
+
depth: number,
|
|
42
|
+
): Promise<{ readonly header: string; readonly content: MailContent } | null> {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = await simpleParser(source);
|
|
45
|
+
return { header: forwardedHeader(parsed), content: await extractMailContent(parsed, depth) };
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function extractMailContent(parsed: ParsedMail, depth = 0): Promise<MailContent> {
|
|
52
|
+
let text = parsed.text?.trim() ?? "";
|
|
53
|
+
const attachments: RawInboundAttachment[] = [];
|
|
54
|
+
const skipped: SkippedInboundAttachment[] = [];
|
|
55
|
+
for (const attachment of parsed.attachments) {
|
|
56
|
+
const mimeType = attachment.contentType;
|
|
57
|
+
if (isInlineImage(attachment)) continue;
|
|
58
|
+
if (mimeType.toLowerCase() === FORWARDED_MESSAGE_MIME) {
|
|
59
|
+
if (depth + 1 > MIME_FORWARD_MAX_DEPTH) {
|
|
60
|
+
skipped.push({ mimeType, size: attachment.size, reason: "nesting_too_deep" });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const inner = await readForwardedMail(attachment.content, depth + 1);
|
|
64
|
+
if (inner === null) {
|
|
65
|
+
// A forwarded mail the parser chokes on must not sink the outer mail: keep it as a plain .eml.
|
|
66
|
+
attachments.push({
|
|
67
|
+
fileName: attachment.filename ?? FALLBACK_FORWARDED_NAME,
|
|
68
|
+
mimeType,
|
|
69
|
+
data: attachment.content,
|
|
70
|
+
});
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
text += inner.header + inner.content.text;
|
|
74
|
+
attachments.push(...inner.content.attachments);
|
|
75
|
+
skipped.push(...inner.content.skipped);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
attachments.push({
|
|
79
|
+
fileName: attachment.filename ?? FALLBACK_ATTACHMENT_NAME,
|
|
80
|
+
mimeType,
|
|
81
|
+
data: attachment.content,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return { text, html: parsed.html || null, attachments, skipped };
|
|
85
|
+
}
|