@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,230 @@
1
+ // createInboundMailConnectRoutes — Hono-Route-Factory die der App-Owner
2
+ // via `extraRoutes` in seinem bin/server.ts mountet (Muster
3
+ // billing-foundation/webhook-handler.ts).
4
+ //
5
+ // GET /inbound-mail/connect (auth-gated — im authed Bereich mounten)
6
+ // GET /inbound-mail/oauth/callback (anonymous — MUSS außerhalb /api/*
7
+ // liegen, /api ist auth-gated;
8
+ // verifiziert user-data-rights §5)
9
+ //
10
+ // Beispiel bin/server.ts:
11
+ //
12
+ // const routes = createInboundMailConnectRoutes({
13
+ // providerCtx: { registry: deps.registry, secrets, config: undefined },
14
+ // dispatchWrite: ({ handlerQn, payload, tenantId }) =>
15
+ // deps.dispatchSystemWrite({ handlerQn, payload, tenantId: tenantId as TenantId }),
16
+ // secrets,
17
+ // stateSecret: env.INBOUND_MAIL_STATE_SECRET,
18
+ // callbackUrl: `${env.PUBLIC_BASE_URL}/inbound-mail/oauth/callback`,
19
+ // });
20
+ // app.get("/api/inbound-mail/connect", routes.connect); // auth-gated
21
+ // app.get("/inbound-mail/oauth/callback", routes.callback); // anonymous
22
+ //
23
+ // Provider registrieren KEINE eigenen Routen — ihr oauth-Block wird über
24
+ // resolveInboundProviderForKey aufgelöst. Der Callback vertraut NUR dem
25
+ // HMAC-verifizierten state (CSRF-/Fremd-Claiming-Sperre, oauth-state.ts).
26
+
27
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
28
+ import type { Context } from "hono";
29
+ import {
30
+ INBOUND_MAIL_FOUNDATION_FEATURE,
31
+ InboundMailAuthMethods,
32
+ InboundMailFoundationHandlers,
33
+ inboundCredentialSecretKey,
34
+ } from "./constants";
35
+ import { signOAuthState, verifyOAuthState } from "./oauth-state";
36
+ import { resolveInboundProviderForKey } from "./provider-factory";
37
+ import type { InboundMailContext } from "./types";
38
+
39
+ const DEFAULT_STATE_TTL_MINUTES = 15;
40
+
41
+ export type InboundMailConnectRoutesDeps = {
42
+ /** Slim-Context für Provider-Lookups + oauth-Calls (registry Pflicht). */
43
+ readonly providerCtx: InboundMailContext;
44
+ /** Schreibt durch den Standard-Dispatcher mit auto-konstruiertem
45
+ * SystemUser — muss connect-account als SystemAdmin durchlassen. */
46
+ readonly dispatchWrite: (args: {
47
+ readonly handlerQn: string;
48
+ readonly payload: unknown;
49
+ readonly tenantId: string;
50
+ }) => Promise<{
51
+ readonly isSuccess: boolean;
52
+ readonly data?: unknown;
53
+ readonly error?: unknown;
54
+ }>;
55
+ /** Tenant-Secret-Write für den Refresh-Token (Slot = accountId). */
56
+ readonly secrets: SecretsContext;
57
+ /** HMAC-Secret für den state-Parameter (Deploy-Config aus env —
58
+ * `scope:"system"`-Secrets existieren in secrets-v1 nicht). */
59
+ readonly stateSecret: string;
60
+ /** Absolute Callback-URL — exakt so beim OAuth-Provider registriert. */
61
+ readonly callbackUrl: string;
62
+ /** Wohin der Browser nach erfolgreichem Connect redirected wird.
63
+ * Fehlt sie, antwortet der Callback mit JSON (API-/Test-Modus). */
64
+ readonly successRedirectUrl?: string;
65
+ readonly stateTtlMinutes?: number;
66
+ };
67
+
68
+ function errorJson(c: Context, status: 400 | 401 | 404 | 502, code: string, message: string) {
69
+ return c.json({ error: { code, message } }, status);
70
+ }
71
+
72
+ /** Session-User aus dem Hono-Context — vom Auth-Middleware gesetzt. */
73
+ function sessionUserOf(c: Context): { id: string; tenantId: string } | undefined {
74
+ const user = c.get("user") as { id?: unknown; tenantId?: unknown } | undefined;
75
+ if (!user || typeof user.id !== "string" || typeof user.tenantId !== "string") return undefined;
76
+ return { id: user.id, tenantId: user.tenantId };
77
+ }
78
+
79
+ export function createInboundMailConnectRoutes(deps: InboundMailConnectRoutesDeps) {
80
+ const ttl = deps.stateTtlMinutes ?? DEFAULT_STATE_TTL_MINUTES;
81
+
82
+ const connect = async (c: Context): Promise<Response> => {
83
+ const user = sessionUserOf(c);
84
+ if (!user) {
85
+ return errorJson(c, 401, "unauthenticated", "connect requires a signed-in user");
86
+ }
87
+ const providerKey = c.req.query("provider") ?? "";
88
+ const scope = c.req.query("scope") ?? "";
89
+ const mailbox = c.req.query("mailbox") ?? "";
90
+ if (!providerKey || (scope !== "user" && scope !== "shared") || !mailbox) {
91
+ return errorJson(
92
+ c,
93
+ 400,
94
+ "invalid_connect_request",
95
+ `${INBOUND_MAIL_FOUNDATION_FEATURE}: expected ?provider=<key>&scope=user|shared&mailbox=<address>`,
96
+ );
97
+ }
98
+
99
+ let plugin: ReturnType<typeof resolveInboundProviderForKey>;
100
+ try {
101
+ plugin = resolveInboundProviderForKey(deps.providerCtx, providerKey);
102
+ } catch (e) {
103
+ return errorJson(
104
+ c,
105
+ 404,
106
+ "provider_not_registered",
107
+ e instanceof Error ? e.message : String(e),
108
+ );
109
+ }
110
+ if (!plugin.oauth) {
111
+ return errorJson(
112
+ c,
113
+ 400,
114
+ "provider_has_no_oauth_flow",
115
+ `provider "${providerKey}" connects via credentials form (connect-account write-handler), not OAuth`,
116
+ );
117
+ }
118
+
119
+ const state = signOAuthState(
120
+ {
121
+ tenantId: user.tenantId,
122
+ ownerUserId: scope === "user" ? user.id : null,
123
+ providerKey,
124
+ mailbox,
125
+ },
126
+ ttl,
127
+ deps.stateSecret,
128
+ );
129
+ const authorizeUrl = await plugin.oauth.buildAuthorizeUrl(deps.providerCtx, {
130
+ state,
131
+ redirectUri: deps.callbackUrl,
132
+ });
133
+ return c.redirect(authorizeUrl, 302);
134
+ };
135
+
136
+ const callback = async (c: Context): Promise<Response> => {
137
+ const code = c.req.query("code") ?? "";
138
+ const rawState = c.req.query("state") ?? "";
139
+ if (!code || !rawState) {
140
+ return errorJson(c, 400, "invalid_callback", "missing code or state");
141
+ }
142
+ const verified = verifyOAuthState(rawState, deps.stateSecret);
143
+ if (!verified.ok) {
144
+ return errorJson(c, 400, "invalid_state", `state rejected: ${verified.reason}`);
145
+ }
146
+ const state = verified.payload;
147
+
148
+ let plugin: ReturnType<typeof resolveInboundProviderForKey>;
149
+ try {
150
+ plugin = resolveInboundProviderForKey(deps.providerCtx, state.providerKey);
151
+ } catch (e) {
152
+ return errorJson(
153
+ c,
154
+ 404,
155
+ "provider_not_registered",
156
+ e instanceof Error ? e.message : String(e),
157
+ );
158
+ }
159
+ if (!plugin.oauth) {
160
+ return errorJson(c, 400, "provider_has_no_oauth_flow", state.providerKey);
161
+ }
162
+
163
+ let tokens: Awaited<ReturnType<NonNullable<typeof plugin.oauth>["exchangeCode"]>>;
164
+ try {
165
+ tokens = await plugin.oauth.exchangeCode(deps.providerCtx, {
166
+ code,
167
+ redirectUri: deps.callbackUrl,
168
+ });
169
+ } catch (e) {
170
+ return errorJson(c, 502, "token_exchange_failed", e instanceof Error ? e.message : String(e));
171
+ }
172
+ if (!tokens.refreshToken) {
173
+ // Ohne Refresh-Token kein Server-Sync — Consent muss offline_access
174
+ // (bzw. access_type=offline) anfordern; der Provider-oauth-Block
175
+ // besitzt die Scopes.
176
+ return errorJson(
177
+ c,
178
+ 502,
179
+ "no_refresh_token",
180
+ `provider "${state.providerKey}" returned no refresh token — check the offline-access scope in the provider's oauth.scopes`,
181
+ );
182
+ }
183
+
184
+ // Account anlegen (programmatic SystemUser; echter Owner kommt aus
185
+ // dem HMAC-verifizierten state → ownerUserIdOverride).
186
+ const dispatched = await deps.dispatchWrite({
187
+ handlerQn: InboundMailFoundationHandlers.connectAccount,
188
+ tenantId: state.tenantId,
189
+ payload: {
190
+ provider: state.providerKey,
191
+ authMethod: InboundMailAuthMethods.oauth,
192
+ displayName: state.mailbox,
193
+ address: state.mailbox,
194
+ scope: state.ownerUserId ? "user" : "shared",
195
+ ownerUserIdOverride: state.ownerUserId,
196
+ },
197
+ });
198
+ if (!dispatched.isSuccess) {
199
+ return errorJson(c, 502, "account_create_failed", JSON.stringify(dispatched.error ?? {}));
200
+ }
201
+ const accountId = (dispatched.data as { accountId?: unknown } | undefined)?.accountId;
202
+ if (typeof accountId !== "string") {
203
+ return errorJson(c, 502, "account_create_failed", "connect-account returned no accountId");
204
+ }
205
+
206
+ // Refresh-Token in den per-Account-Secret-Slot (Request-Pfad =
207
+ // voller SecretsContext). Access-Tokens (~1h) werden NIE persistiert
208
+ // — refresh-before-poll im Sync-Pfad.
209
+ await deps.secrets.set(
210
+ state.tenantId,
211
+ inboundCredentialSecretKey(accountId),
212
+ tokens.refreshToken,
213
+ {
214
+ redact: (plaintext) => `${plaintext.slice(0, 4)}…`,
215
+ hint: `OAuth refresh token for inbound mail account ${accountId}`,
216
+ },
217
+ );
218
+
219
+ if (deps.successRedirectUrl) {
220
+ const target = new URL(deps.successRedirectUrl);
221
+ target.searchParams.set("accountId", accountId);
222
+ return c.redirect(target.toString(), 302);
223
+ }
224
+ return c.json({ connected: true, accountId }, 200);
225
+ };
226
+
227
+ return { connect, callback };
228
+ }
229
+
230
+ export type InboundMailConnectRoutes = ReturnType<typeof createInboundMailConnectRoutes>;
@@ -0,0 +1,80 @@
1
+ // Feature name
2
+ export const INBOUND_MAIL_FOUNDATION_FEATURE = "inbound-mail-foundation" as const;
3
+
4
+ // Extension-point name für Provider-Plugins (inbound-provider-imap,
5
+ // inbound-provider-m365-graph, inbound-provider-gmail-rest, ...).
6
+ export const INBOUND_MAIL_PROVIDER_EXTENSION = "inboundMailProvider" as const;
7
+
8
+ // Qualified write handler names (QN format: scope:type:name).
9
+ export const InboundMailFoundationHandlers = {
10
+ /** Programmatic entry-point für Provider-Plugins (watch-callback +
11
+ * fetchSince-Sync): normalisierte Message rein, Idempotency +
12
+ * PII-encrypt + event-append + Thread-Update atomic. */
13
+ ingestMessage: "inbound-mail-foundation:write:ingest-message",
14
+ /** Tenant-Admin verbindet ein Postfach (IMAP-Credentials via secrets
15
+ * oder OAuth-Redirect via oauth-routes). Legt den mail-account-Stream
16
+ * an. */
17
+ connectAccount: "inbound-mail-foundation:write:connect-account",
18
+ /** Account-Status-Übergänge (watch error/backoff, oauth-refresh
19
+ * failed, re-enabled). */
20
+ updateAccount: "inbound-mail-foundation:write:update-account",
21
+ /** Tenant-Admin trennt ein Postfach. Stream bleibt (Audit), Status
22
+ * wird disconnected, Watch stoppt. */
23
+ disconnectAccount: "inbound-mail-foundation:write:disconnect-account",
24
+ } as const;
25
+
26
+ // Qualified query handler names.
27
+ export const InboundMailFoundationQueries = {
28
+ /** Tenant-scoped Liste der verbundenen Postfächer. */
29
+ listAccounts: "inbound-mail-foundation:query:account:list",
30
+ /** Tenant-scoped Message-Liste (Inbox-Cockpit). PII wird beim Read
31
+ * decrypted. */
32
+ listMessages: "inbound-mail-foundation:query:message:list",
33
+ } as const;
34
+
35
+ // Normalized account status values — provider-agnostic.
36
+ export const InboundMailAccountStatuses = {
37
+ /** Verbunden, Watch/Sync läuft (oder ist startbereit). */
38
+ active: "active",
39
+ /** Credentials/Token ungültig — Tenant-Admin muss re-connecten. */
40
+ authError: "auth_error",
41
+ /** Transienter Fehler, Supervisor backoff-retried. */
42
+ degraded: "degraded",
43
+ /** Vom Tenant-Admin getrennt. Keine Syncs mehr. */
44
+ disconnected: "disconnected",
45
+ } as const;
46
+ export type InboundMailAccountStatus =
47
+ (typeof InboundMailAccountStatuses)[keyof typeof InboundMailAccountStatuses];
48
+
49
+ /**
50
+ * Tenant-Secret-Slot eines Accounts: IMAP-App-Passwort ODER OAuth-
51
+ * Refresh-Token — der Provider interpretiert den Inhalt gemäß
52
+ * `authMethod` (Plan §4). Dynamisches Keying pro Account ist vom
53
+ * secrets-Feature gedeckt: SecretsContext.get/set nehmen beliebige
54
+ * String-Keys (resolveKey in secrets-context.ts) — Plan-Risiko 4
55
+ * verifiziert, kein Framework-Gap.
56
+ */
57
+ export function inboundCredentialSecretKey(accountId: string): string {
58
+ return `${INBOUND_MAIL_FOUNDATION_FEATURE}:inbound.credential.${accountId}`;
59
+ }
60
+
61
+ // Auth-Methode eines Accounts — vom connect-Flow gesetzt, von Providern
62
+ // gelesen (imap unterscheidet hierüber Passwort-Formular vs. XOAUTH2-
63
+ // Fallback, Plan §2).
64
+ export const InboundMailAuthMethods = {
65
+ /** Formular-Connect: Config (host/port/secure) + Tenant-Secret. */
66
+ password: "password",
67
+ /** SASL XOAUTH2 über IMAP — Fallback für M365/Gmail-Konten ohne
68
+ * gemounteten Enterprise-Provider (gleicher Secret-Slot). */
69
+ xoauth2: "xoauth2",
70
+ /** Nativer OAuth-Provider (m365-graph, gmail-rest). */
71
+ oauth: "oauth",
72
+ } as const;
73
+ export type InboundMailAuthMethod =
74
+ (typeof InboundMailAuthMethods)[keyof typeof InboundMailAuthMethods];
75
+
76
+ // **Multi-Provider von Tag 1:** KEIN globaler `provider`-config-key
77
+ // (anders als mail-foundation, gleich wie billing-foundation). Ein Tenant
78
+ // kann parallel ein IMAP-Postfach UND ein M365-Postfach verbinden — der
79
+ // Provider steht pro MailAccount-Row (`provider`-Feld), gesetzt beim
80
+ // connect-Flow.
@@ -0,0 +1,38 @@
1
+ // Raw-SQL-Helper für die inline projections — Muster
2
+ // billing-foundation/db/queries/subscription-projection.ts.
3
+ //
4
+ // Warum raw statt drizzle-insert: die apply-fns laufen in der event-TX
5
+ // mit einem DbRunner, und die ON-CONFLICT-Semantik (UPSERT vs.
6
+ // DO NOTHING) ist der fachliche Kern — explizit hingeschrieben statt
7
+ // hinter einem ORM-Builder versteckt.
8
+
9
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
11
+
12
+ /** INSERT ... ON CONFLICT ("id") DO UPDATE SET <setClauses>. */
13
+ export async function upsertProjectionRow(
14
+ tx: DbRunner,
15
+ tableName: string,
16
+ insertCols: Record<string, unknown>,
17
+ setClauses: readonly string[],
18
+ params: readonly unknown[],
19
+ ): Promise<void> {
20
+ const insertKeys = Object.keys(insertCols);
21
+ const insertPlaceholders = insertKeys.map((_, i) => `$${i + 1}`);
22
+ const sqlText = `INSERT INTO "${tableName}" (${insertKeys.map((k) => `"${k}"`).join(", ")}) VALUES (${insertPlaceholders.join(", ")}) ON CONFLICT ("id") DO UPDATE SET ${setClauses.join(", ")}`;
23
+ await asRawClient(tx).unsafe(sqlText, params);
24
+ }
25
+
26
+ /** INSERT ... ON CONFLICT ("id") DO NOTHING — für append-only-Rows
27
+ * (inbound-message: genau EIN received-event pro Stream, Replays
28
+ * no-op'en auf der PK). */
29
+ export async function insertIgnoreProjectionRow(
30
+ tx: DbRunner,
31
+ tableName: string,
32
+ insertCols: Record<string, unknown>,
33
+ ): Promise<void> {
34
+ const insertKeys = Object.keys(insertCols);
35
+ const insertPlaceholders = insertKeys.map((_, i) => `$${i + 1}`);
36
+ const sqlText = `INSERT INTO "${tableName}" (${insertKeys.map((k) => `"${k}"`).join(", ")}) VALUES (${insertPlaceholders.join(", ")}) ON CONFLICT ("id") DO NOTHING`;
37
+ await asRawClient(tx).unsafe(sqlText, Object.values(insertCols));
38
+ }
@@ -0,0 +1,168 @@
1
+ import { collectPiiSubjectFields } from "@cosmicdrift/kumiko-framework/crypto";
2
+ import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ access,
5
+ createEntity,
6
+ createNumberField,
7
+ createTextField,
8
+ createTimestampField,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
10
+
11
+ // ============================================================================
12
+ // Read-Models der drei event-sourced Aggregates
13
+ // ============================================================================
14
+ //
15
+ // Inline-Projection-Targets. Geschrieben AUSSCHLIESSLICH von den
16
+ // projection-applies (siehe feature.ts), nie direkt vom handler.
17
+ // Source-of-truth sind die event-store-Streams `mail-account` /
18
+ // `inbound-message` / `mail-thread` — die Tabellen sind rebuild-fähig.
19
+ //
20
+ // **PII-Konvention:** tenantOwned (nicht `encrypted`): Felder müssen
21
+ // crypto-shredden wenn eraseSubjectKeys den Tenant-Subject-Key beim
22
+ // tenant-destroy löscht (#800-Muster aus billing-foundation).
23
+ // maxLength jeweils Ciphertext-budgetiert (`kumiko-pii:v1:<subject>:
24
+ // <blob>` — Plaintext×~2.3 + Envelope-Overhead).
25
+ //
26
+ // **Offene Design-Frage aus dem Plan (§3.4):** PII-Subject einer
27
+ // Inbound-Mail ist vorerst der TENANT (Postfach-Inhaber), nicht der
28
+ // externe Absender — ein Absender-Forget-Flow bräuchte per-Sender-Keys
29
+ // und ist bewusst NICHT Teil von Phase 1 (dokumentierte Entscheidung,
30
+ // Erasure-Pfad ist tenant-destroy).
31
+
32
+ /** Ein verbundenes Postfach. Eine Row pro Account, PK = aggregateId
33
+ * (= accountId, random uuid beim connect). */
34
+ export const mailAccountEntity = createEntity({
35
+ table: "read_mail_accounts",
36
+ fields: {
37
+ provider: createTextField({ required: true, maxLength: 50 }),
38
+ authMethod: createTextField({ required: true, maxLength: 30 }),
39
+ // null = tenant-geteiltes Postfach (info@), gesetzt = persönliches
40
+ // Postfach dieses Users. Sichtbarkeits-Filter in den list-queries:
41
+ // Owner + TenantAdmin (Compliance); KEIN Crypto-Subject-Wechsel in
42
+ // V1 (Subject bleibt Tenant, siehe Header).
43
+ ownerUserId: createTextField({ maxLength: 36 }),
44
+ displayName: createTextField({ maxLength: 200 }),
45
+ // Postfach-Adresse — PII des Tenants.
46
+ address: createTextField({ required: true, maxLength: 1000, tenantOwned: true }),
47
+ status: createTextField({ required: true, maxLength: 30 }),
48
+ watchState: createTextField({ maxLength: 100 }),
49
+ connectedAt: createTimestampField({ required: true }),
50
+ },
51
+ });
52
+
53
+ /** Eine eingegangene Mail (Envelope + Snippet, KEIN Body — bodyRef
54
+ * zeigt auf file-foundation). PK = aggregateId =
55
+ * inboundMessageAggregateId(accountId, providerMessageId). */
56
+ export const inboundMessageEntity = createEntity({
57
+ table: "read_inbound_messages",
58
+ fields: {
59
+ accountId: createTextField({ required: true, maxLength: 36 }),
60
+ // Scope-Vererbung vom Account zum Ingest-Zeitpunkt — Messages eines
61
+ // persönlichen Postfachs sind nur für den Owner (+ TenantAdmin)
62
+ // sichtbar, ohne Join auf read_mail_accounts.
63
+ ownerUserId: createTextField({ maxLength: 36 }),
64
+ messageIdHeader: createTextField({ required: true, maxLength: 500 }),
65
+ threadKey: createTextField({ required: true, maxLength: 500 }),
66
+ from: createTextField({ required: true, maxLength: 2000, tenantOwned: true }),
67
+ // JSON-stringified string[] — als Ganzes encrypted.
68
+ to: createTextField({ maxLength: 8000, tenantOwned: true }),
69
+ cc: createTextField({ maxLength: 8000, tenantOwned: true }),
70
+ subject: createTextField({ maxLength: 4000, tenantOwned: true }),
71
+ snippet: createTextField({ maxLength: 4000, tenantOwned: true }),
72
+ receivedAt: createTimestampField({ required: true }),
73
+ bodyRef: createTextField({ maxLength: 500 }),
74
+ scope: createTextField({ maxLength: 200 }),
75
+ },
76
+ });
77
+
78
+ /** Thread-Rollup. PK = aggregateId = mailThreadAggregateId(tenantId,
79
+ * threadKey). */
80
+ export const mailThreadEntity = createEntity({
81
+ table: "read_mail_threads",
82
+ fields: {
83
+ threadKey: createTextField({ required: true, maxLength: 500 }),
84
+ subject: createTextField({ maxLength: 4000, tenantOwned: true }),
85
+ lastMessageAt: createTimestampField({ required: true }),
86
+ messageCount: createNumberField({ required: true }),
87
+ },
88
+ });
89
+
90
+ // Single source of truth für die PII-Feld-Listen — encrypt (ingest/
91
+ // connect-handler) und decrypt (list-queries) teilen dieselbe Liste,
92
+ // damit ein künftiges weiteres tenantOwned-Feld nicht an einer der
93
+ // Stellen vergessen wird (Muster billing-foundation entities.ts).
94
+ export const MAIL_ACCOUNT_PII_FIELDS = collectPiiSubjectFields(mailAccountEntity);
95
+ export const INBOUND_MESSAGE_PII_FIELDS = collectPiiSubjectFields(inboundMessageEntity);
96
+ export const MAIL_THREAD_PII_FIELDS = collectPiiSubjectFields(mailThreadEntity);
97
+
98
+ // ============================================================================
99
+ // Unmanaged Direct-Write-Stores — Sync-Maschinerie, NICHT event-sourced
100
+ // ============================================================================
101
+ //
102
+ // SyncCursor + SeenMessage sind technischer Tick-State des Watch/Sync-
103
+ // Loops: hochfrequente Writes ohne Business-Fakt-Charakter. Als r.entity
104
+ // würden sie (a) das Event-Log fluten und (b) beim Projection-Rebuild
105
+ // gewischt (Direct-Write ohne Event → Rebuild findet nichts, #494/#498-
106
+ // Klasse). Deshalb r.unmanagedTable — Migration-DDL ja, Rebuild-Target
107
+ // nein (Muster sessions/feature.ts).
108
+
109
+ /** Provider-Cursor pro Account ("wo war ich?"): IMAP UIDVALIDITY:UIDNEXT,
110
+ * Graph deltaLink, Gmail historyId. Eine Row pro (accountId, scope).
111
+ * Write-locked auf privileged — nur Foundation-Handler/Supervisor
112
+ * schreiben, kein Tenant-Request. */
113
+ export const syncCursorEntity = createEntity({
114
+ table: "read_mail_sync_cursors",
115
+ softDelete: false,
116
+ fields: {
117
+ accountId: createTextField({
118
+ required: true,
119
+ maxLength: 36,
120
+ access: { write: access.privileged },
121
+ }),
122
+ scope: createTextField({
123
+ required: true,
124
+ maxLength: 200,
125
+ access: { write: access.privileged },
126
+ }),
127
+ cursor: createTextField({
128
+ required: true,
129
+ maxLength: 2000,
130
+ access: { write: access.privileged },
131
+ }),
132
+ updatedAt: createTimestampField({
133
+ required: true,
134
+ access: { write: access.privileged },
135
+ }),
136
+ },
137
+ });
138
+
139
+ /** Dedup-Anchor für O(1)-Idempotency ohne Stream-Scan bei sehr langen
140
+ * Message-Historien: (accountId, providerMessageId) UNIQUE. Der
141
+ * ingest-handler prüft zuerst hier (cheap), der Stream-Scan bleibt
142
+ * Defense-in-depth. */
143
+ export const seenMessageEntity = createEntity({
144
+ table: "read_mail_seen_messages",
145
+ softDelete: false,
146
+ fields: {
147
+ accountId: createTextField({
148
+ required: true,
149
+ maxLength: 36,
150
+ access: { write: access.privileged },
151
+ }),
152
+ providerMessageId: createTextField({
153
+ required: true,
154
+ maxLength: 500,
155
+ access: { write: access.privileged },
156
+ }),
157
+ seenAt: createTimestampField({
158
+ required: true,
159
+ access: { write: access.privileged },
160
+ }),
161
+ },
162
+ });
163
+
164
+ // Plain EntityTableMeta (kein branded EntityTable) — unmanaged Direct-
165
+ // Write-Stores, Handler schreiben via ctx.db (siehe user-session.ts-
166
+ // Rationale).
167
+ export const syncCursorTable = buildEntityTableMeta("mail-sync-cursor", syncCursorEntity);
168
+ export const seenMessageTable = buildEntityTableMeta("mail-seen-message", seenMessageEntity);
@@ -0,0 +1,147 @@
1
+ // Domain-events für inbound-mail-foundation.
2
+ //
3
+ // **Pattern:** event-sourced — jede eingehende Mail und jeder Account-
4
+ // Lifecycle-Übergang wird zu einem domain-event. Read-models =
5
+ // inline projections (read_mail_accounts / read_inbound_messages /
6
+ // read_mail_threads). Future-consumer (Business-Prozesse: Ticket-
7
+ // Anlage, Beleg-Erkennung, Auto-Antwort) listenen direkt auf
8
+ // `inbound-message-received` und können via extendEntityProjection
9
+ // die KOMPLETTE Historie rückwirkend replayen.
10
+ //
11
+ // **Payload-Disziplin (Events sind forever):**
12
+ // - domain-clean + generisch: keine App-Begriffe (kein solon), keine
13
+ // provider-raw-Strukturen im payload — raw lebt in metadata.headers.
14
+ // - KEINE Bodies: bodyRef zeigt auf file-foundation-Storage. Der
15
+ // Event-Store ist kein Blob-Store.
16
+ // - PII (address/from/to/cc/subject/snippet) steht als Ciphertext im
17
+ // payload (`kumiko-pii:v1:...`), encrypted VOR dem append im
18
+ // write-handler — Subject-Key ist der Tenant, tenant-destroy's
19
+ // eraseSubjectKeys macht Event-Log UND Projection unlesbar
20
+ // (crypto-shredding, Muster billing-foundation #800).
21
+
22
+ import { z } from "zod";
23
+ import { INBOUND_MAIL_FOUNDATION_FEATURE, InboundMailAccountStatuses } from "./constants";
24
+
25
+ // Aggregate-types für den event-store.
26
+ export const MAIL_ACCOUNT_AGGREGATE_TYPE = "mail-account" as const;
27
+ export const INBOUND_MESSAGE_AGGREGATE_TYPE = "inbound-message" as const;
28
+ export const MAIL_THREAD_AGGREGATE_TYPE = "mail-thread" as const;
29
+
30
+ // Event-name-Konstanten — short-form (für r.defineEvent) + qualifizierte
31
+ // FQN (für ctx.unsafeAppendEvent + projection-apply-keys).
32
+ export const MAIL_ACCOUNT_CONNECTED_EVENT_SHORT = "mail-account-connected" as const;
33
+ export const MAIL_ACCOUNT_UPDATED_EVENT_SHORT = "mail-account-updated" as const;
34
+ export const MAIL_ACCOUNT_DISCONNECTED_EVENT_SHORT = "mail-account-disconnected" as const;
35
+ export const INBOUND_MESSAGE_RECEIVED_EVENT_SHORT = "inbound-message-received" as const;
36
+ export const MAIL_THREAD_UPDATED_EVENT_SHORT = "mail-thread-updated" as const;
37
+
38
+ export const MAIL_ACCOUNT_CONNECTED_EVENT_QN =
39
+ `${INBOUND_MAIL_FOUNDATION_FEATURE}:event:${MAIL_ACCOUNT_CONNECTED_EVENT_SHORT}` as const;
40
+ export const MAIL_ACCOUNT_UPDATED_EVENT_QN =
41
+ `${INBOUND_MAIL_FOUNDATION_FEATURE}:event:${MAIL_ACCOUNT_UPDATED_EVENT_SHORT}` as const;
42
+ export const MAIL_ACCOUNT_DISCONNECTED_EVENT_QN =
43
+ `${INBOUND_MAIL_FOUNDATION_FEATURE}:event:${MAIL_ACCOUNT_DISCONNECTED_EVENT_SHORT}` as const;
44
+ export const INBOUND_MESSAGE_RECEIVED_EVENT_QN =
45
+ `${INBOUND_MAIL_FOUNDATION_FEATURE}:event:${INBOUND_MESSAGE_RECEIVED_EVENT_SHORT}` as const;
46
+ export const MAIL_THREAD_UPDATED_EVENT_QN =
47
+ `${INBOUND_MAIL_FOUNDATION_FEATURE}:event:${MAIL_THREAD_UPDATED_EVENT_SHORT}` as const;
48
+
49
+ const accountStatusEnum = z.enum([
50
+ InboundMailAccountStatuses.active,
51
+ InboundMailAccountStatuses.authError,
52
+ InboundMailAccountStatuses.degraded,
53
+ InboundMailAccountStatuses.disconnected,
54
+ ]);
55
+
56
+ // ============================================================================
57
+ // mail-account-Stream — connected / updated / disconnected
58
+ // ============================================================================
59
+ //
60
+ // Ein Stream pro Postfach (aggregateId = accountId, random uuid beim
61
+ // connect). address ist PII → maxLength 1000 wegen Ciphertext-Blowup
62
+ // (ein 200-char-Plaintext wird ~460+ chars JSON-envelope, siehe
63
+ // billing-foundation events.ts).
64
+ export const mailAccountEventPayloadSchema = z.object({
65
+ provider: z.string().min(1).max(50),
66
+ /** "password" | "xoauth2" | "oauth" (InboundMailAuthMethods) — bewusst
67
+ * string statt enum: neue Auth-Methoden ohne Event-Upcaster. */
68
+ authMethod: z.string().min(1).max(30),
69
+ /** null = tenant-geteilt, sonst persönliches Postfach dieses Users. */
70
+ ownerUserId: z.string().max(36).nullable(),
71
+ displayName: z.string().max(200),
72
+ address: z.string().min(1).max(1000),
73
+ status: accountStatusEnum,
74
+ /** Provider-agnostischer Watch-Zustand ("idle", "watching",
75
+ * "backoff:3", ...) — reines Ops-Signal, kein PII. */
76
+ watchState: z.string().max(100),
77
+ });
78
+ export type MailAccountEventPayload = z.infer<typeof mailAccountEventPayloadSchema>;
79
+
80
+ // ============================================================================
81
+ // inbound-message-Stream — genau EIN received-Event pro Message
82
+ // ============================================================================
83
+ //
84
+ // aggregateId = inboundMessageAggregateId(accountId, providerMessageId)
85
+ // → deterministic, Provider-Replays kollidieren auf demselben Stream.
86
+ //
87
+ // PII-Felder (from/to/cc/subject/snippet): Ciphertext, maxLength großzügig.
88
+ // to/cc als JSON-stringified string[] VOR encryption — im Payload steht
89
+ // der Ciphertext-String, nicht das Array (primitives only, upcaster-safe).
90
+ export const inboundMessageEventPayloadSchema = z.object({
91
+ accountId: z.uuid(),
92
+ /** Scope-Vererbung vom Account (Ingest-Zeitpunkt). */
93
+ ownerUserId: z.string().max(36).nullable(),
94
+ /** RFC-5322 Message-ID-Header (normalisiert, ohne <>) — NICHT PII-
95
+ * encrypted: Idempotency/Threading-Anchor, opaque technical id. */
96
+ messageIdHeader: z.string().min(1).max(500),
97
+ /** Normalisierter Thread-Schlüssel (References-Chain-Root bzw.
98
+ * Provider-Thread-ID, provider-präfixed). */
99
+ threadKey: z.string().min(1).max(500),
100
+ from: z.string().min(1).max(2000),
101
+ /** JSON-stringified string[], dann PII-encrypted. */
102
+ to: z.string().max(8000),
103
+ /** JSON-stringified string[], dann PII-encrypted. */
104
+ cc: z.string().max(8000),
105
+ subject: z.string().max(4000),
106
+ snippet: z.string().max(4000),
107
+ /** ISO-Instant — Empfangszeit laut Provider (INTERNALDATE bzw.
108
+ * receivedDateTime), nicht Ingest-Zeit. */
109
+ receivedAtIso: z.string().min(1),
110
+ /** file-foundation-Referenz auf den raw-Body (MIME). Leer wenn die
111
+ * App ohne Body-Persistenz mounted (snippet-only-Mode). */
112
+ bodyRef: z.string().max(500),
113
+ /** Fachlicher Scope-Hint für nachgelagerte Business-Prozesse —
114
+ * generisch ("inbox", Folder-Name), KEINE App-Semantik. */
115
+ scope: z.string().max(200),
116
+ });
117
+ export type InboundMessageEventPayload = z.infer<typeof inboundMessageEventPayloadSchema>;
118
+
119
+ // ============================================================================
120
+ // mail-thread-Stream — Thread-Rollup pro (tenantId, threadKey)
121
+ // ============================================================================
122
+ export const mailThreadEventPayloadSchema = z.object({
123
+ threadKey: z.string().min(1).max(500),
124
+ subject: z.string().max(4000),
125
+ lastMessageAtIso: z.string().min(1),
126
+ messageCount: z.number().int().min(1),
127
+ });
128
+ export type MailThreadEventPayload = z.infer<typeof mailThreadEventPayloadSchema>;
129
+
130
+ // ============================================================================
131
+ // Headers-Shapes — event.metadata.headers (open-shape jsonb, primitives
132
+ // only). Idempotency-Anchor: (providerName, providerMessageId).
133
+ // ============================================================================
134
+ export type InboundMessageEventHeaders = {
135
+ readonly providerMessageId: string;
136
+ readonly providerName: string;
137
+ /** Provider-Cursor-Snapshot zum Ingest-Zeitpunkt (UIDVALIDITY:UID,
138
+ * deltaLink, historyId ...) — Debug/Replay-Hilfe, kein Payload. */
139
+ readonly providerCursor: string;
140
+ };
141
+
142
+ export type MailAccountEventHeaders = {
143
+ readonly providerName: string;
144
+ /** Was den Übergang ausgelöst hat ("connect_flow", "watch_supervisor",
145
+ * "oauth_refresh", "tenant_admin"). */
146
+ readonly reason: string;
147
+ };