@cosmicdrift/kumiko-bundled-features 0.390.1 → 0.391.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 (106) hide show
  1. package/dist/audit/event-classification.d.ts +1 -0
  2. package/dist/audit/event-classification.js +4 -0
  3. package/dist/audit/handlers/audit-events.d.ts +3 -0
  4. package/dist/audit/handlers/audit-events.js +9 -2
  5. package/dist/audit/handlers/details.query.js +3 -4
  6. package/dist/audit/handlers/list.query.js +1 -0
  7. package/dist/audit/handlers/resolve-audit-tenant.d.ts +1 -0
  8. package/dist/audit/handlers/resolve-audit-tenant.js +6 -2
  9. package/dist/auth-mfa/i18n.js +1 -0
  10. package/dist/auth-mfa/screens.js +3 -0
  11. package/dist/file-foundation/feature.js +4 -0
  12. package/dist/file-foundation/i18n.d.ts +20 -0
  13. package/dist/file-foundation/i18n.js +12 -0
  14. package/dist/file-foundation/index.d.ts +1 -0
  15. package/dist/file-foundation/index.js +1 -0
  16. package/dist/file-foundation/probe.d.ts +2 -0
  17. package/dist/file-foundation/probe.js +53 -0
  18. package/dist/{user-data-rights/lib → file-foundation}/storage-provider-resolver.js +2 -2
  19. package/dist/inbound-mail-foundation/feature.js +2 -0
  20. package/dist/inbound-mail-foundation/i18n.js +2 -0
  21. package/dist/inbound-mail-foundation/probe-keys.d.ts +28 -0
  22. package/dist/inbound-mail-foundation/probe-keys.js +16 -0
  23. package/dist/inbound-mail-foundation/probe.d.ts +2 -0
  24. package/dist/inbound-mail-foundation/probe.js +89 -0
  25. package/dist/inbound-mail-foundation/types.d.ts +3 -0
  26. package/dist/inbound-provider-imap/feature.js +3 -14
  27. package/dist/inbound-provider-imap/imap-client.d.ts +1 -0
  28. package/dist/inbound-provider-imap/imap-client.js +15 -0
  29. package/dist/mail-foundation/feature.js +2 -0
  30. package/dist/mail-foundation/i18n.d.ts +38 -0
  31. package/dist/mail-foundation/i18n.js +26 -0
  32. package/dist/mail-foundation/index.d.ts +1 -0
  33. package/dist/mail-foundation/index.js +1 -0
  34. package/dist/{user-data-rights/lib → mail-foundation}/mail-transport-resolver.js +2 -2
  35. package/dist/mail-foundation/probe.d.ts +2 -0
  36. package/dist/mail-foundation/probe.js +66 -0
  37. package/dist/readiness/built-in-probes.d.ts +11 -0
  38. package/dist/readiness/built-in-probes.js +55 -0
  39. package/dist/readiness/collect-probes.d.ts +9 -0
  40. package/dist/readiness/collect-probes.js +30 -0
  41. package/dist/readiness/constants.d.ts +19 -0
  42. package/dist/readiness/constants.js +21 -0
  43. package/dist/readiness/execute-probes.d.ts +3 -0
  44. package/dist/readiness/execute-probes.js +80 -0
  45. package/dist/readiness/feature.d.ts +14 -1
  46. package/dist/readiness/feature.js +66 -3
  47. package/dist/readiness/handlers/latest-results.query.d.ts +5 -0
  48. package/dist/readiness/handlers/latest-results.query.js +13 -0
  49. package/dist/readiness/handlers/latest-summary.query.d.ts +39 -0
  50. package/dist/readiness/handlers/latest-summary.query.js +49 -0
  51. package/dist/readiness/handlers/run-detail.query.d.ts +18 -0
  52. package/dist/readiness/handlers/run-detail.query.js +30 -0
  53. package/dist/readiness/handlers/run-list.query.d.ts +24 -0
  54. package/dist/readiness/handlers/run-list.query.js +33 -0
  55. package/dist/readiness/handlers/run-probes.write.d.ts +7 -0
  56. package/dist/readiness/handlers/run-probes.write.js +68 -0
  57. package/dist/readiness/handlers/run-results.query.d.ts +7 -0
  58. package/dist/readiness/handlers/run-results.query.js +17 -0
  59. package/dist/readiness/handlers/run-tone.d.ts +2 -0
  60. package/dist/readiness/handlers/run-tone.js +5 -0
  61. package/dist/readiness/i18n.d.ts +5 -0
  62. package/dist/readiness/i18n.js +53 -0
  63. package/dist/readiness/index.d.ts +1 -1
  64. package/dist/readiness/index.js +1 -1
  65. package/dist/readiness/probe-boot-check.d.ts +2 -0
  66. package/dist/readiness/probe-boot-check.js +39 -0
  67. package/dist/readiness/probe-context.d.ts +5 -0
  68. package/dist/readiness/probe-context.js +43 -0
  69. package/dist/readiness/run-probes-job.d.ts +2 -0
  70. package/dist/readiness/run-probes-job.js +82 -0
  71. package/dist/readiness/run-view.d.ts +25 -0
  72. package/dist/readiness/run-view.js +13 -0
  73. package/dist/readiness/schema/probe-run.d.ts +343 -0
  74. package/dist/readiness/schema/probe-run.js +51 -0
  75. package/dist/readiness/screens.d.ts +31 -0
  76. package/dist/readiness/screens.js +171 -0
  77. package/dist/readiness/visible-results.d.ts +52 -0
  78. package/dist/readiness/visible-results.js +69 -0
  79. package/dist/tenant/membership-stream-history.d.ts +15 -0
  80. package/dist/tenant/membership-stream-history.js +162 -0
  81. package/dist/tenant/membership-table.d.ts +1 -0
  82. package/dist/tenant/membership-table.js +2 -3
  83. package/dist/tenant/seeding.js +9 -12
  84. package/dist/tenant/stream-tenant-backfill.d.ts +2 -0
  85. package/dist/tenant/stream-tenant-backfill.js +34 -1
  86. package/dist/user-data-rights/feature.js +2 -2
  87. package/dist/user-data-rights/handlers/download-by-job.query.js +1 -1
  88. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
  89. package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +1 -1
  90. package/dist/user-profile/feature.d.ts +4 -0
  91. package/dist/user-profile/feature.js +17 -1
  92. package/dist/user-profile/web/constants.d.ts +3 -0
  93. package/dist/user-profile/web/constants.js +4 -0
  94. package/dist/user-profile/web/i18n.js +1 -0
  95. package/kumiko-es-tables.json +4 -0
  96. package/package.json +11 -11
  97. package/src/audit/changes.json +6 -0
  98. package/src/auth-mfa/changes.json +6 -0
  99. package/src/file-foundation/changes.json +6 -0
  100. package/src/inbound-mail-foundation/changes.json +6 -0
  101. package/src/mail-foundation/changes.json +6 -0
  102. package/src/readiness/changes.json +7 -0
  103. package/src/tenant/changes.json +12 -0
  104. package/src/user-profile/changes.json +6 -0
  105. /package/dist/{user-data-rights/lib → file-foundation}/storage-provider-resolver.d.ts +0 -0
  106. /package/dist/{user-data-rights/lib → mail-foundation}/mail-transport-resolver.d.ts +0 -0
