@cosmicdrift/kumiko-bundled-features 0.88.0 → 0.90.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.
Files changed (52) hide show
  1. package/package.json +9 -6
  2. package/src/data-retention/__tests__/cleanup-cron-registration.test.ts +23 -0
  3. package/src/data-retention/__tests__/resolve-tenant-preset.test.ts +57 -0
  4. package/src/data-retention/__tests__/resolver.test.ts +3 -3
  5. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +188 -0
  6. package/src/data-retention/feature.ts +58 -7
  7. package/src/data-retention/presets.ts +5 -5
  8. package/src/data-retention/resolve-for-tenant.ts +9 -4
  9. package/src/data-retention/resolve-tenant-preset.ts +51 -0
  10. package/src/data-retention/run-retention-cleanup.ts +151 -0
  11. package/src/folders/__tests__/drift.test.ts +43 -0
  12. package/src/folders/__tests__/feature.test.ts +168 -0
  13. package/src/folders/__tests__/folders.integration.test.ts +290 -0
  14. package/src/folders/aggregate-id.ts +23 -0
  15. package/src/folders/constants.ts +40 -0
  16. package/src/folders/entity.ts +42 -0
  17. package/src/folders/executor.ts +11 -0
  18. package/src/folders/feature.ts +106 -0
  19. package/src/folders/handlers/clear-folder.write.ts +35 -0
  20. package/src/folders/handlers/set-folder.write.ts +82 -0
  21. package/src/folders/index.ts +23 -0
  22. package/src/folders/schemas.ts +18 -0
  23. package/src/folders/web/__tests__/folder-section.test.tsx +181 -0
  24. package/src/folders/web/__tests__/tree.test.ts +58 -0
  25. package/src/folders/web/client-plugin.tsx +16 -0
  26. package/src/folders/web/folder-manager.tsx +323 -0
  27. package/src/folders/web/folder-section.tsx +198 -0
  28. package/src/folders/web/i18n.ts +55 -0
  29. package/src/folders/web/index.ts +6 -0
  30. package/src/folders/web/tree.ts +54 -0
  31. package/src/folders-user-data/hooks.ts +58 -0
  32. package/src/folders-user-data/index.ts +33 -0
  33. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +8 -1
  34. package/src/legal-pages/feature.ts +22 -10
  35. package/src/mail-foundation/feature.ts +51 -6
  36. package/src/mail-foundation/index.ts +2 -0
  37. package/src/mail-transport-inmemory/feature.ts +6 -3
  38. package/src/mail-transport-smtp/feature.ts +11 -10
  39. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +1 -1
  40. package/src/managed-pages/feature.ts +17 -9
  41. package/src/user-data-rights/__tests__/default-mailers.test.ts +135 -0
  42. package/src/user-data-rights/__tests__/email-templates.test.ts +85 -0
  43. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +154 -0
  44. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +23 -2
  45. package/src/user-data-rights/email-templates.ts +211 -0
  46. package/src/user-data-rights/feature.ts +96 -21
  47. package/src/user-data-rights/handlers/deletion-grace-period.ts +17 -5
  48. package/src/user-data-rights/handlers/request-deletion.write.ts +29 -3
  49. package/src/user-data-rights/lib/default-mailers.ts +116 -0
  50. package/src/user-data-rights/lib/mail-transport-resolver.ts +50 -0
  51. package/src/user-data-rights/run-export-jobs.ts +19 -8
  52. package/src/user-data-rights/run-forget-cleanup.ts +11 -1
