@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.
- package/LICENSE +105 -0
- package/dist/lib/JmapPlugin.mjs +16 -0
- package/dist/lib/JmapPlugin.mjs.map +1 -0
- package/dist/lib/apis.mjs +2 -0
- package/dist/lib/capabilities.mjs +17 -0
- package/dist/lib/capabilities.mjs.map +1 -0
- package/dist/lib/chunk-apis.mjs +756 -0
- package/dist/lib/chunk-apis.mjs.map +1 -0
- package/dist/lib/chunk-connector.mjs +111 -0
- package/dist/lib/chunk-connector.mjs.map +1 -0
- package/dist/lib/chunk-constants.mjs +23 -0
- package/dist/lib/chunk-constants.mjs.map +1 -0
- package/dist/lib/chunk-errors.mjs +42 -0
- package/dist/lib/chunk-errors.mjs.map +1 -0
- package/dist/lib/chunk-handler.mjs +27 -0
- package/dist/lib/chunk-handler.mjs.map +1 -0
- package/dist/lib/chunk-jmap-fixtures.mjs +102 -0
- package/dist/lib/chunk-jmap-fixtures.mjs.map +1 -0
- package/dist/lib/chunk-mail-send.mjs +17 -0
- package/dist/lib/chunk-mail-send.mjs.map +1 -0
- package/dist/lib/chunk-meta.mjs +37 -0
- package/dist/lib/chunk-meta.mjs.map +1 -0
- package/dist/lib/chunk-operation-handler.mjs +12 -0
- package/dist/lib/chunk-operation-handler.mjs.map +1 -0
- package/dist/lib/chunk-send.mjs +98 -0
- package/dist/lib/chunk-send.mjs.map +1 -0
- package/dist/lib/chunk-sync-provider.mjs +511 -0
- package/dist/lib/chunk-sync-provider.mjs.map +1 -0
- package/dist/lib/chunk-sync.mjs +37 -0
- package/dist/lib/chunk-sync.mjs.map +1 -0
- package/dist/lib/index.mjs +4 -0
- package/dist/lib/meta.mjs +2 -0
- package/dist/lib/operations.mjs +13 -0
- package/dist/lib/operations.mjs.map +1 -0
- package/dist/lib/plugin.mjs +9 -0
- package/dist/lib/plugin.mjs.map +1 -0
- package/dist/lib/services.mjs +139 -0
- package/dist/lib/services.mjs.map +1 -0
- package/dist/lib/testing/node.mjs +2 -0
- package/dist/lib/testing.mjs +19 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/lib/translations.mjs +7 -0
- package/dist/lib/translations.mjs.map +1 -0
- package/dist/types/dx.config.d.ts +28 -0
- package/dist/types/dx.config.d.ts.map +1 -0
- package/dist/types/src/JmapPlugin.d.ts +9 -0
- package/dist/types/src/JmapPlugin.d.ts.map +1 -0
- package/dist/types/src/apis/Jmap/api.d.ts +42 -0
- package/dist/types/src/apis/Jmap/api.d.ts.map +1 -0
- package/dist/types/src/apis/Jmap/index.d.ts +3 -0
- package/dist/types/src/apis/Jmap/index.d.ts.map +1 -0
- package/dist/types/src/apis/Jmap/types.d.ts +65 -0
- package/dist/types/src/apis/Jmap/types.d.ts.map +1 -0
- package/dist/types/src/apis/JmapMail/api.d.ts +223 -0
- package/dist/types/src/apis/JmapMail/api.d.ts.map +1 -0
- package/dist/types/src/apis/JmapMail/index.d.ts +4 -0
- package/dist/types/src/apis/JmapMail/index.d.ts.map +1 -0
- package/dist/types/src/apis/JmapMail/query.d.ts +26 -0
- package/dist/types/src/apis/JmapMail/query.d.ts.map +1 -0
- package/dist/types/src/apis/JmapMail/query.test.d.ts +2 -0
- package/dist/types/src/apis/JmapMail/query.test.d.ts.map +1 -0
- package/dist/types/src/apis/JmapMail/types.d.ts +290 -0
- package/dist/types/src/apis/JmapMail/types.d.ts.map +1 -0
- package/dist/types/src/apis/index.d.ts +3 -0
- package/dist/types/src/apis/index.d.ts.map +1 -0
- package/dist/types/src/apis/jmap-api.test.d.ts +2 -0
- package/dist/types/src/apis/jmap-api.test.d.ts.map +1 -0
- package/dist/types/src/capabilities/connector.d.ts +5 -0
- package/dist/types/src/capabilities/connector.d.ts.map +1 -0
- package/dist/types/src/capabilities/credential-form.d.ts +37 -0
- package/dist/types/src/capabilities/credential-form.d.ts.map +1 -0
- package/dist/types/src/capabilities/credential-form.test.d.ts +2 -0
- package/dist/types/src/capabilities/credential-form.test.d.ts.map +1 -0
- package/dist/types/src/capabilities/index.d.ts +5 -0
- package/dist/types/src/capabilities/index.d.ts.map +1 -0
- package/dist/types/src/capabilities/mail-send.d.ts +6 -0
- package/dist/types/src/capabilities/mail-send.d.ts.map +1 -0
- package/dist/types/src/capabilities/operation-handler.d.ts +5 -0
- package/dist/types/src/capabilities/operation-handler.d.ts.map +1 -0
- package/dist/types/src/constants.d.ts +19 -0
- package/dist/types/src/constants.d.ts.map +1 -0
- package/dist/types/src/errors.d.ts +103 -0
- package/dist/types/src/errors.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/meta.d.ts +33 -0
- package/dist/types/src/meta.d.ts.map +1 -0
- package/dist/types/src/operations/index.d.ts +3 -0
- package/dist/types/src/operations/index.d.ts.map +1 -0
- package/dist/types/src/operations/mail/mapper.d.ts +49 -0
- package/dist/types/src/operations/mail/mapper.d.ts.map +1 -0
- package/dist/types/src/operations/mail/materialize/handler.d.ts +11 -0
- package/dist/types/src/operations/mail/materialize/handler.d.ts.map +1 -0
- package/dist/types/src/operations/mail/send/handler.d.ts +4 -0
- package/dist/types/src/operations/mail/send/handler.d.ts.map +1 -0
- package/dist/types/src/operations/mail/send/index.d.ts +2 -0
- package/dist/types/src/operations/mail/send/index.d.ts.map +1 -0
- package/dist/types/src/operations/mail/sync/handler.d.ts +4 -0
- package/dist/types/src/operations/mail/sync/handler.d.ts.map +1 -0
- package/dist/types/src/operations/mail/sync/index.d.ts +3 -0
- package/dist/types/src/operations/mail/sync/index.d.ts.map +1 -0
- package/dist/types/src/operations/mail/sync/mapper.test.d.ts +2 -0
- package/dist/types/src/operations/mail/sync/mapper.test.d.ts.map +1 -0
- package/dist/types/src/operations/mail/sync/sync-e2e.test.d.ts +2 -0
- package/dist/types/src/operations/mail/sync/sync-e2e.test.d.ts.map +1 -0
- package/dist/types/src/operations/mail/sync/sync-provider.d.ts +11 -0
- package/dist/types/src/operations/mail/sync/sync-provider.d.ts.map +1 -0
- package/dist/types/src/operations/mail/sync/sync.test.d.ts +2 -0
- package/dist/types/src/operations/mail/sync/sync.test.d.ts.map +1 -0
- package/dist/types/src/operations/mail/sync/system-tags.d.ts +14 -0
- package/dist/types/src/operations/mail/sync/system-tags.d.ts.map +1 -0
- package/dist/types/src/operations/mail/tags.d.ts +12 -0
- package/dist/types/src/operations/mail/tags.d.ts.map +1 -0
- package/dist/types/src/plugin.d.ts +4 -0
- package/dist/types/src/plugin.d.ts.map +1 -0
- package/dist/types/src/services/index.d.ts +3 -0
- package/dist/types/src/services/index.d.ts.map +1 -0
- package/dist/types/src/services/jmap-credentials.d.ts +39 -0
- package/dist/types/src/services/jmap-credentials.d.ts.map +1 -0
- package/dist/types/src/services/jmap-mail-api.d.ts +109 -0
- package/dist/types/src/services/jmap-mail-api.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +5 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/jmap-fixtures.d.ts +26 -0
- package/dist/types/src/testing/jmap-fixtures.d.ts.map +1 -0
- package/dist/types/src/testing/jmap-fixtures.test.d.ts +2 -0
- package/dist/types/src/testing/jmap-fixtures.test.d.ts.map +1 -0
- package/dist/types/src/testing/node.d.ts +4 -0
- package/dist/types/src/testing/node.d.ts.map +1 -0
- package/dist/types/src/testing/sync-fixture.d.ts +22 -0
- package/dist/types/src/testing/sync-fixture.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +8 -0
- package/dist/types/src/translations.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/dx.config.ts +32 -0
- package/package.json +121 -0
- package/src/JmapPlugin.ts +25 -0
- package/src/apis/Jmap/api.ts +138 -0
- package/src/apis/Jmap/index.ts +6 -0
- package/src/apis/Jmap/types.ts +86 -0
- package/src/apis/JmapMail/api.ts +324 -0
- package/src/apis/JmapMail/index.ts +7 -0
- package/src/apis/JmapMail/query.test.ts +181 -0
- package/src/apis/JmapMail/query.ts +231 -0
- package/src/apis/JmapMail/types.ts +196 -0
- package/src/apis/index.ts +8 -0
- package/src/apis/jmap-api.test.ts +261 -0
- package/src/capabilities/connector.ts +44 -0
- package/src/capabilities/credential-form.test.ts +69 -0
- package/src/capabilities/credential-form.ts +113 -0
- package/src/capabilities/index.ts +24 -0
- package/src/capabilities/mail-send.ts +21 -0
- package/src/capabilities/operation-handler.ts +16 -0
- package/src/constants.ts +24 -0
- package/src/errors.ts +38 -0
- package/src/index.ts +7 -0
- package/src/meta.ts +9 -0
- package/src/operations/index.ts +13 -0
- package/src/operations/mail/mapper.ts +191 -0
- package/src/operations/mail/materialize/handler.ts +41 -0
- package/src/operations/mail/send/handler.ts +116 -0
- package/src/operations/mail/send/index.ts +5 -0
- package/src/operations/mail/sync/handler.ts +52 -0
- package/src/operations/mail/sync/index.ts +7 -0
- package/src/operations/mail/sync/mapper.test.ts +150 -0
- package/src/operations/mail/sync/sync-e2e.test.ts +63 -0
- package/src/operations/mail/sync/sync-provider.ts +481 -0
- package/src/operations/mail/sync/sync.test.ts +766 -0
- package/src/operations/mail/sync/system-tags.ts +24 -0
- package/src/operations/mail/tags.ts +29 -0
- package/src/plugin.ts +11 -0
- package/src/services/index.ts +6 -0
- package/src/services/jmap-credentials.ts +61 -0
- package/src/services/jmap-mail-api.ts +294 -0
- package/src/testing/index.ts +8 -0
- package/src/testing/jmap-fixtures.test.ts +107 -0
- package/src/testing/jmap-fixtures.ts +105 -0
- package/src/testing/node.ts +12 -0
- package/src/testing/sync-fixture.ts +39 -0
- 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
|
+
};
|