@cosmicdrift/kumiko-bundled-features 0.102.2 → 0.104.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.
@@ -14,9 +14,15 @@ import {
14
14
  unsafeCreateEntityTable,
15
15
  unsafePushTables,
16
16
  } from "@cosmicdrift/kumiko-framework/stack";
17
+ import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
17
18
  import { createConfigFeature } from "../../config";
18
19
  import { createConfigResolver } from "../../config/resolver";
19
20
  import { configValuesTable } from "../../config/table";
21
+ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
22
+ import { notificationPreferencesTable } from "../../delivery/tables";
23
+ import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
24
+ import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
25
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
20
26
  import { createTenantFeature } from "../../tenant";
21
27
  import { tenantMembershipsTable } from "../../tenant/membership-table";
22
28
  import { tenantEntity } from "../../tenant/schema/tenant";
@@ -26,6 +32,9 @@ import { AuthHandlers } from "../constants";
26
32
  import { createAuthEmailPasswordFeature } from "../feature";
27
33
 
28
34
  let stack: TestStack;
35
+ // delivery is a hard dep of the reset/verify flows now; mount it so boot
36
+ // passes. These tests hit unknown emails → no-op → no mail is actually sent.
37
+ const emailTransport = createInMemoryTransport();
29
38
  const encryptionKey = randomBytes(32).toString("base64");
30
39
  const resetSecret = randomBytes(32).toString("base64");
31
40
  const verifySecret = randomBytes(32).toString("base64");