@@ -0,0 +1,154 @@
1
+ // C6 Autonomie-Beweis: mountet user-data-rights OHNE send*Email-Opts +
2
+ // mail-foundation + mail-transport-inmemory, und treibt den ECHTEN
3
+ // registrierten Forget-Cron durch einen echten Job-Kontext — `configResolver`
4
+ // gesetzt (App-Override provider=inmemory), KEIN per-request `config`.
5
+ //
6
+ // Das beweist die kritische Naht: der Cron baut den per-Tenant-Mail-Transport
7
+ // aus `ctx.configResolver` (makeTenantMailTransportResolver). Ein hand-
8
+ // gefuetterter Callback oder Fake-Resolver wuerde genau diese Bruecke
9
+ // ueberspringen (vgl. project_export_cron_config_accessor_fix). Akzeptanz von
10
+ // #624: "App mountet mail-foundation+transport → GDPR-Mails ohne Callback-Code".
11
+
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
15
+ import {
16
+ setupTestStack,
17
+ type TestStack,
18
+ unsafeCreateEntityTable,
19
+ unsafePushTables,
20
+ } from "@cosmicdrift/kumiko-framework/stack";
21
+ import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
22
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
23
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
24
+ import { configValuesTable, createConfigFeature, createConfigResolver } from "../../config";
25
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
26
+ import { createFilesFeature } from "../../files";
27
+ import { mailFoundationFeature } from "../../mail-foundation";
28
+ import { clearInbox, getInbox, mailTransportInMemoryFeature } from "../../mail-transport-inmemory";
29
+ import { createSessionsFeature } from "../../sessions";
30
+ import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
31
+ import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
32
+ import { createUserDataRightsFeature } from "../feature";
33
+
34
+ const TENANT_A = "00000000-0000-4000-8000-0000000006a1";
35
+ const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
36
+ const USER_ID = "00000000-0000-4000-8000-0000000006b1";
37
+ const ORIGINAL_EMAIL = "bridge-delete@example.test";
38
+ const FORGET_JOB = "user-data-rights:job:run-forget-cleanup";
39
+
40
+ // App-weiter Override (wie money-horse's Config-Resolver): provider=inmemory
41
+ // ohne per-Tenant-config-Row. Der Job-Kontext traegt DIESEN resolver, kein config.
42
+ const configResolver = createConfigResolver({
43
+ appOverrides: new Map([["mail-foundation:config:provider", "inmemory"]]),
44
+ });
45
+
46
+ let stack: TestStack;
47
+
48
+ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
49
+ const past = (): Instant => getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
50
+
51
+ beforeAll(async () => {
52
+ stack = await setupTestStack({
53
+ features: [
54
+ createConfigFeature(),
55
+ createUserFeature(),
56
+ createFilesFeature(),
57
+ createDataRetentionFeature(),
58
+ createComplianceProfilesFeature(),
59
+ createSessionsFeature(),
60
+ mailFoundationFeature,
61
+ mailTransportInMemoryFeature,
62
+ // KEINE send*Email-Opts — die mail-foundation-Defaults muessen greifen.
63
+ createUserDataRightsFeature(),
64
+ createUserDataRightsDefaultsFeature(),
65
+ ],
66
+ });
67
+
68
+ await unsafePushTables(stack.db, { configValuesTable });
69
+ await unsafeCreateEntityTable(stack.db, userEntity);
70
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
71
+ await asRawClient(stack.db).unsafe(`
72
+ CREATE TABLE IF NOT EXISTS read_tenant_memberships (
73
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
74
+ tenant_id UUID NOT NULL,
75
+ user_id TEXT NOT NULL,
76
+ version INTEGER NOT NULL DEFAULT 0,
77
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
78
+ modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
79
+ inserted_by_id TEXT,
80
+ modified_by_id TEXT,
81
+ is_deleted BOOLEAN NOT NULL DEFAULT false,
82
+ deleted_at TIMESTAMPTZ,
83
+ deleted_by_id TEXT,
84
+ roles TEXT NOT NULL DEFAULT '[]',
85
+ UNIQUE(user_id, tenant_id)
86
+ )
87
+ `);
88
+ await unsafePushTables(stack.db, { fileRefsTable });
89
+ });
90
+
91
+ afterAll(async () => {
92
+ await stack.cleanup();
93
+ });
94
+
95
+ beforeEach(async () => {
96
+ await resetTestTables(stack.db, [userTable, "read_tenant_memberships", fileRefsTable]);
97
+ clearInbox(TENANT_A);
98
+ });
99
+
100
+ describe("C6 default mail bridge :: forget cron sends deletion-executed without app callback", () => {
101
+ test("registered cron + configResolver(provider=inmemory) → user deleted + mail in inbox", async () => {
102
+ await insertOne(stack.db, userTable, {
103
+ id: USER_ID,
104
+ tenantId: TENANT_SYSTEM,
105
+ email: ORIGINAL_EMAIL,
106
+ passwordHash: "hashed",
107
+ displayName: "Bridge Delete",
108
+ locale: "de",
109
+ emailVerified: true,
110
+ roles: '["Member"]',
111
+ status: USER_STATUS.DeletionRequested,
112
+ gracePeriodEnd: past(),
113
+ });
114
+ await asRawClient(stack.db).unsafe(
115
+ `INSERT INTO read_tenant_memberships (tenant_id, user_id, roles) VALUES ($1, $2, '["Member"]')`,
116
+ [TENANT_A, USER_ID],
117
+ );
118
+
119
+ const job = stack.registry.getJob(FORGET_JOB);
120
+ expect(job).toBeDefined();
121
+
122
+ // EXAKT der prod-Job-Kontext: configResolver gesetzt, config undefined.
123
+ const jobCtx = { db: stack.db, registry: stack.registry, configResolver };
124
+ await job?.handler({}, jobCtx as never);
125
+
126
+ // Loeschung lief autonom durch.
127
+ const rows = (await asRawClient(stack.db).unsafe(
128
+ "SELECT status, email FROM read_users WHERE id = $1",
129
+ [USER_ID],
130
+ )) as unknown as { rows?: Array<{ status: string; email: string }> };
131
+ const userRow = (rows.rows ?? (rows as unknown as Array<{ status: string; email: string }>))[0];
132
+ expect(userRow?.status).toBe(USER_STATUS.Deleted);
133
+
134
+ // Die Default-Mail wurde ueber den aus configResolver gebauten inmemory-
135
+ // Transport versendet — keine App-seitige Callback-Verdrahtung.
136
+ const inbox = getInbox(TENANT_A);
137
+ expect(inbox).toHaveLength(1);
138
+ expect(inbox[0]?.to).toBe(ORIGINAL_EMAIL);
139
+ // Der User ist mit locale="de" geseedet → die Default-Mail rendert DEUTSCH
140
+ // (per-recipient locale, KEIN App-Callback). Vorher rendete sie still en —
141
+ // der Advisor-Befund, den dieser Assert jetzt einfaengt.
142
+ expect(inbox[0]?.subject).toContain("Dein Konto wurde geloescht");
143
+ expect(inbox[0]?.html).toContain("endgueltig");
144
+ });
145
+
146
+ test("no mail transport mounted is NOT this stack — sanity: provider really is inmemory", () => {
147
+ // Pinnt dass der Stack den inmemory-Transport registriert hat (sonst waere
148
+ // der obige Beweis vacuously true: ohne mailTransport-Usage greift die
149
+ // Default gar nicht).
150
+ expect(stack.registry.getExtensionUsages("mailTransport").map((u) => u.entityName)).toContain(
151
+ "inmemory",
152
+ );
153
+ });
154
+ });
@@ -28,6 +28,8 @@ import { configValuesTable, createConfigFeature, createConfigResolver } from "..
28
28
  import { createDataRetentionFeature } from "../../data-retention";
