@cosmicdrift/kumiko-bundled-features 0.102.1 → 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.
@@ -1,8 +1,10 @@
1
1
  // Tenant-Invite Step 1 (create).
2
2
  //
3
3
  // Admin invitet email → DB-Row entsteht via event-store-executor (oder
4
- // wird re-used bei Re-Invite), Random-Token in Redis bidirektional,
5
- // Route-Layer schickt Mail mit Activation-URL.
4
+ // wird re-used bei Re-Invite), Random-Token in Redis bidirektional, und der
5
+ // Handler schickt die Invite-Mail an den Invitee via delivery (ctx.notify) —
6
+ // wie reset/verify/signup. Der Token geht NICHT an den Admin zurück (er soll
7
+ // die Annahme nicht impersonieren können).
6
8
  //
7
9
  // Resend-Idempotenz: Re-Invite für gleiche (tenantId, email) während
8
10
  // pending → existing row + token re-genutzt + TTL refresh + zweite Mail
@@ -33,7 +35,12 @@ import {
33
35
  reservedMembershipRoleError,
34
36
  } from "../../tenant/membership-roles";
35
37
  import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants";
38
+ import type { AuthMailLocale } from "../email-templates";
39
+ import { renderInviteEmail } from "../email-templates";
36
40
  import { getTokenForInvitation, storeInviteToken } from "../invite-token-store";
41
+ import { dispatchMagicLinkMail } from "../magic-link-mail";
42
+
43
+ const INVITE_NOTIFICATION_TYPE = "auth-email-password:invite";
37
44
 
