@cosmicdrift/kumiko-bundled-features 0.365.1 → 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 (148) hide show
  1. package/dist/admin-shell/feature.js +9 -9
  2. package/dist/agent-tools/tool-dispatch.d.ts +9 -1
  3. package/dist/agent-tools/tool-dispatch.js +55 -5
  4. package/dist/audit/feature.js +1 -1
  5. package/dist/auth-email-password/constants.d.ts +0 -1
  6. package/dist/auth-email-password/constants.js +0 -2
  7. package/dist/auth-email-password/feature.js +3 -3
  8. package/dist/auth-email-password/handlers/invite-create.write.js +1 -0
  9. package/dist/auth-mfa/feature.js +4 -4
  10. package/dist/auth-mfa-user-data/index.js +1 -1
  11. package/dist/billing-foundation/consumer-protection/register-consent.js +1 -1
  12. package/dist/billing-foundation/feature.js +1 -1
  13. package/dist/cap-counter/cap-reservation.js +73 -25
  14. package/dist/cap-counter/constants.d.ts +1 -0
  15. package/dist/cap-counter/constants.js +2 -0
  16. package/dist/cap-counter/feature.js +2 -2
  17. package/dist/cap-counter/tables.js +5 -2
  18. package/dist/cap-overview/feature.js +3 -3
  19. package/dist/channel-discord/feature.js +2 -2
  20. package/dist/channel-email/email-channel.d.ts +5 -1
  21. package/dist/channel-email/email-channel.js +8 -2
  22. package/dist/channel-email/feature.js +2 -2
  23. package/dist/channel-in-app/feature.js +1 -1
  24. package/dist/channel-push/feature.js +1 -1
  25. package/dist/channel-slack/feature.js +2 -2
  26. package/dist/channel-teams/feature.js +2 -2
  27. package/dist/channel-telegram/feature.js +2 -2
  28. package/dist/compliance-profiles/feature.js +1 -1
  29. package/dist/compliance-profiles-ops/index.js +3 -3
  30. package/dist/config/feature.js +1 -1
  31. package/dist/custom-fields/feature.js +2 -2
  32. package/dist/custom-fields/wire-for-entity.js +17 -13
  33. package/dist/data-retention/feature.js +1 -1
  34. package/dist/data-retention/presets.d.ts +4 -4
  35. package/dist/delivery/channel-context.js +1 -0
  36. package/dist/delivery/db/queries/address-opt-outs.d.ts +1 -1
  37. package/dist/delivery/db/queries/address-opt-outs.js +6 -2
  38. package/dist/delivery/db/queries/preferences.d.ts +1 -1
  39. package/dist/delivery/db/queries/preferences.js +4 -4
  40. package/dist/delivery/delivery-service.d.ts +2 -0
  41. package/dist/delivery/delivery-service.js +39 -6
  42. package/dist/delivery/feature.js +10 -6
  43. package/dist/delivery/handlers/resubscribe-address.write.js +13 -9
  44. package/dist/delivery/handlers/resubscribe-user.write.js +3 -1
  45. package/dist/delivery/handlers/set-preference.write.js +3 -1
  46. package/dist/delivery/handlers/unsubscribe-address.write.d.ts +6 -0
  47. package/dist/delivery/handlers/unsubscribe-address.write.js +11 -6
  48. package/dist/delivery/handlers/unsubscribe-user.write.js +3 -1
  49. package/dist/delivery/index.d.ts +3 -0
  50. package/dist/delivery/index.js +2 -0
  51. package/dist/delivery/notification-type-aliases.d.ts +3 -0
  52. package/dist/delivery/notification-type-aliases.js +11 -0
  53. package/dist/delivery/testing.d.ts +2 -0
  54. package/dist/delivery/types.d.ts +2 -1
  55. package/dist/delivery/unsubscribe-link.d.ts +25 -0
  56. package/dist/delivery/unsubscribe-link.js +40 -0
  57. package/dist/delivery/unsubscribe-page-strings.d.ts +11 -0
  58. package/dist/delivery/unsubscribe-page-strings.js +15 -0
  59. package/dist/delivery/unsubscribe.d.ts +3 -0
  60. package/dist/delivery/unsubscribe.js +65 -29
  61. package/dist/derivatives-sharp/feature.js +1 -1
  62. package/dist/document-ingest-foundation/feature.js +2 -2
  63. package/dist/file-derivatives/feature.js +3 -3
  64. package/dist/file-foundation/feature.js +1 -1
  65. package/dist/file-provider-inmemory/feature.js +2 -2
  66. package/dist/file-provider-s3/feature.js +3 -3
  67. package/dist/file-provider-s3-env/feature.js +2 -2
  68. package/dist/files-tenant-data/index.js +1 -1
  69. package/dist/folders/feature.js +2 -2
  70. package/dist/folders-user-data/index.js +1 -1
  71. package/dist/form-draft/feature.js +2 -2
  72. package/dist/form-draft-user-data/index.js +1 -1
  73. package/dist/inbound-mail-foundation/connect-routes.d.ts +3 -0
  74. package/dist/inbound-mail-foundation/connect-routes.js +6 -1
  75. package/dist/inbound-mail-foundation/feature.js +1 -1
  76. package/dist/inbound-mail-foundation/types.d.ts +17 -7
  77. package/dist/inbound-mail-foundation/watch-supervisor.js +16 -2
  78. package/dist/inbound-provider-imap/feature.js +2 -2
  79. package/dist/inbound-provider-inmemory/feature.js +1 -1
  80. package/dist/ledger/feature.js +5 -5
  81. package/dist/legal-pages/feature.js +1 -1
  82. package/dist/mail-foundation/feature.d.ts +16 -1
  83. package/dist/mail-foundation/feature.js +30 -9
  84. package/dist/mail-foundation/index.d.ts +1 -1
  85. package/dist/mail-foundation/index.js +1 -1
  86. package/dist/mail-transport-inmemory/feature.js +2 -2
  87. package/dist/mail-transport-smtp/feature.js +3 -3
  88. package/dist/managed-pages/css-gate.js +1 -1
  89. package/dist/managed-pages/feature.js +2 -2
  90. package/dist/metrics/feature.js +2 -2
  91. package/dist/notes-history/feature.js +2 -2
  92. package/dist/notes-history-user-data/index.js +1 -1
  93. package/dist/personal-access-tokens/feature.js +17 -13
  94. package/dist/readiness/feature.js +2 -2
  95. package/dist/renderer-foundation/feature.js +1 -1
  96. package/dist/renderer-pdf/feature.js +1 -1
  97. package/dist/secrets/feature.js +1 -1
  98. package/dist/seo/feature.js +1 -1
  99. package/dist/sessions/feature.js +20 -16
  100. package/dist/step-dispatcher/feature.js +1 -1
  101. package/dist/subscription-mollie/feature.js +1 -1
  102. package/dist/subscription-stripe/feature.js +3 -3
  103. package/dist/tags/feature.js +2 -2
  104. package/dist/template-resolver/compile-document-body.js +47 -11
  105. package/dist/template-resolver/document-sanitizer.d.ts +2 -0
  106. package/dist/template-resolver/document-sanitizer.js +16 -1
  107. package/dist/template-resolver/document-schema.d.ts +2 -0
  108. package/dist/template-resolver/document-schema.js +31 -0
  109. package/dist/template-resolver/feature.js +2 -2
  110. package/dist/template-resolver/handlers/bundle-import.js +3 -2
  111. package/dist/template-resolver/handlers/copy-locale.write.js +3 -2
  112. package/dist/template-resolver/handlers/find-by-id.query.js +5 -1
  113. package/dist/template-resolver/handlers/list.query.js +3 -1
  114. package/dist/template-resolver/handlers/overview.query.js +4 -2
  115. package/dist/template-resolver/handlers/template-write.d.ts +2 -1
  116. package/dist/template-resolver/handlers/template-write.js +12 -1
  117. package/dist/template-resolver/index.d.ts +3 -2
  118. package/dist/template-resolver/index.js +3 -2
  119. package/dist/template-resolver/qualified-names.d.ts +1 -0
  120. package/dist/template-resolver/qualified-names.js +1 -0
  121. package/dist/template-resolver/seeding.d.ts +4 -0
  122. package/dist/template-resolver/seeding.js +40 -5
  123. package/dist/template-resolver/template-preview.js +2 -0
  124. package/dist/template-resolver/template-system-only.d.ts +8 -0
  125. package/dist/template-resolver/template-system-only.js +11 -0
  126. package/dist/template-resolver/template-validation.js +42 -28
  127. package/dist/template-resolver/web/i18n.js +1 -0
  128. package/dist/template-resolver-user-data/index.js +1 -1
  129. package/dist/tenant/feature.js +4 -4
  130. package/dist/tenant/seeding.js +1 -1
  131. package/dist/tenant-lifecycle/feature.js +2 -3
  132. package/dist/tenant-settings/feature.js +1 -1
  133. package/dist/tier-engine/feature.js +86 -72
  134. package/dist/user/feature.js +1 -1
  135. package/dist/user-data-rights/feature.js +4 -4
  136. package/dist/user-data-rights-defaults/feature.js +2 -2
  137. package/dist/user-profile/feature.js +4 -4
  138. package/dist/waitlist/feature.js +2 -2
  139. package/package.json +10 -10
  140. package/src/agent-tools/changes.json +6 -0
  141. package/src/auth-email-password/changes.json +13 -0
  142. package/src/cap-counter/changes.json +7 -0
  143. package/src/channel-email/changes.json +6 -0
  144. package/src/crypto-shredding/changes.json +6 -0
  145. package/src/delivery/changes.json +18 -0
  146. package/src/inbound-mail-foundation/changes.json +13 -0
  147. package/src/template-resolver/changes.json +25 -0
  148. package/src/user-data-rights/changes.json +6 -0
