@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,185 @@
1
+ // kumiko-feature-version: 1
2
+ //
3
+ // inbound-mail-foundation — Plugin-Host für eingehende E-Mail
4
+ // (Inbox-Föderation). Gegenstück zu mail-foundation (outbound): getrennte
5
+ // Extension-Points, getrennter State — Architektur-Vorbild ist
6
+ // file-foundation (Foundation besitzt State), nicht mail-transport.
7
+ //
8
+ // **Multi-Provider von Tag 1** (wie billing-foundation): KEIN globaler
9
+ // `provider`-config-key. Ein Tenant verbindet parallel IMAP- und (später)
10
+ // M365-/Gmail-Postfächer; der Provider steht pro MailAccount.
11
+ //
12
+ // **Was diese Foundation liefert:**
13
+ // 1. **Plugin-API** für Inbound-Provider via r.extendsRegistrar
14
+ // ("inboundMailProvider"): verify/fetch/oauth?/watch? (types.ts).
15
+ // 2. **3 event-sourced Streams** — mail-account (Lifecycle),
16
+ // inbound-message (genau EIN received-Event pro Mail, deterministic
17
+ // aggregateId ⇒ exactly-once trotz at-least-once-Ingest),
18
+ // mail-thread (Rollup). Volle Historie replay-fähig für spätere
19
+ // Consumer (Business-Prozesse: Ticket, Beleg, Auto-Antwort).
20
+ // 3. **Inline-Projections** auf read_mail_accounts /
21
+ // read_inbound_messages / read_mail_threads (read-your-own-write).
22
+ // 4. **Write-/Query-Handler**: connect/update/disconnect-account,
23
+ // ingest-message (programmatic, SystemAdmin), account:list +
24
+ // message:list (PII-decrypted, scope-gefiltert).
25
+ // 5. **createInboundMailConnectRoutes**: OAuth-Connect + anonymer
26
+ // Callback (außerhalb /api/*), state HMAC-signiert.
27
+ // 6. **createInboundMailSupervisor**: app-verdrahteter Watch-(IDLE)-
28
+ // Supervisor mit Backoff + Reconciliation-Poll.
29
+ //
30
+ // **Was diese Foundation NICHT macht:**
31
+ // - Keine Business-Semantik (Intent, Ticket, Zuweisung, gelesen/
32
+ // erledigt) — das gehört dem App-Consumer (z.B. via Event-Job auf
33
+ // inbound-message-received + eigener Projection).
34
+ // - Kein Body-Blob im Event-Store: rawMime → file-foundation via
35
+ // Supervisor-storeBody-Hook, Event trägt nur bodyRef.
36
+ // - Kein IMAP-Flag-Write-back (V1) — Inbox-Status lebt beim Consumer.
37
+ //
38
+ // **PII/DSGVO:** address/from/to/cc/subject/snippet sind tenantOwned —
39
+ // encrypted VOR jedem Event-Append (einziger Write-Pfad), Event-Log UND
40
+ // Projections tragen Ciphertext; tenant-destroy shreddet den Subject-Key
41
+ // (crypto-shredding, Muster billing-foundation #800). Der Destroy-Hook
42
+ // archiviert zusätzlich alle Streams + löscht die Rows.
43
+
44
+ import { defineFeature, EXT_TENANT_DATA } from "@cosmicdrift/kumiko-framework/engine";
45
+ import { INBOUND_MAIL_FOUNDATION_FEATURE, INBOUND_MAIL_PROVIDER_EXTENSION } from "./constants";
46
+ import {
47
+ inboundMessageEntity,
48
+ mailAccountEntity,
49
+ mailThreadEntity,
50
+ seenMessageTable,
51
+ syncCursorTable,
52
+ } from "./entities";
53
+ import {
54
+ INBOUND_MESSAGE_AGGREGATE_TYPE,
55
+ INBOUND_MESSAGE_RECEIVED_EVENT_QN,
56
+ INBOUND_MESSAGE_RECEIVED_EVENT_SHORT,
57
+ inboundMessageEventPayloadSchema,
58
+ MAIL_ACCOUNT_AGGREGATE_TYPE,
59
+ MAIL_ACCOUNT_CONNECTED_EVENT_QN,
60
+ MAIL_ACCOUNT_CONNECTED_EVENT_SHORT,
61
+ MAIL_ACCOUNT_DISCONNECTED_EVENT_QN,
62
+ MAIL_ACCOUNT_DISCONNECTED_EVENT_SHORT,
63
+ MAIL_ACCOUNT_UPDATED_EVENT_QN,
64
+ MAIL_ACCOUNT_UPDATED_EVENT_SHORT,
65
+ MAIL_THREAD_AGGREGATE_TYPE,
66
+ MAIL_THREAD_UPDATED_EVENT_QN,
67
+ MAIL_THREAD_UPDATED_EVENT_SHORT,
68
+ mailAccountEventPayloadSchema,
69
+ mailThreadEventPayloadSchema,
70
+ } from "./events";
71
+ import { connectAccountHandler } from "./handlers/connect-account.write";
72
+ import { disconnectAccountHandler } from "./handlers/disconnect-account.write";
73
+ import { ingestMessageHandler } from "./handlers/ingest-message.write";
74
+ import { listAccountsQuery } from "./handlers/list-accounts.query";
75
+ import { listMessagesQuery } from "./handlers/list-messages.query";
76
+ import { updateAccountHandler } from "./handlers/update-account.write";
77
+ import {
78
+ applyInboundMessageReceived,
79
+ applyMailAccountConnected,
80
+ applyMailAccountDisconnected,
81
+ applyMailAccountUpdated,
82
+ applyMailThreadUpdated,
83
+ inboundMessagesProjectionTable,
84
+ mailAccountsProjectionTable,
85
+ mailThreadsProjectionTable,
86
+ } from "./projection";
87
+ import {
88
+ inboundMessageTenantDestroyHook,
89
+ mailAccountTenantDestroyHook,
90
+ mailThreadTenantDestroyHook,
91
+ } from "./tenant-destroy-hook";
92
+
93
+ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATION_FEATURE, (r) => {
94
+ r.describe(
95
+ "Plugin host for inbound e-mail (inbox federation) — provider plugins (inbound-provider-imap, later M365 Graph / Gmail REST) register at the `inboundMailProvider` extension point with verify/fetch and optional oauth/watch (live push). The foundation owns three event-sourced streams (mail-account lifecycle, inbound-message with exactly-once ingest via deterministic aggregate ids, mail-thread rollup) and their projections `read_mail_accounts`/`read_inbound_messages`/`read_mail_threads`; PII fields are envelope-encrypted per tenant subject key before every append (crypto-shredding on tenant destroy). Wire `createInboundMailConnectRoutes` (OAuth connect + anonymous callback outside /api) and `createInboundMailSupervisor` (IDLE watch with backoff + reconciliation poll) in bin/server.ts. Consume `inbound-mail-foundation:event:inbound-message-received` from app features to attach business processes.",
96
+ );
97
+ r.uiHints({
98
+ displayLabel: "Inbound Mail · Foundation",
99
+ category: "notifications",
100
+ recommended: false,
101
+ });
102
+ // EXT_TENANT_DATA-Hook braucht den tenant-lifecycle-Host.
103
+ r.requires("tenant-lifecycle");
104
+
105
+ // 5 fine-grained domain-events (Payload-Schemas: events.ts).
106
+ r.defineEvent(MAIL_ACCOUNT_CONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema);
107
+ r.defineEvent(MAIL_ACCOUNT_UPDATED_EVENT_SHORT, mailAccountEventPayloadSchema);
108
+ r.defineEvent(MAIL_ACCOUNT_DISCONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema);
109
+ r.defineEvent(INBOUND_MESSAGE_RECEIVED_EVENT_SHORT, inboundMessageEventPayloadSchema);
110
+ r.defineEvent(MAIL_THREAD_UPDATED_EVENT_SHORT, mailThreadEventPayloadSchema);
111
+
112
+ // Inline projections — apply in derselben TX wie der Append.
113
+ r.projection({
114
+ name: "mail-account",
115
+ source: MAIL_ACCOUNT_AGGREGATE_TYPE,
116
+ table: mailAccountsProjectionTable,
117
+ entity: mailAccountEntity,
118
+ apply: {
119
+ [MAIL_ACCOUNT_CONNECTED_EVENT_QN]: applyMailAccountConnected,
120
+ [MAIL_ACCOUNT_UPDATED_EVENT_QN]: applyMailAccountUpdated,
121
+ [MAIL_ACCOUNT_DISCONNECTED_EVENT_QN]: applyMailAccountDisconnected,
122
+ },
123
+ });
124
+ r.projection({
125
+ name: "inbound-message",
126
+ source: INBOUND_MESSAGE_AGGREGATE_TYPE,
127
+ table: inboundMessagesProjectionTable,
128
+ entity: inboundMessageEntity,
129
+ apply: {
130
+ [INBOUND_MESSAGE_RECEIVED_EVENT_QN]: applyInboundMessageReceived,
131
+ },
132
+ });
133
+ r.projection({
134
+ name: "mail-thread",
135
+ source: MAIL_THREAD_AGGREGATE_TYPE,
136
+ table: mailThreadsProjectionTable,
137
+ entity: mailThreadEntity,
138
+ apply: {
139
+ [MAIL_THREAD_UPDATED_EVENT_QN]: applyMailThreadUpdated,
140
+ },
141
+ });
142
+
143
+ // Plugin extension-point — Provider registrieren sich via
144
+ // r.useExtension("inboundMailProvider", "<key>", plugin).
145
+ r.extendsRegistrar(INBOUND_MAIL_PROVIDER_EXTENSION, {
146
+ onRegister: () => {
147
+ // No side-effects at register-time — Lookup läuft zur Laufzeit
148
+ // über resolveInboundProviderForKey/-Account.
149
+ },
150
+ });
151
+
152
+ // Sync-Maschinerie: hochfrequenter Tick-State, bewusst NICHT
153
+ // event-sourced — r.unmanagedTable hält die Migration-DDL, nimmt die
154
+ // Tabellen aber aus dem Projection-Rebuild (#494/#498-Klasse).
155
+ r.unmanagedTable(syncCursorTable, {
156
+ reason: "read_side.mail_sync_cursors_direct_write",
157
+ });
158
+ r.unmanagedTable(seenMessageTable, {
159
+ reason: "read_side.mail_seen_messages_direct_write",
160
+ });
161
+
162
+ // Write-Handler:
163
+ // - ingest-message: programmatic (Supervisor/Poll, SystemAdmin)
164
+ // - connect/update/disconnect-account: Lifecycle
165
+ r.writeHandler(ingestMessageHandler);
166
+ r.writeHandler(connectAccountHandler);
167
+ r.writeHandler(updateAccountHandler);
168
+ r.writeHandler(disconnectAccountHandler);
169
+
170
+ // List-Queries (PII-decrypted, scope-gefiltert).
171
+ r.queryHandler(listAccountsQuery);
172
+ r.queryHandler(listMessagesQuery);
173
+
174
+ // Entity-genaue GDPR-Destroy-Hooks — der gdpr-storage-Boot-Validator
175
+ // prueft die EXT_TENANT_DATA-Registrierung pro tenant-subject-Entity.
176
+ r.useExtension(EXT_TENANT_DATA, "mail-account", {
177
+ destroy: mailAccountTenantDestroyHook,
178
+ });
179
+ r.useExtension(EXT_TENANT_DATA, "inbound-message", {
180
+ destroy: inboundMessageTenantDestroyHook,
181
+ });
182
+ r.useExtension(EXT_TENANT_DATA, "mail-thread", {
183
+ destroy: mailThreadTenantDestroyHook,
184
+ });
185
+ });
@@ -0,0 +1,23 @@
1
+ // Geteilter State-Loader für update-account / disconnect-account.
2
+ //
3
+ // Die mail-account-Events sind full-snapshots (Muster billing-foundation:
4
+ // "Alle nutzen denselben payload-shape, der event-type taggt was
5
+ // passiert ist") — ein Übergangs-Handler lädt den letzten Snapshot,
6
+ // merged seine Delta-Felder rein und appendet den neuen Snapshot.
7
+ //
8
+ // **PII-Disziplin:** `address` steht im geladenen Payload bereits als
9
+ // Ciphertext und wird 1:1 durchkopiert — NIE re-encrypten (würde bei
10
+ // fehlendem KMS decrypt erzwingen bzw. Double-Encryption produzieren).
11
+
12
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
13
+ import type { MailAccountEventPayload } from "../events";
14
+
15
+ export async function loadCurrentMailAccountPayload(
16
+ ctx: Pick<HandlerContext, "loadAggregate">,
17
+ accountId: string,
18
+ ): Promise<MailAccountEventPayload | undefined> {
19
+ const events = await ctx.loadAggregate(accountId);
20
+ const last = events[events.length - 1];
21
+ // @cast-boundary engine-payload — eigene events, shape via defineEvent
22
+ return last?.payload as MailAccountEventPayload | undefined;
23
+ }
@@ -0,0 +1,112 @@
1
+ // connect-account — Tenant-Admin verbindet ein Postfach. Legt den
2
+ // mail-account-Stream an (aggregateId = random uuid = accountId =
3
+ // Secret-Slot-Key für IMAP-Passwort/OAuth-Refresh-Token).
4
+ //
5
+ // **Was hier NICHT passiert:**
6
+ // - Kein Secret-Write: IMAP-Credentials laufen über den secrets-Pfad,
7
+ // OAuth-Tokens über die generischen /inbound-mail/oauth-Routen —
8
+ // beides VOR bzw. NACH diesem Call, nie durch den Handler-Payload
9
+ // (Plain-Passwörter gehören nicht in einen Write-Payload, der im
10
+ // Dispatcher-Log landen kann).
11
+ // - Kein Verbindungs-Test: der Watch-Supervisor probiert den ersten
12
+ // fetch und schaltet bei Fehler via update-account auf auth_error —
13
+ // der Connect bleibt schnell und offline-fähig (Test-Ergebnis kommt
14
+ // asynchron als Status-Übergang).
15
+
16
+ import {
17
+ configuredPiiSubjectKms,
18
+ encryptPiiFieldValues,
19
+ } from "@cosmicdrift/kumiko-framework/crypto";
20
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
21
+ import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
22
+ import { z } from "zod";
23
+ import { InboundMailAccountStatuses, InboundMailAuthMethods } from "../constants";
24
+ import { MAIL_ACCOUNT_PII_FIELDS, mailAccountEntity } from "../entities";
25
+ import {
26
+ MAIL_ACCOUNT_AGGREGATE_TYPE,
27
+ MAIL_ACCOUNT_CONNECTED_EVENT_QN,
28
+ type MailAccountEventHeaders,
29
+ type MailAccountEventPayload,
30
+ } from "../events";
31
+
32
+ export const connectAccountSchema = z.object({
33
+ /** Provider-Key wie an der Extension registriert ("imap",
34
+ * "m365-graph", "gmail-rest"). */
35
+ provider: z.string().min(1).max(50),
36
+ authMethod: z.enum([
37
+ InboundMailAuthMethods.password,
38
+ InboundMailAuthMethods.xoauth2,
39
+ InboundMailAuthMethods.oauth,
40
+ ]),
41
+ displayName: z.string().max(200),
42
+ /** Postfach-Adresse, Plaintext — wird hier encrypted. */
43
+ address: z.string().min(1).max(320),
44
+ /** "shared" = tenant-geteilt (info@) · "user" = persönliches Postfach
45
+ * des Callers (ownerUserId wird aus der Session gezogen, nie aus dem
46
+ * Payload — kein Fremd-Claiming). */
47
+ scope: z.enum(["shared", "user"]),
48
+ /** NUR für den programmatic OAuth-Callback-Pfad (SystemAdmin): dort
49
+ * ist der Dispatcher-User ein SystemUser, der echte Owner steht im
50
+ * HMAC-verifizierten state. Tenant-Caller dürfen das Feld nicht
51
+ * setzen (Fremd-Claiming-Sperre im Handler). */
52
+ ownerUserIdOverride: z.string().max(36).nullable().optional(),
53
+ });
54
+ type ConnectAccountPayload = z.infer<typeof connectAccountSchema>;
55
+
56
+ export const connectAccountHandler: WriteHandlerDef = {
57
+ name: "connect-account",
58
+ schema: connectAccountSchema,
59
+ access: { roles: ["SystemAdmin", "TenantAdmin"] },
60
+ handler: async (event, ctx) => {
61
+ // @cast-boundary engine-payload — dispatcher-zod-validated payload
62
+ const payload = event.payload as ConnectAccountPayload;
63
+ const tenantId = event.user.tenantId;
64
+ const accountId = crypto.randomUUID();
65
+
66
+ if (payload.ownerUserIdOverride !== undefined && !event.user.roles.includes("SystemAdmin")) {
67
+ return failUnprocessable("owner_override_requires_system_admin");
68
+ }
69
+ const ownerUserId =
70
+ payload.ownerUserIdOverride !== undefined
71
+ ? payload.ownerUserIdOverride
72
+ : payload.scope === "user"
73
+ ? event.user.id
74
+ : null;
75
+
76
+ // PII-Encrypt der Adresse vor dem append — einziger Write-Pfad auf
77
+ // den Stream, deckt Event-Log UND Projection ab (#800-Muster).
78
+ const piiKms = configuredPiiSubjectKms();
79
+ const plainPii = { tenantId, address: payload.address };
80
+ const encryptedFields = piiKms
81
+ ? await encryptPiiFieldValues(plainPii, mailAccountEntity, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
82
+ requestId: `inbound-mail-foundation:connect-account:${accountId}`,
83
+ tenantId,
84
+ })
85
+ : plainPii;
86
+
87
+ const eventPayload: MailAccountEventPayload = {
88
+ provider: payload.provider,
89
+ authMethod: payload.authMethod,
90
+ ownerUserId,
91
+ displayName: payload.displayName,
92
+ address: encryptedFields["address"] as string,
93
+ // active ab Connect — der Supervisor greift den Account beim
94
+ // nächsten Tick auf und degradiert ihn bei Credential-Fehlern.
95
+ status: InboundMailAccountStatuses.active,
96
+ watchState: "idle",
97
+ };
98
+ const headers: MailAccountEventHeaders = {
99
+ providerName: payload.provider,
100
+ reason: "connect_flow",
101
+ };
102
+ await ctx.unsafeAppendEvent({
103
+ aggregateId: accountId,
104
+ aggregateType: MAIL_ACCOUNT_AGGREGATE_TYPE,
105
+ type: MAIL_ACCOUNT_CONNECTED_EVENT_QN,
106
+ payload: eventPayload,
107
+ headers,
108
+ });
109
+
110
+ return { isSuccess: true as const, data: { accountId } };
111
+ },
112
+ };
@@ -0,0 +1,69 @@
1
+ // disconnect-account — Tenant-Admin trennt ein Postfach. Stream bleibt
2
+ // (Audit-Historie), Status wird disconnected, der Watch-Supervisor
3
+ // überspringt den Account ab dem nächsten Tick. Secret-Cleanup (IMAP-
4
+ // Passwort/Refresh-Token im Slot accountId) macht der Aufrufer-Flow —
5
+ // der Handler fasst den secrets-Pfad nicht an.
6
+ //
7
+ // Idempotent: disconnect auf bereits disconnected Account ist ein
8
+ // success-no-op (kein zweites Event) — Doppelklick-/Retry-freundlich.
9
+
10
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
11
+ import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
12
+ import { z } from "zod";
13
+ import { InboundMailAccountStatuses } from "../constants";
14
+ import {
15
+ MAIL_ACCOUNT_AGGREGATE_TYPE,
16
+ MAIL_ACCOUNT_DISCONNECTED_EVENT_QN,
17
+ type MailAccountEventHeaders,
18
+ type MailAccountEventPayload,
19
+ } from "../events";
20
+ import { loadCurrentMailAccountPayload } from "./account-state";
21
+
22
+ export const disconnectAccountSchema = z.object({
23
+ accountId: z.uuid(),
24
+ reason: z.string().min(1).max(100).default("tenant_admin"),
25
+ });
26
+ type DisconnectAccountPayload = z.infer<typeof disconnectAccountSchema>;
27
+
28
+ export const disconnectAccountHandler: WriteHandlerDef = {
29
+ name: "disconnect-account",
30
+ schema: disconnectAccountSchema,
31
+ access: { roles: ["SystemAdmin", "TenantAdmin"] },
32
+ handler: async (event, ctx) => {
33
+ // @cast-boundary engine-payload — dispatcher-zod-validated payload
34
+ const payload = event.payload as DisconnectAccountPayload;
35
+
36
+ const current = await loadCurrentMailAccountPayload(ctx, payload.accountId);
37
+ if (!current) {
38
+ return failNotFound("mail-account", payload.accountId);
39
+ }
40
+ if (current.status === InboundMailAccountStatuses.disconnected) {
41
+ return {
42
+ isSuccess: true as const,
43
+ data: { accountId: payload.accountId, alreadyDisconnected: true as const },
44
+ };
45
+ }
46
+
47
+ const eventPayload: MailAccountEventPayload = {
48
+ ...current,
49
+ status: InboundMailAccountStatuses.disconnected,
50
+ watchState: "idle",
51
+ };
52
+ const headers: MailAccountEventHeaders = {
53
+ providerName: current.provider,
54
+ reason: payload.reason,
55
+ };
56
+ await ctx.unsafeAppendEvent({
57
+ aggregateId: payload.accountId,
58
+ aggregateType: MAIL_ACCOUNT_AGGREGATE_TYPE,
59
+ type: MAIL_ACCOUNT_DISCONNECTED_EVENT_QN,
60
+ payload: eventPayload,
61
+ headers,
62
+ });
63
+
64
+ return {
65
+ isSuccess: true as const,
66
+ data: { accountId: payload.accountId, alreadyDisconnected: false as const },
67
+ };
68
+ },
69
+ };
@@ -0,0 +1,279 @@
1
+ // ingest-message — programmatic entry-point für Provider-Plugins
2
+ // (watch-callback + fetchSince-Sync). Nimmt eine normalisierte
3
+ // RawInboundMessage und macht atomic in EINER TX:
4
+ //
5
+ // 1. Idempotency-Check (cheap: read_mail_seen_messages; Defense-in-
6
+ // depth: Stream-Existenz auf der deterministic aggregateId).
7
+ // 2. threadKey-Normalisierung (Provider-Thread-ID bevorzugt, sonst
8
+ // References-Chain-Root, sonst Message-ID = Single-Message-Thread).
9
+ // 3. PII-Encrypt (from/to/cc/subject/snippet) VOR dem append —
10
+ // Event-Log UND Projection tragen Ciphertext (#800-Muster).
11
+ // 4. inbound-message-received-append (Inline-Projection schreibt
12
+ // read_inbound_messages in derselben TX).
13
+ // 5. Thread-Rollup: mail-thread-updated-append mit neu berechnetem
14
+ // Snapshot (count+1, max(lastMessageAt)) — die apply ist ein
15
+ // dummer UPSERT.
16
+ // 6. seen-message-Dedup-Anchor einfügen.
17
+ //
18
+ // SystemAdmin-only: Aufrufer ist der Watch/Sync-Supervisor bzw. der
19
+ // Poll-Cron mit programmatic SystemUser — nie ein Tenant-Request.
20
+
21
+ import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
22
+ import {
23
+ configuredPiiSubjectKms,
24
+ encryptPiiFieldValues,
25
+ } from "@cosmicdrift/kumiko-framework/crypto";
26
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
27
+ import { Temporal } from "temporal-polyfill";
28
+ import { z } from "zod";
29
+ import { inboundMessageAggregateId, mailThreadAggregateId } from "../aggregate-id";
30
+ import {
31
+ INBOUND_MESSAGE_PII_FIELDS,
32
+ inboundMessageEntity,
33
+ MAIL_THREAD_PII_FIELDS,
34
+ mailThreadEntity,
35
+ seenMessageTable,
36
+ } from "../entities";
37
+ import {
38
+ INBOUND_MESSAGE_AGGREGATE_TYPE,
39
+ INBOUND_MESSAGE_RECEIVED_EVENT_QN,
40
+ type InboundMessageEventHeaders,
41
+ type InboundMessageEventPayload,
42
+ MAIL_THREAD_AGGREGATE_TYPE,
43
+ MAIL_THREAD_UPDATED_EVENT_QN,
44
+ type MailThreadEventPayload,
45
+ } from "../events";
46
+
47
+ // =============================================================================
48
+ // Input-Schema — der normalisierte Provider-Output (RawInboundMessage,
49
+ // types.ts) + Envelope (accountId/providerName/providerCursor). rawMime
50
+ // ist hier bewusst NICHT dabei: der Aufrufer persisted den Body VOR dem
51
+ // ingest-Call nach file-foundation und übergibt nur bodyRef — der
52
+ // Handler-Payload bleibt blob-frei (Event-Store-Disziplin).
53
+ // =============================================================================
54
+
55
+ export const ingestMessageSchema = z.object({
56
+ accountId: z.uuid(),
57
+ /** Scope-Vererbung vom Account — der Supervisor kennt den
58
+ * MailAccountRecord und reicht ownerUserId durch. */
59
+ ownerUserId: z.string().max(36).nullable(),
60
+ providerName: z.string().min(1).max(50),
61
+ /** Provider-native Message-ID — Idempotency-Anchor mit accountId. */
62
+ providerMessageId: z.string().min(1).max(500),
63
+ /** RFC-5322 Message-ID ohne <>; null → deterministischer Ersatz. */
64
+ messageIdHeader: z.string().min(1).max(500).nullable(),
65
+ providerThreadId: z.string().min(1).max(500).nullable(),
66
+ /** References/In-Reply-To-Chain, älteste zuerst. */
67
+ references: z.array(z.string().min(1).max(500)).max(200),
68
+ from: z.string().min(1).max(2000),
69
+ to: z.array(z.string().max(2000)).max(200),
70
+ cc: z.array(z.string().max(2000)).max(200),
71
+ subject: z.string().max(4000),
72
+ snippet: z.string().max(4000),
73
+ receivedAtIso: z.string().min(1),
74
+ /** file-foundation-Ref; leer im snippet-only-Mode. */
75
+ bodyRef: z.string().max(500),
76
+ scope: z.string().min(1).max(200),
77
+ /** Cursor-Snapshot zum Ingest-Zeitpunkt — Debug/Replay, landet in
78
+ * metadata.headers, nie im payload. */
79
+ providerCursor: z.string().max(2000),
80
+ });
81
+ type IngestMessagePayload = z.infer<typeof ingestMessageSchema>;
82
+
83
+ /**
84
+ * threadKey-Normalisierung (Plan §3.2):
85
+ * 1. Provider-Thread-ID falls vorhanden — provider-präfixed, damit
86
+ * IMAP-References-Keys und Graph-conversationIds nie kollidieren.
87
+ * 2. Sonst Root der References-Chain (älteste Message-ID = der
88
+ * Thread-Anker, den alle Replies teilen).
89
+ * 3. Sonst die eigene Message-ID → Single-Message-Thread; ein späteres
90
+ * Reply trägt sie als References-Root und landet im selben Thread.
91
+ */
92
+ function buildThreadKey(p: IngestMessagePayload, effectiveMessageIdHeader: string): string {
93
+ if (p.providerThreadId) return `${p.providerName}:${p.providerThreadId}`.slice(0, 500);
94
+ const referencesRoot = p.references[0];
95
+ if (referencesRoot) return `mid:${referencesRoot}`.slice(0, 500);
96
+ return `mid:${effectiveMessageIdHeader}`.slice(0, 500);
97
+ }
98
+
99
+ export const ingestMessageHandler: WriteHandlerDef = {
100
+ name: "ingest-message",
101
+ schema: ingestMessageSchema,
102
+ access: { roles: ["SystemAdmin"] },
103
+ handler: async (event, ctx) => {
104
+ // @cast-boundary engine-payload — dispatcher-zod-validated payload
105
+ const payload = event.payload as IngestMessagePayload;
106
+ const tenantId = event.user.tenantId;
107
+ const messageAggId = inboundMessageAggregateId(payload.accountId, payload.providerMessageId);
108
+
109
+ // ---------------------------------------------------------------
110
+ // 1. Idempotency. Cheap path zuerst: Dedup-Anchor-Row. Der
111
+ // Stream-Check dahinter ist Defense-in-depth für den Fall dass
112
+ // ein früherer ingest NACH dem append aber VOR dem seen-insert
113
+ // gestorben ist (kann in einer TX nicht passieren — aber der
114
+ // Check ist billig und macht den Handler rebuild-robust falls
115
+ // read_mail_seen_messages je getruncated wird).
116
+ // ---------------------------------------------------------------
117
+ const seenRows = await selectMany(ctx.db.raw, seenMessageTable, {
118
+ accountId: payload.accountId,
119
+ providerMessageId: payload.providerMessageId,
120
+ });
121
+ if (seenRows.length > 0) {
122
+ return {
123
+ isSuccess: true as const,
124
+ data: { duplicate: true as const, inboundMessageAggregateId: messageAggId },
125
+ };
126
+ }
127
+ const existingEvents = await ctx.loadAggregate(messageAggId);
128
+ if (existingEvents.length > 0) {
129
+ // Stream existiert, Anchor fehlte → Anchor nachziehen, dann raus.
130
+ await insertOne(ctx.db.raw, seenMessageTable, {
131
+ tenantId,
132
+ accountId: payload.accountId,
133
+ providerMessageId: payload.providerMessageId,
134
+ seenAt: Temporal.Now.instant().toString(),
135
+ });
136
+ return {
137
+ isSuccess: true as const,
138
+ data: { duplicate: true as const, inboundMessageAggregateId: messageAggId },
139
+ };
140
+ }
141
+
142
+ // ---------------------------------------------------------------
143
+ // 2. messageIdHeader-Fallback + threadKey.
144
+ // ---------------------------------------------------------------
145
+ const effectiveMessageIdHeader = payload.messageIdHeader ?? `kumiko-inbound:${messageAggId}`;
146
+ const threadKey = buildThreadKey(payload, effectiveMessageIdHeader);
147
+ const threadAggId = mailThreadAggregateId(tenantId, threadKey);
148
+
149
+ // ---------------------------------------------------------------
150
+ // 3. PII-Encrypt. to/cc werden VOR encryption JSON-stringified —
151
+ // im Payload steht der Ciphertext-String, nicht das Array
152
+ // (primitives only, upcaster-safe). Kein KMS konfiguriert =
153
+ // plaintext-passthrough (Engine-off-Verhalten, Muster
154
+ // billing-foundation process-event #724/#800).
155
+ // ---------------------------------------------------------------
156
+ const piiKms = configuredPiiSubjectKms();
157
+ const messagePlainPii = {
158
+ tenantId,
159
+ from: payload.from,
160
+ to: JSON.stringify(payload.to),
161
+ cc: JSON.stringify(payload.cc),
162
+ subject: payload.subject,
163
+ snippet: payload.snippet,
164
+ };
165
+ const encryptedMessageFields = piiKms
166
+ ? await encryptPiiFieldValues(
167
+ messagePlainPii,
168
+ inboundMessageEntity,
169
+ INBOUND_MESSAGE_PII_FIELDS,
170
+ piiKms,
171
+ {
172
+ requestId: `inbound-mail-foundation:ingest-message:${messageAggId}`,
173
+ tenantId,
174
+ },
175
+ )
176
+ : messagePlainPii;
177
+
178
+ // ---------------------------------------------------------------
179
+ // 4. inbound-message-received-append. Inline-Projection schreibt
180
+ // read_inbound_messages in derselben TX.
181
+ // ---------------------------------------------------------------
182
+ const messageEventPayload: InboundMessageEventPayload = {
183
+ accountId: payload.accountId,
184
+ ownerUserId: payload.ownerUserId,
185
+ messageIdHeader: effectiveMessageIdHeader,
186
+ threadKey,
187
+ from: encryptedMessageFields["from"] as string,
188
+ to: encryptedMessageFields["to"] as string,
189
+ cc: encryptedMessageFields["cc"] as string,
190
+ subject: encryptedMessageFields["subject"] as string,
191
+ snippet: encryptedMessageFields["snippet"] as string,
192
+ receivedAtIso: payload.receivedAtIso,
193
+ bodyRef: payload.bodyRef,
194
+ scope: payload.scope,
195
+ };
196
+ const messageHeaders: InboundMessageEventHeaders = {
197
+ providerMessageId: payload.providerMessageId,
198
+ providerName: payload.providerName,
199
+ providerCursor: payload.providerCursor,
200
+ };
201
+ await ctx.unsafeAppendEvent({
202
+ aggregateId: messageAggId,
203
+ aggregateType: INBOUND_MESSAGE_AGGREGATE_TYPE,
204
+ type: INBOUND_MESSAGE_RECEIVED_EVENT_QN,
205
+ payload: messageEventPayload,
206
+ headers: messageHeaders,
207
+ });
208
+
209
+ // ---------------------------------------------------------------
210
+ // 5. Thread-Rollup. Der Handler berechnet den NEUEN Snapshot
211
+ // (count+1, max(lastMessageAt)) — die apply ist ein dummer
212
+ // UPSERT und bleibt replay-idempotent. lastMessageAtIso ist
213
+ // kein PII (Zeitpunkt), damit plaintext-vergleichbar; subject
214
+ // wird frisch encrypted (jede Thread-Version trägt das Subject
215
+ // der jüngsten Mail).
216
+ //
217
+ // **Kein Lost-Update-Fenster:** loadAggregate + append laufen in
218
+ // derselben TX; zwei parallele ingests auf denselben Thread
219
+ // serialisiert der event-store über die Stream-Version.
220
+ // ---------------------------------------------------------------
221
+ const threadEvents = await ctx.loadAggregate(threadAggId);
222
+ const lastThreadEvent = threadEvents[threadEvents.length - 1];
223
+ // @cast-boundary engine-payload — eigene events, shape via defineEvent
224
+ const previousThread = lastThreadEvent?.payload as MailThreadEventPayload | undefined;
225
+ const previousCount = previousThread?.messageCount ?? 0;
226
+ const previousLastAt = previousThread?.lastMessageAtIso ?? "";
227
+ const newLastAt =
228
+ payload.receivedAtIso > previousLastAt ? payload.receivedAtIso : previousLastAt;
229
+
230
+ const threadPlainPii = { tenantId, subject: payload.subject };
231
+ const encryptedThreadFields = piiKms
232
+ ? await encryptPiiFieldValues(
233
+ threadPlainPii,
234
+ mailThreadEntity,
235
+ MAIL_THREAD_PII_FIELDS,
236
+ piiKms,
237
+ {
238
+ requestId: `inbound-mail-foundation:ingest-message:thread:${threadAggId}`,
239
+ tenantId,
240
+ },
241
+ )
242
+ : threadPlainPii;
243
+
244
+ const threadEventPayload: MailThreadEventPayload = {
245
+ threadKey,
246
+ subject: encryptedThreadFields["subject"] as string,
247
+ lastMessageAtIso: newLastAt,
248
+ messageCount: previousCount + 1,
249
+ };
250
+ await ctx.unsafeAppendEvent({
251
+ aggregateId: threadAggId,
252
+ aggregateType: MAIL_THREAD_AGGREGATE_TYPE,
253
+ type: MAIL_THREAD_UPDATED_EVENT_QN,
254
+ payload: threadEventPayload,
255
+ headers: messageHeaders,
256
+ });
257
+
258
+ // ---------------------------------------------------------------
259
+ // 6. Dedup-Anchor. Läuft in derselben TX wie die appends — stirbt
260
+ // der Handler, rollt alles zusammen zurück.
261
+ // ---------------------------------------------------------------
262
+ await insertOne(ctx.db.raw, seenMessageTable, {
263
+ tenantId,
264
+ accountId: payload.accountId,
265
+ providerMessageId: payload.providerMessageId,
266
+ seenAt: Temporal.Now.instant().toString(),
267
+ });
268
+
269
+ return {
270
+ isSuccess: true as const,
271
+ data: {
272
+ duplicate: false as const,
273
+ inboundMessageAggregateId: messageAggId,
274
+ threadAggregateId: threadAggId,
275
+ threadKey,
276
+ },
277
+ };
278
+ },
279
+ };