38
45
  const InviteCreateSchema = z.object({
39
46
  email: z.email(),
@@ -46,20 +53,24 @@ export type InviteCreateData = {
46
53
  readonly tenantId: string;
47
54
  readonly email: string;
48
55
  readonly role: string;
49
- readonly token: string;
50
56
  readonly expiresAt: string;
51
57
  };
52
58
 
53
59
  export type InviteCreateOptions = {
54
60
  /** TTL für den Activation-Token. Default 7 Tage. */
55
61
  readonly tokenTtlMinutes?: number;
62
+ /** App page that receives the magic-link; the handler appends `?token=…`
63
+ * and dispatches the invite mail via delivery (ctx.notify). */
64
+ readonly appUrl: string;
65
+ readonly appName?: string;
66
+ readonly locale?: AuthMailLocale;
56
67
  };
57
68
 
58
69
  const executor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, {
59
70
  entityName: "tenant-invitation",
60
71
  });
61
72
 
62
- export function createInviteCreateHandler(opts: InviteCreateOptions = {}) {
73
+ export function createInviteCreateHandler(opts: InviteCreateOptions) {
63
74
  const ttlMinutes = opts.tokenTtlMinutes ?? AUTH_INVITE_DEFAULT_TTL_MINUTES;
64
75
  const ttlSeconds = ttlMinutes * 60;
65
76
 
@@ -133,6 +144,24 @@ export function createInviteCreateHandler(opts: InviteCreateOptions = {}) {
133
144
 
134
145
  await storeInviteToken(ctx.redis, { invitationId, token, ttlSeconds });
135
146
 
147
+ await dispatchMagicLinkMail(
148
+ ctx.notify,
149
+ {
150
+ handlerName: "invite-create",
151
+ notificationType: INVITE_NOTIFICATION_TYPE,
152
+ renderContent: (renderArgs) =>
153
+ renderInviteEmail({ ...renderArgs, role: event.payload.role }),
154
+ },
155
+ {
156
+ email,
157
+ appUrl: opts.appUrl,
158
+ token,
159
+ expiresAt: expiresAt.toString(),
160
+ ...(opts.appName !== undefined && { appName: opts.appName }),
161
+ ...(opts.locale !== undefined && { locale: opts.locale }),
162
+ },
163
+ );
164
+
136
165
  return {
137
166
  isSuccess: true,
138
167
  data: {
@@ -141,7 +170,6 @@ export function createInviteCreateHandler(opts: InviteCreateOptions = {}) {
141
170
  tenantId,
142
171
  email,
143
172
  role: event.payload.role,
144
- token,
145
173
  expiresAt: expiresAt.toString(),
146
174
  },
147
175
  };
@@ -1,4 +1,5 @@
1
1
  import { AUTH_VERIFY_DEFAULT_TTL_MINUTES, AuthErrors } from "../constants";
2
+ import { renderVerifyEmail } from "../email-templates";
2
3
  import { signVerificationToken } from "../verification-token";
3
4
  import {
4
5
  createTokenRequestHandler,
@@ -6,6 +7,8 @@ import {
6
7
  type TokenRequestOptions,
7
8
  } from "./token-request-handler";
8
9
 
10
+ const VERIFY_NOTIFICATION_TYPE = "auth-email-password:email-verification";
11
+
9
12
  export type RequestEmailVerificationOptions = TokenRequestOptions;
10
13
 
11
14
  export type RequestVerificationData = TokenRequestData<"verification-requested">;
@@ -24,6 +27,8 @@ export function createRequestEmailVerificationHandler(opts: RequestEmailVerifica
24
27
  // unknown/deleted to keep the enumeration surface symmetric — the
25
28
  // caller sees the same 200 regardless of whether a token was minted.
26
29
  extraSilentSkip: (user) => user.emailVerified === true,
30
+ notificationType: VERIFY_NOTIFICATION_TYPE,
31
+ renderContent: renderVerifyEmail,
27
32
  },
28
33
  opts,
29
34
  );
@@ -1,4 +1,5 @@
1
1
  import { AUTH_RESET_DEFAULT_TTL_MINUTES, AuthErrors } from "../constants";
2
+ import { renderResetPasswordEmail } from "../email-templates";
2
3
  import { signResetToken } from "../reset-token";
3
4
  import {
4
5
  createTokenRequestHandler,
@@ -6,6 +7,8 @@ import {
6
7
  type TokenRequestOptions,
7
8
  } from "./token-request-handler";
8
9
 
10
+ const RESET_NOTIFICATION_TYPE = "auth-email-password:password-reset";
11
+
9
12
  export type RequestPasswordResetOptions = TokenRequestOptions;
10
13
 
11
14
  // Public shape re-exported for callers that build custom routes on top of
@@ -26,6 +29,8 @@ export function createRequestPasswordResetHandler(opts: RequestPasswordResetOpti
26
29
  // non-deleted user can initiate a reset regardless of verification
27
30
  // state. The sessions feature handles the post-change revocation.
28
31
  extraSilentSkip: () => false,
32
+ notificationType: RESET_NOTIFICATION_TYPE,
33
+ renderContent: renderResetPasswordEmail,
29
34
  },
30
35
  opts,
31
36
  );
@@ -1,8 +1,8 @@
1
1
  // Magic-Link-Signup, Step 1 (request).
2
2
  //
3
3
  // User gibt Email ein → wir minten einen opaken Random-Token, speichern
4
- // ihn bidirektional in Redis (token↔email), und der Route-Layer schickt
5
- // die Activation-Mail. Anders als reset/verify-Flows machen wir HIER keinen
4
+ // ihn bidirektional in Redis (token↔email), und schicken die Activation-Mail
5
+ // via delivery (ctx.notify) wie reset/verify. Anders als die: HIER kein
6
6
  // userId-Lookup und kein HMAC-signing (es gäbe kein Subject — im Normalfall
7
7
  // existiert der User noch nicht). Ob die Email bereits ein Konto hat,
8
8
  // entscheidet bewusst der Confirm-Schritt, nicht dieser.
@@ -28,8 +28,13 @@ import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/error
28
28
  import { Temporal } from "temporal-polyfill";
29
29
  import { z } from "zod";
30
30
  import { AUTH_SIGNUP_DEFAULT_TTL_MINUTES } from "../constants";
31
+ import type { AuthMailLocale } from "../email-templates";
32
+ import { renderActivationEmail } from "../email-templates";
33
+ import { dispatchMagicLinkMail } from "../magic-link-mail";
31
34
  import { getTokenForSignupEmail, normalizeEmail, storeSignupToken } from "../signup-token-store";
32
35
 
36
+ const SIGNUP_NOTIFICATION_TYPE = "auth-email-password:signup-activation";
37
+
33
38
  const SignupRequestSchema = z.object({
34
39
  email: z.email(),
35
40
  });
@@ -47,9 +52,14 @@ export type SignupRequestOptions = {
47
52
  /** TTL für den Activation-Token. Default 24 h — lang genug damit User
48
53
  * "morgen aktivieren" können ohne Resend-Spam. */
49
54
  readonly tokenTtlMinutes?: number;
55
+ /** App page that receives the magic-link; the handler appends `?token=…`
56
+ * and dispatches the activation mail via delivery (ctx.notify). */
57
+ readonly appUrl: string;
58
+ readonly appName?: string;
59
+ readonly locale?: AuthMailLocale;
50
60
  };
51
61
 
52
- export function createSignupRequestHandler(opts: SignupRequestOptions = {}) {
62
+ export function createSignupRequestHandler(opts: SignupRequestOptions) {
53
63
  const ttlMinutes = opts.tokenTtlMinutes ?? AUTH_SIGNUP_DEFAULT_TTL_MINUTES;
54
64
  const ttlSeconds = ttlMinutes * 60;
55
65
 
@@ -86,19 +96,39 @@ export function createSignupRequestHandler(opts: SignupRequestOptions = {}) {
86
96
  const token = existingToken ?? generateToken();
87
97
 
88
98
  const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
99
+ const expiresAtIso = expiresAt.toString();
89
100
 
90
101
  await storeSignupToken(ctx.redis, { email, token, ttlSeconds });
91
102
 
103
+ // normalizeEmail aus dem Store — eine Quelle für die Normalisierungs-
104
+ // Verantwortung; delivery-Empfänger + Lookup-Pfad kriegen konsistent
105
+ // das gleiche Format.
106
+ const normalizedEmail = normalizeEmail(email);
107
+
108
+ await dispatchMagicLinkMail(
109
+ ctx.notify,
110
+ {
111
+ handlerName: "signup-request",
112
+ notificationType: SIGNUP_NOTIFICATION_TYPE,
113
+ renderContent: renderActivationEmail,
114
+ },
115
+ {
116
+ email: normalizedEmail,
117
+ appUrl: opts.appUrl,
118
+ token,
119
+ expiresAt: expiresAtIso,
120
+ ...(opts.appName !== undefined && { appName: opts.appName }),
121
+ ...(opts.locale !== undefined && { locale: opts.locale }),
122
+ },
123
+ );
124
+
92
125
  return {
93
126
  isSuccess: true,
94
127
  data: {
95
128
  kind: "signup-requested",
96
- // normalizeEmail aus dem Store — eine Quelle für die
97
- // Normalisierungs-Verantwortung; Mail-Callback kriegt
98
- // konsistent das gleiche Format wie der Lookup-Pfad.
99
- email: normalizeEmail(email),
129
+ email: normalizedEmail,
100
130
  token,
101
- expiresAt: expiresAt.toString(),
131
+ expiresAt: expiresAtIso,
102
132
  },
103
133
  };
104
134
  },
@@ -17,6 +17,8 @@ import type { Temporal } from "temporal-polyfill";
17
17
  import { z } from "zod";
18
18
  import { UserQueries } from "../../user";
19
19
  import { type AuthUserRow, parseAuthUserRow } from "../auth-user-row";
20
+ import type { AuthMailContent, AuthMailLocale, RenderTokenContentArgs } from "../email-templates";
21
+ import { dispatchMagicLinkMail } from "../magic-link-mail";
20
22
 
21
23
  const RequestTokenSchema = z.object({
22
24
  email: z.email(),
@@ -56,11 +58,22 @@ export type TokenRequestSpec<TName extends string, TSuccessKind extends string>
56
58
  // soft-deleted". Verification skips when emailVerified is already true;
57
59
  // password-reset has no extra condition (returns false).
58
60
  readonly extraSilentSkip: (user: AuthUserRow) => boolean;
61
+ // Notification type dispatched via ctx.notify on success. No r.notification
62
+ // is declared (the recipient is an anonymous email, not a userId) — the
63
+ // route:{email} path delivers directly and buildMessage falls back to data.
64
+ readonly notificationType: string;
65
+ // Builds the structured mail body from the magic-link + expiry. Per-flow
66
+ // (renderResetPasswordEmail / renderVerifyEmail).
67
+ readonly renderContent: (args: RenderTokenContentArgs) => AuthMailContent;
59
68
  };
60
69
 
61
70
  export type TokenRequestOptions = {
62
71
  readonly hmacSecret: string;
63
72
  readonly tokenTtlMinutes?: number;
73
+ // App page that receives the magic-link; the handler appends `?token=…`.
74
+ readonly appUrl: string;
75
+ readonly appName?: string;
76
+ readonly locale?: AuthMailLocale;
64
77
  };
65
78
 
66
79
  export function createTokenRequestHandler<TName extends string, TSuccessKind extends string>(
@@ -103,6 +116,24 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
103
116
  }
104
117
 
105
118
  const { token, expiresAt } = spec.sign(user.id, ttl, opts.hmacSecret);
119
+
120
+ await dispatchMagicLinkMail(
121
+ ctx.notify,
122
+ {
123
+ handlerName: spec.handlerName,
124
+ notificationType: spec.notificationType,
125
+ renderContent: spec.renderContent,
126
+ },
127
+ {
128
+ email: user.email,
129
+ appUrl: opts.appUrl,
130
+ token,
131
+ expiresAt: expiresAt.toString(),
132
+ ...(opts.appName !== undefined && { appName: opts.appName }),
133
+ ...(opts.locale !== undefined && { locale: opts.locale }),
134
+ },
135
+ );
136
+
106
137
  const data: TokenRequestData<TSuccessKind> = {
107
138
  kind: spec.successKind,
108
139
  email: user.email,
@@ -1,27 +1,16 @@
1
- // Factory für app-spezifische Auth-Mail-Configs. Baut passwordReset,
2
- // emailVerification, signup und invite Setups gegen mailSender + Render-
3
- // Funktionen eliminiert Duplikate zwischen kumiko-studio, publicstatus
4
- // und solon (jede App hatte identische send*Email-Wrapper kopiert).
5
- export {
6
- type AuthMailerConfig,
7
- type AuthPaths,
8
- type CreateAuthMailerConfigArgs,
9
- createAuthMailerConfig,
10
- DEFAULT_AUTH_PATHS,
11
- makeAuthPaths,
12
- } from "./auth-mailer";
1
+ // Convention paths for the auth pages (reset/verify/signup/invite); apps build
2
+ // their magic-link appUrls from these. All mail now goes through delivery.
3
+ export { type AuthPaths, DEFAULT_AUTH_PATHS, makeAuthPaths } from "./auth-paths";
13
4
  export { AUTH_EMAIL_PASSWORD_FEATURE, AuthErrors, AuthHandlers } from "./constants";
14
- // Default-HTML-Renderer für die Reset-Password + Verify-Email Mails.
15
- // Apps wiren die `sendResetEmail` / `sendVerificationEmail` callbacks
16
- // im framework-config — diese Renderer können als one-liner genutzt
17
- // werden, oder die App schreibt einen eigenen Renderer für Branding.
5
+ // Renderers for the auth mails. All four magic-link flows (reset, verify,
6
+ // signup-activation, invite) emit structured AuthMailContent through delivery
7
+ // (ctx.notify).
18
8
  export type {
9
+ AuthMailContent,
19
10
  AuthMailLocale,
20
- RenderActivationEmailArgs,
21
- RenderedEmail,
11
+ AuthMailSection,
22
12
  RenderInviteEmailArgs,
23
- RenderResetPasswordEmailArgs,
24
- RenderVerifyEmailArgs,
13
+ RenderTokenContentArgs,
25
14
  } from "./email-templates";
26
15
  export {
27
16
  renderActivationEmail,
@@ -0,0 +1,62 @@
1
+ // Shared dispatch for the magic-link mails (password-reset, email-verification,
2
+ // signup-activation). All three follow the same delivery shape: append the
3
+ // token to the app page, render structured content, and notify the recipient
4
+ // email directly (route:{email} skips preferences — the recipient may not have
5
+ // a user account yet — and critical priority keeps a security mail
6
+ // non-unsubscribable). No r.notification is declared; buildMessage falls back
7
+ // to the structured `data` for the renderer.
8
+
9
+ import type { NotifyFn } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
11
+ import type { AuthMailContent, AuthMailLocale, RenderTokenContentArgs } from "./email-templates";
12
+
13
+ // Per-flow constants: which notification type to dispatch and how to render the
14
+ // body. renderContent is the flow's template (renderResetPasswordEmail / … /
15
+ // renderActivationEmail), all unified on RenderTokenContentArgs → AuthMailContent.
16
+ export type MagicLinkMailSpec = {
17
+ readonly handlerName: string;
18
+ readonly notificationType: string;
19
+ readonly renderContent: (args: RenderTokenContentArgs) => AuthMailContent;
20
+ };
21
+
22
+ // Per-request values: the recipient + the app page that receives the token, plus
23
+ // optional presentation. appUrl is the bare page URL; the token is appended here.
24
+ export type MagicLinkMailParams = {
25
+ readonly email: string;
26
+ readonly appUrl: string;
27
+ readonly token: string;
28
+ readonly expiresAt: string;
29
+ readonly appName?: string;
30
+ readonly locale?: AuthMailLocale;
31
+ };
32
+
33
+ function appendToken(appUrl: string, token: string): string {
34
+ const sep = appUrl.includes("?") ? "&" : "?";
35
+ return `${appUrl}${sep}token=${encodeURIComponent(token)}`;
36
+ }
37
+
38
+ export async function dispatchMagicLinkMail(
39
+ notify: NotifyFn | undefined,
40
+ spec: MagicLinkMailSpec,
41
+ params: MagicLinkMailParams,
42
+ ): Promise<void> {
43
+ // delivery is a hard requirement when a magic-link flow is mounted (see
44
+ // feature.ts r.requires), so notify is always wired — this guard is a
45
+ // defensive boot-invariant, not a runtime branch.
46
+ if (!notify) {
47
+ throw new InternalError({
48
+ message: `${spec.handlerName}: ctx.notify unavailable — the delivery feature must be mounted`,
49
+ });
50
+ }
51
+ const content = spec.renderContent({
52
+ url: appendToken(params.appUrl, params.token),
53
+ expiresAt: params.expiresAt,
54
+ ...(params.locale !== undefined && { locale: params.locale }),
55
+ ...(params.appName !== undefined && { appName: params.appName }),
56
+ });
57
+ await notify(spec.notificationType, {
58
+ route: { email: params.email },
59
+ data: content,
60
+ priority: "critical",
61
+ });
62
+ }
@@ -1,138 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { createInMemoryTransport } from "../../channel-email";
3
- import { createAuthMailerConfig } from "../auth-mailer";
4
-
5
- function makeArgs(overrides?: Record<string, unknown>) {
6
- const mailSender = createInMemoryTransport();
7
- return {
8
- mailSender,
9
- hmacSecret: "test-hmac-secret",
10
- baseUrl: "https://admin.example.com",
11
- paths: {
12
- resetPassword: "/reset-password",
13
- verifyEmail: "/verify-email",
14
- signupComplete: "/signup/complete",
15
- inviteAccept: "/invite/accept",
16
- },
17
- appName: "TestApp",
18
- locale: "en" as const,
19
- ...overrides,
20
- };
21
- }
22
-
23
- describe("createAuthMailerConfig", () => {
24
- test("returns all 4 setups", () => {
25
- const config = createAuthMailerConfig(makeArgs());
26
- expect(config.passwordReset).toBeDefined();
27
- expect(config.emailVerification).toBeDefined();
28
- expect(config.signup).toBeDefined();
29
- expect(config.invite).toBeDefined();
30
- });
31
-
32
- test("constructs URLs from baseUrl + paths", () => {
33
- const config = createAuthMailerConfig(makeArgs());
34
- expect(config.passwordReset.appResetUrl).toBe("https://admin.example.com/reset-password");
35
- expect(config.emailVerification.appVerifyUrl).toBe("https://admin.example.com/verify-email");
36
- expect(config.signup.appActivationUrl).toBe("https://admin.example.com/signup/complete");
37
- expect(config.invite.appAcceptUrl).toBe("https://admin.example.com/invite/accept");
38
- });
39
-
40
- test("forwards hmacSecret", () => {
41
- const config = createAuthMailerConfig(makeArgs());
42
- expect(config.passwordReset.hmacSecret).toBe("test-hmac-secret");
43
- expect(config.emailVerification.hmacSecret).toBe("test-hmac-secret");
44
- });
45
-
46
- test("sendResetEmail calls mailSender.send with rendered content", async () => {
47
- const args = makeArgs();
48
- const config = createAuthMailerConfig(args);
49
-
50
- await config.passwordReset.sendResetEmail({
51
- email: "user@example.com",
52
- resetUrl: "https://admin.example.com/reset?token=abc",
53
- expiresAt: "2026-06-09T12:00:00.000Z",
54
- });
55
-
56
- expect(args.mailSender.sent).toHaveLength(1);
57
- expect(args.mailSender.sent[0]!.to).toBe("user@example.com");
58
- expect(args.mailSender.sent[0]!.subject).toContain("TestApp");
59
- expect(args.mailSender.sent[0]!.subject).toContain("Reset");
60
- expect(args.mailSender.sent[0]!.html).toContain("https://admin.example.com/reset?token=abc");
61
- });
62
-
63
- test("sendVerificationEmail calls mailSender.send", async () => {
64
- const args = makeArgs();
65
- const config = createAuthMailerConfig(args);
66
-
67
- await config.emailVerification.sendVerificationEmail({
68
- email: "user@example.com",
69
- verificationUrl: "https://admin.example.com/verify?token=abc",
70
- expiresAt: "2026-06-09T12:00:00.000Z",
71
- });
72
-
73
- expect(args.mailSender.sent).toHaveLength(1);
74
- expect(args.mailSender.sent[0]!.to).toBe("user@example.com");
75
- expect(args.mailSender.sent[0]!.subject).toContain("TestApp");
76
- expect(args.mailSender.sent[0]!.subject).toContain("Verify");
77
- });
78
-
79
- test("sendActivationEmail calls mailSender.send", async () => {
80
- const args = makeArgs();
81
- const config = createAuthMailerConfig(args);
82
-
83
- await config.signup.sendActivationEmail({
84
- email: "user@example.com",
85
- activationUrl: "https://admin.example.com/signup/complete?token=abc",
86
- expiresAt: "2026-06-09T12:00:00.000Z",
87
- });
88
-
89
- expect(args.mailSender.sent).toHaveLength(1);
90
- expect(args.mailSender.sent[0]!.to).toBe("user@example.com");
91
- expect(args.mailSender.sent[0]!.subject).toContain("TestApp");
92
- });
93
-
94
- test("sendInviteEmail calls mailSender.send with role", async () => {
95
- const args = makeArgs();
96
- const config = createAuthMailerConfig(args);
97
-
98
- await config.invite.sendInviteEmail({
99
- email: "user@example.com",
100
- inviteUrl: "https://admin.example.com/invite/accept?token=abc",
101
- expiresAt: "2026-06-09T12:00:00.000Z",
102
- role: "Admin",
103
- });
104
-
105
- expect(args.mailSender.sent).toHaveLength(1);
106
- expect(args.mailSender.sent[0]!.to).toBe("user@example.com");
107
- expect(args.mailSender.sent[0]!.subject).toContain("TestApp");
108
- expect(args.mailSender.sent[0]!.html).toContain("Admin");
109
- });
110
-
111
- test("uses defaults when appName is omitted", () => {
112
- const config = createAuthMailerConfig(makeArgs({ appName: undefined }));
113
- expect(config.passwordReset.appResetUrl).toBeDefined();
114
- });
115
-
116
- test("emailVerificationMode is absent when not provided", () => {
117
- const config = createAuthMailerConfig(makeArgs());
118
- expect("mode" in config.emailVerification).toBe(false);
119
- });
120
-
121
- test("emailVerificationMode is set when provided", () => {
122
- const config = createAuthMailerConfig(makeArgs({ emailVerificationMode: "strict" }));
123
- expect(config.emailVerification).toHaveProperty("mode", "strict");
124
- });
125
-
126
- test("locale 'de' renders German subject", async () => {
127
- const args = makeArgs({ locale: "de" });
128
- const config = createAuthMailerConfig(args);
129
-
130
- await config.passwordReset.sendResetEmail({
131
- email: "user@example.com",
132
- resetUrl: "https://example.com/reset?token=abc",
133
- expiresAt: "2026-06-09T12:00:00.000Z",
134
- });
135
-
136
- expect(args.mailSender.sent[0]!.subject).toContain("Passwort");
137
- });
138
- });
@@ -1,153 +0,0 @@
1
- import type { EmailTransport } from "../channel-email";
2
- import type { AuthMailLocale } from "./email-templates";
3
- import {
4
- renderActivationEmail,
5
- renderInviteEmail,
6
- renderResetPasswordEmail,
7
- renderVerifyEmail,
8
- } from "./email-templates";
9
- import type {
10
- EmailVerificationOptions,
11
- InviteOptions,
12
- PasswordResetOptions,
13
- SignupOptions,
14
- } from "./feature";
15
-
16
- /**
17
- * Komplette Konfiguration für die 4 Auth-Mail-Flows einer App.
18
- *
19
- * Strukturell kompatibel mit den `*Setup`-Typen aus
20
- * `@cosmicdrift/kumiko-dev-server` (`PasswordResetSetup`,
21
- * `EmailVerificationSetup`, `SignupSetup`, `InviteSetup`).
22
- */
23
- export type AuthMailerConfig = {
24
- readonly passwordReset: PasswordResetOptions & {
25
- readonly appResetUrl: string;
26
- readonly sendResetEmail: (args: {
27
- email: string;
28
- resetUrl: string;
29
- expiresAt: string;
30
- }) => Promise<void>;
31
- };
32
- readonly emailVerification: EmailVerificationOptions & {
33
- readonly appVerifyUrl: string;
34
- readonly sendVerificationEmail: (args: {
35
- email: string;
36
- verificationUrl: string;
37
- expiresAt: string;
38
- }) => Promise<void>;
39
- };
40
- readonly signup: SignupOptions & {
41
- readonly appActivationUrl: string;
42
- readonly sendActivationEmail: (args: {
43
- email: string;
44
- activationUrl: string;
45
- expiresAt: string;
46
- }) => Promise<void>;
47
- };
48
- readonly invite: InviteOptions & {
49
- readonly appAcceptUrl: string;
50
- readonly sendInviteEmail: (args: {
51
- email: string;
52
- inviteUrl: string;
53
- expiresAt: string;
54
- role: string;
55
- }) => Promise<void>;
56
- };
57
- };
58
-
59
- /** Pfad-Konstanten der 4 Auth-Seiten (relativ zur App-baseUrl). */
60
- export type AuthPaths = {
61
- readonly resetPassword: string;
62
- readonly verifyEmail: string;
63
- readonly signupComplete: string;
64
- readonly inviteAccept: string;
65
- };
66
-
67
- /** Konventions-Pfade — alle Kumiko-Apps nutzen dieselben. Apps überschreiben
68
- * nur die Ausnahme via `makeAuthPaths({ ... })`. */
69
- export const DEFAULT_AUTH_PATHS: AuthPaths = {
70
- resetPassword: "/reset-password",
71
- verifyEmail: "/verify-email",
72
- signupComplete: "/signup/complete",
73
- inviteAccept: "/invite/accept",
74
- };
75
-
76
- export function makeAuthPaths(overrides: Partial<AuthPaths> = {}): AuthPaths {
77
- return { ...DEFAULT_AUTH_PATHS, ...overrides };
78
- }
79
-
80
- export type CreateAuthMailerConfigArgs = {
81
- readonly mailSender: EmailTransport;
82
- readonly hmacSecret: string;
83
- /** Basis-URL der App inkl. Schema (z.B. "https://admin.example.com").
84
- * Die factory hängt paths.resetPassword etc. an. */
85
- readonly baseUrl: string;
86
- /** Pfad-Konstanten für die Auth-Seiten. Default: DEFAULT_AUTH_PATHS. */
87
- readonly paths?: AuthPaths;
88
- /** App-Name für Mail-Subject + Body. Default "Account". */
89
- readonly appName?: string;
90
- /** Locale für die Mail-Templates. Default "de". */
91
- readonly locale?: AuthMailLocale;
92
- /** Email-verification mode. Default undefined (kein Gate).
93
- * "strict" blockt Login solange `emailVerified=false`.
94
- * "off" mountet die Routes ohne login-gating. */
95
- readonly emailVerificationMode?: "strict" | "off";
96
- };
97
-
98
- /**
99
- * Factory für `AuthMailerConfig` — baut die 4 Auth-Mail-Setups
100
- * (passwordReset / emailVerification / signup / invite) inklusive
101
- * `send*Email`-Wrapper + URL-Konstruktion.
102
- *
103
- * Jede App ruft das einmal auf und spreadet das Resultat in die
104
- * `auth`-Option von `runProdApp` / `runDevApp`.
105
- */
106
- export function createAuthMailerConfig(args: CreateAuthMailerConfigArgs): AuthMailerConfig {
107
- const appName = args.appName ?? "Account";
108
- const locale = args.locale ?? "de";
109
- const paths = args.paths ?? DEFAULT_AUTH_PATHS;
110
- return {
111
- passwordReset: {
112
- hmacSecret: args.hmacSecret,
113
- appResetUrl: `${args.baseUrl}${paths.resetPassword}`,
114
- sendResetEmail: async ({ email, resetUrl, expiresAt }) => {
115
- await args.mailSender.send({
116
- to: email,
117
- ...renderResetPasswordEmail({ resetUrl, expiresAt, locale, appName }),
118
- });
119
- },
120
- },
121
- emailVerification: {
122
- hmacSecret: args.hmacSecret,
123
- ...(args.emailVerificationMode !== undefined && {
124
- mode: args.emailVerificationMode,
125
- }),
126
- appVerifyUrl: `${args.baseUrl}${paths.verifyEmail}`,
127
- sendVerificationEmail: async ({ email, verificationUrl, expiresAt }) => {
128
- await args.mailSender.send({
129
- to: email,
130
- ...renderVerifyEmail({ verificationUrl, expiresAt, locale, appName }),
131
- });
132
- },
133
- },
134
- signup: {
135
- appActivationUrl: `${args.baseUrl}${paths.signupComplete}`,
136
- sendActivationEmail: async ({ email, activationUrl, expiresAt }) => {
137
- await args.mailSender.send({
138
- to: email,
139
- ...renderActivationEmail({ activationUrl, expiresAt, locale, appName }),
140
- });
141
- },
142
- },
143
- invite: {
144
- appAcceptUrl: `${args.baseUrl}${paths.inviteAccept}`,
145
- sendInviteEmail: async ({ email, inviteUrl, expiresAt, role }) => {
146
- await args.mailSender.send({
147
- to: email,
148
- ...renderInviteEmail({ inviteUrl, expiresAt, role, locale, appName }),
149
- });
150
- },
151
- },
152
- };
153
- }