@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.183.2",
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.183.2",
125
- "@cosmicdrift/kumiko-framework": "0.183.2",
126
- "@cosmicdrift/kumiko-headless": "0.183.2",
127
- "@cosmicdrift/kumiko-renderer": "0.183.2",
128
- "@cosmicdrift/kumiko-renderer-web": "0.183.2",
129
- "@cosmicdrift/kumiko-types": "0.183.2",
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
- await transport.send(guardEmailMessage({ to: address, subject, html }));
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
- if (message.to.includes(CIPHERTEXT_MARKER)) {
16
- throw new Error(
17
- "[channel-email] refusing to send: recipient address is a PII ciphertext " +
18
- `("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before mailing (decryptStoredPii).`,
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
- /** Standard-From-Adresse für jede Mail. EmailMessage hat kein from-
39
- * Feld die Auswahl gehört zur Transport-Konfig (App-weit, nicht
40
- * pro Mail). Format akzeptiert beides: "noreply@ex.com" oder
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
  },