29
29
  import { fileFoundationFeature } from "../../file-foundation";
30
30
  import { fileProviderInMemoryFeature } from "../../file-provider-inmemory";
31
+ import { mailFoundationFeature } from "../../mail-foundation";
32
+ import { clearInbox, getInbox, mailTransportInMemoryFeature } from "../../mail-transport-inmemory";
31
33
  import { createSessionsFeature } from "../../sessions";
32
34
  import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
33
35
  import { createUserDataRightsFeature } from "../feature";
@@ -41,9 +43,14 @@ const JOB_QN = "user-data-rights:job:run-export-jobs";
41
43
  // App-weiter Override wie money-horse's cashColtConfigResolver — provider=inmemory
42
44
  // ohne per-Tenant-config-Row. Der Job-Kontext trägt DIESEN resolver, kein config.
43
45
  const configResolver = createConfigResolver({
44
- appOverrides: new Map([["file-foundation:config:provider", "inmemory"]]),
46
+ appOverrides: new Map([
47
+ ["file-foundation:config:provider", "inmemory"],
48
+ ["mail-foundation:config:provider", "inmemory"],
49
+ ]),
45
50
  });
46
51
 
52
+ const EXPORT_DOWNLOAD_URL = "https://app.test/user-export/by-token";
53
+
47
54
  let stack: TestStack;
48
55
 
49
56
  beforeAll(async () => {
@@ -55,8 +62,12 @@ beforeAll(async () => {
55
62
  createComplianceProfilesFeature(),
56
63
  fileFoundationFeature,
57
64
  fileProviderInMemoryFeature,
65
+ mailFoundationFeature,
66
+ mailTransportInMemoryFeature,
58
67
  createSessionsFeature(),
59
- createUserDataRightsFeature(),
68
+ // appExportDownloadUrl set → the default export-ready mail is enabled, so
69
+ // this also proves the export cron's mail bridge end-to-end (C6).
70
+ createUserDataRightsFeature({ appExportDownloadUrl: EXPORT_DOWNLOAD_URL }),
60
71
  ],
61
72
  });
62
73
  await createEventsTable(stack.db);
@@ -107,6 +118,7 @@ beforeEach(async () => {
107
118
  await raw.unsafe(
108
119
  `INSERT INTO read_tenant_memberships (tenant_id, user_id, roles) VALUES ('${TENANT}', '${USER_ID}', '["Member"]')`,
109
120
  );
121
+ clearInbox(TENANT);
110
122
  });
111
123
 
112
124
  // Seedet einen pending Export-Job über den echten request-export-Handler.
@@ -144,5 +156,14 @@ describe("run-export-jobs cron-context", () => {
144
156
  expect(row?.errorMessage).toBeNull();
145
157
  expect(row?.status).toBe(EXPORT_JOB_STATUS.Done);
146
158
  expect(row?.bytesWritten ?? 0).toBeGreaterThan(0);
159
+
160
+ // C6 — der echte Export-Cron versendet die Default-Export-ready-Mail ueber
161
+ // den aus configResolver gebauten inmemory-Transport (kein App-Callback).
162
+ // user.locale="de" → deutsches Subject; downloadUrl traegt den Magic-Link.
163
+ const inbox = getInbox(TENANT);
164
+ expect(inbox).toHaveLength(1);
165
+ expect(inbox[0]?.to).toBe("export-cron@example.test");
166
+ expect(inbox[0]?.subject).toContain("Dein Datenexport ist bereit");
167
+ expect(inbox[0]?.html).toContain(EXPORT_DOWNLOAD_URL);
147
168
  });
148
169
  });
