@cosmicdrift/kumiko-samples 0.368.0 → 0.369.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 (28) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/channel-email/changes.json +7 -0
  4. package/packages/bundled-features/src/channel-email/email-channel.ts +39 -8
  5. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  6. package/packages/bundled-features/src/channel-email/index.ts +5 -1
  7. package/packages/bundled-features/src/config/web/i18n.ts +1 -2
  8. package/packages/bundled-features/src/delivery/changes.json +6 -0
  9. package/packages/bundled-features/src/delivery/feature.ts +1 -1
  10. package/packages/bundled-features/src/delivery/index.ts +8 -1
  11. package/packages/bundled-features/src/delivery/unsubscribe.ts +66 -37
  12. package/packages/bundled-features/src/mail-foundation/feature.ts +21 -12
  13. package/packages/bundled-features/src/mail-foundation/i18n.ts +8 -0
  14. package/packages/bundled-features/src/mail-transport-inmemory/feature.ts +6 -0
  15. package/packages/bundled-features/src/mail-transport-smtp/changes.json +7 -0
  16. package/packages/bundled-features/src/mail-transport-smtp/config-keys.ts +67 -0
  17. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +6 -33
  18. package/packages/bundled-features/src/mail-transport-smtp/i18n.ts +28 -0
  19. package/packages/bundled-features/src/secrets/changes.json +6 -0
  20. package/packages/bundled-features/src/secrets/feature.ts +18 -3
  21. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +5 -2
  22. package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -2
  23. package/packages/bundled-features/src/template-resolver/constants.ts +0 -1
  24. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +4 -2
  25. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  26. package/packages/bundled-features/src/user-data-rights/feature.ts +7 -4
  27. package/packages/bundled-features/src/user-data-rights/lib/default-mailers.ts +40 -3
  28. package/samples/apps/use-all-bundled/feature-manifest.json +16 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.368.0",
3
+ "version": "0.369.0",
4
4
  "description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.368.0",
