@cosmicdrift/kumiko-bundled-features 0.391.1 → 0.392.1

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 (100) hide show
  1. package/dist/admin-shell/feature.d.ts +3 -0
  2. package/dist/admin-shell/feature.js +47 -30
  3. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  4. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +2 -2
  5. package/dist/cap-overview/handlers/caps-usage.query.js +1 -0
  6. package/dist/cap-overview/handlers/tenant-caps-list.query.js +3 -2
  7. package/dist/cap-overview/i18n.js +4 -0
  8. package/dist/cap-overview/types.d.ts +3 -1
  9. package/dist/cap-overview/web/cap-amount-format.d.ts +3 -0
  10. package/dist/cap-overview/web/cap-amount-format.js +30 -0
  11. package/dist/cap-overview/web/cap-cards-panel.js +63 -23
  12. package/dist/cap-overview/web/cap-usage-bar.js +9 -4
  13. package/dist/cap-overview/web/cap-usage-cell.js +4 -2
  14. package/dist/cap-overview/web/usage-math.d.ts +1 -0
  15. package/dist/cap-overview/web/usage-math.js +4 -1
  16. package/dist/channel-email/app-transport.d.ts +14 -0
  17. package/dist/channel-email/app-transport.js +20 -0
  18. package/dist/channel-email/feature.js +6 -0
  19. package/dist/channel-email/index.d.ts +1 -0
  20. package/dist/channel-email/index.js +1 -0
  21. package/dist/channel-email/smtp-transport.js +2 -19
  22. package/dist/compliance-profiles/schema/profile-selection.d.ts +8 -8
  23. package/dist/foundation-shared/index.d.ts +1 -0
  24. package/dist/foundation-shared/index.js +1 -0
  25. package/dist/foundation-shared/mail-display-name.d.ts +1 -0
  26. package/dist/foundation-shared/mail-display-name.js +18 -0
  27. package/dist/inbound-mail-foundation/account-record.d.ts +13 -0
  28. package/dist/inbound-mail-foundation/account-record.js +27 -0
  29. package/dist/inbound-mail-foundation/feature.js +1 -1
  30. package/dist/inbound-mail-foundation/i18n.js +17 -0
  31. package/dist/inbound-mail-foundation/index.d.ts +3 -1
  32. package/dist/inbound-mail-foundation/index.js +3 -1
  33. package/dist/inbound-mail-foundation/outbound-mail.d.ts +28 -0
  34. package/dist/inbound-mail-foundation/outbound-mail.js +52 -0
  35. package/dist/inbound-mail-foundation/probe.js +5 -28
  36. package/dist/inbound-mail-foundation/screens.d.ts +2 -0
  37. package/dist/inbound-mail-foundation/screens.js +14 -0
  38. package/dist/inbound-mail-foundation/send-from-account.d.ts +31 -0
  39. package/dist/inbound-mail-foundation/send-from-account.js +87 -0
  40. package/dist/inbound-mail-foundation/types.d.ts +4 -0
  41. package/dist/inbound-mail-foundation/watch-supervisor.js +10 -8
  42. package/dist/inbound-provider-imap/connect-mailbox.d.ts +28 -0
  43. package/dist/inbound-provider-imap/connect-mailbox.js +125 -0
  44. package/dist/inbound-provider-imap/constants.d.ts +42 -0
  45. package/dist/inbound-provider-imap/constants.js +40 -0
  46. package/dist/inbound-provider-imap/credential-document.d.ts +5 -0
  47. package/dist/inbound-provider-imap/credential-document.js +30 -1
  48. package/dist/inbound-provider-imap/feature.d.ts +1 -1
  49. package/dist/inbound-provider-imap/feature.js +13 -22
  50. package/dist/inbound-provider-imap/i18n.d.ts +3 -0
  51. package/dist/inbound-provider-imap/i18n.js +46 -0
  52. package/dist/inbound-provider-imap/imap-client.d.ts +1 -0
  53. package/dist/inbound-provider-imap/imap-client.js +1 -1
  54. package/dist/inbound-provider-imap/screens.d.ts +2 -0
  55. package/dist/inbound-provider-imap/screens.js +47 -0
  56. package/dist/inbound-provider-imap/send-mail.d.ts +2 -0
  57. package/dist/inbound-provider-imap/send-mail.js +48 -0
  58. package/dist/inbound-provider-imap/sent-folder.d.ts +8 -0
  59. package/dist/inbound-provider-imap/sent-folder.js +40 -0
  60. package/dist/inbound-provider-imap/smtp-client.d.ts +37 -0
  61. package/dist/inbound-provider-imap/smtp-client.js +109 -0
  62. package/dist/ledger/entity.d.ts +8 -8
  63. package/dist/mail-foundation/i18n.d.ts +9 -1
  64. package/dist/mail-foundation/i18n.js +9 -1
  65. package/dist/mail-foundation/probe.js +32 -16
  66. package/dist/readiness/built-in-probes.d.ts +2 -1
  67. package/dist/readiness/built-in-probes.js +12 -1
  68. package/dist/readiness/constants.d.ts +5 -0
  69. package/dist/readiness/constants.js +1 -0
  70. package/dist/readiness/i18n.js +7 -1
  71. package/dist/readiness/index.d.ts +1 -1
  72. package/dist/readiness/index.js +1 -1
  73. package/dist/readiness/schema/probe-run.d.ts +8 -8
  74. package/dist/readiness/screens.d.ts +3 -0
  75. package/dist/readiness/screens.js +25 -2
  76. package/dist/readiness/visible-results.d.ts +2 -1
  77. package/dist/template-resolver/table.d.ts +36 -36
  78. package/dist/template-resolver/user-content-table.d.ts +26 -26
  79. package/dist/tenant/invitation-table.d.ts +8 -8
  80. package/dist/tenant/schema/tenant.d.ts +10 -10
  81. package/dist/tier-engine/entity.d.ts +3 -3
  82. package/dist/tier-engine/feature.js +8 -4
  83. package/dist/tier-engine/tier-resolver.d.ts +3 -3
  84. package/dist/user/schema/user.d.ts +8 -8
  85. package/dist/user-data-rights/handlers/download-by-token.query.js +3 -3
  86. package/dist/user-data-rights/schema/download-token.js +5 -3
  87. package/dist/user-data-rights/schema/export-job.d.ts +8 -8
  88. package/dist/user-data-rights/token-helpers.d.ts +1 -2
  89. package/dist/user-data-rights/token-helpers.js +5 -4
  90. package/dist/waitlist/entity.d.ts +8 -8
  91. package/package.json +11 -11
  92. package/src/admin-shell/changes.json +6 -0
  93. package/src/cap-overview/changes.json +18 -0
  94. package/src/channel-email/changes.json +6 -0
  95. package/src/foundation-shared/changes.json +6 -0
  96. package/src/inbound-mail-foundation/changes.json +18 -0
  97. package/src/inbound-provider-imap/changes.json +18 -0
  98. package/src/mail-foundation/changes.json +6 -0
  99. package/src/readiness/changes.json +30 -0
  100. package/src/tier-engine/changes.json +6 -0