@@ -3,6 +3,7 @@ export type AuditEventStance = typeof TECHNICAL_EVENT | {
3
3
  readonly labelKey: string;
4
4
  };
5
5
  export declare const AUDIT_EVENT_STANCES: Readonly<Record<string, AuditEventStance>>;
6
+ export declare const SYSTEM_ADMIN_ONLY_AGGREGATE_TYPES: readonly string[];
6
7
  export declare const FRAMEWORK_SYSTEM_EVENT_TYPES: readonly string[];
7
8
  export declare const TECHNICAL_EVENT_TYPES: readonly string[];
8
9
  export declare function eventLabelKeyOf(type: string): string | null;
@@ -53,6 +53,10 @@ export const AUDIT_EVENT_STANCES = {
53
53
  "waitlist:event:purged": label("waitlist-purged"),
54
54
  "feature-toggles:event:toggle-set": label("feature-toggle-set"),
55
55
  };
56
+ // Streams whose payload holds rows that only a SystemAdmin or a single owner may read: a
57
+ // readiness probe run stores system-probe and personal-mailbox results next to the tenant's
58
+ // own. Below SystemAdmin the audit log leaves these streams out entirely.
59
+ export const SYSTEM_ADMIN_ONLY_AGGREGATE_TYPES = ["probe-run"];
56
60
  // Framework system events (kumiko:system:*) are engine plumbing, never audit-worthy on their own.
