@cosmicdrift/kumiko-bundled-features 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 (38) hide show
  1. package/dist/channel-email/email-channel.d.ts +13 -5
  2. package/dist/channel-email/email-channel.js +17 -3
  3. package/dist/channel-email/feature.js +1 -1
  4. package/dist/channel-email/index.d.ts +1 -1
  5. package/dist/channel-email/index.js +1 -1
  6. package/dist/config/web/i18n.js +1 -2
  7. package/dist/delivery/feature.js +1 -1
  8. package/dist/delivery/index.d.ts +3 -2
  9. package/dist/delivery/index.js +3 -2
  10. package/dist/delivery/unsubscribe.d.ts +13 -0
  11. package/dist/delivery/unsubscribe.js +32 -27
  12. package/dist/mail-foundation/feature.d.ts +4 -0
  13. package/dist/mail-foundation/feature.js +16 -12
  14. package/dist/mail-foundation/i18n.d.ts +14 -0
  15. package/dist/mail-foundation/i18n.js +8 -0
  16. package/dist/mail-transport-inmemory/feature.js +6 -0
  17. package/dist/mail-transport-smtp/config-keys.d.ts +7 -0
  18. package/dist/mail-transport-smtp/config-keys.js +65 -0
  19. package/dist/mail-transport-smtp/feature.d.ts +2 -2
  20. package/dist/mail-transport-smtp/feature.js +5 -33
  21. package/dist/mail-transport-smtp/i18n.d.ts +41 -0
  22. package/dist/mail-transport-smtp/i18n.js +28 -0
  23. package/dist/secrets/feature.d.ts +1 -0
  24. package/dist/secrets/feature.js +16 -4
  25. package/dist/secrets/handlers/delete.write.js +3 -2
  26. package/dist/secrets/handlers/set.write.js +3 -2
  27. package/dist/template-resolver/constants.d.ts +0 -1
  28. package/dist/template-resolver/constants.js +0 -1
  29. package/dist/template-resolver/handlers/send-test.write.js +4 -2
  30. package/dist/user-data-rights/feature.js +7 -4
  31. package/dist/user-data-rights/lib/default-mailers.d.ts +3 -0
  32. package/dist/user-data-rights/lib/default-mailers.js +25 -3
  33. package/package.json +11 -11
  34. package/src/channel-email/changes.json +7 -0
  35. package/src/delivery/changes.json +6 -0
  36. package/src/mail-transport-smtp/changes.json +7 -0
  37. package/src/secrets/changes.json +6 -0
  38. package/src/user-data-rights/changes.json +6 -0
@@ -2,15 +2,23 @@ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { type ChannelContext, type DeliveryChannel, type EmailAttachmentRef } from "../delivery/index.js";
3
3
  import type { EmailTransport } from "./types.js";
4
4
  export declare function fileAttachmentsFrom(data: Readonly<Record<string, unknown>> | undefined, messageLocale?: string): readonly EmailAttachmentRef[];
5
- export type EmailChannelOptions = {
6
- /** The app-wide transport, used for every tenant `tenantTransport` returns null for. */
5
+ /** Resolves the sending tenant's own transport, null when it has none. Built per send and
6
+ * never cached, so a provider change applies to the next mail; a throw fails the send. */
7
+ export type TenantTransportResolver = (ctx: ChannelContext) => Promise<EmailTransport | null>;
8
+ type EmailChannelTransports = {
9
+ /** The app-wide transport for every mail that does not go through a tenant transport. */
7
10
  readonly transport: EmailTransport;
8
- /** Resolves the sending tenant's own transport, null when it has none. Built per send and
9
- * never cached, so a provider change applies to the next mail; a throw fails the send. */
10
- readonly tenantTransport?: (ctx: ChannelContext) => Promise<EmailTransport | null>;
11
+ /** Used only for notifications declared with `tenantTransport: true`. */
12
+ readonly tenantTransport?: TenantTransportResolver;
13
+ } | {
14
+ readonly transport?: undefined;
15
+ readonly tenantTransport: TenantTransportResolver;
16
+ };
17
+ export type EmailChannelOptions = EmailChannelTransports & {
11
18
  readonly resolveEmail: (userId: string, ctx: {
12
19
  db: unknown;
13
20
  tenantId: TenantId;
14
21
  }) => Promise<string | null>;
15
22
  };
16
23
  export declare function createEmailChannel(options: EmailChannelOptions): DeliveryChannel;
24
+ export {};
@@ -1,7 +1,7 @@
1
1
  import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
2
2
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
3
3
  import * as z from "zod";
4
- import { DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, DELIVERY_UNSUBSCRIBE_PATH, EMAIL_CHANNEL_NAME, fileAttachmentRefSchema, MAX_EMAIL_ATTACHMENTS, renderedPdfAttachmentRefSchema, } from "../delivery/index.js";
4
+ import { currentNotificationType, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, DELIVERY_UNSUBSCRIBE_PATH, EMAIL_CHANNEL_NAME, fileAttachmentRefSchema, MAX_EMAIL_ATTACHMENTS, renderedPdfAttachmentRefSchema, } from "../delivery/index.js";
5
5
  import { decryptStoredPii, extensionForResourceContentType } from "../shared/index.js";
6
6
  import { guardEmailMessage } from "./pii-guard.js";
7
7
  // Envelope (From / Reply-To / threading headers) rides on the channel data —
@@ -121,6 +121,10 @@ async function resolveAttachments(sources, ctx) {
121
121
  }
122
122
  return { kind: "resolved", attachments: resolved };
123
123
  }
124
+ function isTenantTransportNotification(ctx, notificationType) {
125
+ const current = currentNotificationType(ctx.registry, notificationType);
126
+ return ctx.registry.getAllNotifications().get(current)?.tenantTransport === true;
127
+ }
124
128
  export function createEmailChannel(options) {
125
129
  const { transport, resolveEmail, tenantTransport } = options;
126
130
  // Render runs in the delivery.render job, decoupled from the SMTP send so
@@ -150,9 +154,11 @@ export function createEmailChannel(options) {
150
154
  ...(attachments !== undefined && { attachments }),
151
155
  };
152
156
  }