@@ -15,6 +15,9 @@ export type CreateAdminShellOptions = {
15
15
  /** Tenant nav → document-designer:nav:templates, platform nav → document-designer:nav:platform-templates.
16
16
  * Requires document-designer mounted (a separate package, so no import here). Default false. */
17
17
  readonly includeDocumentDesigner?: boolean;
18
+ /** Tenant overview dashboard: screen, nav and the entry in the tenant workspace. Default true.
19
+ * Set false when the app ships its own landing screen for tenant admins. */
20
+ readonly includeTenantOverview?: boolean;
18
21
  /** When false, only overview screens + nav are registered — app owns r.workspace(). Default true. */
19
22
  readonly registerWorkspaces?: boolean;
20
23
  /** Metric list the overview panels are built from — pass the SAME list to
@@ -6,16 +6,37 @@ import { DEFAULT_METRICS } from "../metrics/index.js";
6
6
  import { OVERVIEW_TIME_RANGE, PLATFORM_SCOPE, PLATFORM_TENANT_FILTER, platformOverviewPanels, tenantOverviewPanels, } from "./overview-panels.js";
7
7
  import { ADMIN_SHELL_FEATURE, DEFAULT_PLATFORM_WORKSPACE_ID, DEFAULT_TENANT_WORKSPACE_ID, PLATFORM_OVERVIEW_SCREEN_ID, TENANT_OVERVIEW_SCREEN_ID, } from "./web/constants.js";
8
8
  import { ADMIN_SHELL_I18N } from "./web/i18n.js";
9
+ function tenantWorkspaceNavIds(options) {
10
+ return [
11
+ ...(options.includeTenantOverview === true ? ["admin-shell:nav:tenant-overview"] : []),
12
+ "tenant:nav:members",
13
+ "audit:nav:audit-log",
14
+ ...(options.includeCapOverview ? ["admin-shell:nav:my-caps"] : []),
15
+ ...(options.includeDocumentDesigner ? ["document-designer:nav:templates"] : []),
16
+ ];
17
+ }
18
+ function platformWorkspaceNavIds(options) {
19
+ return [
20
+ "admin-shell:nav:platform-overview",
21
+ "admin-shell:nav:tenants",
22
+ ...(options.includeCapOverview ? ["admin-shell:nav:tenant-caps"] : []),
23
+ "audit:nav:audit-log-all-tenants",
24
+ "jobs:nav:job-runs",
25
+ ...(options.includeDocumentDesigner ? ["document-designer:nav:platform-templates"] : []),
26
+ ...(options.includeTierAdmin === true ? ["admin-shell:nav:tier-admin"] : []),
27
+ ];
28
+ }
9
29
  export function createAdminShellFeature(options = {}) {
10
30
  const tenantWsId = options.workspaceIds?.tenant ?? DEFAULT_TENANT_WORKSPACE_ID;
11
31
  const platformWsId = options.workspaceIds?.platform ?? DEFAULT_PLATFORM_WORKSPACE_ID;
12
32
  const includeTierAdmin = options.includeTierAdmin ?? true;
13
33
  const includeCapOverview = options.includeCapOverview ?? false;
14
34
  const includeDocumentDesigner = options.includeDocumentDesigner ?? false;
35
+ const includeTenantOverview = options.includeTenantOverview ?? true;
15
36
  const registerWorkspaces = options.registerWorkspaces ?? true;
16
37
  const metrics = options.metrics ?? DEFAULT_METRICS;
17
38
  return defineFeature(ADMIN_SHELL_FEATURE, (r) => {
18
- r.describe("Registers tenant-admin and platform-admin workspaces with provider nav into owner-feature screens (`tenant:screen:members`, `audit:screen:audit-log`, `audit:screen:audit-log-all-tenants`, `tenant:screen:tenant-list`, `jobs:screen:job-runs`, optional `tier-engine:screen:tier-admin`, optional `cap-overview:screen:my-caps`/`cap-overview:screen:tenant-cap-list`, optional `document-designer` template navs) and the two overview dashboards, built from the metrics list (`metrics` option, default DEFAULT_METRICS). Mount after user, tenant, audit, jobs, metrics and metrics-system; pass `workspaceIds` to match app URL conventions (e.g. Studio `d`/`s`, PublicStatus `admin`/`sysadmin`). Client: `adminShellClient()`, `tenantClient()`, `auditClient()`, `jobsClient()`, optional `tierEngineClient()`.");
39
+ r.describe("Registers tenant-admin and platform-admin workspaces with provider nav into owner-feature screens (`tenant:screen:members`, `audit:screen:audit-log`, `audit:screen:audit-log-all-tenants`, `tenant:screen:tenant-list`, `jobs:screen:job-runs`, optional `tier-engine:screen:tier-admin`, optional `cap-overview:screen:my-caps`/`cap-overview:screen:tenant-cap-list`, optional `document-designer` template navs) and the two overview dashboards (the tenant one can be dropped with `includeTenantOverview: false`), built from the metrics list (`metrics` option, default DEFAULT_METRICS). Mount after user, tenant, audit, jobs, metrics and metrics-system; pass `workspaceIds` to match app URL conventions (e.g. Studio `d`/`s`, PublicStatus `admin`/`sysadmin`). Client: `adminShellClient()`, `tenantClient()`, `auditClient()`, `jobsClient()`, optional `tierEngineClient()`.");
19
40
  r.uiHints({
20
41
  displayLabel: "Admin Shell",
21
42
  category: "operations",
@@ -34,37 +55,33 @@ export function createAdminShellFeature(options = {}) {
34
55
  if (includeDocumentDesigner)
35
56
  r.requires({ features: ["document-designer"] });
36
57
  r.translations({ keys: ADMIN_SHELL_I18N });
37
- const tenantNav = [
38
- "admin-shell:nav:tenant-overview",
39
- "tenant:nav:members",
40
- "audit:nav:audit-log",
41
- ...(includeCapOverview ? ["admin-shell:nav:my-caps"] : []),
42
- ...(includeDocumentDesigner ? ["document-designer:nav:templates"] : []),
43
- ];
44
- const platformNav = [
45
- "admin-shell:nav:platform-overview",
46
- "admin-shell:nav:tenants",
47
- ...(includeCapOverview ? ["admin-shell:nav:tenant-caps"] : []),
48
- "audit:nav:audit-log-all-tenants",
49
- "jobs:nav:job-runs",
50
- ...(includeDocumentDesigner ? ["document-designer:nav:platform-templates"] : []),
51
- ...(includeTierAdmin ? ["admin-shell:nav:tier-admin"] : []),
52
- ];
53
- r.screen({
54
- id: TENANT_OVERVIEW_SCREEN_ID,
55
- type: "dashboard",
56
- access: { roles: access.admin },
57
- description: "admin-shell.screen.tenant-overview.subtitle",
58
- timeRange: OVERVIEW_TIME_RANGE,
59
- panels: [...tenantOverviewPanels(metrics, { includeCapOverview })],
58
+ const tenantNav = tenantWorkspaceNavIds({
59
+ includeTenantOverview,
60
+ includeCapOverview,
61
+ includeDocumentDesigner,
60
62
  });
61
- r.nav({
62
- id: "tenant-overview",
63
- label: "admin-shell:nav.tenantOverview",
64
- icon: "home",
65
- screen: "admin-shell:screen:tenant-overview",
66
- order: 1,
63
+ const platformNav = platformWorkspaceNavIds({
64
+ includeTierAdmin,
65
+ includeCapOverview,
66
+ includeDocumentDesigner,
67
67
  });
68
+ if (includeTenantOverview) {
69
+ r.screen({
70
+ id: TENANT_OVERVIEW_SCREEN_ID,
71
+ type: "dashboard",
72
+ access: { roles: access.admin },
73
+ description: "admin-shell.screen.tenant-overview.subtitle",
74
+ timeRange: OVERVIEW_TIME_RANGE,
75
+ panels: [...tenantOverviewPanels(metrics, { includeCapOverview })],
76
+ });
77
+ r.nav({
78
+ id: "tenant-overview",
79
+ label: "admin-shell:nav.tenantOverview",
80
+ icon: "home",
81
+ screen: "admin-shell:screen:tenant-overview",
82
+ order: 1,
83
+ });
84
+ }
68
85
  r.screen({
69
86
  id: PLATFORM_OVERVIEW_SCREEN_ID,
70
87
  type: "dashboard",
@@ -25,13 +25,13 @@ import { buildSessionRoles, createSystemUser, defineWriteHandler, } from "@cosmi
25
25
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
26
26
  import * as z from "zod";
27
27
  import { decryptStoredPii, sessionLocaleField, sessionTimezoneField } from "../../shared/index.js";
28
- // kumiko-lint-ignore cross-feature-import invite-flow
28
+ // kumiko-lint-ignore cross-feature-import the handler loads the pending invitation and marks it accepted
29
29
  import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } from "../../tenant/invitation-table.js";
30
30
  // kumiko-lint-ignore cross-feature-import membership grant for a privileged cross-tenant add
31
31
  import { grantInvitedMembershipRole } from "../../tenant/invited-membership.js";
32
32
  // kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
33
33
  import { grantInvitedGlobalRoles, parseInvitedGlobalRoles, } from "../../user/invited-global-roles.js";
34
- // kumiko-lint-ignore cross-feature-import login-style password-check
34
+ // kumiko-lint-ignore cross-feature-import the handler verifies the invited email password against the user row, like login
35
35
  import { userTable } from "../../user/schema/user.js";
36
36
  import { invalidCredentials, invalidInviteToken, inviteEmailMismatch } from "../errors.js";
37
37
  import { burnInviteToken, deleteInviteToken, getInvitationIdForToken, unburnInviteToken, } from "../invite-token-store.js";
@@ -21,7 +21,7 @@ import { buildSessionRoles, createSystemUser, defineWriteHandler, } from "@cosmi
21
21
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
22
22
  import * as z from "zod";
23
23
  import { decryptStoredPii, registrationLocaleFromRequest } from "../../shared/index.js";
24
- // kumiko-lint-ignore cross-feature-import invite-flow
24
+ // kumiko-lint-ignore cross-feature-import the handler loads the pending invitation and marks it accepted
25
25
  import { INVITATION_STATUS, tenantInvitationEntity, tenantInvitationsTable, } from "../../tenant/invitation-table.js";
26
26
  // kumiko-lint-ignore cross-feature-import reserved-role check owned by tenant-feature
27
27
  import { findForbiddenMembershipRole, reservedMembershipRoleError, } from "../../tenant/membership-roles.js";
@@ -29,7 +29,7 @@ import { findForbiddenMembershipRole, reservedMembershipRoleError, } from "../..
29
29
  import { seedTenantMembership } from "../../tenant/seeding.js";
30
30
  // kumiko-lint-ignore cross-feature-import global roles granted on invite accept live on the user row
31
31
  import { parseInvitedGlobalRoles } from "../../user/invited-global-roles.js";
32
- // kumiko-lint-ignore cross-feature-import existence-check
32
+ // kumiko-lint-ignore cross-feature-import the handler rejects an invitation whose email already has a user row
33
33
  import { userTable } from "../../user/schema/user.js";
34
34
  import { invalidInviteToken } from "../errors.js";
35
35
  import { burnInviteToken, deleteInviteToken, getInvitationIdForToken, unburnInviteToken, } from "../invite-token-store.js";
@@ -59,6 +59,7 @@ export function createCapsUsageQuery(caps, usageRoles) {
59
59
  id: cap.id,
60
60
  label: cap.label,
61
61
  limit,
62
+ ...(cap.unit !== undefined && { unit: cap.unit }),
62
63
  ...(cap.icon !== undefined && { icon: cap.icon }),
63
64
  ...(cap.accentColor !== undefined && { accentColor: cap.accentColor }),
64
65
  };
@@ -172,10 +172,11 @@ export function createTenantCapsListQuery(caps, listCaps) {
172
172
  const rawUsed = usageByCap.get(cap.id)?.get(row.tenantId);
173
173
  const used = rawUsed === undefined ? 0 : rawUsed;
174
174
  const limit = limitByTenantAndCap.get(tenantCapKey(row.tenantId, cap.id)) ?? null;
175
+ const unit = cap.unit !== undefined ? { unit: cap.unit } : {};
175
176
  capFields[capFieldName(cap.id)] =
176
177
  used === null
177
- ? { used: null, limit, fraction: 0 }
178
- : { used, limit, fraction: computeFraction(used, limit) };
178
+ ? { used: null, limit, fraction: 0, ...unit }
179
+ : { used, limit, fraction: computeFraction(used, limit), ...unit };
179
180
  }
180
181
  return {
181
182
  tenantId: row.tenantId,
@@ -10,7 +10,11 @@ export const CAP_OVERVIEW_I18N = {
10
10
  "cap-overview.platform.filter.tenant": { en: "Tenant" },
11
11
  "cap-overview.cards.empty": { en: "No caps configured for this tenant." },
12
12
  "cap-overview.cards.loading": { en: "Loading usage…" },
13
+ "cap-overview.cards.remaining": { en: "{amount} left" },
14
+ "cap-overview.cards.noneLeft": { en: "None left" },
15
+ "cap-overview.cards.viewAll": { en: "View all usage" },
13
16
  "cap-overview.notMeasured": { en: "Not measured yet" },
17
+ "cap-overview.notIncluded": { en: "Not included" },
14
18
  "cap-overview.unlimited": { en: "{used} · unlimited" },
15
19
  "cap-overview.errors.progressPrimitiveMissing": {
16
20
  en: "Usage bar unavailable — Progress primitive is not registered.",
@@ -1,6 +1,7 @@
1
1
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import type { CapLimitContext } from "../cap-counter/index.ts";
4
+ export type CapUnit = "count" | "mb" | "tokens";
4
5
  export type CapIconKey = "file" | "hash" | "database" | "users" | "mail" | "gauge";
5
6
  export type CapSpec = {
6
7
  readonly id: string;
@@ -8,7 +9,7 @@ export type CapSpec = {
8
9
  readonly limit: (tier: string, context: CapLimitContext) => number | null | Promise<number | null>;
9
10
  readonly usage: (db: TenantDb, tenantId: TenantId) => Promise<number | null>;
10
11
  readonly usageBatch?: (db: TenantDb, tenantIds: readonly TenantId[]) => Promise<Map<TenantId, number | null>>;
11
- readonly unit?: "count" | "mb" | "tokens";
12
+ readonly unit?: CapUnit;
12
13
  /** Icon chip on the dashboard cards. Optional — omitted renders the card
13
14
  * without an icon, same as before this field existed. */
14
15
  readonly icon?: CapIconKey;
@@ -22,6 +23,7 @@ export type CapUsage = {
22
23
  readonly used: number | null;
23
24
  readonly limit: number | null;
24
25
  readonly fraction: number;
26
+ readonly unit?: CapUnit;
25
27
  };
26
28
  export type CapUsageWithMeta = CapUsage & {
27
29
  readonly id: string;
@@ -0,0 +1,3 @@
1
+ import type { CapUnit } from "../types.ts";
2
+ export declare function formatCapAmount(value: number, unit: CapUnit | undefined, locale: string): string;
3
+ export declare function formatCapUsedOfLimit(used: number, limit: number, unit: CapUnit | undefined, locale: string): string;
@@ -0,0 +1,30 @@
1
+ const MB_PER_GB = 1024;
2
+ const MB_PER_TB = 1024 * 1024;
3
+ function megabyteUnit(valueInMb) {
4
+ const magnitude = Math.abs(valueInMb);
5
+ if (magnitude < MB_PER_GB)
6
+ return { unit: "megabyte", divisor: 1 };
7
+ if (magnitude < MB_PER_TB)
8
+ return { unit: "gigabyte", divisor: MB_PER_GB };
9
+ return { unit: "terabyte", divisor: MB_PER_TB };
10
+ }
11
+ export function formatCapAmount(value, unit, locale) {
12
+ try {
13
+ if (unit === "mb") {
14
+ const scaled = megabyteUnit(value);
15
+ return new Intl.NumberFormat(locale, {
16
+ style: "unit",
17
+ unit: scaled.unit,
18
+ unitDisplay: "short",
19
+ maximumFractionDigits: 1,
20
+ }).format(value / scaled.divisor);
21
+ }
22
+ return new Intl.NumberFormat(locale).format(value);
23
+ }
24
+ catch {
25
+ return String(value);
26
+ }
27
+ }
28
+ export function formatCapUsedOfLimit(used, limit, unit, locale) {
29
+ return `${formatCapAmount(used, unit, locale)} / ${formatCapAmount(limit, unit, locale)}`;
30
+ }
@@ -1,10 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { usePrimitives, useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
2
+ import { useLocale, useNav, usePrimitives, useQuery, useTranslation, } from "@cosmicdrift/kumiko-renderer";
3
3
  import { EMPTY_VALUE_PLACEHOLDER } from "@cosmicdrift/kumiko-renderer/headless";
4
4
  import { EmptyState, StatCard, StatusBadge, } from "@cosmicdrift/kumiko-renderer/web";
5
5
  import { Database, FileText, Gauge, Hash, Mail, Users } from "lucide-react";
6
+ import { formatCapAmount, formatCapUsedOfLimit } from "./cap-amount-format.js";
6
7
  import { CapUsageBar } from "./cap-usage-bar.js";
7
- import { CapOverviewQueries } from "./constants.js";
8
+ import { CapOverviewQueries, MY_CAPS_SCREEN_ID } from "./constants.js";
9
+ import { isCapNotIncluded } from "./usage-math.js";
8
10
  // caps:usage's wire tone stays "default"/"warn"/"danger" (matches the
9
11
  // server-side thresholds literally); mapped here to the renderer's actual
10
12
  // StatusTone vocabulary instead of inventing new CSS ("danger" has no
@@ -28,6 +30,50 @@ const CAP_ICONS = {
28
30
  function isMeasured(cap) {
29
31
  return cap.used !== null;
30
32
  }
33
+ // Boundary check: only an array of strings restricts; anything else means no restriction.
34
+ function readCapKeys(value) {
35
+ if (!Array.isArray(value))
36
+ return undefined;
37
+ return new Set(value.filter((entry) => typeof entry === "string"));
38
+ }
39
+ function ViewAllLink() {
40
+ const t = useTranslation();
41
+ const { Link } = usePrimitives();
42
+ const nav = useNav();
43
+ const target = { screenId: MY_CAPS_SCREEN_ID };
44
+ return (_jsx(Link, { href: nav.hrefFor(target), onPress: () => nav.navigate(target), testId: "cap-cards-view-all", children: t("cap-overview.cards.viewAll") }));
45
+ }
46
+ function CapUsageSummaryRow({ cap }) {
47
+ const t = useTranslation();
48
+ const locale = useLocale().locale();
49
+ const remainingText = isMeasured(cap) && cap.limit !== null && cap.limit > 0
50
+ ? remainingLabel(t, Math.max(cap.limit - cap.used, 0), cap.unit, locale)
51
+ : undefined;
52
+ if (cap.percent === null && remainingText === undefined)
53
+ return null;
54
+ return (_jsxs("div", { className: "flex items-center gap-2", children: [cap.percent !== null && (_jsx(StatusBadge, { tone: TONE_TO_STATUS[cap.tone], children: `${cap.percent}%` })), remainingText !== undefined && (_jsx("span", { className: "text-sm text-muted-foreground", "data-testid": "cap-card-remaining", children: remainingText }))] }));
55
+ }
56
+ function remainingLabel(t, rest, unit, locale) {
57
+ return rest > 0
58
+ ? t("cap-overview.cards.remaining", { amount: formatCapAmount(rest, unit, locale) })
59
+ : t("cap-overview.cards.noneLeft");
60
+ }
61
+ function CapUsageStat({ cap, footer, }) {
62
+ const t = useTranslation();
63
+ const locale = useLocale().locale();
64
+ const Icon = cap.icon !== undefined ? CAP_ICONS[cap.icon] : undefined;
65
+ // An unlimited, measured cap has neither a badge nor a bar; StatCard still
66
+ // wraps any defined child in a spacing div, so pass undefined then.
67
+ const notIncluded = isCapNotIncluded(cap.limit);
68
+ const body = !notIncluded && (!isMeasured(cap) || cap.limit !== null) ? (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(CapUsageSummaryRow, { cap: cap }), _jsx(CapUsageBar, { usage: cap, showLabel: false }), footer] })) : (footer);
69
+ return (_jsx(StatCard, { icon: Icon !== undefined ? _jsx(Icon, { className: "size-4" }) : undefined, label: t(cap.label), value: notIncluded
70
+ ? t("cap-overview.notIncluded")
71
+ : !isMeasured(cap)
72
+ ? EMPTY_VALUE_PLACEHOLDER
73
+ : cap.limit === null
74
+ ? formatCapAmount(cap.used, cap.unit, locale)
75
+ : formatCapUsedOfLimit(cap.used, cap.limit, cap.unit, locale), ...(cap.accentColor !== undefined && { accentColor: cap.accentColor }), testId: "cap-card", children: body }));
76
+ }
31
77
  export function CapCardsPanel({ filterParams }) {
32
78
  const t = useTranslation();
33
79
  const { Banner } = usePrimitives();
@@ -38,28 +84,22 @@ export function CapCardsPanel({ filterParams }) {
38
84
  if (query.error !== null) {
39
85
  return (_jsx(Banner, { variant: "error", testId: "cap-cards-panel-error", children: query.error.message }));
40
86
  }
41
- const rows = query.data?.rows ?? [];
87
+ const capKeys = readCapKeys(filterParams?.["capKeys"]);
88
+ const allRows = query.data?.rows ?? [];
89
+ const rows = capKeys === undefined ? allRows : allRows.filter((row) => capKeys.has(row.id));
42
90
  if (!query.loading && rows.length === 0) {
43
91
  return _jsx(EmptyState, { title: t("cap-overview.cards.empty"), testId: "cap-cards-panel-empty" });
44
92
  }
45
- return (_jsxs("div", {
46
- // Arbitrary bracket values (grid-cols-[repeat(...)]) never compiled:
47
- // styles.css's Tailwind v4 @source scan only covers renderer/web/src,
48
- // renderer/src and samples/**/src (see its comment) — bundled-features
49
- // isn't scanned, so a class unique to this file has no generated rule
50
- // and silently no-ops (the grid fell back to one implicit column,
51
- // stacking every card full-width). Reusing the exact classes
52
- // dashboard-body.tsx's own panel grid already uses guarantees the
53
- // rule exists, since that file IS in the scanned surface.
54
- className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-4", "data-testid": "cap-cards-panel", children: [query.loading && rows.length === 0 && (_jsx("div", { className: "text-sm text-muted-foreground", children: t("cap-overview.cards.loading") })), rows.map((cap) => {
55
- const Icon = cap.icon !== undefined ? CAP_ICONS[cap.icon] : undefined;
56
- // An unlimited, measured cap has neither a badge nor a bar; StatCard still
57
- // wraps any defined child in a spacing div, so pass undefined then.
58
- const body = !isMeasured(cap) || cap.limit !== null ? (_jsxs("div", { className: "flex flex-col gap-2", children: [cap.percent !== null && (_jsx("div", { className: "flex items-center justify-between gap-2", children: _jsx(StatusBadge, { tone: TONE_TO_STATUS[cap.tone], children: `${cap.percent}%` }) })), _jsx(CapUsageBar, { usage: cap, showLabel: false })] })) : undefined;
59
- return (_jsx(StatCard, { icon: Icon !== undefined ? _jsx(Icon, { className: "size-4" }) : undefined, label: t(cap.label), value: !isMeasured(cap)
60
- ? EMPTY_VALUE_PLACEHOLDER
61
- : cap.limit === null
62
- ? String(cap.used)
63
- : `${cap.used} / ${cap.limit}`, ...(cap.accentColor !== undefined && { accentColor: cap.accentColor }), testId: "cap-card", children: body }, cap.id));
64
- })] }));
93
+ // A single limited card carries the link itself; several share one under the grid.
94
+ const linkInCard = capKeys !== undefined && rows.length === 1;
95
+ return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", {
96
+ // Arbitrary bracket values (grid-cols-[repeat(...)]) never compiled:
97
+ // styles.css's Tailwind v4 @source scan only covers renderer/web/src,
98
+ // renderer/src and samples/**/src (see its comment) — bundled-features
99
+ // isn't scanned, so a class unique to this file has no generated rule
100
+ // and silently no-ops (the grid fell back to one implicit column,
101
+ // stacking every card full-width). Reusing the exact classes
102
+ // dashboard-body.tsx's own panel grid already uses guarantees the
103
+ // rule exists, since that file IS in the scanned surface.
104
+ className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-4", "data-testid": "cap-cards-panel", children: [query.loading && rows.length === 0 && (_jsx("div", { className: "text-sm text-muted-foreground", children: t("cap-overview.cards.loading") })), rows.map((cap) => (_jsx(CapUsageStat, { cap: cap, footer: linkInCard ? _jsx(ViewAllLink, {}) : undefined }, cap.id)))] }), capKeys !== undefined && !linkInCard && _jsx(ViewAllLink, {})] }));
65
105
  }
