@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.143.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -48,6 +48,9 @@
|
|
|
48
48
|
"./subscription-mollie": "./src/subscription-mollie/index.ts",
|
|
49
49
|
"./foundation-shared": "./src/foundation-shared/index.ts",
|
|
50
50
|
"./mail-foundation": "./src/mail-foundation/index.ts",
|
|
51
|
+
"./inbound-mail-foundation": "./src/inbound-mail-foundation/index.ts",
|
|
52
|
+
"./inbound-provider-inmemory": "./src/inbound-provider-inmemory/index.ts",
|
|
53
|
+
"./inbound-provider-imap": "./src/inbound-provider-imap/index.ts",
|
|
51
54
|
"./mail-transport-smtp": "./src/mail-transport-smtp/index.ts",
|
|
52
55
|
"./mail-transport-inmemory": "./src/mail-transport-inmemory/index.ts",
|
|
53
56
|
"./file-foundation": "./src/file-foundation/index.ts",
|
|
@@ -107,12 +110,14 @@
|
|
|
107
110
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
108
111
|
},
|
|
109
112
|
"dependencies": {
|
|
110
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
111
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
112
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
113
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
114
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
113
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.143.0",
|
|
114
|
+
"@cosmicdrift/kumiko-framework": "0.143.0",
|
|
115
|
+
"@cosmicdrift/kumiko-headless": "0.143.0",
|
|
116
|
+
"@cosmicdrift/kumiko-renderer": "0.143.0",
|
|
117
|
+
"@cosmicdrift/kumiko-renderer-web": "0.143.0",
|
|
115
118
|
"@mollie/api-client": "^4.5.0",
|
|
119
|
+
"imapflow": "^1.3.3",
|
|
120
|
+
"mailparser": "^3.9.8",
|
|
116
121
|
"@node-rs/argon2": "^2.0.2",
|
|
117
122
|
"@types/nodemailer": "^8.0.0",
|
|
118
123
|
"clsx": "^2.1.1",
|
|
@@ -133,6 +138,7 @@
|
|
|
133
138
|
"LICENSE"
|
|
134
139
|
],
|
|
135
140
|
"devDependencies": {
|
|
136
|
-
"@testing-library/user-event": "^14.6.1"
|
|
141
|
+
"@testing-library/user-event": "^14.6.1",
|
|
142
|
+
"@types/mailparser": "^3.4.6"
|
|
137
143
|
}
|
|
138
144
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// feature.ts contract tests for inbound-mail-foundation.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { Temporal } from "temporal-polyfill";
|
|
5
|
+
import { inboundMessageAggregateId, mailThreadAggregateId } from "../aggregate-id";
|
|
6
|
+
import {
|
|
7
|
+
INBOUND_MAIL_FOUNDATION_FEATURE,
|
|
8
|
+
INBOUND_MAIL_PROVIDER_EXTENSION,
|
|
9
|
+
inboundCredentialSecretKey,
|
|
10
|
+
} from "../constants";
|
|
11
|
+
import { inboundMailFoundationFeature } from "../feature";
|
|
12
|
+
import { isVisibleToCaller } from "../handlers/scope-visibility";
|
|
13
|
+
import { signOAuthState, verifyOAuthState } from "../oauth-state";
|
|
14
|
+
import { isInboundMailProviderPlugin } from "../types";
|
|
15
|
+
|
|
16
|
+
describe("inboundMailFoundationFeature — shape", () => {
|
|
17
|
+
test("has the expected name", () => {
|
|
18
|
+
expect(inboundMailFoundationFeature.name).toBe(INBOUND_MAIL_FOUNDATION_FEATURE);
|
|
19
|
+
expect(inboundMailFoundationFeature.name).toBe("inbound-mail-foundation");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("does NOT require config — Multi-Provider, Provider-Config liegt in den Plugins", () => {
|
|
23
|
+
// Drift-Pin (Plan §1.3): KEIN globaler provider-config-key. Ein
|
|
24
|
+
// Tenant kann parallel IMAP + M365 + Gmail verbinden — der Provider
|
|
25
|
+
// steht pro MailAccount-Row.
|
|
26
|
+
expect(inboundMailFoundationFeature.requires).not.toContain("config");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("declares the 'inboundMailProvider' extension-point", () => {
|
|
30
|
+
expect(
|
|
31
|
+
inboundMailFoundationFeature.registrarExtensions[INBOUND_MAIL_PROVIDER_EXTENSION],
|
|
32
|
+
).toBeDefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("5 domain-events registriert", () => {
|
|
36
|
+
const events = inboundMailFoundationFeature.events;
|
|
37
|
+
expect(events["mail-account-connected"]).toBeDefined();
|
|
38
|
+
expect(events["mail-account-updated"]).toBeDefined();
|
|
39
|
+
expect(events["mail-account-disconnected"]).toBeDefined();
|
|
40
|
+
expect(events["inbound-message-received"]).toBeDefined();
|
|
41
|
+
expect(events["mail-thread-updated"]).toBeDefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("SyncCursor + SeenMessage sind unmanaged (NICHT event-sourced, Plan §3.4)", () => {
|
|
45
|
+
// Drift-Pin: als r.entity würden die Tick-State-Tabellen (a) das
|
|
46
|
+
// Event-Log fluten und (b) beim Projection-Rebuild gewischt.
|
|
47
|
+
const unmanaged = Object.keys(inboundMailFoundationFeature.unmanagedTables);
|
|
48
|
+
expect(unmanaged.length).toBe(2);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("aggregate-ids — deterministic drift-pins", () => {
|
|
53
|
+
// In Stein gemeißelt: ein Namespace-Wechsel würde jeden existing
|
|
54
|
+
// Stream re-keyen → kaputte Idempotency. Werte einmalig berechnet.
|
|
55
|
+
test("inboundMessageAggregateId is stable", () => {
|
|
56
|
+
expect(inboundMessageAggregateId("11111111-1111-1111-1111-111111111111", "uid-42")).toBe(
|
|
57
|
+
"c3a5c3fc-706e-5c66-8617-d6c08e0f4a6b",
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("mailThreadAggregateId is stable", () => {
|
|
62
|
+
expect(
|
|
63
|
+
mailThreadAggregateId("22222222-2222-2222-2222-222222222222", "mid:root@example.com"),
|
|
64
|
+
).toBe("45c4d3a9-0a97-5a0b-a3c5-4ba9c8740dcd");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("same account + same providerMessageId → same stream (dedup anchor)", () => {
|
|
68
|
+
const a = inboundMessageAggregateId("acc", "m1");
|
|
69
|
+
expect(inboundMessageAggregateId("acc", "m1")).toBe(a);
|
|
70
|
+
expect(inboundMessageAggregateId("acc2", "m1")).not.toBe(a);
|
|
71
|
+
expect(inboundMessageAggregateId("acc", "m2")).not.toBe(a);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("oauth-state — sign/verify roundtrip", () => {
|
|
76
|
+
const secret = "test-state-secret";
|
|
77
|
+
const payload = {
|
|
78
|
+
tenantId: "00000000-0000-0000-0000-00000000aaaa",
|
|
79
|
+
ownerUserId: "00000000-0000-0000-0000-00000000bbbb",
|
|
80
|
+
providerKey: "imap",
|
|
81
|
+
mailbox: "user@example.com",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
test("roundtrip carries tenantId + ownerUserId + providerKey + mailbox", () => {
|
|
85
|
+
const state = signOAuthState(payload, 15, secret);
|
|
86
|
+
const verified = verifyOAuthState(state, secret);
|
|
87
|
+
expect(verified.ok).toBe(true);
|
|
88
|
+
if (!verified.ok) return;
|
|
89
|
+
expect(verified.payload.tenantId).toBe(payload.tenantId);
|
|
90
|
+
expect(verified.payload.ownerUserId).toBe(payload.ownerUserId);
|
|
91
|
+
expect(verified.payload.providerKey).toBe("imap");
|
|
92
|
+
expect(verified.payload.mailbox).toBe("user@example.com");
|
|
93
|
+
expect(verified.payload.nonce.length).toBeGreaterThan(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("ownerUserId=null (shared mailbox) survives the roundtrip", () => {
|
|
97
|
+
const state = signOAuthState({ ...payload, ownerUserId: null }, 15, secret);
|
|
98
|
+
const verified = verifyOAuthState(state, secret);
|
|
99
|
+
expect(verified.ok).toBe(true);
|
|
100
|
+
if (!verified.ok) return;
|
|
101
|
+
expect(verified.payload.ownerUserId).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("tampered payload → bad_signature", () => {
|
|
105
|
+
const state = signOAuthState(payload, 15, secret);
|
|
106
|
+
const [b64, exp, sig] = state.split(".");
|
|
107
|
+
const evil = {
|
|
108
|
+
...payload,
|
|
109
|
+
tenantId: "00000000-0000-0000-0000-00000000cccc",
|
|
110
|
+
nonce: "x",
|
|
111
|
+
};
|
|
112
|
+
const evilB64 = Buffer.from(JSON.stringify(evil), "utf8").toString("base64url");
|
|
113
|
+
const forged = `${evilB64}.${exp}.${sig}`;
|
|
114
|
+
expect(b64).not.toBe(evilB64);
|
|
115
|
+
expect(verifyOAuthState(forged, secret)).toEqual({ ok: false, reason: "bad_signature" });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("wrong secret → bad_signature", () => {
|
|
119
|
+
const state = signOAuthState(payload, 15, secret);
|
|
120
|
+
expect(verifyOAuthState(state, "other-secret")).toEqual({
|
|
121
|
+
ok: false,
|
|
122
|
+
reason: "bad_signature",
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("expired state → expired", () => {
|
|
127
|
+
const past = Temporal.Now.instant().subtract({ hours: 1 });
|
|
128
|
+
const state = signOAuthState(payload, 15, secret, past);
|
|
129
|
+
expect(verifyOAuthState(state, secret)).toEqual({ ok: false, reason: "expired" });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("garbage → malformed", () => {
|
|
133
|
+
expect(verifyOAuthState("not-a-state", secret)).toEqual({ ok: false, reason: "malformed" });
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("scope-visibility (Plan Entscheidung 2)", () => {
|
|
138
|
+
const owner = { id: "user-1", roles: ["User"] as const };
|
|
139
|
+
const other = { id: "user-2", roles: ["User"] as const };
|
|
140
|
+
const admin = { id: "user-3", roles: ["TenantAdmin"] as const };
|
|
141
|
+
|
|
142
|
+
test("shared row (ownerUserId=null) ist für alle sichtbar", () => {
|
|
143
|
+
expect(isVisibleToCaller({ ownerUserId: null }, owner)).toBe(true);
|
|
144
|
+
expect(isVisibleToCaller({ ownerUserId: null }, other)).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("persönliche Row nur für Owner + TenantAdmin", () => {
|
|
148
|
+
const row = { ownerUserId: "user-1" };
|
|
149
|
+
expect(isVisibleToCaller(row, owner)).toBe(true);
|
|
150
|
+
expect(isVisibleToCaller(row, other)).toBe(false);
|
|
151
|
+
expect(isVisibleToCaller(row, admin)).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("plugin type-guard + secret-key helper", () => {
|
|
156
|
+
test("isInboundMailProviderPlugin verlangt verify + fetch", () => {
|
|
157
|
+
expect(isInboundMailProviderPlugin({ verify: async () => {}, fetch: async () => ({}) })).toBe(
|
|
158
|
+
true,
|
|
159
|
+
);
|
|
160
|
+
expect(isInboundMailProviderPlugin({ verify: async () => {} })).toBe(false);
|
|
161
|
+
expect(isInboundMailProviderPlugin(null)).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("inboundCredentialSecretKey ist per-Account gekeyt", () => {
|
|
165
|
+
expect(inboundCredentialSecretKey("abc")).toBe(
|
|
166
|
+
"inbound-mail-foundation:inbound.credential.abc",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
// Integration-test für inbound-mail-foundation. Treibt connect/ingest/
|
|
2
|
+
// list/disconnect durch den full Dispatcher + DB (setupTestStack, echte
|
|
3
|
+
// HTTP — kein Fake-Dispatcher).
|
|
4
|
+
//
|
|
5
|
+
// Plan-Pflicht-Szenarien (§6 Phase 1):
|
|
6
|
+
// - Dedup: 2× dieselbe providerMessageId → 1 Event/Row, duplicate=true
|
|
7
|
+
// - Raw-Event-Row: PII als Ciphertext im Event-Store, Klartext nur
|
|
8
|
+
// über die decrypt-on-read-Queries
|
|
9
|
+
// - Projection-Rebuild: Event-Log-Replay reproduziert die Read-Models
|
|
10
|
+
// - Scope-Sichtbarkeit (ownerUserId): persönliches Postfach nur für
|
|
11
|
+
// Owner + TenantAdmin
|
|
12
|
+
// - Crypto-Shredding: Subject-Key-Erase macht Payload unlesbar (#800)
|
|
13
|
+
|
|
14
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
15
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import {
|
|
17
|
+
configuredPiiSubjectKms,
|
|
18
|
+
configurePiiSubjectKms,
|
|
19
|
+
decryptPiiFieldValues,
|
|
20
|
+
InMemoryKmsAdapter,
|
|
21
|
+
isPiiCiphertext,
|
|
22
|
+
PII_ERASED_SENTINEL,
|
|
23
|
+
resetPiiSubjectKmsForTests,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
25
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
26
|
+
import { createEventsTable, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
|
|
27
|
+
import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
28
|
+
import {
|
|
29
|
+
createTestUser,
|
|
30
|
+
setupTestStack,
|
|
31
|
+
type TestStack,
|
|
32
|
+
testTenantId,
|
|
33
|
+
unsafeCreateEntityTable,
|
|
34
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
35
|
+
import {
|
|
36
|
+
createComplianceProfilesFeature,
|
|
37
|
+
tenantComplianceProfileEntity,
|
|
38
|
+
} from "../../compliance-profiles";
|
|
39
|
+
import { createConfigFeature } from "../../config";
|
|
40
|
+
import { inboundProviderInMemoryFeature } from "../../inbound-provider-inmemory";
|
|
41
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
42
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
43
|
+
import { inboundMessageAggregateId, mailThreadAggregateId } from "../aggregate-id";
|
|
44
|
+
import { InboundMailFoundationHandlers, InboundMailFoundationQueries } from "../constants";
|
|
45
|
+
import { seenMessageEntity, syncCursorEntity } from "../entities";
|
|
46
|
+
import { inboundMailFoundationFeature } from "../feature";
|
|
47
|
+
import {
|
|
48
|
+
inboundMessagesProjectionTable,
|
|
49
|
+
mailAccountsProjectionTable,
|
|
50
|
+
mailThreadsProjectionTable,
|
|
51
|
+
} from "../projection";
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// Setup
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
let stack: TestStack;
|
|
58
|
+
let db: DbConnection;
|
|
59
|
+
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
stack = await setupTestStack({
|
|
62
|
+
features: [
|
|
63
|
+
createConfigFeature(),
|
|
64
|
+
createTenantFeature(),
|
|
65
|
+
createComplianceProfilesFeature(),
|
|
66
|
+
createTenantLifecycleFeature(),
|
|
67
|
+
inboundMailFoundationFeature,
|
|
68
|
+
inboundProviderInMemoryFeature,
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
db = stack.db;
|
|
72
|
+
await createEventsTable(db);
|
|
73
|
+
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
74
|
+
// Unmanaged direct-write stores — kein r.entity, kein Auto-Push.
|
|
75
|
+
await unsafeCreateEntityTable(db, syncCursorEntity);
|
|
76
|
+
await unsafeCreateEntityTable(db, seenMessageEntity);
|
|
77
|
+
// PII-Felder sind tenantOwned — Handler rufen configuredPiiSubjectKms()
|
|
78
|
+
// direkt (raw r.projection, kein Executor-Wiring), wie run{Prod,Dev}App
|
|
79
|
+
// at boot.
|
|
80
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
await stack.cleanup();
|
|
85
|
+
resetPiiSubjectKmsForTests();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function adminFor(tenantNumber: number) {
|
|
89
|
+
return createTestUser({
|
|
90
|
+
id: tenantNumber,
|
|
91
|
+
tenantId: testTenantId(tenantNumber),
|
|
92
|
+
roles: ["TenantAdmin", "SystemAdmin"],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function ingestPayload(
|
|
97
|
+
accountId: string,
|
|
98
|
+
overrides: Partial<{
|
|
99
|
+
ownerUserId: string | null;
|
|
100
|
+
providerMessageId: string;
|
|
101
|
+
messageIdHeader: string | null;
|
|
102
|
+
references: string[];
|
|
103
|
+
from: string;
|
|
104
|
+
subject: string;
|
|
105
|
+
receivedAtIso: string;
|
|
106
|
+
}> = {},
|
|
107
|
+
) {
|
|
108
|
+
return {
|
|
109
|
+
accountId,
|
|
110
|
+
ownerUserId: overrides.ownerUserId ?? null,
|
|
111
|
+
providerName: "inmemory",
|
|
112
|
+
providerMessageId: overrides.providerMessageId ?? "uid-1",
|
|
113
|
+
messageIdHeader: overrides.messageIdHeader ?? "msg-1@example.com",
|
|
114
|
+
providerThreadId: null,
|
|
115
|
+
references: overrides.references ?? [],
|
|
116
|
+
from: overrides.from ?? "sender@example.com",
|
|
117
|
+
to: ["inbox@tenant.example"],
|
|
118
|
+
cc: [],
|
|
119
|
+
subject: overrides.subject ?? "Hello",
|
|
120
|
+
snippet: "Hello world …",
|
|
121
|
+
receivedAtIso: overrides.receivedAtIso ?? "2026-07-01T10:00:00Z",
|
|
122
|
+
bodyRef: "",
|
|
123
|
+
scope: "inbox",
|
|
124
|
+
providerCursor: '{"offset":1}',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function connectSharedAccount(admin: ReturnType<typeof adminFor>): Promise<string> {
|
|
129
|
+
const result = (await stack.http.writeOk(
|
|
130
|
+
InboundMailFoundationHandlers.connectAccount,
|
|
131
|
+
{
|
|
132
|
+
provider: "inmemory",
|
|
133
|
+
authMethod: "password",
|
|
134
|
+
displayName: "Team-Inbox",
|
|
135
|
+
address: "inbox@tenant.example",
|
|
136
|
+
scope: "shared",
|
|
137
|
+
},
|
|
138
|
+
admin,
|
|
139
|
+
)) as { accountId: string };
|
|
140
|
+
expect(result.accountId).toBeDefined();
|
|
141
|
+
return result.accountId;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// =============================================================================
|
|
145
|
+
// Scenarios
|
|
146
|
+
// =============================================================================
|
|
147
|
+
|
|
148
|
+
describe("scenario 1: connect-account + list mit decrypt-on-read", () => {
|
|
149
|
+
test("connect legt Stream + Projection-Row an; address ist at-rest Ciphertext, list liefert Klartext", async () => {
|
|
150
|
+
const admin = adminFor(4001);
|
|
151
|
+
const accountId = await connectSharedAccount(admin);
|
|
152
|
+
|
|
153
|
+
// Raw event-log payload: Ciphertext.
|
|
154
|
+
const events = await loadAggregate(db, accountId, admin.tenantId);
|
|
155
|
+
expect(events).toHaveLength(1);
|
|
156
|
+
const payload = events[0]?.payload as { address?: string };
|
|
157
|
+
expect(payload.address).not.toBe("inbox@tenant.example");
|
|
158
|
+
expect(isPiiCiphertext(payload.address)).toBe(true);
|
|
159
|
+
|
|
160
|
+
// Raw projection row: Ciphertext.
|
|
161
|
+
const rawRows = await selectMany(db, mailAccountsProjectionTable, { id: accountId });
|
|
162
|
+
expect(rawRows).toHaveLength(1);
|
|
163
|
+
expect(isPiiCiphertext(rawRows[0]?.["address"])).toBe(true);
|
|
164
|
+
expect(rawRows[0]?.["status"]).toBe("active");
|
|
165
|
+
|
|
166
|
+
// decrypt-on-read list-query: Klartext.
|
|
167
|
+
const list = (await stack.http.queryOk(
|
|
168
|
+
InboundMailFoundationQueries.listAccounts,
|
|
169
|
+
{},
|
|
170
|
+
admin,
|
|
171
|
+
)) as { rows: Array<Record<string, unknown>> };
|
|
172
|
+
const row = list.rows.find((r) => r["id"] === accountId);
|
|
173
|
+
expect(row?.["address"]).toBe("inbox@tenant.example");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("scenario 2: ingest-message — Dedup + Thread-Rollup", () => {
|
|
178
|
+
test("dieselbe providerMessageId 2× → genau 1 Row, duplicate=true; Thread zählt korrekt", async () => {
|
|
179
|
+
const admin = adminFor(4002);
|
|
180
|
+
const accountId = await connectSharedAccount(admin);
|
|
181
|
+
|
|
182
|
+
const first = (await stack.http.writeOk(
|
|
183
|
+
InboundMailFoundationHandlers.ingestMessage,
|
|
184
|
+
ingestPayload(accountId, { providerMessageId: "uid-1", messageIdHeader: "root@example.com" }),
|
|
185
|
+
admin,
|
|
186
|
+
)) as Record<string, unknown>;
|
|
187
|
+
expect(first["duplicate"]).toBe(false);
|
|
188
|
+
expect(first["inboundMessageAggregateId"]).toBe(inboundMessageAggregateId(accountId, "uid-1"));
|
|
189
|
+
|
|
190
|
+
// Replay (IDLE-Doppel-Notify / Cursor-Overlap) → duplicate, kein 2. Row.
|
|
191
|
+
const replay = (await stack.http.writeOk(
|
|
192
|
+
InboundMailFoundationHandlers.ingestMessage,
|
|
193
|
+
ingestPayload(accountId, { providerMessageId: "uid-1", messageIdHeader: "root@example.com" }),
|
|
194
|
+
admin,
|
|
195
|
+
)) as Record<string, unknown>;
|
|
196
|
+
expect(replay["duplicate"]).toBe(true);
|
|
197
|
+
|
|
198
|
+
const msgRows = await selectMany(db, inboundMessagesProjectionTable, {
|
|
199
|
+
accountId,
|
|
200
|
+
});
|
|
201
|
+
expect(msgRows).toHaveLength(1);
|
|
202
|
+
|
|
203
|
+
// Reply im selben Thread (References-Root) → Thread-Count 2.
|
|
204
|
+
const reply = (await stack.http.writeOk(
|
|
205
|
+
InboundMailFoundationHandlers.ingestMessage,
|
|
206
|
+
ingestPayload(accountId, {
|
|
207
|
+
providerMessageId: "uid-2",
|
|
208
|
+
messageIdHeader: "reply-1@example.com",
|
|
209
|
+
references: ["root@example.com"],
|
|
210
|
+
subject: "Re: Hello",
|
|
211
|
+
receivedAtIso: "2026-07-01T11:00:00Z",
|
|
212
|
+
}),
|
|
213
|
+
admin,
|
|
214
|
+
)) as Record<string, unknown>;
|
|
215
|
+
expect(reply["duplicate"]).toBe(false);
|
|
216
|
+
expect(reply["threadKey"]).toBe("mid:root@example.com");
|
|
217
|
+
expect(reply["threadAggregateId"]).toBe(
|
|
218
|
+
mailThreadAggregateId(admin.tenantId, "mid:root@example.com"),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const threadRows = await selectMany(db, mailThreadsProjectionTable, {
|
|
222
|
+
id: mailThreadAggregateId(admin.tenantId, "mid:root@example.com"),
|
|
223
|
+
});
|
|
224
|
+
expect(threadRows).toHaveLength(1);
|
|
225
|
+
expect(threadRows[0]?.["messageCount"]).toBe(2);
|
|
226
|
+
expect(String(threadRows[0]?.["lastMessageAt"])).toContain("2026-07-01T11:00:00");
|
|
227
|
+
|
|
228
|
+
// Raw message row: PII-Ciphertext; message:list liefert Klartext.
|
|
229
|
+
expect(isPiiCiphertext(msgRows[0]?.["from"])).toBe(true);
|
|
230
|
+
expect(isPiiCiphertext(msgRows[0]?.["subject"])).toBe(true);
|
|
231
|
+
const list = (await stack.http.queryOk(
|
|
232
|
+
InboundMailFoundationQueries.listMessages,
|
|
233
|
+
{ accountId },
|
|
234
|
+
admin,
|
|
235
|
+
)) as { rows: Array<Record<string, unknown>> };
|
|
236
|
+
expect(list.rows).toHaveLength(2);
|
|
237
|
+
const firstRow = list.rows.find((r) => r["messageIdHeader"] === "root@example.com");
|
|
238
|
+
expect(firstRow?.["from"]).toBe("sender@example.com");
|
|
239
|
+
expect(firstRow?.["subject"]).toBe("Hello");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("scenario 3: Scope-Sichtbarkeit (persönliches Postfach)", () => {
|
|
244
|
+
test("Messages eines user-scoped Accounts sieht nur der Owner (+ TenantAdmin)", async () => {
|
|
245
|
+
const tenantNumber = 4003;
|
|
246
|
+
const owner = createTestUser({
|
|
247
|
+
id: 40031,
|
|
248
|
+
tenantId: testTenantId(tenantNumber),
|
|
249
|
+
roles: ["TenantAdmin", "SystemAdmin"],
|
|
250
|
+
});
|
|
251
|
+
const otherUser = createTestUser({
|
|
252
|
+
id: 40032,
|
|
253
|
+
tenantId: testTenantId(tenantNumber),
|
|
254
|
+
roles: ["User"],
|
|
255
|
+
});
|
|
256
|
+
const otherAdmin = createTestUser({
|
|
257
|
+
id: 40033,
|
|
258
|
+
tenantId: testTenantId(tenantNumber),
|
|
259
|
+
roles: ["TenantAdmin"],
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const connect = (await stack.http.writeOk(
|
|
263
|
+
InboundMailFoundationHandlers.connectAccount,
|
|
264
|
+
{
|
|
265
|
+
provider: "inmemory",
|
|
266
|
+
authMethod: "password",
|
|
267
|
+
displayName: "Privat",
|
|
268
|
+
address: "owner@tenant.example",
|
|
269
|
+
scope: "user",
|
|
270
|
+
},
|
|
271
|
+
owner,
|
|
272
|
+
)) as { accountId: string };
|
|
273
|
+
|
|
274
|
+
await stack.http.writeOk(
|
|
275
|
+
InboundMailFoundationHandlers.ingestMessage,
|
|
276
|
+
ingestPayload(connect.accountId, {
|
|
277
|
+
ownerUserId: owner.id,
|
|
278
|
+
providerMessageId: "uid-personal-1",
|
|
279
|
+
messageIdHeader: "personal-1@example.com",
|
|
280
|
+
}),
|
|
281
|
+
owner,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const forOwner = (await stack.http.queryOk(
|
|
285
|
+
InboundMailFoundationQueries.listMessages,
|
|
286
|
+
{ accountId: connect.accountId },
|
|
287
|
+
owner,
|
|
288
|
+
)) as { rows: unknown[] };
|
|
289
|
+
expect(forOwner.rows).toHaveLength(1);
|
|
290
|
+
|
|
291
|
+
const forOther = (await stack.http.queryOk(
|
|
292
|
+
InboundMailFoundationQueries.listMessages,
|
|
293
|
+
{ accountId: connect.accountId },
|
|
294
|
+
otherUser,
|
|
295
|
+
)) as { rows: unknown[] };
|
|
296
|
+
expect(forOther.rows).toHaveLength(0);
|
|
297
|
+
|
|
298
|
+
const forAdmin = (await stack.http.queryOk(
|
|
299
|
+
InboundMailFoundationQueries.listMessages,
|
|
300
|
+
{ accountId: connect.accountId },
|
|
301
|
+
otherAdmin,
|
|
302
|
+
)) as { rows: unknown[] };
|
|
303
|
+
expect(forAdmin.rows).toHaveLength(1);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("scenario 4: Account-Lifecycle", () => {
|
|
308
|
+
test("update → disconnect → update wird abgelehnt, disconnect ist idempotent", async () => {
|
|
309
|
+
const admin = adminFor(4004);
|
|
310
|
+
const accountId = await connectSharedAccount(admin);
|
|
311
|
+
|
|
312
|
+
await stack.http.writeOk(
|
|
313
|
+
InboundMailFoundationHandlers.updateAccount,
|
|
314
|
+
{ accountId, status: "degraded", watchState: "backoff:5000ms", reason: "test" },
|
|
315
|
+
admin,
|
|
316
|
+
);
|
|
317
|
+
let rows = await selectMany(db, mailAccountsProjectionTable, { id: accountId });
|
|
318
|
+
expect(rows[0]?.["status"]).toBe("degraded");
|
|
319
|
+
// connected_at bleibt der Erst-Connect-Zeitpunkt (SET-Klausel lässt
|
|
320
|
+
// die Spalte aus).
|
|
321
|
+
const connectedAt = String(rows[0]?.["connectedAt"]);
|
|
322
|
+
|
|
323
|
+
const disconnect = (await stack.http.writeOk(
|
|
324
|
+
InboundMailFoundationHandlers.disconnectAccount,
|
|
325
|
+
{ accountId, reason: "test" },
|
|
326
|
+
admin,
|
|
327
|
+
)) as Record<string, unknown>;
|
|
328
|
+
expect(disconnect["alreadyDisconnected"]).toBe(false);
|
|
329
|
+
rows = await selectMany(db, mailAccountsProjectionTable, { id: accountId });
|
|
330
|
+
expect(rows[0]?.["status"]).toBe("disconnected");
|
|
331
|
+
expect(String(rows[0]?.["connectedAt"])).toBe(connectedAt);
|
|
332
|
+
|
|
333
|
+
// Idempotent: zweiter disconnect ist success-no-op.
|
|
334
|
+
const again = (await stack.http.writeOk(
|
|
335
|
+
InboundMailFoundationHandlers.disconnectAccount,
|
|
336
|
+
{ accountId, reason: "test" },
|
|
337
|
+
admin,
|
|
338
|
+
)) as Record<string, unknown>;
|
|
339
|
+
expect(again["alreadyDisconnected"]).toBe(true);
|
|
340
|
+
|
|
341
|
+
// update auf disconnected Account → 422 (kein Supervisor-Race darf
|
|
342
|
+
// einen getrennten Account wiederbeleben).
|
|
343
|
+
const err = await stack.http.writeErr(
|
|
344
|
+
InboundMailFoundationHandlers.updateAccount,
|
|
345
|
+
{ accountId, status: "active", reason: "test" },
|
|
346
|
+
admin,
|
|
347
|
+
);
|
|
348
|
+
expect(err.httpStatus).toBe(422);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("scenario 5: Projection-Rebuild reproduziert die Read-Models (Plan §3.4)", () => {
|
|
353
|
+
test("rebuild aus dem Event-Log → identische inbound-message-Rows", async () => {
|
|
354
|
+
const admin = adminFor(4005);
|
|
355
|
+
const accountId = await connectSharedAccount(admin);
|
|
356
|
+
await stack.http.writeOk(
|
|
357
|
+
InboundMailFoundationHandlers.ingestMessage,
|
|
358
|
+
ingestPayload(accountId, { providerMessageId: "uid-r1", messageIdHeader: "r1@example.com" }),
|
|
359
|
+
admin,
|
|
360
|
+
);
|
|
361
|
+
await stack.http.writeOk(
|
|
362
|
+
InboundMailFoundationHandlers.ingestMessage,
|
|
363
|
+
ingestPayload(accountId, {
|
|
364
|
+
providerMessageId: "uid-r2",
|
|
365
|
+
messageIdHeader: "r2@example.com",
|
|
366
|
+
references: ["r1@example.com"],
|
|
367
|
+
receivedAtIso: "2026-07-02T09:00:00Z",
|
|
368
|
+
}),
|
|
369
|
+
admin,
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
const before = await selectMany(db, inboundMessagesProjectionTable, { accountId });
|
|
373
|
+
expect(before).toHaveLength(2);
|
|
374
|
+
|
|
375
|
+
const result = await rebuildProjection("inbound-mail-foundation:projection:inbound-message", {
|
|
376
|
+
db,
|
|
377
|
+
registry: stack.registry,
|
|
378
|
+
});
|
|
379
|
+
expect(result.eventsProcessed).toBeGreaterThanOrEqual(2);
|
|
380
|
+
|
|
381
|
+
const after = await selectMany(db, inboundMessagesProjectionTable, { accountId });
|
|
382
|
+
expect(after).toHaveLength(2);
|
|
383
|
+
const key = (rows: Array<Record<string, unknown>>) =>
|
|
384
|
+
rows.map((r) => `${r["id"]}|${r["threadKey"]}|${r["from"]}|${r["subject"]}`).sort();
|
|
385
|
+
expect(key(after as Array<Record<string, unknown>>)).toEqual(
|
|
386
|
+
key(before as Array<Record<string, unknown>>),
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
// Thread-Rollup ebenso rebuild-fähig.
|
|
390
|
+
const threadResult = await rebuildProjection("inbound-mail-foundation:projection:mail-thread", {
|
|
391
|
+
db,
|
|
392
|
+
registry: stack.registry,
|
|
393
|
+
});
|
|
394
|
+
expect(threadResult.eventsProcessed).toBeGreaterThanOrEqual(2);
|
|
395
|
+
const threads = await selectMany(db, mailThreadsProjectionTable, {
|
|
396
|
+
id: mailThreadAggregateId(admin.tenantId, "mid:r1@example.com"),
|
|
397
|
+
});
|
|
398
|
+
expect(threads[0]?.["messageCount"]).toBe(2);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe("scenario 6: crypto-shredding (#800)", () => {
|
|
403
|
+
test("Subject-Key-Erase macht den Event-Payload permanent unlesbar", async () => {
|
|
404
|
+
const admin = adminFor(4006);
|
|
405
|
+
const accountId = await connectSharedAccount(admin);
|
|
406
|
+
await stack.http.writeOk(
|
|
407
|
+
InboundMailFoundationHandlers.ingestMessage,
|
|
408
|
+
ingestPayload(accountId, {
|
|
409
|
+
providerMessageId: "uid-shred",
|
|
410
|
+
messageIdHeader: "shred@example.com",
|
|
411
|
+
from: "secret-sender@example.com",
|
|
412
|
+
}),
|
|
413
|
+
admin,
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
const events = await loadAggregate(
|
|
417
|
+
db,
|
|
418
|
+
inboundMessageAggregateId(accountId, "uid-shred"),
|
|
419
|
+
admin.tenantId,
|
|
420
|
+
);
|
|
421
|
+
const ciphertext = (events[0]?.payload as { from?: string }).from as string;
|
|
422
|
+
expect(isPiiCiphertext(ciphertext)).toBe(true);
|
|
423
|
+
|
|
424
|
+
const kms = configuredPiiSubjectKms();
|
|
425
|
+
if (!kms) throw new Error("expected a configured PII-subject KMS in this test stack");
|
|
426
|
+
await kms.eraseKey(
|
|
427
|
+
{ kind: "tenant", tenantId: admin.tenantId },
|
|
428
|
+
{ requestId: "test:crypto-shred", eraseReason: "test" },
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
const decrypted = await decryptPiiFieldValues({ from: ciphertext }, ["from"], kms, {
|
|
432
|
+
requestId: "test:crypto-shred-verify",
|
|
433
|
+
});
|
|
434
|
+
expect(decrypted["from"]).toBe(PII_ERASED_SENTINEL);
|
|
435
|
+
expect(decrypted["from"]).not.toBe("secret-sender@example.com");
|
|
436
|
+
});
|
|
437
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { v5 as uuidv5 } from "uuid";
|
|
2
|
+
|
|
3
|
+
// Fixed UUID-namespaces für deterministic aggregate-id-Ableitung.
|
|
4
|
+
// Generiert einmalig (2026-07-12), in Stein gemeißelt: ein Wechsel würde
|
|
5
|
+
// jeden existing aggregate-Stream re-keyen → kaputte Message-Historie,
|
|
6
|
+
// kaputte Idempotency. Drift-Pins in __tests__/feature.test.ts.
|
|
7
|
+
|
|
8
|
+
/** Namespace für inbound-message-Streams. */
|
|
9
|
+
const INBOUND_MESSAGE_NAMESPACE = "7f2a9c41-3d6b-4e0f-9c58-2b1e7a4d8f63";
|
|
10
|
+
|
|
11
|
+
/** Namespace für mail-thread-Streams. */
|
|
12
|
+
const MAIL_THREAD_NAMESPACE = "b48d1e72-5c9a-4f36-8e0d-6a3f9b2c7e15";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Deterministic aggregate-id für eine inbound-message. Key ist
|
|
16
|
+
* (accountId, providerMessageId): derselbe Provider-Fetch der zweimal
|
|
17
|
+
* dieselbe Message liefert (IDLE-Doppel-Notify, Cursor-Overlap,
|
|
18
|
+
* Watch-Restart-Replay) landet auf demselben Stream — der Idempotency-
|
|
19
|
+
* Check im ingest-handler sieht den bereits appendeten Event.
|
|
20
|
+
*
|
|
21
|
+
* accountId (nicht tenantId) im Key: zwei Accounts desselben Tenants
|
|
22
|
+
* können dieselbe Mail empfangen (CC an beide Postfächer) — das sind
|
|
23
|
+
* fachlich ZWEI inbound-messages.
|
|
24
|
+
*/
|
|
25
|
+
// @wrapper-known uuid-domain
|
|
26
|
+
export function inboundMessageAggregateId(accountId: string, providerMessageId: string): string {
|
|
27
|
+
return uuidv5(`${accountId}:${providerMessageId}`, INBOUND_MESSAGE_NAMESPACE);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Deterministic aggregate-id für einen mail-thread. Key ist
|
|
32
|
+
* (tenantId, threadKey) — Threads sind tenant-weit, nicht account-weit:
|
|
33
|
+
* eine Konversation die über zwei verbundene Postfächer läuft ist EIN
|
|
34
|
+
* Thread. threadKey kommt normalisiert vom Provider (References/
|
|
35
|
+
* In-Reply-To-Chain bzw. Provider-Thread-ID).
|
|
36
|
+
*/
|
|
37
|
+
// @wrapper-known uuid-domain
|
|
38
|
+
export function mailThreadAggregateId(tenantId: string, threadKey: string): string {
|
|
39
|
+
return uuidv5(`${tenantId}:${threadKey}`, MAIL_THREAD_NAMESPACE);
|
|
40
|
+
}
|