@cosmicdrift/kumiko-bundled-features 0.366.0 → 0.367.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 (51) hide show
  1. package/dist/channel-email/email-channel.d.ts +5 -1
  2. package/dist/channel-email/email-channel.js +8 -2
  3. package/dist/channel-email/feature.js +1 -1
  4. package/dist/delivery/channel-context.js +1 -0
  5. package/dist/delivery/delivery-service.d.ts +2 -0
  6. package/dist/delivery/delivery-service.js +30 -4
  7. package/dist/delivery/feature.js +1 -1
  8. package/dist/delivery/handlers/unsubscribe-address.write.d.ts +6 -0
  9. package/dist/delivery/handlers/unsubscribe-address.write.js +8 -5
  10. package/dist/delivery/index.d.ts +3 -0
  11. package/dist/delivery/index.js +2 -0
  12. package/dist/delivery/testing.d.ts +2 -0
  13. package/dist/delivery/types.d.ts +2 -1
  14. package/dist/delivery/unsubscribe-link.d.ts +25 -0
  15. package/dist/delivery/unsubscribe-link.js +40 -0
  16. package/dist/delivery/unsubscribe-page-strings.d.ts +11 -0
  17. package/dist/delivery/unsubscribe-page-strings.js +15 -0
  18. package/dist/delivery/unsubscribe.d.ts +3 -0
  19. package/dist/delivery/unsubscribe.js +65 -29
  20. package/dist/mail-foundation/feature.d.ts +16 -1
  21. package/dist/mail-foundation/feature.js +29 -8
  22. package/dist/mail-foundation/index.d.ts +1 -1
  23. package/dist/mail-foundation/index.js +1 -1
  24. package/dist/template-resolver/compile-document-body.js +47 -11
  25. package/dist/template-resolver/document-sanitizer.d.ts +2 -0
  26. package/dist/template-resolver/document-sanitizer.js +16 -1
  27. package/dist/template-resolver/document-schema.d.ts +2 -0
  28. package/dist/template-resolver/document-schema.js +31 -0
  29. package/dist/template-resolver/handlers/bundle-import.js +3 -2
  30. package/dist/template-resolver/handlers/copy-locale.write.js +3 -2
  31. package/dist/template-resolver/handlers/find-by-id.query.js +5 -1
  32. package/dist/template-resolver/handlers/list.query.js +3 -1
  33. package/dist/template-resolver/handlers/overview.query.js +4 -2
  34. package/dist/template-resolver/handlers/template-write.d.ts +2 -1
  35. package/dist/template-resolver/handlers/template-write.js +12 -1
  36. package/dist/template-resolver/index.d.ts +3 -2
  37. package/dist/template-resolver/index.js +3 -2
  38. package/dist/template-resolver/qualified-names.d.ts +1 -0
  39. package/dist/template-resolver/qualified-names.js +1 -0
  40. package/dist/template-resolver/seeding.d.ts +4 -0
  41. package/dist/template-resolver/seeding.js +40 -5
  42. package/dist/template-resolver/template-preview.js +2 -0
  43. package/dist/template-resolver/template-system-only.d.ts +8 -0
  44. package/dist/template-resolver/template-system-only.js +11 -0
  45. package/dist/template-resolver/template-validation.js +42 -28
  46. package/dist/template-resolver/web/i18n.js +1 -0
  47. package/package.json +10 -10
  48. package/src/channel-email/changes.json +6 -0
  49. package/src/delivery/changes.json +12 -0
  50. package/src/template-resolver/changes.json +25 -0
  51. package/src/user-data-rights/changes.json +6 -0