57
61
  export const FRAMEWORK_SYSTEM_EVENT_TYPES = [
58
62
  ...WORKFLOW_SYSTEM_EVENT_DEFS.keys(),
@@ -52,7 +52,10 @@ export type AuditListFilters = {
52
52
  cursor?: string | undefined;
53
53
  before?: string | undefined;
54
54
  includeTechnical?: boolean | undefined;
55
+ hiddenAggregateTypes?: readonly string[] | undefined;
55
56
  };
57
+ /** The aggregateType condition: the requested type, minus the streams hidden from the caller. */
58
+ export declare function aggregateTypeCondition(aggregateType: string | undefined, hidden: readonly string[] | undefined): unknown;
56
59
  /** `tenantCondition` is a tenant id, or an operator object for reads across tenants. */
57
60
  export declare function buildAuditWhere(tenantCondition: unknown, p: AuditListFilters, direction: "asc" | "desc"): WhereObject;
58
61
  type AuditEventRow = {
@@ -60,11 +60,18 @@ function buildDateRange(from, to) {
60
60
  function escapeLikePattern(value) {
61
61
  return value.replace(/[\\%_]/g, (m) => `\\${m}`);
62
62
  }
63
+ /** The aggregateType condition: the requested type, minus the streams hidden from the caller. */
64
+ export function aggregateTypeCondition(aggregateType, hidden) {
65
+ if (hidden === undefined)
66
+ return aggregateType;
67
+ return { ...(aggregateType !== undefined && { in: [aggregateType] }), notIn: hidden };
68
+ }
63
69
  /** `tenantCondition` is a tenant id, or an operator object for reads across tenants. */
64
70
  export function buildAuditWhere(tenantCondition, p, direction) {
65
71
  const where = { tenantId: tenantCondition };
66
- if (p.aggregateType)
67
- where["aggregateType"] = p.aggregateType;
72
+ const aggregateType = aggregateTypeCondition(p.aggregateType || undefined, p.hiddenAggregateTypes);
73
+ if (aggregateType !== undefined)
74
+ where["aggregateType"] = aggregateType;
68
75
  if (p.aggregateId)
69
76
  where["aggregateId"] = p.aggregateId;
70
77
  if (p.eventType)
@@ -5,7 +5,7 @@
5
5
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import * as z from "zod";
7
7
  import { AUDIT_SCOPE_VALUES } from "../web/constants.js";
8
- import { auditEventIdSchema, createEntityNounKeyResolver, loadAuditEvent } from "./audit-events.js";
8
+ import { aggregateTypeCondition, auditEventIdSchema, createEntityNounKeyResolver, loadAuditEvent, } from "./audit-events.js";
9
9
  import { requireAuditSystemDb } from "./audit-system-db.js";
10
10
  import { resolveAuditScopeFilter } from "./resolve-audit-tenant.js";
11
11
  export const detailsQuery = defineQueryHandler({
@@ -19,11 +19,10 @@ export const detailsQuery = defineQueryHandler({
19
19
  handler: async (query, ctx) => {
20
20
  const scopeFilter = resolveAuditScopeFilter(query.user, query.payload.scope);
21
21
  const db = requireAuditSystemDb(ctx.systemDb, "audit:query:details").assertTenantMatch(query.user.tenantId);
22
+ const aggregateType = aggregateTypeCondition(scopeFilter.aggregateType, scopeFilter.hiddenAggregateTypes);
22
23
  return loadAuditEvent(db, {
23
24
  tenantId: scopeFilter.tenantId,
24
- ...(scopeFilter.aggregateType !== undefined && {
25
- aggregateType: scopeFilter.aggregateType,
26
- }),
25
+ ...(aggregateType !== undefined && { aggregateType }),
27
26
  id: BigInt(query.payload.id),
28
27
  }, createEntityNounKeyResolver(ctx.registry));
29
28
  },
@@ -32,6 +32,7 @@ export const listQuery = defineQueryHandler({
32
32
  const where = buildAuditWhere(scopeFilter.tenantId, {
33
33
  ...p,
34
34
  aggregateType: scopeFilter.aggregateType ?? p.aggregateType,
35
+ hiddenAggregateTypes: scopeFilter.hiddenAggregateTypes,
35
36
  includeTechnical: includesTechnicalEntries(p.filters),
36
37
  }, direction);
37
38
  const db = requireAuditSystemDb(ctx.systemDb, "audit:query:list").assertTenantMatch(query.user.tenantId);
@@ -3,6 +3,7 @@ import { type AuditScope } from "../web/constants.ts";
3
3
  export type AuditScopeFilter = {
4
4
  readonly tenantId: string;
5
5
  readonly aggregateType?: string;
6
+ readonly hiddenAggregateTypes?: readonly string[];
6
7
  };
7
8
  export declare function resolveAuditScopeFilter(user: SessionUser, scope: AuditScope | undefined): AuditScopeFilter;
8
9
  export declare function assertMayReadAllTenants(user: SessionUser): void;
@@ -2,6 +2,7 @@ import { mayOverrideTenant } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { AccessDeniedError, FrameworkReasons } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import { APP_INSTANCE_STREAM_TYPE } from "@cosmicdrift/kumiko-framework/event-store";
4
4
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-types/identifiers";
5
+ import { SYSTEM_ADMIN_ONLY_AGGREGATE_TYPES } from "../event-classification.js";
5
6
  import { AuditScopes } from "../web/constants.js";
6
7
  function denySystemAdminOnlyRead() {
7
8
  throw new AccessDeniedError({
@@ -12,8 +13,11 @@ function denySystemAdminOnlyRead() {
12
13
  // The system tenant also holds personal streams of other features (session
13
14
  // revocations, exports, ...); only app-instance streams are released.
14
15
  export function resolveAuditScopeFilter(user, scope) {
15
- if (scope !== AuditScopes.system)
16
- return { tenantId: user.tenantId };
16
+ if (scope !== AuditScopes.system) {
17
+ return mayOverrideTenant(user)
18
+ ? { tenantId: user.tenantId }
19
+ : { tenantId: user.tenantId, hiddenAggregateTypes: SYSTEM_ADMIN_ONLY_AGGREGATE_TYPES };
20
+ }
17
21
  if (!mayOverrideTenant(user))
18
22
  denySystemAdminOnlyRead();
19
23
  return { tenantId: SYSTEM_TENANT_ID, aggregateType: APP_INSTANCE_STREAM_TYPE };
@@ -24,6 +24,7 @@ export const AUTH_MFA_FEATURE_I18N = {
24
24
  "mfa.enable.confirm.done": { en: "Two-factor authentication is now enabled." },
25
25
  "screen:auth-mfa-disable.title": { en: "Disable two-factor authentication" },
26
26
  "mfa.disable.submit": { en: "Disable" },
27
+ "mfa.disable.done": { en: "Two-factor authentication is now turned off." },
27
28
  "screen:auth-mfa-regenerate-recovery.title": { en: "Generate new recovery codes" },
28
29
  "mfa.regenerate.submit": { en: "Generate new codes" },
29
30
  "mfa.regenerate.reveal.title": { en: "Your new recovery codes" },
@@ -73,6 +73,9 @@ export const mfaDisableScreen = {
73
73
  layout: { sections: [{ fields: ["code"] }] },
74
74
  submitLabel: i18nKey("mfa.disable.submit"),
75
75
  submitStyle: "danger",
76
+ successMessage: i18nKey("mfa.disable.done"),
77
+ // The used code is spent; clearing it keeps the form from looking like it still waits.
78
+ resetOnSuccess: true,
76
79
  cancelTarget: false,
77
80
  access: {
78
81
  openToAll: {
@@ -19,6 +19,8 @@ import { access, createTenantConfig, defineFeature, EXT_FILE_PROVIDER, FILE_STOR
19
19
  // Moved into the framework — re-exported here so `@cosmicdrift/kumiko-bundled-
20
20
  // features/file-foundation` consumers (user-data-rights, app code) keep working.
21
21
  export { createFileProviderForTenant, isFileProviderPlugin, } from "@cosmicdrift/kumiko-framework/files";
22
+ import { FILE_FOUNDATION_I18N } from "./i18n.js";
23
+ import { registerStorageProbe } from "./probe.js";
22
24
  const FEATURE_NAME = "file-foundation";
23
25
  export function createFileFoundationFeature() {
24
26
  return defineFeature(FEATURE_NAME, (r) => {
@@ -44,6 +46,8 @@ export function createFileFoundationFeature() {
44
46
  // Readiness gating: provider-plugins' required keys/secrets count only
45
47
  // while their plugin is the one this key selects.
46
48
  r.extensionSelector(EXT_FILE_PROVIDER, providerConfigKey);
49
+ r.translations({ keys: FILE_FOUNDATION_I18N });
50
+ registerStorageProbe(r);
47
51
  return { providerConfigKey };
48
52
  }, { dedupeOptions: {} });
49
53
  }
@@ -0,0 +1,20 @@
1
+ export declare const FILE_PROBE_KEYS: {
2
+ readonly area: "file-foundation.probe.area";
3
+ readonly label: "file-foundation.probe.storage-roundtrip.label";
4
+ readonly reachable: "file-foundation.probe.storage-roundtrip.reachable";
5
+ readonly notConfigured: "file-foundation.probe.not-configured";
6
+ };
7
+ export declare const FILE_FOUNDATION_I18N: {
8
+ readonly "file-foundation.probe.area": {
9
+ readonly en: "File storage";
10
+ };
11
+ readonly "file-foundation.probe.storage-roundtrip.label": {
12
+ readonly en: "Write, read and delete a test file";
13
+ };
14
+ readonly "file-foundation.probe.storage-roundtrip.reachable": {
15
+ readonly en: "Storage reachable";
16
+ };
17
+ readonly "file-foundation.probe.not-configured": {
18
+ readonly en: "No file storage set up";
19
+ };
20
+ };
@@ -0,0 +1,12 @@
1
+ export const FILE_PROBE_KEYS = {
2
+ area: "file-foundation.probe.area",
3
+ label: "file-foundation.probe.storage-roundtrip.label",
4
+ reachable: "file-foundation.probe.storage-roundtrip.reachable",
5
+ notConfigured: "file-foundation.probe.not-configured",
6
+ };
7
+ export const FILE_FOUNDATION_I18N = {
8
+ [FILE_PROBE_KEYS.area]: { en: "File storage" },
9
+ [FILE_PROBE_KEYS.label]: { en: "Write, read and delete a test file" },
10
+ [FILE_PROBE_KEYS.reachable]: { en: "Storage reachable" },
11
+ [FILE_PROBE_KEYS.notConfigured]: { en: "No file storage set up" },
12
+ };
@@ -1,2 +1,3 @@
1
1
  export { createFileFoundationFeature, createFileProviderForTenant, type FileProviderContext, type FileProviderPlugin, isFileProviderPlugin, } from "./feature.ts";
2
2
  export { type FileRefContent, FileRefTooLargeError, FileStorageUnavailableError, fileContextForTenant, fileContextFromResolver, readFileRefContent, } from "./read-file-ref-content.ts";
3
+ export { makeTenantStorageProviderResolver, type TenantStorageResolverCtx, } from "./storage-provider-resolver.ts";
@@ -1,3 +1,4 @@
1
1
  // Public API of the file-foundation bundled-feature.
2
2
  export { createFileFoundationFeature, createFileProviderForTenant, isFileProviderPlugin, } from "./feature.js";
3
3
  export { FileRefTooLargeError, FileStorageUnavailableError, fileContextForTenant, fileContextFromResolver, readFileRefContent, } from "./read-file-ref-content.js";
4
+ export { makeTenantStorageProviderResolver, } from "./storage-provider-resolver.js";
@@ -0,0 +1,2 @@
1
+ import { type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
2
+ export declare function registerStorageProbe(r: FeatureRegistrar<"file-foundation">): void;
@@ -0,0 +1,53 @@
1
+ import { createSystemUser, FILE_PROVIDER_CONFIG_KEY, FILE_STORAGE_PROVIDER_BOOT_SENTINEL, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { createConfigAccessor } from "../config/index.js";
3
+ import { FILE_PROBE_KEYS } from "./i18n.js";
4
+ import { makeTenantStorageProviderResolver } from "./storage-provider-resolver.js";
5
+ const PROBE_CONTENT = new TextEncoder().encode("kumiko-readiness-probe");
6
+ function isSameBytes(left, right) {
7
+ return left.length === right.length && left.every((byte, index) => byte === right[index]);
8
+ }
9
+ // Bypasses the file-ref entity on purpose: a probe leaves neither a ref nor events behind.
10
+ export function registerStorageProbe(r) {
11
+ r.probe({
12
+ name: "storage-roundtrip",
13
+ scope: "tenant",
14
+ cost: "free",
15
+ area: FILE_PROBE_KEYS.area,
16
+ label: FILE_PROBE_KEYS.label,
17
+ run: async (ctx) => {
18
+ const { configResolver, secrets } = ctx.services;
19
+ const userId = createSystemUser(ctx.tenantId).id;
20
+ const notConfigured = {
21
+ status: "skipped",
22
+ message: { key: FILE_PROBE_KEYS.notConfigured },
23
+ };
24
+ if (!configResolver)
25
+ return notConfigured;
26
+ const selected = await createConfigAccessor(ctx.registry, configResolver, ctx.tenantId, userId, ctx.db)(FILE_PROVIDER_CONFIG_KEY);
27
+ if (selected === undefined ||
28
+ selected === "" ||
29
+ selected === FILE_STORAGE_PROVIDER_BOOT_SENTINEL) {
30
+ return notConfigured;
31
+ }
32
+ const provider = await makeTenantStorageProviderResolver({
33
+ registry: ctx.registry,
34
+ configResolver,
35
+ secrets,
36
+ db: ctx.db,
37
+ userId,
38
+ handlerName: "file-foundation:probe",
39
+ })(ctx.tenantId);
40
+ const key = `${ctx.tenantId}/__readiness-probe/${ctx.probeRunId}.txt`;
41
+ try {
42
+ await provider.write(key, PROBE_CONTENT, "text/plain");
43
+ if (!isSameBytes(await provider.read(key), PROBE_CONTENT)) {
44
+ throw new Error("storage returned different bytes than were written");
45
+ }
46
+ }
47
+ finally {
48
+ await provider.delete(key);
49
+ }
50
+ return { status: "ok", message: { key: FILE_PROBE_KEYS.reachable } };
51
+ },
52
+ });
53
+ }
@@ -4,8 +4,8 @@
4
4
  // construction). Extracted from the export cron so both crons + the manual
5
5
  // forget handler share one construction site instead of inlining it three times.
6
6
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
7
- import { createConfigAccessor } from "../../config/index.js";
8
- import { createFileProviderForTenant } from "../../file-foundation/index.js";
7
+ import { createFileProviderForTenant } from "@cosmicdrift/kumiko-framework/files";
8
+ import { createConfigAccessor } from "../config/index.js";
9
9
  export function makeTenantStorageProviderResolver(ctx) {
10
10
  return async (tenantId) => {
11
11
  if (!ctx.configResolver) {
@@ -52,6 +52,7 @@ import { listAccountsQuery } from "./handlers/list-accounts.query.js";
52
52
  import { listMessagesQuery } from "./handlers/list-messages.query.js";
53
53
  import { updateAccountHandler } from "./handlers/update-account.write.js";
54
54
  import { INBOUND_MAIL_FOUNDATION_I18N } from "./i18n.js";
55
+ import { registerMailboxProbe } from "./probe.js";
55
56
  import { applyInboundMessageReceived, applyMailAccountConnected, applyMailAccountDisconnected, applyMailAccountUpdated, applyMailThreadUpdated, inboundMessagesProjectionTable, mailAccountsProjectionTable, mailThreadsProjectionTable, } from "./projection.js";
56
57
  import { runInboundMailRetention } from "./retention-sweep.js";
57
58
  import { mailAccountEditScreen, mailAccountListScreen } from "./screens.js";
@@ -149,6 +150,7 @@ export function createInboundMailFoundationFeature() {
149
150
  r.screen(mailAccountListScreen);
150
151
  r.screen(mailAccountEditScreen);
151
152
  r.translations({ keys: INBOUND_MAIL_FOUNDATION_I18N });
153
+ registerMailboxProbe(r);
152
154
  // Entity-specific GDPR destroy hooks — the gdpr-storage boot validator
153
155
  // checks the EXT_TENANT_DATA registration for each tenant-subject entity.
154
156
  r.useExtension(EXT_TENANT_DATA, "mail-account", {
@@ -1,9 +1,11 @@
1
1
  import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { fieldLabelKey } from "@cosmicdrift/kumiko-renderer/headless";
3
3
  import { INBOUND_MAIL_FOUNDATION_FEATURE, InboundMailAccountStatuses } from "./constants.js";
4
+ import { INBOUND_PROBE_I18N } from "./probe-keys.js";
4
5
  import { MAIL_ACCOUNT_EDIT_SCREEN_ID, MAIL_ACCOUNT_LIST_SCREEN_ID, MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX, } from "./screens.js";
5
6
  const formFieldKey = (field) => fieldLabelKey(INBOUND_MAIL_FOUNDATION_FEATURE, ACTION_FORM_ENTITY, field);
6
7
  export const INBOUND_MAIL_FOUNDATION_I18N = {
8
+ ...INBOUND_PROBE_I18N,
7
9
  [`screen:${MAIL_ACCOUNT_LIST_SCREEN_ID}.title`]: { en: "Mailboxes" },
8
10
  [`screen:${MAIL_ACCOUNT_EDIT_SCREEN_ID}.title`]: { en: "Edit mailbox" },
9
11
  "inboundMail.accounts.col.name": { en: "Name" },
@@ -0,0 +1,28 @@
1
+ export declare const INBOUND_PROBE_KEYS: {
2
+ readonly area: "inbound-mail-foundation.probe.area";
3
+ readonly label: "inbound-mail-foundation.probe.mailboxes.label";
4
+ readonly mailbox: "inbound-mail-foundation.probe.mailbox";
5
+ readonly reachable: "inbound-mail-foundation.probe.mailbox.reachable";
6
+ readonly authFailed: "inbound-mail-foundation.probe.mailbox.auth-failed";
7
+ readonly gone: "inbound-mail-foundation.probe.mailbox.gone";
8
+ };
9
+ export declare const INBOUND_PROBE_I18N: {
10
+ readonly "inbound-mail-foundation.probe.area": {
11
+ readonly en: "Inbound email";
12
+ };
13
+ readonly "inbound-mail-foundation.probe.mailboxes.label": {
14
+ readonly en: "Connected mailboxes";
15
+ };
16
+ readonly "inbound-mail-foundation.probe.mailbox": {
17
+ readonly en: "Mailbox {name}";
18
+ };
19
+ readonly "inbound-mail-foundation.probe.mailbox.reachable": {
20
+ readonly en: "Mailbox reachable";
21
+ };
22
+ readonly "inbound-mail-foundation.probe.mailbox.auth-failed": {
23
+ readonly en: "The mailbox rejected the stored login";
24
+ };
25
+ readonly "inbound-mail-foundation.probe.mailbox.gone": {
26
+ readonly en: "Mailbox no longer exists";
27
+ };
28
+ };
@@ -0,0 +1,16 @@
1
+ export const INBOUND_PROBE_KEYS = {
2
+ area: "inbound-mail-foundation.probe.area",
3
+ label: "inbound-mail-foundation.probe.mailboxes.label",
4
+ mailbox: "inbound-mail-foundation.probe.mailbox",
5
+ reachable: "inbound-mail-foundation.probe.mailbox.reachable",
6
+ authFailed: "inbound-mail-foundation.probe.mailbox.auth-failed",
7
+ gone: "inbound-mail-foundation.probe.mailbox.gone",
8
+ };
9
+ export const INBOUND_PROBE_I18N = {
10
+ [INBOUND_PROBE_KEYS.area]: { en: "Inbound email" },
11
+ [INBOUND_PROBE_KEYS.label]: { en: "Connected mailboxes" },
12
+ [INBOUND_PROBE_KEYS.mailbox]: { en: "Mailbox {name}" },
13
+ [INBOUND_PROBE_KEYS.reachable]: { en: "Mailbox reachable" },
14
+ [INBOUND_PROBE_KEYS.authFailed]: { en: "The mailbox rejected the stored login" },
15
+ [INBOUND_PROBE_KEYS.gone]: { en: "Mailbox no longer exists" },
16
+ };
@@ -0,0 +1,2 @@
1
+ import { type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
2
+ export declare function registerMailboxProbe(r: FeatureRegistrar<"inbound-mail-foundation">): void;
@@ -0,0 +1,89 @@
1
+ import { configuredPiiSubjectKms, decryptPiiFieldValues, } from "@cosmicdrift/kumiko-framework/crypto";
2
+ import { createSystemUser, ProbeFailure, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createConfigAccessor } from "../config/index.js";
4
+ import { INBOUND_MAIL_FOUNDATION_FEATURE, InboundMailAccountStatuses } from "./constants.js";
5
+ import { MAIL_ACCOUNT_PII_FIELDS } from "./entities.js";
6
+ import { INBOUND_PROBE_KEYS } from "./probe-keys.js";
7
+ import { mailAccountsProjectionTable } from "./projection.js";
8
+ import { resolveInboundProviderForAccount } from "./provider-factory.js";
9
+ import { isInboundAuthError } from "./types.js";
10
+ const SHORT_ID_LENGTH = 8;
11
+ function providerContext(ctx) {
12
+ const { configResolver, secrets } = ctx.services;
13
+ const auditUserId = createSystemUser(ctx.tenantId).id;
14
+ return {
15
+ registry: ctx.registry,
16
+ ...(secrets && { secrets }),
17
+ auditUserId,
18
+ ...(configResolver && {
19
+ config: createConfigAccessor(ctx.registry, configResolver, ctx.tenantId, auditUserId, ctx.db),
20
+ }),
21
+ };
22
+ }
23
+ // Providers never see ciphertext, so the address is decrypted before the call.
24
+ async function toAccountRecord(row, tenantId) {
25
+ const piiKms = configuredPiiSubjectKms();
26
+ const decrypted = piiKms
27
+ ? await decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
28
+ requestId: "inbound-mail-foundation:probe",
29
+ })
30
+ : row;
31
+ const address = decrypted["address"];
32
+ if (typeof address !== "string")
33
+ throw new Error("mailbox address did not decrypt to text");
34
+ return {
35
+ id: row.id,
36
+ tenantId,
37
+ provider: row.provider,
38
+ authMethod: row.authMethod,
39
+ ownerUserId: row.ownerUserId ?? null,
40
+ address,
41
+ displayName: row.displayName ?? "",
42
+ status: InboundMailAccountStatuses.active,
43
+ watchState: row.watchState ?? "idle",
44
+ };
45
+ }
46
+ export function registerMailboxProbe(r) {
47
+ r.probe({
48
+ name: "mailboxes",
49
+ scope: "tenant",
50
+ cost: "free",
51
+ area: INBOUND_PROBE_KEYS.area,
52
+ label: INBOUND_PROBE_KEYS.label,
53
+ fixScreen: `${INBOUND_MAIL_FOUNDATION_FEATURE}:screen:mail-accounts`,
54
+ // The address is PII, so the target label carries only the tenant-authored display name.
55
+ targets: async (ctx) => {
56
+ const rows = await ctx.db.selectMany(mailAccountsProjectionTable, {
57
+ status: InboundMailAccountStatuses.active,
58
+ });
59
+ return rows.map((row) => ({
60
+ id: row.id,
61
+ label: {
62
+ key: INBOUND_PROBE_KEYS.mailbox,
63
+ params: { name: row.displayName || row.id.slice(0, SHORT_ID_LENGTH) },
64
+ },
65
+ ...(row.ownerUserId != null && { ownerUserId: row.ownerUserId }),
66
+ }));
67
+ },
68
+ run: async (ctx, target) => {
69
+ const row = target
70
+ ? await ctx.db.fetchOne(mailAccountsProjectionTable, { id: target.id })
71
+ : undefined;
72
+ if (!row)
73
+ return { status: "skipped", message: { key: INBOUND_PROBE_KEYS.gone } };
74
+ const account = await toAccountRecord(row, ctx.tenantId);
75
+ const providerCtx = providerContext(ctx);
76
+ const plugin = resolveInboundProviderForAccount(providerCtx, account);
77
+ try {
78
+ await (plugin.probe ?? plugin.verify)(providerCtx, account);
79
+ }
80
+ catch (error) {
81
+ if (isInboundAuthError(error)) {
82
+ throw new ProbeFailure({ key: INBOUND_PROBE_KEYS.authFailed });
83
+ }
84
+ throw error;
85
+ }
86
+ return { status: "ok", message: { key: INBOUND_PROBE_KEYS.reachable } };
87
+ },
88
+ });
89
+ }
@@ -137,6 +137,9 @@ export type InboundMailProviderPlugin = {
137
137
  /** Credential/reachability check during the connect flow — throws a
138
138
  * typed error (InboundAuthError, etc.) and returns no value. */
139
139
  readonly verify: (ctx: InboundMailContext, account: MailAccountRecord) => Promise<void>;
140
+ /** Read-only liveness check for the system check; falls back to `verify`. Must not
141
+ * change mailbox state (no flags, no new connection side effects). */
142
+ readonly probe?: (ctx: InboundMailContext, account: MailAccountRecord) => Promise<void>;
140
143
  /** Fetch since cursor. `cursor === null` means initial population,
141
144
  * limited by backfillWindowDays. */
142
145
  readonly fetch: (ctx: InboundMailContext, account: MailAccountRecord, cursor: SyncCursorPayload | null, opts: {
@@ -29,7 +29,7 @@ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
29
29
  import { instantToLegacyDate } from "@cosmicdrift/kumiko-framework/time";
30
30
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
31
31
  import { parseImapCredentialDocument } from "./credential-document.js";
32
- import { assertUidValidity, coerceDate, createImapClient, IMAP_MAILBOX, imapMailHostGuard, mapImapError, parseImapCursor, toRawInboundMessage, } from "./imap-client.js";
32
+ import { assertUidValidity, coerceDate, connectOnce, createImapClient, IMAP_MAILBOX, imapMailHostGuard, mapImapError, parseImapCursor, toRawInboundMessage, } from "./imap-client.js";
33
33
  const FEATURE_NAME = "inbound-provider-imap";
34
34
  export const IMAP_PROVIDER_KEY = "imap";
35
35
  export { setImapMailHostLookup } from "./imap-client.js";
@@ -200,19 +200,8 @@ async function watchMailbox(ctx, account, handlers) {
200
200
  /** Exported for the live test (imap-live.integration.test.ts) —
201
201
  * applications use the feature, never the plugin directly. */
202
202
  export const imapInboundMailPlugin = {
203
- verify: async (ctx, account) => {
204
- const doc = await readCredentialDocument(ctx, account);
205
- const client = await createImapClient(doc, imapMailHostGuard());
206
- try {
207
- await client.connect();
208
- }
209
- catch (err) {
210
- throw mapImapError(err, doc.host);
211
- }
212
- finally {
213
- await client.logout().catch(() => { });
214
- }
215
- },
203
+ verify: async (ctx, account) => connectOnce(await readCredentialDocument(ctx, account), false),
204
+ probe: async (ctx, account) => connectOnce(await readCredentialDocument(ctx, account), true),
216
205
  fetch: fetchMessages,
217
206
  watch: watchMailbox,
218
207
  // No oauth block: password authentication runs through the credential
@@ -7,6 +7,7 @@ export declare function setImapMailHostLookup(fn: MailHostGuardOptions["lookupFn
7
7
  export declare function imapMailHostGuard(): MailHostGuardOptions;
8
8
  export declare function createImapClient(doc: ImapCredentialDocument, hostGuard?: MailHostGuardOptions): Promise<ImapFlow>;
9
9
  /** Server-Fehler → typisierte Foundation-Fehlerklassen (Plan §2). */
10
+ export declare function connectOnce(doc: ImapCredentialDocument, examineInbox: boolean): Promise<void>;
10
11
  export declare function mapImapError(err: unknown, host: string): Error;
11
12
  export type ImapCursor = {
12
13
  readonly uidValidity: string;
@@ -70,6 +70,21 @@ export async function createImapClient(doc, hostGuard = {}) {
70
70
  });
71
71
  }
72
72
  /** Server-Fehler → typisierte Foundation-Fehlerklassen (Plan §2). */
73
+ // examineInbox opens INBOX with EXAMINE instead of SELECT, so the system check never touches \Recent or flags.
74
+ export async function connectOnce(doc, examineInbox) {
75
+ const client = await createImapClient(doc, imapMailHostGuard());
76
+ try {
77
+ await client.connect();
78
+ if (examineInbox)
79
+ await client.mailboxOpen(IMAP_MAILBOX, { readOnly: true });
80
+ }
81
+ catch (err) {
82
+ throw mapImapError(err, doc.host);
83
+ }
84
+ finally {
85
+ await client.logout().catch(() => { });
86
+ }
87
+ }
73
88
  export function mapImapError(err, host) {
74
89
  // imapflow errors carry the server response in responseText; the
75
90
  // message is only "Command failed", so both are included in the match;
@@ -33,6 +33,7 @@ import { withPiiCiphertextGuard, } from "@cosmicdrift/kumiko-bundled-features/ch
33
33
  import { requireDefined } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
34
34
  import { access, createSystemUser, createTenantConfig, defineFeature, } from "@cosmicdrift/kumiko-framework/engine";
35
35
  import { MAIL_FOUNDATION_I18N } from "./i18n.js";
36
+ import { registerMailProbe } from "./probe.js";
36
37
  const FEATURE_NAME = "mail-foundation";
37
38
  // Extension-point name for transport-provider plugins (mail-transport-smtp,
38
39
  // mail-transport-brevo-api, ...).
@@ -85,6 +86,7 @@ export function createMailFoundationFeature() {
85
86
  // while their plugin is the one this key selects.
86
87
  r.extensionSelector(MAIL_TRANSPORT_EXTENSION, providerConfigKey);
87
88
  r.translations({ keys: MAIL_FOUNDATION_I18N });
89
+ registerMailProbe(r);
88
90
  return {
89
91
  /** Config-key-handle for the provider-selector. */
90
92
  providerConfigKey,
@@ -1,3 +1,14 @@
1
+ export declare const MAIL_PROBE_KEYS: {
2
+ readonly area: "mail-foundation.probe.area";
3
+ readonly label: "mail-foundation.probe.send-test-mail.label";
4
+ readonly accepted: "mail-foundation.probe.send-test-mail.accepted";
5
+ readonly notConfigured: "mail-foundation.probe.not-configured";
6
+ readonly noRecipient: "mail-foundation.probe.no-recipient";
7
+ readonly hostNotFound: "mail-foundation.probe.host-not-found";
8
+ readonly setupIncomplete: "mail-foundation.probe.setup-incomplete";
9
+ readonly setupFailed: "mail-foundation.probe.setup-failed";
10
+ readonly sendRejected: "mail-foundation.probe.send-rejected";
11
+ };
1
12
  export declare const MAIL_FOUNDATION_I18N: {
2
13
  readonly "screen:mail-foundation-tenant.title": {
3
14
  readonly en: "Email sending";
@@ -14,4 +25,31 @@ export declare const MAIL_FOUNDATION_I18N: {
14
25
  readonly "mail-foundation.provider.platformDefault": {
15
26
  readonly en: "Platform sending (default)";
16
27
  };
28
+ readonly "mail-foundation.probe.area": {
29
+ readonly en: "Email";
30
+ };
31
+ readonly "mail-foundation.probe.send-test-mail.label": {
32
+ readonly en: "Test email to the person who started the check";
33
+ };
34
+ readonly "mail-foundation.probe.send-test-mail.accepted": {
35
+ readonly en: "Accepted by the mail server";
36
+ };
37
+ readonly "mail-foundation.probe.not-configured": {
38
+ readonly en: "No own email sending set up";
39
+ };
40
+ readonly "mail-foundation.probe.no-recipient": {
41
+ readonly en: "No email address known for the person who started the check";
42
+ };
43
+ readonly "mail-foundation.probe.host-not-found": {
44
+ readonly en: "Mail server not found, check the server address";
45
+ };
46
+ readonly "mail-foundation.probe.setup-incomplete": {
47
+ readonly en: "Email sending settings are incomplete or the server is not allowed";
48
+ };
49
+ readonly "mail-foundation.probe.setup-failed": {
50
+ readonly en: "Email sending could not be set up with these settings";
51
+ };
52
+ readonly "mail-foundation.probe.send-rejected": {
53
+ readonly en: "The mail server did not accept the test email, check address, port and login";
54
+ };
17
55
  };
@@ -1,3 +1,14 @@
1
+ export const MAIL_PROBE_KEYS = {
2
+ area: "mail-foundation.probe.area",
3
+ label: "mail-foundation.probe.send-test-mail.label",
4
+ accepted: "mail-foundation.probe.send-test-mail.accepted",
5
+ notConfigured: "mail-foundation.probe.not-configured",
6
+ noRecipient: "mail-foundation.probe.no-recipient",
7
+ hostNotFound: "mail-foundation.probe.host-not-found",
8
+ setupIncomplete: "mail-foundation.probe.setup-incomplete",
9
+ setupFailed: "mail-foundation.probe.setup-failed",
10
+ sendRejected: "mail-foundation.probe.send-rejected",
11
+ };
1
12
  export const MAIL_FOUNDATION_I18N = {
2
13
  "screen:mail-foundation-tenant.title": { en: "Email sending" },
3
14
  "mail-foundation.settings": { en: "Email sending" },
@@ -6,4 +17,19 @@ export const MAIL_FOUNDATION_I18N = {
6
17
  en: "Choose who sends your emails. The platform default needs no settings of your own.",
7
18
  },
8
19
  "mail-foundation.provider.platformDefault": { en: "Platform sending (default)" },
20
+ [MAIL_PROBE_KEYS.area]: { en: "Email" },
21
+ [MAIL_PROBE_KEYS.label]: { en: "Test email to the person who started the check" },
22
+ [MAIL_PROBE_KEYS.accepted]: { en: "Accepted by the mail server" },
23
+ [MAIL_PROBE_KEYS.notConfigured]: { en: "No own email sending set up" },
24
+ [MAIL_PROBE_KEYS.noRecipient]: {
25
+ en: "No email address known for the person who started the check",
26
+ },
27
+ [MAIL_PROBE_KEYS.hostNotFound]: { en: "Mail server not found, check the server address" },
28
+ [MAIL_PROBE_KEYS.setupIncomplete]: {
29
+ en: "Email sending settings are incomplete or the server is not allowed",
30
+ },
31
+ [MAIL_PROBE_KEYS.setupFailed]: { en: "Email sending could not be set up with these settings" },
32
+ [MAIL_PROBE_KEYS.sendRejected]: {
33
+ en: "The mail server did not accept the test email, check address, port and login",
34
+ },
9
35
  };
@@ -1 +1,2 @@
1
1
  export { createMailFoundationFeature, createTransportForTenant, createTransportForTenantIfConfigured, isMailTransportPlugin, MAIL_TRANSPORT_EXTENSION, type MailTransportContext, type MailTransportPlugin, type TenantMailTransportScope, tenantMailTransport, } from "./feature.ts";
2
+ export { makeTenantMailTransportResolver, type TenantMailResolverCtx, } from "./mail-transport-resolver.ts";
@@ -7,3 +7,4 @@
7
7
  // - `MailTransportPlugin` — type that provider-features implement
8
8
  // when registering via `r.useExtension("mailTransport", ...)`
9
9
  export { createMailFoundationFeature, createTransportForTenant, createTransportForTenantIfConfigured, isMailTransportPlugin, MAIL_TRANSPORT_EXTENSION, tenantMailTransport, } from "./feature.js";
10
+ export { makeTenantMailTransportResolver, } from "./mail-transport-resolver.js";