@cosmicdrift/kumiko-bundled-features 0.183.2 → 0.185.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 +7 -7
- package/src/channel-email/__tests__/email-channel.test.ts +83 -0
- package/src/channel-email/__tests__/pii-guard.test.ts +23 -0
- package/src/channel-email/email-channel.ts +22 -1
- package/src/channel-email/pii-guard.ts +13 -5
- package/src/channel-email/smtp-transport.ts +7 -5
- package/src/channel-email/types.ts +9 -0
- package/src/delivery/delivery-service.ts +1 -1
- package/src/jobs/__tests__/job-system-user.integration.test.ts +2 -0
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.185.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>",
|
|
@@ -121,12 +121,12 @@
|
|
|
121
121
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
122
122
|
},
|
|
123
123
|
"dependencies": {
|
|
124
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
125
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
126
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
127
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
128
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
124
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.185.0",
|
|
125
|
+
"@cosmicdrift/kumiko-framework": "0.185.0",
|
|
126
|
+
"@cosmicdrift/kumiko-headless": "0.185.0",
|
|
127
|
+
"@cosmicdrift/kumiko-renderer": "0.185.0",
|
|
128
|
+
"@cosmicdrift/kumiko-renderer-web": "0.185.0",
|
|
129
|
+
"@cosmicdrift/kumiko-types": "0.185.0",
|
|
130
130
|
"@mollie/api-client": "^4.5.0",
|
|
131
131
|
"imapflow": "^1.3.3",
|
|
132
132
|
"mailparser": "^3.9.8",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
ChannelContext,
|
|
4
|
+
ChannelMessage,
|
|
5
|
+
NotificationRenderer,
|
|
6
|
+
RenderedMessage,
|
|
7
|
+
} from "../../delivery";
|
|
8
|
+
import { createEmailChannel } from "../email-channel";
|
|
9
|
+
import { createInMemoryTransport } from "../types";
|
|
10
|
+
|
|
11
|
+
const stubRenderer: NotificationRenderer = {
|
|
12
|
+
name: "stub",
|
|
13
|
+
render: async () => "<p>rendered</p>",
|
|
14
|
+
};
|
|
15
|
+
const resolveEmail = async () => "user@example.com";
|
|
16
|
+
// send() never touches ctx — only render()/resolve() would, and this test
|
|
17
|
+
// passes `rendered` so render() is skipped.
|
|
18
|
+
const ctx = {} as unknown as ChannelContext;
|
|
19
|
+
const rendered: RenderedMessage = { html: "<p>x</p>", subject: "Re: Wasserschaden" };
|
|
20
|
+
|
|
21
|
+
function channelWith(transport: ReturnType<typeof createInMemoryTransport>) {
|
|
22
|
+
return createEmailChannel({ transport, renderer: stubRenderer, resolveEmail });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("email channel envelope", () => {
|
|
26
|
+
test("from / replyTo / headers from channel data reach the transport", async () => {
|
|
27
|
+
const transport = createInMemoryTransport();
|
|
28
|
+
const message: ChannelMessage = {
|
|
29
|
+
notificationType: "inbound-reply-sent",
|
|
30
|
+
title: "Re: Wasserschaden",
|
|
31
|
+
body: "Danke für Ihre Nachricht.",
|
|
32
|
+
data: {
|
|
33
|
+
subject: "Re: Wasserschaden",
|
|
34
|
+
body: "Danke für Ihre Nachricht.",
|
|
35
|
+
from: "verwaltung@haus.de",
|
|
36
|
+
replyTo: "verwaltung@haus.de",
|
|
37
|
+
headers: { "In-Reply-To": "<abc@mail>", References: "<abc@mail>" },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
await channelWith(transport).send("mieter@example.com", message, ctx, rendered);
|
|
41
|
+
|
|
42
|
+
expect(transport.sent).toHaveLength(1);
|
|
43
|
+
const [sent] = transport.sent;
|
|
44
|
+
if (!sent) throw new Error("expected a sent mail");
|
|
45
|
+
expect(sent.to).toBe("mieter@example.com");
|
|
46
|
+
expect(sent.from).toBe("verwaltung@haus.de");
|
|
47
|
+
expect(sent.replyTo).toBe("verwaltung@haus.de");
|
|
48
|
+
expect(sent.headers).toEqual({ "In-Reply-To": "<abc@mail>", References: "<abc@mail>" });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("no envelope keys → transport gets none and falls back to its default From", async () => {
|
|
52
|
+
const transport = createInMemoryTransport();
|
|
53
|
+
const message: ChannelMessage = {
|
|
54
|
+
notificationType: "x",
|
|
55
|
+
title: "t",
|
|
56
|
+
body: "b",
|
|
57
|
+
data: { subject: "t", body: "b" },
|
|
58
|
+
};
|
|
59
|
+
await channelWith(transport).send("mieter@example.com", message, ctx, rendered);
|
|
60
|
+
|
|
61
|
+
const [sent] = transport.sent;
|
|
62
|
+
if (!sent) throw new Error("expected a sent mail");
|
|
63
|
+
expect(sent.from).toBeUndefined();
|
|
64
|
+
expect(sent.replyTo).toBeUndefined();
|
|
65
|
+
expect(sent.headers).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a non-object headers value is ignored, not forwarded", async () => {
|
|
69
|
+
const transport = createInMemoryTransport();
|
|
70
|
+
const message: ChannelMessage = {
|
|
71
|
+
notificationType: "x",
|
|
72
|
+
title: "t",
|
|
73
|
+
body: "b",
|
|
74
|
+
data: { subject: "t", body: "b", from: 42, headers: "not-an-object" },
|
|
75
|
+
};
|
|
76
|
+
await channelWith(transport).send("mieter@example.com", message, ctx, rendered);
|
|
77
|
+
|
|
78
|
+
const [sent] = transport.sent;
|
|
79
|
+
if (!sent) throw new Error("expected a sent mail");
|
|
80
|
+
expect(sent.from).toBeUndefined();
|
|
81
|
+
expect(sent.headers).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -39,6 +39,29 @@ describe("guardEmailMessage", () => {
|
|
|
39
39
|
expect(out.subject).toBe("Re: [pii-redacted]");
|
|
40
40
|
expect(out.html).toBe("<p>[pii-redacted]</p>");
|
|
41
41
|
});
|
|
42
|
+
test("ciphertext From is refused like a ciphertext recipient", () => {
|
|
43
|
+
expect(() =>
|
|
44
|
+
guardEmailMessage({ to: "ok@example.com", from: CIPHERTEXT, subject: "Hi", html: "x" }),
|
|
45
|
+
).toThrow(/from address is a PII ciphertext/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("ciphertext Reply-To is refused", () => {
|
|
49
|
+
expect(() =>
|
|
50
|
+
guardEmailMessage({ to: "ok@example.com", replyTo: CIPHERTEXT, subject: "Hi", html: "x" }),
|
|
51
|
+
).toThrow(/reply-to address is a PII ciphertext/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("clean message with an envelope passes through untouched", () => {
|
|
55
|
+
const msg = {
|
|
56
|
+
to: "ok@example.com",
|
|
57
|
+
from: "verwaltung@haus.de",
|
|
58
|
+
replyTo: "verwaltung@haus.de",
|
|
59
|
+
headers: { "In-Reply-To": "<abc@mail>" },
|
|
60
|
+
subject: "Hi",
|
|
61
|
+
html: "<p>Hi</p>",
|
|
62
|
+
};
|
|
63
|
+
expect(guardEmailMessage(msg)).toBe(msg);
|
|
64
|
+
});
|
|
42
65
|
});
|
|
43
66
|
|
|
44
67
|
describe("withPiiCiphertextGuard", () => {
|
|
@@ -9,6 +9,26 @@ import type {
|
|
|
9
9
|
import { guardEmailMessage } from "./pii-guard";
|
|
10
10
|
import type { EmailTransport } from "./types";
|
|
11
11
|
|
|
12
|
+
// Envelope (From / Reply-To / threading headers) rides on the channel data —
|
|
13
|
+
// the notification's email template echoes it out of the notify() call, since
|
|
14
|
+
// buildMessage collapses the raw data to the template's result. Content
|
|
15
|
+
// (subject, html) is rendered; these three are copied straight to the message.
|
|
16
|
+
function emailEnvelopeFrom(data: Readonly<Record<string, unknown>> | undefined): {
|
|
17
|
+
from?: string;
|
|
18
|
+
replyTo?: string;
|
|
19
|
+
headers?: Readonly<Record<string, string>>;
|
|
20
|
+
} {
|
|
21
|
+
if (!data) return {};
|
|
22
|
+
const from = typeof data["from"] === "string" ? (data["from"] as string) : undefined;
|
|
23
|
+
const replyTo = typeof data["replyTo"] === "string" ? (data["replyTo"] as string) : undefined;
|
|
24
|
+
const raw = data["headers"];
|
|
25
|
+
const headers =
|
|
26
|
+
raw && typeof raw === "object" && !Array.isArray(raw)
|
|
27
|
+
? (raw as Readonly<Record<string, string>>)
|
|
28
|
+
: undefined;
|
|
29
|
+
return { ...(from && { from }), ...(replyTo && { replyTo }), ...(headers && { headers }) };
|
|
30
|
+
}
|
|
31
|
+
|
|
12
32
|
export type EmailChannelOptions = {
|
|
13
33
|
readonly transport: EmailTransport;
|
|
14
34
|
readonly renderer: NotificationRenderer;
|
|
@@ -54,7 +74,8 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
|
|
|
54
74
|
|
|
55
75
|
async send(address, message, _ctx, rendered) {
|
|
56
76
|
const { html, subject } = rendered ?? (await renderMessage(message));
|
|
57
|
-
|
|
77
|
+
const envelope = emailEnvelopeFrom(message.data);
|
|
78
|
+
await transport.send(guardEmailMessage({ to: address, subject, html, ...envelope }));
|
|
58
79
|
return { status: "sent", address };
|
|
59
80
|
},
|
|
60
81
|
};
|
|
@@ -12,11 +12,19 @@ const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
|
|
|
12
12
|
// blob); ciphertext in subject/body fails loud in dev and is redacted+logged
|
|
13
13
|
// in prod.
|
|
14
14
|
export function guardEmailMessage(message: EmailMessage): EmailMessage {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
// Every envelope address, not just `to`: a ciphertext From or Reply-To is
|
|
16
|
+
// the same garbage as a ciphertext recipient — refuse rather than mail it.
|
|
17
|
+
for (const [label, address] of [
|
|
18
|
+
["recipient", message.to],
|
|
19
|
+
["from", message.from],
|
|
20
|
+
["reply-to", message.replyTo],
|
|
21
|
+
] as const) {
|
|
22
|
+
if (address?.includes(CIPHERTEXT_MARKER)) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`[channel-email] refusing to send: ${label} address is a PII ciphertext ` +
|
|
25
|
+
`("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before mailing (decryptStoredPii).`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
20
28
|
}
|
|
21
29
|
const leaking =
|
|
22
30
|
message.subject.includes(CIPHERTEXT_MARKER) || message.html.includes(CIPHERTEXT_MARKER);
|
|
@@ -35,10 +35,10 @@ export type SmtpTransportOptions = {
|
|
|
35
35
|
readonly user: string;
|
|
36
36
|
readonly pass: string;
|
|
37
37
|
};
|
|
38
|
-
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* "Name <noreply@ex.com>". */
|
|
38
|
+
/** Default From-Adresse, app-weit. Eine einzelne Mail kann sie über
|
|
39
|
+
* `EmailMessage.from` überschreiben (Reply aus einem bestimmten
|
|
40
|
+
* Postfach); ohne Override greift diese. Format akzeptiert beides:
|
|
41
|
+
* "noreply@ex.com" oder "Name <noreply@ex.com>". */
|
|
42
42
|
readonly from: string;
|
|
43
43
|
};
|
|
44
44
|
|
|
@@ -55,10 +55,12 @@ export function createSmtpTransport(options: SmtpTransportOptions): EmailTranspo
|
|
|
55
55
|
return {
|
|
56
56
|
async send(message: EmailMessage): Promise<void> {
|
|
57
57
|
await transporter.sendMail({
|
|
58
|
-
from: options.from,
|
|
58
|
+
from: message.from ?? options.from,
|
|
59
59
|
to: message.to,
|
|
60
60
|
subject: message.subject,
|
|
61
61
|
html: message.html,
|
|
62
|
+
...(message.replyTo && { replyTo: message.replyTo }),
|
|
63
|
+
...(message.headers && { headers: message.headers }),
|
|
62
64
|
});
|
|
63
65
|
},
|
|
64
66
|
};
|
|
@@ -3,6 +3,15 @@ export type EmailMessage = {
|
|
|
3
3
|
readonly to: string;
|
|
4
4
|
readonly subject: string;
|
|
5
5
|
readonly html: string;
|
|
6
|
+
// Per-message From override. Absent → the transport's configured From (the
|
|
7
|
+
// app-wide default). Set when one send must originate from a specific
|
|
8
|
+
// mailbox — a reply from the address the original mail reached, not noreply@.
|
|
9
|
+
readonly from?: string;
|
|
10
|
+
// Reply-To header: where a recipient's answer goes when it differs from From.
|
|
11
|
+
readonly replyTo?: string;
|
|
12
|
+
// Extra RFC-5322 headers — In-Reply-To / References thread a reply into the
|
|
13
|
+
// conversation it answers.
|
|
14
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
6
15
|
};
|
|
7
16
|
|
|
8
17
|
export type EmailTransport = {
|
|
@@ -161,7 +161,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
161
161
|
// @cast-boundary engine-payload — generic query-handler return for typed convention
|
|
162
162
|
return (await handler.handler(
|
|
163
163
|
{ type: tenantUserIdsQuery, payload: { tenantId }, user: systemUser },
|
|
164
|
-
{ db: tenantDb, registry, ...bridgeStub() },
|
|
164
|
+
{ db: tenantDb, dbOutsideTransaction: tenantDb, registry, ...bridgeStub() },
|
|
165
165
|
)) as readonly string[];
|
|
166
166
|
}
|
|
167
167
|
|
|
@@ -77,6 +77,7 @@ const billingFeature = defineFeature("billing", (r) => {
|
|
|
77
77
|
{ type: "config:write:set", payload: parsed, user: systemUser },
|
|
78
78
|
{
|
|
79
79
|
db: tenantDb,
|
|
80
|
+
dbOutsideTransaction: tenantDb,
|
|
80
81
|
registry: reg,
|
|
81
82
|
configResolver: ctx["configResolver"] as ConfigResolver,
|
|
82
83
|
...bridgeStub(),
|
|
@@ -164,6 +165,7 @@ describe("SYSTEM_USER in jobs", () => {
|
|
|
164
165
|
{ type: ConfigHandlers.set, payload: parsed, user: adminUser },
|
|
165
166
|
{
|
|
166
167
|
db: createTenantDb(db, adminUser.tenantId, "system"),
|
|
168
|
+
dbOutsideTransaction: createTenantDb(db, adminUser.tenantId, "system"),
|
|
167
169
|
registry,
|
|
168
170
|
configResolver: resolver,
|
|
169
171
|
...bridgeStub(),
|
|
@@ -92,6 +92,11 @@ beforeAll(async () => {
|
|
|
92
92
|
},
|
|
93
93
|
{
|
|
94
94
|
db: createTenantDb(db, "00000000-0000-4000-8000-000000000000", "system"),
|
|
95
|
+
dbOutsideTransaction: createTenantDb(
|
|
96
|
+
db,
|
|
97
|
+
"00000000-0000-4000-8000-000000000000",
|
|
98
|
+
"system",
|
|
99
|
+
),
|
|
95
100
|
registry,
|
|
96
101
|
...bridgeStub(),
|
|
97
102
|
},
|