3
+ "version": "0.369.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.369.0",
4
+ "type": "breaking",
5
+ "title": "The tenant mail transport applies per notification",
6
+ "detail": "r.notification(name, { tenantTransport: true }) marks a notification whose mails go through the tenant's own mail transport. channel-email consults its tenantTransport resolver only for these (also under a renamedFrom name); every other notification, such as password reset or sign-in mails, always takes the app transport, even when the tenant has a provider. With tenantTransport set, transport may be omitted: a mail that then has no transport fails with missing_credentials instead of throwing. The template test mail (template-resolver send-test) now gets the tenant config in its channel context and uses the template's notification, so it leaves through the same transport as the real mail.",
7
+ "migration": "Apps that pass tenantTransport to createChannelEmailFeature add `tenantTransport: true` to every r.notification whose mails should go through the tenant's provider; without the flag those mails now go through the app transport. Apps without an app SMTP server can drop transport and keep tenantTransport."
8
+ },
2
9
  {
3
10
  "version": "0.367.0",
4
11
  "type": "improvement",
@@ -6,6 +6,7 @@ import {
6
6
  type AttachmentOrigin,
7
7
  type ChannelContext,
8
8
  type ChannelMessage,
9
+ currentNotificationType,
9
10
  DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE,
10
11
  DELIVERY_UNSUBSCRIBE_PATH,
11
12
  type DeliveryChannel,
@@ -163,18 +164,36 @@ async function resolveAttachments(
163
164
  return { kind: "resolved", attachments: resolved };
164
165
  }
165
166
 
166
- export type EmailChannelOptions = {
167
- /** The app-wide transport, used for every tenant `tenantTransport` returns null for. */
168
- readonly transport: EmailTransport;
169
- /** Resolves the sending tenant's own transport, null when it has none. Built per send and
170
- * never cached, so a provider change applies to the next mail; a throw fails the send. */
171
- readonly tenantTransport?: (ctx: ChannelContext) => Promise<EmailTransport | null>;
167
+ /** Resolves the sending tenant's own transport, null when it has none. Built per send and
168
+ * never cached, so a provider change applies to the next mail; a throw fails the send. */
169
+ export type TenantTransportResolver = (ctx: ChannelContext) => Promise<EmailTransport | null>;
170
+
171
+ // The app transport may only be left out when tenant transports exist: a channel with
172
+ // neither could never send.
173
+ type EmailChannelTransports =
174
+ | {
175
+ /** The app-wide transport for every mail that does not go through a tenant transport. */
176
+ readonly transport: EmailTransport;
177
+ /** Used only for notifications declared with `tenantTransport: true`. */
178
+ readonly tenantTransport?: TenantTransportResolver;
179
+ }
180
+ | {
181
+ readonly transport?: undefined;
182
+ readonly tenantTransport: TenantTransportResolver;
183
+ };
184
+
185
+ export type EmailChannelOptions = EmailChannelTransports & {
172
186
  readonly resolveEmail: (
173
187
  userId: string,
174
188
  ctx: { db: unknown; tenantId: TenantId },
175
189
  ) => Promise<string | null>;
176
190
  };
177
191
 
192
+ function isTenantTransportNotification(ctx: ChannelContext, notificationType: string): boolean {
193
+ const current = currentNotificationType(ctx.registry, notificationType);
194
+ return ctx.registry.getAllNotifications().get(current)?.tenantTransport === true;
195
+ }
196
+
178
197
  export function createEmailChannel(options: EmailChannelOptions): DeliveryChannel {
179
198
  const { transport, resolveEmail, tenantTransport } = options;
180
199
 
@@ -213,8 +232,12 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
213
232
  };
214
233
  }
215
234
 
216
- async function transportFor(ctx: ChannelContext): Promise<EmailTransport> {
235
+ async function transportFor(
236
+ message: ChannelMessage,
237
+ ctx: ChannelContext,
238
+ ): Promise<EmailTransport | undefined> {
217
239
  if (tenantTransport === undefined) return transport;
240
+ if (!isTenantTransportNotification(ctx, message.notificationType)) return transport;
218
241
  return (await tenantTransport(ctx)) ?? transport;
219
242
  }
220
243
 
@@ -260,7 +283,15 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
260
283
  return { status: "failed", error: "send_failed", address };
261
284
  }
262
285
  const resolvedAttachments = resolution.attachments;
263
- const sendTransport = await transportFor(ctx);
286
+ const sendTransport = await transportFor(message, ctx);
287
+ if (sendTransport === undefined) {
288
+ // Configuration, not a transient error: a retry cannot conjure a transport.
289
+ createFallbackLogger("delivery").warn("no mail transport for this mail, send failed", {
290
+ tenantId: ctx.tenantId,
291
+ notificationType: message.notificationType,
292
+ });
293
+ return { status: "failed", error: "missing_credentials", address };
294
+ }
264
295
  await sendTransport.send(
265
296
  guardEmailMessage({
266
297
  to: address,
@@ -10,7 +10,7 @@ export function createChannelEmailFeature(options: EmailChannelOptions): Feature
10
10
 
11
11
  return defineFeature("channel-email", (r) => {
12
12
  r.describe(
13
- "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send each tenant's mail through its own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.",
13
+ "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set; a mail that then has no transport fails with `missing_credentials`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.",
14
14
  );
15
15
  r.uiHints({
16
16
  displayLabel: "Email Channel",
@@ -1,4 +1,8 @@
1
- export { createEmailChannel, type EmailChannelOptions } from "./email-channel.js";
1
+ export {
2
+ createEmailChannel,
3
+ type EmailChannelOptions,
4
+ type TenantTransportResolver,
5
+ } from "./email-channel.js";
2
6
  export { createChannelEmailFeature } from "./feature.js";
3
7
  export { guardEmailMessage, withPiiCiphertextGuard } from "./pii-guard.js";
4
8
  export {
@@ -32,9 +32,8 @@ export const defaultTranslations: TranslationsByLocale = {
32
32
  "config.errors.unknownKey": "Unknown configuration key.",
33
33
  "config.errors.unknownExtensionPlugin": "This provider is not available.",
34
34
  "config.settings.extensionSelectorHint":
35
- "Choose a provider and save. Its settings appear below.",
35
+ "Choose a provider. Its settings appear below and are saved together with it.",
36
36
  "config.settings.provider": "Provider",
37
- "config.settings.saveProvider": "Save provider",
38
37
  "config.settings.audience.system":
39
38
  "Applies to every tenant unless a tenant sets its own value.",
40
39
  "config.settings.audience.tenant":
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.369.0",
4
+ "type": "improvement",
5
+ "title": "Unsubscribe links in dev and a reusable result page",
6
+ "detail": "runDevApp accepts unsubscribe: { secret, tenantOrigin? } like runProdApp, mounts the unsubscribe routes and signs the links (base URL from auth.mail.baseUrl or APP_BASE_URL). Both share resolveUnsubscribeWiring from server-runtime. delivery exports renderUnsubscribeResultPage({ kind: \"unsubscribed\" | \"resubscribed\" | \"failed\", locale, resubscribeToken? }), the page the unsubscribe routes answer with, for apps that serve their own (e.g. legacy) unsubscribe links; without a resubscribeToken it offers no undo button."
7
+ },
2
8
  {
3
9
  "version": "0.367.0",
4
10
  "type": "improvement",
@@ -61,7 +61,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
61
61
  const attemptLogRetentionDays = resolveAttemptLogRetentionDays(options?.attemptLogRetentionDays);
62
62
  return defineFeature("delivery", (r) => {
63
63
  r.describe(
64
- "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (`runProdApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.",
64
+ "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (the `runProdApp` and `runDevApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. An app serving its own (e.g. legacy) unsubscribe links renders the same result page with `renderUnsubscribeResultPage({ kind, locale })`. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.",
65
65
  );
66
66
  r.uiHints({
67
67
  displayLabel: "Notifications \u00b7 Dispatch Core",
@@ -20,7 +20,11 @@ export {
20
20
  DEFAULT_ATTEMPT_LOG_RETENTION_DAYS,
21
21
  runAttemptLogRetention,
22
22
  } from "./attempt-log-retention.js";
23
- export { createEmailAttachmentReader, createTemplateMailRenderer } from "./channel-context.js";
23
+ export {
24
+ buildChannelContext,
25
+ createEmailAttachmentReader,
26
+ createTemplateMailRenderer,
27
+ } from "./channel-context.js";
24
28
  export {
25
29
  type ChatWebhookChannelOptions,
26
30
  chatMessageText,
@@ -67,6 +71,7 @@ export {
67
71
  type UnsubscribeAddressPayload,
68
72
  unsubscribeAddressPayloadSchema,
69
73
  } from "./handlers/unsubscribe-address.write.js";
74
+ export { currentNotificationType } from "./notification-type-aliases.js";
70
75
  export { createDeliveryNotifyFactory } from "./notify-factory.js";
71
76
  export {
72
77
  deliveryAttemptsTable,
@@ -93,8 +98,10 @@ export {
93
98
  type AddressUnsubscribeTokenPayload,
94
99
  createUnsubscribeRoutes,
95
100
  DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE,
101
+ renderUnsubscribeResultPage,
96
102
  signAddressUnsubscribeToken,
97
103
  signUnsubscribeToken,
104
+ type UnsubscribeResultPage,
98
105
  type UnsubscribeRouteOptions,
99
106
  type UnsubscribeTokenPayload,
100
107
  } from "./unsubscribe.js";
@@ -415,33 +415,48 @@ function confirmationPage(token: string, locale: string | undefined): string {
415
415
  );
416
416
  }
417
417
 
418
- function unsubscribedPage(resubscribeToken: string, locale: string | undefined): string {
419
- const lang = pageLocale(locale);
420
- return htmlPage(
421
- lang,
422
- mailT(lang, "mail.unsubscribe.doneTitle"),
423
- `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.doneMessage"))}</p>
418
+ export type UnsubscribeResultPage =
419
+ // Without a resubscribeToken the page offers no undo button.
420
+ | { readonly kind: "unsubscribed"; readonly resubscribeToken?: string }
421
+ | { readonly kind: "resubscribed" }
422
+ | { readonly kind: "failed" };
423
+
424
+ /** The plain result page the unsubscribe routes answer with, for apps serving their own
425
+ * (e.g. legacy) unsubscribe links. Texts come from the mail translations of `locale`. */
426
+ export function renderUnsubscribeResultPage(
427
+ page: UnsubscribeResultPage & { readonly locale?: string | undefined },
428
+ ): string {
429
+ const lang = pageLocale(page.locale);
430
+ switch (page.kind) {
431
+ case "unsubscribed": {
432
+ const undoForm =
433
+ page.resubscribeToken === undefined
434
+ ? ""
435
+ : `
424
436
  <form method="post" action="${DELIVERY_RESUBSCRIBE_PATH}">
425
- <input type="hidden" name="token" value="${escapeHtmlAttr(resubscribeToken)}">
437
+ <input type="hidden" name="token" value="${escapeHtmlAttr(page.resubscribeToken)}">
426
438
  <button type="submit">${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.undoButton"))}</button>
427
- </form>`,
428
- );
429
- }
430
-
431
- function resubscribedPage(locale: string | undefined): string {
432
- const lang = pageLocale(locale);
433
- return htmlPage(
434
- lang,
435
- mailT(lang, "mail.unsubscribe.resubscribedTitle"),
436
- `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.resubscribedMessage"))}</p>`,
437
- );
439
+ </form>`;
440
+ return htmlPage(
441
+ lang,
442
+ mailT(lang, "mail.unsubscribe.doneTitle"),
443
+ `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.doneMessage"))}</p>${undoForm}`,
444
+ );
445
+ }
446
+ case "resubscribed":
447
+ return htmlPage(
448
+ lang,
449
+ mailT(lang, "mail.unsubscribe.resubscribedTitle"),
450
+ `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.resubscribedMessage"))}</p>`,
451
+ );
452
+ case "failed": {
453
+ const message = mailT(lang, "mail.unsubscribe.failed");
454
+ return htmlPage(lang, message, `<p>${escapeHtmlAttr(message)}</p>`);
455
+ }
456
+ }
438
457
  }
439
458
 
440
- function failedPage(locale: string | undefined): string {
441
- const lang = pageLocale(locale);
442
- const message = mailT(lang, "mail.unsubscribe.failed");
443
- return htmlPage(lang, message, `<p>${escapeHtmlAttr(message)}</p>`);
444
- }
459
+ const NO_STORE_HEADERS = { "Cache-Control": "no-store" } as const;
445
460
 
446
461
  function isFormRequest(contentType: string | undefined): boolean {
447
462
  return contentType?.includes("application/x-www-form-urlencoded") === true;
@@ -516,12 +531,20 @@ export function createUnsubscribeRoutes(
516
531
  // (e.g. events-table missing, DB down) behind a misleading 400.
517
532
  const succeeded = await dispatchUnsubscribeWrite(verified, deps);
518
533
  if (!succeeded) {
519
- return c.html(failedPage(verified.locale), 500, { "Cache-Control": "no-store" });
534
+ return c.html(
535
+ renderUnsubscribeResultPage({ kind: "failed", locale: verified.locale }),
536
+ 500,
537
+ NO_STORE_HEADERS,
538
+ );
520
539
  }
521
540
  return c.html(
522
- unsubscribedPage(await signResubscribeToken(verified, encodedSecret), verified.locale),
541
+ renderUnsubscribeResultPage({
542
+ kind: "unsubscribed",
543
+ resubscribeToken: await signResubscribeToken(verified, encodedSecret),
544
+ locale: verified.locale,
545
+ }),
523
546
  200,
524
- { "Cache-Control": "no-store" },
547
+ NO_STORE_HEADERS,
525
548
  );
526
549
  },
527
550
  });
@@ -540,22 +563,28 @@ export function createUnsubscribeRoutes(
540
563
  // The undo button on the unsubscribed page posts a form; a browser should land on a page.
541
564
  if (isFormRequest(c.req.header("content-type"))) {
542
565
  return outcome === "success"
543
- ? c.html(resubscribedPage(verified.locale), 200, { "Cache-Control": "no-store" })
544
- : c.html(failedPage(verified.locale), outcome === "limit_reached" ? 409 : 500, {
545
- "Cache-Control": "no-store",
546
- });
566
+ ? c.html(
567
+ renderUnsubscribeResultPage({ kind: "resubscribed", locale: verified.locale }),
568
+ 200,
569
+ NO_STORE_HEADERS,
570
+ )
571
+ : c.html(
572
+ renderUnsubscribeResultPage({ kind: "failed", locale: verified.locale }),
573
+ outcome === "limit_reached" ? 409 : 500,
574
+ NO_STORE_HEADERS,
575
+ );
547
576
  }
548
577
  if (outcome === "limit_reached") {
549
- return c.json({ error: { code: DeliveryErrors.resubscribeLimitReached } }, 409, {
550
- "Cache-Control": "no-store",
551
- });
578
+ return c.json(
579
+ { error: { code: DeliveryErrors.resubscribeLimitReached } },
580
+ 409,
581
+ NO_STORE_HEADERS,
582
+ );
552
583
  }
553
584
  if (outcome === "failed") {
554
- return c.json({ error: { code: "resubscribe_failed" } }, 500, {
555
- "Cache-Control": "no-store",
556
- });
585
+ return c.json({ error: { code: "resubscribe_failed" } }, 500, NO_STORE_HEADERS);
557
586
  }
558
- return c.json({ isSuccess: true }, 200, { "Cache-Control": "no-store" });
587
+ return c.json({ isSuccess: true }, 200, NO_STORE_HEADERS);
559
588
  },
560
589
  });
561
590
 
@@ -45,6 +45,7 @@ import {
45
45
  type Registry,
46
46
  type TenantId,
47
47
  } from "@cosmicdrift/kumiko-framework/engine";
48
+ import { MAIL_FOUNDATION_I18N } from "./i18n.js";
48
49
 
49
50
  const FEATURE_NAME = "mail-foundation";
50
51
 
@@ -102,6 +103,10 @@ export type MailTransportContext = {
102
103
  */
103
104
  export type MailTransportPlugin = {
104
105
  readonly build: (ctx: MailTransportContext, tenantId: string) => Promise<EmailTransport>;
106
+ /** `false` keeps the provider out of the generated settings: it can still be set by a
107
+ * seed or config write, but a tenant admin is never offered it (e.g. a test inbox
108
+ * that swallows every mail). */
109
+ readonly selectableInSettings?: false;
105
110
  };
106
111
 
107
112
  // extension-usage `options` is engine-payload (unknown) — structurally validate
@@ -125,7 +130,7 @@ export function createMailFoundationFeature() {
125
130
  FEATURE_NAME,
126
131
  (r) => {
127
132
  r.describe(
128
- "Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending \u2014 use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature` so a tenant with a provider configured sends delivery mails through its own transport and other tenants through the app transport.",
133
+ "Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending \u2014 use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature`: notifications declared with `tenantTransport: true` then go through the tenant's own transport when it has a provider and through the app transport otherwise. Tenant admins pick the provider and fill in its settings on the generated Email sending settings screen; an empty provider is a valid choice and means the platform sends.",
129
134
  );
130
135
  r.uiHints({
131
136
  displayLabel: "Mail Transport Foundation",
@@ -145,28 +150,32 @@ export function createMailFoundationFeature() {
145
150
  },
146
151
  });
147
152
 
148
- // Provider-selector. Default empty so the boot-validator throws if a
149
- // tenant tries to send mail without first picking + setting up a
150
- // provider — better than a silent fallback. The actual list of valid
151
- // values lives in the registered plugins, not here — Designer-UI can
152
- // render `getExtensionUsages("mailTransport").map(u => u.entityName)`
153
- // as the option-list.
153
+ // Empty = this tenant has no own provider: tenantMailTransport yields null and
154
+ // channel-email uses the app transport, so empty is a valid choice a tenant can
155
+ // return to (reset in the settings mask) and readiness must not flag it.
156
+ // createTransportForTenant still throws on empty.
154
157
  const providerConfigKey = r.config(
155
158
  "provider",
156
159
  createTenantConfig("text", {
157
160
  default: "",
158
- // required: without a chosen provider createTransportForTenant throws —
159
- // readiness used to report ready:true and the first mail send
160
- // surfaced the UnconfiguredError (280/1).
161
- required: true,
162
- write: access.roles("TenantAdmin", "SystemAdmin"),
161
+ // No SystemAdmin: a platform-level provider would be inherited by every tenant,
162
+ // whose own credentials (tenant secrets) are missing, and break all their mail.
163
+ write: access.withSystem(access.roles("TenantAdmin")),
163
164
  read: access.roles("TenantAdmin", "SystemAdmin", "User"),
165
+ mask: {
166
+ title: "mail-foundation.provider",
167
+ description: "mail-foundation.provider.description",
168
+ icon: "mail",
169
+ order: 1,
170
+ },
164
171
  }),
165
172
  );
166
173
  // Readiness gating: transport-plugins' required keys/secrets count only
167
174
  // while their plugin is the one this key selects.
168
175
  r.extensionSelector(MAIL_TRANSPORT_EXTENSION, providerConfigKey);
169
176
 
177
+ r.translations({ keys: MAIL_FOUNDATION_I18N });
178
+
170
179
  return {
171
180
  /** Config-key-handle for the provider-selector. */
172
181
  providerConfigKey,
@@ -0,0 +1,8 @@
1
+ export const MAIL_FOUNDATION_I18N = {
2
+ "screen:mail-foundation-tenant.title": { en: "Email sending" },
3
+ "mail-foundation.settings": { en: "Email sending" },
4
+ "mail-foundation.provider": { en: "Own mail server" },
5
+ "mail-foundation.provider.description": {
6
+ en: "Without one, the platform sends the mails. Reset the choice to go back to that.",
7
+ },
8
+ } as const;
@@ -89,6 +89,9 @@ export function createMailTransportInMemoryFeature() {
89
89
  r.requires({ features: ["mail-foundation"] });
90
90
 
91
91
  const plugin: MailTransportPlugin = {
92
+ // Solon and studio mount it next to smtp in production; offering it there would
93
+ // let a tenant admin route every mail into process memory.
94
+ selectableInSettings: false,
92
95
  build: async (_ctx: MailTransportContext, tenantId: string): Promise<EmailTransport> => {
93
96
  // Returns the per-tenant buffer. Identity-stable across calls so the
94
97
  // demo inbox keeps accumulating.
@@ -96,6 +99,9 @@ export function createMailTransportInMemoryFeature() {
96
99
  }, // @wrapper-known semantic-alias
97
100
  };
98
101
  r.useExtension(MAIL_TRANSPORT_EXTENSION, "inmemory", plugin);
102
+ r.translations({
103
+ keys: { "mail-transport-inmemory.settings": { en: "Test inbox (in memory)" } },
104
+ });
99
105
  },
100
106
  { dedupeOptions: {} },
101
107
  );
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.369.0",
4
+ "type": "breaking",
5
+ "title": "Tenant admins edit their own SMTP server on the Email sending screen",
6
+ "detail": "mail-foundation's provider key and the SMTP keys (host, port, SSL/TLS, user name, sender address) carry settings masks, so the generated Settings-Hub shows tenant admins one Email sending form: the provider choice, the chosen provider's connection fields and its password, each field with a help text and every label in English, German and Spanish, saved together by one Save button in one /api/batch. Switching the provider swaps the fields at once; fields of a provider that is not selected are not saved. A selector owner's selection screen (`<owner>-tenant-selection`) now carries the plugins' config keys (field id `<owner>-<key>`) and secrets (field id `<plugin>-<key>`, listed in the new ConfigEditScreenDefinition.secretKeys) behind a visible condition on the provider field; the separate `<plugin>-tenant` and `<plugin>-tenant-secrets` screens and their dashboard panels are gone, and so is the `config.settings.saveProvider` label. A secret with its own writeRoles stays on the global secrets screen. Boot validation checks every secretKeys field (tenant scope, writeOnly text, declared secret). The configEdit renderer loads secretKeys fields from secrets:query:list (set or unset, no preview), writes them with secrets:write:set or secrets:write:delete in the same batch, and leaves hidden fields out. The provider key is no longer required: empty means the tenant has no own server and the platform sends, so readiness no longer reports it as missing (createTransportForTenant still throws on an empty provider). The provider and SMTP keys are written by TenantAdmin and the system identity, no longer by SystemAdmin, because a platform-level provider would reach every tenant without that tenant's password. The secrets set and delete handlers also admit the system identity, so server code stores a tenant secret via dispatchSystemWrite through the same key and value checks as an admin; the settings screens keep showing only the human roles. The provider choice shows each plugin's `<feature>.settings` name, also for a plugin without settings of its own, and keeps the English name when a locale feature mounted earlier declares the same key in its own language only. A MailTransportPlugin with `selectableInSettings: false` stays allowed on the server but is not offered in the form; the in-memory transport sets it, because apps mount it next to smtp in production and a tenant admin picking it would drop every mail. A plugin's secrets section takes an optional `<feature>.settings.secrets` title (SMTP password under \"SMTP sign-in\").",
7
+ "migration": "E2E specs that used the testIds `dashboard-panel-<plugin>-tenant`, `dashboard-panel-<plugin>-tenant-secrets` or the separate secret input of these screens address the fields of `<owner>-tenant-selection` instead (one Save). Code that wrote these keys as a SystemAdmin user writes them as the tenant's TenantAdmin or via dispatchSystemWrite. Apps that relied on readiness flagging an empty provider check it themselves."
8
+ },
2
9
  {
3
10
  "version": "0.326.1",
4
11
  "type": "fix",
@@ -0,0 +1,67 @@
1
+ import { access, createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ // No SystemAdmin write: the password is a tenant secret, so platform-level SMTP keys
4
+ // could never form a working transport (see mail-foundation's provider key).
5
+ const tenantAdminWrite = access.withSystem(access.roles("TenantAdmin"));
6
+ const tenantAdminRead = access.roles("TenantAdmin", "SystemAdmin");
7
+
8
+ // required: true ↔ the requireNonEmpty calls in buildSmtpTransport — keep in sync.
9
+ export const SMTP_CONFIG_KEYS = {
10
+ host: createTenantConfig("text", {
11
+ required: true,
12
+ default: "",
13
+ write: tenantAdminWrite,
14
+ read: tenantAdminRead,
15
+ mask: {
16
+ title: "mail-transport-smtp.host",
17
+ description: "mail-transport-smtp.host.description",
18
+ icon: "server",
19
+ order: 1,
20
+ },
21
+ }),
22
+ port: createTenantConfig("number", {
23
+ default: 587,
24
+ bounds: { min: 1, max: 65535 },
25
+ write: tenantAdminWrite,
26
+ mask: {
27
+ title: "mail-transport-smtp.port",
28
+ description: "mail-transport-smtp.port.description",
29
+ icon: "hash",
30
+ order: 2,
31
+ },
32
+ }),
33
+ secure: createTenantConfig("boolean", {
34
+ default: false,
35
+ write: tenantAdminWrite,
36
+ mask: {
37
+ title: "mail-transport-smtp.secure",
38
+ description: "mail-transport-smtp.secure.description",
39
+ icon: "lock",
40
+ order: 3,
41
+ },
42
+ }),
43
+ authUser: createTenantConfig("text", {
44
+ required: true,
45
+ default: "",
46
+ write: tenantAdminWrite,
47
+ read: tenantAdminRead,
48
+ mask: {
49
+ title: "mail-transport-smtp.authUser",
50
+ description: "mail-transport-smtp.authUser.description",
51
+ icon: "user",
52
+ order: 4,
53
+ },
54
+ }),
55
+ from: createTenantConfig("text", {
56
+ required: true,
57
+ default: "",
58
+ write: tenantAdminWrite,
59
+ read: tenantAdminRead,
60
+ mask: {
61
+ title: "mail-transport-smtp.from",
62
+ description: "mail-transport-smtp.from.description",
63
+ icon: "mail",
64
+ order: 5,
65
+ },
66
+ }),
67
+ };
@@ -47,9 +47,11 @@ import {
47
47
  type MailTransportPlugin,
48
48
  } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
49
49
  import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
50
- import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
50
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
51
51
  import { UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
52
52
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
53
+ import { SMTP_CONFIG_KEYS } from "./config-keys.js";
54
+ import { MAIL_TRANSPORT_SMTP_I18N } from "./i18n.js";
53
55
 
54
56
  const FEATURE_NAME = "mail-transport-smtp";
55
57
 
@@ -107,38 +109,9 @@ export function createMailTransportSmtpFeature() {
107
109
  required: true,
108
110
  });
109
111
 
110
- // required: true ↔ the requireNonEmpty calls in buildSmtpTransport — keep in sync.
111
- const configKeys = r.config({
112
- keys: {
113
- host: createTenantConfig("text", {
114
- required: true,
115
- default: "",
116
- write: access.roles("TenantAdmin", "SystemAdmin"),
117
- read: access.roles("TenantAdmin", "SystemAdmin"),
118
- }),
119
- port: createTenantConfig("number", {
120
- default: 587,
121
- bounds: { min: 1, max: 65535 },
122
- write: access.roles("TenantAdmin", "SystemAdmin"),
123
- }),
124
- secure: createTenantConfig("boolean", {
125
- default: false,
126
- write: access.roles("TenantAdmin", "SystemAdmin"),
127
- }),
128
- from: createTenantConfig("text", {
129
- required: true,
130
- default: "",
131
- write: access.roles("TenantAdmin", "SystemAdmin"),
132
- read: access.roles("TenantAdmin", "SystemAdmin"),
133
- }),
134
- authUser: createTenantConfig("text", {
135
- required: true,
136
- default: "",
137
- write: access.roles("TenantAdmin", "SystemAdmin"),
138
- read: access.roles("TenantAdmin", "SystemAdmin"),
139
- }),
140
- },
141
- });
112
+ const configKeys = r.config({ keys: SMTP_CONFIG_KEYS });
113
+
114
+ r.translations({ keys: MAIL_TRANSPORT_SMTP_I18N });
142
115
 
143
116
  // Plugin: register against mail-foundation's "mailTransport" extension.
144
117
  // `entityName` "smtp" is what tenants set in mail-foundation's
@@ -0,0 +1,28 @@
1
+ export const MAIL_TRANSPORT_SMTP_I18N = {
2
+ // Also the option label of mail-foundation's provider select.
3
+ "mail-transport-smtp.settings": { en: "SMTP server" },
4
+ "mail-transport-smtp.settings.secrets": { en: "SMTP sign-in" },
5
+ "mail-transport-smtp.settings.description": {
6
+ en: "The connection details come from your mail provider, for example Brevo, Postmark, Amazon SES or your own server.",
7
+ },
8
+ "mail-transport-smtp.host": { en: "Server address" },
9
+ "mail-transport-smtp.host.description": {
10
+ en: "Host name of the SMTP server, for example smtp.example.com.",
11
+ },
12
+ "mail-transport-smtp.port": { en: "Port number" },
13
+ "mail-transport-smtp.port.description": {
14
+ en: "Usually 587. Use 465 when your provider asks for SSL/TLS.",
15
+ },
16
+ "mail-transport-smtp.secure": { en: "SSL/TLS from the start" },
17
+ "mail-transport-smtp.secure.description": {
18
+ en: "Turn on for port 465. Leave off for 587, which switches to encryption by itself.",
19
+ },
20
+ "mail-transport-smtp.authUser": { en: "User name" },
21
+ "mail-transport-smtp.authUser.description": {
22
+ en: "Login name at the SMTP server, often an email address.",
23
+ },
24
+ "mail-transport-smtp.from": { en: "Sender address" },
25
+ "mail-transport-smtp.from.description": {
26
+ en: "The address mails are sent from, for example status@example.com. Your provider must allow it.",
27
+ },
28
+ } as const;
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.369.0",
4
+ "type": "fix",
5
+ "title": "secrets:write:set and secrets:write:delete join the request transaction",
6
+ "detail": "Both handlers ran on the boot database connection, so in an /api/batch a later failing command rolled back the config writes but kept the stored secret. They now run on the request transaction (escape hatch unsafeRaw, reason documented on the handler), so a batch of config and secret writes is stored as a whole or not at all."
7
+ },
2
8
  {
3
9
  "version": "0.365.0",
4
10
  "type": "fix",
@@ -3,6 +3,7 @@ import {
3
3
  type AccessRule,
4
4
  defineFeature,
5
5
  type FeatureDefinition,
6
+ SYSTEM_ROLE,
6
7
  } from "@cosmicdrift/kumiko-framework/engine";
7
8
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
8
9
  import { SETTINGS_HUB_I18N } from "@cosmicdrift/kumiko-framework/i18n";
@@ -63,6 +64,11 @@ export {
63
64
  } from "./secrets-context.js";
64
65
  export { type StoredEnvelope, type StoredMetadata, tenantSecretsTable } from "./table.js";
65
66
 
67
+ // The settings form saves config values and secrets in one /api/batch; the secret
68
+ // write has to join that transaction so a rejected command rolls it back too.
69
+ export const JOIN_REQUEST_TRANSACTION_REASON =
70
+ "the secret write runs on the request transaction so a failing batch rolls it back together with the config writes";
71
+
66
72
  // AppContext carries ctx.secrets via extraContext. requireSecretsContext
67
73
  // wraps that raw context so every `.get(...)` call auto-includes the
68
74
  // current user + handler as audit metadata — feature code can't forget
@@ -133,11 +139,20 @@ function resolveSecretsAccess(opts: SecretsFeatureOptions): AccessRule {
133
139
  return DEFAULT_SECRETS_ACCESS;
134
140
  }
135
141
 
142
+ // Server-side system writes (dispatchSystemWrite, e.g. a wire hook migrating credentials) take the
143
+ // same declared-key gate, value check and cache-sync announce as an admin instead of bypassing them
144
+ // via SecretsContext.set. The system role is in-process only and never minted into a session.
145
+ function withSystemWriter(rule: AccessRule): AccessRule {
146
+ if (!("roles" in rule) || rule.roles.includes(SYSTEM_ROLE)) return rule;
147
+ return { ...rule, roles: [SYSTEM_ROLE, ...rule.roles] };
148
+ }
149
+
136
150
  export function createSecretsFeature(opts: SecretsFeatureOptions = {}): FeatureDefinition {
137
151
  const access = resolveSecretsAccess(opts);
152
+ const writeAccess = withSystemWriter(access);
138
153
  return defineFeature(SECRETS_FEATURE_NAME, (r) => {
139
154
  r.describe(
140
- 'Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: ["TenantAdmin"] }; adopt the host\'s role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: { reason } } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins).',
155
+ 'Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: ["TenantAdmin"] }; adopt the host\'s role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: { reason } } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins). Server-side code writes a tenant secret with `dispatchSystemWrite({ handlerQn: "secrets:write:set", payload, tenantId })`: set and delete also admit the in-process system identity, so such writes pass the same declared-key and value checks.',
141
156
  );
142
157
  r.uiHints({
143
158
  displayLabel: "Tenant Secrets",
@@ -177,8 +192,8 @@ export function createSecretsFeature(opts: SecretsFeatureOptions = {}): FeatureD
177
192
  // The rotation job deliberately reaches for ctx.db.unsafeRaw() (raw,
178
193
  // cross-tenant) because rotation is a deployment-wide operation — no
179
194
  // feature-wide r.systemScope() needed.
180
- r.writeHandler(createSetHandler(access));
181
- r.writeHandler(createDeleteHandler(access));
195
+ r.writeHandler(createSetHandler(writeAccess));
196
+ r.writeHandler(createDeleteHandler(writeAccess));
182
197
  r.queryHandler(createListHandler(access));
183
198
  // Manual-only by design: ops triggers rotation after a KEK version flip.
184
199
  // BullMQ delivers to exactly one worker, so running it against a busy
@@ -3,7 +3,7 @@ import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
4
  import { announceSecretChange } from "../announce-secret-change.js";
5
5
  import { DEFAULT_SECRETS_ACCESS } from "../constants.js";
6
- import { requireSecretsContext } from "../feature.js";
6
+ import { JOIN_REQUEST_TRANSACTION_REASON, requireSecretsContext } from "../feature.js";
7
7
  import { checkSecretKeyWrite } from "../write-gate.js";
8
8
 
9
9
  export function createDeleteHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
@@ -16,8 +16,11 @@ export function createDeleteHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS)
16
16
  key: z.string().min(1).max(100),
17
17
  }),
18
18
  access,
19
+ escapeHatch: { grants: ["unsafeRaw"], reason: JOIN_REQUEST_TRANSACTION_REASON },
19
20
  handler: async (event, ctx) => {
20
- const secrets = requireSecretsContext(ctx, "secrets:write:delete");
21
+ const secrets = requireSecretsContext(ctx, "secrets:write:delete").withRunner(
22
+ ctx.db.unsafeRaw(),
23
+ );
21
24
  const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, event.payload.key, {
22
25
  allowUndeclared: true,
23
26
  });
@@ -2,7 +2,7 @@ import { type AccessRule, defineWriteHandler } from "@cosmicdrift/kumiko-framewo
2
2
  import * as z from "zod";
3
3
  import { announceSecretChange } from "../announce-secret-change.js";
4
4
  import { DEFAULT_SECRETS_ACCESS } from "../constants.js";
5
- import { requireSecretsContext } from "../feature.js";
5
+ import { JOIN_REQUEST_TRANSACTION_REASON, requireSecretsContext } from "../feature.js";
6
6
  import { checkSecretKeyWrite, checkSecretValue } from "../write-gate.js";
7
7
 
8
8
  export function createSetHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
@@ -23,8 +23,11 @@ export function createSetHandler(access: AccessRule = DEFAULT_SECRETS_ACCESS) {
23
23
  hint: z.string().max(200).optional(),
24
24
  }),
25
25
  access,
26
+ escapeHatch: { grants: ["unsafeRaw"], reason: JOIN_REQUEST_TRANSACTION_REASON },
26
27
  handler: async (event, ctx) => {
27
- const secrets = requireSecretsContext(ctx, "secrets:write:set");
28
+ const secrets = requireSecretsContext(ctx, "secrets:write:set").withRunner(
29
+ ctx.db.unsafeRaw(),
30
+ );
28
31
  const { key, value, redactedPreview, hint } = event.payload;
29
32
 
30
33
  // Preview-priority: explicit payload param > feature-declared redact
@@ -90,7 +90,6 @@ export const DEFAULT_PDF_LAYOUT_SLUG = "default";
90
90
  // The mail transport enforces no size limit of its own; 10 MiB is a common provider ceiling.
91
91
  export const MAIL_PREVIEW_MAX_BYTES = 10 * 1024 * 1024;
92
92
 
93
- export const TEST_MAIL_NOTIFICATION_TYPE = "template-resolver:test-mail";
94
93
  export const TEST_MAIL_SUBJECT_PREFIX = "[TEST] ";
95
94
 
96
95
  // System-Templates leben unter der canonical SYSTEM_TENANT_ID-Sentinel-UUID.
@@ -7,7 +7,7 @@ import {
7
7
  EMAIL_CHANNEL_NAME,
8
8
  } from "../../delivery/index.js";
9
9
  import { fileContextForTenant } from "../../file-foundation/index.js";
10
- import { TEST_MAIL_NOTIFICATION_TYPE, TEST_MAIL_SUBJECT_PREFIX } from "../constants.js";
10
+ import { TEST_MAIL_SUBJECT_PREFIX } from "../constants.js";
11
11
  import { TemplateResolverErrors } from "../qualified-names.js";
12
12
  import { renderTemplatePreview } from "../template-preview.js";
13
13
  import { localeSchema, templateSlugSchema } from "./shared.js";
@@ -75,6 +75,7 @@ export const sendTestWrite = defineWriteHandler({
75
75
  sseBroker: undefined,
76
76
  tenantId: event.user.tenantId,
77
77
  secrets: ctx.secrets,
78
+ config: ctx.config,
78
79
  readAttachment: createEmailAttachmentReader(
79
80
  ctx.db,
80
81
  ctx.registry,
@@ -98,7 +99,8 @@ export const sendTestWrite = defineWriteHandler({
98
99
  const result = await channel.send(
99
100
  address,
100
101
  {
101
- notificationType: TEST_MAIL_NOTIFICATION_TYPE,
102
+ // The template slug is the notification name, so the test mail takes the transport the real mail takes.
103
+ notificationType: event.payload.slug,
102
104
  title: subject,
103
105
  body: undefined,
104
106
  data: undefined,
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.369.0",
4
+ "type": "fix",
5
+ "title": "The \"account deleted\" mail is sent through channel-email when delivery is mounted",
6
+ "detail": "The default mailer for the Art. 17 deletion confirmation called createTransportForTenant directly, which threw for a tenant without its own mail provider. With delivery and channel-email mounted it now sends through the email channel like the other GDPR mails, over the app transport, without writing a delivery-attempt row for the already erased user. Without channel-email the previous path stays."
7
+ },
2
8
  {
3
9
  "version": "0.367.0",
4
10
  "type": "fix",
@@ -719,17 +719,20 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
719
719
  userId: forgetUserId,
720
720
  });
721
721
 
722
- // Without an app callback the default mail goes out when a mail transport and a
723
- // mail renderer are mounted. The user is already erased here, so it bypasses the
724
- // delivery log: an attempt row would be new personal data.
722
+ // Without an app callback the default mail goes out when an email channel or a
723
+ // mail transport, plus a mail renderer, are mounted. The user is already erased
724
+ // here, so it bypasses the delivery log: an attempt row would be new personal data.
725
725
  const forgetConfigResolver = ctx.configResolver;
726
726
  const sendDeletionExecutedEmail =
727
727
  opts.sendDeletionExecutedEmail ??
728
- (isMailTransportAvailable(forgetRegistry) && isMailRendererAvailable(forgetRegistry)
728
+ ((isGdprMailDeliveryAvailable(forgetRegistry) ||
729
+ isMailTransportAvailable(forgetRegistry)) &&
730
+ isMailRendererAvailable(forgetRegistry)
729
731
  ? makeDefaultDeletionExecutedEmail(
730
732
  {
731
733
  registry: forgetRegistry,
732
734
  db: forgetDb,
735
+ ...(ctx.secrets !== undefined && { secrets: ctx.secrets }),
733
736
  resolveTransport: makeTenantMailTransportResolver({
734
737
  registry: forgetRegistry,
735
738
  configResolver: forgetConfigResolver,
@@ -7,8 +7,8 @@
7
7
  // to the user, so delivery resolves the address and the attempt row carries
8
8
  // the recipientId the forget path erases. Deletion-executed cannot: the user
9
9
  // is gone by then, and an attempt row written after the erasure would be new
10
- // personal data. It renders the seeded template and sends over the tenant's
11
- // mail transport, with no delivery log.
10
+ // personal data. It renders the seeded template and sends it straight through the
11
+ // email channel (or, without one, the tenant's mail transport), with no delivery log.
12
12
 
13
13
  import type { EmailTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";
14
14
  import { MAIL_TRANSPORT_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
@@ -21,7 +21,14 @@ import type {
21
21
  TenantId,
22
22
  } from "@cosmicdrift/kumiko-framework/engine";
23
23
  import { hasMailTranslations, mailT } from "@cosmicdrift/kumiko-framework/i18n";
24
- import { DELIVERY_CHANNEL_EXTENSION } from "../../delivery/index.js";
24
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
25
+ import {
26
+ buildChannelContext,
27
+ collectChannels,
28
+ DELIVERY_CHANNEL_EXTENSION,
29
+ type DeliveryChannel,
30
+ EMAIL_CHANNEL_NAME,
31
+ } from "../../delivery/index.js";
25
32
  import {
26
33
  collectRendererPlugins,
27
34
  createRendererFoundationApi,
@@ -145,10 +152,16 @@ export function makeDefaultDeletionRequestedEmail(
145
152
  export type DeletionExecutedMailDeps = {
146
153
  readonly registry: Registry;
147
154
  readonly db: DbConnection;
155
+ /** Used only when no email delivery channel is mounted. */
148
156
  readonly resolveTransport: (tenantId: string) => Promise<EmailTransport>;
149
157
  readonly configFor: (tenantId: TenantId) => ConfigAccessor | undefined;
158
+ readonly secrets?: SecretsContext;
150
159
  };
151
160
 
161
+ function emailChannelOf(registry: Registry): DeliveryChannel | undefined {
162
+ return collectChannels(registry).find((channel) => channel.name === EMAIL_CHANNEL_NAME);
163
+ }
164
+
152
165
  export function makeDefaultDeletionExecutedEmail(
153
166
  deps: DeletionExecutedMailDeps,
154
167
  defaults: GdprMailDefaults = {},
@@ -190,6 +203,30 @@ export function makeDefaultDeletionExecutedEmail(
190
203
  if (rendered.kind !== "mail") {
191
204
  throw new Error(`deletion-executed mail: renderer answered with kind "${rendered.kind}"`);
192
205
  }
206
+ // The channel picks the same transport as every other mail of this notification
207
+ // (the app transport unless it is declared tenantTransport), so a tenant without
208
+ // its own mail provider still gets the confirmation. channel.send writes no attempt row.
209
+ const channel = emailChannelOf(deps.registry);
210
+ if (channel !== undefined) {
211
+ const result = await channel.send(
212
+ args.userEmail,
213
+ {
214
+ notificationType: type,
215
+ title: rendered.subject,
216
+ body: undefined,
217
+ data: undefined,
218
+ locale,
219
+ },
220
+ buildChannelContext(deps.db, deps.registry, undefined, tenantId, deps.secrets, config),
221
+ { subject: rendered.subject, html: rendered.html, text: rendered.text },
222
+ );
223
+ if (result.status !== "sent") {
224
+ // No address in the message: the user is already erased.
225
+ throw new Error(`deletion-executed mail not sent: ${result.error ?? result.status}`);
226
+ }
227
+ // skip: the channel delivered it, the transport fallback below is for apps without one
228
+ return;
229
+ }
193
230
  const transport = await deps.resolveTransport(tenantId);
194
231
  await transport.send({
195
232
  to: args.userEmail,
@@ -407,7 +407,7 @@
407
407
  },
408
408
  {
409
409
  "name": "channel-email",
410
- "description": "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send each tenant's mail through its own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.",
410
+ "description": "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set; a mail that then has no transport fails with `missing_credentials`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.",
411
411
  "toggleableDefault": null,
412
412
  "requires": [
413
413
  "delivery"
@@ -725,7 +725,7 @@
725
725
  },
726
726
  {
727
727
  "name": "delivery",
728
- "description": "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (`runProdApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.",
728
+ "description": "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (the `runProdApp` and `runDevApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. An app serving its own (e.g. legacy) unsubscribe links renders the same result page with `renderUnsubscribeResultPage({ kind, locale })`. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.",
729
729
  "toggleableDefault": null,
730
730
  "requires": [
731
731
  "tenant"
@@ -1507,7 +1507,7 @@
1507
1507
  },
1508
1508
  {
1509
1509
  "name": "mail-foundation",
1510
- "description": "Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature` so a tenant with a provider configured sends delivery mails through its own transport and other tenants through the app transport.",
1510
+ "description": "Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature`: notifications declared with `tenantTransport: true` then go through the tenant's own transport when it has a provider and through the app transport otherwise. Tenant admins pick the provider and fill in its settings on the generated Email sending settings screen; an empty provider is a valid choice and means the platform sends.",
1511
1511
  "toggleableDefault": null,
1512
1512
  "requires": [
1513
1513
  "config"
@@ -1531,8 +1531,8 @@
1531
1531
  "bounds": null,
1532
1532
  "pattern": null,
1533
1533
  "writeRoles": [
1534
- "TenantAdmin",
1535
- "SystemAdmin"
1534
+ "system",
1535
+ "TenantAdmin"
1536
1536
  ],
1537
1537
  "readRoles": [
1538
1538
  "TenantAdmin",
@@ -1603,8 +1603,8 @@
1603
1603
  "bounds": null,
1604
1604
  "pattern": null,
1605
1605
  "writeRoles": [
1606
- "TenantAdmin",
1607
- "SystemAdmin"
1606
+ "system",
1607
+ "TenantAdmin"
1608
1608
  ],
1609
1609
  "readRoles": [
1610
1610
  "TenantAdmin",
@@ -1624,8 +1624,8 @@
1624
1624
  "bounds": null,
1625
1625
  "pattern": null,
1626
1626
  "writeRoles": [
1627
- "TenantAdmin",
1628
- "SystemAdmin"
1627
+ "system",
1628
+ "TenantAdmin"
1629
1629
  ],
1630
1630
  "readRoles": [
1631
1631
  "TenantAdmin",
@@ -1645,8 +1645,8 @@
1645
1645
  "bounds": null,
1646
1646
  "pattern": null,
1647
1647
  "writeRoles": [
1648
- "TenantAdmin",
1649
- "SystemAdmin"
1648
+ "system",
1649
+ "TenantAdmin"
1650
1650
  ],
1651
1651
  "readRoles": [
1652
1652
  "TenantAdmin",
@@ -1669,8 +1669,8 @@
1669
1669
  },
1670
1670
  "pattern": null,
1671
1671
  "writeRoles": [
1672
- "TenantAdmin",
1673
- "SystemAdmin"
1672
+ "system",
1673
+ "TenantAdmin"
1674
1674
  ],
1675
1675
  "readRoles": [
1676
1676
  "all"
@@ -1689,8 +1689,8 @@
1689
1689
  "bounds": null,
1690
1690
  "pattern": null,
1691
1691
  "writeRoles": [
1692
- "TenantAdmin",
1693
- "SystemAdmin"
1692
+ "system",
1693
+ "TenantAdmin"
1694
1694
  ],
1695
1695
  "readRoles": [
1696
1696
  "all"
@@ -2191,7 +2191,7 @@
2191
2191
  },
2192
2192
  {
2193
2193
  "name": "secrets",
2194
- "description": "Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: [\"TenantAdmin\"] }; adopt the host's role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: { reason } } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins).",
2194
+ "description": "Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: [\"TenantAdmin\"] }; adopt the host's role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: { reason } } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins). Server-side code writes a tenant secret with `dispatchSystemWrite({ handlerQn: \"secrets:write:set\", payload, tenantId })`: set and delete also admit the in-process system identity, so such writes pass the same declared-key and value checks.",
2195
2195
  "toggleableDefault": null,
2196
2196
  "requires": [],
2197
2197
  "optionalRequires": [],