@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,44 @@
|
|
|
1
|
+
// list-accounts — tenant-scoped Liste der verbundenen Postfächer fürs
|
|
2
|
+
// Connect-/Settings-UI. PII (address) wird beim Read decrypted (Muster
|
|
3
|
+
// billing-foundation list-subscriptions).
|
|
4
|
+
//
|
|
5
|
+
// **Scope-Sichtbarkeit (Plan Entscheidung 2):** shared-Postfächer
|
|
6
|
+
// (ownerUserId=null) sieht jeder Berechtigte; persönliche nur der Owner
|
|
7
|
+
// selbst + TenantAdmin/SystemAdmin (Compliance-Sicht). Filter läuft
|
|
8
|
+
// in-memory nach dem tenant-scoped select — Volumen ist die Anzahl
|
|
9
|
+
// verbundener Postfächer eines Tenants (einstellig bis zweistellig).
|
|
10
|
+
|
|
11
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
12
|
+
import {
|
|
13
|
+
configuredPiiSubjectKms,
|
|
14
|
+
decryptPiiFieldValues,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
16
|
+
import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { MAIL_ACCOUNT_PII_FIELDS } from "../entities";
|
|
19
|
+
import { mailAccountsProjectionTable } from "../projection";
|
|
20
|
+
import { isVisibleToCaller } from "./scope-visibility";
|
|
21
|
+
|
|
22
|
+
const listAccountsSchema = z.object({}).strict();
|
|
23
|
+
|
|
24
|
+
export const listAccountsQuery: QueryHandlerDef = {
|
|
25
|
+
name: "account:list",
|
|
26
|
+
schema: listAccountsSchema,
|
|
27
|
+
access: { roles: ["SystemAdmin", "TenantAdmin", "User"] },
|
|
28
|
+
handler: async (_query, ctx) => {
|
|
29
|
+
const allRows = await selectMany(ctx.db.raw, mailAccountsProjectionTable, {
|
|
30
|
+
tenantId: ctx.user.tenantId,
|
|
31
|
+
});
|
|
32
|
+
const rows = allRows.filter((row) => isVisibleToCaller(row, ctx.user));
|
|
33
|
+
const piiKms = configuredPiiSubjectKms();
|
|
34
|
+
if (!piiKms) return { rows };
|
|
35
|
+
const decryptedRows = await Promise.all(
|
|
36
|
+
rows.map((row) =>
|
|
37
|
+
decryptPiiFieldValues(row as Record<string, unknown>, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
|
|
38
|
+
requestId: `inbound-mail-foundation:list-accounts:${ctx.user.tenantId}`,
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
return { rows: decryptedRows };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// list-messages — tenant-scoped Message-Liste (Inbox-Cockpit).
|
|
2
|
+
// Optionale Filter: accountId (ein Postfach), threadKey (eine
|
|
3
|
+
// Konversation), scope (Folder-Hint). PII (from/to/cc/subject/snippet)
|
|
4
|
+
// wird beim Read decrypted; to/cc bleiben JSON-stringified strings —
|
|
5
|
+
// der Client parsed sie (dokumentiert im Event-Payload).
|
|
6
|
+
//
|
|
7
|
+
// Bewusst KEINE Pagination in V1: die Projection ist tenant-scoped und
|
|
8
|
+
// das Inbox-Cockpit lädt begrenzte Zeiträume; ein `limit`-Feld ist
|
|
9
|
+
// vorwärtskompatibel ergänzbar ohne den QN zu brechen.
|
|
10
|
+
|
|
11
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
12
|
+
import {
|
|
13
|
+
configuredPiiSubjectKms,
|
|
14
|
+
decryptPiiFieldValues,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
16
|
+
import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { INBOUND_MESSAGE_PII_FIELDS } from "../entities";
|
|
19
|
+
import { inboundMessagesProjectionTable } from "../projection";
|
|
20
|
+
import { isVisibleToCaller } from "./scope-visibility";
|
|
21
|
+
|
|
22
|
+
const listMessagesSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
accountId: z.uuid().optional(),
|
|
25
|
+
threadKey: z.string().min(1).max(500).optional(),
|
|
26
|
+
scope: z.string().min(1).max(200).optional(),
|
|
27
|
+
})
|
|
28
|
+
.strict();
|
|
29
|
+
type ListMessagesPayload = z.infer<typeof listMessagesSchema>;
|
|
30
|
+
|
|
31
|
+
export const listMessagesQuery: QueryHandlerDef = {
|
|
32
|
+
name: "message:list",
|
|
33
|
+
schema: listMessagesSchema,
|
|
34
|
+
access: { roles: ["SystemAdmin", "TenantAdmin", "User"] },
|
|
35
|
+
handler: async (query, ctx) => {
|
|
36
|
+
// @cast-boundary engine-payload — dispatcher-zod-validated payload
|
|
37
|
+
const payload = query.payload as ListMessagesPayload;
|
|
38
|
+
const filter: Record<string, unknown> = { tenantId: ctx.user.tenantId };
|
|
39
|
+
if (payload.accountId) filter["accountId"] = payload.accountId;
|
|
40
|
+
if (payload.threadKey) filter["threadKey"] = payload.threadKey;
|
|
41
|
+
if (payload.scope) filter["scope"] = payload.scope;
|
|
42
|
+
|
|
43
|
+
const allRows = await selectMany(ctx.db.raw, inboundMessagesProjectionTable, filter);
|
|
44
|
+
// Scope-Sichtbarkeit (Plan Entscheidung 2): Messages persönlicher
|
|
45
|
+
// Postfächer nur für Owner + TenantAdmin/SystemAdmin.
|
|
46
|
+
const rows = allRows.filter((row) => isVisibleToCaller(row, ctx.user));
|
|
47
|
+
const piiKms = configuredPiiSubjectKms();
|
|
48
|
+
if (!piiKms) return { rows };
|
|
49
|
+
const decryptedRows = await Promise.all(
|
|
50
|
+
rows.map((row) =>
|
|
51
|
+
decryptPiiFieldValues(row as Record<string, unknown>, INBOUND_MESSAGE_PII_FIELDS, piiKms, {
|
|
52
|
+
requestId: `inbound-mail-foundation:list-messages:${ctx.user.tenantId}`,
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
return { rows: decryptedRows };
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Scope-Sichtbarkeit (Plan Entscheidung 2): shared-Rows
|
|
2
|
+
// (ownerUserId=null) sieht jeder Berechtigte; persönliche nur der Owner
|
|
3
|
+
// selbst + TenantAdmin/SystemAdmin (Compliance-Sicht). Gilt identisch
|
|
4
|
+
// für read_mail_accounts und read_inbound_messages (Scope-Vererbung).
|
|
5
|
+
|
|
6
|
+
export function isVisibleToCaller(
|
|
7
|
+
row: unknown,
|
|
8
|
+
user: { readonly id: string; readonly roles: readonly string[] },
|
|
9
|
+
): boolean {
|
|
10
|
+
const owner = (row as Record<string, unknown>)["ownerUserId"];
|
|
11
|
+
if (owner == null || owner === "") return true;
|
|
12
|
+
if (owner === user.id) return true;
|
|
13
|
+
return user.roles.includes("TenantAdmin") || user.roles.includes("SystemAdmin");
|
|
14
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// update-account — Account-Status-Übergänge: Watch-Supervisor meldet
|
|
2
|
+
// error/backoff, OAuth-Refresh scheitert (auth_error), Tenant-Admin
|
|
3
|
+
// re-enabled nach Credential-Fix. Full-snapshot-Event: lädt den letzten
|
|
4
|
+
// Stand, merged die Delta-Felder, appendet den neuen Snapshot.
|
|
5
|
+
|
|
6
|
+
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { InboundMailAccountStatuses } from "../constants";
|
|
10
|
+
import {
|
|
11
|
+
MAIL_ACCOUNT_AGGREGATE_TYPE,
|
|
12
|
+
MAIL_ACCOUNT_UPDATED_EVENT_QN,
|
|
13
|
+
type MailAccountEventHeaders,
|
|
14
|
+
type MailAccountEventPayload,
|
|
15
|
+
} from "../events";
|
|
16
|
+
import { loadCurrentMailAccountPayload } from "./account-state";
|
|
17
|
+
|
|
18
|
+
export const updateAccountSchema = z.object({
|
|
19
|
+
accountId: z.uuid(),
|
|
20
|
+
status: z
|
|
21
|
+
.enum([
|
|
22
|
+
InboundMailAccountStatuses.active,
|
|
23
|
+
InboundMailAccountStatuses.authError,
|
|
24
|
+
InboundMailAccountStatuses.degraded,
|
|
25
|
+
])
|
|
26
|
+
.optional(),
|
|
27
|
+
/** Ops-Signal ("idle", "watching", "backoff:3", ...). */
|
|
28
|
+
watchState: z.string().max(100).optional(),
|
|
29
|
+
displayName: z.string().max(200).optional(),
|
|
30
|
+
/** Audit-Grund für metadata.headers ("watch_supervisor",
|
|
31
|
+
* "oauth_refresh", "tenant_admin"). */
|
|
32
|
+
reason: z.string().min(1).max(100),
|
|
33
|
+
});
|
|
34
|
+
type UpdateAccountPayload = z.infer<typeof updateAccountSchema>;
|
|
35
|
+
|
|
36
|
+
export const updateAccountHandler: WriteHandlerDef = {
|
|
37
|
+
name: "update-account",
|
|
38
|
+
schema: updateAccountSchema,
|
|
39
|
+
access: { roles: ["SystemAdmin", "TenantAdmin"] },
|
|
40
|
+
handler: async (event, ctx) => {
|
|
41
|
+
// @cast-boundary engine-payload — dispatcher-zod-validated payload
|
|
42
|
+
const payload = event.payload as UpdateAccountPayload;
|
|
43
|
+
|
|
44
|
+
const current = await loadCurrentMailAccountPayload(ctx, payload.accountId);
|
|
45
|
+
if (!current) {
|
|
46
|
+
return failNotFound("mail-account", payload.accountId);
|
|
47
|
+
}
|
|
48
|
+
if (current.status === InboundMailAccountStatuses.disconnected) {
|
|
49
|
+
// Getrennte Accounts sind final — Re-Enable läuft über einen
|
|
50
|
+
// frischen connect (neuer Stream, neuer Secret-Slot). Verhindert
|
|
51
|
+
// dass ein nachzügelnder Supervisor-Tick einen disconnected
|
|
52
|
+
// Account wieder auf active hebt (Race Disconnect vs. Poll).
|
|
53
|
+
return failUnprocessable("account_disconnected", { accountId: payload.accountId });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Merge: address/provider/authMethod kommen 1:1 aus dem letzten
|
|
57
|
+
// Snapshot (address bleibt Ciphertext — nie re-encrypten).
|
|
58
|
+
const eventPayload: MailAccountEventPayload = {
|
|
59
|
+
...current,
|
|
60
|
+
...(payload.status !== undefined ? { status: payload.status } : {}),
|
|
61
|
+
...(payload.watchState !== undefined ? { watchState: payload.watchState } : {}),
|
|
62
|
+
...(payload.displayName !== undefined ? { displayName: payload.displayName } : {}),
|
|
63
|
+
};
|
|
64
|
+
const headers: MailAccountEventHeaders = {
|
|
65
|
+
providerName: current.provider,
|
|
66
|
+
reason: payload.reason,
|
|
67
|
+
};
|
|
68
|
+
await ctx.unsafeAppendEvent({
|
|
69
|
+
aggregateId: payload.accountId,
|
|
70
|
+
aggregateType: MAIL_ACCOUNT_AGGREGATE_TYPE,
|
|
71
|
+
type: MAIL_ACCOUNT_UPDATED_EVENT_QN,
|
|
72
|
+
payload: eventPayload,
|
|
73
|
+
headers,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return { isSuccess: true as const, data: { accountId: payload.accountId } };
|
|
77
|
+
},
|
|
78
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Public API of the inbound-mail-foundation bundled-feature.
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
inboundMessageAggregateId,
|
|
5
|
+
mailThreadAggregateId,
|
|
6
|
+
} from "./aggregate-id";
|
|
7
|
+
export {
|
|
8
|
+
createInboundMailConnectRoutes,
|
|
9
|
+
type InboundMailConnectRoutes,
|
|
10
|
+
type InboundMailConnectRoutesDeps,
|
|
11
|
+
} from "./connect-routes";
|
|
12
|
+
export {
|
|
13
|
+
INBOUND_MAIL_FOUNDATION_FEATURE,
|
|
14
|
+
INBOUND_MAIL_PROVIDER_EXTENSION,
|
|
15
|
+
type InboundMailAccountStatus,
|
|
16
|
+
InboundMailAccountStatuses,
|
|
17
|
+
type InboundMailAuthMethod,
|
|
18
|
+
InboundMailAuthMethods,
|
|
19
|
+
InboundMailFoundationHandlers,
|
|
20
|
+
InboundMailFoundationQueries,
|
|
21
|
+
inboundCredentialSecretKey,
|
|
22
|
+
} from "./constants";
|
|
23
|
+
export {
|
|
24
|
+
INBOUND_MESSAGE_PII_FIELDS,
|
|
25
|
+
inboundMessageEntity,
|
|
26
|
+
MAIL_ACCOUNT_PII_FIELDS,
|
|
27
|
+
MAIL_THREAD_PII_FIELDS,
|
|
28
|
+
mailAccountEntity,
|
|
29
|
+
mailThreadEntity,
|
|
30
|
+
seenMessageEntity,
|
|
31
|
+
seenMessageTable,
|
|
32
|
+
syncCursorEntity,
|
|
33
|
+
syncCursorTable,
|
|
34
|
+
} from "./entities";
|
|
35
|
+
export {
|
|
36
|
+
INBOUND_MESSAGE_AGGREGATE_TYPE,
|
|
37
|
+
INBOUND_MESSAGE_RECEIVED_EVENT_QN,
|
|
38
|
+
INBOUND_MESSAGE_RECEIVED_EVENT_SHORT,
|
|
39
|
+
type InboundMessageEventHeaders,
|
|
40
|
+
type InboundMessageEventPayload,
|
|
41
|
+
inboundMessageEventPayloadSchema,
|
|
42
|
+
MAIL_ACCOUNT_AGGREGATE_TYPE,
|
|
43
|
+
MAIL_ACCOUNT_CONNECTED_EVENT_QN,
|
|
44
|
+
MAIL_ACCOUNT_CONNECTED_EVENT_SHORT,
|
|
45
|
+
MAIL_ACCOUNT_DISCONNECTED_EVENT_QN,
|
|
46
|
+
MAIL_ACCOUNT_DISCONNECTED_EVENT_SHORT,
|
|
47
|
+
MAIL_ACCOUNT_UPDATED_EVENT_QN,
|
|
48
|
+
MAIL_ACCOUNT_UPDATED_EVENT_SHORT,
|
|
49
|
+
MAIL_THREAD_AGGREGATE_TYPE,
|
|
50
|
+
MAIL_THREAD_UPDATED_EVENT_QN,
|
|
51
|
+
MAIL_THREAD_UPDATED_EVENT_SHORT,
|
|
52
|
+
type MailAccountEventHeaders,
|
|
53
|
+
type MailAccountEventPayload,
|
|
54
|
+
type MailThreadEventPayload,
|
|
55
|
+
mailAccountEventPayloadSchema,
|
|
56
|
+
mailThreadEventPayloadSchema,
|
|
57
|
+
} from "./events";
|
|
58
|
+
export { inboundMailFoundationFeature } from "./feature";
|
|
59
|
+
export {
|
|
60
|
+
type OAuthStatePayload,
|
|
61
|
+
signOAuthState,
|
|
62
|
+
type VerifyOAuthStateResult,
|
|
63
|
+
verifyOAuthState,
|
|
64
|
+
} from "./oauth-state";
|
|
65
|
+
export {
|
|
66
|
+
inboundMessagesProjectionTable,
|
|
67
|
+
mailAccountsProjectionTable,
|
|
68
|
+
mailThreadsProjectionTable,
|
|
69
|
+
} from "./projection";
|
|
70
|
+
export {
|
|
71
|
+
resolveInboundProviderForAccount,
|
|
72
|
+
resolveInboundProviderForKey,
|
|
73
|
+
} from "./provider-factory";
|
|
74
|
+
export {
|
|
75
|
+
InboundAuthError,
|
|
76
|
+
InboundCursorInvalidError,
|
|
77
|
+
type InboundFetchResult,
|
|
78
|
+
type InboundMailContext,
|
|
79
|
+
type InboundMailProviderPlugin,
|
|
80
|
+
type InboundOAuthFlow,
|
|
81
|
+
InboundRateLimitError,
|
|
82
|
+
InboundTransientError,
|
|
83
|
+
isInboundAuthError,
|
|
84
|
+
isInboundCursorInvalidError,
|
|
85
|
+
isInboundMailProviderPlugin,
|
|
86
|
+
isInboundRateLimitError,
|
|
87
|
+
isInboundTransientError,
|
|
88
|
+
type MailAccountRecord,
|
|
89
|
+
type OAuthTokenSet,
|
|
90
|
+
type RawInboundMessage,
|
|
91
|
+
type SyncCursorPayload,
|
|
92
|
+
} from "./types";
|
|
93
|
+
export {
|
|
94
|
+
createInboundMailSupervisor,
|
|
95
|
+
type InboundMailSupervisor,
|
|
96
|
+
type InboundMailSupervisorDeps,
|
|
97
|
+
} from "./watch-supervisor";
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// HMAC-signierter OAuth-`state`-Parameter für den Connect-Flow.
|
|
2
|
+
//
|
|
3
|
+
// Bindet {tenantId, ownerUserId?, providerKey, mailbox, nonce} an den
|
|
4
|
+
// Redirect: der anonyme Callback (außerhalb /api/*) vertraut NUR dem
|
|
5
|
+
// verifizierten state — verhindert CSRF und Fremd-Claiming (ein
|
|
6
|
+
// Angreifer kann den Callback nicht auf einen fremden Tenant/User
|
|
7
|
+
// umbiegen, ohne die Signatur zu brechen).
|
|
8
|
+
//
|
|
9
|
+
// Format: <payload-base64url>.<expiresAtMs>.<hmac-base64url>
|
|
10
|
+
// Muster: auth-email-password/signed-token.ts (HMAC-SHA256, purpose im
|
|
11
|
+
// HMAC-Input, timing-safe compare, Expiry im Klartext-Segment).
|
|
12
|
+
|
|
13
|
+
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
14
|
+
import { Temporal } from "temporal-polyfill";
|
|
15
|
+
|
|
16
|
+
const STATE_PURPOSE = "inbound-mail-oauth-connect";
|
|
17
|
+
|
|
18
|
+
export type OAuthStatePayload = {
|
|
19
|
+
readonly tenantId: string;
|
|
20
|
+
/** null = shared-Postfach, sonst persönliches Postfach dieses Users. */
|
|
21
|
+
readonly ownerUserId: string | null;
|
|
22
|
+
readonly providerKey: string;
|
|
23
|
+
/** Postfach-Adresse aus dem Connect-Request — landet nach dem
|
|
24
|
+
* Token-Exchange im MailAccount. */
|
|
25
|
+
readonly mailbox: string;
|
|
26
|
+
/** Einmal-Nonce — macht jeden state einzigartig (kein Replay über
|
|
27
|
+
* zwei parallele Connect-Flows hinweg). */
|
|
28
|
+
readonly nonce: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type VerifyOAuthStateResult =
|
|
32
|
+
| { readonly ok: true; readonly payload: OAuthStatePayload }
|
|
33
|
+
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
34
|
+
|
|
35
|
+
function sign(input: string, secret: string): string {
|
|
36
|
+
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hmacInput(payloadB64: string, expiresAtMs: number): string {
|
|
40
|
+
return `${STATE_PURPOSE}:${payloadB64}.${expiresAtMs}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function signOAuthState(
|
|
44
|
+
payload: Omit<OAuthStatePayload, "nonce">,
|
|
45
|
+
ttlMinutes: number,
|
|
46
|
+
secret: string,
|
|
47
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
48
|
+
): string {
|
|
49
|
+
const full: OAuthStatePayload = { ...payload, nonce: randomUUID() };
|
|
50
|
+
const payloadB64 = Buffer.from(JSON.stringify(full), "utf8").toString("base64url");
|
|
51
|
+
const expiresAtMs = now.add({ minutes: ttlMinutes }).epochMilliseconds;
|
|
52
|
+
const signature = sign(hmacInput(payloadB64, expiresAtMs), secret);
|
|
53
|
+
return `${payloadB64}.${expiresAtMs}.${signature}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function verifyOAuthState(
|
|
57
|
+
state: string,
|
|
58
|
+
secret: string,
|
|
59
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
60
|
+
): VerifyOAuthStateResult {
|
|
61
|
+
const parts = state.split(".");
|
|
62
|
+
if (parts.length !== 3) return { ok: false, reason: "malformed" };
|
|
63
|
+
const [payloadB64, expiresAtRaw, providedSig] = parts;
|
|
64
|
+
if (!payloadB64 || !expiresAtRaw || !providedSig) return { ok: false, reason: "malformed" };
|
|
65
|
+
|
|
66
|
+
const expiresAtMs = Number(expiresAtRaw);
|
|
67
|
+
if (!Number.isFinite(expiresAtMs) || String(expiresAtMs) !== expiresAtRaw) {
|
|
68
|
+
return { ok: false, reason: "malformed" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const expected = sign(hmacInput(payloadB64, expiresAtMs), secret);
|
|
72
|
+
const expectedBuf = Buffer.from(expected, "base64url");
|
|
73
|
+
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
74
|
+
// Length-check VOR timingSafeEqual — die würde bei Längen-Mismatch
|
|
75
|
+
// werfen, und der Throw selbst leakt via Timing.
|
|
76
|
+
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
77
|
+
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
78
|
+
|
|
79
|
+
if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(expiresAtMs)) > 0) {
|
|
80
|
+
return { ok: false, reason: "expired" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let parsed: unknown;
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
|
|
86
|
+
} catch {
|
|
87
|
+
return { ok: false, reason: "malformed" };
|
|
88
|
+
}
|
|
89
|
+
if (!isOAuthStatePayload(parsed)) return { ok: false, reason: "malformed" };
|
|
90
|
+
return { ok: true, payload: parsed };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isOAuthStatePayload(v: unknown): v is OAuthStatePayload {
|
|
94
|
+
if (typeof v !== "object" || v === null) return false;
|
|
95
|
+
const o = v as Record<string, unknown>;
|
|
96
|
+
return (
|
|
97
|
+
typeof o["tenantId"] === "string" &&
|
|
98
|
+
(o["ownerUserId"] === null || typeof o["ownerUserId"] === "string") &&
|
|
99
|
+
typeof o["providerKey"] === "string" &&
|
|
100
|
+
typeof o["mailbox"] === "string" &&
|
|
101
|
+
typeof o["nonce"] === "string"
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Inline-projections für die drei event-sourced Streams:
|
|
2
|
+
// mail-account → read_mail_accounts (UPSERT, current state)
|
|
3
|
+
// inbound-message → read_inbound_messages (INSERT-ignore, append-only)
|
|
4
|
+
// mail-thread → read_mail_threads (UPSERT, Rollup)
|
|
5
|
+
//
|
|
6
|
+
// Apply läuft in derselben TX wie ctx.unsafeAppendEvent — read-your-
|
|
7
|
+
// own-write ohne dispatcher-tick (Muster billing-foundation).
|
|
8
|
+
//
|
|
9
|
+
// **Production-deployment caveat** (gleich wie billing-foundation):
|
|
10
|
+
// die Tables sind als raw drizzle-pgTable in r.projection registriert,
|
|
11
|
+
// NICHT als r.entity — Apps müssen sie in ihre drizzle/generate.ts
|
|
12
|
+
// aufnehmen (via *-ProjectionTable-Imports). setupTestStack pusht sie
|
|
13
|
+
// automatisch via r.projection.table.
|
|
14
|
+
//
|
|
15
|
+
// **PII:** die Payload-Felder (address/from/to/cc/subject/snippet)
|
|
16
|
+
// kommen bereits als Ciphertext an — encrypted VOR dem append im
|
|
17
|
+
// write-handler. Die applies kopieren nur durch; decrypted wird erst
|
|
18
|
+
// in den list-queries.
|
|
19
|
+
|
|
20
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
21
|
+
import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import { insertIgnoreProjectionRow, upsertProjectionRow } from "./db/queries/inbound-projections";
|
|
23
|
+
import { inboundMessageEntity, mailAccountEntity, mailThreadEntity } from "./entities";
|
|
24
|
+
import type {
|
|
25
|
+
InboundMessageEventPayload,
|
|
26
|
+
MailAccountEventPayload,
|
|
27
|
+
MailThreadEventPayload,
|
|
28
|
+
} from "./events";
|
|
29
|
+
|
|
30
|
+
// Drizzle-table-instances aus den entity-shapes — geteilt zwischen
|
|
31
|
+
// projection-applies und list-queries (ein column-namespace).
|
|
32
|
+
export const mailAccountsProjectionTable = buildEntityTable("mail-account", mailAccountEntity);
|
|
33
|
+
export const inboundMessagesProjectionTable = buildEntityTable(
|
|
34
|
+
"inbound-message",
|
|
35
|
+
inboundMessageEntity,
|
|
36
|
+
);
|
|
37
|
+
export const mailThreadsProjectionTable = buildEntityTable("mail-thread", mailThreadEntity);
|
|
38
|
+
|
|
39
|
+
function tableNameOf(table: unknown): string {
|
|
40
|
+
return (table as { tableName: string }).tableName;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// mail-account — connected/updated/disconnected teilen den payload-shape,
|
|
45
|
+
// der event-type taggt was passiert ist. Alle drei UPSERT-full für
|
|
46
|
+
// defensive consistency (rebuild-aus-dem-Nichts, out-of-order).
|
|
47
|
+
// =============================================================================
|
|
48
|
+
|
|
49
|
+
const ACCOUNT_SET_CLAUSES = [
|
|
50
|
+
`"provider" = EXCLUDED."provider"`,
|
|
51
|
+
`"auth_method" = EXCLUDED."auth_method"`,
|
|
52
|
+
`"owner_user_id" = EXCLUDED."owner_user_id"`,
|
|
53
|
+
`"display_name" = EXCLUDED."display_name"`,
|
|
54
|
+
`"address" = EXCLUDED."address"`,
|
|
55
|
+
`"status" = EXCLUDED."status"`,
|
|
56
|
+
`"watch_state" = EXCLUDED."watch_state"`,
|
|
57
|
+
// connected_at bewusst NICHT im SET: der Erst-Connect-Zeitpunkt
|
|
58
|
+
// bleibt stehen, updated/disconnected überschreiben ihn nicht.
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const applyMailAccountUpsert = defineApply<MailAccountEventPayload>(async (event, tx) => {
|
|
62
|
+
const p = event.payload;
|
|
63
|
+
const insertCols = {
|
|
64
|
+
id: event.aggregateId,
|
|
65
|
+
tenant_id: event.tenantId,
|
|
66
|
+
provider: p.provider,
|
|
67
|
+
auth_method: p.authMethod,
|
|
68
|
+
owner_user_id: p.ownerUserId,
|
|
69
|
+
display_name: p.displayName,
|
|
70
|
+
address: p.address,
|
|
71
|
+
status: p.status,
|
|
72
|
+
watch_state: p.watchState,
|
|
73
|
+
// Insert-Pfad = Erst-Connect (bzw. Rebuild: createdAt des ersten
|
|
74
|
+
// events des Streams — fachlich derselbe Zeitpunkt).
|
|
75
|
+
connected_at: event.createdAt.toString(),
|
|
76
|
+
};
|
|
77
|
+
await upsertProjectionRow(
|
|
78
|
+
tx,
|
|
79
|
+
tableNameOf(mailAccountsProjectionTable),
|
|
80
|
+
insertCols,
|
|
81
|
+
ACCOUNT_SET_CLAUSES,
|
|
82
|
+
Object.values(insertCols),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** mail-account-connected → UPSERT full. */
|
|
87
|
+
export const applyMailAccountConnected = applyMailAccountUpsert;
|
|
88
|
+
/** mail-account-updated → UPSERT full (status/watchState-Übergänge). */
|
|
89
|
+
export const applyMailAccountUpdated = applyMailAccountUpsert;
|
|
90
|
+
/** mail-account-disconnected → UPSERT full (payload.status=disconnected).
|
|
91
|
+
* Row bleibt (Audit-Sicht in der Account-Liste), Stream bleibt. */
|
|
92
|
+
export const applyMailAccountDisconnected = applyMailAccountUpsert;
|
|
93
|
+
|
|
94
|
+
// =============================================================================
|
|
95
|
+
// inbound-message — genau EIN received-event pro Stream (deterministic
|
|
96
|
+
// aggregateId). INSERT-ignore: Replays no-op'en auf der PK statt zu
|
|
97
|
+
// knallen; es gibt keinen update-Fall.
|
|
98
|
+
// =============================================================================
|
|
99
|
+
|
|
100
|
+
/** inbound-message-received → INSERT ... ON CONFLICT DO NOTHING. */
|
|
101
|
+
export const applyInboundMessageReceived = defineApply<InboundMessageEventPayload>(
|
|
102
|
+
async (event, tx) => {
|
|
103
|
+
const p = event.payload;
|
|
104
|
+
await insertIgnoreProjectionRow(tx, tableNameOf(inboundMessagesProjectionTable), {
|
|
105
|
+
id: event.aggregateId,
|
|
106
|
+
tenant_id: event.tenantId,
|
|
107
|
+
account_id: p.accountId,
|
|
108
|
+
owner_user_id: p.ownerUserId,
|
|
109
|
+
message_id_header: p.messageIdHeader,
|
|
110
|
+
thread_key: p.threadKey,
|
|
111
|
+
from: p.from,
|
|
112
|
+
to: p.to,
|
|
113
|
+
cc: p.cc,
|
|
114
|
+
subject: p.subject,
|
|
115
|
+
snippet: p.snippet,
|
|
116
|
+
received_at: p.receivedAtIso,
|
|
117
|
+
body_ref: p.bodyRef,
|
|
118
|
+
scope: p.scope,
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// =============================================================================
|
|
124
|
+
// mail-thread — Rollup pro (tenantId, threadKey). Der write-handler
|
|
125
|
+
// berechnet messageCount/lastMessageAt und appendet den updated-event
|
|
126
|
+
// mit dem NEUEN Stand — die apply ist ein dummer UPSERT-full (kein
|
|
127
|
+
// increment in der apply: Replays wären sonst nicht idempotent).
|
|
128
|
+
// =============================================================================
|
|
129
|
+
|
|
130
|
+
/** mail-thread-updated → UPSERT full mit dem Payload-Snapshot. */
|
|
131
|
+
export const applyMailThreadUpdated = defineApply<MailThreadEventPayload>(async (event, tx) => {
|
|
132
|
+
const p = event.payload;
|
|
133
|
+
const insertCols = {
|
|
134
|
+
id: event.aggregateId,
|
|
135
|
+
tenant_id: event.tenantId,
|
|
136
|
+
thread_key: p.threadKey,
|
|
137
|
+
subject: p.subject,
|
|
138
|
+
last_message_at: p.lastMessageAtIso,
|
|
139
|
+
message_count: p.messageCount,
|
|
140
|
+
};
|
|
141
|
+
await upsertProjectionRow(
|
|
142
|
+
tx,
|
|
143
|
+
tableNameOf(mailThreadsProjectionTable),
|
|
144
|
+
insertCols,
|
|
145
|
+
[
|
|
146
|
+
`"subject" = EXCLUDED."subject"`,
|
|
147
|
+
`"last_message_at" = EXCLUDED."last_message_at"`,
|
|
148
|
+
`"message_count" = EXCLUDED."message_count"`,
|
|
149
|
+
],
|
|
150
|
+
Object.values(insertCols),
|
|
151
|
+
);
|
|
152
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Provider-Factory — Mirror von mail-foundation's createTransportForTenant
|
|
2
|
+
// inkl. der drei Fehlerpfade (kein registry / nicht registriert / falsche
|
|
3
|
+
// Plugin-Shape). Abweichung zum Outbound-Muster (Plan §1.3): KEIN
|
|
4
|
+
// per-Tenant-Config-Selector — der Provider steht pro MailAccount
|
|
5
|
+
// (`account.provider`), ein Tenant kann mehrere Provider parallel nutzen.
|
|
6
|
+
|
|
7
|
+
import { INBOUND_MAIL_FOUNDATION_FEATURE, INBOUND_MAIL_PROVIDER_EXTENSION } from "./constants";
|
|
8
|
+
import {
|
|
9
|
+
type InboundMailContext,
|
|
10
|
+
type InboundMailProviderPlugin,
|
|
11
|
+
isInboundMailProviderPlugin,
|
|
12
|
+
type MailAccountRecord,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
/** Lookup per Provider-Key ("imap", "m365-graph", ...) — genutzt von den
|
|
16
|
+
* OAuth-Connect-Routen, wo noch kein Account existiert. */
|
|
17
|
+
export function resolveInboundProviderForKey(
|
|
18
|
+
ctx: InboundMailContext,
|
|
19
|
+
providerKey: string,
|
|
20
|
+
handlerName = "inbound-mail-foundation:provider-factory",
|
|
21
|
+
): InboundMailProviderPlugin {
|
|
22
|
+
if (!ctx.registry) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`${handlerName}: ctx.registry is missing — required to look up registered inbound-mail provider plugins`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const usages = ctx.registry.getExtensionUsages(INBOUND_MAIL_PROVIDER_EXTENSION);
|
|
28
|
+
const usage = usages.find((u) => u.entityName === providerKey);
|
|
29
|
+
if (!usage) {
|
|
30
|
+
const known = usages.map((u) => u.entityName).join(", ") || "<none>";
|
|
31
|
+
throw new Error(
|
|
32
|
+
`${INBOUND_MAIL_FOUNDATION_FEATURE}: provider "${providerKey}" not registered. Known: ${known}. ` +
|
|
33
|
+
`Mount the matching inbound-provider-${providerKey} feature.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (!isInboundMailProviderPlugin(usage.options)) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`${INBOUND_MAIL_FOUNDATION_FEATURE}: provider "${providerKey}" registered without verify()/fetch() — ` +
|
|
39
|
+
`extension options must be an InboundMailProviderPlugin.`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return usage.options;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Lookup für einen konkreten Account — der Standard-Pfad in Poll-Job
|
|
46
|
+
* und Watch-Supervisor. */
|
|
47
|
+
export function resolveInboundProviderForAccount(
|
|
48
|
+
ctx: InboundMailContext,
|
|
49
|
+
account: MailAccountRecord,
|
|
50
|
+
handlerName = "inbound-mail-foundation:provider-factory",
|
|
51
|
+
): InboundMailProviderPlugin {
|
|
52
|
+
return resolveInboundProviderForKey(ctx, account.provider, handlerName);
|
|
53
|
+
}
|