153
- async function transportFor(ctx) {
157
+ async function transportFor(message, ctx) {
154
158
  if (tenantTransport === undefined)
155
159
  return transport;
160
+ if (!isTenantTransportNotification(ctx, message.notificationType))
161
+ return transport;
156
162
  return (await tenantTransport(ctx)) ?? transport;
157
163
  }
158
164
  return {
@@ -189,7 +195,15 @@ export function createEmailChannel(options) {
189
195
  return { status: "failed", error: "send_failed", address };
190
196
  }
191
197
  const resolvedAttachments = resolution.attachments;
192
- const sendTransport = await transportFor(ctx);
198
+ const sendTransport = await transportFor(message, ctx);
199
+ if (sendTransport === undefined) {
200
+ // Configuration, not a transient error: a retry cannot conjure a transport.
201
+ createFallbackLogger("delivery").warn("no mail transport for this mail, send failed", {
202
+ tenantId: ctx.tenantId,
203
+ notificationType: message.notificationType,
204
+ });
205
+ return { status: "failed", error: "missing_credentials", address };
206
+ }
193
207
  await sendTransport.send(guardEmailMessage({
194
208
  to: address,
195
209
  subject,
@@ -4,7 +4,7 @@ import { createEmailChannel } from "./email-channel.js";
4
4
  export function createChannelEmailFeature(options) {
5
5
  const channel = createEmailChannel(options);
6
6
  return defineFeature("channel-email", (r) => {
7
- r.describe("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.");
7
+ r.describe("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.");
8
8
  r.uiHints({
9
9
  displayLabel: "Email Channel",
10
10
  category: "notifications",
@@ -1,4 +1,4 @@
1
- export { createEmailChannel, type EmailChannelOptions } from "./email-channel.js";
1
+ export { createEmailChannel, type EmailChannelOptions, type TenantTransportResolver, } from "./email-channel.js";
2
2
  export { createChannelEmailFeature } from "./feature.js";
3
3
  export { guardEmailMessage, withPiiCiphertextGuard } from "./pii-guard.js";
4
4
  export { createSmtpTransport, createSmtpTransportFromEnv, type SmtpEnv, type SmtpTransportOptions, } from "./smtp-transport.js";
@@ -1,4 +1,4 @@
1
- export { createEmailChannel } from "./email-channel.js";
1
+ export { createEmailChannel, } from "./email-channel.js";
2
2
  export { createChannelEmailFeature } from "./feature.js";
3
3
  export { guardEmailMessage, withPiiCiphertextGuard } from "./pii-guard.js";
4
4
  export { createSmtpTransport, createSmtpTransportFromEnv, } from "./smtp-transport.js";
@@ -27,9 +27,8 @@ export const defaultTranslations = {
27
27
  "config.errors.invalidScope": "This scope is not allowed for this key.",
28
28
  "config.errors.unknownKey": "Unknown configuration key.",
29
29
  "config.errors.unknownExtensionPlugin": "This provider is not available.",
30
- "config.settings.extensionSelectorHint": "Choose a provider and save. Its settings appear below.",
30
+ "config.settings.extensionSelectorHint": "Choose a provider. Its settings appear below and are saved together with it.",
31
31
  "config.settings.provider": "Provider",
32
- "config.settings.saveProvider": "Save provider",
33
32
  "config.settings.audience.system": "Applies to every tenant unless a tenant sets its own value.",
34
33
  "config.settings.audience.tenant": "Applies to everyone in this tenant. Platform defaults are shown where nothing is set here.",
35
34
  "config.settings.audience.user": "Only applies to you.",
@@ -19,7 +19,7 @@ export function createDeliveryFeature(options) {
19
19
  const resolvedAccess = options?.access ?? access.admin;
20
20
  const attemptLogRetentionDays = resolveAttemptLogRetentionDays(options?.attemptLogRetentionDays);
21
21
  return defineFeature("delivery", (r) => {
22
- r.describe("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.");
22
+ r.describe("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.");
23
23
  r.uiHints({
24
24
  displayLabel: "Notifications \u00b7 Dispatch Core",
25
25
  category: "notifications",
@@ -2,7 +2,7 @@ export { sanitizePdfAttachmentFileName } from "../shared/index.js";
2
2
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
3
3
  export { DELIVERY_ENVELOPE_DATA_KEYS, type EmailAttachmentRef, emailAttachmentRefSchema, type FileAttachmentRef, fileAttachmentRefSchema, type InlineAttachmentRef, inlineAttachmentRefSchema, MAX_EMAIL_ATTACHMENTS, MAX_RENDERED_PDF_VARIABLES_BYTES, type RenderedPdfAttachmentRef, renderedPdfAttachmentRefSchema, toAttachmentRef, withoutDeliveryEnvelopeKeys, } from "./attachment-ref.js";
4
4
  export { type AttemptLogRetentionOptions, DEFAULT_ATTEMPT_LOG_RETENTION_DAYS, runAttemptLogRetention, } from "./attempt-log-retention.js";
5
- export { createEmailAttachmentReader, createTemplateMailRenderer } from "./channel-context.js";
5
+ export { buildChannelContext, createEmailAttachmentReader, createTemplateMailRenderer, } from "./channel-context.js";
6
6
  export { type ChatWebhookChannelOptions, chatMessageText, createChatWebhookChannel, resolveChatWebhookTarget, toChannelResult, } from "./chat-webhook-channel.js";
7
7
  export { type ChatSendFailureCode, type ChatSendResult, type ChatWebhookRequest, type ChatWebhookResponse, type ChatWebhookTarget, chatConnectionNameSchema, chatWebhookUrlSchema, checkChatWebhookTarget, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
8
8
  export type { DeliveryStatusValue } from "./constants.js";
@@ -10,10 +10,11 @@ export { DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT, DELIVERY_CHANNEL_EXTENSION, DELI
10
10
  export { collectChannels, createDeliveryService, type DeliveryServiceOptions, type KillSwitchResolver, type RateLimitConfig, } from "./delivery-service.js";
11
11
  export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature.js";
12
12
  export { type UnsubscribeAddressPayload, unsubscribeAddressPayloadSchema, } from "./handlers/unsubscribe-address.write.js";
13
+ export { currentNotificationType } from "./notification-type-aliases.js";
13
14
  export { createDeliveryNotifyFactory } from "./notify-factory.js";
14
15
  export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.js";
15
16
  export { type CreateDeliveryTestContextOptions, createDeliveryTestContext } from "./testing.js";
16
17
  export { type AttachmentOrigin, type ChannelContext, type ChannelMessage, type ChannelResult, type DeliveryChannel, type DeliveryChannelMode, type DeliveryChannelPlugin, type DeliveryLogEntry, type DeliveryService, isDeliveryChannelPlugin, type RenderedMessage, } from "./types.js";
17
- export { type AddressUnsubscribeTokenPayload, createUnsubscribeRoutes, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, signAddressUnsubscribeToken, signUnsubscribeToken, type UnsubscribeRouteOptions, type UnsubscribeTokenPayload, } from "./unsubscribe.js";
18
+ export { type AddressUnsubscribeTokenPayload, createUnsubscribeRoutes, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, renderUnsubscribeResultPage, signAddressUnsubscribeToken, signUnsubscribeToken, type UnsubscribeResultPage, type UnsubscribeRouteOptions, type UnsubscribeTokenPayload, } from "./unsubscribe.js";
18
19
  export type { UnsubscribeLinkConfig } from "./unsubscribe-link.js";
19
20
  export { UNSUBSCRIBE_PAGE_EN } from "./unsubscribe-page-strings.js";
@@ -2,16 +2,17 @@ export { sanitizePdfAttachmentFileName } from "../shared/index.js";
2
2
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
3
3
  export { DELIVERY_ENVELOPE_DATA_KEYS, emailAttachmentRefSchema, fileAttachmentRefSchema, inlineAttachmentRefSchema, MAX_EMAIL_ATTACHMENTS, MAX_RENDERED_PDF_VARIABLES_BYTES, renderedPdfAttachmentRefSchema, toAttachmentRef, withoutDeliveryEnvelopeKeys, } from "./attachment-ref.js";
4
4
  export { DEFAULT_ATTEMPT_LOG_RETENTION_DAYS, runAttemptLogRetention, } from "./attempt-log-retention.js";
5
- export { createEmailAttachmentReader, createTemplateMailRenderer } from "./channel-context.js";
5
+ export { buildChannelContext, createEmailAttachmentReader, createTemplateMailRenderer, } from "./channel-context.js";
6
6
  export { chatMessageText, createChatWebhookChannel, resolveChatWebhookTarget, toChannelResult, } from "./chat-webhook-channel.js";
7
7
  export { chatConnectionNameSchema, chatWebhookUrlSchema, checkChatWebhookTarget, DEFAULT_CHAT_TIMEOUT_MS, postChatWebhook, truncateChars, } from "./chat-webhook-sender.js";
8
8
  export { DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT, DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, EMAIL_CHANNEL_NAME, } from "./constants.js";
9
9
  export { collectChannels, createDeliveryService, } from "./delivery-service.js";
10
10
  export { createDeliveryFeature } from "./feature.js";
11
11
  export { unsubscribeAddressPayloadSchema, } from "./handlers/unsubscribe-address.write.js";
12
+ export { currentNotificationType } from "./notification-type-aliases.js";
12
13
  export { createDeliveryNotifyFactory } from "./notify-factory.js";
13
14
  export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.js";
14
15
  export { createDeliveryTestContext } from "./testing.js";
15
16
  export { isDeliveryChannelPlugin, } from "./types.js";
16
- export { createUnsubscribeRoutes, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, signAddressUnsubscribeToken, signUnsubscribeToken, } from "./unsubscribe.js";
17
+ export { createUnsubscribeRoutes, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, renderUnsubscribeResultPage, signAddressUnsubscribeToken, signUnsubscribeToken, } from "./unsubscribe.js";
17
18
  export { UNSUBSCRIBE_PAGE_EN } from "./unsubscribe-page-strings.js";
@@ -34,4 +34,17 @@ export declare function signUnsubscribeToken(payload: UnsubscribeTokenPayload, s
34
34
  */
35
35
  export declare function signAddressUnsubscribeToken(payload: AddressUnsubscribeTokenPayload, secret: string): Promise<string>;
36
36
  export declare const DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE: "List-Unsubscribe=One-Click";
37
+ export type UnsubscribeResultPage = {
38
+ readonly kind: "unsubscribed";
39
+ readonly resubscribeToken?: string;
40
+ } | {
41
+ readonly kind: "resubscribed";
42
+ } | {
43
+ readonly kind: "failed";
44
+ };
45
+ /** The plain result page the unsubscribe routes answer with, for apps serving their own
46
+ * (e.g. legacy) unsubscribe links. Texts come from the mail translations of `locale`. */
47
+ export declare function renderUnsubscribeResultPage(page: UnsubscribeResultPage & {
48
+ readonly locale?: string | undefined;
49
+ }): string;
37
50
  export declare function createUnsubscribeRoutes(options: UnsubscribeRouteOptions): readonly ExtraRouteDefinition[];
@@ -272,23 +272,30 @@ function confirmationPage(token, locale) {
272
272
  <button type="submit">${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.confirmButton"))}</button>
273
273
  </form>`);
274
274
  }
275
- function unsubscribedPage(resubscribeToken, locale) {
276
- const lang = pageLocale(locale);
277
- return htmlPage(lang, mailT(lang, "mail.unsubscribe.doneTitle"), `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.doneMessage"))}</p>
275
+ /** The plain result page the unsubscribe routes answer with, for apps serving their own
276
+ * (e.g. legacy) unsubscribe links. Texts come from the mail translations of `locale`. */
277
+ export function renderUnsubscribeResultPage(page) {
278
+ const lang = pageLocale(page.locale);
279
+ switch (page.kind) {
280
+ case "unsubscribed": {
281
+ const undoForm = page.resubscribeToken === undefined
282
+ ? ""
283
+ : `
278
284
  <form method="post" action="${DELIVERY_RESUBSCRIBE_PATH}">
279
- <input type="hidden" name="token" value="${escapeHtmlAttr(resubscribeToken)}">
285
+ <input type="hidden" name="token" value="${escapeHtmlAttr(page.resubscribeToken)}">
280
286
  <button type="submit">${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.undoButton"))}</button>
281
- </form>`);
282
- }
283
- function resubscribedPage(locale) {
284
- const lang = pageLocale(locale);
285
- return htmlPage(lang, mailT(lang, "mail.unsubscribe.resubscribedTitle"), `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.resubscribedMessage"))}</p>`);
286
- }
287
- function failedPage(locale) {
288
- const lang = pageLocale(locale);
289
- const message = mailT(lang, "mail.unsubscribe.failed");
290
- return htmlPage(lang, message, `<p>${escapeHtmlAttr(message)}</p>`);
287
+ </form>`;
288
+ return htmlPage(lang, mailT(lang, "mail.unsubscribe.doneTitle"), `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.doneMessage"))}</p>${undoForm}`);
289
+ }
290
+ case "resubscribed":
291
+ return htmlPage(lang, mailT(lang, "mail.unsubscribe.resubscribedTitle"), `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.resubscribedMessage"))}</p>`);
292
+ case "failed": {
293
+ const message = mailT(lang, "mail.unsubscribe.failed");
294
+ return htmlPage(lang, message, `<p>${escapeHtmlAttr(message)}</p>`);
295
+ }
296
+ }
291
297
  }
298
+ const NO_STORE_HEADERS = { "Cache-Control": "no-store" };
292
299
  function isFormRequest(contentType) {
293
300
  return contentType?.includes("application/x-www-form-urlencoded") === true;
294
301
  }
@@ -350,9 +357,13 @@ export function createUnsubscribeRoutes(options) {
350
357
  // (e.g. events-table missing, DB down) behind a misleading 400.
351
358
  const succeeded = await dispatchUnsubscribeWrite(verified, deps);
352
359
  if (!succeeded) {
353
- return c.html(failedPage(verified.locale), 500, { "Cache-Control": "no-store" });
360
+ return c.html(renderUnsubscribeResultPage({ kind: "failed", locale: verified.locale }), 500, NO_STORE_HEADERS);
354
361
  }
355
- return c.html(unsubscribedPage(await signResubscribeToken(verified, encodedSecret), verified.locale), 200, { "Cache-Control": "no-store" });
362
+ return c.html(renderUnsubscribeResultPage({
363
+ kind: "unsubscribed",
364
+ resubscribeToken: await signResubscribeToken(verified, encodedSecret),
365
+ locale: verified.locale,
366
+ }), 200, NO_STORE_HEADERS);
356
367
  },
357
368
  });
358
369
  // Undo direction: token only from the JSON/form body, never the query
@@ -368,22 +379,16 @@ export function createUnsubscribeRoutes(options) {
368
379
  // The undo button on the unsubscribed page posts a form; a browser should land on a page.
369
380
  if (isFormRequest(c.req.header("content-type"))) {
370
381
  return outcome === "success"
371
- ? c.html(resubscribedPage(verified.locale), 200, { "Cache-Control": "no-store" })
372
- : c.html(failedPage(verified.locale), outcome === "limit_reached" ? 409 : 500, {
373
- "Cache-Control": "no-store",
374
- });
382
+ ? c.html(renderUnsubscribeResultPage({ kind: "resubscribed", locale: verified.locale }), 200, NO_STORE_HEADERS)
383
+ : c.html(renderUnsubscribeResultPage({ kind: "failed", locale: verified.locale }), outcome === "limit_reached" ? 409 : 500, NO_STORE_HEADERS);
375
384
  }
376
385
  if (outcome === "limit_reached") {
377
- return c.json({ error: { code: DeliveryErrors.resubscribeLimitReached } }, 409, {
378
- "Cache-Control": "no-store",
379
- });
386
+ return c.json({ error: { code: DeliveryErrors.resubscribeLimitReached } }, 409, NO_STORE_HEADERS);
380
387
  }
381
388
  if (outcome === "failed") {
382
- return c.json({ error: { code: "resubscribe_failed" } }, 500, {
383
- "Cache-Control": "no-store",
384
- });
389
+ return c.json({ error: { code: "resubscribe_failed" } }, 500, NO_STORE_HEADERS);
385
390
  }
386
- return c.json({ isSuccess: true }, 200, { "Cache-Control": "no-store" });
391
+ return c.json({ isSuccess: true }, 200, NO_STORE_HEADERS);
387
392
  },
388
393
  });
389
394
  return [confirmRoute, writeRoute, resubscribeRoute];
@@ -46,6 +46,10 @@ export type MailTransportContext = {
46
46
  */
47
47
  export type MailTransportPlugin = {
48
48
  readonly build: (ctx: MailTransportContext, tenantId: string) => Promise<EmailTransport>;
49
+ /** `false` keeps the provider out of the generated settings: it can still be set by a
50
+ * seed or config write, but a tenant admin is never offered it (e.g. a test inbox
51
+ * that swallows every mail). */
52
+ readonly selectableInSettings?: false;
49
53
  };
50
54
  export declare function isMailTransportPlugin(o: unknown): o is MailTransportPlugin;
51
55
  declare module "@cosmicdrift/kumiko-framework/engine" {
@@ -33,6 +33,7 @@
33
33
  import { withPiiCiphertextGuard, } from "@cosmicdrift/kumiko-bundled-features/channel-email";
34
34
  import { requireDefined } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
35
35
  import { access, createSystemUser, createTenantConfig, defineFeature, } from "@cosmicdrift/kumiko-framework/engine";
36
+ import { MAIL_FOUNDATION_I18N } from "./i18n.js";
36
37
  const FEATURE_NAME = "mail-foundation";
37
38
  // Extension-point name for transport-provider plugins (mail-transport-smtp,
38
39
  // mail-transport-brevo-api, ...).
@@ -47,7 +48,7 @@ export function isMailTransportPlugin(o) {
47
48
  // =============================================================================
48
49
  export function createMailFoundationFeature() {
49
50
  return defineFeature(FEATURE_NAME, (r) => {
50
- r.describe("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.");
51
+ r.describe("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.");
51
52
  r.uiHints({
52
53
  displayLabel: "Mail Transport Foundation",
53
54
  category: "notifications",
@@ -64,24 +65,27 @@ export function createMailFoundationFeature() {
64
65
  // delivery's extendsRegistrar.
65
66
  },
66
67
  });
67
- // Provider-selector. Default empty so the boot-validator throws if a
68
- // tenant tries to send mail without first picking + setting up a
69
- // provider — better than a silent fallback. The actual list of valid
70
- // values lives in the registered plugins, not here — Designer-UI can
71
- // render `getExtensionUsages("mailTransport").map(u => u.entityName)`
72
- // as the option-list.
68
+ // Empty = this tenant has no own provider: tenantMailTransport yields null and
69
+ // channel-email uses the app transport, so empty is a valid choice a tenant can
70
+ // return to (reset in the settings mask) and readiness must not flag it.
71
+ // createTransportForTenant still throws on empty.
73
72
  const providerConfigKey = r.config("provider", createTenantConfig("text", {
74
73
  default: "",
75
- // required: without a chosen provider createTransportForTenant throws —
76
- // readiness used to report ready:true and the first mail send
77
- // surfaced the UnconfiguredError (280/1).
78
- required: true,
79
- write: access.roles("TenantAdmin", "SystemAdmin"),
74
+ // No SystemAdmin: a platform-level provider would be inherited by every tenant,
75
+ // whose own credentials (tenant secrets) are missing, and break all their mail.
76
+ write: access.withSystem(access.roles("TenantAdmin")),
80
77
  read: access.roles("TenantAdmin", "SystemAdmin", "User"),
78
+ mask: {
79
+ title: "mail-foundation.provider",
80
+ description: "mail-foundation.provider.description",
81
+ icon: "mail",
82
+ order: 1,
83
+ },
81
84
  }));
82
85
  // Readiness gating: transport-plugins' required keys/secrets count only
83
86
  // while their plugin is the one this key selects.
84
87
  r.extensionSelector(MAIL_TRANSPORT_EXTENSION, providerConfigKey);
88
+ r.translations({ keys: MAIL_FOUNDATION_I18N });
85
89
  return {
86
90
  /** Config-key-handle for the provider-selector. */
87
91
  providerConfigKey,
@@ -0,0 +1,14 @@
1
+ export declare const MAIL_FOUNDATION_I18N: {
2
+ readonly "screen:mail-foundation-tenant.title": {
3
+ readonly en: "Email sending";
4
+ };
5
+ readonly "mail-foundation.settings": {
6
+ readonly en: "Email sending";
7
+ };
8
+ readonly "mail-foundation.provider": {
9
+ readonly en: "Own mail server";
10
+ };
11
+ readonly "mail-foundation.provider.description": {
12
+ readonly en: "Without one, the platform sends the mails. Reset the choice to go back to that.";
13
+ };
14
+ };
@@ -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
+ };
@@ -69,6 +69,9 @@ export function createMailTransportInMemoryFeature() {
69
69
  // extension-point "mailTransport" definiert.
70
70
  r.requires({ features: ["mail-foundation"] });
71
71
  const plugin = {
72
+ // Solon and studio mount it next to smtp in production; offering it there would
73
+ // let a tenant admin route every mail into process memory.
74
+ selectableInSettings: false,
72
75
  build: async (_ctx, tenantId) => {
73
76
  // Returns the per-tenant buffer. Identity-stable across calls so the
74
77
  // demo inbox keeps accumulating.
@@ -76,6 +79,9 @@ export function createMailTransportInMemoryFeature() {
76
79
  }, // @wrapper-known semantic-alias
77
80
  };
78
81
  r.useExtension(MAIL_TRANSPORT_EXTENSION, "inmemory", plugin);
82
+ r.translations({
83
+ keys: { "mail-transport-inmemory.settings": { en: "Test inbox (in memory)" } },
84
+ });
79
85
  }, { dedupeOptions: {} });
80
86
  }
81
87
  /** @deprecated Use createMailTransportInMemoryFeature(). Run `bun kumiko-upgrade --apply`. Removed in the first release after 2026-10-29. */
@@ -0,0 +1,7 @@
1
+ export declare const SMTP_CONFIG_KEYS: {
2
+ host: import("@cosmicdrift/kumiko-types/config").ConfigKeyDefinition<"text">;
3
+ port: import("@cosmicdrift/kumiko-types/config").ConfigKeyDefinition<"number">;
4
+ secure: import("@cosmicdrift/kumiko-types/config").ConfigKeyDefinition<"boolean">;
5
+ authUser: import("@cosmicdrift/kumiko-types/config").ConfigKeyDefinition<"text">;
6
+ from: import("@cosmicdrift/kumiko-types/config").ConfigKeyDefinition<"text">;
7
+ };
@@ -0,0 +1,65 @@
1
+ import { access, createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
2
+ // No SystemAdmin write: the password is a tenant secret, so platform-level SMTP keys
3
+ // could never form a working transport (see mail-foundation's provider key).
4
+ const tenantAdminWrite = access.withSystem(access.roles("TenantAdmin"));
5
+ const tenantAdminRead = access.roles("TenantAdmin", "SystemAdmin");
6
+ // required: true ↔ the requireNonEmpty calls in buildSmtpTransport — keep in sync.
7
+ export const SMTP_CONFIG_KEYS = {
8
+ host: createTenantConfig("text", {
9
+ required: true,
10
+ default: "",
11
+ write: tenantAdminWrite,
12
+ read: tenantAdminRead,
13
+ mask: {
14
+ title: "mail-transport-smtp.host",
15
+ description: "mail-transport-smtp.host.description",
16
+ icon: "server",
17
+ order: 1,
18
+ },
19
+ }),
20
+ port: createTenantConfig("number", {
21
+ default: 587,
22
+ bounds: { min: 1, max: 65535 },
23
+ write: tenantAdminWrite,
24
+ mask: {
25
+ title: "mail-transport-smtp.port",
26
+ description: "mail-transport-smtp.port.description",
27
+ icon: "hash",
28
+ order: 2,
29
+ },
30
+ }),
31
+ secure: createTenantConfig("boolean", {
32
+ default: false,
33
+ write: tenantAdminWrite,
34
+ mask: {
35
+ title: "mail-transport-smtp.secure",
36
+ description: "mail-transport-smtp.secure.description",
37
+ icon: "lock",
38
+ order: 3,
39
+ },
40
+ }),
41
+ authUser: createTenantConfig("text", {
42
+ required: true,
43
+ default: "",
44
+ write: tenantAdminWrite,
45
+ read: tenantAdminRead,
46
+ mask: {
47
+ title: "mail-transport-smtp.authUser",
48
+ description: "mail-transport-smtp.authUser.description",
49
+ icon: "user",
50
+ order: 4,
51
+ },
52
+ }),
53
+ from: createTenantConfig("text", {
54
+ required: true,
55
+ default: "",
56
+ write: tenantAdminWrite,
57
+ read: tenantAdminRead,
58
+ mask: {
59
+ title: "mail-transport-smtp.from",
60
+ description: "mail-transport-smtp.from.description",
61
+ icon: "mail",
62
+ order: 5,
63
+ },
64
+ }),
65
+ };
@@ -11,8 +11,8 @@ export declare function createMailTransportSmtpFeature(): import("@cosmicdrift/k
11
11
  readonly host: import("@cosmicdrift/kumiko-types/config").ConfigKeyHandle<"text">;
12
12
  readonly port: import("@cosmicdrift/kumiko-types/config").ConfigKeyHandle<"number">;
13
13
  readonly secure: import("@cosmicdrift/kumiko-types/config").ConfigKeyHandle<"boolean">;
14
- readonly from: import("@cosmicdrift/kumiko-types/config").ConfigKeyHandle<"text">;
15
14
  readonly authUser: import("@cosmicdrift/kumiko-types/config").ConfigKeyHandle<"text">;
15
+ readonly from: import("@cosmicdrift/kumiko-types/config").ConfigKeyHandle<"text">;
16
16
  };
17
17
  /** Secret-handle for the SMTP password. */
18
18
  password: import("@cosmicdrift/kumiko-types/feature").SecretKeyHandle;
@@ -27,8 +27,8 @@ export declare const mailTransportSmtpFeature: import("@cosmicdrift/kumiko-types
27
27
  readonly host: import("@cosmicdrift/kumiko-types/config").ConfigKeyHandle<"text">;
28
28
  readonly port: import("@cosmicdrift/kumiko-types/config").ConfigKeyHandle<"number">;
29
29
  readonly secure: import("@cosmicdrift/kumiko-types/config").ConfigKeyHandle<"boolean">;
30
- readonly from: import("@cosmicdrift/kumiko-types/config").ConfigKeyHandle<"text">;
31
30
  readonly authUser: import("@cosmicdrift/kumiko-types/config").ConfigKeyHandle<"text">;
31
+ readonly from: import("@cosmicdrift/kumiko-types/config").ConfigKeyHandle<"text">;
32
32
  };
33
33
  /** Secret-handle for the SMTP password. */
34
34
  password: import("@cosmicdrift/kumiko-types/feature").SecretKeyHandle;
@@ -28,9 +28,11 @@ import { createSmtpTransport, } from "@cosmicdrift/kumiko-bundled-features/chann
28
28
  import { BlockedHostError, HostResolutionError, mailAllowedPrivateHostsEnvSchema, readAllowedPrivateMailHostsFromEnv, requireDefined, requireNonEmpty, requireSecretSet, resolveMailConnectTarget, } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
29
29
  import { MAIL_TRANSPORT_EXTENSION, } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
30
30
  import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
31
- import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
31
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
32
32
  import { UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
33
33
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
34
+ import { SMTP_CONFIG_KEYS } from "./config-keys.js";
35
+ import { MAIL_TRANSPORT_SMTP_I18N } from "./i18n.js";
34
36
  const FEATURE_NAME = "mail-transport-smtp";
35
37
  const log = createFallbackLogger(FEATURE_NAME);
36
38
  // Operator escape hatch for an internal relay or a dev/test SMTP server
@@ -77,38 +79,8 @@ export function createMailTransportSmtpFeature() {
77
79
  // required: true ↔ the missing-secret throw in readPassword — keep in sync.
78
80
  required: true,
79
81
  });
80
- // required: true ↔ the requireNonEmpty calls in buildSmtpTransport — keep in sync.
81
- const configKeys = r.config({
82
- keys: {
83
- host: createTenantConfig("text", {
84
- required: true,
85
- default: "",
86
- write: access.roles("TenantAdmin", "SystemAdmin"),
87
- read: access.roles("TenantAdmin", "SystemAdmin"),
88
- }),
89
- port: createTenantConfig("number", {
90
- default: 587,
91
- bounds: { min: 1, max: 65535 },
92
- write: access.roles("TenantAdmin", "SystemAdmin"),
93
- }),
94
- secure: createTenantConfig("boolean", {
95
- default: false,
96
- write: access.roles("TenantAdmin", "SystemAdmin"),
97
- }),
98
- from: createTenantConfig("text", {
99
- required: true,
100
- default: "",
101
- write: access.roles("TenantAdmin", "SystemAdmin"),
102
- read: access.roles("TenantAdmin", "SystemAdmin"),
103
- }),
104
- authUser: createTenantConfig("text", {
105
- required: true,
106
- default: "",
107
- write: access.roles("TenantAdmin", "SystemAdmin"),
108
- read: access.roles("TenantAdmin", "SystemAdmin"),
109
- }),
110
- },
111
- });
82
+ const configKeys = r.config({ keys: SMTP_CONFIG_KEYS });
83
+ r.translations({ keys: MAIL_TRANSPORT_SMTP_I18N });
112
84
  // Plugin: register against mail-foundation's "mailTransport" extension.
113
85
  // `entityName` "smtp" is what tenants set in mail-foundation's
114
86
  // `provider` config-key to pick this transport.
@@ -0,0 +1,41 @@
1
+ export declare const MAIL_TRANSPORT_SMTP_I18N: {
2
+ readonly "mail-transport-smtp.settings": {
3
+ readonly en: "SMTP server";
4
+ };
5
+ readonly "mail-transport-smtp.settings.secrets": {
6
+ readonly en: "SMTP sign-in";
7
+ };
8
+ readonly "mail-transport-smtp.settings.description": {
9
+ readonly en: "The connection details come from your mail provider, for example Brevo, Postmark, Amazon SES or your own server.";
10
+ };
11
+ readonly "mail-transport-smtp.host": {
12
+ readonly en: "Server address";
13
+ };
14
+ readonly "mail-transport-smtp.host.description": {
15
+ readonly en: "Host name of the SMTP server, for example smtp.example.com.";
16
+ };
17
+ readonly "mail-transport-smtp.port": {
18
+ readonly en: "Port number";
19
+ };
20
+ readonly "mail-transport-smtp.port.description": {
21
+ readonly en: "Usually 587. Use 465 when your provider asks for SSL/TLS.";
22
+ };
23
+ readonly "mail-transport-smtp.secure": {
24
+ readonly en: "SSL/TLS from the start";
25
+ };
26
+ readonly "mail-transport-smtp.secure.description": {
27
+ readonly en: "Turn on for port 465. Leave off for 587, which switches to encryption by itself.";
28
+ };
29
+ readonly "mail-transport-smtp.authUser": {
30
+ readonly en: "User name";
31
+ };
32
+ readonly "mail-transport-smtp.authUser.description": {
33
+ readonly en: "Login name at the SMTP server, often an email address.";
34
+ };
35
+ readonly "mail-transport-smtp.from": {
36
+ readonly en: "Sender address";
37
+ };
38
+ readonly "mail-transport-smtp.from.description": {
39
+ readonly en: "The address mails are sent from, for example status@example.com. Your provider must allow it.";
40
+ };
41
+ };
@@ -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
+ };
@@ -22,6 +22,7 @@ export declare const secretsEnvSchema: z.ZodObject<{
22
22
  }, z.core.$strip>;
23
23
  export { createSecretsContext, type SecretsContext, type SecretsContextOptions, TENANT_SECRET_READ_EVENT, } from "./secrets-context.js";
24
24
  export { type StoredEnvelope, type StoredMetadata, tenantSecretsTable } from "./table.js";
25
+ export declare const JOIN_REQUEST_TRANSACTION_REASON = "the secret write runs on the request transaction so a failing batch rolls it back together with the config writes";
25
26
  export declare function requireSecretsContext(ctx: {
26
27
  readonly secrets?: SecretsContext;
27
28
  readonly _userId?: string | undefined;
@@ -1,4 +1,4 @@
1
- import { defineFeature, } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { defineFeature, SYSTEM_ROLE, } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { SETTINGS_HUB_I18N } from "@cosmicdrift/kumiko-framework/i18n";
4
4
  import * as z from "zod";
@@ -44,6 +44,9 @@ export const secretsEnvSchema = z.object({
44
44
  });
45
45
  export { createSecretsContext, TENANT_SECRET_READ_EVENT, } from "./secrets-context.js";
46
46
  export { tenantSecretsTable } from "./table.js";
47
+ // The settings form saves config values and secrets in one /api/batch; the secret
48
+ // write has to join that transaction so a rejected command rolls it back too.
49
+ export const JOIN_REQUEST_TRANSACTION_REASON = "the secret write runs on the request transaction so a failing batch rolls it back together with the config writes";
47
50
  // AppContext carries ctx.secrets via extraContext. requireSecretsContext
48
51
  // wraps that raw context so every `.get(...)` call auto-includes the
49
52
  // current user + handler as audit metadata — feature code can't forget
@@ -90,10 +93,19 @@ function resolveSecretsAccess(opts) {
90
93
  return { roles: opts.roles };
91
94
  return DEFAULT_SECRETS_ACCESS;
92
95
  }
96
+ // Server-side system writes (dispatchSystemWrite, e.g. a wire hook migrating credentials) take the
97
+ // same declared-key gate, value check and cache-sync announce as an admin instead of bypassing them
98
+ // via SecretsContext.set. The system role is in-process only and never minted into a session.
99
+ function withSystemWriter(rule) {
100
+ if (!("roles" in rule) || rule.roles.includes(SYSTEM_ROLE))
101
+ return rule;
102
+ return { ...rule, roles: [SYSTEM_ROLE, ...rule.roles] };
103
+ }
93
104
  export function createSecretsFeature(opts = {}) {
94
105
  const access = resolveSecretsAccess(opts);
106
+ const writeAccess = withSystemWriter(access);
95
107
  return defineFeature(SECRETS_FEATURE_NAME, (r) => {
96
- r.describe('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).');
108
+ r.describe('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.');
97
109
  r.uiHints({
98
110
  displayLabel: "Tenant Secrets",
99
111
  category: "infrastructure",
@@ -128,8 +140,8 @@ export function createSecretsFeature(opts = {}) {
128
140
  // The rotation job deliberately reaches for ctx.db.unsafeRaw() (raw,
129
141
  // cross-tenant) because rotation is a deployment-wide operation — no
130
142
  // feature-wide r.systemScope() needed.
131
- r.writeHandler(createSetHandler(access));
132
- r.writeHandler(createDeleteHandler(access));
143
+ r.writeHandler(createSetHandler(writeAccess));
144
+ r.writeHandler(createDeleteHandler(writeAccess));
133
145
  r.queryHandler(createListHandler(access));
134
146
  // Manual-only by design: ops triggers rotation after a KEK version flip.
135
147
  // 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
  export function createDeleteHandler(access = DEFAULT_SECRETS_ACCESS) {
9
9
  return defineWriteHandler({
@@ -14,8 +14,9 @@ export function createDeleteHandler(access = DEFAULT_SECRETS_ACCESS) {
14
14
  key: z.string().min(1).max(100),
15
15
  }),
16
16
  access,
17
+ escapeHatch: { grants: ["unsafeRaw"], reason: JOIN_REQUEST_TRANSACTION_REASON },
17
18
  handler: async (event, ctx) => {
18
- const secrets = requireSecretsContext(ctx, "secrets:write:delete");
19
+ const secrets = requireSecretsContext(ctx, "secrets:write:delete").withRunner(ctx.db.unsafeRaw());
19
20
  const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, event.payload.key, {
20
21
  allowUndeclared: true,
21
22
  });
@@ -2,7 +2,7 @@ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
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
  export function createSetHandler(access = DEFAULT_SECRETS_ACCESS) {
8
8
  return defineWriteHandler({
@@ -21,8 +21,9 @@ export function createSetHandler(access = DEFAULT_SECRETS_ACCESS) {
21
21
  hint: z.string().max(200).optional(),
22
22
  }),
23
23
  access,
24
+ escapeHatch: { grants: ["unsafeRaw"], reason: JOIN_REQUEST_TRANSACTION_REASON },
24
25
  handler: async (event, ctx) => {
25
- const secrets = requireSecretsContext(ctx, "secrets:write:set");
26
+ const secrets = requireSecretsContext(ctx, "secrets:write:set").withRunner(ctx.db.unsafeRaw());
26
27
  const { key, value, redactedPreview, hint } = event.payload;
27
28
  // Preview-priority: explicit payload param > feature-declared redact
28
29
  // (via r.secret()) > generic default. A feature that declared a
@@ -23,7 +23,6 @@ export declare const HEX_COLOR_PATTERN: RegExp;
23
23
  export declare const DEFAULT_MAIL_LAYOUT_SLUG = "default";
24
24
  export declare const DEFAULT_PDF_LAYOUT_SLUG = "default";
25
25
  export declare const MAIL_PREVIEW_MAX_BYTES: number;
26
- export declare const TEST_MAIL_NOTIFICATION_TYPE = "template-resolver:test-mail";
27
26
  export declare const TEST_MAIL_SUBJECT_PREFIX = "[TEST] ";
28
27
  export { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
29
28
  export declare const FALLBACK_LOCALE = "de";
@@ -59,7 +59,6 @@ export const DEFAULT_MAIL_LAYOUT_SLUG = "default";
59
59
  export const DEFAULT_PDF_LAYOUT_SLUG = "default";
60
60
  // The mail transport enforces no size limit of its own; 10 MiB is a common provider ceiling.
61
61
  export const MAIL_PREVIEW_MAX_BYTES = 10 * 1024 * 1024;
62
- export const TEST_MAIL_NOTIFICATION_TYPE = "template-resolver:test-mail";
63
62
  export const TEST_MAIL_SUBJECT_PREFIX = "[TEST] ";
64
63
  // System-Templates leben unter der canonical SYSTEM_TENANT_ID-Sentinel-UUID.
65
64
  // Re-Export aus framework — single source of truth, vermeidet Drift.
@@ -3,7 +3,7 @@ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/
3
3
  import * as z from "zod";
4
4
  import { collectChannels, createEmailAttachmentReader, EMAIL_CHANNEL_NAME, } from "../../delivery/index.js";
5
5
  import { fileContextForTenant } from "../../file-foundation/index.js";
6
- import { TEST_MAIL_NOTIFICATION_TYPE, TEST_MAIL_SUBJECT_PREFIX } from "../constants.js";
6
+ import { TEST_MAIL_SUBJECT_PREFIX } from "../constants.js";
7
7
  import { TemplateResolverErrors } from "../qualified-names.js";
8
8
  import { renderTemplatePreview } from "../template-preview.js";
9
9
  import { localeSchema, templateSlugSchema } from "./shared.js";
@@ -61,6 +61,7 @@ export const sendTestWrite = defineWriteHandler({
61
61
  sseBroker: undefined,
62
62
  tenantId: event.user.tenantId,
63
63
  secrets: ctx.secrets,
64
+ config: ctx.config,
64
65
  readAttachment: createEmailAttachmentReader(ctx.db, ctx.registry, event.user.tenantId, ctx.config, (tenantId) => fileContextForTenant(ctx, ctx.user.tenantId, tenantId)),
65
66
  };
66
67
  const address = (await channel.resolve?.(event.user.id, channelContext)) ?? null;
@@ -73,7 +74,8 @@ export const sendTestWrite = defineWriteHandler({
73
74
  let sentStatus;
74
75
  try {
75
76
  const result = await channel.send(address, {
76
- notificationType: TEST_MAIL_NOTIFICATION_TYPE,
77
+ // The template slug is the notification name, so the test mail takes the transport the real mail takes.
78
+ notificationType: event.payload.slug,
77
79
  title: subject,
78
80
  body: undefined,
79
81
  data: undefined,
@@ -521,15 +521,18 @@ export function createUserDataRightsFeature(opts = {}) {
521
521
  db: forgetDb,
522
522
  userId: forgetUserId,
523
523
  });
524
- // Without an app callback the default mail goes out when a mail transport and a
525
- // mail renderer are mounted. The user is already erased here, so it bypasses the
526
- // delivery log: an attempt row would be new personal data.
524
+ // Without an app callback the default mail goes out when an email channel or a
525
+ // mail transport, plus a mail renderer, are mounted. The user is already erased
526
+ // here, so it bypasses the delivery log: an attempt row would be new personal data.
527
527
  const forgetConfigResolver = ctx.configResolver;
528
528
  const sendDeletionExecutedEmail = opts.sendDeletionExecutedEmail ??
529
- (isMailTransportAvailable(forgetRegistry) && isMailRendererAvailable(forgetRegistry)
529
+ ((isGdprMailDeliveryAvailable(forgetRegistry) ||
530
+ isMailTransportAvailable(forgetRegistry)) &&
531
+ isMailRendererAvailable(forgetRegistry)
530
532
  ? makeDefaultDeletionExecutedEmail({
531
533
  registry: forgetRegistry,
532
534
  db: forgetDb,
535
+ ...(ctx.secrets !== undefined && { secrets: ctx.secrets }),
533
536
  resolveTransport: makeTenantMailTransportResolver({
534
537
  registry: forgetRegistry,
535
538
  configResolver: forgetConfigResolver,
@@ -1,6 +1,7 @@
1
1
  import type { EmailTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { ConfigAccessor, NotifyFn, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
4
5
  import { type GdprMailLocale } from "../email-templates.js";
5
6
  import type { SendDeletionRequestedEmailFn } from "../handlers/request-deletion.write.js";
6
7
  import type { SendExportFailedEmailFn, SendExportReadyEmailFn } from "../run-export-jobs.js";
@@ -19,7 +20,9 @@ export declare function makeDefaultDeletionRequestedEmail(notify: NotifyFn, defa
19
20
  export type DeletionExecutedMailDeps = {
20
21
  readonly registry: Registry;
21
22
  readonly db: DbConnection;
23
+ /** Used only when no email delivery channel is mounted. */
22
24
  readonly resolveTransport: (tenantId: string) => Promise<EmailTransport>;
23
25
  readonly configFor: (tenantId: TenantId) => ConfigAccessor | undefined;
26
+ readonly secrets?: SecretsContext;
24
27
  };
25
28
  export declare function makeDefaultDeletionExecutedEmail(deps: DeletionExecutedMailDeps, defaults?: GdprMailDefaults): SendDeletionExecutedEmailFn;
@@ -7,11 +7,11 @@
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
  import { MAIL_TRANSPORT_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
13
13
  import { hasMailTranslations, mailT } from "@cosmicdrift/kumiko-framework/i18n";
14
- import { DELIVERY_CHANNEL_EXTENSION } from "../../delivery/index.js";
14
+ import { buildChannelContext, collectChannels, DELIVERY_CHANNEL_EXTENSION, EMAIL_CHANNEL_NAME, } from "../../delivery/index.js";
15
15
  import { collectRendererPlugins, createRendererFoundationApi, } from "../../renderer-foundation/index.js";
16
16
  import { formatGdprMailTimestamp, normalizeGdprMailLocale, } from "../email-templates.js";
17
17
  import { GdprMailNotificationTypes, gdprMailTemplateLocale } from "../mail-notifications.js";
@@ -88,6 +88,9 @@ export function makeDefaultDeletionRequestedEmail(notify, defaults = {}) {
88
88
  });
89
89
  };
90
90
  }
91
+ function emailChannelOf(registry) {
92
+ return collectChannels(registry).find((channel) => channel.name === EMAIL_CHANNEL_NAME);
93
+ }
91
94
  export function makeDefaultDeletionExecutedEmail(deps, defaults = {}) {
92
95
  return async (args) => {
93
96
  // Der User ist global, der Mail-Transport per-Tenant — wir senden ueber den
@@ -121,6 +124,25 @@ export function makeDefaultDeletionExecutedEmail(deps, defaults = {}) {
121
124
  if (rendered.kind !== "mail") {
122
125
  throw new Error(`deletion-executed mail: renderer answered with kind "${rendered.kind}"`);
123
126
  }
127
+ // The channel picks the same transport as every other mail of this notification
128
+ // (the app transport unless it is declared tenantTransport), so a tenant without
129
+ // its own mail provider still gets the confirmation. channel.send writes no attempt row.
130
+ const channel = emailChannelOf(deps.registry);
131
+ if (channel !== undefined) {
132
+ const result = await channel.send(args.userEmail, {
133
+ notificationType: type,
134
+ title: rendered.subject,
135
+ body: undefined,
136
+ data: undefined,
137
+ locale,
138
+ }, buildChannelContext(deps.db, deps.registry, undefined, tenantId, deps.secrets, config), { subject: rendered.subject, html: rendered.html, text: rendered.text });
139
+ if (result.status !== "sent") {
140
+ // No address in the message: the user is already erased.
141
+ throw new Error(`deletion-executed mail not sent: ${result.error ?? result.status}`);
142
+ }
143
+ // skip: the channel delivered it, the transport fallback below is for apps without one
144
+ return;
145
+ }
124
146
  const transport = await deps.resolveTransport(tenantId);
125
147
  await transport.send({
126
148
  to: args.userEmail,
package/package.json CHANGED
@@ -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>",
@@ -528,12 +528,12 @@
528
528
  }
529
529
  },
530
530
  "dependencies": {
531
- "@cosmicdrift/kumiko-dispatcher-live": "0.368.0",
532
- "@cosmicdrift/kumiko-framework": "0.368.0",
533
- "@cosmicdrift/kumiko-headless": "0.368.0",
534
- "@cosmicdrift/kumiko-renderer": "0.368.0",
535
- "@cosmicdrift/kumiko-renderer-web": "0.368.0",
536
- "@cosmicdrift/kumiko-types": "0.368.0",
531
+ "@cosmicdrift/kumiko-dispatcher-live": "0.369.0",
532
+ "@cosmicdrift/kumiko-framework": "0.369.0",
533
+ "@cosmicdrift/kumiko-headless": "0.369.0",
534
+ "@cosmicdrift/kumiko-renderer": "0.369.0",
535
+ "@cosmicdrift/kumiko-renderer-web": "0.369.0",
536
+ "@cosmicdrift/kumiko-types": "0.369.0",
537
537
  "@mollie/api-client": "^4.5.0",
538
538
  "@node-rs/argon2": "^2.0.2",
539
539
  "@tiptap/core": "3.31.3",
@@ -1086,12 +1086,12 @@
1086
1086
  ],
1087
1087
  "devDependencies": {
1088
1088
  "@testing-library/user-event": "^14.6.1",
1089
- "@cosmicdrift/kumiko-locale-de": "0.368.0",
1090
- "@cosmicdrift/kumiko-locale-es": "0.368.0",
1091
- "@cosmicdrift/kumiko-renderer-mail-html": "0.368.0",
1089
+ "@cosmicdrift/kumiko-locale-de": "0.369.0",
1090
+ "@cosmicdrift/kumiko-locale-es": "0.369.0",
1091
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.369.0",
1092
1092
  "jsqr": "^1.4.0",
1093
1093
  "unpdf": "^1.8.1",
1094
- "@cosmicdrift/kumiko-testing": "0.368.0"
1094
+ "@cosmicdrift/kumiko-testing": "0.369.0"
1095
1095
  },
1096
1096
  "peerDependencies": {
1097
1097
  "@cosmicdrift/kumiko-testing": ">=0.366.0 <1.0.0"
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",