@@ -39,26 +48,44 @@ beforeAll(async () => {
39
48
  createConfigFeature(),
40
49
  createUserFeature(),
41
50
  createTenantFeature(),
51
+ createTemplateResolverFeature(),
52
+ createRendererFoundationFeature(),
53
+ createDeliveryFeature(),
54
+ createRendererSimpleFeature(),
55
+ createChannelEmailFeature({
56
+ transport: emailTransport,
57
+ renderer: simpleRenderer,
58
+ resolveEmail: async () => "unused@test.local",
59
+ }),
42
60
  createAuthEmailPasswordFeature({
43
- passwordReset: { hmacSecret: resetSecret, tokenTtlMinutes: 15 },
44
- emailVerification: { hmacSecret: verifySecret, tokenTtlMinutes: 60, mode: "strict" },
61
+ passwordReset: {
62
+ hmacSecret: resetSecret,
63
+ tokenTtlMinutes: 15,
64
+ appUrl: "https://app.example.com/reset",
65
+ },
66
+ emailVerification: {
67
+ hmacSecret: verifySecret,
68
+ tokenTtlMinutes: 60,
69
+ mode: "strict",
70
+ appUrl: "https://app.example.com/verify",
71
+ },
45
72
  }),
46
73
  ],
47
- extraContext: { configResolver: resolver, configEncryption: encryption },
74
+ extraContext: (deps) => ({
75
+ ...createDeliveryTestContext(deps),
76
+ configResolver: resolver,
77
+ configEncryption: encryption,
78
+ }),
48
79
  authConfig: {
49
80
  membershipQuery: "tenant:query:memberships",
50
81
  loginHandler: AuthHandlers.login,
51
82
  passwordReset: {
52
83
  requestHandler: AuthHandlers.requestPasswordReset,
53
84
  confirmHandler: AuthHandlers.resetPassword,
54
- appResetUrl: "https://app.example.com/reset",
55
- sendResetEmail: async () => {},
56
85
  },
57
86
  emailVerification: {
58
87
  requestHandler: AuthHandlers.requestEmailVerification,
59
88
  confirmHandler: AuthHandlers.verifyEmail,
60
- appVerifyUrl: "https://app.example.com/verify",
61
- sendVerificationEmail: async () => {},
62
89
  },
63
90
  },
64
91
  // Tight limit so the test trips it with a small number of requests,
@@ -70,7 +97,11 @@ beforeAll(async () => {
70
97
 
71
98
  await unsafeCreateEntityTable(stack.db, userEntity);
72
99
  await unsafeCreateEntityTable(stack.db, tenantEntity);
73
- await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
100
+ await unsafePushTables(stack.db, {
101
+ configValuesTable,
102
+ tenantMembershipsTable,
103
+ notificationPreferencesTable,
104
+ });
74
105
  });
75
106
 
76
107
  afterAll(async () => {
@@ -4,8 +4,8 @@
4
4
  // reale User-Pfad sind.
5
5
  //
6
6
  // Pinst:
7
- // 1. POST signup-request mit valid email → 200, Mail captured durch
8
- // sendActivationEmail-callback (echte route + signup-feature).
7
+ // 1. POST signup-request mit valid email → 200, Activation-Mail via
8
+ // delivery (channel-email in-memory transport) mit Token-URL.
9
9
  // 2. Resend-Idempotenz: zweiter Request für selbe email → gleicher
10
10
  // Token in Mail (existing token in Redis wird re-genutzt).
11
11
  // 3. POST signup-confirm mit captured Token + Password → 200, Cookies
@@ -32,9 +32,15 @@ import {
32
32
  unsafeCreateEntityTable,
33
33
  unsafePushTables,
34
34
  } from "@cosmicdrift/kumiko-framework/stack";
35
+ import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
35
36
  import { createConfigFeature } from "../../config";
36
37
  import { createConfigResolver } from "../../config/resolver";
37
38
  import { configValuesTable } from "../../config/table";
39
+ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
40
+ import { notificationPreferencesTable } from "../../delivery/tables";
41
+ import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
42
+ import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
43
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
38
44
  import { createTenantFeature } from "../../tenant";
39
45
  import { tenantMembershipsTable } from "../../tenant/membership-table";
40
46
  import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
@@ -44,11 +50,11 @@ import { AuthErrors, AuthHandlers } from "../constants";
44
50
  import { createAuthEmailPasswordFeature } from "../feature";
45
51
 
46
52
  const APP_ACTIVATION_URL = "https://app.example.com/signup/complete";
47
- const capturedActivationEmails: Array<{
48
- email: string;
49
- activationUrl: string;
50
- expiresAt: string;
51
- }> = [];
53
+
54
+ // Activation mails now go through delivery (ctx.notify → channel-email). The
55
+ // in-memory transport captures what would be sent; route:{email} delivers
56
+ // directly (no jobRunner in the test stack → inline send).
57
+ const emailTransport = createInMemoryTransport();
52
58
 
53
59
  let stack: TestStack;
54
60
 
@@ -58,21 +64,31 @@ beforeAll(async () => {
58
64
  createConfigFeature(),
59
65
  createUserFeature(),
60
66
  createTenantFeature(),
67
+ createTemplateResolverFeature(),
68
+ createRendererFoundationFeature(),
69
+ createDeliveryFeature(),
70
+ createRendererSimpleFeature(),
71
+ createChannelEmailFeature({
72
+ transport: emailTransport,
73
+ renderer: simpleRenderer,
74
+ // route:{email} delivers directly — resolveEmail (userId→address) is
75
+ // never hit by the signup flow, but the channel requires it.
76
+ resolveEmail: async () => "unused@test.local",
77
+ }),
61
78
  createAuthEmailPasswordFeature({
62
- signup: { tokenTtlMinutes: 60 },
79
+ signup: { tokenTtlMinutes: 60, appUrl: APP_ACTIVATION_URL },
63
80
  }),
64
81
  ],
65
- extraContext: { configResolver: createConfigResolver() },
82
+ extraContext: (deps) => ({
83
+ ...createDeliveryTestContext(deps),
84
+ configResolver: createConfigResolver(),
85
+ }),
66
86
  authConfig: {
67
87
  membershipQuery: "tenant:query:memberships",
68
88
  loginHandler: AuthHandlers.login,
69
89
  signup: {
70
90
  requestHandler: AuthHandlers.signupRequest,
71
91
  confirmHandler: AuthHandlers.signupConfirm,
72
- appActivationUrl: APP_ACTIVATION_URL,
73
- sendActivationEmail: async (args) => {
74
- capturedActivationEmails.push(args);
75
- },
76
92
  },
77
93
  },
78
94
  });
@@ -82,7 +98,11 @@ beforeAll(async () => {
82
98
  // tenant.schema.indexes). unsafeCreateEntityTable baut das via
83
99
  // buildEntityTable nach — pinst den TOCTOU-Schutz für signup-confirm.
84
100
  await unsafeCreateEntityTable(stack.db, tenantEntity);
85
- await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
101
+ await unsafePushTables(stack.db, {
102
+ configValuesTable,
103
+ tenantMembershipsTable,
104
+ notificationPreferencesTable,
105
+ });
86
106
  });
87
107
 
88
108
  afterAll(async () => {
@@ -93,7 +113,7 @@ beforeEach(async () => {
93
113
  await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
94
114
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
95
115
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
96
- capturedActivationEmails.length = 0;
116
+ emailTransport.sent.length = 0;
97
117
  // Redis-cleanup damit Resend-Tests keine state-leaks haben.
98
118
  const allKeys = await stack.redis.redis.keys("signup:*");
99
119
  if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
@@ -111,52 +131,49 @@ async function postLogin(email: string, password: string): Promise<Response> {
111
131
  return stack.http.raw("POST", "/api/auth/login", { email, password });
112
132
  }
113
133
 
114
- function extractTokenFromUrl(url: string): string {
115
- const match = url.match(/[?&]token=([^&]+)/);
116
- if (!match?.[1]) throw new Error(`No token in url: ${url}`);
134
+ function extractTokenFromMail(html: string): string {
135
+ const match = html.match(/[?&]token=([^&"'<\s]+)/);
136
+ if (!match?.[1]) throw new Error(`No token in mail html: ${html.slice(0, 200)}`);
117
137
  return decodeURIComponent(match[1]);
118
138
  }
119
139
 
120
140
  describe("POST /api/auth/signup-request", () => {
121
- test("known email → 200, mail captured mit activation-url", async () => {
141
+ test("known email → 200, delivery sends activation mail with token URL", async () => {
122
142
  const res = await postSignupRequest("alice@example.com");
123
143
  expect(res.status).toBe(200);
124
144
  expect(await res.json()).toEqual({ isSuccess: true });
125
- expect(capturedActivationEmails).toHaveLength(1);
126
- const [captured] = capturedActivationEmails;
127
- if (!captured) throw new Error("no captured email");
128
- expect(captured.email).toBe("alice@example.com");
129
- expect(captured.activationUrl.startsWith(`${APP_ACTIVATION_URL}?token=`)).toBe(true);
130
- expect(typeof captured.expiresAt).toBe("string");
145
+ expect(emailTransport.sent).toHaveLength(1);
146
+ const sent = emailTransport.sent[0];
147
+ if (!sent) throw new Error("no mail sent");
148
+ expect(sent.to).toBe("alice@example.com");
149
+ expect(sent.html).toContain(`${APP_ACTIVATION_URL}?token=`);
131
150
  });
132
151
 
133
152
  test("Resend: zweiter Request für selbe email → gleicher token in Mail", async () => {
134
153
  await postSignupRequest("resend@example.com");
135
154
  await postSignupRequest("resend@example.com");
136
155
 
137
- expect(capturedActivationEmails).toHaveLength(2);
138
- const [first, second] = capturedActivationEmails;
139
- if (!first || !second) throw new Error("missing emails");
140
- expect(extractTokenFromUrl(second.activationUrl)).toBe(
141
- extractTokenFromUrl(first.activationUrl),
142
- );
156
+ expect(emailTransport.sent).toHaveLength(2);
157
+ const [first, second] = emailTransport.sent;
158
+ if (!first || !second) throw new Error("missing mails");
159
+ expect(extractTokenFromMail(second.html)).toBe(extractTokenFromMail(first.html));
143
160
  });
144
161
 
145
162
  test("malformed body → 200 (silent success, anti-enumeration)", async () => {
146
163
  const res = await stack.http.raw("POST", "/api/auth/signup-request", { wrong: "shape" });
147
164
  expect(res.status).toBe(200);
148
- expect(capturedActivationEmails).toHaveLength(0);
165
+ expect(emailTransport.sent).toHaveLength(0);
149
166
  });
150
167
  });
151
168
 
152
169
  describe("POST /api/auth/signup-confirm", () => {
153
170
  async function requestSignup(email: string): Promise<string> {
154
- capturedActivationEmails.length = 0;
171
+ emailTransport.sent.length = 0;
155
172
  const res = await postSignupRequest(email);
156
173
  expect(res.status).toBe(200);
157
- const captured = capturedActivationEmails[0];
158
- if (!captured) throw new Error("signup-request fixture didn't capture mail");
159
- return extractTokenFromUrl(captured.activationUrl);
174
+ const sent = emailTransport.sent[0];
175
+ if (!sent) throw new Error("signup-request fixture didn't send mail");
176
+ return extractTokenFromMail(sent.html);
160
177
  }
161
178
 
162
179
  test("voller Roundtrip: confirm legt user + tenant + Admin-Membership an, Cookies + Login funktioniert", async () => {
@@ -0,0 +1,24 @@
1
+ // Convention paths for the four auth pages (relative to the app base URL).
2
+ // resolveAuthMail builds each flow's magic-link appUrl from baseUrl + these;
3
+ // the handlers append `?token=…` and mail via delivery (ctx.notify).
4
+
5
+ /** Pfad-Konstanten der 4 Auth-Seiten (relativ zur App-baseUrl). */
6
+ export type AuthPaths = {
7
+ readonly resetPassword: string;
8
+ readonly verifyEmail: string;
9
+ readonly signupComplete: string;
10
+ readonly inviteAccept: string;
11
+ };
12
+
13
+ /** Konventions-Pfade — alle Kumiko-Apps nutzen dieselben. Apps überschreiben
14
+ * nur die Ausnahme via `makeAuthPaths({ ... })`. */
15
+ export const DEFAULT_AUTH_PATHS: AuthPaths = {
16
+ resetPassword: "/reset-password",
17
+ verifyEmail: "/verify-email",
18
+ signupComplete: "/signup/complete",
19
+ inviteAccept: "/invite/accept",
20
+ };
21
+
22
+ export function makeAuthPaths(overrides: Partial<AuthPaths> = {}): AuthPaths {
23
+ return { ...DEFAULT_AUTH_PATHS, ...overrides };
24
+ }
@@ -1,63 +1,42 @@
1
- // Default-HTML-Renderer für die transactional Auth-Mails (Reset-Password
2
- // + Verify-Email). Apps wiren die `sendResetEmail` / `sendVerificationEmail`
3
- // callbacks im framework-config (siehe PasswordResetConfig im
4
- // auth-routes.ts). Statt jede App selbst HTML zu schreiben, kann sie diese
5
- // Renderer als one-liner nutzen:
1
+ // Default renderers for the transactional auth mails. All four magic-link flows
2
+ // (password-reset, email-verification, signup-activation, invite) emit
3
+ // structured AuthMailContent that the handler hands to delivery (ctx.notify)
4
+ // renderer-simple turns it into HTML. Apps wanting their own branding swap the
5
+ // renderer; these templates are deliberately plain so the renderer (and the
6
+ // operator reading the mailer log) can rely on them.
6
7
  //
7
- // passwordReset: {
8
- // sendResetEmail: ({ email, resetUrl, expiresAt }) =>
9
- // mailSender.send({
10
- // to: email,
11
- // ...renderResetPasswordEmail({ resetUrl, expiresAt, locale: "de" }),
12
- // }),
13
- // }
14
- //
15
- // Apps die ihr eigenes Branding wollen, schreiben einen eigenen Renderer
16
- // und mischen ihn in. Die Templates hier sind bewusst plain HTML mit
17
- // inline-styling — kein CSS-Framework, kein bild-asset. Mail-Clients
18
- // rendern das verlässlich, und der Operator kann das HTML im Mailer-Log
19
- // problemlos lesen.
20
- //
21
- // Locale: de + en. Apps mit anderen Sprachen rendern selbst.
8
+ // Locale: de + en. Apps with other languages render themselves.
22
9
 
23
- import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
24
10
  import { Temporal } from "temporal-polyfill";
25
11
 
26
12
  export type AuthMailLocale = "de" | "en";
27
13
 
28
- export type RenderResetPasswordEmailArgs = {
29
- readonly resetUrl: string;
14
+ // Unified args for the structured token-mail renderers (reset + verify).
15
+ // `url` is the fully-built magic-link — the handler already appended ?token=.
16
+ export type RenderTokenContentArgs = {
17
+ readonly url: string;
30
18
  readonly expiresAt: string;
31
19
  readonly locale?: AuthMailLocale;
32
20
  /** Optional: App-Name fürs Subject + Header. Default "Account". */
33
21
  readonly appName?: string;
34
22
  };
35
23
 
36
- export type RenderVerifyEmailArgs = {
37
- readonly verificationUrl: string;
38
- readonly expiresAt: string;
39
- readonly locale?: AuthMailLocale;
40
- readonly appName?: string;
41
- };
24
+ // Invite adds the role to the unified token-content args; `url` is the fully-
25
+ // built magic-link (the handler already appended ?token=).
26
+ export type RenderInviteEmailArgs = RenderTokenContentArgs & { readonly role: string };
42
27
 
43
- export type RenderActivationEmailArgs = {
44
- readonly activationUrl: string;
45
- readonly expiresAt: string;
46
- readonly locale?: AuthMailLocale;
47
- readonly appName?: string;
48
- };
28
+ export type AuthMailSection =
29
+ | { readonly text: string }
30
+ | { readonly button: { readonly label: string; readonly url: string } };
49
31
 
50
- export type RenderInviteEmailArgs = {
51
- readonly inviteUrl: string;
52
- readonly expiresAt: string;
53
- readonly role: string;
54
- readonly locale?: AuthMailLocale;
55
- readonly appName?: string;
56
- };
57
-
58
- export type RenderedEmail = {
32
+ // Structured content the magic-link handlers pass as the delivery `data`
33
+ // payload: renderer-simple turns header/sections/footer into HTML, the
34
+ // email-channel reads `subject`. No pre-rendered HTML — the renderer owns layout.
35
+ export type AuthMailContent = {
59
36
  readonly subject: string;
60
- readonly html: string;
37
+ readonly header: string;
38
+ readonly sections: readonly AuthMailSection[];
39
+ readonly footer: string;
61
40
  };
62
41
 
63
42
  const STRINGS = {
@@ -94,7 +73,6 @@ const STRINGS = {
94
73
  inviteExpiry: (when: string) => `Der Link läuft am ${when} ab.`,
95
74
  inviteIgnore:
96
75
  "Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren.",
97
- fallbackUrl: "Falls der Button nicht funktioniert, kopiere diesen Link in den Browser:",
98
76
  },
99
77
  en: {
100
78
  resetSubject: (app: string) => `${app} — Reset your password`,
@@ -127,134 +105,97 @@ const STRINGS = {
127
105
  inviteButton: "Accept invitation",
128
106
  inviteExpiry: (when: string) => `The link expires on ${when}.`,
129
107
  inviteIgnore: "If you weren't expecting this invitation, you can ignore this email.",
130
- fallbackUrl: "If the button doesn't work, copy this link into your browser:",
131
108
  },
132
109
  } as const;
133
110
 
134
- // Shared shape für beide Token-Mails heading/intro/button/expiry/ignore
135
- // + button-Url + fallback-Url. renderResetPasswordEmail und renderVerifyEmail
136
- // bauen den Spec aus den lokalisierten STRINGS und delegieren ans
137
- // renderTokenEmail. Damit ist die Layout-Logik genau einmal definiert.
138
- type TokenEmailSpec = {
111
+ // Structured content for the delivery path. header = action title (CTA label),
112
+ // sections = greeting/intro/button/expiry, footer = the "ignore if not you"
113
+ // reassurance. The old plain-text fallback-URL link drops out renderer-simple
114
+ // has no link-text section and the button carries the URL.
115
+ function tokenMailContent(spec: {
139
116
  readonly subject: string;
117
+ readonly header: string;
140
118
  readonly greeting: string;
141
119
  readonly intro: string;
142
120
  readonly buttonLabel: string;
143
121
  readonly buttonUrl: string;
144
122
  readonly expiry: string;
145
123
  readonly ignore: string;
146
- readonly fallbackUrlLabel: string;
147
- };
148
-
149
- function renderTokenEmail(spec: TokenEmailSpec): RenderedEmail {
150
- const bodyHtml = `
151
- <tr><td>
152
- <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(spec.greeting)}</p>
153
- <p style="margin: 0 0 24px; font-size: 14px; line-height: 1.5;">${escapeHtml(spec.intro)}</p>
154
- <p style="margin: 0 0 24px;">${renderButton({ url: spec.buttonUrl, label: spec.buttonLabel })}</p>
155
- <p style="margin: 0 0 8px; font-size: 13px; color: #555;">${escapeHtml(spec.expiry)}</p>
156
- <p style="margin: 0; font-size: 13px; color: #555;">${escapeHtml(spec.ignore)}</p>
157
- ${renderFallbackUrl({ url: spec.buttonUrl, label: spec.fallbackUrlLabel })}
158
- </td></tr>`;
159
- return { subject: spec.subject, html: renderShell({ title: spec.subject, bodyHtml }) };
160
- }
161
-
162
- // Plain inline-styled HTML — funktioniert in Gmail/Outlook/Apple-Mail
163
- // ohne dass wir Tailwind oder eine HTML-mail-Lib reinziehen müssen.
164
- // guard:dup-ok — Email-HTML (table-layout, inline CSS) ≠ Web-HTML (legal-pages/markdown.ts)
165
- function renderShell(args: { title: string; bodyHtml: string }): string {
166
- return `<!DOCTYPE html>
167
- <html lang="en">
168
- <head>
169
- <meta charset="utf-8" />
170
- <meta name="viewport" content="width=device-width, initial-scale=1" />
171
- <title>${escapeHtml(args.title)}</title>
172
- </head>
173
- <body style="margin: 0; padding: 0; background: #f7f7f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a;">
174
- <table width="100%" cellpadding="0" cellspacing="0" style="padding: 24px 0;">
175
- <tr>
176
- <td align="center">
177
- <table width="560" cellpadding="0" cellspacing="0" style="max-width: 560px; background: #ffffff; border-radius: 8px; padding: 32px;">
178
- ${args.bodyHtml}
179
- </table>
180
- </td>
181
- </tr>
182
- </table>
183
- </body>
184
- </html>`;
185
- }
186
-
187
- // guard:dup-ok — Email-HTML-Helper; selbe normalisierte AST-Form wie wrapInLayout (legal-pages), verschiedene Semantik
188
- function renderButton(args: { url: string; label: string }): string {
189
- return `<a href="${escapeHtmlAttr(args.url)}" style="display: inline-block; background: #1a1a1a; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;">${escapeHtml(args.label)}</a>`;
190
- }
191
-
192
- // guard:dup-ok — Email-HTML-Helper; selbe normalisierte AST-Form wie wrapInLayout (legal-pages), verschiedene Semantik
193
- function renderFallbackUrl(args: { url: string; label: string }): string {
194
- return `<p style="margin: 24px 0 0; font-size: 12px; color: #666;">${escapeHtml(args.label)}<br /><a href="${escapeHtmlAttr(args.url)}" style="color: #1a1a1a; word-break: break-all;">${escapeHtml(args.url)}</a></p>`;
124
+ }): AuthMailContent {
125
+ return {
126
+ subject: spec.subject,
127
+ header: spec.header,
128
+ sections: [
129
+ { text: spec.greeting },
130
+ { text: spec.intro },
131
+ { button: { label: spec.buttonLabel, url: spec.buttonUrl } },
132
+ { text: spec.expiry },
133
+ ],
134
+ footer: spec.ignore,
135
+ };
195
136
  }
196
137
 
197
- export function renderResetPasswordEmail(args: RenderResetPasswordEmailArgs): RenderedEmail {
138
+ export function renderResetPasswordEmail(args: RenderTokenContentArgs): AuthMailContent {
198
139
  const locale = args.locale ?? "en";
199
140
  const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
200
141
  const t = STRINGS[locale];
201
- return renderTokenEmail({
142
+ return tokenMailContent({
202
143
  subject: t.resetSubject(appName),
144
+ header: t.resetButton,
203
145
  greeting: t.resetGreeting,
204
146
  intro: t.resetIntro(appName),
205
147
  buttonLabel: t.resetButton,
206
- buttonUrl: args.resetUrl,
148
+ buttonUrl: args.url,
207
149
  expiry: t.resetExpiry(formatExpiry(args.expiresAt)),
208
150
  ignore: t.resetIgnore,
209
- fallbackUrlLabel: t.fallbackUrl,
210
151
  });
211
152
  }
212
153
 
213
- export function renderVerifyEmail(args: RenderVerifyEmailArgs): RenderedEmail {
154
+ export function renderVerifyEmail(args: RenderTokenContentArgs): AuthMailContent {
214
155
  const locale = args.locale ?? "en";
215
156
  const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
216
157
  const t = STRINGS[locale];
217
- return renderTokenEmail({
158
+ return tokenMailContent({
218
159
  subject: t.verifySubject(appName),
160
+ header: t.verifyButton,
219
161
  greeting: t.verifyGreeting,
220
162
  intro: t.verifyIntro(appName),
221
163
  buttonLabel: t.verifyButton,
222
- buttonUrl: args.verificationUrl,
164
+ buttonUrl: args.url,
223
165
  expiry: t.verifyExpiry(formatExpiry(args.expiresAt)),
224
166
  ignore: t.verifyIgnore,
225
- fallbackUrlLabel: t.fallbackUrl,
226
167
  });
227
168
  }
228
169
 
229
- export function renderActivationEmail(args: RenderActivationEmailArgs): RenderedEmail {
170
+ export function renderActivationEmail(args: RenderTokenContentArgs): AuthMailContent {
230
171
  const locale = args.locale ?? "en";
231
172
  const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
232
173
  const t = STRINGS[locale];
233
- return renderTokenEmail({
174
+ return tokenMailContent({
234
175
  subject: t.activationSubject(appName),
176
+ header: t.activationButton,
235
177
  greeting: t.activationGreeting,
236
178
  intro: t.activationIntro(appName),
237
179
  buttonLabel: t.activationButton,
238
- buttonUrl: args.activationUrl,
180
+ buttonUrl: args.url,
239
181
  expiry: t.activationExpiry(formatExpiry(args.expiresAt)),
240
182
  ignore: t.activationIgnore,
241
- fallbackUrlLabel: t.fallbackUrl,
242
183
  });
243
184
  }
244
185
 
245
- export function renderInviteEmail(args: RenderInviteEmailArgs): RenderedEmail {
186
+ export function renderInviteEmail(args: RenderInviteEmailArgs): AuthMailContent {
246
187
  const locale = args.locale ?? "en";
247
- const appName = args.appName ?? (locale === "de" ? "Workspace" : "Workspace");
188
+ const appName = args.appName ?? "Workspace";
248
189
  const t = STRINGS[locale];
249
- return renderTokenEmail({
190
+ return tokenMailContent({
250
191
  subject: t.inviteSubject(appName),
192
+ header: t.inviteButton,
251
193
  greeting: t.inviteGreeting,
252
194
  intro: t.inviteIntro(appName, args.role),
253
195
  buttonLabel: t.inviteButton,
254
- buttonUrl: args.inviteUrl,
196
+ buttonUrl: args.url,
255
197
  expiry: t.inviteExpiry(formatExpiry(args.expiresAt)),
256
198
  ignore: t.inviteIgnore,
257
- fallbackUrlLabel: t.fallbackUrl,
258
199
  });
259
200
  }
260
201
 
@@ -1,5 +1,6 @@
1
1
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { z } from "zod";
3
+ import type { AuthMailLocale } from "./email-templates";
3
4
  import { changePasswordWrite } from "./handlers/change-password.write";
4
5
  import { createInviteAcceptHandler } from "./handlers/invite-accept.write";
5
6
  import { createInviteAcceptWithLoginHandler } from "./handlers/invite-accept-with-login.write";
@@ -54,6 +55,12 @@ export const authEmailPasswordEnvSchema = z.object({
54
55
  export type PasswordResetOptions = {
55
56
  readonly hmacSecret: string;
56
57
  readonly tokenTtlMinutes?: number;
58
+ // App page that receives the magic-link; the handler appends `?token=…` and
59
+ // sends the mail via delivery (ctx.notify). No sendResetEmail callback — the
60
+ // app mounts `delivery` + a mail channel instead.
61
+ readonly appUrl: string;
62
+ readonly appName?: string;
63
+ readonly locale?: AuthMailLocale;
57
64
  };
58
65
 
59
66
  // Opt-in configuration for the email-verification flow. mode="strict"
@@ -65,6 +72,11 @@ export type EmailVerificationOptions = {
65
72
  readonly hmacSecret: string;
66
73
  readonly tokenTtlMinutes?: number;
67
74
  readonly mode?: "strict" | "off";
75
+ // App page that receives the magic-link; the handler appends `?token=…` and
76
+ // sends via delivery (ctx.notify). No sendVerificationEmail callback.
77
+ readonly appUrl: string;
78
+ readonly appName?: string;
79
+ readonly locale?: AuthMailLocale;
68
80
  };
69
81
 
70
82
  // Brute-force protection on the login handler. Omit for the defaults
@@ -126,7 +138,7 @@ export function createAuthEmailPasswordFeature(
126
138
 
127
139
  return defineFeature("auth-email-password", (r) => {
128
140
  r.describe(
129
- "Provides email+password authentication: the always-on handlers are `login`, `changePassword`, and `logout`; optional flows \u2014 password reset, email verification, magic-link self-signup, and tenant invite \u2014 are registered only when you pass their respective option objects (`passwordReset`, `emailVerification`, `signup`, `invite`) to `createAuthEmailPasswordFeature(opts)`. Each opt-in flow uses HMAC-signed or opaque-random tokens delivered via callback (e.g. `sendResetEmail`) so the feature stays transport-agnostic. Requires the `user` and `tenant` features, and declares `JWT_SECRET` (\u2265 32 chars) in `authEmailPasswordEnvSchema` so a missing secret surfaces at boot validation rather than on the first login attempt.",
141
+ "Provides email+password authentication: the always-on handlers are `login`, `changePassword`, and `logout`; optional flows \u2014 password reset, email verification, magic-link self-signup, and tenant invite \u2014 are registered only when you pass their respective option objects (`passwordReset`, `emailVerification`, `signup`, `invite`) to `createAuthEmailPasswordFeature(opts)`. All four magic-link flows (reset, verification, signup activation, tenant invite) dispatch their mail through the `delivery` feature via `ctx.notify`, so mounting any of them additionally requires `delivery`. Tokens are HMAC-signed (reset/verify) or opaque-random in Redis (signup/invite). Requires the `user` and `tenant` features, and declares `JWT_SECRET` (\u2265 32 chars) in `authEmailPasswordEnvSchema` so a missing secret surfaces at boot validation rather than on the first login attempt.",
130
142
  );
131
143
  r.uiHints({
132
144
  displayLabel: "Auth \u00b7 Email + Password",
@@ -146,6 +158,12 @@ export function createAuthEmailPasswordFeature(
146
158
  });
147
159
  r.requires("user");
148
160
  r.requires("tenant");
161
+ // All four magic-link flows (reset/verify/signup/invite) dispatch via
162
+ // ctx.notify → delivery must be mounted. Fail closed at boot instead of
163
+ // silently dropping the mail.
164
+ if (opts.passwordReset || opts.emailVerification || opts.signup || opts.invite) {
165
+ r.requires("delivery");
166
+ }
149
167
  r.envSchema(authEmailPasswordEnvSchema);
150
168
 
151
169
  const handlers = {