@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.
- package/package.json +13 -7
- package/src/inbound-mail-foundation/__tests__/feature.test.ts +169 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +437 -0
- package/src/inbound-mail-foundation/aggregate-id.ts +40 -0
- package/src/inbound-mail-foundation/connect-routes.ts +230 -0
- package/src/inbound-mail-foundation/constants.ts +80 -0
- package/src/inbound-mail-foundation/db/queries/inbound-projections.ts +38 -0
- package/src/inbound-mail-foundation/entities.ts +168 -0
- package/src/inbound-mail-foundation/events.ts +147 -0
- package/src/inbound-mail-foundation/feature.ts +185 -0
- package/src/inbound-mail-foundation/handlers/account-state.ts +23 -0
- package/src/inbound-mail-foundation/handlers/connect-account.write.ts +112 -0
- package/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +69 -0
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +279 -0
- package/src/inbound-mail-foundation/handlers/list-accounts.query.ts +44 -0
- package/src/inbound-mail-foundation/handlers/list-messages.query.ts +58 -0
- package/src/inbound-mail-foundation/handlers/scope-visibility.ts +14 -0
- package/src/inbound-mail-foundation/handlers/update-account.write.ts +78 -0
- package/src/inbound-mail-foundation/index.ts +97 -0
- package/src/inbound-mail-foundation/oauth-state.ts +103 -0
- package/src/inbound-mail-foundation/projection.ts +152 -0
- package/src/inbound-mail-foundation/provider-factory.ts +53 -0
- package/src/inbound-mail-foundation/tenant-destroy-hook.ts +93 -0
- package/src/inbound-mail-foundation/types.ts +250 -0
- package/src/inbound-mail-foundation/watch-supervisor.ts +482 -0
- package/src/inbound-provider-imap/__tests__/feature.test.ts +115 -0
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +194 -0
- package/src/inbound-provider-imap/credential-document.ts +51 -0
- package/src/inbound-provider-imap/feature.ts +287 -0
- package/src/inbound-provider-imap/imap-client.ts +158 -0
- package/src/inbound-provider-imap/index.ts +17 -0
- package/src/inbound-provider-inmemory/feature.ts +154 -0
- 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
|
+
};
|