@cosmicdrift/kumiko-bundled-features 0.141.0 → 0.143.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.
Files changed (33) hide show
  1. package/package.json +13 -7
  2. package/src/inbound-mail-foundation/__tests__/feature.test.ts +169 -0
  3. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +437 -0
  4. package/src/inbound-mail-foundation/aggregate-id.ts +40 -0
  5. package/src/inbound-mail-foundation/connect-routes.ts +230 -0
  6. package/src/inbound-mail-foundation/constants.ts +80 -0
  7. package/src/inbound-mail-foundation/db/queries/inbound-projections.ts +38 -0
  8. package/src/inbound-mail-foundation/entities.ts +168 -0
  9. package/src/inbound-mail-foundation/events.ts +147 -0
  10. package/src/inbound-mail-foundation/feature.ts +185 -0
  11. package/src/inbound-mail-foundation/handlers/account-state.ts +23 -0
  12. package/src/inbound-mail-foundation/handlers/connect-account.write.ts +112 -0
  13. package/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +69 -0
  14. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +279 -0
  15. package/src/inbound-mail-foundation/handlers/list-accounts.query.ts +44 -0
  16. package/src/inbound-mail-foundation/handlers/list-messages.query.ts +58 -0
  17. package/src/inbound-mail-foundation/handlers/scope-visibility.ts +14 -0
  18. package/src/inbound-mail-foundation/handlers/update-account.write.ts +78 -0
  19. package/src/inbound-mail-foundation/index.ts +97 -0
  20. package/src/inbound-mail-foundation/oauth-state.ts +103 -0
  21. package/src/inbound-mail-foundation/projection.ts +152 -0
  22. package/src/inbound-mail-foundation/provider-factory.ts +53 -0
  23. package/src/inbound-mail-foundation/tenant-destroy-hook.ts +93 -0
  24. package/src/inbound-mail-foundation/types.ts +250 -0
  25. package/src/inbound-mail-foundation/watch-supervisor.ts +482 -0
  26. package/src/inbound-provider-imap/__tests__/feature.test.ts +115 -0
  27. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +194 -0
  28. package/src/inbound-provider-imap/credential-document.ts +51 -0
  29. package/src/inbound-provider-imap/feature.ts +287 -0
  30. package/src/inbound-provider-imap/imap-client.ts +158 -0
  31. package/src/inbound-provider-imap/index.ts +17 -0
  32. package/src/inbound-provider-inmemory/feature.ts +154 -0
  33. package/src/inbound-provider-inmemory/index.ts +12 -0
