@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.
- 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,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
|
+
};
|