@@ -1,15 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
- import { computeTone } from "./usage-math.js";
2
+ import { useLocale, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
+ import { formatCapAmount, formatCapUsedOfLimit } from "./cap-amount-format.js";
4
+ import { computeTone, isCapNotIncluded } from "./usage-math.js";
4
5
  export function CapUsageBar({ usage, showLabel = true, }) {
5
6
  const { Progress, Text, Banner } = usePrimitives();
6
7
  const t = useTranslation();
8
+ const locale = useLocale().locale();
9
+ if (isCapNotIncluded(usage.limit)) {
10
+ return (_jsx(Text, { variant: "small", testId: "cap-usage-not-included", children: t("cap-overview.notIncluded") }));
11
+ }
7
12
  if (usage.used === null) {
8
13
  return (_jsx(Text, { variant: "small", testId: "cap-usage-not-measured", children: t("cap-overview.notMeasured") }));
9
14
  }
10
15
  if (usage.limit === null) {
11
16
  // A progress bar with no denominator says nothing — show the raw count instead.
12
- return showLabel ? (_jsx(Text, { variant: "small", testId: "cap-usage-unlimited", children: t("cap-overview.unlimited", { used: usage.used }) })) : null;
17
+ return showLabel ? (_jsx(Text, { variant: "small", testId: "cap-usage-unlimited", children: t("cap-overview.unlimited", { used: formatCapAmount(usage.used, usage.unit, locale) }) })) : null;
13
18
  }
14
- return (_jsxs("div", { className: "flex flex-col gap-1", children: [Progress ? (_jsx(Progress, { value: usage.fraction, tone: computeTone(usage.fraction), testId: "cap-usage-bar" })) : (_jsx(Banner, { variant: "error", testId: "cap-usage-bar-missing-primitive", children: t("cap-overview.errors.progressPrimitiveMissing") })), showLabel && _jsx(Text, { variant: "small", children: `${usage.used} / ${usage.limit}` })] }));
19
+ return (_jsxs("div", { className: "flex flex-col gap-1", children: [Progress ? (_jsx(Progress, { value: usage.fraction, tone: computeTone(usage.fraction), testId: "cap-usage-bar" })) : (_jsx(Banner, { variant: "error", testId: "cap-usage-bar-missing-primitive", children: t("cap-overview.errors.progressPrimitiveMissing") })), showLabel && (_jsx(Text, { variant: "small", children: formatCapUsedOfLimit(usage.used, usage.limit, usage.unit, locale) }))] }));
15
20
  }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
+ import { isOneOf, isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
3
3
  import { CapUsageBar } from "./cap-usage-bar.js";
4
+ const CAP_UNITS = ["count", "mb", "tokens"];
4
5
  function isCapUsage(value) {
5
6
  if (!isPlainObject(value))
6
7
  return false;
@@ -8,7 +9,8 @@ function isCapUsage(value) {
8
9
  const limit = value["limit"];
9
10
  return ((typeof used === "number" || used === null) &&
10
11
  (typeof limit === "number" || limit === null) &&
11
- typeof value["fraction"] === "number");
12
+ typeof value["fraction"] === "number" &&
13
+ (value["unit"] === undefined || isOneOf(CAP_UNITS, value["unit"])));
12
14
  }
13
15
  export function CapUsageCell({ value }) {
14
16
  if (!isCapUsage(value))
@@ -1,4 +1,5 @@
1
1
  import type { CapUsageTone } from "../types.ts";
2
+ export declare function isCapNotIncluded(limit: number | null): boolean;
2
3
  export declare function computeFraction(used: number, limit: number | null): number;
3
4
  export declare function computeUnclampedFraction(used: number, limit: number | null): number;
4
5
  export declare function computeTone(fraction: number): CapUsageTone;
@@ -2,10 +2,13 @@ const WARN_THRESHOLD = 0.8;
2
2
  const DANGER_THRESHOLD = 1;
3
3
  // limit === null means unlimited (no cap to measure against); limit <= 0
4
4
  // means "not part of this tier" — both render as 0, never NaN/Infinity.
5
+ export function isCapNotIncluded(limit) {
6
+ return limit !== null && limit <= 0;
7
+ }
5
8
  function rawFraction(used, limit) {
6
9
  if (limit === null)
7
10
  return 0;
8
- if (limit <= 0)
11
+ if (isCapNotIncluded(limit))
9
12
  return 0;
10
13
  const fraction = used / limit;
11
14
  return Number.isFinite(fraction) ? fraction : 0;
@@ -0,0 +1,14 @@
1
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { EmailTransport } from "./types.ts";
3
+ export declare const EMAIL_APP_TRANSPORT_EXTENSION: "emailAppTransport";
4
+ export declare const EMAIL_APP_TRANSPORT_USAGE: "app";
5
+ export type EmailAppTransport = {
6
+ readonly transport: EmailTransport | null;
7
+ };
8
+ declare module "@cosmicdrift/kumiko-framework/engine" {
9
+ interface KumikoExtensionOptionsMap {
10
+ [EMAIL_APP_TRANSPORT_EXTENSION]: EmailAppTransport;
11
+ }
12
+ }
13
+ /** The app-wide transport of channel-email, null when the app has none or no channel-email. */
14
+ export declare function findEmailAppTransport(registry: Pick<Registry, "getExtensionUsages">): EmailTransport | null;
@@ -0,0 +1,20 @@
1
+ // The app transport otherwise lives only in the channel closure; publishing it lets other
2
+ // features reach the same sender a tenant without its own provider falls back to.
3
+ export const EMAIL_APP_TRANSPORT_EXTENSION = "emailAppTransport";
4
+ export const EMAIL_APP_TRANSPORT_USAGE = "app";
5
+ function isEmailAppTransport(options) {
6
+ return (typeof options === "object" &&
7
+ options !== null &&
8
+ "transport" in options &&
9
+ typeof options.transport === "object" &&
10
+ options.transport !== null &&
11
+ "send" in options.transport &&
12
+ typeof options.transport.send === "function");
13
+ }
14
+ /** The app-wide transport of channel-email, null when the app has none or no channel-email. */
15
+ export function findEmailAppTransport(registry) {
16
+ const usage = registry
17
+ .getExtensionUsages(EMAIL_APP_TRANSPORT_EXTENSION)
18
+ .find((candidate) => candidate.entityName === EMAIL_APP_TRANSPORT_USAGE);
19
+ return usage !== undefined && isEmailAppTransport(usage.options) ? usage.options.transport : null;
20
+ }
@@ -1,10 +1,12 @@
1
1
  import { DELIVERY_CHANNEL_EXTENSION, EMAIL_CHANNEL_NAME, } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
2
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { featureWithEnvSchema } from "@cosmicdrift/kumiko-framework/env";
4
+ import { EMAIL_APP_TRANSPORT_EXTENSION, EMAIL_APP_TRANSPORT_USAGE } from "./app-transport.js";
4
5
  import { createEmailChannel } from "./email-channel.js";
5
6
  import { smtpEnvSchema } from "./env.js";
6
7
  export function createChannelEmailFeature(options) {
7
8
  const channel = createEmailChannel(options);
9
+ const appTransport = options.transport ?? null;
8
10
  const feature = defineFeature("channel-email", (r) => {
9
11
  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, but then only notifications declared with `tenantTransport: true` are delivered: every other notification (password reset, sign-in, invitations) fails with `missing_credentials`, so an app without an app SMTP server must mark every notification that should go out or keep an app `transport`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.");
10
12
  r.uiHints({
@@ -15,6 +17,10 @@ export function createChannelEmailFeature(options) {
15
17
  r.requires({ features: ["delivery"] });
16
18
  r.envSchema(smtpEnvSchema);
17
19
  r.translations({ keys: { "delivery.channel.email": { en: "Email" } } });
20
+ r.extendsRegistrar(EMAIL_APP_TRANSPORT_EXTENSION, { onRegister: () => { } });
21
+ r.useExtension(EMAIL_APP_TRANSPORT_EXTENSION, EMAIL_APP_TRANSPORT_USAGE, {
22
+ transport: appTransport,
23
+ });
18
24
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, EMAIL_CHANNEL_NAME, {
19
25
  mode: channel.mode,
20
26
  resolve: channel.resolve,
@@ -1,3 +1,4 @@
1
+ export { EMAIL_APP_TRANSPORT_EXTENSION, type EmailAppTransport, findEmailAppTransport, } from "./app-transport.ts";
1
2
  export { createEmailChannel, type EmailChannelOptions, type TenantTransportResolver, } from "./email-channel.ts";
2
3
  export { smtpEnvSchema } from "./env.ts";
3
4
  export { createChannelEmailFeature } from "./feature.ts";
@@ -1,3 +1,4 @@
1
+ export { EMAIL_APP_TRANSPORT_EXTENSION, findEmailAppTransport, } from "./app-transport.js";
1
2
  export { createEmailChannel, } from "./email-channel.js";
2
3
  export { smtpEnvSchema } from "./env.js";
3
4
  export { createChannelEmailFeature } from "./feature.js";
@@ -17,28 +17,11 @@
17
17
  // connections open and reuses them. That is enough for small apps; for
18
18
  // high-volume apps the caller must layer its own implementation with a
19
19
  // dedicated queue (BullMQ + retry) on top.
20
+ import { sanitizeMailDisplayName } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
20
21
  import { createTransport } from "nodemailer";
21
22
  import addressparser from "nodemailer/lib/addressparser";
22
- function isControlCharacter(char) {
23
- const code = char.charCodeAt(0);
24
- return code <= 0x1f || code === 0x7f;
25
- }
26
- // Mail clients often show only the display name: an embedded address ("<", ">", "@") would
27
- // pass for a foreign sender on the platform's own SPF/DKIM-signed domain.
28
- function isUnsafeDisplayNameCharacter(char) {
29
- return isControlCharacter(char) || char === "<" || char === ">" || char === "@";
30
- }
31
- // Control characters in a display name would let a caller inject headers.
32
- function sanitizedDisplayName(name) {
33
- const cleaned = name
34
- ?.split("")
35
- .map((char) => (isUnsafeDisplayNameCharacter(char) ? " " : char))
36
- .join("")
37
- .trim();
38
- return cleaned === undefined || cleaned === "" ? undefined : cleaned;
39
- }
40
23
  function fromHeader(address, fromName) {
41
- const name = sanitizedDisplayName(fromName);
24
+ const name = sanitizeMailDisplayName(fromName);
42
25
  if (name === undefined)
43
26
  return address;
44
27
  const [parsed] = addressparser(address, { flatten: true });
@@ -11,10 +11,10 @@ export declare const tenantComplianceProfileEntity: import("@cosmicdrift/kumiko-
11
11
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
12
12
  readonly display?: "radio" | "dropdown";
13
13
  readonly optionTones?: {
14
- readonly "de-hr-dsgvo-hgb"?: "bad" | "neutral" | "ok" | "warn" | undefined;
15
- readonly "eu-dsgvo"?: "bad" | "neutral" | "ok" | "warn" | undefined;
16
- readonly "minimal-no-region"?: "bad" | "neutral" | "ok" | "warn" | undefined;
17
- readonly "swiss-dsg"?: "bad" | "neutral" | "ok" | "warn" | undefined;
14
+ readonly "de-hr-dsgvo-hgb"?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
15
+ readonly "eu-dsgvo"?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
16
+ readonly "minimal-no-region"?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
17
+ readonly "swiss-dsg"?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
18
18
  } | undefined;
19
19
  readonly optionsQuery?: string;
20
20
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
@@ -40,10 +40,10 @@ export declare const tenantComplianceProfileTable: import("@cosmicdrift/kumiko-f
40
40
  readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
41
41
  readonly display?: "radio" | "dropdown";
42
42
  readonly optionTones?: {
43
- readonly "de-hr-dsgvo-hgb"?: "bad" | "neutral" | "ok" | "warn" | undefined;
44
- readonly "eu-dsgvo"?: "bad" | "neutral" | "ok" | "warn" | undefined;
45
- readonly "minimal-no-region"?: "bad" | "neutral" | "ok" | "warn" | undefined;
46
- readonly "swiss-dsg"?: "bad" | "neutral" | "ok" | "warn" | undefined;
43
+ readonly "de-hr-dsgvo-hgb"?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
44
+ readonly "eu-dsgvo"?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
45
+ readonly "minimal-no-region"?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
46
+ readonly "swiss-dsg"?: "accent" | "bad" | "critical" | "neutral" | "ok" | "warn" | undefined;
47
47
  } | undefined;
48
48
  readonly optionsQuery?: string;
49
49
  readonly optionsQueryPayload?: import("@cosmicdrift/kumiko-types/fields").OptionsQueryPayload;
@@ -2,4 +2,5 @@ export { buildInfoEnvSchema } from "./build-info-env.ts";
2
2
  export { requireDefined, requireNonEmpty, requireSecretSet } from "./config-helpers.ts";
3
3
  export { demoAdminEnvSchema } from "./demo-admin-env.ts";
4
4
  export { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.ts";
5
+ export { sanitizeMailDisplayName } from "./mail-display-name.ts";
5
6
  export { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, type MailConnectTarget, type MailHostGuardOptions, mailAllowedPrivateHostsEnvSchema, readAllowedPrivateMailHostsFromEnv, resolveMailConnectTarget, } from "./mail-host-policy.ts";
@@ -4,4 +4,5 @@ export { buildInfoEnvSchema } from "./build-info-env.js";
4
4
  export { requireDefined, requireNonEmpty, requireSecretSet } from "./config-helpers.js";
5
5
  export { demoAdminEnvSchema } from "./demo-admin-env.js";
6
6
  export { isHostAllowlisted, readHostAllowlistFromEnv } from "./host-allowlist.js";
7
+ export { sanitizeMailDisplayName } from "./mail-display-name.js";
7
8
  export { BlockedHostError, HostResolutionError, MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR, mailAllowedPrivateHostsEnvSchema, readAllowedPrivateMailHostsFromEnv, resolveMailConnectTarget, } from "./mail-host-policy.js";
@@ -0,0 +1 @@
1
+ export declare function sanitizeMailDisplayName(name: string | undefined): string | undefined;
@@ -0,0 +1,18 @@
1
+ function isControlCharacter(char) {
2
+ const code = char.charCodeAt(0);
3
+ return code <= 0x1f || code === 0x7f;
4
+ }
5
+ // Mail clients often show only the display name: an embedded address ("<", ">", "@") would
6
+ // pass for a foreign sender on the sending domain's own SPF/DKIM-signed mail.
7
+ function isUnsafeDisplayNameCharacter(char) {
8
+ return isControlCharacter(char) || char === "<" || char === ">" || char === "@";
9
+ }
10
+ // Control characters in a display name would let a caller inject headers.
11
+ export function sanitizeMailDisplayName(name) {
12
+ const cleaned = name
13
+ ?.split("")
14
+ .map((char) => (isUnsafeDisplayNameCharacter(char) ? " " : char))
15
+ .join("")
16
+ .trim();
17
+ return cleaned === undefined || cleaned === "" ? undefined : cleaned;
18
+ }