@@ -0,0 +1,194 @@
1
+ // Live-IMAP-Test gegen greenmail (Plan §6 Phase 2, opt-in):
2
+ // docker run -d --name cdgs-greenmail -p 3025:3025 -p 3143:3143 \
3
+ // -e GREENMAIL_OPTS="-Dgreenmail.setup.test.all -Dgreenmail.users=testuser:testpass@example.com" \
4
+ // greenmail/standalone:2.1.0
5
+ //
6
+ // Läuft NUR wenn der Server erreichbar ist (top-level Probe) — sonst
7
+ // skippen alle Tests sichtbar statt rot zu sein. Verifiziert die
8
+ // Plan-Pflichten: fetch-Backfill + inkrementeller Cursor, IDLE-Push
9
+ // < 5 s, Auth-Fehler → InboundAuthError.
10
+ //
11
+ // **Provider-Level-Test:** getestet wird das imapflow-Wiring des
12
+ // Plugins gegen einen echten IMAP-Server. Der ctx ist der schmale
13
+ // InboundMailContext des Provider-Contracts (kein HandlerContext);
14
+ // das Secret-Slot-Read wird mit einem minimalen SecretsContext bedient
15
+ // — der volle Dispatcher-Pfad ist in
16
+ // inbound-mail-foundation.integration.test.ts abgedeckt.
17
+
18
+ import { describe, expect, test } from "bun:test";
19
+ import { connect } from "node:net";
20
+ import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
21
+ import { createTransport } from "nodemailer";
22
+ import {
23
+ type InboundMailContext,
24
+ isInboundAuthError,
25
+ type MailAccountRecord,
26
+ type RawInboundMessage,
27
+ } from "../../inbound-mail-foundation";
28
+ import { imapInboundMailPlugin } from "../feature";
29
+
30
+ const HOST = process.env["IMAP_LIVE_HOST"] ?? "127.0.0.1";
31
+ const IMAP_PORT = Number(process.env["IMAP_LIVE_PORT"] ?? 3143);
32
+ const SMTP_PORT = Number(process.env["IMAP_LIVE_SMTP_PORT"] ?? 3025);
33
+ const USER = "testuser";
34
+ const PASSWORD = "testpass";
35
+ const ADDRESS = "testuser@example.com";
36
+
37
+ function probe(host: string, port: number): Promise<boolean> {
38
+ return new Promise((resolve) => {
39
+ const socket = connect({ host, port, timeout: 1500 });
40
+ socket.once("connect", () => {
41
+ socket.destroy();
42
+ resolve(true);
43
+ });
44
+ socket.once("error", () => resolve(false));
45
+ socket.once("timeout", () => {
46
+ socket.destroy();
47
+ resolve(false);
48
+ });
49
+ });
50
+ }
51
+
52
+ const available = await probe(HOST, IMAP_PORT);
53
+ const liveTest = available ? test : test.skip;
54
+ if (!available) {
55
+ console.warn(
56
+ `imap-live: kein IMAP-Server auf ${HOST}:${IMAP_PORT} — Live-Tests werden geskippt (greenmail starten, siehe Header)`,
57
+ );
58
+ }
59
+
60
+ function credentialDoc(password: string): string {
61
+ return JSON.stringify({
62
+ host: HOST,
63
+ port: IMAP_PORT,
64
+ secure: false,
65
+ user: USER,
66
+ password,
67
+ });
68
+ }
69
+
70
+ /** Minimaler SecretsContext für den Provider-Contract-ctx — liefert das
71
+ * Credential-Dokument aus einer Map statt aus der DB. */
72
+ function ctxWithCredentials(doc: string): InboundMailContext {
73
+ return {
74
+ // Worker-Identity fürs Secret-Read-Audit (requireSecretsContext).
75
+ _userId: "imap-live-test",
76
+ secrets: {
77
+ get: async () => createSecret(doc),
78
+ has: async () => true,
79
+ set: async () => {},
80
+ delete: async () => {},
81
+ } as unknown as import("@cosmicdrift/kumiko-framework/secrets").SecretsContext, // @cast-boundary test-double, nur get()/has() werden gelesen
82
+ };
83
+ }
84
+
85
+ const account: MailAccountRecord = {
86
+ id: "00000000-0000-4000-8000-000000001a01",
87
+ tenantId: "00000000-0000-4000-8000-000000004242",
88
+ provider: "imap",
89
+ authMethod: "password",
90
+ ownerUserId: null,
91
+ address: ADDRESS,
92
+ displayName: "Live-Test",
93
+ status: "active",
94
+ watchState: "idle",
95
+ };
96
+
97
+ async function sendTestMail(subject: string, text: string): Promise<void> {
98
+ const transport = createTransport({ host: HOST, port: SMTP_PORT, secure: false });
99
+ await transport.sendMail({
100
+ from: "Sender <sender@example.com>",
101
+ to: ADDRESS,
102
+ subject,
103
+ text,
104
+ });
105
+ transport.close();
106
+ }
107
+
108
+ describe("imap-live — echter Server (greenmail)", () => {
109
+ liveTest(
110
+ "verify: korrekte Credentials ok, falsches Passwort → InboundAuthError",
111
+ async () => {
112
+ await imapInboundMailPlugin.verify(ctxWithCredentials(credentialDoc(PASSWORD)), account);
113
+
114
+ try {
115
+ await imapInboundMailPlugin.verify(
116
+ ctxWithCredentials(credentialDoc("wrong-pass")),
117
+ account,
118
+ );
119
+ throw new Error("expected verify to throw");
120
+ } catch (e) {
121
+ expect(isInboundAuthError(e)).toBe(true);
122
+ }
123
+ },
124
+ 20_000,
125
+ );
126
+
127
+ liveTest(
128
+ "fetch: Backfill liefert die Mail, inkrementeller Cursor liefert danach nichts Neues",
129
+ async () => {
130
+ const ctx = ctxWithCredentials(credentialDoc(PASSWORD));
131
+ const subject = `backfill-${crypto.randomUUID()}`;
132
+ await sendTestMail(subject, "Hallo aus dem Live-Test");
133
+
134
+ const first = await imapInboundMailPlugin.fetch(ctx, account, null, {
135
+ backfillWindowDays: 1,
136
+ maxMessages: 50,
137
+ });
138
+ const match = first.messages.find((m) => m.subject === subject);
139
+ expect(match).toBeDefined();
140
+ expect(match?.from).toContain("sender@example.com");
141
+ expect(match?.snippet).toContain("Hallo aus dem Live-Test");
142
+ expect(match?.rawMime).not.toBeNull();
143
+ expect(typeof first.nextCursor["uidValidity"]).toBe("string");
144
+ expect(typeof first.nextCursor["lastUid"]).toBe("number");
145
+
146
+ // Inkrementell ab Cursor: nichts Neues.
147
+ const second = await imapInboundMailPlugin.fetch(ctx, account, first.nextCursor, {
148
+ backfillWindowDays: 1,
149
+ maxMessages: 50,
150
+ });
151
+ expect(second.messages).toHaveLength(0);
152
+ expect(second.nextCursor["lastUid"]).toBe(first.nextCursor["lastUid"]);
153
+ },
154
+ 30_000,
155
+ );
156
+
157
+ liveTest(
158
+ "watch (IDLE): neue Mail wird in < 5 s gepusht",
159
+ async () => {
160
+ const ctx = ctxWithCredentials(credentialDoc(PASSWORD));
161
+ const subject = `idle-${crypto.randomUUID()}`;
162
+
163
+ const pushed = new Promise<readonly RawInboundMessage[]>((resolve, reject) => {
164
+ const timer = setTimeout(
165
+ () => reject(new Error("IDLE push not received within 5s")),
166
+ 5_000,
167
+ );
168
+ void imapInboundMailPlugin
169
+ .watch?.(ctx, account, {
170
+ onMessages: async (msgs) => {
171
+ if (msgs.some((m) => m.subject === subject)) {
172
+ clearTimeout(timer);
173
+ resolve(msgs);
174
+ }
175
+ },
176
+ onError: (err) => {
177
+ clearTimeout(timer);
178
+ reject(err instanceof Error ? err : new Error(String(err)));
179
+ },
180
+ })
181
+ .then((stop) => {
182
+ // Nach Aufbau der IDLE-Verbindung die Mail schicken.
183
+ void sendTestMail(subject, "IDLE push").catch(reject);
184
+ // stop() nach Auflösung — pushed-Promise cleanup.
185
+ void pushed.finally(() => void stop().catch(() => {}));
186
+ }, reject);
187
+ });
188
+
189
+ const msgs = await pushed;
190
+ expect(msgs.some((m) => m.subject === subject)).toBe(true);
191
+ },
192
+ 20_000,
193
+ );
194
+ });
@@ -0,0 +1,51 @@
1
+ // Connection-Dokument im per-Account-Secret-Slot
2
+ // (inboundCredentialSecretKey(accountId), Plan §4).
3
+ //
4
+ // **Warum JSON im Secret statt Tenant-Config-Keys:** anders als
5
+ // mail-transport-smtp (EIN SMTP pro Tenant) hat Inbound MEHRERE
6
+ // Accounts pro Tenant mit verschiedenen Hosts — per-Tenant-Config-Keys
7
+ // können das nicht tragen. Host/Port/User sind zusammen mit dem
8
+ // Passwort ein zusammengehöriges Verbindungs-Dokument; der Slot ist
9
+ // ohnehin encrypted-at-rest.
10
+ //
11
+ // authMethod des Accounts bestimmt die Interpretation:
12
+ // password → { host, port, secure, user, password }
13
+ // xoauth2 → { host, port, secure, user, accessToken } — V1-Fallback:
14
+ // Token-Beschaffung/-Refresh gehört den OAuth-Providern
15
+ // (M365/Gmail, Phase 4/5); der imap-Provider konsumiert
16
+ // nur einen gültigen Access-Token aus dem Slot.
17
+
18
+ import { z } from "zod";
19
+
20
+ export const imapCredentialDocumentSchema = z.object({
21
+ host: z.string().min(1).max(255),
22
+ port: z.number().int().min(1).max(65535).default(993),
23
+ secure: z.boolean().default(true),
24
+ user: z.string().min(1).max(320),
25
+ password: z.string().min(1).optional(),
26
+ accessToken: z.string().min(1).optional(),
27
+ });
28
+ export type ImapCredentialDocument = z.infer<typeof imapCredentialDocumentSchema>;
29
+
30
+ export type ImapCredentialRejection = "not_json" | "invalid_schema" | "missing_credentials";
31
+
32
+ export type ParseCredentialResult =
33
+ | { readonly ok: true; readonly doc: ImapCredentialDocument }
34
+ | { readonly ok: false; readonly reason: ImapCredentialRejection; readonly detail?: string };
35
+
36
+ export function parseImapCredentialDocument(raw: string): ParseCredentialResult {
37
+ let json: unknown;
38
+ try {
39
+ json = JSON.parse(raw);
40
+ } catch {
41
+ return { ok: false, reason: "not_json" };
42
+ }
43
+ const parsed = imapCredentialDocumentSchema.safeParse(json);
44
+ if (!parsed.success) {
45
+ return { ok: false, reason: "invalid_schema", detail: parsed.error.message };
46
+ }
47
+ if (!parsed.data.password && !parsed.data.accessToken) {
48
+ return { ok: false, reason: "missing_credentials", detail: "needs password or accessToken" };
49
+ }
50
+ return { ok: true, doc: parsed.data };
51
+ }
@@ -0,0 +1,287 @@
1
+ // kumiko-feature-version: 1
2
+ //
3
+ // inbound-provider-imap — IMAP-Implementierung der inbound-mail-
4
+ // foundation Plugin-API. Deckt den DACH-Long-Tail (IONOS, Telekom,
5
+ // Strato, GMX/Web.de, mailbox.org, Hetzner, all-inkl) via Passwort/
6
+ // App-Passwort ab — komplett OHNE OAuth. XOAUTH2 als Fallback für
7
+ // M365-/Gmail-Konten (Access-Token aus dem Secret-Slot; Token-Refresh
8
+ // gehört den OAuth-Providern, Phase 4/5).
9
+ //
10
+ // **Was diese Feature liefert:**
11
+ // 1. Plugin-Registration via r.useExtension("inboundMailProvider",
12
+ // "imap", { verify, fetch, watch }).
13
+ // 2. verify: connect+logout mit typisiertem Fehler-Mapping.
14
+ // 3. fetch: inkrementeller Sync über UIDVALIDITY:lastUid-Cursor —
15
+ // UIDVALIDITY-Wechsel wirft InboundCursorInvalidError (Foundation
16
+ // resettet + resynct im Backfill-Fenster). Erst-Befüllung via
17
+ // SEARCH since=backfillWindowDays.
18
+ // 4. watch: IMAP IDLE via imapflow — 'exists'-Event → neue UIDs
19
+ // fetchen → onMessages; Verbindungs-Tod → onError (Supervisor
20
+ // restartet mit Backoff, der Provider reconnected NICHT selbst).
21
+ //
22
+ // Credentials: JSON-Dokument im per-Account-Secret-Slot
23
+ // (credential-document.ts) — kein Tenant-Config, weil mehrere Accounts
24
+ // pro Tenant verschiedene Hosts haben.
25
+
26
+ import {
27
+ INBOUND_MAIL_PROVIDER_EXTENSION,
28
+ InboundAuthError,
29
+ type InboundFetchResult,
30
+ type InboundMailContext,
31
+ type InboundMailProviderPlugin,
32
+ inboundCredentialSecretKey,
33
+ type MailAccountRecord,
34
+ type RawInboundMessage,
35
+ } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
36
+ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
37
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
38
+ import { instantToLegacyDate } from "@cosmicdrift/kumiko-framework/time";
39
+ import type { ImapFlow, MailboxObject } from "imapflow";
40
+ import { Temporal } from "temporal-polyfill";
41
+ import { type ImapCredentialDocument, parseImapCredentialDocument } from "./credential-document";
42
+ import {
43
+ assertUidValidity,
44
+ coerceDate,
45
+ createImapClient,
46
+ IMAP_MAILBOX,
47
+ mapImapError,
48
+ parseImapCursor,
49
+ toRawInboundMessage,
50
+ } from "./imap-client";
51
+
52
+ const FEATURE_NAME = "inbound-provider-imap";
53
+ export const IMAP_PROVIDER_KEY = "imap";
54
+
55
+ // =============================================================================
56
+ // Credential-Read — per-Account-Slot, Worker-tauglich (slim ctx).
57
+ // =============================================================================
58
+
59
+ async function readCredentialDocument(
60
+ ctx: InboundMailContext,
61
+ account: MailAccountRecord,
62
+ ): Promise<ImapCredentialDocument> {
63
+ const secrets = requireSecretsContext(ctx, FEATURE_NAME);
64
+ const secret = await secrets.get(account.tenantId, inboundCredentialSecretKey(account.id));
65
+ if (!secret) {
66
+ throw new InboundAuthError(
67
+ `${FEATURE_NAME}: no credential document in secret slot for account ${account.id} — ` +
68
+ `store the IMAP connection JSON under ${inboundCredentialSecretKey(account.id)} first`,
69
+ );
70
+ }
71
+ const parsed = parseImapCredentialDocument(secret.reveal());
72
+ if (!parsed.ok) {
73
+ throw new InboundAuthError(
74
+ `${FEATURE_NAME}: account ${account.id}: ${parsed.reason}${parsed.detail ? `: ${parsed.detail}` : ""}`,
75
+ );
76
+ }
77
+ return parsed.doc;
78
+ }
79
+
80
+ // =============================================================================
81
+ // fetch — inkrementeller UID-Sync
82
+ // =============================================================================
83
+
84
+ async function fetchMessages(
85
+ ctx: InboundMailContext,
86
+ account: MailAccountRecord,
87
+ cursor: Parameters<InboundMailProviderPlugin["fetch"]>[2],
88
+ opts: { readonly backfillWindowDays: number; readonly maxMessages: number },
89
+ ): Promise<InboundFetchResult> {
90
+ const doc = await readCredentialDocument(ctx, account);
91
+ const client = createImapClient(doc);
92
+ try {
93
+ await client.connect();
94
+ } catch (err) {
95
+ throw mapImapError(err, doc.host);
96
+ }
97
+
98
+ const lock = await client.getMailboxLock(IMAP_MAILBOX);
99
+ try {
100
+ const mailbox = client.mailbox as MailboxObject;
101
+ const serverUidValidity = String(mailbox.uidValidity);
102
+ const parsedCursor = parseImapCursor(cursor);
103
+ assertUidValidity(parsedCursor, serverUidValidity);
104
+
105
+ // UID-Menge bestimmen: inkrementell ab lastUid+1, sonst Backfill
106
+ // über SEARCH since (Fenster begrenzt die Erst-Befüllung).
107
+ let uids: number[];
108
+ if (parsedCursor) {
109
+ const found = await client.search({ uid: `${parsedCursor.lastUid + 1}:*` }, { uid: true });
110
+ // `N:*`-Gotcha: liegt N über der höchsten UID, liefert IMAP
111
+ // trotzdem die letzte Message — rausfiltern.
112
+ uids = (found || []).filter((uid) => uid > parsedCursor.lastUid);
113
+ } else {
114
+ const since = Temporal.Now.instant().subtract({ hours: opts.backfillWindowDays * 24 });
115
+ // imapflow-API-Boundary: search() verlangt ein JS-Date.
116
+ uids = (await client.search({ since: instantToLegacyDate(since) }, { uid: true })) || [];
117
+ }
118
+ uids.sort((a, b) => a - b);
119
+
120
+ const batch = uids.slice(0, opts.maxMessages);
121
+ const hasMore = uids.length > batch.length;
122
+ const messages: RawInboundMessage[] = [];
123
+ let maxUid = parsedCursor?.lastUid ?? 0;
124
+
125
+ if (batch.length > 0) {
126
+ for await (const msg of client.fetch(
127
+ batch.join(","),
128
+ { uid: true, internalDate: true, source: true },
129
+ { uid: true },
130
+ )) {
131
+ if (!msg.source) continue;
132
+ messages.push(
133
+ await toRawInboundMessage({
134
+ source: msg.source,
135
+ uid: msg.uid,
136
+ uidValidity: serverUidValidity,
137
+ internalDate: coerceDate(msg.internalDate ?? undefined),
138
+ }),
139
+ );
140
+ if (msg.uid > maxUid) maxUid = msg.uid;
141
+ }
142
+ }
143
+
144
+ return {
145
+ messages,
146
+ nextCursor: { uidValidity: serverUidValidity, lastUid: maxUid },
147
+ hasMore,
148
+ };
149
+ } catch (err) {
150
+ throw err instanceof Error && "kind" in err ? err : mapImapError(err, doc.host);
151
+ } finally {
152
+ lock.release();
153
+ await client.logout().catch(() => {});
154
+ }
155
+ }
156
+
157
+ // =============================================================================
158
+ // watch — IMAP IDLE (imapflow idlet automatisch bei offener Mailbox und
159
+ // emittet 'exists' bei neuen Messages).
160
+ // =============================================================================
161
+
162
+ async function watchMailbox(
163
+ ctx: InboundMailContext,
164
+ account: MailAccountRecord,
165
+ handlers: {
166
+ readonly onMessages: (msgs: readonly RawInboundMessage[]) => Promise<void>;
167
+ readonly onError: (err: unknown) => void;
168
+ },
169
+ ): Promise<() => Promise<void>> {
170
+ const doc = await readCredentialDocument(ctx, account);
171
+ const client: ImapFlow = createImapClient(doc);
172
+ let stopped = false;
173
+
174
+ try {
175
+ await client.connect();
176
+ const mailbox = (await client.mailboxOpen(IMAP_MAILBOX)) as MailboxObject;
177
+ const uidValidity = String(mailbox.uidValidity);
178
+ // uidNext = nächste zu vergebende UID — alles ab hier ist "neu".
179
+ let nextUid = Number(mailbox.uidNext ?? 1);
180
+
181
+ const drainNew = async () => {
182
+ const messages: RawInboundMessage[] = [];
183
+ let maxSeen = nextUid - 1;
184
+ for await (const msg of client.fetch(
185
+ `${nextUid}:*`,
186
+ { uid: true, internalDate: true, source: true },
187
+ { uid: true },
188
+ )) {
189
+ // `N:*`-Gotcha (siehe fetch): alte letzte Message rausfiltern.
190
+ if (msg.uid < nextUid || !msg.source) continue;
191
+ messages.push(
192
+ await toRawInboundMessage({
193
+ source: msg.source,
194
+ uid: msg.uid,
195
+ uidValidity,
196
+ internalDate: coerceDate(msg.internalDate ?? undefined),
197
+ }),
198
+ );
199
+ if (msg.uid > maxSeen) maxSeen = msg.uid;
200
+ }
201
+ nextUid = maxSeen + 1;
202
+ if (messages.length > 0) await handlers.onMessages(messages);
203
+ };
204
+
205
+ client.on("exists", () => {
206
+ // skip: Watcher gestoppt — exists-Nachzügler ignorieren.
207
+ if (stopped) return;
208
+ void drainNew().catch((err) => {
209
+ if (!stopped) handlers.onError(mapImapError(err, doc.host));
210
+ });
211
+ });
212
+ const die = (err: unknown) => {
213
+ // skip: bereits gestoppt/gestorben — onError nur einmal feuern.
214
+ if (stopped) return;
215
+ stopped = true;
216
+ handlers.onError(mapImapError(err, doc.host));
217
+ };
218
+ client.on("error", die);
219
+ client.on("close", () => die(new Error("connection closed")));
220
+
221
+ // IDLE-Loop: 'exists' feuert NUR während client.idle() läuft
222
+ // (empirisch gegen greenmail verifiziert). Jedes zwischengeschobene
223
+ // Kommando (drainNew-fetch) beendet idle() — die Loop re-idlet,
224
+ // bis stop() die Verbindung schließt.
225
+ void (async () => {
226
+ while (!stopped) {
227
+ try {
228
+ await client.idle();
229
+ } catch (err) {
230
+ if (!stopped) die(err);
231
+ // skip: idle-Loop endet — die() hat den Supervisor informiert (bzw. stop() war die Ursache).
232
+ return;
233
+ }
234
+ }
235
+ })();
236
+
237
+ return async () => {
238
+ stopped = true;
239
+ client.removeAllListeners("exists");
240
+ client.removeAllListeners("error");
241
+ client.removeAllListeners("close");
242
+ await client.logout().catch(() => {});
243
+ };
244
+ } catch (err) {
245
+ await client.logout().catch(() => {});
246
+ throw mapImapError(err, doc.host);
247
+ }
248
+ }
249
+
250
+ // =============================================================================
251
+ // Plugin + Feature-definition
252
+ // =============================================================================
253
+
254
+ /** Exportiert für den Live-Test (imap-live.integration.test.ts) —
255
+ * Apps nutzen das Feature, nie das Plugin direkt. */
256
+ export const imapInboundMailPlugin: InboundMailProviderPlugin = {
257
+ verify: async (ctx, account) => {
258
+ const doc = await readCredentialDocument(ctx, account);
259
+ const client = createImapClient(doc);
260
+ try {
261
+ await client.connect();
262
+ } catch (err) {
263
+ throw mapImapError(err, doc.host);
264
+ } finally {
265
+ await client.logout().catch(() => {});
266
+ }
267
+ },
268
+ fetch: fetchMessages,
269
+ watch: watchMailbox,
270
+ // Kein oauth-Block: Passwort-Connect läuft über das Credential-
271
+ // Formular (connect-account + Secret-Write); XOAUTH2-Token-Flows
272
+ // gehören den OAuth-Providern (M365/Gmail).
273
+ };
274
+
275
+ export const inboundProviderImapFeature = defineFeature(FEATURE_NAME, (r) => {
276
+ r.describe(
277
+ '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. Store the per-account connection JSON (host/port/secure/user/password) in the account credential secret slot before the first sync.',
278
+ );
279
+ r.uiHints({
280
+ displayLabel: "Inbound Mail · IMAP",
281
+ category: "notifications",
282
+ recommended: false,
283
+ });
284
+ r.requires("inbound-mail-foundation");
285
+ r.requires("secrets");
286
+ r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, IMAP_PROVIDER_KEY, imapInboundMailPlugin);
287
+ });
@@ -0,0 +1,158 @@
1
+ // imapflow-Wiring + pure Helpers des IMAP-Providers.
2
+ // Referenz-Impl: /Users/marc/code/doc-o-mat (HEINZ) — aber inkrementeller
3
+ // UIDVALIDITY:lastUid-Cursor statt '1:*'-Vollscan.
4
+
5
+ import {
6
+ InboundAuthError,
7
+ InboundCursorInvalidError,
8
+ InboundTransientError,
9
+ type RawInboundMessage,
10
+ type SyncCursorPayload,
11
+ } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
12
+ import { legacyDateToInstant } from "@cosmicdrift/kumiko-framework/time";
13
+ import { ImapFlow } from "imapflow";
14
+ import { type AddressObject, type ParsedMail, simpleParser } from "mailparser";
15
+ import { Temporal } from "temporal-polyfill";
16
+ import type { ImapCredentialDocument } from "./credential-document";
17
+
18
+ export const IMAP_MAILBOX = "INBOX";
19
+ const SNIPPET_MAX = 300;
20
+
21
+ // =============================================================================
22
+ // Client-Factory + Fehler-Mapping
23
+ // =============================================================================
24
+
25
+ export function createImapClient(doc: ImapCredentialDocument): ImapFlow {
26
+ return new ImapFlow({
27
+ host: doc.host,
28
+ port: doc.port,
29
+ secure: doc.secure,
30
+ auth: doc.password
31
+ ? { user: doc.user, pass: doc.password }
32
+ : { user: doc.user, accessToken: doc.accessToken as string },
33
+ logger: false,
34
+ emitLogs: false,
35
+ });
36
+ }
37
+
38
+ /** Server-Fehler → typisierte Foundation-Fehlerklassen (Plan §2). */
39
+ export function mapImapError(err: unknown, host: string): Error {
40
+ // imapflow-Fehler tragen die Server-Antwort in responseText, die
41
+ // message ist nur "Command failed" — beides in den Match einbeziehen;
42
+ // authenticationFailed ist das verlässliche Flag.
43
+ const carrier = err as { authenticationFailed?: unknown; responseText?: unknown };
44
+ const responseText = typeof carrier?.responseText === "string" ? carrier.responseText : "";
45
+ const msg = `${err instanceof Error ? err.message : String(err)} ${responseText}`.trim();
46
+ if (
47
+ carrier?.authenticationFailed === true ||
48
+ /auth(entication)? failed|invalid credentials|invalid login|LOGIN failed|AUTHENTICATIONFAILED/i.test(
49
+ msg,
50
+ )
51
+ ) {
52
+ return new InboundAuthError(`IMAP auth failed at ${host}: ${msg}`);
53
+ }
54
+ if (/ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|ECONNRESET|socket|closed/i.test(msg)) {
55
+ return new InboundTransientError(`IMAP ${host} unreachable: ${msg}`);
56
+ }
57
+ return new InboundTransientError(`IMAP ${host}: ${msg}`);
58
+ }
59
+
60
+ // =============================================================================
61
+ // Cursor — { uidValidity: string, lastUid: number }
62
+ // =============================================================================
63
+
64
+ export type ImapCursor = { readonly uidValidity: string; readonly lastUid: number };
65
+
66
+ export function parseImapCursor(cursor: SyncCursorPayload | null): ImapCursor | null {
67
+ if (!cursor) return null;
68
+ const uidValidity = cursor["uidValidity"];
69
+ const lastUid = cursor["lastUid"];
70
+ if (typeof uidValidity !== "string" || typeof lastUid !== "number") return null;
71
+ return { uidValidity, lastUid };
72
+ }
73
+
74
+ /** Wirft InboundCursorInvalidError bei UIDVALIDITY-Wechsel — die
75
+ * Foundation resettet den Cursor und macht einen Voll-Resync im
76
+ * Backfill-Fenster (Dedup fängt Dubletten). */
77
+ export function assertUidValidity(cursor: ImapCursor | null, serverUidValidity: string): void {
78
+ if (cursor && cursor.uidValidity !== serverUidValidity) {
79
+ throw new InboundCursorInvalidError(
80
+ `UIDVALIDITY changed (${cursor.uidValidity} → ${serverUidValidity}) — full resync required`,
81
+ );
82
+ }
83
+ }
84
+
85
+ // =============================================================================
86
+ // Message-Normalisierung → RawInboundMessage
87
+ // =============================================================================
88
+
89
+ function stripAngles(id: string): string {
90
+ return id.replace(/^<|>$/g, "").trim();
91
+ }
92
+
93
+ /** References-Chain normalisieren: mailparser liefert string | string[]
94
+ * | undefined; fehlt References, trägt In-Reply-To den Thread-Anker. */
95
+ export function normalizeReferences(parsed: {
96
+ references?: string | string[];
97
+ inReplyTo?: string;
98
+ }): string[] {
99
+ const raw = parsed.references;
100
+ const list = raw === undefined ? [] : Array.isArray(raw) ? raw : raw.split(/\s+/);
101
+ const refs = list.map(stripAngles).filter((r) => r.length > 0);
102
+ if (refs.length === 0 && parsed.inReplyTo) {
103
+ const inReplyTo = stripAngles(parsed.inReplyTo);
104
+ if (inReplyTo) return [inReplyTo];
105
+ }
106
+ return refs;
107
+ }
108
+
109
+ function addressList(a: AddressObject | AddressObject[] | undefined): string[] {
110
+ if (!a) return [];
111
+ const arr = Array.isArray(a) ? a : [a];
112
+ return arr.map((x) => x.text).filter((t): t is string => Boolean(t));
113
+ }
114
+
115
+ /** imapflow liefert internalDate je nach Codepfad als Date ODER string.
116
+ * String-Form verwerfen wir bewusst (kein Date-Parsing im Feature-Code,
117
+ * no-date-api) — toRawInboundMessage fällt dann auf mailparsers
118
+ * Date-Header zurück, der immer als echtes Date kommt. */
119
+ export function coerceDate(d: Date | string | undefined): Date | undefined {
120
+ return d instanceof Date ? d : undefined;
121
+ }
122
+
123
+ /** providerMessageId: UID ist nur innerhalb einer UIDVALIDITY eindeutig
124
+ * — beides zusammen ist der stabile Idempotency-Anchor. */
125
+ export function buildProviderMessageId(uidValidity: string, uid: number): string {
126
+ return `${uidValidity}:${uid}`;
127
+ }
128
+
129
+ export async function toRawInboundMessage(args: {
130
+ readonly source: Uint8Array;
131
+ readonly uid: number;
132
+ readonly uidValidity: string;
133
+ readonly internalDate: Date | undefined;
134
+ }): Promise<RawInboundMessage> {
135
+ const parsed: ParsedMail = await simpleParser(Buffer.from(args.source));
136
+ const messageIdHeader = parsed.messageId ? stripAngles(parsed.messageId) : null;
137
+ const receivedAt = args.internalDate ?? parsed.date;
138
+ const text = parsed.text?.trim() ?? "";
139
+ return {
140
+ providerMessageId: buildProviderMessageId(args.uidValidity, args.uid),
141
+ messageIdHeader: messageIdHeader && messageIdHeader.length > 0 ? messageIdHeader : null,
142
+ providerThreadId: null,
143
+ references: normalizeReferences(parsed),
144
+ from: parsed.from?.text ?? "",
145
+ to: addressList(parsed.to),
146
+ cc: addressList(parsed.cc),
147
+ subject: parsed.subject ?? "",
148
+ snippet: text.slice(0, SNIPPET_MAX),
149
+ // Lib-Boundary-Bridge (Date von imapflow/mailparser) → Temporal;
150
+ // fehlt jedes Datum, ist Epoch der ehrliche Marker.
151
+ receivedAtIso: (receivedAt
152
+ ? legacyDateToInstant(receivedAt)
153
+ : Temporal.Instant.fromEpochMilliseconds(0)
154
+ ).toString(),
155
+ rawMime: args.source,
156
+ scope: "inbox",
157
+ };
158
+ }