@@ -0,0 +1,211 @@
1
+ // Default-HTML-Renderer fuer die GDPR-Notification-Mails (Export-bereit,
2
+ // Export-fehlgeschlagen, Loeschung-angefordert, Loeschung-ausgefuehrt).
3
+ // Damit eine App keine eigenen send*Email-Callbacks schreiben muss: sie
4
+ // mountet mail-foundation + einen mail-transport-* und user-data-rights
5
+ // rendert + versendet ueber diese Templates (siehe lib/default-mailers.ts).
6
+ //
7
+ // Apps die ihr eigenes Branding wollen, uebergeben eigene send*Email-Opts —
8
+ // dann greifen diese Defaults nicht. Pattern + plain-inline-HTML gespiegelt
9
+ // von auth-email-password/email-templates.ts (kein CSS-Framework, kein
10
+ // Bild-Asset; Mail-Clients rendern table-layout + inline-CSS verlaesslich).
11
+ //
12
+ // Locale: de + en. Apps mit anderen Sprachen uebergeben eigene Callbacks.
13
+
14
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
15
+ import { Temporal } from "temporal-polyfill";
16
+
17
+ export type GdprMailLocale = "de" | "en";
18
+
19
+ // user.locale ist Freitext ("de", "en", "de-DE", "fr", null). Die Templates
20
+ // koennen nur de/en — alles andere (inkl. unbekannte Sprachen) faellt auf
21
+ // undefined zurueck, sodass der Caller auf mailDefaults.locale bzw. "en" geht.
22
+ export function normalizeGdprMailLocale(
23
+ raw: string | null | undefined,
24
+ ): GdprMailLocale | undefined {
25
+ if (!raw) return undefined;
26
+ const lower = raw.toLowerCase();
27
+ if (lower.startsWith("de")) return "de";
28
+ if (lower.startsWith("en")) return "en";
29
+ return undefined;
30
+ }
31
+
32
+ export type RenderedEmail = {
33
+ readonly subject: string;
34
+ readonly html: string;
35
+ };
36
+
37
+ export type RenderExportReadyEmailArgs = {
38
+ readonly downloadUrl: string;
39
+ readonly expiresAt: string;
40
+ readonly locale?: GdprMailLocale;
41
+ readonly appName?: string;
42
+ };
43
+
44
+ export type RenderExportFailedEmailArgs = {
45
+ readonly locale?: GdprMailLocale;
46
+ readonly appName?: string;
47
+ };
48
+
49
+ export type RenderDeletionRequestedEmailArgs = {
50
+ readonly gracePeriodEnd: string;
51
+ readonly locale?: GdprMailLocale;
52
+ readonly appName?: string;
53
+ };
54
+
55
+ export type RenderDeletionExecutedEmailArgs = {
56
+ readonly executedAt: string;
57
+ readonly locale?: GdprMailLocale;
58
+ readonly appName?: string;
59
+ };
60
+
61
+ const STRINGS = {
62
+ de: {
63
+ greeting: "Hallo,",
64
+ exportReadySubject: (app: string) => `${app} — Dein Datenexport ist bereit`,
65
+ exportReadyIntro: (app: string) =>
66
+ `dein angeforderter Datenexport fuer ${app} ist fertig. Lade ihn ueber den folgenden Link herunter:`,
67
+ exportReadyButton: "Datenexport herunterladen",
68
+ exportReadyExpiry: (when: string) => `Der Download-Link laeuft am ${when} ab.`,
69
+ exportFailedSubject: (app: string) => `${app} — Dein Datenexport ist fehlgeschlagen`,
70
+ exportFailedIntro: (app: string) =>
71
+ `dein angeforderter Datenexport fuer ${app} konnte leider nicht erstellt werden. Bitte fordere den Export erneut an.`,
72
+ deletionRequestedSubject: (app: string) => `${app} — Loeschung deines Kontos angefordert`,
73
+ deletionRequestedIntro: (app: string, when: string) =>
74
+ `wir haben deinen Antrag zur Loeschung deines ${app}-Kontos erhalten. Dein Konto und die zugehoerigen Daten werden am ${when} endgueltig geloescht.`,
75
+ deletionRequestedCancel:
76
+ "Falls du das nicht angefordert hast, melde dich an und brich die Loeschung in den Kontoeinstellungen ab, bevor die Frist ablaeuft.",
77
+ deletionExecutedSubject: (app: string) => `${app} — Dein Konto wurde geloescht`,
78
+ deletionExecutedIntro: (app: string, when: string) =>
79
+ `dein ${app}-Konto und die zugehoerigen personenbezogenen Daten wurden am ${when} geloescht. Diese Aktion ist endgueltig.`,
80
+ fallbackUrl: "Falls der Button nicht funktioniert, kopiere diesen Link in den Browser:",
81
+ },
82
+ en: {
83
+ greeting: "Hi,",
84
+ exportReadySubject: (app: string) => `${app} — Your data export is ready`,
85
+ exportReadyIntro: (app: string) =>
86
+ `your requested data export for ${app} is ready. Download it using the link below:`,
87
+ exportReadyButton: "Download data export",
88
+ exportReadyExpiry: (when: string) => `The download link expires on ${when}.`,
89
+ exportFailedSubject: (app: string) => `${app} — Your data export failed`,
90
+ exportFailedIntro: (app: string) =>
91
+ `your requested data export for ${app} could not be created. Please request the export again.`,
92
+ deletionRequestedSubject: (app: string) => `${app} — Account deletion requested`,
93
+ deletionRequestedIntro: (app: string, when: string) =>
94
+ `we received your request to delete your ${app} account. Your account and associated data will be permanently deleted on ${when}.`,
95
+ deletionRequestedCancel:
96
+ "If you didn't request this, sign in and cancel the deletion in your account settings before the deadline.",
97
+ deletionExecutedSubject: (app: string) => `${app} — Your account has been deleted`,
98
+ deletionExecutedIntro: (app: string, when: string) =>
99
+ `your ${app} account and the associated personal data were deleted on ${when}. This action is permanent.`,
100
+ fallbackUrl: "If the button doesn't work, copy this link into your browser:",
101
+ },
102
+ } as const;
103
+
104
+ function appNameFor(args: { locale?: GdprMailLocale; appName?: string }): string {
105
+ const locale = args.locale ?? "en";
106
+ return args.appName ?? (locale === "de" ? "Konto" : "Account");
107
+ }
108
+
109
+ export function renderExportReadyEmail(args: RenderExportReadyEmailArgs): RenderedEmail {
110
+ const locale = args.locale ?? "en";
111
+ const app = appNameFor(args);
112
+ const t = STRINGS[locale];
113
+ const subject = t.exportReadySubject(app);
114
+ const body = `
115
+ <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t.greeting)}</p>
116
+ <p style="margin: 0 0 24px; font-size: 14px; line-height: 1.5;">${escapeHtml(t.exportReadyIntro(app))}</p>
117
+ <p style="margin: 0 0 24px;">${renderButton({ url: args.downloadUrl, label: t.exportReadyButton })}</p>
118
+ <p style="margin: 0 0 8px; font-size: 13px; color: #555;">${escapeHtml(t.exportReadyExpiry(formatTimestamp(args.expiresAt)))}</p>
119
+ ${renderFallbackUrl({ url: args.downloadUrl, label: t.fallbackUrl })}`;
120
+ return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body) }) };
121
+ }
122
+
123
+ export function renderExportFailedEmail(args: RenderExportFailedEmailArgs): RenderedEmail {
124
+ const locale = args.locale ?? "en";
125
+ const app = appNameFor(args);
126
+ const t = STRINGS[locale];
127
+ const subject = t.exportFailedSubject(app);
128
+ const body = `
129
+ <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t.greeting)}</p>
130
+ <p style="margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(t.exportFailedIntro(app))}</p>`;
131
+ return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body) }) };
132
+ }
133
+
134
+ export function renderDeletionRequestedEmail(
135
+ args: RenderDeletionRequestedEmailArgs,
136
+ ): RenderedEmail {
137
+ const locale = args.locale ?? "en";
138
+ const app = appNameFor(args);
139
+ const t = STRINGS[locale];
140
+ const subject = t.deletionRequestedSubject(app);
141
+ const body = `
142
+ <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t.greeting)}</p>
143
+ <p style="margin: 0 0 16px; font-size: 14px; line-height: 1.5;">${escapeHtml(t.deletionRequestedIntro(app, formatTimestamp(args.gracePeriodEnd)))}</p>
144
+ <p style="margin: 0; font-size: 13px; color: #555;">${escapeHtml(t.deletionRequestedCancel)}</p>`;
145
+ return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body) }) };
146
+ }
147
+
148
+ export function renderDeletionExecutedEmail(args: RenderDeletionExecutedEmailArgs): RenderedEmail {
149
+ const locale = args.locale ?? "en";
150
+ const app = appNameFor(args);
151
+ const t = STRINGS[locale];
152
+ const subject = t.deletionExecutedSubject(app);
153
+ const body = `
154
+ <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t.greeting)}</p>
155
+ <p style="margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(t.deletionExecutedIntro(app, formatTimestamp(args.executedAt)))}</p>`;
156
+ return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body) }) };
157
+ }
158
+
159
+ function wrapCell(bodyHtml: string): string {
160
+ return `<tr><td>${bodyHtml}</td></tr>`;
161
+ }
162
+
163
+ // Plain inline-styled HTML — gespiegelt von auth-email-password.
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 auth-email-password, 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 auth-email-password, 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>`;
195
+ }
196
+
197
+ // ISO-Timestamp → "2026-05-04 13:45 UTC". Locale-unabhaengig + UTC-Suffix
198
+ // damit der User unabhaengig von seiner Tz sieht wann etwas passiert; bei
199
+ // un-parsbarem Input faellt's auf den raw-string zurueck.
200
+ function formatTimestamp(iso: string): string {
201
+ try {
202
+ const z = Temporal.Instant.from(iso).toZonedDateTimeISO("UTC");
203
+ return `${z.year}-${pad2(z.month)}-${pad2(z.day)} ${pad2(z.hour)}:${pad2(z.minute)} UTC`;
204
+ } catch {
205
+ return iso;
206
+ }
207
+ }
208
+
209
+ function pad2(n: number): string {
210
+ return String(n).padStart(2, "0");
211
+ }
@@ -28,6 +28,14 @@ import {
28
28
  import { requestExportWrite } from "./handlers/request-export.write";
29
29
  import { restrictAccountWrite } from "./handlers/restrict-account.write";
30
30
  import { createRunForgetCleanupHandler } from "./handlers/run-forget-cleanup.write";
31
+ import {
32
+ type GdprMailDefaults,
33
+ isMailTransportAvailable,
34
+ makeDefaultDeletionExecutedEmail,
35
+ makeDefaultExportFailedEmail,
36
+ makeDefaultExportReadyEmail,
37
+ } from "./lib/default-mailers";
38
+ import { makeTenantMailTransportResolver } from "./lib/mail-transport-resolver";
31
39
  import { resolveAppTenantModel } from "./lib/resolve-tenant-model";
32
40
  import { makeTenantStorageProviderResolver } from "./lib/storage-provider-resolver";
33
41
  import {
@@ -104,11 +112,25 @@ export type UserDataRightsOptions = {
104
112
  /** Versand des Verify-Magic-Links (Schritt 1 des anonymen Flows).
105
113
  * Best-effort, app-author-wired. MUSS non-blocking sein (enqueue, z.B.
106
114
  * delivery.notify) — ein synchroner Send reintroduziert ein Timing-Oracle
107
- * für Account-Enumeration (siehe SendDeletionVerificationEmailFn-Doc). */
115
+ * für Account-Enumeration (siehe SendDeletionVerificationEmailFn-Doc).
116
+ * Bewusst KEINE mail-foundation-Default: ein synchroner Default-Send
117
+ * brächte genau dieses Enumeration-Oracle zurück, deshalb app-wired. */
108
118
  readonly sendDeletionVerificationEmail?: SendDeletionVerificationEmailFn;
119
+ /** C6 — Zero-Callback-GDPR-Mails: ist mail-foundation + ein mail-transport-*
120
+ * gemountet, versendet user-data-rights die Export-/Loesch-Notifications
121
+ * selbst (Export-ready/-failed, Deletion-requested/-executed) ueber die
122
+ * Default-Templates (email-templates.ts) — die App schreibt keinen Callback.
123
+ * `mailDefaults` brandet diese Default-Mails (Locale + App-Name). Greift NUR
124
+ * wenn der jeweilige send*Email-Opt NICHT gesetzt ist; Export-ready braucht
125
+ * zusaetzlich appExportDownloadUrl. */
126
+ readonly mailDefaults?: GdprMailDefaults;
109
127
  };
110
128
 
111
129
  export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): FeatureDefinition {
130
+ // One-shot operator warning (the export cron fires every minute — warn once
131
+ // per process, not every run). Lives in the factory scope so the cron closure
132
+ // shares it across runs.
133
+ let warnedMissingExportUrl = false;
112
134
  return defineFeature("user-data-rights", (r) => {
113
135
  r.describe(
114
136
  'Implements GDPR Art. 15 (access / `my-audit-log` query), Art. 17 (erasure / `request-deletion` + `cancel-deletion`, plus the anonymous email-verified `request-deletion-by-email` + `confirm-deletion-by-token` flow for lockout-safe self-service, + cron cleanup with grace period), Art. 18 (restriction / `restrict-account` + `lift-restriction`), and Art. 20 (portability / async `request-export` \u2192 ZIP via `file-foundation`, Magic-Link download) as first-class HTTP handlers and cron jobs. Each domain feature opts in by calling `r.useExtension(EXT_USER_DATA, "<entity>", { export, delete })` \u2014 the feature then orchestrates the export and forget pipelines across all registered hooks automatically. Requires `user`, `data-retention`, `compliance-profiles`, and `sessions`.',
@@ -166,11 +188,12 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
166
188
 
167
189
  // S2.U5a — Endpoints fuer DSGVO Art. 17 Forget-Pfad mit Grace.
168
190
  r.writeHandler(
169
- createRequestDeletionHandler(
170
- opts.sendDeletionRequestedEmail
171
- ? { sendDeletionRequestedEmail: opts.sendDeletionRequestedEmail }
172
- : {},
173
- ),
191
+ createRequestDeletionHandler({
192
+ ...(opts.sendDeletionRequestedEmail && {
193
+ sendDeletionRequestedEmail: opts.sendDeletionRequestedEmail,
194
+ }),
195
+ ...(opts.mailDefaults && { mailDefaults: opts.mailDefaults }),
196
+ }),
174
197
  );
175
198
  r.writeHandler(cancelDeletionWrite);
176
199
 
@@ -303,6 +326,44 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
303
326
  const exportUserId = ctx._userId ?? SYSTEM_USER_ID;
304
327
  const exportDb = ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator
305
328
  const exportRegistry = ctx.registry;
329
+
330
+ // C6 — ohne eigene send*Email-Opts aber mit gemountetem mail-transport
331
+ // versendet der Cron die Export-Notifications selbst (Default-Templates).
332
+ // Der Resolver baut den per-Tenant-Transport aus configResolver — gleiche
333
+ // Bruecke wie buildStorageProvider.
334
+ const exportMailResolver = isMailTransportAvailable(exportRegistry)
335
+ ? makeTenantMailTransportResolver({
336
+ registry: exportRegistry,
337
+ configResolver: ctx.configResolver,
338
+ secrets: ctx.secrets,
339
+ db: exportDb,
340
+ userId: exportUserId,
341
+ handlerName: "user-data-rights:run-export-jobs",
342
+ })
343
+ : undefined;
344
+ const sendExportReadyEmail =
345
+ opts.sendExportReadyEmail ??
346
+ (exportMailResolver && opts.appExportDownloadUrl !== undefined
347
+ ? makeDefaultExportReadyEmail(exportMailResolver, opts.mailDefaults)
348
+ : undefined);
349
+ const sendExportFailedEmail =
350
+ opts.sendExportFailedEmail ??
351
+ (exportMailResolver
352
+ ? makeDefaultExportFailedEmail(exportMailResolver, opts.mailDefaults)
353
+ : undefined);
354
+ if (
355
+ exportMailResolver &&
356
+ !opts.sendExportReadyEmail &&
357
+ opts.appExportDownloadUrl === undefined &&
358
+ !warnedMissingExportUrl
359
+ ) {
360
+ warnedMissingExportUrl = true;
361
+ // biome-ignore lint/suspicious/noConsole: one-shot operator visibility for misconfig
362
+ console.warn(
363
+ "[user-data-rights:run-export-jobs] mail transport mounted but appExportDownloadUrl unset — default export-ready emails disabled (export-failed + deletion mails still send)",
364
+ );
365
+ }
366
+
306
367
  await runExportJobs({
307
368
  db: exportDb,
308
369
  registry: exportRegistry,
@@ -319,15 +380,11 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
319
380
  handlerName: "user-data-rights:run-export-jobs",
320
381
  }),
321
382
  now: T.Now.instant(),
322
- // Atom 5 — App-Author-Callbacks fuer Email-Notification.
323
- // Optional: wenn nicht gesetzt, kein Email; User pollt
324
- // export-status.query + UI-Klick.
325
- ...(opts.sendExportReadyEmail && {
326
- sendExportReadyEmail: opts.sendExportReadyEmail,
327
- }),
328
- ...(opts.sendExportFailedEmail && {
329
- sendExportFailedEmail: opts.sendExportFailedEmail,
330
- }),
383
+ // App-Author-Callbacks haben Vorrang; sonst greift die mail-foundation-
384
+ // Default oben. Beide optional: ohne mail-transport + ohne Callback
385
+ // bleibt es bei Polling via export-status.query.
386
+ ...(sendExportReadyEmail && { sendExportReadyEmail }),
387
+ ...(sendExportFailedEmail && { sendExportFailedEmail }),
331
388
  ...(opts.appExportDownloadUrl !== undefined && {
332
389
  appExportDownloadUrl: opts.appExportDownloadUrl,
333
390
  }),
@@ -352,30 +409,48 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
352
409
  const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
353
410
  const forgetUserId = ctx._userId ?? SYSTEM_USER_ID;
354
411
  const forgetDb = ctx.db as import("@cosmicdrift/kumiko-framework/db").DbConnection; // @cast-boundary db-operator
412
+ const forgetRegistry = ctx.registry;
355
413
  const tenantModel = await resolveAppTenantModel({
356
- registry: ctx.registry,
414
+ registry: forgetRegistry,
357
415
  configResolver: ctx.configResolver,
358
416
  db: forgetDb,
359
417
  userId: forgetUserId,
360
418
  });
419
+
420
+ // C6 — Default-Mail beim delete-flip wenn kein Callback gesetzt + ein
421
+ // mail-transport gemountet ist (gleiche per-Tenant-Bruecke wie Export).
422
+ const forgetMailResolver = isMailTransportAvailable(forgetRegistry)
423
+ ? makeTenantMailTransportResolver({
424
+ registry: forgetRegistry,
425
+ configResolver: ctx.configResolver,
426
+ secrets: ctx.secrets,
427
+ db: forgetDb,
428
+ userId: forgetUserId,
429
+ handlerName: "user-data-rights:run-forget-cleanup",
430
+ })
431
+ : undefined;
432
+ const sendDeletionExecutedEmail =
433
+ opts.sendDeletionExecutedEmail ??
434
+ (forgetMailResolver
435
+ ? makeDefaultDeletionExecutedEmail(forgetMailResolver, opts.mailDefaults)
436
+ : undefined);
437
+
361
438
  await runForgetCleanup({
362
439
  db: forgetDb,
363
- registry: ctx.registry,
440
+ registry: forgetRegistry,
364
441
  now: T.Now.instant(),
365
442
  tenantModel,
366
443
  // Same per-tenant provider resolution as the export cron — forget
367
444
  // deletes binaries from the store upload + export use.
368
445
  buildStorageProvider: makeTenantStorageProviderResolver({
369
- registry: ctx.registry,
446
+ registry: forgetRegistry,
370
447
  configResolver: ctx.configResolver,
371
448
  secrets: ctx.secrets,
372
449
  db: forgetDb,
373
450
  userId: forgetUserId,
374
451
  handlerName: "user-data-rights:run-forget-cleanup",
375
452
  }),
376
- ...(opts.sendDeletionExecutedEmail && {
377
- sendDeletionExecutedEmail: opts.sendDeletionExecutedEmail,
378
- }),
453
+ ...(sendDeletionExecutedEmail && { sendDeletionExecutedEmail }),
379
454
  });
380
455
  },
381
456
  );
@@ -9,7 +9,12 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
9
9
  type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
10
10
 
11
11
  export type StartGracePeriodResult =
12
- | { readonly ok: true; readonly gracePeriodEnd: Instant; readonly userEmail: string }
12
+ | {
13
+ readonly ok: true;
14
+ readonly gracePeriodEnd: Instant;
15
+ readonly userEmail: string;
16
+ readonly userLocale: string | null;
17
+ }
13
18
  | { readonly ok: false; readonly error: UnprocessableError };
14
19
 
15
20
  // Flippt einen aktiven User auf DeletionRequested + setzt gracePeriodEnd aus
@@ -26,9 +31,11 @@ export async function startDeletionGracePeriod(
26
31
  userId: string,
27
32
  complianceTenantId: string,
28
33
  ): Promise<StartGracePeriodResult> {
29
- const userRow = await fetchOne<{ status: string; email: string }>(ctx.db.raw, userTable, {
30
- id: userId,
31
- });
34
+ const userRow = await fetchOne<{ status: string; email: string; locale: string | null }>(
35
+ ctx.db.raw,
36
+ userTable,
37
+ { id: userId },
38
+ );
32
39
  if (!userRow) {
33
40
  return {
34
41
  ok: false,
@@ -63,5 +70,10 @@ export async function startDeletionGracePeriod(
63
70
  gracePeriodEnd,
64
71
  });
65
72
 
66
- return { ok: true, gracePeriodEnd, userEmail: userRow["email"] ?? "" };
73
+ return {
74
+ ok: true,
75
+ gracePeriodEnd,
76
+ userEmail: userRow["email"] ?? "",
77
+ userLocale: userRow["locale"] ?? null,
78
+ };
67
79
  }