@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,481 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Chunk from 'effect/Chunk';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Layer from 'effect/Layer';
8
+ import * as Option from 'effect/Option';
9
+ import * as Predicate from 'effect/Predicate';
10
+ import * as Stream from 'effect/Stream';
11
+
12
+ import { type Resolver, resolve } from '@dxos/extractor';
13
+ import { Cursor } from '@dxos/link';
14
+ import { log } from '@dxos/log';
15
+ import { Stage } from '@dxos/pipeline';
16
+ import { EmailStage } from '@dxos/pipeline-email';
17
+ import * as Mailbox from '@dxos/plugin-inbox/Mailbox';
18
+ import { MailSyncError, type MailSyncItem, MailSyncProvider, type MailSyncSource } from '@dxos/plugin-inbox/sync';
19
+ import type * as SyncStreamConfig from '@dxos/plugin-inbox/SyncStreamConfig';
20
+ import * as SystemTags from '@dxos/plugin-inbox/SystemTags';
21
+ import { TagIndex } from '@dxos/schema';
22
+ import { Person } from '@dxos/types';
23
+
24
+ import { Jmap, JmapMail } from '../../../apis';
25
+ import { JMAP_DOMAIN } from '../../../constants';
26
+ import { type JmapApiError } from '../../../errors';
27
+ import { JmapMailApi } from '../../../services';
28
+ import { type AttachmentMetadata, decodeBody, mapToMessage } from '../mapper';
29
+ import { findOrCreateJmapTag } from '../tags';
30
+ import { JMAP_KEYWORD_TAGS, JMAP_ROLE_TAGS } from './system-tags';
31
+
32
+ /** The resolved delta for one run — either a fresh capture (no delta) or a fetched `Email/changes` chunk. */
33
+ type DeltaPlan = {
34
+ readonly token: string | undefined;
35
+ readonly createdIds: readonly string[] | undefined;
36
+ readonly updatedIds: readonly string[];
37
+ readonly hasMoreDelta: boolean;
38
+ };
39
+
40
+ const MAIL_ACCOUNT_CAPABILITY = 'urn:ietf:params:jmap:mail';
41
+
42
+ /** JMAP mail's streaming-pipeline tuning; see {@link SyncStreamConfig.SyncStreamConfig}. */
43
+ const JMAP_SYNC_CONFIG = {
44
+ listPageSize: 50,
45
+ fetchConcurrency: 5,
46
+ commitPageSize: 10,
47
+ maxItemsPerRun: 500,
48
+ } as const satisfies SyncStreamConfig.SyncStreamConfig;
49
+
50
+ /**
51
+ * JMAP's {@link MailSyncProvider}: session/account discovery, the email source, the folder→tag map, and
52
+ * the fused decode+map. Captures {@link JmapMailApi} + {@link Resolver} so the harness never names them.
53
+ * Mirror of the Gmail provider (`googleMailSyncProvider`).
54
+ */
55
+ export const jmapMailSyncProvider = (): Layer.Layer<MailSyncProvider, never, JmapMailApi | Resolver> =>
56
+ Layer.effect(
57
+ MailSyncProvider,
58
+ Effect.gen(function* () {
59
+ // The API is provided into the source stream (leaving `Cursor.Service` for the harness); the full
60
+ // context into each `process` (whose only needs are API + resolver).
61
+ const context = yield* Effect.context<JmapMailApi | Resolver>();
62
+ const providerApi = yield* JmapMailApi;
63
+ return {
64
+ name: 'jmap',
65
+ config: JMAP_SYNC_CONFIG,
66
+ foreignKeySource: JMAP_DOMAIN,
67
+ prepare: ({ db, binding, now, token, maxMessages }) =>
68
+ Effect.gen(function* () {
69
+ const api = yield* JmapMailApi;
70
+ const session = yield* api.getSession;
71
+ const accountId = session.primaryAccounts[MAIL_ACCOUNT_CAPABILITY];
72
+ if (!accountId) {
73
+ log.warn('jmap sync: session has no mail account', { username: session.username });
74
+ return undefined;
75
+ }
76
+ const target: JmapMail.Target = { apiUrl: session.apiUrl, accountId, downloadUrl: session.downloadUrl };
77
+ log('jmap sync: session resolved', { apiUrl: session.apiUrl, accountId });
78
+
79
+ // TODO(wittjosiah): Migrate this folder→Tag sync onto a pipeline (source: folders; sink: find-or-create Tag).
80
+ // Build a folder-id → tag-uri map — mirrors Gmail's `syncLabels`. A well-known role
81
+ // (`inbox`/`sent`) maps onto the shared canonical system tag; a custom folder gets a
82
+ // JMAP-scoped provider tag; a dropped role (`archive` — derived as "not in inbox";
83
+ // `drafts`/`trash`/`junk` — not synced) produces no tag.
84
+ const { list: folders } = yield* api.mailboxGet(target);
85
+ const folderTagMap = new Map<string, string>();
86
+ for (const folder of folders) {
87
+ const canonical = folder.role ? JMAP_ROLE_TAGS[folder.role] : undefined;
88
+ if (canonical) {
89
+ const tag = yield* Effect.promise(() => SystemTags.findOrCreateSystemTag(db, canonical));
90
+ folderTagMap.set(folder.id, Mailbox.tagUri(tag));
91
+ } else if (folder.role) {
92
+ continue;
93
+ } else {
94
+ const tag = yield* Effect.promise(() => findOrCreateJmapTag(db, { id: folder.id, name: folder.name }));
95
+ folderTagMap.set(folder.id, Mailbox.tagUri(tag));
96
+ }
97
+ }
98
+
99
+ // Keyword → canonical system tag uri (only `$flagged`/starred today). Built once so the
100
+ // initial map and the reconcile diff resolve the same tags.
101
+ const keywordTagMap = new Map<string, string>();
102
+ for (const [keyword, canonical] of Object.entries(JMAP_KEYWORD_TAGS)) {
103
+ if (!canonical) {
104
+ continue;
105
+ }
106
+ const tag = yield* Effect.promise(() => SystemTags.findOrCreateSystemTag(db, canonical));
107
+ keywordTagMap.set(keyword, Mailbox.tagUri(tag));
108
+ }
109
+
110
+ // Fused decode + map; `undefined` drops the item (no body, or unmappable). Constructs the
111
+ // `Change` (an `insert`) directly, so no separate wrapping stage is needed downstream.
112
+ const toMapped = (
113
+ email: JmapMail.Email,
114
+ ): Effect.Effect<EmailStage.Change | undefined, never, JmapMailApi | Resolver> =>
115
+ Effect.gen(function* () {
116
+ const decoded = decodeBody(email);
117
+ if (!decoded) {
118
+ return undefined;
119
+ }
120
+ const fromAddress = decoded.raw.from?.[0];
121
+ const contact = fromAddress ? yield* resolve(Person.Person, { email: fromAddress.email }) : undefined;
122
+ const mapped = mapToMessage(decoded, contact ?? undefined);
123
+ if (!mapped) {
124
+ return undefined;
125
+ }
126
+ const folderUris = mapped.mailboxIds.flatMap((folderId) => {
127
+ const uri = folderTagMap.get(folderId);
128
+ return uri ? [uri] : [];
129
+ });
130
+ const keywordUris = mapped.keywords.flatMap((keyword) => {
131
+ const uri = keywordTagMap.get(keyword);
132
+ return uri ? [uri] : [];
133
+ });
134
+ const tagUris = [...folderUris, ...keywordUris];
135
+ const attachments = yield* fetchAttachments(target, decoded.attachments);
136
+ return {
137
+ _tag: 'insert',
138
+ message: mapped.message,
139
+ foreignId: decoded.raw.id,
140
+ key: new Date(decoded.raw.receivedAt).getTime(),
141
+ tagUris,
142
+ attachments,
143
+ } satisfies EmailStage.Change;
144
+ });
145
+
146
+ const toItem = (email: JmapMail.Email): MailSyncItem => ({
147
+ foreignId: email.id,
148
+ key: new Date(email.receivedAt).getTime(),
149
+ process: toMapped(email).pipe(Effect.provide(context)),
150
+ });
151
+
152
+ // The first-tick baseline (and stale-token fallback): the current `Email/get` state with no
153
+ // delta applied (so mail arriving during backfill is caught by the next incremental, not
154
+ // missed). Defined once so both call sites share the same capture.
155
+ const captureFreshDelta = Effect.map(
156
+ api.emailGet(target, []),
157
+ (result): DeltaPlan => ({
158
+ token: result.state,
159
+ createdIds: undefined,
160
+ updatedIds: [],
161
+ hasMoreDelta: false,
162
+ }),
163
+ );
164
+
165
+ // Resolve the delta plan. An incremental run fetches one bounded `Email/changes` chunk since
166
+ // the token (`maxChanges` = the per-run budget); `hasMoreChanges` drives `runAgain`, and
167
+ // `newState` is the chunk boundary the token advances to — so a large delta drains across
168
+ // runs. A stale token (`cannotCalculateChanges`) falls back to `captureFreshDelta`;
169
+ // `Effect.catchIf` recovers only that case.
170
+ const resolveDelta: Effect.Effect<DeltaPlan, JmapApiError, never> =
171
+ token === undefined
172
+ ? captureFreshDelta
173
+ : api.emailChanges(target, token, maxMessages).pipe(
174
+ Effect.map((result): DeltaPlan => {
175
+ // `updated` are ids whose keywords/mailboxIds may have changed — re-fetched + diffed
176
+ // against local tags in the reconcile branch. Exclude ids that are also newly
177
+ // created (their full fetch already carries current tags).
178
+ const created = new Set(result.created);
179
+ const updatedIds = result.updated.filter((id) => !created.has(id));
180
+ log('jmap sync: incremental delta', {
181
+ created: result.created.length,
182
+ updated: result.updated.length,
183
+ destroyed: result.destroyed.length,
184
+ hasMoreDelta: result.hasMoreChanges,
185
+ });
186
+ return {
187
+ token: result.newState,
188
+ createdIds: result.created,
189
+ updatedIds,
190
+ hasMoreDelta: result.hasMoreChanges,
191
+ };
192
+ }),
193
+ Effect.catchIf(
194
+ (error) => error.type === 'cannotCalculateChanges',
195
+ () => {
196
+ log('jmap sync: state token stale, falling back to window scan');
197
+ Cursor.clearToken(binding);
198
+ return captureFreshDelta;
199
+ },
200
+ ),
201
+ );
202
+ const { token: capturedToken, createdIds, updatedIds, hasMoreDelta } = yield* resolveDelta;
203
+
204
+ const source: MailSyncSource = {
205
+ buildSource: ({ windows, filter, tagIndex, onEnumerated, onRetrieved }) => {
206
+ // Incremental replaces the forward window with the delta's created ids but keeps the
207
+ // backward backfill window, so each tick still makes backfill progress. When a user filter
208
+ // is set, the delta's account-wide created ids would bypass it — so fall back to the
209
+ // filtered forward window scan for additions (the delta still drives reconcile).
210
+ const forwardIds = filter ? undefined : createdIds;
211
+ if (forwardIds) {
212
+ onEnumerated(forwardIds.length);
213
+ }
214
+ return {
215
+ additions: jmapEmails(target, folders, {
216
+ windows,
217
+ filter,
218
+ now,
219
+ onEnumerated,
220
+ onRetrieved,
221
+ forwardIds,
222
+ }).pipe(
223
+ Stream.map(toItem),
224
+ Stream.provideService(JmapMailApi, providerApi),
225
+ Stream.mapError(MailSyncError.wrap()),
226
+ ),
227
+ // Empty on non-incremental runs; `jmapReconcile` re-fetches + diffs each `updated` id
228
+ // and resolves it to a `Change` itself (it needs the entityId to read local tags).
229
+ reconciles: jmapReconcile(updatedIds, target, folderTagMap, keywordTagMap, tagIndex).pipe(
230
+ Stream.provideService(JmapMailApi, providerApi),
231
+ Stream.mapError(MailSyncError.wrap()),
232
+ ),
233
+ };
234
+ },
235
+ nextToken: () => capturedToken,
236
+ reconcileForeignIds: updatedIds,
237
+ hasMoreDelta: () => hasMoreDelta,
238
+ };
239
+ return source;
240
+ }).pipe(Effect.provide(context), Effect.mapError(MailSyncError.wrap())),
241
+ };
242
+ }),
243
+ );
244
+
245
+ /**
246
+ * Reconcile branch for JMAP: for each `updated` email id, re-fetch its current `mailboxIds` + `keywords`,
247
+ * map them to Tags, and diff against the message's current *local* tags — a remote-wins, snapshot-free
248
+ * reconciliation that is idempotent (a crash re-run produces an empty diff). Emits a retag
249
+ * {@link EmailStage.Change} keyed by the resolved EntityId; ids not in the feed or with no net change are
250
+ * dropped.
251
+ *
252
+ * The two axes differ: **folder** tags are fully remote-wins (added *and* removed, since folder
253
+ * membership is server-authoritative), while **keyword** tags (starred) are **add-only** — the remote can
254
+ * add a star but we never auto-remove one, which would clobber a locally-toggled star until we write
255
+ * local flags back to the provider.
256
+ */
257
+ const jmapReconcile = (
258
+ updatedIds: readonly string[],
259
+ target: JmapMail.Target,
260
+ folderTagMap: ReadonlyMap<string, string>,
261
+ keywordTagMap: ReadonlyMap<string, string>,
262
+ tagIndex: TagIndex.TagIndex,
263
+ ): Stream.Stream<EmailStage.Change, JmapApiError, JmapMailApi | Cursor.Service> => {
264
+ const folderProviderUris = new Set(folderTagMap.values());
265
+ return Stream.fromIterable(updatedIds).pipe(
266
+ Stage.map('jmap-reconcile', (id: string) =>
267
+ Effect.gen(function* () {
268
+ const { foreignIndex } = yield* Cursor.Service;
269
+ const entityId = foreignIndex?.get(id);
270
+ if (!entityId) {
271
+ return undefined;
272
+ }
273
+ const api = yield* JmapMailApi;
274
+ const { list } = yield* api.emailGet(target, [id]);
275
+ const email = list[0];
276
+ if (!email) {
277
+ return undefined;
278
+ }
279
+ const remoteFolderUris = (email.mailboxIds ? Object.keys(email.mailboxIds) : []).flatMap((folderId) => {
280
+ const uri = folderTagMap.get(folderId);
281
+ return uri ? [uri] : [];
282
+ });
283
+ const remoteKeywordUris = (
284
+ email.keywords
285
+ ? Object.entries(email.keywords)
286
+ .filter(([, set]) => set)
287
+ .map(([keyword]) => keyword)
288
+ : []
289
+ ).flatMap((keyword) => {
290
+ const uri = keywordTagMap.get(keyword);
291
+ return uri ? [uri] : [];
292
+ });
293
+ const localTags = TagIndex.bind(tagIndex).tags(entityId);
294
+ const remoteFolders = new Set(remoteFolderUris);
295
+ // Add any remote folder/keyword tag the message lacks.
296
+ const addTagIds = [...remoteFolderUris, ...remoteKeywordUris].filter((tagUri) => !localTags.includes(tagUri));
297
+ // Remove only folder tags the remote no longer has; user tags and starred are never auto-removed.
298
+ const removeTagIds = localTags.filter((tagUri) => folderProviderUris.has(tagUri) && !remoteFolders.has(tagUri));
299
+ if (addTagIds.length === 0 && removeTagIds.length === 0) {
300
+ return undefined;
301
+ }
302
+ return { _tag: 'retag', foreignId: id, entityId, addTagIds, removeTagIds } satisfies EmailStage.Change;
303
+ }),
304
+ ),
305
+ );
306
+ };
307
+
308
+ /**
309
+ * Downloads each attachment's bytes via `JmapMailApi.downloadBlob`. One failed download (including a
310
+ * session with no `downloadUrl`) is logged and dropped rather than failing the whole message.
311
+ */
312
+ const fetchAttachments = (
313
+ target: JmapMail.Target,
314
+ attachments: readonly AttachmentMetadata[],
315
+ ): Effect.Effect<readonly EmailStage.Attachment[], never, JmapMailApi> =>
316
+ Effect.gen(function* () {
317
+ const api = yield* JmapMailApi;
318
+ const fetched = yield* Effect.forEach(
319
+ attachments,
320
+ (attachment) =>
321
+ api.downloadBlob(target, attachment.blobId, { name: attachment.name, type: attachment.mimeType }).pipe(
322
+ Effect.map(
323
+ (bytes): EmailStage.Attachment => ({
324
+ name: attachment.name,
325
+ mimeType: attachment.mimeType,
326
+ size: attachment.size ?? bytes.byteLength,
327
+ bytes,
328
+ contentId: attachment.contentId,
329
+ }),
330
+ ),
331
+ Effect.catchAll((error) => {
332
+ log.catch(error, { blobId: attachment.blobId, name: attachment.name });
333
+ return Effect.succeed(undefined);
334
+ }),
335
+ ),
336
+ { concurrency: JMAP_SYNC_CONFIG.fetchConcurrency },
337
+ );
338
+ return fetched.filter((attachment): attachment is EmailStage.Attachment => attachment !== undefined);
339
+ });
340
+
341
+ /**
342
+ * Streams JMAP email ids over a {@link Cursor.Window}: build the query filter (folder scope + date
343
+ * bounds + optional user DSL), then paginate. Forward pages oldest-first from `max` so a capped run
344
+ * advances `max` gap-free instead of jumping to the newest key and stranding the middle; backward pages
345
+ * newest-first from `min` to the horizon. Backward's upper bound is queried 1ms past `min` so a message
346
+ * sharing that exact millisecond is re-queried (and deduped) rather than skipped once `min` passes it.
347
+ * Split from the full-email fetch so a bidirectional run can cap the combined id stream before fetching.
348
+ */
349
+ const jmapIds = (
350
+ target: JmapMail.Target,
351
+ folders: readonly JmapMail.Mailbox[],
352
+ window: Cursor.Window,
353
+ options: {
354
+ filter?: string;
355
+ /** Reference "now" for the user filter's relative dates (pinned by tests). */
356
+ now: Date;
357
+ /** Called with each query page's enumerated id count, to accumulate the retrieval total. */
358
+ onEnumerated?: (count: number) => void;
359
+ },
360
+ ): Stream.Stream<string, JmapApiError, JmapMailApi> =>
361
+ Stream.unwrap(
362
+ Effect.gen(function* () {
363
+ const api = yield* JmapMailApi;
364
+
365
+ const userFilter = options.filter
366
+ ? JmapMail.parseMailQuery(options.filter, {
367
+ now: options.now,
368
+ resolveMailbox: (nameOrRole) => JmapMail.resolveMailboxByNameOrRole(folders, nameOrRole),
369
+ })
370
+ : Option.none<Jmap.Filter>();
371
+ // When the user filter already scopes a mailbox, skip the default folder restriction.
372
+ const scopesMailbox = Option.match(userFilter, { onNone: () => false, onSome: JmapMail.filterScopesMailbox });
373
+ // Default to all mail (every folder incl. Sent) so full conversations sync, excluding Trash/Junk/Drafts.
374
+ const excludedFolderIds = folders
375
+ .filter((folder) => folder.role === 'trash' || folder.role === 'junk' || folder.role === 'drafts')
376
+ .map((folder) => folder.id);
377
+
378
+ const conditions: Jmap.Filter[] = [];
379
+ if (!scopesMailbox && excludedFolderIds.length > 0) {
380
+ conditions.push({ inMailboxOtherThan: excludedFolderIds });
381
+ }
382
+ // Bound the query to the window. Backward's `end` is `min` — extend 1ms to include the boundary
383
+ // millisecond (see doc above).
384
+ const upperBound = window.direction === 'backward' ? new Date(window.end.getTime() + 1) : window.end;
385
+ conditions.push({ after: window.start.toISOString() });
386
+ conditions.push({ before: upperBound.toISOString() });
387
+ if (Option.isSome(userFilter)) {
388
+ conditions.push(userFilter.value);
389
+ }
390
+ const filter: Jmap.Filter = conditions.length === 1 ? conditions[0] : { operator: 'AND', conditions };
391
+ log('starting jmap sync', {
392
+ direction: window.direction,
393
+ start: window.start.toISOString(),
394
+ end: window.end.toISOString(),
395
+ conditions: conditions.length,
396
+ });
397
+
398
+ return Stream.paginateChunkEffect(0, (position: number) =>
399
+ Effect.gen(function* () {
400
+ const { ids } = yield* api.emailQuery(target, {
401
+ filter,
402
+ sort: [{ property: 'receivedAt', isAscending: window.direction === 'forward' }],
403
+ position,
404
+ limit: JMAP_SYNC_CONFIG.listPageSize,
405
+ });
406
+ log('jmap sync: queried page', { position, total: ids.length });
407
+ // Report each page's count as it arrives so the meter's retrieval total leads the fetch.
408
+ options.onEnumerated?.(ids.length);
409
+ const next =
410
+ ids.length < JMAP_SYNC_CONFIG.listPageSize ? Option.none<number>() : Option.some(position + ids.length);
411
+ return [Chunk.fromIterable(ids), next];
412
+ }),
413
+ );
414
+ }),
415
+ );
416
+
417
+ /**
418
+ * Fetches the full JMAP email for each id. Takes a plain id stream (not a window) so a bidirectional
419
+ * run can cap the combined stream once, then fetch full emails for exactly the capped set.
420
+ */
421
+ const jmapEmailsForIds = (
422
+ target: JmapMail.Target,
423
+ ids: Stream.Stream<string, JmapApiError, JmapMailApi | Cursor.Service>,
424
+ options: {
425
+ /** Called once per email retrieved (full fetch), to advance progress. */
426
+ onRetrieved?: () => void;
427
+ } = {},
428
+ ): Stream.Stream<JmapMail.Email, JmapApiError, JmapMailApi | Cursor.Service> =>
429
+ ids.pipe(
430
+ Stream.flatMap(
431
+ (id) =>
432
+ // Drop an id deleted between query and `emailGet` (returns nothing) by filtering out the null.
433
+ // Do NOT recover the error channel: a real `JmapApiError` must propagate and fail the run so the
434
+ // durable retry re-fetches, rather than stranding the message once `max` advances.
435
+ Stream.fromEffect(
436
+ Effect.gen(function* () {
437
+ const api = yield* JmapMailApi;
438
+ const { list } = yield* api.emailGet(target, [id]);
439
+ options.onRetrieved?.();
440
+ return list[0];
441
+ }),
442
+ ).pipe(Stream.filter(Predicate.isNotNullable)),
443
+ { concurrency: JMAP_SYNC_CONFIG.fetchConcurrency, bufferSize: 10 },
444
+ ),
445
+ );
446
+
447
+ /**
448
+ * Streams full JMAP emails for one run: the forward source's ids then the backward window's,
449
+ * concatenated and fetched in full. Intentionally UNBOUNDED — the harness caps after dedup (see
450
+ * `runMailSync`). The forward source is either the `max`-anchored forward window (first tick / stale
451
+ * fallback) or, when `forwardIds` is set, the incremental delta's created ids — in which case the
452
+ * backward window still runs, so an incremental tick keeps making backfill progress.
453
+ *
454
+ * `Cursor.skipCommitted` drops ids already in the dedup set before `emailGet`, so re-queried boundary
455
+ * ids aren't downloaded; the harness's post-fetch `Cursor.dedupStage` stays the authority.
456
+ */
457
+ const jmapEmails = (
458
+ target: JmapMail.Target,
459
+ folders: readonly JmapMail.Mailbox[],
460
+ config: {
461
+ windows: Cursor.Windows;
462
+ filter?: string;
463
+ now: Date;
464
+ onEnumerated?: (count: number) => void;
465
+ onRetrieved?: () => void;
466
+ /** Incremental delta's created ids; when set, replaces the forward window (backward still runs). */
467
+ forwardIds?: readonly string[];
468
+ },
469
+ ): Stream.Stream<JmapMail.Email, JmapApiError, JmapMailApi | Cursor.Service> => {
470
+ const idsFor = (window: Cursor.Window | undefined) =>
471
+ window
472
+ ? jmapIds(target, folders, window, { filter: config.filter, now: config.now, onEnumerated: config.onEnumerated })
473
+ : Stream.empty;
474
+ const forward = config.forwardIds ? Stream.fromIterable(config.forwardIds) : idsFor(config.windows.forward);
475
+
476
+ return jmapEmailsForIds(
477
+ target,
478
+ Stream.concat(forward, idsFor(config.windows.backward)).pipe(Cursor.skipCommitted('skip-committed', (id) => id)),
479
+ { onRetrieved: config.onRetrieved },
480
+ );
481
+ };