@dxos/plugin-jmap 0.10.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 (180) hide show
  1. package/LICENSE +105 -0
  2. package/dist/lib/JmapPlugin.mjs +16 -0
  3. package/dist/lib/JmapPlugin.mjs.map +1 -0
  4. package/dist/lib/apis.mjs +2 -0
  5. package/dist/lib/capabilities.mjs +17 -0
  6. package/dist/lib/capabilities.mjs.map +1 -0
  7. package/dist/lib/chunk-apis.mjs +756 -0
  8. package/dist/lib/chunk-apis.mjs.map +1 -0
  9. package/dist/lib/chunk-connector.mjs +111 -0
  10. package/dist/lib/chunk-connector.mjs.map +1 -0
  11. package/dist/lib/chunk-constants.mjs +23 -0
  12. package/dist/lib/chunk-constants.mjs.map +1 -0
  13. package/dist/lib/chunk-errors.mjs +42 -0
  14. package/dist/lib/chunk-errors.mjs.map +1 -0
  15. package/dist/lib/chunk-handler.mjs +27 -0
  16. package/dist/lib/chunk-handler.mjs.map +1 -0
  17. package/dist/lib/chunk-jmap-fixtures.mjs +102 -0
  18. package/dist/lib/chunk-jmap-fixtures.mjs.map +1 -0
  19. package/dist/lib/chunk-mail-send.mjs +17 -0
  20. package/dist/lib/chunk-mail-send.mjs.map +1 -0
  21. package/dist/lib/chunk-meta.mjs +37 -0
  22. package/dist/lib/chunk-meta.mjs.map +1 -0
  23. package/dist/lib/chunk-operation-handler.mjs +12 -0
  24. package/dist/lib/chunk-operation-handler.mjs.map +1 -0
  25. package/dist/lib/chunk-send.mjs +98 -0
  26. package/dist/lib/chunk-send.mjs.map +1 -0
  27. package/dist/lib/chunk-sync-provider.mjs +511 -0
  28. package/dist/lib/chunk-sync-provider.mjs.map +1 -0
  29. package/dist/lib/chunk-sync.mjs +37 -0
  30. package/dist/lib/chunk-sync.mjs.map +1 -0
  31. package/dist/lib/index.mjs +4 -0
  32. package/dist/lib/meta.mjs +2 -0
  33. package/dist/lib/operations.mjs +13 -0
  34. package/dist/lib/operations.mjs.map +1 -0
  35. package/dist/lib/plugin.mjs +9 -0
  36. package/dist/lib/plugin.mjs.map +1 -0
  37. package/dist/lib/services.mjs +139 -0
  38. package/dist/lib/services.mjs.map +1 -0
  39. package/dist/lib/testing/node.mjs +2 -0
  40. package/dist/lib/testing.mjs +19 -0
  41. package/dist/lib/testing.mjs.map +1 -0
  42. package/dist/lib/translations.mjs +7 -0
  43. package/dist/lib/translations.mjs.map +1 -0
  44. package/dist/types/dx.config.d.ts +28 -0
  45. package/dist/types/dx.config.d.ts.map +1 -0
  46. package/dist/types/src/JmapPlugin.d.ts +9 -0
  47. package/dist/types/src/JmapPlugin.d.ts.map +1 -0
  48. package/dist/types/src/apis/Jmap/api.d.ts +42 -0
  49. package/dist/types/src/apis/Jmap/api.d.ts.map +1 -0
  50. package/dist/types/src/apis/Jmap/index.d.ts +3 -0
  51. package/dist/types/src/apis/Jmap/index.d.ts.map +1 -0
  52. package/dist/types/src/apis/Jmap/types.d.ts +65 -0
  53. package/dist/types/src/apis/Jmap/types.d.ts.map +1 -0
  54. package/dist/types/src/apis/JmapMail/api.d.ts +223 -0
  55. package/dist/types/src/apis/JmapMail/api.d.ts.map +1 -0
  56. package/dist/types/src/apis/JmapMail/index.d.ts +4 -0
  57. package/dist/types/src/apis/JmapMail/index.d.ts.map +1 -0
  58. package/dist/types/src/apis/JmapMail/query.d.ts +26 -0
  59. package/dist/types/src/apis/JmapMail/query.d.ts.map +1 -0
  60. package/dist/types/src/apis/JmapMail/query.test.d.ts +2 -0
  61. package/dist/types/src/apis/JmapMail/query.test.d.ts.map +1 -0
  62. package/dist/types/src/apis/JmapMail/types.d.ts +290 -0
  63. package/dist/types/src/apis/JmapMail/types.d.ts.map +1 -0
  64. package/dist/types/src/apis/index.d.ts +3 -0
  65. package/dist/types/src/apis/index.d.ts.map +1 -0
  66. package/dist/types/src/apis/jmap-api.test.d.ts +2 -0
  67. package/dist/types/src/apis/jmap-api.test.d.ts.map +1 -0
  68. package/dist/types/src/capabilities/connector.d.ts +5 -0
  69. package/dist/types/src/capabilities/connector.d.ts.map +1 -0
  70. package/dist/types/src/capabilities/credential-form.d.ts +37 -0
  71. package/dist/types/src/capabilities/credential-form.d.ts.map +1 -0
  72. package/dist/types/src/capabilities/credential-form.test.d.ts +2 -0
  73. package/dist/types/src/capabilities/credential-form.test.d.ts.map +1 -0
  74. package/dist/types/src/capabilities/index.d.ts +5 -0
  75. package/dist/types/src/capabilities/index.d.ts.map +1 -0
  76. package/dist/types/src/capabilities/mail-send.d.ts +6 -0
  77. package/dist/types/src/capabilities/mail-send.d.ts.map +1 -0
  78. package/dist/types/src/capabilities/operation-handler.d.ts +5 -0
  79. package/dist/types/src/capabilities/operation-handler.d.ts.map +1 -0
  80. package/dist/types/src/constants.d.ts +19 -0
  81. package/dist/types/src/constants.d.ts.map +1 -0
  82. package/dist/types/src/errors.d.ts +103 -0
  83. package/dist/types/src/errors.d.ts.map +1 -0
  84. package/dist/types/src/index.d.ts +4 -0
  85. package/dist/types/src/index.d.ts.map +1 -0
  86. package/dist/types/src/meta.d.ts +33 -0
  87. package/dist/types/src/meta.d.ts.map +1 -0
  88. package/dist/types/src/operations/index.d.ts +3 -0
  89. package/dist/types/src/operations/index.d.ts.map +1 -0
  90. package/dist/types/src/operations/mail/mapper.d.ts +49 -0
  91. package/dist/types/src/operations/mail/mapper.d.ts.map +1 -0
  92. package/dist/types/src/operations/mail/materialize/handler.d.ts +11 -0
  93. package/dist/types/src/operations/mail/materialize/handler.d.ts.map +1 -0
  94. package/dist/types/src/operations/mail/send/handler.d.ts +4 -0
  95. package/dist/types/src/operations/mail/send/handler.d.ts.map +1 -0
  96. package/dist/types/src/operations/mail/send/index.d.ts +2 -0
  97. package/dist/types/src/operations/mail/send/index.d.ts.map +1 -0
  98. package/dist/types/src/operations/mail/sync/handler.d.ts +4 -0
  99. package/dist/types/src/operations/mail/sync/handler.d.ts.map +1 -0
  100. package/dist/types/src/operations/mail/sync/index.d.ts +3 -0
  101. package/dist/types/src/operations/mail/sync/index.d.ts.map +1 -0
  102. package/dist/types/src/operations/mail/sync/mapper.test.d.ts +2 -0
  103. package/dist/types/src/operations/mail/sync/mapper.test.d.ts.map +1 -0
  104. package/dist/types/src/operations/mail/sync/sync-e2e.test.d.ts +2 -0
  105. package/dist/types/src/operations/mail/sync/sync-e2e.test.d.ts.map +1 -0
  106. package/dist/types/src/operations/mail/sync/sync-provider.d.ts +11 -0
  107. package/dist/types/src/operations/mail/sync/sync-provider.d.ts.map +1 -0
  108. package/dist/types/src/operations/mail/sync/sync.test.d.ts +2 -0
  109. package/dist/types/src/operations/mail/sync/sync.test.d.ts.map +1 -0
  110. package/dist/types/src/operations/mail/sync/system-tags.d.ts +14 -0
  111. package/dist/types/src/operations/mail/sync/system-tags.d.ts.map +1 -0
  112. package/dist/types/src/operations/mail/tags.d.ts +12 -0
  113. package/dist/types/src/operations/mail/tags.d.ts.map +1 -0
  114. package/dist/types/src/plugin.d.ts +4 -0
  115. package/dist/types/src/plugin.d.ts.map +1 -0
  116. package/dist/types/src/services/index.d.ts +3 -0
  117. package/dist/types/src/services/index.d.ts.map +1 -0
  118. package/dist/types/src/services/jmap-credentials.d.ts +39 -0
  119. package/dist/types/src/services/jmap-credentials.d.ts.map +1 -0
  120. package/dist/types/src/services/jmap-mail-api.d.ts +109 -0
  121. package/dist/types/src/services/jmap-mail-api.d.ts.map +1 -0
  122. package/dist/types/src/testing/index.d.ts +5 -0
  123. package/dist/types/src/testing/index.d.ts.map +1 -0
  124. package/dist/types/src/testing/jmap-fixtures.d.ts +26 -0
  125. package/dist/types/src/testing/jmap-fixtures.d.ts.map +1 -0
  126. package/dist/types/src/testing/jmap-fixtures.test.d.ts +2 -0
  127. package/dist/types/src/testing/jmap-fixtures.test.d.ts.map +1 -0
  128. package/dist/types/src/testing/node.d.ts +4 -0
  129. package/dist/types/src/testing/node.d.ts.map +1 -0
  130. package/dist/types/src/testing/sync-fixture.d.ts +22 -0
  131. package/dist/types/src/testing/sync-fixture.d.ts.map +1 -0
  132. package/dist/types/src/translations.d.ts +8 -0
  133. package/dist/types/src/translations.d.ts.map +1 -0
  134. package/dist/types/tsconfig.tsbuildinfo +1 -0
  135. package/dx.config.ts +32 -0
  136. package/package.json +121 -0
  137. package/src/JmapPlugin.ts +25 -0
  138. package/src/apis/Jmap/api.ts +138 -0
  139. package/src/apis/Jmap/index.ts +6 -0
  140. package/src/apis/Jmap/types.ts +86 -0
  141. package/src/apis/JmapMail/api.ts +324 -0
  142. package/src/apis/JmapMail/index.ts +7 -0
  143. package/src/apis/JmapMail/query.test.ts +181 -0
  144. package/src/apis/JmapMail/query.ts +231 -0
  145. package/src/apis/JmapMail/types.ts +196 -0
  146. package/src/apis/index.ts +8 -0
  147. package/src/apis/jmap-api.test.ts +261 -0
  148. package/src/capabilities/connector.ts +44 -0
  149. package/src/capabilities/credential-form.test.ts +69 -0
  150. package/src/capabilities/credential-form.ts +113 -0
  151. package/src/capabilities/index.ts +24 -0
  152. package/src/capabilities/mail-send.ts +21 -0
  153. package/src/capabilities/operation-handler.ts +16 -0
  154. package/src/constants.ts +24 -0
  155. package/src/errors.ts +38 -0
  156. package/src/index.ts +7 -0
  157. package/src/meta.ts +9 -0
  158. package/src/operations/index.ts +13 -0
  159. package/src/operations/mail/mapper.ts +191 -0
  160. package/src/operations/mail/materialize/handler.ts +41 -0
  161. package/src/operations/mail/send/handler.ts +116 -0
  162. package/src/operations/mail/send/index.ts +5 -0
  163. package/src/operations/mail/sync/handler.ts +52 -0
  164. package/src/operations/mail/sync/index.ts +7 -0
  165. package/src/operations/mail/sync/mapper.test.ts +150 -0
  166. package/src/operations/mail/sync/sync-e2e.test.ts +63 -0
  167. package/src/operations/mail/sync/sync-provider.ts +481 -0
  168. package/src/operations/mail/sync/sync.test.ts +766 -0
  169. package/src/operations/mail/sync/system-tags.ts +24 -0
  170. package/src/operations/mail/tags.ts +29 -0
  171. package/src/plugin.ts +11 -0
  172. package/src/services/index.ts +6 -0
  173. package/src/services/jmap-credentials.ts +61 -0
  174. package/src/services/jmap-mail-api.ts +294 -0
  175. package/src/testing/index.ts +8 -0
  176. package/src/testing/jmap-fixtures.test.ts +107 -0
  177. package/src/testing/jmap-fixtures.ts +105 -0
  178. package/src/testing/node.ts +12 -0
  179. package/src/testing/sync-fixture.ts +39 -0
  180. package/src/translations.ts +15 -0