@@ -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",
@@ -9,7 +9,7 @@ export const derivativesSharpFeature = defineFeature(FEATURE_NAME, (r) => {
9
9
  category: "storage",
10
10
  recommended: false,
11
11
  });
12
- r.requires("file-derivatives");
12
+ r.requires({ features: ["file-derivatives"] });
13
13
  const plugin = { render: renderImage };
14
14
  r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", plugin);
15
15
  });
@@ -90,8 +90,8 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
90
90
  });
91
91
  // tenant-lifecycle hosts EXT_TENANT_DATA — documentExtract.pages is
92
92
  // tenant-subject ciphertext and needs the destroy hook below.
93
- r.requires("config", "tenant-lifecycle");
94
- r.entity("documentExtract", documentExtractEntity);
93
+ r.requires({ features: ["config", "tenant-lifecycle"] });
94
+ r.entity({ name: "documentExtract", ...documentExtractEntity });
95
95
  r.useExtension(EXT_TENANT_DATA, "documentExtract", {
96
96
  destroy: documentExtractTenantDestroyHook,
97
97
  });
@@ -53,12 +53,12 @@ export function createFileDerivativesFeature(opts = {}) {
53
53
  });
54
54
  // Needs a storage provider to read the original + write the variant —
55
55
  // without file-foundation there's nothing to derive from or write to.