@@ -1,9 +1,13 @@
1
1
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
- import { type DeliveryChannel, type EmailAttachmentRef } from "../delivery/index.js";
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
5
  export type EmailChannelOptions = {
6
+ /** The app-wide transport, used for every tenant `tenantTransport` returns null for. */
6
7
  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>;
7
11
  readonly resolveEmail: (userId: string, ctx: {
8
12
  db: unknown;
9
13
  tenantId: TenantId;
@@ -122,7 +122,7 @@ async function resolveAttachments(sources, ctx) {
122
122
  return { kind: "resolved", attachments: resolved };
123
123
  }
124
124
  export function createEmailChannel(options) {
125
- const { transport, resolveEmail } = options;
125
+ const { transport, resolveEmail, tenantTransport } = options;
126
126
  // Render runs in the delivery.render job, decoupled from the SMTP send so
127
127
  // each can retry independently. Extracted so the inline path (no job runner)
128
128
  // can reuse it without going through the channel's own render() indirection.
@@ -150,6 +150,11 @@ export function createEmailChannel(options) {
150
150
  ...(attachments !== undefined && { attachments }),
151
151
  };
152
152
  }
153
+ async function transportFor(ctx) {
154
+ if (tenantTransport === undefined)
155
+ return transport;
156
+ return (await tenantTransport(ctx)) ?? transport;
157
+ }
153
158
  return {
154
159
  name: EMAIL_CHANNEL_NAME,
155
160
  mode: "queued",
@@ -184,7 +189,8 @@ export function createEmailChannel(options) {
184
189
  return { status: "failed", error: "send_failed", address };
185
190
  }
186
191
  const resolvedAttachments = resolution.attachments;
187
- await transport.send(guardEmailMessage({
192
+ const sendTransport = await transportFor(ctx);
193
+ await sendTransport.send(guardEmailMessage({
188
194
  to: address,
189
195
  subject,
190
196
  html,
@@ -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.");
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.");
8
8
  r.uiHints({
9
9
  displayLabel: "Email Channel",
10
10
  category: "notifications",
@@ -92,6 +92,7 @@ export function buildChannelContext(db, registry, sseBroker, tenantId, secrets,
92
92
  sseBroker,
93
93
  tenantId,
94
94
  secrets,
95
+ config: tenantConfig,
95
96
  renderMail: createTemplateMailRenderer(db, registry, tenantId, tenantConfig),
96
97
  ...(filesForTenant !== undefined && {
97
98
  readAttachment: createEmailAttachmentReader(db, registry, tenantId, tenantConfig, filesForTenant),
@@ -6,6 +6,7 @@ import { type Logger } from "@cosmicdrift/kumiko-framework/logging";
6
6
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
7
7
  import type { Redis } from "ioredis";
8
8
  import { type DeliveryChannel, type DeliveryService } from "./types.js";
9
+ import { type UnsubscribeLinkConfig } from "./unsubscribe-link.js";
9
10
  export declare function redactedMessageOf(err: unknown): string;
10
11
  export type RateLimitConfig = {
11
12
  readonly redis: Redis;
@@ -28,6 +29,7 @@ export type DeliveryServiceOptions = {
28
29
  readonly tenantConfigFor?: (tenantId: TenantId) => ConfigAccessor;
29
30
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
30
31
  readonly log?: Logger;
32
+ readonly unsubscribeLinks?: UnsubscribeLinkConfig;
31
33
  };
32
34
  export declare function collectChannels(registry: Registry): DeliveryChannel[];
33
35
  export declare function createDeliveryService(options: DeliveryServiceOptions): DeliveryService;
@@ -9,11 +9,12 @@ import { redactErrorText } from "../shared/index.js";
9
9
  import { hashUnsubscribeAddress } from "./address-opt-out.js";
10
10
  import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
11
11
  import { buildChannelContext } from "./channel-context.js";
12
- import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank, } from "./constants.js";
12
+ import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank, EMAIL_CHANNEL_NAME, } from "./constants.js";
13
13
  import { addressOptOutKey, selectOptedOutAddresses, } from "./db/queries/address-opt-outs.js";
14
14
  import { selectNotificationPreferences } from "./db/queries/preferences.js";
15
15
  import { notificationTypeNames } from "./notification-type-aliases.js";
16
16
  import { isDeliveryChannelPlugin, } from "./types.js";
17
+ import { buildUnsubscribeUrl, unsubscribeLinkOrigin, } from "./unsubscribe-link.js";
17
18
  export function redactedMessageOf(err) {
18
19
  return redactErrorText(err instanceof Error ? err.message : String(err));
19
20
  }
@@ -28,7 +29,7 @@ export function collectChannels(registry) {
28
29
  });
29
30
  }
30
31
  export function createDeliveryService(options) {
31
- const { db, registry, sseBroker, channels, tenantUserIdsQuery, rateLimit, isChannelKilled, idempotencyRedis, jobRunner, secrets, filesForTenant, tenantConfigFor, escapeHatchAuditSink, log, } = options;
32
+ const { db, registry, sseBroker, channels, tenantUserIdsQuery, rateLimit, isChannelKilled, idempotencyRedis, jobRunner, secrets, filesForTenant, tenantConfigFor, escapeHatchAuditSink, log, unsubscribeLinks, } = options;
32
33
  const idemRedis = idempotencyRedis ?? rateLimit?.redis;
33
34
  const logError = createFallbackLogger("delivery", log);
34
35
  // Rate limit check: atomic INCR + TTL + over-limit rollback via server-side
@@ -255,6 +256,31 @@ export function createDeliveryService(options) {
255
256
  });
256
257
  }
257
258
  }
259
+ // A caller-supplied unsubscribeUrl wins; the recipient comes from the server-side notify
260
+ // context, never from the notification data.
261
+ async function withUnsubscribeUrl(message, channelName, recipient, channelCtx) {
262
+ if (!unsubscribeLinks || channelName !== EMAIL_CHANNEL_NAME)
263
+ return message;
264
+ if (registry.getAllNotifications().get(message.notificationType)?.unsubscribe !== true) {
265
+ return message;
266
+ }
267
+ const supplied = message.data?.["unsubscribeUrl"];
268
+ if (typeof supplied === "string" && supplied !== "")
269
+ return message;
270
+ const origin = await unsubscribeLinkOrigin(unsubscribeLinks, channelCtx, (reason) => logError.warn(`unsubscribe link falls back to the app origin: ${reason}`, {
271
+ tenantId: channelCtx.tenantId,
272
+ notificationType: message.notificationType,
273
+ }));
274
+ const unsubscribeUrl = await buildUnsubscribeUrl(unsubscribeLinks, origin, recipient, {
275
+ tenantId: channelCtx.tenantId,
276
+ notificationType: message.notificationType,
277
+ channel: channelName,
278
+ locale: message.locale,
279
+ });
280
+ return unsubscribeUrl === undefined
281
+ ? message
282
+ : { ...message, data: { ...message.data, unsubscribeUrl } };
283
+ }
258
284
  function buildMessage(notificationType, data, channelName, locale) {
259
285
  // Look up per-channel template from notification definition
260
286
  const notifDef = registry.getAllNotifications().get(notificationType);
@@ -435,7 +461,7 @@ export function createDeliveryService(options) {
435
461
  run,
436
462
  channel,
437
463
  address,
438
- message,
464
+ message: await withUnsubscribeUrl(message, channel.name, { kind: "user", userId }, channelCtx),
439
465
  channelCtx,
440
466
  tenantId,
441
467
  recipientId: userId,
@@ -508,7 +534,7 @@ export function createDeliveryService(options) {
508
534
  run,
509
535
  channel,
510
536
  address,
511
- message,
537
+ message: await withUnsubscribeUrl(message, channel.name, { kind: "address", address }, channelCtx),
512
538
  channelCtx,
513
539
  tenantId,
514
540
  recipientId,
@@ -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.');
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.");
23
23
  r.uiHints({
24
24
  displayLabel: "Notifications \u00b7 Dispatch Core",
25
25
  category: "notifications",
@@ -1,4 +1,10 @@
1
1
  import * as z from "zod";
2
+ export declare const unsubscribeAddressPayloadSchema: z.ZodObject<{
3
+ addressHash: z.ZodString;
4
+ notificationType: z.ZodString;
5
+ channel: z.ZodString;
6
+ }, z.core.$strip>;
7
+ export type UnsubscribeAddressPayload = z.infer<typeof unsubscribeAddressPayloadSchema>;
2
8
  export declare const unsubscribeAddressWrite: import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"unsubscribeAddress", z.ZodObject<{
3
9
  addressHash: z.ZodString;
4
10
  notificationType: z.ZodString;
@@ -3,13 +3,16 @@ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
4
  import { upsertAddressOptOut } from "../address-opt-out.js";
5
5
  import { currentNotificationType } from "../notification-type-aliases.js";
6
+ // Also the payload an app job receives with `trigger: { on: DeliveryHandlers.unsubscribeAddress }`
7
+ // after a committed address opt-out (the tenant is the job's `ctx.triggeredBy.tenantId`).
8
+ export const unsubscribeAddressPayloadSchema = z.object({
9
+ addressHash: z.string().min(1),
10
+ notificationType: z.string().min(1),
11
+ channel: z.string().min(1),
12
+ });
6
13
  export const unsubscribeAddressWrite = defineWriteHandler({
7
14
  name: "unsubscribeAddress",
8
- schema: z.object({
9
- addressHash: z.string().min(1),
10
- notificationType: z.string().min(1),
11
- channel: z.string().min(1),
12
- }),
15
+ schema: unsubscribeAddressPayloadSchema,
13
16
  access: { roles: access.systemAdmin },
14
17
  // Only reachable via createUnsubscribeRoutes' POST dispatchSystemWrite, once
15
18
  // verify() has already proven the address-token signature — not a surface
@@ -9,8 +9,11 @@ export type { DeliveryStatusValue } from "./constants.js";
9
9
  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";
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
+ export { type UnsubscribeAddressPayload, unsubscribeAddressPayloadSchema, } from "./handlers/unsubscribe-address.write.js";
12
13
  export { createDeliveryNotifyFactory } from "./notify-factory.js";
13
14
  export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.js";
14
15
  export { type CreateDeliveryTestContextOptions, createDeliveryTestContext } from "./testing.js";
15
16
  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";
16
17
  export { type AddressUnsubscribeTokenPayload, createUnsubscribeRoutes, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, signAddressUnsubscribeToken, signUnsubscribeToken, type UnsubscribeRouteOptions, type UnsubscribeTokenPayload, } from "./unsubscribe.js";
18
+ export type { UnsubscribeLinkConfig } from "./unsubscribe-link.js";
19
+ export { UNSUBSCRIBE_PAGE_EN } from "./unsubscribe-page-strings.js";
@@ -8,8 +8,10 @@ export { chatConnectionNameSchema, chatWebhookUrlSchema, checkChatWebhookTarget,
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
+ export { unsubscribeAddressPayloadSchema, } from "./handlers/unsubscribe-address.write.js";
11
12
  export { createDeliveryNotifyFactory } from "./notify-factory.js";
12
13
  export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.js";
13
14
  export { createDeliveryTestContext } from "./testing.js";
14
15
  export { isDeliveryChannelPlugin, } from "./types.js";
15
16
  export { createUnsubscribeRoutes, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, signAddressUnsubscribeToken, signUnsubscribeToken, } from "./unsubscribe.js";
17
+ export { UNSUBSCRIBE_PAGE_EN } from "./unsubscribe-page-strings.js";
@@ -8,6 +8,7 @@ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
8
8
  import type { Redis } from "ioredis";
9
9
  import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service.js";
10
10
  import type { DeliveryService } from "./types.js";
11
+ import type { UnsubscribeLinkConfig } from "./unsubscribe-link.js";
11
12
  export type CreateDeliveryTestContextOptions = {
12
13
  readonly tenantUserIdsQuery?: string;
13
14
  readonly rateLimit?: RateLimitConfig;
@@ -19,6 +20,7 @@ export type CreateDeliveryTestContextOptions = {
19
20
  readonly idempotencyRedis?: Redis;
20
21
  readonly filesForTenant?: (tenantId: string) => FileContext | undefined;
21
22
  readonly tenantConfigFor?: (tenantId: TenantId) => ConfigAccessor;
23
+ readonly unsubscribeLinks?: UnsubscribeLinkConfig;
22
24
  };
23
25
  /**
24
26
  * Helper for setupTestStack: creates a DeliveryService + _notifyFactory for the test context.
@@ -1,6 +1,6 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
- import type { DeliveryErrorCode, NotifyDeliveryStatus, NotifyDispatched, NotifyJobDispatcher, NotifyOptions, NotifyPriority, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { ConfigAccessor, DeliveryErrorCode, NotifyDeliveryStatus, NotifyDispatched, NotifyJobDispatcher, NotifyOptions, NotifyPriority, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
5
5
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
6
6
  import type { MailPayload, RenderedTemplateRef } from "../renderer-foundation/index.js";
@@ -23,6 +23,7 @@ export type ChannelContext = {
23
23
  readonly sseBroker: SseBroker | undefined;
24
24
  readonly tenantId: TenantId;
25
25
  readonly secrets?: SecretsContext | undefined;
26
+ readonly config?: ConfigAccessor | undefined;
26
27
  readonly renderMail?: (payload: MailPayload) => Promise<RenderedMail>;
27
28
  readonly readAttachment?: (ref: EmailAttachmentRef, origin?: AttachmentOrigin) => Promise<{
28
29
  readonly content: Uint8Array;
@@ -0,0 +1,25 @@
1
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { ChannelContext } from "./types.js";
3
+ /** Secret shared with `createUnsubscribeRoutes` plus the public origin the links point at. */
4
+ export type UnsubscribeLinkConfig = {
5
+ readonly secret: string;
6
+ readonly baseUrl: string;
7
+ /** The sending tenant's own public origin (subdomain or custom domain), null when it has
8
+ * none. The host must serve this app's `/api` routes. Null, a throw or an unusable value
9
+ * fall back to `baseUrl`, so a missing tenant origin never fails a send. */
10
+ readonly tenantOrigin?: (ctx: ChannelContext) => Promise<string | null | undefined>;
11
+ };
12
+ export type UnsubscribeLinkRecipient = {
13
+ readonly kind: "user";
14
+ readonly userId: string;
15
+ } | {
16
+ readonly kind: "address";
17
+ readonly address: string;
18
+ };
19
+ export declare function unsubscribeLinkOrigin(config: UnsubscribeLinkConfig, ctx: ChannelContext, onFallback: (reason: string) => void): Promise<string>;
20
+ export declare function buildUnsubscribeUrl(config: UnsubscribeLinkConfig, origin: string, recipient: UnsubscribeLinkRecipient, scope: {
21
+ tenantId: TenantId;
22
+ notificationType: string;
23
+ channel: string;
24
+ locale: string | undefined;
25
+ }): Promise<string | undefined>;
@@ -0,0 +1,40 @@
1
+ import { publicOriginOf } from "@cosmicdrift/kumiko-framework/api";
2
+ import { hashUnsubscribeAddress } from "./address-opt-out.js";
3
+ import { DELIVERY_UNSUBSCRIBE_PATH } from "./constants.js";
4
+ import { signAddressUnsubscribeToken, signUnsubscribeToken } from "./unsubscribe.js";
5
+ // https only, unless the app itself runs on plain http (local dev): a tenant origin must not
6
+ // downgrade the link that carries the token.
7
+ function isUsableTenantOrigin(origin, baseUrl) {
8
+ return (origin.startsWith("https://") || (baseUrl.startsWith("http://") && origin.startsWith("http://")));
9
+ }
10
+ export async function unsubscribeLinkOrigin(config, ctx, onFallback) {
11
+ if (config.tenantOrigin === undefined)
12
+ return config.baseUrl;
13
+ let resolved;
14
+ try {
15
+ resolved = await config.tenantOrigin(ctx);
16
+ }
17
+ catch (err) {
18
+ onFallback(`tenantOrigin threw (${err instanceof Error ? err.name : typeof err})`);
19
+ return config.baseUrl;
20
+ }
21
+ if (resolved === null || resolved === undefined)
22
+ return config.baseUrl;
23
+ const origin = publicOriginOf(resolved);
24
+ if (origin === undefined || !isUsableTenantOrigin(origin, config.baseUrl)) {
25
+ onFallback("tenantOrigin returned no usable https origin");
26
+ return config.baseUrl;
27
+ }
28
+ return origin;
29
+ }
30
+ // Undefined means "no link": without a blind-index key an address opt-out could never be
31
+ // matched again, and a mail without a link is better than a failed send.
32
+ export async function buildUnsubscribeUrl(config, origin, recipient, scope) {
33
+ if (recipient.kind === "address" && hashUnsubscribeAddress(recipient.address) === undefined) {
34
+ return undefined;
35
+ }
36
+ const token = recipient.kind === "user"
37
+ ? await signUnsubscribeToken({ ...scope, userId: recipient.userId }, config.secret)
38
+ : await signAddressUnsubscribeToken({ ...scope, address: recipient.address }, config.secret);
39
+ return `${origin}${DELIVERY_UNSUBSCRIBE_PATH}?token=${encodeURIComponent(token)}`;
40
+ }
@@ -0,0 +1,11 @@
1
+ export declare const UNSUBSCRIBE_PAGE_EN: {
2
+ readonly "mail.unsubscribe.confirmTitle": "Unsubscribe";
3
+ readonly "mail.unsubscribe.confirmQuestion": "Unsubscribe from these emails?";
4
+ readonly "mail.unsubscribe.confirmButton": "Unsubscribe";
5
+ readonly "mail.unsubscribe.doneTitle": "Unsubscribed";
6
+ readonly "mail.unsubscribe.doneMessage": "You have been unsubscribed.";
7
+ readonly "mail.unsubscribe.undoButton": "Undo";
8
+ readonly "mail.unsubscribe.resubscribedTitle": "Subscribed again";
9
+ readonly "mail.unsubscribe.resubscribedMessage": "You have been subscribed again.";
10
+ readonly "mail.unsubscribe.failed": "That did not work. Please try again later.";
11
+ };
@@ -0,0 +1,15 @@
1
+ import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
2
+ // The pages behind a mailed unsubscribe link speak the mail's language, so their texts live in
3
+ // the mail translations: English here, other languages in the locale packages.
4
+ export const UNSUBSCRIBE_PAGE_EN = {
5
+ "mail.unsubscribe.confirmTitle": "Unsubscribe",
6
+ "mail.unsubscribe.confirmQuestion": "Unsubscribe from these emails?",
7
+ "mail.unsubscribe.confirmButton": "Unsubscribe",
8
+ "mail.unsubscribe.doneTitle": "Unsubscribed",
9
+ "mail.unsubscribe.doneMessage": "You have been unsubscribed.",
10
+ "mail.unsubscribe.undoButton": "Undo",
11
+ "mail.unsubscribe.resubscribedTitle": "Subscribed again",
12
+ "mail.unsubscribe.resubscribedMessage": "You have been subscribed again.",
13
+ "mail.unsubscribe.failed": "That did not work. Please try again later.",
14
+ };
15
+ registerMailTranslations("en", UNSUBSCRIBE_PAGE_EN);
@@ -1,16 +1,19 @@
1
1
  import { type ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import "./unsubscribe-page-strings.js";
3
4
  export type UnsubscribeTokenPayload = {
4
5
  readonly userId: string;
5
6
  readonly tenantId: TenantId;
6
7
  readonly notificationType: string;
7
8
  readonly channel: string;
9
+ readonly locale?: string | undefined;
8
10
  };
9
11
  export type AddressUnsubscribeTokenPayload = {
10
12
  readonly tenantId: TenantId;
11
13
  readonly address: string;
12
14
  readonly notificationType: string;
13
15
  readonly channel: string;
16
+ readonly locale?: string | undefined;
14
17
  };
15
18
  /**
16
19
  * `secret` must be a value dedicated to unsubscribe-token signing — do NOT
@@ -1,10 +1,18 @@
1
1
  import { ExtraRouteRejection, signatureRoute, } from "@cosmicdrift/kumiko-framework/api";
2
+ import { mailT, resolveMailLocale } from "@cosmicdrift/kumiko-framework/i18n";
2
3
  import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
3
4
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
4
5
  import * as jose from "jose";
5
6
  import * as z from "zod";
6
7
  import { hashUnsubscribeAddress } from "./address-opt-out.js";
7
8
  import { DELIVERY_RESUBSCRIBE_PATH, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, } from "./constants.js";
9
+ import "./unsubscribe-page-strings.js";
10
+ // The mail's locale, so the pages behind the link speak the mail's language. Optional: links
11
+ // signed before the claim existed keep verifying and fall back to English.
12
+ const localeClaimSchema = z
13
+ .string()
14
+ .regex(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$/)
15
+ .optional();
8
16
  // Shape des verified-JWT-payloads. tenantId kommt als string aus jose und
9
17
  // wird NACH erfolgreichem parse() zur Branded TenantId — kein blind-cast.
10
18
  //
@@ -19,6 +27,7 @@ const unsubscribeJwtPayloadSchema = z.object({
19
27
  tenantId: z.string().min(1),
20
28
  notificationType: z.string().min(1),
21
29
  channel: z.string().min(1),
30
+ locale: localeClaimSchema,
22
31
  });
23
32
  // Address-token payload. `sub` and `addressHash` are the same value (the
24
33
  // subject claim IS the blind-index hash) — kept as two fields because the
@@ -31,6 +40,7 @@ const addressUnsubscribeJwtPayloadSchema = z.object({
31
40
  notificationType: z.string().min(1),
32
41
  channel: z.string().min(1),
33
42
  addressHash: z.string().min(1),
43
+ locale: localeClaimSchema,
34
44
  });
35
45
  const UNSUBSCRIBE_EXPIRY = "7d";
36
46
  const RESUBSCRIBE_EXPIRY = "1h";
@@ -42,6 +52,10 @@ function assertUnsubscribeSecret(secret, caller) {
42
52
  throw new Error(`${caller}: secret must be ≥${MIN_UNSUBSCRIBE_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`);
43
53
  }
44
54
  }
55
+ // An unusable locale is left out instead of failing the send; the pages then fall back to English.
56
+ function localeClaim(locale) {
57
+ return locale !== undefined && localeClaimSchema.safeParse(locale).success ? { locale } : {};
58
+ }
45
59
  const UNSUBSCRIBE_TOKEN_INVALID_BODY = {
46
60
  error: { code: "unsubscribe_token_invalid", message: "Invalid or expired token" },
47
61
  };
@@ -52,6 +66,7 @@ export async function signUnsubscribeToken(payload, secret) {
52
66
  tenantId: payload.tenantId,
53
67
  notificationType: payload.notificationType,
54
68
  channel: payload.channel,
69
+ ...localeClaim(payload.locale),
55
70
  })
56
71
  .setProtectedHeader({ alg: "HS256" })
57
72
  .setSubject(String(payload.userId))
@@ -80,6 +95,7 @@ export async function signAddressUnsubscribeToken(payload, secret) {
80
95
  channel: payload.channel,
81
96
  addressHash,
82
97
  kind: "address",
98
+ ...localeClaim(payload.locale),
83
99
  })
84
100
  .setProtectedHeader({ alg: "HS256" })
85
101
  .setSubject(addressHash)
@@ -104,6 +120,7 @@ async function signResubscribeToken(verified, encodedSecret) {
104
120
  tenantId: verified.tenantId,
105
121
  notificationType: verified.notificationType,
106
122
  channel: verified.channel,
123
+ ...localeClaim(verified.locale),
107
124
  };
108
125
  const jwt = verified.kind === "address"
109
126
  ? new jose.SignJWT({
@@ -154,6 +171,7 @@ async function verifyUnsubscribeToken(token, encodedSecret, purpose) {
154
171
  addressHash: parsed.addressHash,
155
172
  notificationType: parsed.notificationType,
156
173
  channel: parsed.channel,
174
+ locale: parsed.locale,
157
175
  };
158
176
  }
159
177
  const userAttempt = unsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
@@ -169,6 +187,7 @@ async function verifyUnsubscribeToken(token, encodedSecret, purpose) {
169
187
  userId: parsed.sub,
170
188
  notificationType: parsed.notificationType,
171
189
  channel: parsed.channel,
190
+ locale: parsed.locale,
172
191
  };
173
192
  }
174
193
  catch (err) {
@@ -228,39 +247,50 @@ async function dispatchResubscribeWrite(verified, deps) {
228
247
  return "success";
229
248
  return isResubscribeLimitReached(dispatched.error) ? "limit_reached" : "failed";
230
249
  }
231
- function confirmationPage(token) {
250
+ function pageLocale(locale) {
251
+ return resolveMailLocale(locale ?? "en");
252
+ }
253
+ function htmlPage(locale, title, bodyHtml) {
232
254
  return `<!doctype html>
233
- <html>
255
+ <html lang="${escapeHtmlAttr(locale)}">
234
256
  <head>
235
257
  <meta charset="utf-8">
236
258
  <meta name="robots" content="noindex">
237
- <title>Unsubscribe</title>
259
+ <meta name="viewport" content="width=device-width, initial-scale=1">
260
+ <title>${escapeHtmlAttr(title)}</title>
238
261
  </head>
239
262
  <body>
240
- <p>Unsubscribe from these emails?</p>
241
- <form method="post" action="${DELIVERY_UNSUBSCRIBE_PATH}">
242
- <input type="hidden" name="token" value="${escapeHtmlAttr(token)}">
243
- <button type="submit">Unsubscribe</button>
244
- </form>
263
+ ${bodyHtml}
245
264
  </body>
246
265
  </html>`;
247
266
  }
248
- function unsubscribedPage(resubscribeToken) {
249
- return `<!doctype html>
250
- <html>
251
- <head>
252
- <meta charset="utf-8">
253
- <meta name="robots" content="noindex">
254
- <title>Unsubscribed</title>
255
- </head>
256
- <body>
257
- <p>You have been unsubscribed.</p>
267
+ function confirmationPage(token, locale) {
268
+ const lang = pageLocale(locale);
269
+ return htmlPage(lang, mailT(lang, "mail.unsubscribe.confirmTitle"), `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.confirmQuestion"))}</p>
270
+ <form method="post" action="${DELIVERY_UNSUBSCRIBE_PATH}">
271
+ <input type="hidden" name="token" value="${escapeHtmlAttr(token)}">
272
+ <button type="submit">${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.confirmButton"))}</button>
273
+ </form>`);
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>
258
278
  <form method="post" action="${DELIVERY_RESUBSCRIBE_PATH}">
259
279
  <input type="hidden" name="token" value="${escapeHtmlAttr(resubscribeToken)}">
260
- <button type="submit">Undo</button>
261
- </form>
262
- </body>
263
- </html>`;
280
+ <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>`);
291
+ }
292
+ function isFormRequest(contentType) {
293
+ return contentType?.includes("application/x-www-form-urlencoded") === true;
264
294
  }
265
295
  // One-click clients put the token in the query string; only the
266
296
  // confirmation-page form submits it in the body.
@@ -304,10 +334,10 @@ export function createUnsubscribeRoutes(options) {
304
334
  if (!token) {
305
335
  throw new ExtraRouteRejection(400, UNSUBSCRIBE_TOKEN_INVALID_BODY, "missing unsubscribe token");
306
336
  }
307
- await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
308
- return { token };
337
+ const verified = await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
338
+ return { token, locale: verified.locale };
309
339
  },
310
- handler: async (c, { token }) => c.html(confirmationPage(token), 200, CONFIRMATION_PAGE_HEADERS),
340
+ handler: async (c, { token, locale }) => c.html(confirmationPage(token, locale), 200, CONFIRMATION_PAGE_HEADERS),
311
341
  });
312
342
  const writeRoute = signatureRoute({
313
343
  method: "POST",
@@ -320,11 +350,9 @@ export function createUnsubscribeRoutes(options) {
320
350
  // (e.g. events-table missing, DB down) behind a misleading 400.
321
351
  const succeeded = await dispatchUnsubscribeWrite(verified, deps);
322
352
  if (!succeeded) {
323
- return c.html("Unsubscribe failed", 500, { "Cache-Control": "no-store" });
353
+ return c.html(failedPage(verified.locale), 500, { "Cache-Control": "no-store" });
324
354
  }
325
- return c.html(unsubscribedPage(await signResubscribeToken(verified, encodedSecret)), 200, {
326
- "Cache-Control": "no-store",
327
- });
355
+ return c.html(unsubscribedPage(await signResubscribeToken(verified, encodedSecret), verified.locale), 200, { "Cache-Control": "no-store" });
328
356
  },
329
357
  });
330
358
  // Undo direction: token only from the JSON/form body, never the query
@@ -337,6 +365,14 @@ export function createUnsubscribeRoutes(options) {
337
365
  verify: async (request) => verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret, "resubscribe"),
338
366
  handler: async (c, verified, deps) => {
339
367
  const outcome = await dispatchResubscribeWrite(verified, deps);
368
+ // The undo button on the unsubscribed page posts a form; a browser should land on a page.
369
+ if (isFormRequest(c.req.header("content-type"))) {
370
+ 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
+ });
375
+ }
340
376
  if (outcome === "limit_reached") {
341
377
  return c.json({ error: { code: DeliveryErrors.resubscribeLimitReached } }, 409, {
342
378
  "Cache-Control": "no-store",
@@ -1,5 +1,5 @@
1
1
  import { type EmailTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";
2
- import { type ConfigAccessor, type Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type ConfigAccessor, type Registry, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
3
  export declare const MAIL_TRANSPORT_EXTENSION: "mailTransport";
4
4
  /**
5
5
  * Schmaler Surface-Type fuer Transport-Plugins — gespiegelt von
@@ -69,3 +69,18 @@ export declare const mailFoundationFeature: import("@cosmicdrift/kumiko-types/fe
69
69
  * await transport.send({ to, subject, html });
70
70
  */
71
71
  export declare function createTransportForTenant(ctx: MailTransportContext, tenantId: string, handlerName?: string): Promise<EmailTransport>;
72
+ /**
73
+ * Like createTransportForTenant, but an empty `provider` key means "this tenant has no
74
+ * mail provider" and yields null. Every other problem (unknown provider, plugin build
75
+ * failure) still throws, so a configured tenant never silently falls back.
76
+ */
77
+ export declare function createTransportForTenantIfConfigured(ctx: MailTransportContext, tenantId: string, handlerName?: string): Promise<EmailTransport | null>;
78
+ export type TenantMailTransportScope = {
79
+ readonly tenantId: TenantId;
80
+ readonly config?: ConfigAccessor | undefined;
81
+ readonly registry: Registry;
82
+ readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext | undefined;
83
+ };
84
+ /** The `tenantTransport` resolver for `createChannelEmailFeature`: the tenant's configured
85
+ * provider, or null so the channel falls back to its app transport. */
86
+ export declare function tenantMailTransport(scope: TenantMailTransportScope): Promise<EmailTransport | null>;