@cosmicdrift/kumiko-bundled-features 0.141.0 → 0.142.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,93 @@
1
+ // Tenant-destroy hooks — räumen beim Tenant-Destroy alle Inbound-Mail-
2
+ // Spuren des Tenants ab. Ein Hook PRO tenant-subject-Entity (der
3
+ // gdpr-storage-Boot-Validator prüft die EXT_TENANT_DATA-Registrierung
4
+ // entity-genau), jeweils:
5
+ //
6
+ // 1. Streams archivieren — ein künftiger Projection-Rebuild kann
7
+ // Gelöschtes nicht resurrecten (Muster billing-foundation #800).
8
+ // Message-Streams sind viele → Loop über die Projection-Rows
9
+ // (Row-PK = Stream-ID); tenant-destroy ist ein seltener
10
+ // Batch-Vorgang, O(n) ist ok.
11
+ // 2. Projection-Rows löschen.
12
+ // 3. Nur mail-account: unmanaged Sync-State (Cursors, Seen-Anchors)
13
+ // löschen — gekeyt per accountId, deshalb aus den Account-Rows
14
+ // VOR dem Delete eingesammelt.
15
+ //
16
+ // Die eigentliche PII-Erasure macht crypto-shredding: eraseSubjectKeys
17
+ // löscht den Tenant-Subject-Key, damit werden Event-Log-Payloads UND
18
+ // etwaige Ciphertext-Kopien unlesbar. Diese Hooks entsorgen die Rows.
19
+
20
+ import {
21
+ type DbRunner,
22
+ deleteMany,
23
+ type EntityTableMeta,
24
+ selectMany,
25
+ } from "@cosmicdrift/kumiko-framework/db";
26
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
27
+ import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
28
+ import { seenMessageTable, syncCursorTable } from "./entities";
29
+ import {
30
+ INBOUND_MESSAGE_AGGREGATE_TYPE,
31
+ MAIL_ACCOUNT_AGGREGATE_TYPE,
32
+ MAIL_THREAD_AGGREGATE_TYPE,
33
+ } from "./events";
34
+ import {
35
+ inboundMessagesProjectionTable,
36
+ mailAccountsProjectionTable,
37
+ mailThreadsProjectionTable,
38
+ } from "./projection";
39
+
40
+ const ARCHIVED_BY = "tenant-lifecycle:destroy";
41
+ const REASON = "tenant_destroy";
42
+
43
+ type DestroyCtx = {
44
+ readonly db: DbRunner;
45
+ readonly tenantId: TenantId;
46
+ };
47
+
48
+ async function archiveAndDeleteRows(
49
+ ctx: DestroyCtx,
50
+ table: EntityTableMeta,
51
+ aggregateType: string,
52
+ ): Promise<readonly string[]> {
53
+ const rows = await selectMany<{ id: string }>(ctx.db, table, { tenantId: ctx.tenantId });
54
+ for (const row of rows) {
55
+ await archiveStream(ctx.db, {
56
+ tenantId: ctx.tenantId,
57
+ aggregateId: row.id,
58
+ aggregateType,
59
+ archivedBy: ARCHIVED_BY,
60
+ reason: REASON,
61
+ });
62
+ }
63
+ await deleteMany(ctx.db, table, { tenantId: ctx.tenantId });
64
+ return rows.map((row) => row.id);
65
+ }
66
+
67
+ export async function mailAccountTenantDestroyHook(ctx: DestroyCtx): Promise<void> {
68
+ const accountIds = await archiveAndDeleteRows(
69
+ ctx,
70
+ mailAccountsProjectionTable as EntityTableMeta,
71
+ MAIL_ACCOUNT_AGGREGATE_TYPE,
72
+ );
73
+ for (const accountId of accountIds) {
74
+ await deleteMany(ctx.db, syncCursorTable as EntityTableMeta, { accountId });
75
+ await deleteMany(ctx.db, seenMessageTable as EntityTableMeta, { accountId });
76
+ }
77
+ }
78
+
79
+ export async function inboundMessageTenantDestroyHook(ctx: DestroyCtx): Promise<void> {
80
+ await archiveAndDeleteRows(
81
+ ctx,
82
+ inboundMessagesProjectionTable as EntityTableMeta,
83
+ INBOUND_MESSAGE_AGGREGATE_TYPE,
84
+ );
85
+ }
86
+
87
+ export async function mailThreadTenantDestroyHook(ctx: DestroyCtx): Promise<void> {
88
+ await archiveAndDeleteRows(
89
+ ctx,
90
+ mailThreadsProjectionTable as EntityTableMeta,
91
+ MAIL_THREAD_AGGREGATE_TYPE,
92
+ );
93
+ }
@@ -0,0 +1,250 @@
1
+ // Provider-Contract für inbound-mail-Provider-Plugins (Plan §2).
2
+ //
3
+ // Gespiegelt von mail-foundation's MailTransportContext/-Plugin —
4
+ // gleiche Trennung Foundation ↔ Provider, gleicher Slim-Context.
5
+ //
6
+ // **Warum kein voller HandlerContext?** fetch()/watch() laufen im
7
+ // Worker-Pfad (Poll-Cron, IDLE-Supervisor) — dort gibt es keinen
8
+ // per-request `tx`/`actor`. Ein Plugin das mehr liest würde den
9
+ // Worker-Pfad zur Runtime brechen, und das fiele nur in production
10
+ // auf. Plugin das mehr braucht: InboundMailContext explizit erweitern
11
+ // (sichtbarer breaking change) statt ctx-cast.
12
+
13
+ import type { ConfigAccessor, Registry } from "@cosmicdrift/kumiko-framework/engine";
14
+ import type { InboundMailAccountStatus } from "./constants";
15
+
16
+ /**
17
+ * Slim-Context für Provider-Plugins.
18
+ *
19
+ * **Felder:**
20
+ * config — tenant-config-reads (host/port/... der Plugins)
21
+ * registry — extension-Lookup in der Factory (nicht plugin-intern)
22
+ * secrets — tenant-secret-reads (IMAP-Passwort, OAuth-Refresh-Token)
23
+ * _userId — Audit-Identity für secret-reads. Handler-Pfad: dispatcher
24
+ * setzt Caller-User-ID; Worker-Pfad: System-Identity.
25
+ */
26
+ export type InboundMailContext = {
27
+ readonly config?: ConfigAccessor;
28
+ readonly registry?: Registry;
29
+ readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
30
+ readonly _userId?: string | undefined;
31
+ };
32
+
33
+ /**
34
+ * Plaintext-Sicht auf ein verbundenes Postfach, wie Provider sie sehen.
35
+ * Die Foundation decryptet `address` VOR dem Provider-Call — Provider
36
+ * arbeiten nie mit `kumiko-pii:`-Ciphertext (Guard analog
37
+ * withPiiCiphertextGuard im Transport-Pfad).
38
+ */
39
+ export type MailAccountRecord = {
40
+ /** = aggregateId des mail-account-Streams; zugleich Secret-Slot-Key. */
41
+ readonly id: string;
42
+ readonly tenantId: string;
43
+ /** Provider-Key wie bei der Extension registriert ("imap", "m365-graph",
44
+ * "gmail-rest"). */
45
+ readonly provider: string;
46
+ /** "password" | "xoauth2" | "oauth" — imap unterscheidet hierüber
47
+ * Formular-Connect vs. XOAUTH2-Fallback (Plan §2). */
48
+ readonly authMethod: string;
49
+ /** null = tenant-geteiltes Postfach, sonst persönliches Postfach
50
+ * dieses Users (Sichtbarkeits-Scope, Plan Entscheidung 2). */
51
+ readonly ownerUserId: string | null;
52
+ /** Postfach-Adresse, Plaintext (decrypted). */
53
+ readonly address: string;
54
+ readonly displayName: string;
55
+ readonly status: InboundMailAccountStatus;
56
+ readonly watchState: string;
57
+ };
58
+
59
+ /**
60
+ * Normalisierte eingehende Mail — Provider-Output, Ingest-Input.
61
+ * Provider-raw-Strukturen (IMAP-Flags, Graph-Objekte) bleiben im
62
+ * Provider; hier nur der domain-clean Schnitt.
63
+ */
64
+ export type RawInboundMessage = {
65
+ /** Provider-native Message-ID (IMAP UID, Graph id, Gmail id) —
66
+ * Idempotency-Anchor zusammen mit providerName. */
67
+ readonly providerMessageId: string;
68
+ /** RFC-5322 Message-ID-Header ohne <>; null wenn der Header fehlt
69
+ * (Foundation fällt auf deterministischen Ersatz zurück). */
70
+ readonly messageIdHeader: string | null;
71
+ /** Provider-native Thread-/Conversation-ID falls vorhanden (Graph
72
+ * conversationId, Gmail threadId); Foundation bevorzugt sie vor der
73
+ * References-Chain beim threadKey-Bau. */
74
+ readonly providerThreadId: string | null;
75
+ /** References/In-Reply-To-Chain (Message-IDs ohne <>), älteste zuerst. */
76
+ readonly references: readonly string[];
77
+ readonly from: string;
78
+ readonly to: readonly string[];
79
+ readonly cc: readonly string[];
80
+ readonly subject: string;
81
+ /** Plaintext-Vorschau, provider-generiert oder aus dem Body geschnitten. */
82
+ readonly snippet: string;
83
+ /** ISO-Instant — INTERNALDATE bzw. receivedDateTime, nicht Ingest-Zeit. */
84
+ readonly receivedAtIso: string;
85
+ /** Raw MIME; null im snippet-only-Mode. Foundation persisted ihn nach
86
+ * file-foundation und schreibt bodyRef — der Event-Store bleibt
87
+ * blob-frei. */
88
+ readonly rawMime: Uint8Array | null;
89
+ /** Generischer Scope-Hint ("inbox", Folder-Name) — keine App-Semantik. */
90
+ readonly scope: string;
91
+ };
92
+
93
+ /** Provider-opaker Cursor: IMAP {uidValidity,lastUid} · Graph {deltaLink}
94
+ * · Gmail {historyId}. Foundation persisted ihn JSON-stringified in
95
+ * read_mail_sync_cursors, interpretiert ihn nie. */
96
+ export type SyncCursorPayload = Readonly<Record<string, unknown>>;
97
+
98
+ export type InboundFetchResult = {
99
+ readonly messages: readonly RawInboundMessage[];
100
+ readonly nextCursor: SyncCursorPayload;
101
+ /** true ⇒ Foundation ruft fetch im selben Poll-Tick erneut auf
102
+ * (Pagination), bis false oder maxMessages-Budget erschöpft. */
103
+ readonly hasMore: boolean;
104
+ };
105
+
106
+ export type OAuthTokenSet = {
107
+ readonly accessToken: string;
108
+ /** ISO-Instant. TTL typisch ~1h ⇒ Foundation refresht before-poll. */
109
+ readonly expiresAt: string;
110
+ readonly refreshToken?: string;
111
+ readonly scopesGranted: readonly string[];
112
+ };
113
+
114
+ /**
115
+ * OAuth-Teil des Contracts — nur OAuth-Provider; imap (Passwort-Modus)
116
+ * lässt `oauth` weg. Die Routen (`/inbound-mail/connect`,
117
+ * `/inbound-mail/oauth/callback`) stellt die Foundation generisch —
118
+ * Provider registrieren KEINE eigenen Routen.
119
+ */
120
+ export type InboundOAuthFlow = {
121
+ /** Scopes: Empfang UND Send in EINEM Consent — Re-Consent ist teuer. */
122
+ readonly scopes: {
123
+ readonly receive: readonly string[];
124
+ readonly send?: readonly string[];
125
+ };
126
+ readonly buildAuthorizeUrl: (
127
+ ctx: InboundMailContext,
128
+ p: { readonly state: string; readonly redirectUri: string },
129
+ ) => Promise<string>;
130
+ readonly exchangeCode: (
131
+ ctx: InboundMailContext,
132
+ p: { readonly code: string; readonly redirectUri: string },
133
+ ) => Promise<OAuthTokenSet>;
134
+ readonly refreshAccessToken: (
135
+ ctx: InboundMailContext,
136
+ account: MailAccountRecord,
137
+ refreshToken: string,
138
+ ) => Promise<OAuthTokenSet>;
139
+ };
140
+
141
+ /**
142
+ * Inbound-Mail-Provider-Plugin contract. Jedes Provider-Feature
143
+ * (inbound-provider-imap, -m365-graph, -gmail-rest) registriert eine
144
+ * Implementation via `r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION,
145
+ * "<key>", plugin)`.
146
+ */
147
+ export type InboundMailProviderPlugin = {
148
+ /** Credentials-/Erreichbarkeits-Check beim Connect-Flow — wirft
149
+ * typisiert (InboundAuthError etc.), kein Rückgabewert. */
150
+ readonly verify: (ctx: InboundMailContext, account: MailAccountRecord) => Promise<void>;
151
+ /** Pull seit cursor. `cursor === null` ⇒ Erst-Befüllung, begrenzt
152
+ * durch backfillWindowDays. */
153
+ readonly fetch: (
154
+ ctx: InboundMailContext,
155
+ account: MailAccountRecord,
156
+ cursor: SyncCursorPayload | null,
157
+ opts: { readonly backfillWindowDays: number; readonly maxMessages: number },
158
+ ) => Promise<InboundFetchResult>;
159
+ /** Nur OAuth-Provider. */
160
+ readonly oauth?: InboundOAuthFlow;
161
+ /** Optional: Live-Push (IMAP IDLE, später Graph/Gmail-Webhooks).
162
+ * Liefert stop(). Poll bleibt Reconciliation — watch ist Latenz-
163
+ * Optimierung, nie Korrektheits-Anker. `onError` meldet den Tod der
164
+ * Live-Verbindung (Drop, Auth-Revoke) an den Supervisor, der mit
165
+ * Backoff neu startet — der Provider reconnected NICHT selbst. */
166
+ readonly watch?: (
167
+ ctx: InboundMailContext,
168
+ account: MailAccountRecord,
169
+ handlers: {
170
+ readonly onMessages: (msgs: readonly RawInboundMessage[]) => Promise<void>;
171
+ readonly onError: (err: unknown) => void;
172
+ },
173
+ ) => Promise<() => Promise<void>>;
174
+ };
175
+
176
+ // extension-usage `options` ist engine-payload (unknown) — strukturell
177
+ // validieren statt blind casten (Muster isMailTransportPlugin).
178
+ export function isInboundMailProviderPlugin(o: unknown): o is InboundMailProviderPlugin {
179
+ return (
180
+ typeof o === "object" &&
181
+ o !== null &&
182
+ "verify" in o &&
183
+ typeof (o as { verify: unknown }).verify === "function" &&
184
+ "fetch" in o &&
185
+ typeof (o as { fetch: unknown }).fetch === "function"
186
+ );
187
+ }
188
+
189
+ // =============================================================================
190
+ // Typisierte Fehlerklassen — Foundation reagiert einheitlich im Poll
191
+ // (Plan §2, Fehler-Tabelle):
192
+ // InboundAuthError → status=auth_error, kein Retry
193
+ // InboundRateLimitError → Poll-Abbruch, nächster Cron-Tick
194
+ // InboundCursorInvalidError → Cursor-Reset + Voll-Resync im Backfill-
195
+ // Fenster; Dedup fängt Dubletten
196
+ // InboundTransientError → Job-Retry (retries: 3, exponential)
197
+ // =============================================================================
198
+ //
199
+ // `kind`-Discriminant statt instanceof: Plugin und Foundation können in
200
+ // getrennten Bundles leben (dual-package-hazard), instanceof über
201
+ // Realm-/Kopie-Grenzen ist unzuverlässig.
202
+
203
+ export class InboundAuthError extends Error {
204
+ readonly kind = "inbound-auth-error" as const;
205
+ constructor(message: string) {
206
+ super(message);
207
+ this.name = "InboundAuthError";
208
+ }
209
+ }
210
+
211
+ export class InboundRateLimitError extends Error {
212
+ readonly kind = "inbound-rate-limit-error" as const;
213
+ readonly retryAfterMs: number;
214
+ constructor(message: string, retryAfterMs: number) {
215
+ super(message);
216
+ this.name = "InboundRateLimitError";
217
+ this.retryAfterMs = retryAfterMs;
218
+ }
219
+ }
220
+
221
+ export class InboundCursorInvalidError extends Error {
222
+ readonly kind = "inbound-cursor-invalid-error" as const;
223
+ constructor(message: string) {
224
+ super(message);
225
+ this.name = "InboundCursorInvalidError";
226
+ }
227
+ }
228
+
229
+ export class InboundTransientError extends Error {
230
+ readonly kind = "inbound-transient-error" as const;
231
+ constructor(message: string) {
232
+ super(message);
233
+ this.name = "InboundTransientError";
234
+ }
235
+ }
236
+
237
+ type KindCarrier = { readonly kind?: unknown };
238
+
239
+ export function isInboundAuthError(e: unknown): e is InboundAuthError {
240
+ return e instanceof Error && (e as KindCarrier).kind === "inbound-auth-error";
241
+ }
242
+ export function isInboundRateLimitError(e: unknown): e is InboundRateLimitError {
243
+ return e instanceof Error && (e as KindCarrier).kind === "inbound-rate-limit-error";
244
+ }
245
+ export function isInboundCursorInvalidError(e: unknown): e is InboundCursorInvalidError {
246
+ return e instanceof Error && (e as KindCarrier).kind === "inbound-cursor-invalid-error";
247
+ }
248
+ export function isInboundTransientError(e: unknown): e is InboundTransientError {
249
+ return e instanceof Error && (e as KindCarrier).kind === "inbound-transient-error";
250
+ }