@@ -0,0 +1,24 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import type * as SystemTags from '@dxos/plugin-inbox/SystemTags';
6
+
7
+ /**
8
+ * JMAP mailbox role → canonical system tag ({@link SystemTags.SystemTag}). Roles absent here are
9
+ * intentionally dropped: `archive` is derived as "not in inbox" (Gmail's model), and
10
+ * `drafts`/`trash`/`junk` are never synced.
11
+ */
12
+ export const JMAP_ROLE_TAGS: Partial<Record<string, SystemTags.SystemTagId>> = {
13
+ inbox: 'inbox',
14
+ sent: 'sent',
15
+ };
16
+
17
+ /**
18
+ * JMAP keyword → canonical system tag ({@link SystemTags.SystemTag}). Only `$flagged` (starred) is
19
+ * projected; read-state (`$seen`) and the rest (`$answered`, `$draft`, `$forwarded`, …) are intentionally
20
+ * dropped as high-churn and noisier than useful.
21
+ */
22
+ export const JMAP_KEYWORD_TAGS: Partial<Record<string, SystemTags.SystemTagId>> = {
23
+ $flagged: 'starred',
24
+ };
@@ -0,0 +1,29 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Database, Tag } from '@dxos/echo';
6
+
7
+ import { JMAP_DOMAIN } from '../../constants';
8
+
9
+ /**
10
+ * The pre-rename source, matched as a fallback so folders synced before the rename keep their tag
11
+ * identity instead of being duplicated. Remove one release after landing.
12
+ */
13
+ const LEGACY_JMAP_TAG_SOURCE = 'org.ietf.jmap.mailbox';
14
+
15
+ /**
16
+ * Finds an existing JMAP provider {@link Tag} object by its JMAP mailbox-id foreign key, or creates one
17
+ * carrying that key ({@link JMAP_DOMAIN}, which also marks it read-only via `Tag.isProviderTag`). Keeps
18
+ * the folder label in sync with the server on re-sync. Used by `sync/` for custom user folders;
19
+ * well-known roles map onto canonical system tags instead. Mirrors Gmail's `findOrCreateGmailTag`.
20
+ */
21
+ export const findOrCreateJmapTag = (
22
+ db: Database.Database,
23
+ { id, name }: { id: string; name: string },
24
+ ): Promise<Tag.Tag> =>
25
+ Tag.findOrCreate(db, {
26
+ key: { source: JMAP_DOMAIN, id },
27
+ legacyKeys: [{ source: LEGACY_JMAP_TAG_SOURCE, id }],
28
+ label: name,
29
+ });
package/src/plugin.ts ADDED
@@ -0,0 +1,11 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Plugin from '@dxos/app-framework/Plugin';
6
+
7
+ import { meta } from './meta';
8
+
9
+ export const JmapPlugin = Plugin.lazy(meta, () => import('#plugin'));
10
+
11
+ export { JmapOperationHandlerSet } from './operations';
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './jmap-credentials';
6
+ export * from './jmap-mail-api';
@@ -0,0 +1,61 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Context from 'effect/Context';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Layer from 'effect/Layer';
8
+
9
+ import { Database, type Ref } from '@dxos/echo';
10
+ import { type AccessToken } from '@dxos/link';
11
+ import { log } from '@dxos/log';
12
+ import * as Connection from '@dxos/plugin-connector/Connection';
13
+
14
+ /**
15
+ * Credentials needed to talk to a JMAP server: the server `host` (used to discover the session at
16
+ * `https://${host}/.well-known/jmap`), the optional `account` (email/username for display and
17
+ * identity matching), and the Bearer `token`.
18
+ */
19
+ export type Credentials = {
20
+ readonly host: string;
21
+ readonly account: string | undefined;
22
+ readonly token: string;
23
+ };
24
+
25
+ /**
26
+ * Service for accessing JMAP credentials.
27
+ *
28
+ * Mirrors `GoogleCredentials`: an operation invoked with a `Connection` composes
29
+ * `fromConnection(ref)`; one invoked with an external-sync cursor composes
30
+ * `fromAccessToken(cursor.spec.source)` directly (the cursor no longer relates to `Connection`).
31
+ * Unlike Google, there is no database-credential fallback — a JMAP connection always carries host +
32
+ * token in its token record.
33
+ */
34
+ export class JmapCredentials extends Context.Tag('JmapCredentials')<JmapCredentials, Credentials>() {
35
+ /** Creates a credentials layer from an AccessToken ref. Loads its `source` (host), `account`, `token`. */
36
+ static fromAccessToken = (accessTokenRef: Ref.Ref<AccessToken.AccessToken>) =>
37
+ Layer.effect(
38
+ JmapCredentials,
39
+ Effect.gen(function* () {
40
+ const accessToken = yield* Database.load(accessTokenRef);
41
+ log('using access token', { source: accessToken.source });
42
+ return { host: accessToken.source, account: accessToken.account, token: accessToken.token };
43
+ }),
44
+ );
45
+
46
+ /** Creates a credentials layer from a Connection ref. Loads its `accessToken`'s host/account/token. */
47
+ static fromConnection = (connectionRef: Ref.Ref<Connection.Connection>) =>
48
+ Layer.effect(
49
+ JmapCredentials,
50
+ Effect.gen(function* () {
51
+ const connection = yield* Database.load(connectionRef);
52
+ const accessToken = yield* Database.load(connection.accessToken);
53
+ log('using connection access token', { source: accessToken.source });
54
+ return { host: accessToken.source, account: accessToken.account, token: accessToken.token };
55
+ }),
56
+ );
57
+
58
+ /** Creates a credentials layer from explicit values (credential-form validation and tests). */
59
+ static fromValues = (values: { host: string; account?: string; token: string }) =>
60
+ Layer.succeed(JmapCredentials, { host: values.host, account: values.account, token: values.token });
61
+ }
@@ -0,0 +1,294 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import type * as HttpClient from '@effect/platform/HttpClient';
6
+ import * as Context from 'effect/Context';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as Layer from 'effect/Layer';
9
+ import * as Predicate from 'effect/Predicate';
10
+
11
+ import { Jmap, JmapMail } from '../apis';
12
+ import { JmapApiError } from '../errors';
13
+ import { type JmapCredentials } from './jmap-credentials';
14
+
15
+ /**
16
+ * The requirements the underlying {@link Jmap}/{@link JmapMail} request functions carry (HTTP client
17
+ * + JMAP credentials). {@link JmapMailApi.Live} bakes these in so the service methods themselves
18
+ * require nothing — which is what lets a test satisfy {@link JmapMailApi} with a zero-dependency mock
19
+ * (no HTTP, no credentials, no live server).
20
+ */
21
+ type Requirements = HttpClient.HttpClient | JmapCredentials;
22
+
23
+ /**
24
+ * Swappable JMAP API surface. `Live` delegates to the real {@link Jmap}/{@link JmapMail} request
25
+ * functions; tests provide a data-backed mock. Making the JMAP dependency a service (rather than the
26
+ * sync operation calling `Jmap.*`/`JmapMail.*` and hardcoding `FetchHttpClient.layer` internally) is
27
+ * what lets the sync run against generated data with no live account — mirrors `GoogleMailApi`.
28
+ * Every request fails only with {@link JmapApiError}, and methods carry no requirements: `Live`
29
+ * bakes them in (see {@link Requirements}) so a mock can satisfy the surface with no HTTP or creds.
30
+ */
31
+ export interface JmapMailApiService {
32
+ readonly getSession: Effect.Effect<Jmap.Session, JmapApiError>;
33
+ readonly mailboxGet: (target: JmapMail.Target) => Effect.Effect<JmapMail.MailboxGetResult, JmapApiError>;
34
+ readonly emailQuery: (
35
+ target: JmapMail.Target,
36
+ options?: {
37
+ filter?: unknown;
38
+ sort?: readonly { property: string; isAscending?: boolean }[];
39
+ position?: number;
40
+ limit?: number;
41
+ calculateTotal?: boolean;
42
+ },
43
+ ) => Effect.Effect<JmapMail.EmailQueryResult, JmapApiError>;
44
+ readonly emailGet: (
45
+ target: JmapMail.Target,
46
+ ids: readonly string[],
47
+ properties?: readonly string[],
48
+ ) => Effect.Effect<JmapMail.EmailGetResult, JmapApiError>;
49
+ readonly emailChanges: (
50
+ target: JmapMail.Target,
51
+ sinceState: string,
52
+ maxChanges?: number,
53
+ ) => Effect.Effect<JmapMail.EmailChangesResult, JmapApiError>;
54
+ readonly downloadBlob: (
55
+ target: JmapMail.Target,
56
+ blobId: string,
57
+ options?: { name?: string; type?: string },
58
+ ) => Effect.Effect<Uint8Array, JmapApiError>;
59
+ readonly identityGet: (target: JmapMail.Target) => Effect.Effect<JmapMail.IdentityGetResult, JmapApiError>;
60
+ readonly emailSetUpdate: (
61
+ target: JmapMail.Target,
62
+ emailId: string,
63
+ patch: Record<string, unknown>,
64
+ ) => Effect.Effect<JmapMail.EmailSetResult, JmapApiError>;
65
+ readonly submitEmail: (
66
+ target: JmapMail.Target,
67
+ args: {
68
+ identityId: string;
69
+ draftsMailboxId: string;
70
+ sentMailboxId: string;
71
+ draft: {
72
+ from: readonly JmapMail.EmailAddress[];
73
+ to: readonly JmapMail.EmailAddress[];
74
+ cc?: readonly JmapMail.EmailAddress[];
75
+ bcc?: readonly JmapMail.EmailAddress[];
76
+ subject?: string;
77
+ inReplyTo?: readonly string[];
78
+ references?: readonly string[];
79
+ text: string;
80
+ };
81
+ },
82
+ ) => Effect.Effect<{ id: string; threadId: string | undefined }, JmapApiError>;
83
+ }
84
+
85
+ /**
86
+ * An in-memory JMAP dataset a {@link JmapMailApi.mock} serves — the session (account discovery), the
87
+ * folders (mailboxes), and the full emails the sync would fetch. Build one with `generateJmapDataset`
88
+ * from this plugin's `./testing`.
89
+ */
90
+ export interface JmapDataset {
91
+ readonly session: Jmap.Session;
92
+ readonly folders: readonly JmapMail.Mailbox[];
93
+ /** Full emails, ordered ascending by `receivedAt` (the mock sorts/paginates a window over them). */
94
+ readonly emails: readonly JmapMail.Email[];
95
+ /** Attachment bytes keyed by `blobId`, served by `downloadBlob` for emails that carry one. */
96
+ readonly blobs?: Readonly<Record<string, Uint8Array>>;
97
+ /**
98
+ * Current `Email/get` state token — `emailGet` returns it and it's the newest `Email/changes` state.
99
+ * Set to exercise the incremental path; absent means "no delta support" (token capture returns undefined).
100
+ */
101
+ readonly state?: string;
102
+ /** Ordered `Email/changes` steps chaining `sinceState` → `newState`, each carrying the delta. */
103
+ readonly changeLog?: readonly JmapChangeStep[];
104
+ }
105
+
106
+ /** One `Email/changes` step in a {@link JmapDataset}'s change log. */
107
+ export interface JmapChangeStep {
108
+ readonly sinceState: string;
109
+ readonly newState: string;
110
+ readonly created?: readonly string[];
111
+ readonly updated?: readonly string[];
112
+ readonly destroyed?: readonly string[];
113
+ }
114
+
115
+ /** Narrows an `unknown` filter value to a plain object so the mock can walk its conditions. */
116
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
117
+
118
+ /**
119
+ * Evaluates an `Email/query` filter against an email — enough of RFC 8621 §4.4.1 for the sync's
120
+ * generated filter: the `after`/`before` window, `inMailbox`/`inMailboxOtherThan` folder scope, and
121
+ * the AND/OR/NOT operator combinators. Unknown conditions are treated as matching.
122
+ */
123
+ const matchesFilter = (email: JmapMail.Email, filter: unknown): boolean => {
124
+ if (!isRecord(filter)) {
125
+ return true;
126
+ }
127
+ if ('operator' in filter && Array.isArray(filter.conditions)) {
128
+ const results = filter.conditions.map((condition) => matchesFilter(email, condition));
129
+ if (filter.operator === 'OR') {
130
+ return results.some(Boolean);
131
+ }
132
+ if (filter.operator === 'NOT') {
133
+ return !results.some(Boolean);
134
+ }
135
+ return results.every(Boolean); // AND (the default the sync builds).
136
+ }
137
+
138
+ const receivedAt = new Date(email.receivedAt).getTime();
139
+ // `after` is inclusive (>=), `before` is exclusive (<) — matches the sync's window semantics.
140
+ if (typeof filter.after === 'string' && receivedAt < new Date(filter.after).getTime()) {
141
+ return false;
142
+ }
143
+ if (typeof filter.before === 'string' && receivedAt >= new Date(filter.before).getTime()) {
144
+ return false;
145
+ }
146
+ const mailboxIds = email.mailboxIds ? Object.keys(email.mailboxIds) : [];
147
+ if (typeof filter.inMailbox === 'string' && !mailboxIds.includes(filter.inMailbox)) {
148
+ return false;
149
+ }
150
+ if (Array.isArray(filter.inMailboxOtherThan)) {
151
+ const excluded = filter.inMailboxOtherThan;
152
+ if (!mailboxIds.some((id) => !excluded.includes(id))) {
153
+ return false;
154
+ }
155
+ }
156
+ return true;
157
+ };
158
+
159
+ /** Sorts emails by the query's primary sort (only `receivedAt` is used by the sync). */
160
+ const sortEmails = (
161
+ emails: readonly JmapMail.Email[],
162
+ sort: readonly { property: string; isAscending?: boolean }[] | undefined,
163
+ ): readonly JmapMail.Email[] => {
164
+ const primary = sort?.[0];
165
+ if (!primary || primary.property !== 'receivedAt') {
166
+ return emails;
167
+ }
168
+ const direction = primary.isAscending ? 1 : -1;
169
+ return [...emails].sort(
170
+ (left, right) => direction * (new Date(left.receivedAt).getTime() - new Date(right.receivedAt).getTime()),
171
+ );
172
+ };
173
+
174
+ export class JmapMailApi extends Context.Tag('@dxos/plugin-inbox/JmapMailApi')<JmapMailApi, JmapMailApiService>() {
175
+ /**
176
+ * Live layer backed by the real JMAP HTTP client. Captures the auth/HTTP context once and provides
177
+ * it to each request, so the resulting service methods carry no requirements. Requires an
178
+ * `HttpClient` and a `JmapCredentials` (e.g. {@link JmapCredentials.fromConnection}) to be available
179
+ * where it is provided.
180
+ */
181
+ static readonly Live: Layer.Layer<JmapMailApi, never, Requirements> = Layer.effect(
182
+ JmapMailApi,
183
+ Effect.gen(function* () {
184
+ const context = yield* Effect.context<Requirements>();
185
+ return JmapMailApi.of({
186
+ getSession: Effect.provide(Jmap.getSession, context),
187
+ mailboxGet: (target) => Effect.provide(JmapMail.mailboxGet(target), context),
188
+ emailQuery: (target, options) => Effect.provide(JmapMail.emailQuery(target, options), context),
189
+ emailGet: (target, ids, properties) => Effect.provide(JmapMail.emailGet(target, ids, properties), context),
190
+ emailChanges: (target, sinceState, maxChanges) =>
191
+ Effect.provide(JmapMail.emailChanges(target, sinceState, maxChanges), context),
192
+ downloadBlob: (target, blobId, options) =>
193
+ Effect.provide(JmapMail.downloadBlob(target, blobId, options), context),
194
+ identityGet: (target) => Effect.provide(JmapMail.identityGet(target), context),
195
+ emailSetUpdate: (target, emailId, patch) =>
196
+ Effect.provide(JmapMail.emailSetUpdate(target, emailId, patch), context),
197
+ submitEmail: (target, args) => Effect.provide(JmapMail.submitEmail(target, args), context),
198
+ });
199
+ }),
200
+ );
201
+
202
+ /**
203
+ * Zero-dependency mock backed by an in-memory {@link JmapDataset} — for unit tests that drive the
204
+ * real sync pipeline with no live account. `emailQuery` honours the query filter (`after`/`before`
205
+ * window + folder scope), the `receivedAt` sort, and `position`/`limit` pagination, so the sync's
206
+ * window walk and pagination exercise realistically. Write methods are unsupported (die).
207
+ */
208
+ static readonly mock = (dataset: JmapDataset): Layer.Layer<JmapMailApi> => {
209
+ const byId = new Map(dataset.emails.map((email) => [email.id, email]));
210
+ return Layer.succeed(
211
+ JmapMailApi,
212
+ JmapMailApi.of({
213
+ getSession: Effect.succeed(dataset.session),
214
+ mailboxGet: () => Effect.succeed({ list: dataset.folders }),
215
+ emailQuery: (_target, options = {}) =>
216
+ Effect.sync(() => {
217
+ const matching = sortEmails(
218
+ dataset.emails.filter((email) => matchesFilter(email, options.filter)),
219
+ options.sort,
220
+ );
221
+ const position = options.position ?? 0;
222
+ const limit = options.limit ?? matching.length;
223
+ const page = matching.slice(position, position + limit);
224
+ return { position, total: matching.length, ids: page.map((email) => email.id) };
225
+ }),
226
+ emailGet: (_target, ids) =>
227
+ Effect.sync(() => ({
228
+ list: ids.map((id) => byId.get(id)).filter(Predicate.isNotNullable),
229
+ state: dataset.state,
230
+ })),
231
+ // Chains the change-log steps from `sinceState`, honoring `maxChanges`: it stops before a step
232
+ // that would exceed the budget (always including at least one, so a single oversized step still
233
+ // makes progress), sets `hasMoreChanges`, and returns the intermediate `newState` as the chunk
234
+ // boundary to resume from. An unknown/evicted state (no chain match, not already the latest) fails
235
+ // with `cannotCalculateChanges`, matching a server past its retention window.
236
+ emailChanges: (_target, sinceState, maxChanges) =>
237
+ Effect.gen(function* () {
238
+ const latest = dataset.state ?? sinceState;
239
+ if (sinceState === latest) {
240
+ return {
241
+ oldState: sinceState,
242
+ newState: latest,
243
+ hasMoreChanges: false,
244
+ created: [],
245
+ updated: [],
246
+ destroyed: [],
247
+ };
248
+ }
249
+ const created: string[] = [];
250
+ const updated: string[] = [];
251
+ const destroyed: string[] = [];
252
+ let chain = sinceState;
253
+ let matched = false;
254
+ let hasMoreChanges = false;
255
+ for (const step of dataset.changeLog ?? []) {
256
+ if (step.sinceState === chain) {
257
+ const stepSize =
258
+ (step.created?.length ?? 0) + (step.updated?.length ?? 0) + (step.destroyed?.length ?? 0);
259
+ // Stop before overflowing the budget, but never before the first step (guarantees progress).
260
+ if (
261
+ maxChanges !== undefined &&
262
+ chain !== sinceState &&
263
+ created.length + updated.length + destroyed.length + stepSize > maxChanges
264
+ ) {
265
+ hasMoreChanges = true;
266
+ break;
267
+ }
268
+ matched = true;
269
+ created.push(...(step.created ?? []));
270
+ updated.push(...(step.updated ?? []));
271
+ destroyed.push(...(step.destroyed ?? []));
272
+ chain = step.newState;
273
+ }
274
+ }
275
+ if (!matched) {
276
+ return yield* Effect.fail(
277
+ new JmapApiError(undefined, 'Cannot calculate changes', 'cannotCalculateChanges'),
278
+ );
279
+ }
280
+ return { oldState: sinceState, newState: chain, hasMoreChanges, created, updated, destroyed };
281
+ }),
282
+ downloadBlob: (_target, blobId) => {
283
+ const bytes = dataset.blobs?.[blobId];
284
+ return bytes
285
+ ? Effect.succeed(bytes)
286
+ : Effect.die(new Error(`mock JmapMailApi: blob not in dataset: ${blobId}`));
287
+ },
288
+ identityGet: () => Effect.die(new Error('mock JmapMailApi: identityGet not supported')),
289
+ emailSetUpdate: () => Effect.die(new Error('mock JmapMailApi: emailSetUpdate not supported')),
290
+ submitEmail: () => Effect.die(new Error('mock JmapMailApi: submitEmail not supported')),
291
+ }),
292
+ );
293
+ };
294
+ }
@@ -0,0 +1,8 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './jmap-fixtures';
6
+ export * from './sync-fixture';
7
+ export type { JmapDataset } from '../services';
8
+ export type { Jmap } from '../apis';
@@ -0,0 +1,107 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+ import { describe, test } from 'vitest';
7
+
8
+ import { EffectEx } from '@dxos/effect';
9
+
10
+ import { type JmapMail } from '../apis';
11
+ import { JmapMailApi } from '../services';
12
+ import { generateJmapDataset } from './jmap-fixtures';
13
+
14
+ const MAIL_ACCOUNT_CAPABILITY = 'urn:ietf:params:jmap:mail';
15
+ const INBOX_ID = 'mb-inbox';
16
+
17
+ describe('generateJmapDataset + JmapMailApi.mock', () => {
18
+ test('generates a coherent, ascending-by-date dataset', ({ expect }) => {
19
+ const { session, folders, emails } = generateJmapDataset({ count: 30, seed: 1 });
20
+ expect(session.primaryAccounts[MAIL_ACCOUNT_CAPABILITY]).toBeTruthy();
21
+ expect(folders.some((folder) => folder.role === 'inbox')).toBe(true);
22
+ expect(emails).toHaveLength(30);
23
+ // Every email has the sender + body the mapper reads.
24
+ for (const email of emails) {
25
+ expect(email.from?.[0]?.email).toMatch(/@/);
26
+ expect(email.subject).toBeTruthy();
27
+ const partId = email.textBody?.[0]?.partId;
28
+ expect(partId && email.bodyValues?.[partId]?.value).toBeTruthy();
29
+ }
30
+ // Ascending by receivedAt (the mock's window filter + sort rely on it).
31
+ const dates = emails.map((email) => new Date(email.receivedAt).getTime());
32
+ expect(dates).toEqual([...dates].sort((a, b) => a - b));
33
+ });
34
+
35
+ test('is deterministic for a fixed seed', ({ expect }) => {
36
+ // Pin the window too: with the default window anchored to `new Date()`, two calls a moment apart
37
+ // would produce different receivedAt values.
38
+ const window = { start: new Date('2026-01-01T00:00:00Z'), end: new Date('2026-02-01T00:00:00Z') };
39
+ const first = generateJmapDataset({ count: 10, seed: 7, ...window });
40
+ const second = generateJmapDataset({ count: 10, seed: 7, ...window });
41
+ expect(second.emails).toEqual(first.emails);
42
+ });
43
+
44
+ test('mock service discovers the session, folders, and paginates emails', async ({ expect }) => {
45
+ const dataset = generateJmapDataset({ count: 25, seed: 3 });
46
+ const accountId = dataset.session.primaryAccounts[MAIL_ACCOUNT_CAPABILITY];
47
+ const target: JmapMail.Target = { apiUrl: dataset.session.apiUrl, accountId };
48
+
49
+ const program = Effect.gen(function* () {
50
+ const api = yield* JmapMailApi;
51
+
52
+ const session = yield* api.getSession;
53
+ expect(session.apiUrl).toBe(dataset.session.apiUrl);
54
+
55
+ const { list: folders } = yield* api.mailboxGet(target);
56
+ expect(folders).toEqual(dataset.folders);
57
+
58
+ // Sorted newest-first; first page.
59
+ const sort = [{ property: 'receivedAt', isAscending: false }];
60
+ const page1 = yield* api.emailQuery(target, { sort, position: 0, limit: 10 });
61
+ expect(page1.ids).toHaveLength(10);
62
+ expect(page1.total).toBe(25);
63
+
64
+ // Walk the rest via position.
65
+ const page2 = yield* api.emailQuery(target, { sort, position: 10, limit: 10 });
66
+ const page3 = yield* api.emailQuery(target, { sort, position: 20, limit: 10 });
67
+ expect(page2.ids).toHaveLength(10);
68
+ expect(page3.ids).toHaveLength(5);
69
+
70
+ // No overlap; newest id first.
71
+ const all = [...page1.ids, ...page2.ids, ...page3.ids];
72
+ expect(new Set(all).size).toBe(25);
73
+
74
+ // emailGet resolves full bodies.
75
+ const { list } = yield* api.emailGet(target, [page1.ids[0]]);
76
+ expect(list[0]?.id).toBe(page1.ids[0]);
77
+ const partId = list[0]?.textBody?.[0]?.partId;
78
+ expect(partId && list[0]?.bodyValues?.[partId]?.value).toBeTruthy();
79
+ });
80
+
81
+ await EffectEx.runPromise(program.pipe(Effect.provide(JmapMailApi.mock(dataset))));
82
+ });
83
+
84
+ test('mock service honours the after/before window and folder scope', async ({ expect }) => {
85
+ const start = new Date('2026-01-01T00:00:00Z');
86
+ const end = new Date('2026-01-31T00:00:00Z');
87
+ const dataset = generateJmapDataset({ count: 30, seed: 5, start, end });
88
+ const accountId = dataset.session.primaryAccounts[MAIL_ACCOUNT_CAPABILITY];
89
+ const target: JmapMail.Target = { apiUrl: dataset.session.apiUrl, accountId };
90
+
91
+ const program = Effect.gen(function* () {
92
+ const api = yield* JmapMailApi;
93
+ // A window covering only the back half of the dataset, scoped to the inbox.
94
+ const { total } = yield* api.emailQuery(target, {
95
+ filter: { operator: 'AND', conditions: [{ after: '2026-01-16T00:00:00Z' }, { inMailbox: INBOX_ID }] },
96
+ });
97
+ expect(total).toBeGreaterThan(0);
98
+ expect(total).toBeLessThan(30);
99
+
100
+ // A folder with no emails yields nothing.
101
+ const empty = yield* api.emailQuery(target, { filter: { inMailbox: 'mb-sent' } });
102
+ expect(empty.total).toBe(0);
103
+ });
104
+
105
+ await EffectEx.runPromise(program.pipe(Effect.provide(JmapMailApi.mock(dataset))));
106
+ });
107
+ });
@@ -0,0 +1,105 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { subDays } from 'date-fns';
6
+
7
+ import { random } from '@dxos/random';
8
+
9
+ import { JmapMail } from '../apis';
10
+ import { type JmapDataset } from '../services';
11
+
12
+ export interface GenerateJmapDatasetOptions {
13
+ /** Number of emails to generate. */
14
+ count?: number;
15
+ /** Distinct conversation threads to spread emails across. Defaults to ~count/3. */
16
+ threads?: number;
17
+ /** Distinct senders. Defaults to ~count/5. */
18
+ senders?: number;
19
+ /** Custom (user) folder names, in addition to the always-present system folders. */
20
+ folders?: readonly string[];
21
+ /** Seed for deterministic output (same seed → same dataset). */
22
+ seed?: number;
23
+ /** `receivedAt` window. Defaults to [now − 90 days, now]. */
24
+ start?: Date;
25
+ end?: Date;
26
+ /** Prefix for email + thread ids, so disjoint datasets (e.g. date bands) don't collide. Defaults to `eml`. */
27
+ idPrefix?: string;
28
+ }
29
+
30
+ const MAIL_ACCOUNT_CAPABILITY = 'urn:ietf:params:jmap:mail';
31
+ const ACCOUNT_ID = 'test-account';
32
+ const INBOX_ID = 'mb-inbox';
33
+
34
+ /** System folders every JMAP account has; the sync excludes trash/junk/drafts from the default scope. */
35
+ const SYSTEM_FOLDERS: readonly JmapMail.Mailbox[] = [
36
+ { id: INBOX_ID, name: 'Inbox', role: 'inbox' },
37
+ { id: 'mb-sent', name: 'Sent', role: 'sent' },
38
+ { id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
39
+ { id: 'mb-trash', name: 'Trash', role: 'trash' },
40
+ { id: 'mb-junk', name: 'Junk', role: 'junk' },
41
+ ];
42
+
43
+ /** The session a {@link JmapMailApi.mock} serves — a single mail account discovered at `apiUrl`. */
44
+ const SESSION = {
45
+ apiUrl: 'https://jmap.test/api/',
46
+ username: 'test@jmap.test',
47
+ primaryAccounts: { [MAIL_ACCOUNT_CAPABILITY]: ACCOUNT_ID },
48
+ downloadUrl: 'https://jmap.test/download/{accountId}/{blobId}/{name}?type={type}',
49
+ };
50
+
51
+ /**
52
+ * Deterministically generates a {@link JmapDataset} for driving {@link JmapMailApi.mock} in unit
53
+ * tests — no live account. Emails are ordered ascending by `receivedAt` and spread across the
54
+ * [start, end] window (all in the Inbox) so the sync's window walk + pagination exercise realistically.
55
+ * Mirrors `generateGmailDataset`.
56
+ */
57
+ export const generateJmapDataset = (options: GenerateJmapDatasetOptions = {}): JmapDataset => {
58
+ const count = options.count ?? 100;
59
+ const threadCount = Math.max(1, options.threads ?? Math.ceil(count / 3));
60
+ const senderCount = Math.max(1, options.senders ?? Math.ceil(count / 5));
61
+ const end = options.end ?? new Date();
62
+ const start = options.start ?? subDays(end, 90);
63
+ const idPrefix = options.idPrefix ?? 'eml';
64
+ random.seed(options.seed ?? 42);
65
+
66
+ const customFolders: JmapMail.Mailbox[] = (options.folders ?? ['Work', 'Personal']).map((name, index) => ({
67
+ id: `mb-custom-${index + 1}`,
68
+ name,
69
+ role: null,
70
+ }));
71
+ const folders = [...SYSTEM_FOLDERS, ...customFolders];
72
+
73
+ const senders = Array.from({ length: senderCount }, () => ({
74
+ name: random.person.fullName(),
75
+ email: random.internet.email(),
76
+ }));
77
+ const threadIds = Array.from({ length: threadCount }, (_, index) => `${idPrefix}-thread-${index}`);
78
+
79
+ const startMs = start.getTime();
80
+ const endMs = end.getTime();
81
+
82
+ const emails: JmapMail.Email[] = Array.from({ length: count }, (_, index) => {
83
+ const sender = random.helpers.arrayElement(senders);
84
+ const threadId = random.helpers.arrayElement(threadIds);
85
+ // Monotonic ascending across the window (the mock relies on ascending receivedAt).
86
+ const receivedAtMs = Math.round(startMs + ((endMs - startMs) * index) / Math.max(1, count));
87
+ const subject = random.lorem.sentence();
88
+ const body = random.lorem.paragraph();
89
+
90
+ return {
91
+ id: `${idPrefix}-${index}`,
92
+ threadId,
93
+ mailboxIds: { [INBOX_ID]: true },
94
+ from: [{ name: sender.name, email: sender.email }],
95
+ to: [{ email: 'me@jmap.test' }],
96
+ subject,
97
+ receivedAt: new Date(receivedAtMs).toISOString(),
98
+ preview: body.slice(0, 100),
99
+ bodyValues: { body: { value: body } },
100
+ textBody: [{ partId: 'body', type: 'text/plain' }],
101
+ };
102
+ });
103
+
104
+ return { session: SESSION, folders, emails };
105
+ };
@@ -0,0 +1,12 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // Deliberately does NOT re-export `./index` — that pulls `./sync-fixture`, which reaches
6
+ // `@dxos/plugin-inbox/sync` and so `@dxos/compute` → `@dxos/ai`, whose parser uses `parsimmon`: a CJS
7
+ // module Playwright's esbuild-based Node loader mishandles (`parsimmon.regexp is not a function`). The
8
+ // same split, for the same reason, as `@dxos/plugin-inbox`'s `testing/node.ts`. Node consumers get the
9
+ // deterministic fixtures plus the API contracts needed to build a faithful HTTP mock.
10
+ export * from './jmap-fixtures';
11
+ export type { JmapDataset } from '../services';
12
+ export type { Jmap } from '../apis';