56
- r.requires("file-foundation");
56
+ r.requires({ features: ["file-foundation"] });
57
57
  // variant() reads fileRefsTable, which "files" (not file-foundation)
58
58
  // owns — without this, mounting file-derivatives + file-foundation
59
59
  // alone boots fine but fails at request-time with a SQL error the first
60
60
  // time variant() runs.
61
- r.requires("files");
61
+ r.requires({ features: ["files"] });
62
62
  r.extendsRegistrar(EXT_DERIVATIVE_RENDERER, {
63
63
  onRegister: () => {
64
64
  // No side-effects at register-time — resolution (exact MIME match,
@@ -103,7 +103,7 @@ export function createFileDerivativesFeature(opts = {}) {
103
103
  // tenant's public variants from staying reachable — require the
104
104
  // dependency so a missing mount fails boot, not the tenant check
105
105
  // silently no-op-ing at request time.
106
- r.requires("tenant");
106
+ r.requires({ features: ["tenant"] });
107
107
  r.queryHandler(publicVariantByFileRefQuery);
108
108
  }
109
109
  r.httpRoute({
@@ -27,7 +27,7 @@ export const fileFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
27
27
  category: "storage",
28
28
  recommended: false,
29
29
  });
30
- r.requires("config");
30
+ r.requires({ features: ["config"] });
31
31
  r.extendsRegistrar(EXT_FILE_PROVIDER, {
32
32
  onRegister: () => {
33
33
  // No side-effects at register-time — registry stores the usage,
@@ -49,10 +49,10 @@ export const fileProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) => {
49
49
  category: "storage",
50
50
  recommended: false,
51
51
  });
52
- // Kein r.requires("config") + kein r.requires("secrets") — der
52
+ // Kein requires auf "config" oder "secrets" — der
53
53
  // In-Memory-Provider hat keine Config + kein Secret. Nur die
54
54
  // file-foundation muss da sein (Plugin-extension-point).
55
- r.requires("file-foundation");
55
+ r.requires({ features: ["file-foundation"] });
56
56
  const plugin = {
57
57
  build: async (_ctx, tenantId) => {
58
58
  // Returnt den per-tenant Storage. Identitätsstabil zwischen calls
@@ -32,9 +32,9 @@ export const fileProviderS3Feature = defineFeature(FEATURE_NAME, (r) => {
32
32
  category: "storage",
33
33
  recommended: false,
34
34
  });
35
- r.requires("config");
36
- r.requires("secrets");
37
- r.requires("file-foundation");
35
+ r.requires({ features: ["config"] });
36
+ r.requires({ features: ["secrets"] });
37
+ r.requires({ features: ["file-foundation"] });
38
38
  const secretAccessKey = r.secret("s3.secretAccessKey", {
39
39
  label: { en: "S3 secret access key" },
40
40
  hint: {
@@ -32,10 +32,10 @@ export const fileProviderS3EnvFeature = defineFeature(FEATURE_NAME, (r) => {
32
32
  category: "storage",
33
33
  recommended: false,
34
34
  });
35
- // No r.requires("config") / r.requires("secrets") — credentials come from
35
+ // No requires on "config" or "secrets" — credentials come from
36
36
  // env, not the per-tenant config + secrets store. Only the file-foundation
37
37
  // plugin-extension-point must be mounted.
38
- r.requires("file-foundation");
38
+ r.requires({ features: ["file-foundation"] });
39
39
  const plugin = {
40
40
  // tenantId ignored: one shared bucket serves all tenants (isolation via
41
41
  // tenant-prefixed / UUID keys). Built fresh per call like file-provider-s3;
@@ -23,7 +23,7 @@ export function createFilesTenantDataFeature() {
23
23
  category: "compliance",
24
24
  recommended: false,
25
25
  });
26
- r.requires("tenant-lifecycle");
26
+ r.requires({ features: ["tenant-lifecycle"] });
27
27
  r.optionalRequires("files");
28
28
  r.useExtension(EXT_TENANT_DATA, "fileRef", { destroy: fileRefTenantDestroyHook });
29
29
  r.useExtension(EXT_STORAGE_PROVIDER, "fileRef", { destroyTenant: fileRefStorageDestroyHook });
@@ -31,8 +31,8 @@ function registerFolders(r, access, toggleable, ownership, parents) {
31
31
  if (toggleable !== undefined)
32
32
  r.toggleable(toggleable);
33
33
  const folderAssignmentEntity = createFolderAssignmentEntity(ownership, parents);
34
- r.entity("folder", folderEntity);
35
- r.entity("folder-assignment", folderAssignmentEntity);
34
+ r.entity({ name: "folder", ...folderEntity });
35
+ r.entity({ name: "folder-assignment", ...folderAssignmentEntity });
36
36
  // Folder catalog — plain CRUD, no custom logic. update is rename (and, in a
37
37
  // later stage, reparent: it accepts changes.parentId, optimistic-locked).
38
38
  r.writeHandler(defineEntityCreateHandler("folder", folderEntity, {
@@ -11,7 +11,7 @@ export const foldersUserDataFeature = defineFeature("folders-user-data", (r) =>
11
11
  // OPTIONAL: if it's mounted toggleable(default=false) (e.g. per-tenant via
12
12
  // tier), a hard r.requires would throw an "effectively disabled" boot
13
13
  // warning even though the folder entities exist and the hooks work fine.
14
- r.requires("user-data-rights");
14
+ r.requires({ features: ["user-data-rights"] });
15
15
  r.optionalRequires("folders");
16
16
  r.useExtension(EXT_USER_DATA, "folder", {
17
17
  export: folderExportHook,
@@ -23,11 +23,11 @@ function registerFormDraft(r) {
23
23
  // config is optional: the retention-days key only resolves when the
24
24
  // config feature is mounted (ctx.configResolver present) — the cleanup
25
25
  // job already falls back to FORM_DRAFT_DEFAULT_RETENTION_DAYS otherwise
26
- // (see handlers/cleanup.job.ts). A hard r.requires("config") would break
26
+ // (see handlers/cleanup.job.ts). A hard r.requires({ features: ["config"] }) would break
27
27
  // every app that mounts form-draft without config, which is a breaking
28
28
  // change no minor/patch release should introduce silently.
29
29
  r.optionalRequires("config");
30
- r.entity("form-draft", formDraftEntity);
30
+ r.entity({ name: "form-draft", ...formDraftEntity });
31
31
  r.writeHandler(saveDraftWrite);
32
32
  r.writeHandler(discardDraftWrite);
33
33
  r.queryHandler(getDraftQuery);
@@ -8,7 +8,7 @@ import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engi
8
8
  import { formDraftDeleteHook, formDraftExportHook } from "./hooks.js";
9
9
  export const formDraftUserDataFeature = defineFeature("form-draft-user-data", (r) => {
10
10
  r.describe("GDPR (Art. 20 export / Art. 17 erasure) coverage for the `form-draft` feature's `form-draft` entity. Mounts the EXT_USER_DATA export hook so a user's saved drafts are included in the user-data export bundle, and a delete hook that physically removes the user's own draft rows on forget. Kept separate from `form-draft` so form-draft consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `form-draft`.");
11
- r.requires("user-data-rights");
11
+ r.requires({ features: ["user-data-rights"] });
12
12
  r.optionalRequires("form-draft");
13
13
  r.useExtension(EXT_USER_DATA, "form-draft", {
14
14
  export: formDraftExportHook,
@@ -1,5 +1,6 @@
1
1
  import type { ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { type OAuthAccessTokenManager } from "./oauth-access-token.js";
3
+ import type { InboundMailContext } from "./types.js";
3
4
  export type InboundMailConnectRoutesOptions = {
4
5
  /** HMAC-secret for the state param (deploy-time env — `scope:"system"`
5
6
  * secrets don't exist in secrets-v1). */
@@ -21,6 +22,8 @@ export type InboundMailConnectRoutesOptions = {
21
22
  * lock and primes the access-token cache from the code exchange. Omitted
22
23
  * → plain secret write, no cache priming. */
23
24
  readonly oauthTokens?: OAuthAccessTokenManager;
25
+ /** Handed to the provider plugin's context (`InboundMailContext.metricsFor`). */
26
+ readonly metricsFor?: InboundMailContext["metricsFor"];
24
27
  };
25
28
  /** Builds the `entry:"user"`/`entry:"signature"` extraRoute pair. Mount via
26
29
  * `extraRoutes: createInboundMailConnectRoutes(options)`. */
@@ -80,7 +80,12 @@ export function createInboundMailConnectRoutes(options) {
80
80
  if (!deps.secrets) {
81
81
  return errorJson(c, 500, "secrets_context_missing", `${INBOUND_MAIL_FOUNDATION_FEATURE}: no secrets context wired for the oauth callback route — the refresh token cannot be persisted`);
82
82
  }
83
- const providerCtx = { registry: deps.registry, secrets: deps.secrets, config: undefined };
83
+ const providerCtx = {
84
+ registry: deps.registry,
85
+ secrets: deps.secrets,
86
+ config: undefined,
87
+ ...(options.metricsFor && { metricsFor: options.metricsFor }),
88
+ };
84
89
  let plugin;
85
90
  try {
86
91
  plugin = resolveInboundProviderForKey(providerCtx, state.providerKey);
@@ -62,7 +62,7 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
62
62
  recommended: false,
63
63
  });
64
64
  // EXT_TENANT_DATA-Hook braucht den tenant-lifecycle-Host.
65
- r.requires("tenant-lifecycle");
65
+ r.requires({ features: ["tenant-lifecycle"] });
66
66
  // 5 fine-grained domain-events (Payload-Schemas: events.ts).
67
67
  // piiFields: "none" — address/subject/snippet are already ciphertext under the tenant subject key before append (see events.ts).
68
68
  // "displayName" stays plaintext (often the mailbox owner's real name for
@@ -1,20 +1,25 @@
1
1
  import type { ConfigAccessor, Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability";
2
3
  import { INBOUND_MAIL_PROVIDER_EXTENSION, type InboundMailAccountStatus } from "./constants.js";
3
4
  /**
4
- * Slim-Context für Provider-Plugins.
5
+ * Slim context for provider plugins.
5
6
  *
6
- * **Felder:**
7
- * config — tenant-config-reads (host/port/... der Plugins)
8
- * registry — extension-Lookup in der Factory (nicht plugin-intern)
9
- * secrets — tenant-secret-reads (IMAP-Passwort, OAuth-Refresh-Token)
10
- * _userId — Audit-Identity für secret-reads. Handler-Pfad: dispatcher
11
- * setzt Caller-User-ID; Worker-Pfad: System-Identity.
7
+ * **Fields:**
8
+ * config — tenant config reads (host/port/... of the plugins)
9
+ * registry — extension lookup in the factory (not plugin-internal)
10
+ * secrets — tenant secret reads (IMAP password, OAuth refresh token)
11
+ * _userId — audit identity for secret reads. Handler path: the dispatcher
12
+ * sets the caller user ID; worker path: system identity.
13
+ * metricsFor — metrics handle of a feature by name, same behavior as
14
+ * HandlerContext.metricsFor: unregistered metrics are a silent
15
+ * no-op instead of a throw.
12
16
  */
13
17
  export type InboundMailContext = {
14
18
  readonly config?: ConfigAccessor;
15
19
  readonly registry?: Registry;
16
20
  readonly secrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
17
21
  readonly _userId?: string | undefined;
22
+ readonly metricsFor?: (featureName: string) => MetricsHandle;
18
23
  };
19
24
  /**
20
25
  * Plaintext-Sicht auf ein verbundenes Postfach, wie Provider sie sehen.
@@ -141,6 +146,11 @@ export type InboundMailProviderPlugin = {
141
146
  * whose provider declares `oauth` (authMethod oauth/xoauth2). Absent
142
147
  * for password accounts and providers without an oauth flow. */
143
148
  readonly accessToken?: string;
149
+ /** Batch lookup of the provider message ids not yet ingested for this
150
+ * account (committed seen anchors, one query per call). Call it after
151
+ * the listing/delta page and download MIME only for the returned ids.
152
+ * Ingest dedup stays the correctness anchor; this only saves downloads. */
153
+ readonly filterUnknownMessageIds?: (providerMessageIds: readonly string[]) => Promise<ReadonlySet<string>>;
144
154
  }) => Promise<InboundFetchResult>;
145
155
  /** Nur OAuth-Provider. */
146
156
  readonly oauth?: InboundOAuthFlow;
@@ -14,7 +14,7 @@
14
14
  // app owner still wires the supervisor in bin/server.ts:
15
15
  //
16
16
  // const supervisor = createInboundMailSupervisor({
17
- // providerCtx: { registry: deps.registry, secrets },
17
+ // providerCtx: { registry: deps.registry, secrets, metricsFor: deps.metricsFor },
18
18
  // db,
19
19
  // dispatchWrite: ({ handlerQn, payload, tenantId }) =>
20
20
  // deps.dispatchSystemWrite({ handlerQn, payload, tenantId: tenantId as TenantId }),
@@ -48,7 +48,7 @@ import { configuredPiiSubjectKms, decryptPiiFieldValues, } from "@cosmicdrift/ku
48
48
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
49
49
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
50
50
  import { InboundMailAccountStatuses, InboundMailFoundationHandlers } from "./constants.js";
51
- import { MAIL_ACCOUNT_PII_FIELDS, syncCursorTable } from "./entities.js";
51
+ import { MAIL_ACCOUNT_PII_FIELDS, seenMessageTable, syncCursorTable } from "./entities.js";
52
52
  import { createOAuthAccessTokenManager, usesFoundationManagedOAuth, } from "./oauth-access-token.js";
53
53
  import { mailAccountsProjectionTable } from "./projection.js";
54
54
  import { resolveInboundProviderForAccount } from "./provider-factory.js";
@@ -255,6 +255,19 @@ export function createInboundMailSupervisor(deps) {
255
255
  // ---------------------------------------------------------------
256
256
  // Poll (Reconciliation).
257
257
  // ---------------------------------------------------------------
258
+ function unknownMessageIdsFilterFor(account) {
259
+ return async (providerMessageIds) => {
260
+ if (providerMessageIds.length === 0)
261
+ return new Set();
262
+ const seen = await selectMany(deps.db, seenMessageTable, {
263
+ tenantId: account.tenantId,
264
+ accountId: account.id,
265
+ providerMessageId: { in: providerMessageIds },
266
+ }, { fields: ["providerMessageId"] });
267
+ const seenIds = new Set(seen.map((row) => row.providerMessageId));
268
+ return new Set(providerMessageIds.filter((id) => !seenIds.has(id)));
269
+ };
270
+ }
258
271
  async function pollAccount(account, plugin) {
259
272
  let cursor = await loadCursor(account.id);
260
273
  let budget = maxMessagesPerPoll;
@@ -265,6 +278,7 @@ export function createInboundMailSupervisor(deps) {
265
278
  const result = await plugin.fetch(deps.providerCtx, account, cursor, {
266
279
  backfillWindowDays,
267
280
  maxMessages: budget,
281
+ filterUnknownMessageIds: unknownMessageIdsFilterFor(account),
268
282
  ...(accessToken !== undefined && { accessToken }),
269
283
  });
270
284
  await ingestBatch(account, result.messages, JSON.stringify(result.nextCursor));
@@ -224,8 +224,8 @@ export const inboundProviderImapFeature = defineFeature(FEATURE_NAME, (r) => {
224
224
  category: "notifications",
225
225
  recommended: false,
226
226
  });
227
- r.requires("inbound-mail-foundation");
228
- r.requires("secrets");
227
+ r.requires({ features: ["inbound-mail-foundation"] });
228
+ r.requires({ features: ["secrets"] });
229
229
  r.envSchema(mailAllowedPrivateHostsEnvSchema);
230
230
  r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, IMAP_PROVIDER_KEY, imapInboundMailPlugin);
231
231
  });
@@ -110,6 +110,6 @@ export const inboundProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) =>
110
110
  category: "notifications",
111
111
  recommended: false,
112
112
  });
113
- r.requires("inbound-mail-foundation");
113
+ r.requires({ features: ["inbound-mail-foundation"] });
114
114
  r.useExtension("inboundMailProvider", INMEMORY_PROVIDER_KEY, inMemoryInboundMailPlugin);
115
115
  });