@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.160.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 (163) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
  3. package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
  4. package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
  5. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
  6. package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
  7. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
  8. package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
  9. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
  10. package/src/auth-email-password/constants.ts +15 -0
  11. package/src/auth-email-password/email-templates.ts +32 -0
  12. package/src/auth-email-password/errors.ts +9 -0
  13. package/src/auth-email-password/feature.ts +37 -5
  14. package/src/auth-email-password/handlers/change-password.write.ts +2 -1
  15. package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
  16. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
  17. package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
  19. package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
  20. package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
  21. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
  22. package/src/auth-email-password/i18n.ts +42 -0
  23. package/src/auth-email-password/index.ts +2 -0
  24. package/src/auth-email-password/lockout-store.ts +10 -5
  25. package/src/auth-email-password/password-policy.ts +2108 -0
  26. package/src/auth-email-password/signed-token.ts +1 -0
  27. package/src/auth-email-password/unlock-token.ts +28 -0
  28. package/src/auth-email-password/web/__tests__/auth-client.test.ts +525 -0
  29. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
  30. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
  31. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
  32. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +125 -0
  33. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
  34. package/src/auth-email-password/web/auth-client.ts +33 -0
  35. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
  36. package/src/auth-email-password/web/index.ts +6 -0
  37. package/src/auth-email-password/web/login-screen.tsx +15 -0
  38. package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
  39. package/src/auth-foundation/__tests__/feature.test.ts +33 -0
  40. package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
  41. package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
  42. package/src/auth-foundation/boot-checks.ts +91 -0
  43. package/src/auth-foundation/feature.ts +103 -0
  44. package/src/auth-foundation/index.ts +23 -0
  45. package/src/auth-foundation/types.ts +107 -0
  46. package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
  47. package/src/auth-mfa/__tests__/verify.integration.test.ts +44 -0
  48. package/src/auth-mfa/handlers/verify.write.ts +1 -1
  49. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
  50. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
  52. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
  53. package/src/channel-email/pii-guard.ts +6 -3
  54. package/src/config/__tests__/app-overrides.test.ts +11 -0
  55. package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
  56. package/src/config/__tests__/cascade.integration.test.ts +39 -0
  57. package/src/config/__tests__/config.integration.test.ts +14 -0
  58. package/src/config/__tests__/pii-encrypted.integration.test.ts +23 -2
  59. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
  61. package/src/config/feature.ts +2 -4
  62. package/src/config/handlers/set.write.ts +7 -1
  63. package/src/config/resolver.ts +1 -1
  64. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
  65. package/src/delivery/handlers/log.query.ts +1 -1
  66. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
  67. package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
  68. package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
  69. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
  70. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
  71. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
  72. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
  73. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
  74. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
  75. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
  76. package/src/inbound-provider-inmemory/feature.ts +2 -2
  77. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
  78. package/src/jobs/handlers/detail.query.ts +1 -1
  79. package/src/jobs/handlers/list.query.ts +1 -1
  80. package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
  81. package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
  82. package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
  83. package/src/managed-pages/handlers/branding.query.ts +5 -1
  84. package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
  85. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
  86. package/src/personal-access-tokens/feature.ts +23 -8
  87. package/src/personal-access-tokens/handlers/list.query.ts +1 -1
  88. package/src/personal-access-tokens/index.ts +0 -14
  89. package/src/personal-access-tokens/resolver.ts +2 -2
  90. package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
  91. package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
  92. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
  93. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
  94. package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
  95. package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
  96. package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
  97. package/src/sessions/__tests__/test-helpers.ts +15 -2
  98. package/src/sessions/feature.ts +31 -3
  99. package/src/sessions/handlers/detail.query.ts +2 -2
  100. package/src/sessions/handlers/list.query.ts +4 -2
  101. package/src/sessions/handlers/mine.query.ts +4 -2
  102. package/src/sessions/testing.ts +14 -1
  103. package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
  104. package/src/shared/decrypt-stored-pii.ts +12 -5
  105. package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
  106. package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
  107. package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
  108. package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
  109. package/src/tenant/handlers/invitations.query.ts +4 -2
  110. package/src/tenant/handlers/members.query.ts +2 -2
  111. package/src/tenant/seeding.ts +2 -2
  112. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
  113. package/src/tenant-lifecycle/boot-checks.ts +53 -0
  114. package/src/tenant-lifecycle/feature.ts +7 -0
  115. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  116. package/src/tier-engine/feature.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +20 -0
  118. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
  119. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
  120. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
  121. package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
  122. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
  123. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
  124. package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
  125. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
  126. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
  127. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
  128. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
  129. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
  130. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
  131. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
  132. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
  133. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
  134. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
  135. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
  136. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
  137. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
  138. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
  139. package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
  140. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
  141. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
  142. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +184 -1
  143. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
  144. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
  145. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
  146. package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
  147. package/src/user-data-rights/boot-checks.ts +86 -0
  148. package/src/user-data-rights/feature.ts +116 -6
  149. package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
  150. package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  151. package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
  152. package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
  153. package/src/user-data-rights/run-export-jobs.ts +3 -3
  154. package/src/user-data-rights/run-forget-cleanup.ts +1 -1
  155. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
  156. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
  157. package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
  159. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
  160. package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
  161. package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
  162. package/src/user-profile/handlers/change-email.write.ts +1 -1
  163. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
@@ -26,6 +26,7 @@ import {
26
26
  encryptPiiFieldValues,
27
27
  } from "@cosmicdrift/kumiko-framework/crypto";
28
28
  import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
29
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
29
30
  import { Temporal } from "temporal-polyfill";
30
31
  import { z } from "zod";
31
32
  import { inboundMessageAggregateId, mailThreadAggregateId } from "../aggregate-id";
@@ -99,6 +100,7 @@ function buildThreadKey(p: IngestMessagePayload, effectiveMessageIdHeader: strin
99
100
  return `mid:${effectiveMessageIdHeader}`.slice(0, 500);
100
101
  }
101
102
 
103
+ const THREAD_ROLLUP_MAX_ATTEMPTS = 5;
102
104
  export const ingestMessageHandler: WriteHandlerDef = {
103
105
  name: "ingest-message",
104
106
  schema: ingestMessageSchema,
@@ -221,46 +223,33 @@ export const ingestMessageHandler: WriteHandlerDef = {
221
223
  }
222
224
 
223
225
  // ---------------------------------------------------------------
224
- // 5. Thread-Rollup. Der Handler berechnet den NEUEN Snapshot
225
- // (count via Live-COUNT, max(lastMessageAt)) — die apply ist ein
226
- // dummer UPSERT und bleibt replay-idempotent. lastMessageAtIso ist
227
- // kein PII (Zeitpunkt), damit plaintext-vergleichbar; subject
228
- // wird frisch encrypted (jede Thread-Version trägt das Subject
229
- // der jüngsten Mail).
226
+ // 5. Thread-Rollup. New snapshot (live COUNT + max(lastMessageAt)) via
227
+ // ctx.tryAppendEvent in a bounded retry loop — the apply stays a
228
+ // dumb, replay-idempotent UPSERT. lastMessageAtIso is not PII (a
229
+ // timestamp), so it stays plaintext-comparable; subject is
230
+ // encrypted once up front (constant across retries, doesn't
231
+ // depend on the thread's prior state).
230
232
  //
231
- // messageCount kommt bewusst NICHT aus einem frueher gelesenen
232
- // threadEvents-Snapshot (previousCount+1): getStreamVersion in
233
- // unsafeAppendEvent liest die Stream-Version FRISCH zum
234
- // Append-Zeitpunkt, unabhaengig vom Read hier — zwei parallele
235
- // ingests auf denselben Thread (Watch-Push + Poll-Reconciliation)
236
- // koennten beide denselben previousCount sehen und der Append der
237
- // zweiten TX wuerde trotzdem gelingen (kein VersionConflictError,
238
- // da der Version-Check nur die Stream-Version prueft), sodass
239
- // previousCount+1 dauerhaft nach unten drieftet.
233
+ // messageCount deliberately does NOT come from a previously loaded
234
+ // threadEvents snapshot (previousCount+1): two concurrent ingests
235
+ // on the same thread (Watch-Push vs. Poll-Reconciliation overlap,
236
+ // or two different messages of the same thread arriving at once)
237
+ // can both read the same stream version before either commits.
238
+ // tryAppendEvent's fresh version-read at append time then makes
239
+ // the loser's append fail with a VersionConflictError instead of
240
+ // silently succeeding — so we retry: reload the thread stream,
241
+ // recompute the live row-count and lastMessageAt, and append
242
+ // again. Unlike step 4, a lost race here must NOT be treated as a
243
+ // no-op duplicate — skipping would leave this message's
244
+ // contribution to messageCount/lastMessageAt out of the thread
245
+ // forever. Exhausting all attempts throws, rolling back the whole
246
+ // TX (including the step-4 message append) for a clean re-ingest
247
+ // on the next poll — never return success with a stale snapshot.
240
248
  //
241
- // Ein COUNT gegen read_inbound_messages SCHLIESST das Race-Fenster
242
- // NICHT (COUNT und Append sind weiterhin zwei getrennte Statements —
243
- // zwischen COUNT und Append kann die andere TX committen und derselbe
244
- // Drift theoretisch weiterhin auftreten). Was der COUNT aendert: er
245
- // macht den Fehler SELBSTKORRIGIEREND statt kumulativ — jeder
246
- // nachfolgende ingest liest den echten Row-Count neu, statt einen
247
- // frueher falsch geschriebenen previousCount fortzuschreiben. Eine
248
- // echte Race-freie Loesung braucht ein atomares Read+Append (z.B.
249
- // SELECT ... FOR UPDATE oder eine Advisory-Lock auf threadAggId) —
250
- // das ist bewusst nicht Teil dieses Fixes (siehe PR-Beschreibung).
249
+ // A true race-free read+append (e.g. SELECT ... FOR UPDATE or an
250
+ // advisory lock on threadAggId) is deliberately not part of this
251
+ // fix — two writers (watch + poll) make bounded retry sufficient.
251
252
  // ---------------------------------------------------------------
252
- const threadEvents = await ctx.loadAggregate(threadAggId);
253
- const lastThreadEvent = threadEvents[threadEvents.length - 1];
254
- // @cast-boundary engine-payload — eigene events, shape via defineEvent
255
- const previousThread = lastThreadEvent?.payload as MailThreadEventPayload | undefined;
256
- const previousLastAt = previousThread?.lastMessageAtIso ?? "";
257
- const newLastAt =
258
- payload.receivedAtIso > previousLastAt ? payload.receivedAtIso : previousLastAt;
259
- const messageCount = await countWhere(ctx.db.raw, inboundMessagesProjectionTable, {
260
- tenantId,
261
- threadKey,
262
- });
263
-
264
253
  const threadPlainPii = { tenantId, subject: payload.subject };
265
254
  const encryptedThreadFields = piiKms
266
255
  ? await encryptPiiFieldValues(
@@ -275,19 +264,40 @@ export const ingestMessageHandler: WriteHandlerDef = {
275
264
  )
276
265
  : threadPlainPii;
277
266
 
278
- const threadEventPayload: MailThreadEventPayload = {
279
- threadKey,
280
- subject: encryptedThreadFields["subject"] as string,
281
- lastMessageAtIso: newLastAt,
282
- messageCount,
283
- };
284
- await ctx.unsafeAppendEvent({
285
- aggregateId: threadAggId,
286
- aggregateType: MAIL_THREAD_AGGREGATE_TYPE,
287
- type: MAIL_THREAD_UPDATED_EVENT_QN,
288
- payload: threadEventPayload,
289
- headers: messageHeaders,
290
- });
267
+ let threadAppendOk = false;
268
+ for (let attempt = 1; attempt <= THREAD_ROLLUP_MAX_ATTEMPTS && !threadAppendOk; attempt++) {
269
+ const threadEvents = await ctx.loadAggregate(threadAggId);
270
+ const lastThreadEvent = threadEvents[threadEvents.length - 1];
271
+ // @cast-boundary engine-payload — eigene events, shape via defineEvent
272
+ const previousThread = lastThreadEvent?.payload as MailThreadEventPayload | undefined;
273
+ const previousLastAt = previousThread?.lastMessageAtIso ?? "";
274
+ const newLastAt =
275
+ payload.receivedAtIso > previousLastAt ? payload.receivedAtIso : previousLastAt;
276
+ const messageCount = await countWhere(ctx.db.raw, inboundMessagesProjectionTable, {
277
+ tenantId,
278
+ threadKey,
279
+ });
280
+
281
+ const threadEventPayload: MailThreadEventPayload = {
282
+ threadKey,
283
+ subject: encryptedThreadFields["subject"] as string,
284
+ lastMessageAtIso: newLastAt,
285
+ messageCount,
286
+ };
287
+ const threadAppend = await ctx.tryAppendEvent({
288
+ aggregateId: threadAggId,
289
+ aggregateType: MAIL_THREAD_AGGREGATE_TYPE,
290
+ type: MAIL_THREAD_UPDATED_EVENT_QN,
291
+ payload: threadEventPayload,
292
+ headers: messageHeaders,
293
+ });
294
+ threadAppendOk = threadAppend.ok;
295
+ }
296
+ if (!threadAppendOk) {
297
+ throw new InternalError({
298
+ message: `ingest-message: thread-rollup append for "${threadAggId}" lost the version-conflict race ${THREAD_ROLLUP_MAX_ATTEMPTS} times in a row — aborting for a clean re-ingest instead of persisting a stale messageCount.`,
299
+ });
300
+ }
291
301
 
292
302
  // ---------------------------------------------------------------
293
303
  // 6. Dedup-Anchor. Läuft in derselben TX wie die appends — stirbt
@@ -25,6 +25,7 @@ import {
25
25
  type MailAccountRecord,
26
26
  type RawInboundMessage,
27
27
  } from "../../inbound-mail-foundation";
28
+ import { describeInboundMailProviderContract } from "../../inbound-mail-foundation/__tests__/inbound-mail-provider-contract";
28
29
  import { imapInboundMailPlugin } from "../feature";
29
30
 
30
31
  const HOST = process.env["IMAP_LIVE_HOST"] ?? "127.0.0.1";
@@ -192,3 +193,14 @@ describe("imap-live — echter Server (greenmail)", () => {
192
193
  20_000,
193
194
  );
194
195
  });
196
+
197
+ describeInboundMailProviderContract(
198
+ "imap (greenmail)",
199
+ () => ({
200
+ plugin: imapInboundMailPlugin,
201
+ ctx: ctxWithCredentials(credentialDoc(PASSWORD)),
202
+ account,
203
+ seed: (subject) => sendTestMail(subject, "contract seed"),
204
+ }),
205
+ { skip: !available },
206
+ );
@@ -0,0 +1,331 @@
1
+ // Always-on unit coverage for imapInboundMailPlugin.verify/fetch/watch.
2
+ // Greenmail suites skip in CI when the container is down — this file mocks
3
+ // imapflow so the plugin body stays on the coverage badge without Docker.
4
+
5
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
6
+ import { EventEmitter } from "node:events";
7
+ import { createSecret } from "@cosmicdrift/kumiko-framework/secrets";
8
+ import {
9
+ type InboundMailContext,
10
+ isInboundAuthError,
11
+ isInboundCursorInvalidError,
12
+ isInboundTransientError,
13
+ type MailAccountRecord,
14
+ type RawInboundMessage,
15
+ } from "../../inbound-mail-foundation";
16
+
17
+ type FakeMsg = {
18
+ readonly uid: number;
19
+ readonly source: Buffer;
20
+ readonly internalDate: Date;
21
+ };
22
+
23
+ type FakeState = {
24
+ connectError?: Error;
25
+ authFailed?: boolean;
26
+ uidValidity: string;
27
+ uidNext: number;
28
+ messages: FakeMsg[];
29
+ /** When set, search() returns this list instead of all message uids. */
30
+ searchUids?: number[];
31
+ };
32
+
33
+ let state: FakeState;
34
+ let lastIdleClient: FakeImapFlow | undefined;
35
+
36
+ class FakeImapFlow extends EventEmitter {
37
+ mailbox: { uidValidity: bigint; uidNext: number } | false = false;
38
+ private idleReject: ((err: Error) => void) | undefined;
39
+
40
+ constructor(_opts: unknown) {
41
+ super();
42
+ lastIdleClient = this;
43
+ }
44
+
45
+ async connect(): Promise<void> {
46
+ if (state.connectError) {
47
+ const err = state.connectError as Error & { authenticationFailed?: boolean };
48
+ if (state.authFailed) err.authenticationFailed = true;
49
+ throw err;
50
+ }
51
+ }
52
+
53
+ async logout(): Promise<void> {
54
+ this.idleReject?.(new Error("connection closed"));
55
+ this.idleReject = undefined;
56
+ }
57
+
58
+ async getMailboxLock(_box: string): Promise<{ release: () => void }> {
59
+ this.mailbox = {
60
+ uidValidity: BigInt(state.uidValidity),
61
+ uidNext: state.uidNext,
62
+ };
63
+ return { release: () => {} };
64
+ }
65
+
66
+ async mailboxOpen(_box: string): Promise<{ uidValidity: bigint; uidNext: number }> {
67
+ this.mailbox = {
68
+ uidValidity: BigInt(state.uidValidity),
69
+ uidNext: state.uidNext,
70
+ };
71
+ return this.mailbox;
72
+ }
73
+
74
+ async search(_query: unknown, _opts?: unknown): Promise<number[]> {
75
+ return state.searchUids ?? state.messages.map((m) => m.uid);
76
+ }
77
+
78
+ async *fetch(
79
+ range: string,
80
+ _opts?: unknown,
81
+ _uidOpts?: unknown,
82
+ ): AsyncGenerator<{
83
+ uid: number;
84
+ source: Buffer;
85
+ internalDate: Date;
86
+ }> {
87
+ // Real imapflow is called with either "N,M,…" (explicit uid list) or "N:*".
88
+ const wanted = new Set<number>();
89
+ if (range.includes("*")) {
90
+ const start = Number(range.split(":")[0]);
91
+ for (const m of state.messages) {
92
+ if (m.uid >= start) wanted.add(m.uid);
93
+ }
94
+ } else {
95
+ for (const part of range.split(",")) {
96
+ const n = Number(part.trim());
97
+ if (Number.isFinite(n)) wanted.add(n);
98
+ }
99
+ }
100
+ for (const m of state.messages) {
101
+ if (wanted.size > 0 && !wanted.has(m.uid)) continue;
102
+ yield { uid: m.uid, source: m.source, internalDate: m.internalDate };
103
+ }
104
+ }
105
+
106
+ async idle(): Promise<void> {
107
+ await new Promise<void>((_resolve, reject) => {
108
+ this.idleReject = reject;
109
+ });
110
+ }
111
+ }
112
+
113
+ mock.module("imapflow", () => ({ ImapFlow: FakeImapFlow }));
114
+
115
+ const { imapInboundMailPlugin } = await import("../feature");
116
+
117
+ function mime(subject: string, text: string): Buffer {
118
+ return Buffer.from(
119
+ [
120
+ "From: Sender <sender@example.com>",
121
+ "To: inbox@example.com",
122
+ `Subject: ${subject}`,
123
+ "Message-ID: <msg-1@example.com>",
124
+ "Date: Mon, 01 Jan 2024 12:00:00 +0000",
125
+ "MIME-Version: 1.0",
126
+ "Content-Type: text/plain; charset=utf-8",
127
+ "",
128
+ text,
129
+ ].join("\r\n"),
130
+ );
131
+ }
132
+
133
+ const account: MailAccountRecord = {
134
+ id: "00000000-0000-4000-8000-000000001a01",
135
+ tenantId: "00000000-0000-4000-8000-000000004242",
136
+ provider: "imap",
137
+ authMethod: "password",
138
+ ownerUserId: null,
139
+ address: "inbox@example.com",
140
+ displayName: "Mock",
141
+ status: "active",
142
+ watchState: "idle",
143
+ };
144
+
145
+ function ctxWithDoc(doc: string | null): InboundMailContext {
146
+ return {
147
+ _userId: "imap-plugin-mocked",
148
+ secrets: {
149
+ get: async () => (doc === null ? null : createSecret(doc)),
150
+ has: async () => doc !== null,
151
+ set: async () => {},
152
+ delete: async () => {},
153
+ } as unknown as import("@cosmicdrift/kumiko-framework/secrets").SecretsContext, // @cast-boundary test-double
154
+ };
155
+ }
156
+
157
+ const goodDoc = JSON.stringify({
158
+ host: "imap.example.com",
159
+ port: 993,
160
+ secure: true,
161
+ user: "u@example.com",
162
+ password: "pw",
163
+ });
164
+
165
+ beforeEach(() => {
166
+ lastIdleClient = undefined;
167
+ state = {
168
+ uidValidity: "17",
169
+ uidNext: 3,
170
+ messages: [
171
+ {
172
+ uid: 1,
173
+ source: mime("one", "hello one"),
174
+ internalDate: new Date("2024-01-01T12:00:00Z"),
175
+ },
176
+ {
177
+ uid: 2,
178
+ source: mime("two", "hello two"),
179
+ internalDate: new Date("2024-01-02T12:00:00Z"),
180
+ },
181
+ ],
182
+ };
183
+ });
184
+
185
+ describe("imapInboundMailPlugin — mocked imapflow", () => {
186
+ test("verify: connect+logout happy path", async () => {
187
+ await expect(
188
+ imapInboundMailPlugin.verify(ctxWithDoc(goodDoc), account),
189
+ ).resolves.toBeUndefined();
190
+ });
191
+
192
+ test("verify: auth failure → InboundAuthError", async () => {
193
+ state.connectError = new Error("Authentication failed");
194
+ state.authFailed = true;
195
+ try {
196
+ await imapInboundMailPlugin.verify(ctxWithDoc(goodDoc), account);
197
+ expect.unreachable("expected verify to throw");
198
+ } catch (e) {
199
+ expect(isInboundAuthError(e)).toBe(true);
200
+ }
201
+ });
202
+
203
+ test("verify: missing credential secret → InboundAuthError", async () => {
204
+ try {
205
+ await imapInboundMailPlugin.verify(ctxWithDoc(null), account);
206
+ expect.unreachable("expected verify to throw");
207
+ } catch (e) {
208
+ expect(isInboundAuthError(e)).toBe(true);
209
+ }
210
+ });
211
+
212
+ test("verify: malformed credential JSON → InboundAuthError", async () => {
213
+ try {
214
+ await imapInboundMailPlugin.verify(ctxWithDoc("not-json"), account);
215
+ expect.unreachable("expected verify to throw");
216
+ } catch (e) {
217
+ expect(isInboundAuthError(e)).toBe(true);
218
+ }
219
+ });
220
+
221
+ test("fetch: backfill (null cursor) returns messages + nextCursor", async () => {
222
+ const result = await imapInboundMailPlugin.fetch(ctxWithDoc(goodDoc), account, null, {
223
+ backfillWindowDays: 7,
224
+ maxMessages: 50,
225
+ });
226
+ expect(result.messages).toHaveLength(2);
227
+ expect(result.messages[0]?.subject).toBe("one");
228
+ expect(result.nextCursor).toEqual({ uidValidity: "17", lastUid: 2 });
229
+ expect(result.hasMore).toBe(false);
230
+ });
231
+
232
+ test("fetch: incremental cursor filters uid <= lastUid + hasMore via maxMessages", async () => {
233
+ state.searchUids = [1, 2, 3, 4];
234
+ state.messages = [
235
+ {
236
+ uid: 2,
237
+ source: mime("two", "x"),
238
+ internalDate: new Date("2024-01-02T12:00:00Z"),
239
+ },
240
+ {
241
+ uid: 3,
242
+ source: mime("three", "y"),
243
+ internalDate: new Date("2024-01-03T12:00:00Z"),
244
+ },
245
+ {
246
+ uid: 4,
247
+ source: mime("four", "z"),
248
+ internalDate: new Date("2024-01-04T12:00:00Z"),
249
+ },
250
+ ];
251
+ const result = await imapInboundMailPlugin.fetch(
252
+ ctxWithDoc(goodDoc),
253
+ account,
254
+ { uidValidity: "17", lastUid: 1 },
255
+ { backfillWindowDays: 7, maxMessages: 2 },
256
+ );
257
+ // search returns 2,3,4 (> lastUid=1); maxMessages=2 → hasMore
258
+ expect(result.messages.length).toBeLessThanOrEqual(2);
259
+ expect(result.hasMore).toBe(true);
260
+ expect(result.nextCursor["uidValidity"]).toBe("17");
261
+ });
262
+
263
+ test("fetch: UIDVALIDITY change → InboundCursorInvalidError", async () => {
264
+ try {
265
+ await imapInboundMailPlugin.fetch(
266
+ ctxWithDoc(goodDoc),
267
+ account,
268
+ { uidValidity: "16", lastUid: 1 },
269
+ { backfillWindowDays: 7, maxMessages: 50 },
270
+ );
271
+ expect.unreachable("expected fetch to throw");
272
+ } catch (e) {
273
+ expect(isInboundCursorInvalidError(e)).toBe(true);
274
+ }
275
+ });
276
+
277
+ test("fetch: network error → InboundTransientError", async () => {
278
+ state.connectError = new Error("ECONNREFUSED");
279
+ try {
280
+ await imapInboundMailPlugin.fetch(ctxWithDoc(goodDoc), account, null, {
281
+ backfillWindowDays: 7,
282
+ maxMessages: 50,
283
+ });
284
+ expect.unreachable("expected fetch to throw");
285
+ } catch (e) {
286
+ expect(isInboundTransientError(e)).toBe(true);
287
+ }
288
+ });
289
+
290
+ test("watch: exists → onMessages, stop() tears down", async () => {
291
+ expect(imapInboundMailPlugin.watch).toBeDefined();
292
+ const received: RawInboundMessage[][] = [];
293
+ const stop = await imapInboundMailPlugin.watch!(ctxWithDoc(goodDoc), account, {
294
+ onMessages: async (msgs) => {
295
+ received.push([...msgs]);
296
+ },
297
+ onError: () => {},
298
+ });
299
+
300
+ // New mail above uidNext-1 (uidNext=3 → nextUid starts at 3)
301
+ state.messages = [
302
+ {
303
+ uid: 3,
304
+ source: mime("pushed", "idle body"),
305
+ internalDate: new Date("2024-01-05T12:00:00Z"),
306
+ },
307
+ ];
308
+ lastIdleClient?.emit("exists");
309
+
310
+ await Bun.sleep(50);
311
+ expect(received.some((batch) => batch.some((m) => m.subject === "pushed"))).toBe(true);
312
+
313
+ await stop();
314
+ });
315
+
316
+ test("watch: client error → onError once", async () => {
317
+ let errors = 0;
318
+ const stop = await imapInboundMailPlugin.watch!(ctxWithDoc(goodDoc), account, {
319
+ onMessages: async () => {},
320
+ onError: () => {
321
+ errors += 1;
322
+ },
323
+ });
324
+ lastIdleClient?.emit("error", new Error("socket hang up"));
325
+ await Bun.sleep(20);
326
+ lastIdleClient?.emit("error", new Error("second"));
327
+ await Bun.sleep(20);
328
+ expect(errors).toBe(1);
329
+ await stop().catch(() => {});
330
+ });
331
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { describeInboundMailProviderContract } from "../../inbound-mail-foundation/__tests__/inbound-mail-provider-contract";
3
+ import {
4
+ inboundProviderInMemoryFeature,
5
+ inMemoryInboundMailPlugin,
6
+ seedInboundMessage,
7
+ } from "../feature";
8
+
9
+ describe("inboundProviderInMemoryFeature — shape", () => {
10
+ test("has the expected name + requirement", () => {
11
+ expect(inboundProviderInMemoryFeature.name).toBe("inbound-provider-inmemory");
12
+ expect(inboundProviderInMemoryFeature.requires).toContain("inbound-mail-foundation");
13
+ });
14
+ });
15
+
16
+ describeInboundMailProviderContract("inmemory", () => {
17
+ const accountId = crypto.randomUUID();
18
+ return {
19
+ plugin: inMemoryInboundMailPlugin,
20
+ ctx: {},
21
+ account: {
22
+ id: accountId,
23
+ tenantId: "00000000-0000-4000-8000-000000004242",
24
+ provider: "inmemory",
25
+ authMethod: "password",
26
+ ownerUserId: null,
27
+ address: "contract@example.com",
28
+ displayName: "Contract-Test",
29
+ status: "active",
30
+ watchState: "idle",
31
+ },
32
+ seed: (subject) =>
33
+ seedInboundMessage(accountId, {
34
+ providerMessageId: crypto.randomUUID(),
35
+ messageIdHeader: null,
36
+ providerThreadId: null,
37
+ references: [],
38
+ from: "sender@example.com",
39
+ to: ["contract@example.com"],
40
+ cc: [],
41
+ subject,
42
+ snippet: subject,
43
+ receivedAtIso: "2026-07-01T10:00:00Z",
44
+ rawMime: null,
45
+ scope: "inbox",
46
+ }),
47
+ };
48
+ });
@@ -102,7 +102,7 @@ function offsetOf(cursor: SyncCursorPayload | null): number {
102
102
  return typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? raw : 0;
103
103
  }
104
104
 
105
- const plugin: InboundMailProviderPlugin = {
105
+ export const inMemoryInboundMailPlugin: InboundMailProviderPlugin = {
106
106
  verify: async (_ctx, account: MailAccountRecord) => {
107
107
  const state = stateFor(account.id);
108
108
  if (state.nextVerifyError) {
@@ -150,5 +150,5 @@ export const inboundProviderInMemoryFeature = defineFeature(FEATURE_NAME, (r) =>
150
150
  recommended: false,
151
151
  });
152
152
  r.requires("inbound-mail-foundation");
153
- r.useExtension("inboundMailProvider", INMEMORY_PROVIDER_KEY, plugin);
153
+ r.useExtension("inboundMailProvider", INMEMORY_PROVIDER_KEY, inMemoryInboundMailPlugin);
154
154
  });
@@ -12,7 +12,12 @@ import {
12
12
  createEventStoreExecutor,
13
13
  createTenantDb,
14
14
  } from "@cosmicdrift/kumiko-framework/db";
15
- import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
15
+ import {
16
+ createEntity,
17
+ createTextField,
18
+ defineFeature,
19
+ type JobContext,
20
+ } from "@cosmicdrift/kumiko-framework/engine";
16
21
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
22
  import {
18
23
  setupTestStack,
@@ -20,6 +25,7 @@ import {
20
25
  TestUsers,
21
26
  unsafeCreateEntityTable,
22
27
  } from "@cosmicdrift/kumiko-framework/stack";
28
+ import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
23
29
  import { createJobsFeature } from "../feature";
24
30
  import { reindexEntityJob } from "../handlers/reindex-entity.job";
25
31
 
@@ -43,6 +49,16 @@ const widgetFeature = defineFeature("reindex-job-test", (r) => {
43
49
  let stack: TestStack;
44
50
  const admin = TestUsers.admin;
45
51
 
52
+ const noopLogger: JobContext["log"] = {
53
+ info() {},
54
+ warn() {},
55
+ error() {},
56
+ debug() {},
57
+ child() {
58
+ return noopLogger;
59
+ },
60
+ };
61
+
46
62
  beforeAll(async () => {
47
63
  stack = await setupTestStack({ features: [widgetFeature, createJobsFeature()] });
48
64
  await unsafeCreateEntityTable(stack.db, widgetEntity);
@@ -87,6 +103,42 @@ describe("reindexEntityJob", () => {
87
103
  expect(postResults.some((r) => r.entityId === created.data.id)).toBe(true);
88
104
  });
89
105
 
106
+ test("the registered job handler resolves a systemUser-scoped fan-out run and actually indexes (not reindexEntityJob called directly)", async () => {
107
+ // The two direct-call tests above bypass jobRunner.dispatch()'s
108
+ // ctx-resolution/guard chain entirely — a bug there (e.g. a systemUser
109
+ // that isn't wired through) would be invisible to them. Invoke the
110
+ // ACTUAL registered handler, same as retention-cleanup's handler test.
111
+ const executor = createEventStoreExecutor(widgetTable, widgetEntity, { entityName: "widget" });
112
+ const tenantDb = createTenantDb(stack.db, admin.tenantId, "system");
113
+ const created = await executor.create({ name: "Registry-Resolved Widget" }, admin, tenantDb);
114
+ if (!created.isSuccess) throw new Error("seed failed");
115
+
116
+ const preResults = await stack.search.search(admin.tenantId, "registry-resolved", {
117
+ filterType: "widget",
118
+ });
119
+ expect(preResults).toHaveLength(0);
120
+
121
+ const job = stack.registry.getJob(REINDEX_ENTITY_JOB);
122
+ expect(job).toBeDefined();
123
+ if (!job) return;
124
+
125
+ const ctx: JobContext = {
126
+ db: stack.db,
127
+ registry: stack.registry,
128
+ searchAdapter: stack.search,
129
+ systemUser: admin,
130
+ log: noopLogger,
131
+ triggeredBy: null,
132
+ ...bridgeStub(),
133
+ };
134
+ await job.handler({ entity: "widget" }, ctx);
135
+
136
+ const postResults = await stack.search.search(admin.tenantId, "registry-resolved", {
137
+ filterType: "widget",
138
+ });
139
+ expect(postResults.some((r) => r.entityId === created.data.id)).toBe(true);
140
+ });
141
+
90
142
  test("skips silently when no tenant is resolvable (fan-out misfire)", async () => {
91
143
  await expect(
92
144
  reindexEntityJob(
@@ -21,7 +21,7 @@ export const detailQuery = defineQueryHandler({
21
21
 
22
22
  // payload is stored encrypted under the triggering user's DEK (#799).
23
23
  if (typeof row["payload"] === "string") {
24
- row["payload"] = await decryptStoredPii(row["payload"], "job-run-detail");
24
+ row["payload"] = await decryptStoredPii(row["payload"], "payload", "job-run-detail");
25
25
  }
26
26
 
27
27
  const logs = await selectMany(
@@ -6,7 +6,7 @@ import { jobRunsTable } from "../job-run-table";
6
6
 
7
7
  async function decryptRunPayload<T extends Record<string, unknown>>(row: T): Promise<T> {
8
8
  if (typeof row["payload"] !== "string") return row;
9
- return { ...row, payload: await decryptStoredPii(row["payload"], "job-runs") };
9
+ return { ...row, payload: await decryptStoredPii(row["payload"], "payload", "job-runs") };
10
10
  }
11
11
 
12
12
  export const listQuery = defineQueryHandler({