@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,98 @@
|
|
|
1
|
+
import { d as mailboxGet, f as submitEmail, h as JmapCredentials, m as getSession, u as identityGet } from "./chunk-apis.mjs";
|
|
2
|
+
import { n as JmapSendIdentityNotFoundError, r as JmapSendMessageInvalidError, t as JmapApiError } from "./chunk-errors.mjs";
|
|
3
|
+
import "./services.mjs";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import { log } from "@dxos/log";
|
|
6
|
+
import * as Operation from "@dxos/compute/Operation";
|
|
7
|
+
import * as InboxOperation from "@dxos/plugin-inbox/InboxOperation";
|
|
8
|
+
import * as SystemTags from "@dxos/plugin-inbox/SystemTags";
|
|
9
|
+
import * as FetchHttpClient from "@effect/platform/FetchHttpClient";
|
|
10
|
+
//#region src/operations/mail/send/handler.ts
|
|
11
|
+
var __dxlog_file = "/Users/burdon/Code/dxos/dxos/.claude/worktrees/confident-jones-b0b4bf/packages/plugins/plugin-jmap/src/operations/mail/send/handler.ts";
|
|
12
|
+
var MAIL_ACCOUNT_CAPABILITY = "urn:ietf:params:jmap:mail";
|
|
13
|
+
var handler_default = InboxOperation.JmapSend.pipe(Operation.withHandler(({ message, connection: connectionRef }) => Effect.gen(function* () {
|
|
14
|
+
log("sending email via jmap", { connection: connectionRef.uri }, {
|
|
15
|
+
"~LogMeta": "~LogMeta",
|
|
16
|
+
F: __dxlog_file,
|
|
17
|
+
L: 22,
|
|
18
|
+
S: this
|
|
19
|
+
});
|
|
20
|
+
const to = message.properties?.to;
|
|
21
|
+
const cc = message.properties?.cc;
|
|
22
|
+
const bcc = message.properties?.bcc;
|
|
23
|
+
const subject = message.properties?.subject;
|
|
24
|
+
const inReplyTo = message.properties?.inReplyTo;
|
|
25
|
+
const references = message.properties?.references;
|
|
26
|
+
const textBlock = message.blocks.find((block) => block._tag === "text");
|
|
27
|
+
const text = textBlock?._tag === "text" ? textBlock.text : void 0;
|
|
28
|
+
if (!to || !text) return yield* Effect.fail(new JmapSendMessageInvalidError());
|
|
29
|
+
const session = yield* getSession;
|
|
30
|
+
const accountId = session.primaryAccounts[MAIL_ACCOUNT_CAPABILITY];
|
|
31
|
+
if (!accountId) return yield* Effect.fail(new JmapApiError(void 0, "JMAP session has no mail account."));
|
|
32
|
+
const target = {
|
|
33
|
+
apiUrl: session.apiUrl,
|
|
34
|
+
accountId
|
|
35
|
+
};
|
|
36
|
+
const account = (yield* JmapCredentials).account ?? session.username;
|
|
37
|
+
const { list: identities } = yield* identityGet(target);
|
|
38
|
+
const identity = account ? identities.find((candidate) => candidate.email.toLowerCase() === account.toLowerCase()) : identities[0];
|
|
39
|
+
if (!identity) return yield* Effect.fail(new JmapSendIdentityNotFoundError(account));
|
|
40
|
+
const { list: folders } = yield* mailboxGet(target);
|
|
41
|
+
const drafts = folders.find((folder) => folder.role === "drafts");
|
|
42
|
+
const sent = folders.find((folder) => folder.role === "sent");
|
|
43
|
+
if (!drafts || !sent) return yield* Effect.fail(new JmapApiError(void 0, "JMAP account is missing a Drafts or Sent folder."));
|
|
44
|
+
const fromAddress = identity.name ? {
|
|
45
|
+
email: identity.email,
|
|
46
|
+
name: identity.name
|
|
47
|
+
} : { email: identity.email };
|
|
48
|
+
const result = yield* submitEmail(target, {
|
|
49
|
+
identityId: identity.id,
|
|
50
|
+
draftsMailboxId: drafts.id,
|
|
51
|
+
sentMailboxId: sent.id,
|
|
52
|
+
draft: {
|
|
53
|
+
from: [fromAddress],
|
|
54
|
+
to: parseAddressList(to),
|
|
55
|
+
...cc ? { cc: parseAddressList(cc) } : {},
|
|
56
|
+
...bcc ? { bcc: parseAddressList(bcc) } : {},
|
|
57
|
+
...subject ? { subject } : {},
|
|
58
|
+
...inReplyTo ? { inReplyTo: [inReplyTo] } : {},
|
|
59
|
+
...references ? { references: splitReferences(references) } : {},
|
|
60
|
+
text
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
log("email sent via jmap", { id: result.id }, {
|
|
64
|
+
"~LogMeta": "~LogMeta",
|
|
65
|
+
F: __dxlog_file,
|
|
66
|
+
L: 82,
|
|
67
|
+
S: this
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
id: result.id,
|
|
71
|
+
threadId: result.threadId ?? "",
|
|
72
|
+
sentTag: {
|
|
73
|
+
...SystemTags.systemTagKey("sent"),
|
|
74
|
+
label: SystemTags.SystemTag.sent.label
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}).pipe(Effect.provide(FetchHttpClient.layer), Effect.provide(JmapCredentials.fromConnection(connectionRef)))), Operation.opaqueHandler);
|
|
78
|
+
/** Parses a comma-separated header value into JMAP structured addresses. */
|
|
79
|
+
var parseAddressList = (value) => value.split(",").map((part) => part.trim()).filter(Boolean).map(parseAddress);
|
|
80
|
+
/** Parses a single `"Name <email>"` (or bare `email`) into a JMAP structured address. */
|
|
81
|
+
var parseAddress = (value) => {
|
|
82
|
+
const match = value.match(/^\s*(?:"?([^"<]*?)"?\s+)?<([^>]+)>\s*$/);
|
|
83
|
+
if (match) {
|
|
84
|
+
const name = match[1]?.trim();
|
|
85
|
+
const email = match[2].trim();
|
|
86
|
+
return name ? {
|
|
87
|
+
email,
|
|
88
|
+
name
|
|
89
|
+
} : { email };
|
|
90
|
+
}
|
|
91
|
+
return { email: value.trim() };
|
|
92
|
+
};
|
|
93
|
+
/** Splits a `References` header (whitespace-separated message ids) into an array. */
|
|
94
|
+
var splitReferences = (value) => value.split(/\s+/).filter(Boolean);
|
|
95
|
+
//#endregion
|
|
96
|
+
export { handler_default as default };
|
|
97
|
+
|
|
98
|
+
//# sourceMappingURL=chunk-send.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chunk-send.mjs","names":[],"sources":["../../src/operations/mail/send/handler.ts"],"sourcesContent":["//\n// Copyright 2026 DXOS.org\n//\n\nimport * as FetchHttpClient from '@effect/platform/FetchHttpClient';\nimport * as Effect from 'effect/Effect';\n\nimport * as Operation from '@dxos/compute/Operation';\nimport { log } from '@dxos/log';\nimport * as InboxOperation from '@dxos/plugin-inbox/InboxOperation';\nimport * as SystemTags from '@dxos/plugin-inbox/SystemTags';\n\nimport { Jmap, JmapMail } from '../../../apis';\nimport { JmapApiError, JmapSendIdentityNotFoundError, JmapSendMessageInvalidError } from '../../../errors';\nimport { JmapCredentials } from '../../../services';\n\nconst MAIL_ACCOUNT_CAPABILITY = 'urn:ietf:params:jmap:mail';\n\nexport default InboxOperation.JmapSend.pipe(\n Operation.withHandler(({ message, connection: connectionRef }) =>\n Effect.gen(function* () {\n log('sending email via jmap', { connection: connectionRef.uri });\n\n const to = message.properties?.to;\n const cc = message.properties?.cc;\n const bcc = message.properties?.bcc;\n const subject = message.properties?.subject;\n const inReplyTo = message.properties?.inReplyTo;\n const references = message.properties?.references;\n const textBlock = message.blocks.find((block) => block._tag === 'text');\n const text = textBlock?._tag === 'text' ? textBlock.text : undefined;\n\n if (!to || !text) {\n return yield* Effect.fail(new JmapSendMessageInvalidError());\n }\n\n const session = yield* Jmap.getSession;\n const accountId = session.primaryAccounts[MAIL_ACCOUNT_CAPABILITY];\n if (!accountId) {\n return yield* Effect.fail(new JmapApiError(undefined, 'JMAP session has no mail account.'));\n }\n const target: JmapMail.Target = { apiUrl: session.apiUrl, accountId };\n\n const credentials = yield* JmapCredentials;\n const account = credentials.account ?? session.username;\n\n // The submission requires an identity whose email the server permits as `from`; pick the one\n // matching the connection account (fall back to the first if the account is unknown).\n const { list: identities } = yield* JmapMail.identityGet(target);\n const identity = account\n ? identities.find((candidate) => candidate.email.toLowerCase() === account.toLowerCase())\n : identities[0];\n if (!identity) {\n return yield* Effect.fail(new JmapSendIdentityNotFoundError(account));\n }\n\n const { list: folders } = yield* JmapMail.mailboxGet(target);\n const drafts = folders.find((folder) => folder.role === 'drafts');\n const sent = folders.find((folder) => folder.role === 'sent');\n if (!drafts || !sent) {\n return yield* Effect.fail(new JmapApiError(undefined, 'JMAP account is missing a Drafts or Sent folder.'));\n }\n\n const fromAddress = identity.name ? { email: identity.email, name: identity.name } : { email: identity.email };\n\n const result = yield* JmapMail.submitEmail(target, {\n identityId: identity.id,\n draftsMailboxId: drafts.id,\n sentMailboxId: sent.id,\n draft: {\n from: [fromAddress],\n to: parseAddressList(to),\n ...(cc ? { cc: parseAddressList(cc) } : {}),\n ...(bcc ? { bcc: parseAddressList(bcc) } : {}),\n ...(subject ? { subject } : {}),\n ...(inReplyTo ? { inReplyTo: [inReplyTo] } : {}),\n ...(references ? { references: splitReferences(references) } : {}),\n text,\n },\n });\n\n log('email sent via jmap', { id: result.id });\n return {\n id: result.id,\n threadId: result.threadId ?? '',\n // The submission filed the message into the Sent (`sent`-role) folder, which sync maps onto the\n // canonical `sent` system tag; return that same canonical tag so the caller can tag the local\n // draft to match the copy that will sync down.\n sentTag: { ...SystemTags.systemTagKey('sent'), label: SystemTags.SystemTag.sent.label },\n };\n }).pipe(Effect.provide(FetchHttpClient.layer), Effect.provide(JmapCredentials.fromConnection(connectionRef))),\n ),\n Operation.opaqueHandler,\n);\n\n/** Parses a comma-separated header value into JMAP structured addresses. */\nconst parseAddressList = (value: string): { email: string; name?: string }[] =>\n value\n .split(',')\n .map((part) => part.trim())\n .filter(Boolean)\n .map(parseAddress);\n\n/** Parses a single `\"Name <email>\"` (or bare `email`) into a JMAP structured address. */\nconst parseAddress = (value: string): { email: string; name?: string } => {\n const match = value.match(/^\\s*(?:\"?([^\"<]*?)\"?\\s+)?<([^>]+)>\\s*$/);\n if (match) {\n const name = match[1]?.trim();\n const email = match[2].trim();\n return name ? { email, name } : { email };\n }\n return { email: value.trim() };\n};\n\n/** Splits a `References` header (whitespace-separated message ids) into an array. */\nconst splitReferences = (value: string): string[] => value.split(/\\s+/).filter(Boolean);\n"],"mappings":"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"}
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import { i as resolveMailboxByNameOrRole, n as filterScopesMailbox, r as parseMailQuery } from "./chunk-apis.mjs";
|
|
2
|
+
import { n as JMAP_DOMAIN } from "./chunk-constants.mjs";
|
|
3
|
+
import { JmapMailApi } from "./services.mjs";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Layer from "effect/Layer";
|
|
6
|
+
import { Obj, Ref, Tag } from "@dxos/echo";
|
|
7
|
+
import { log } from "@dxos/log";
|
|
8
|
+
import * as Option from "effect/Option";
|
|
9
|
+
import * as Predicate from "effect/Predicate";
|
|
10
|
+
import { MailSyncError, MailSyncProvider } from "@dxos/plugin-inbox/sync";
|
|
11
|
+
import * as Chunk from "effect/Chunk";
|
|
12
|
+
import * as Stream from "effect/Stream";
|
|
13
|
+
import { resolve } from "@dxos/extractor";
|
|
14
|
+
import { Cursor } from "@dxos/link";
|
|
15
|
+
import { Stage } from "@dxos/pipeline";
|
|
16
|
+
import * as Mailbox from "@dxos/plugin-inbox/Mailbox";
|
|
17
|
+
import * as SystemTags from "@dxos/plugin-inbox/SystemTags";
|
|
18
|
+
import { TagIndex } from "@dxos/schema";
|
|
19
|
+
import { Message, Person } from "@dxos/types";
|
|
20
|
+
//#region src/operations/mail/mapper.ts
|
|
21
|
+
var __dxlog_file$1 = "/Users/burdon/Code/dxos/dxos/.claude/worktrees/confident-jones-b0b4bf/packages/plugins/plugin-jmap/src/operations/mail/mapper.ts";
|
|
22
|
+
/** Attachment parts carry a `blobId` to fetch bytes for; parts without one can't be downloaded. */
|
|
23
|
+
var collectAttachments = (attachments) => (attachments ?? []).flatMap((part) => part.blobId ? [{
|
|
24
|
+
blobId: part.blobId,
|
|
25
|
+
name: part.name ?? void 0,
|
|
26
|
+
mimeType: part.type,
|
|
27
|
+
size: part.size,
|
|
28
|
+
contentId: part.cid ? part.cid.replace(/^<|>$/g, "") : void 0
|
|
29
|
+
}] : []);
|
|
30
|
+
/**
|
|
31
|
+
* Extracts the raw HTML and plaintext bodies from a JMAP email (values fetched via
|
|
32
|
+
* `fetchHTMLBodyValues`/`fetchTextBodyValues`). Returns `null` when the email has neither. No
|
|
33
|
+
* HTML→markdown conversion — the markdown/plain views derive from these raw blocks in the component.
|
|
34
|
+
*/
|
|
35
|
+
var decodeBody = (email) => {
|
|
36
|
+
const html = getBodyValue(email, email.htmlBody);
|
|
37
|
+
const plain = getBodyValue(email, email.textBody);
|
|
38
|
+
if (html === void 0 && plain === void 0) {
|
|
39
|
+
log("jmap decodeBody: dropping email with no extractable body", {
|
|
40
|
+
id: email.id,
|
|
41
|
+
htmlBodyParts: email.htmlBody?.length ?? 0,
|
|
42
|
+
textBodyParts: email.textBody?.length ?? 0,
|
|
43
|
+
bodyValues: email.bodyValues ? Object.keys(email.bodyValues).length : 0
|
|
44
|
+
}, {
|
|
45
|
+
"~LogMeta": "~LogMeta",
|
|
46
|
+
F: __dxlog_file$1,
|
|
47
|
+
L: 72,
|
|
48
|
+
S: void 0
|
|
49
|
+
});
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
raw: email,
|
|
54
|
+
html,
|
|
55
|
+
plain,
|
|
56
|
+
attachments: collectAttachments(email.attachments)
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Builds an ECHO Message from a decoded (already body-normalized) JMAP email and an optional resolved
|
|
61
|
+
* sender contact. Pure — contact resolution is done by the caller. Returns `null` when the email has
|
|
62
|
+
* no sender.
|
|
63
|
+
*/
|
|
64
|
+
var mapToMessage = (decoded, contact) => {
|
|
65
|
+
const { raw: email, html, plain } = decoded;
|
|
66
|
+
const fromAddress = email.from?.[0];
|
|
67
|
+
if (!fromAddress) {
|
|
68
|
+
log("jmap mapToMessage: dropping email with no sender", { id: email.id }, {
|
|
69
|
+
"~LogMeta": "~LogMeta",
|
|
70
|
+
F: __dxlog_file$1,
|
|
71
|
+
L: 92,
|
|
72
|
+
S: void 0
|
|
73
|
+
});
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const sender = {
|
|
77
|
+
email: fromAddress.email,
|
|
78
|
+
...fromAddress.name ? { name: fromAddress.name } : {},
|
|
79
|
+
...contact ? { contact: Ref.make(contact) } : {}
|
|
80
|
+
};
|
|
81
|
+
const blocks = [];
|
|
82
|
+
if (html !== void 0) blocks.push({
|
|
83
|
+
_tag: "text",
|
|
84
|
+
text: html,
|
|
85
|
+
mimeType: "text/html"
|
|
86
|
+
});
|
|
87
|
+
if (plain !== void 0) blocks.push({
|
|
88
|
+
_tag: "text",
|
|
89
|
+
text: plain,
|
|
90
|
+
mimeType: "text/plain"
|
|
91
|
+
});
|
|
92
|
+
const echoMessage = Obj.make(Message.Message, {
|
|
93
|
+
[Obj.Meta]: { keys: [{
|
|
94
|
+
id: email.id,
|
|
95
|
+
source: JMAP_DOMAIN
|
|
96
|
+
}] },
|
|
97
|
+
created: email.receivedAt,
|
|
98
|
+
sender,
|
|
99
|
+
threadId: email.threadId,
|
|
100
|
+
properties: {
|
|
101
|
+
threadId: email.threadId,
|
|
102
|
+
snippet: email.preview ?? void 0,
|
|
103
|
+
subject: email.subject ?? void 0,
|
|
104
|
+
messageId: email.messageId?.[0],
|
|
105
|
+
inReplyTo: email.inReplyTo?.[0],
|
|
106
|
+
references: email.references?.join(" "),
|
|
107
|
+
to: formatAddresses(email.to),
|
|
108
|
+
cc: formatAddresses(email.cc)
|
|
109
|
+
},
|
|
110
|
+
blocks
|
|
111
|
+
});
|
|
112
|
+
const keywords = email.keywords ? Object.entries(email.keywords).filter(([, set]) => set).map(([keyword]) => keyword) : [];
|
|
113
|
+
return {
|
|
114
|
+
message: echoMessage,
|
|
115
|
+
mailboxIds: email.mailboxIds ? Object.keys(email.mailboxIds) : [],
|
|
116
|
+
keywords
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
Effect.fnUntraced(function* (email) {
|
|
120
|
+
const decoded = decodeBody(email);
|
|
121
|
+
if (!decoded) return null;
|
|
122
|
+
const fromAddress = email.from?.[0];
|
|
123
|
+
return mapToMessage(decoded, (fromAddress ? yield* resolve(Person.Person, { email: fromAddress.email }) : void 0) ?? void 0);
|
|
124
|
+
});
|
|
125
|
+
/** Formats a JMAP address as `"Name <email>"`, or just the address when unnamed. */
|
|
126
|
+
var formatAddress = (address) => address.name ? `${address.name} <${address.email}>` : address.email;
|
|
127
|
+
/** Joins an address list into a header-style string, or `undefined` when empty. */
|
|
128
|
+
var formatAddresses = (addresses) => addresses && addresses.length > 0 ? addresses.map(formatAddress).join(", ") : void 0;
|
|
129
|
+
/**
|
|
130
|
+
* Returns the first non-empty decoded value among the given body parts (e.g. `email.htmlBody` or
|
|
131
|
+
* `email.textBody`). `Email/get` is requested with `fetchHTMLBodyValues`/`fetchTextBodyValues`, so the
|
|
132
|
+
* referenced `bodyValues` are already decoded.
|
|
133
|
+
*/
|
|
134
|
+
var getBodyValue = (email, parts) => {
|
|
135
|
+
for (const part of parts ?? []) {
|
|
136
|
+
const value = part.partId ? email.bodyValues?.[part.partId]?.value : void 0;
|
|
137
|
+
if (value && value.trim().length > 0) return value;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/operations/mail/tags.ts
|
|
142
|
+
/**
|
|
143
|
+
* The pre-rename source, matched as a fallback so folders synced before the rename keep their tag
|
|
144
|
+
* identity instead of being duplicated. Remove one release after landing.
|
|
145
|
+
*/
|
|
146
|
+
var LEGACY_JMAP_TAG_SOURCE = "org.ietf.jmap.mailbox";
|
|
147
|
+
/**
|
|
148
|
+
* Finds an existing JMAP provider {@link Tag} object by its JMAP mailbox-id foreign key, or creates one
|
|
149
|
+
* carrying that key ({@link JMAP_DOMAIN}, which also marks it read-only via `Tag.isProviderTag`). Keeps
|
|
150
|
+
* the folder label in sync with the server on re-sync. Used by `sync/` for custom user folders;
|
|
151
|
+
* well-known roles map onto canonical system tags instead. Mirrors Gmail's `findOrCreateGmailTag`.
|
|
152
|
+
*/
|
|
153
|
+
var findOrCreateJmapTag = (db, { id, name }) => Tag.findOrCreate(db, {
|
|
154
|
+
key: {
|
|
155
|
+
source: JMAP_DOMAIN,
|
|
156
|
+
id
|
|
157
|
+
},
|
|
158
|
+
legacyKeys: [{
|
|
159
|
+
source: LEGACY_JMAP_TAG_SOURCE,
|
|
160
|
+
id
|
|
161
|
+
}],
|
|
162
|
+
label: name
|
|
163
|
+
});
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/operations/mail/sync/system-tags.ts
|
|
166
|
+
/**
|
|
167
|
+
* JMAP mailbox role → canonical system tag ({@link SystemTags.SystemTag}). Roles absent here are
|
|
168
|
+
* intentionally dropped: `archive` is derived as "not in inbox" (Gmail's model), and
|
|
169
|
+
* `drafts`/`trash`/`junk` are never synced.
|
|
170
|
+
*/
|
|
171
|
+
var JMAP_ROLE_TAGS = {
|
|
172
|
+
inbox: "inbox",
|
|
173
|
+
sent: "sent"
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* JMAP keyword → canonical system tag ({@link SystemTags.SystemTag}). Only `$flagged` (starred) is
|
|
177
|
+
* projected; read-state (`$seen`) and the rest (`$answered`, `$draft`, `$forwarded`, …) are intentionally
|
|
178
|
+
* dropped as high-churn and noisier than useful.
|
|
179
|
+
*/
|
|
180
|
+
var JMAP_KEYWORD_TAGS = { $flagged: "starred" };
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/operations/mail/sync/sync-provider.ts
|
|
183
|
+
var __dxlog_file = "/Users/burdon/Code/dxos/dxos/.claude/worktrees/confident-jones-b0b4bf/packages/plugins/plugin-jmap/src/operations/mail/sync/sync-provider.ts";
|
|
184
|
+
var MAIL_ACCOUNT_CAPABILITY = "urn:ietf:params:jmap:mail";
|
|
185
|
+
/** JMAP mail's streaming-pipeline tuning; see {@link SyncStreamConfig.SyncStreamConfig}. */
|
|
186
|
+
var JMAP_SYNC_CONFIG = {
|
|
187
|
+
listPageSize: 50,
|
|
188
|
+
fetchConcurrency: 5,
|
|
189
|
+
commitPageSize: 10,
|
|
190
|
+
maxItemsPerRun: 500
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* JMAP's {@link MailSyncProvider}: session/account discovery, the email source, the folder→tag map, and
|
|
194
|
+
* the fused decode+map. Captures {@link JmapMailApi} + {@link Resolver} so the harness never names them.
|
|
195
|
+
* Mirror of the Gmail provider (`googleMailSyncProvider`).
|
|
196
|
+
*/
|
|
197
|
+
var jmapMailSyncProvider = () => Layer.effect(MailSyncProvider, Effect.gen(function* () {
|
|
198
|
+
const context = yield* Effect.context();
|
|
199
|
+
const providerApi = yield* JmapMailApi;
|
|
200
|
+
return {
|
|
201
|
+
name: "jmap",
|
|
202
|
+
config: JMAP_SYNC_CONFIG,
|
|
203
|
+
foreignKeySource: JMAP_DOMAIN,
|
|
204
|
+
prepare: ({ db, binding, now, token, maxMessages }) => Effect.gen(function* () {
|
|
205
|
+
const api = yield* JmapMailApi;
|
|
206
|
+
const session = yield* api.getSession;
|
|
207
|
+
const accountId = session.primaryAccounts[MAIL_ACCOUNT_CAPABILITY];
|
|
208
|
+
if (!accountId) {
|
|
209
|
+
log.warn("jmap sync: session has no mail account", { username: session.username }, {
|
|
210
|
+
"~LogMeta": "~LogMeta",
|
|
211
|
+
F: __dxlog_file,
|
|
212
|
+
L: 73,
|
|
213
|
+
S: this
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const target = {
|
|
218
|
+
apiUrl: session.apiUrl,
|
|
219
|
+
accountId,
|
|
220
|
+
downloadUrl: session.downloadUrl
|
|
221
|
+
};
|
|
222
|
+
log("jmap sync: session resolved", {
|
|
223
|
+
apiUrl: session.apiUrl,
|
|
224
|
+
accountId
|
|
225
|
+
}, {
|
|
226
|
+
"~LogMeta": "~LogMeta",
|
|
227
|
+
F: __dxlog_file,
|
|
228
|
+
L: 77,
|
|
229
|
+
S: this
|
|
230
|
+
});
|
|
231
|
+
const { list: folders } = yield* api.mailboxGet(target);
|
|
232
|
+
const folderTagMap = /* @__PURE__ */ new Map();
|
|
233
|
+
for (const folder of folders) {
|
|
234
|
+
const canonical = folder.role ? JMAP_ROLE_TAGS[folder.role] : void 0;
|
|
235
|
+
if (canonical) {
|
|
236
|
+
const tag = yield* Effect.promise(() => SystemTags.findOrCreateSystemTag(db, canonical));
|
|
237
|
+
folderTagMap.set(folder.id, Mailbox.tagUri(tag));
|
|
238
|
+
} else if (folder.role) continue;
|
|
239
|
+
else {
|
|
240
|
+
const tag = yield* Effect.promise(() => findOrCreateJmapTag(db, {
|
|
241
|
+
id: folder.id,
|
|
242
|
+
name: folder.name
|
|
243
|
+
}));
|
|
244
|
+
folderTagMap.set(folder.id, Mailbox.tagUri(tag));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const keywordTagMap = /* @__PURE__ */ new Map();
|
|
248
|
+
for (const [keyword, canonical] of Object.entries(JMAP_KEYWORD_TAGS)) {
|
|
249
|
+
if (!canonical) continue;
|
|
250
|
+
const tag = yield* Effect.promise(() => SystemTags.findOrCreateSystemTag(db, canonical));
|
|
251
|
+
keywordTagMap.set(keyword, Mailbox.tagUri(tag));
|
|
252
|
+
}
|
|
253
|
+
const toMapped = (email) => Effect.gen(function* () {
|
|
254
|
+
const decoded = decodeBody(email);
|
|
255
|
+
if (!decoded) return;
|
|
256
|
+
const fromAddress = decoded.raw.from?.[0];
|
|
257
|
+
const mapped = mapToMessage(decoded, (fromAddress ? yield* resolve(Person.Person, { email: fromAddress.email }) : void 0) ?? void 0);
|
|
258
|
+
if (!mapped) return;
|
|
259
|
+
const folderUris = mapped.mailboxIds.flatMap((folderId) => {
|
|
260
|
+
const uri = folderTagMap.get(folderId);
|
|
261
|
+
return uri ? [uri] : [];
|
|
262
|
+
});
|
|
263
|
+
const keywordUris = mapped.keywords.flatMap((keyword) => {
|
|
264
|
+
const uri = keywordTagMap.get(keyword);
|
|
265
|
+
return uri ? [uri] : [];
|
|
266
|
+
});
|
|
267
|
+
const tagUris = [...folderUris, ...keywordUris];
|
|
268
|
+
const attachments = yield* fetchAttachments(target, decoded.attachments);
|
|
269
|
+
return {
|
|
270
|
+
_tag: "insert",
|
|
271
|
+
message: mapped.message,
|
|
272
|
+
foreignId: decoded.raw.id,
|
|
273
|
+
key: new Date(decoded.raw.receivedAt).getTime(),
|
|
274
|
+
tagUris,
|
|
275
|
+
attachments
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
const toItem = (email) => ({
|
|
279
|
+
foreignId: email.id,
|
|
280
|
+
key: new Date(email.receivedAt).getTime(),
|
|
281
|
+
process: toMapped(email).pipe(Effect.provide(context))
|
|
282
|
+
});
|
|
283
|
+
const captureFreshDelta = Effect.map(api.emailGet(target, []), (result) => ({
|
|
284
|
+
token: result.state,
|
|
285
|
+
createdIds: void 0,
|
|
286
|
+
updatedIds: [],
|
|
287
|
+
hasMoreDelta: false
|
|
288
|
+
}));
|
|
289
|
+
const { token: capturedToken, createdIds, updatedIds, hasMoreDelta } = yield* token === void 0 ? captureFreshDelta : api.emailChanges(target, token, maxMessages).pipe(Effect.map((result) => {
|
|
290
|
+
const created = new Set(result.created);
|
|
291
|
+
const updatedIds = result.updated.filter((id) => !created.has(id));
|
|
292
|
+
log("jmap sync: incremental delta", {
|
|
293
|
+
created: result.created.length,
|
|
294
|
+
updated: result.updated.length,
|
|
295
|
+
destroyed: result.destroyed.length,
|
|
296
|
+
hasMoreDelta: result.hasMoreChanges
|
|
297
|
+
}, {
|
|
298
|
+
"~LogMeta": "~LogMeta",
|
|
299
|
+
F: __dxlog_file,
|
|
300
|
+
L: 180,
|
|
301
|
+
S: this
|
|
302
|
+
});
|
|
303
|
+
return {
|
|
304
|
+
token: result.newState,
|
|
305
|
+
createdIds: result.created,
|
|
306
|
+
updatedIds,
|
|
307
|
+
hasMoreDelta: result.hasMoreChanges
|
|
308
|
+
};
|
|
309
|
+
}), Effect.catchIf((error) => error.type === "cannotCalculateChanges", () => {
|
|
310
|
+
log("jmap sync: state token stale, falling back to window scan", void 0, {
|
|
311
|
+
"~LogMeta": "~LogMeta",
|
|
312
|
+
F: __dxlog_file,
|
|
313
|
+
L: 196,
|
|
314
|
+
S: this
|
|
315
|
+
});
|
|
316
|
+
Cursor.clearToken(binding);
|
|
317
|
+
return captureFreshDelta;
|
|
318
|
+
}));
|
|
319
|
+
return {
|
|
320
|
+
buildSource: ({ windows, filter, tagIndex, onEnumerated, onRetrieved }) => {
|
|
321
|
+
const forwardIds = filter ? void 0 : createdIds;
|
|
322
|
+
if (forwardIds) onEnumerated(forwardIds.length);
|
|
323
|
+
return {
|
|
324
|
+
additions: jmapEmails(target, folders, {
|
|
325
|
+
windows,
|
|
326
|
+
filter,
|
|
327
|
+
now,
|
|
328
|
+
onEnumerated,
|
|
329
|
+
onRetrieved,
|
|
330
|
+
forwardIds
|
|
331
|
+
}).pipe(Stream.map(toItem), Stream.provideService(JmapMailApi, providerApi), Stream.mapError(MailSyncError.wrap())),
|
|
332
|
+
reconciles: jmapReconcile(updatedIds, target, folderTagMap, keywordTagMap, tagIndex).pipe(Stream.provideService(JmapMailApi, providerApi), Stream.mapError(MailSyncError.wrap()))
|
|
333
|
+
};
|
|
334
|
+
},
|
|
335
|
+
nextToken: () => capturedToken,
|
|
336
|
+
reconcileForeignIds: updatedIds,
|
|
337
|
+
hasMoreDelta: () => hasMoreDelta
|
|
338
|
+
};
|
|
339
|
+
}).pipe(Effect.provide(context), Effect.mapError(MailSyncError.wrap()))
|
|
340
|
+
};
|
|
341
|
+
}));
|
|
342
|
+
/**
|
|
343
|
+
* Reconcile branch for JMAP: for each `updated` email id, re-fetch its current `mailboxIds` + `keywords`,
|
|
344
|
+
* map them to Tags, and diff against the message's current *local* tags — a remote-wins, snapshot-free
|
|
345
|
+
* reconciliation that is idempotent (a crash re-run produces an empty diff). Emits a retag
|
|
346
|
+
* {@link EmailStage.Change} keyed by the resolved EntityId; ids not in the feed or with no net change are
|
|
347
|
+
* dropped.
|
|
348
|
+
*
|
|
349
|
+
* The two axes differ: **folder** tags are fully remote-wins (added *and* removed, since folder
|
|
350
|
+
* membership is server-authoritative), while **keyword** tags (starred) are **add-only** — the remote can
|
|
351
|
+
* add a star but we never auto-remove one, which would clobber a locally-toggled star until we write
|
|
352
|
+
* local flags back to the provider.
|
|
353
|
+
*/
|
|
354
|
+
var jmapReconcile = (updatedIds, target, folderTagMap, keywordTagMap, tagIndex) => {
|
|
355
|
+
const folderProviderUris = new Set(folderTagMap.values());
|
|
356
|
+
return Stream.fromIterable(updatedIds).pipe(Stage.map("jmap-reconcile", (id) => Effect.gen(function* () {
|
|
357
|
+
const { foreignIndex } = yield* Cursor.Service;
|
|
358
|
+
const entityId = foreignIndex?.get(id);
|
|
359
|
+
if (!entityId) return;
|
|
360
|
+
const { list } = yield* (yield* JmapMailApi).emailGet(target, [id]);
|
|
361
|
+
const email = list[0];
|
|
362
|
+
if (!email) return;
|
|
363
|
+
const remoteFolderUris = (email.mailboxIds ? Object.keys(email.mailboxIds) : []).flatMap((folderId) => {
|
|
364
|
+
const uri = folderTagMap.get(folderId);
|
|
365
|
+
return uri ? [uri] : [];
|
|
366
|
+
});
|
|
367
|
+
const remoteKeywordUris = (email.keywords ? Object.entries(email.keywords).filter(([, set]) => set).map(([keyword]) => keyword) : []).flatMap((keyword) => {
|
|
368
|
+
const uri = keywordTagMap.get(keyword);
|
|
369
|
+
return uri ? [uri] : [];
|
|
370
|
+
});
|
|
371
|
+
const localTags = TagIndex.bind(tagIndex).tags(entityId);
|
|
372
|
+
const remoteFolders = new Set(remoteFolderUris);
|
|
373
|
+
const addTagIds = [...remoteFolderUris, ...remoteKeywordUris].filter((tagUri) => !localTags.includes(tagUri));
|
|
374
|
+
const removeTagIds = localTags.filter((tagUri) => folderProviderUris.has(tagUri) && !remoteFolders.has(tagUri));
|
|
375
|
+
if (addTagIds.length === 0 && removeTagIds.length === 0) return;
|
|
376
|
+
return {
|
|
377
|
+
_tag: "retag",
|
|
378
|
+
foreignId: id,
|
|
379
|
+
entityId,
|
|
380
|
+
addTagIds,
|
|
381
|
+
removeTagIds
|
|
382
|
+
};
|
|
383
|
+
})));
|
|
384
|
+
};
|
|
385
|
+
/**
|
|
386
|
+
* Downloads each attachment's bytes via `JmapMailApi.downloadBlob`. One failed download (including a
|
|
387
|
+
* session with no `downloadUrl`) is logged and dropped rather than failing the whole message.
|
|
388
|
+
*/
|
|
389
|
+
var fetchAttachments = (target, attachments) => Effect.gen(function* () {
|
|
390
|
+
const api = yield* JmapMailApi;
|
|
391
|
+
return (yield* Effect.forEach(attachments, (attachment) => api.downloadBlob(target, attachment.blobId, {
|
|
392
|
+
name: attachment.name,
|
|
393
|
+
type: attachment.mimeType
|
|
394
|
+
}).pipe(Effect.map((bytes) => ({
|
|
395
|
+
name: attachment.name,
|
|
396
|
+
mimeType: attachment.mimeType,
|
|
397
|
+
size: attachment.size ?? bytes.byteLength,
|
|
398
|
+
bytes,
|
|
399
|
+
contentId: attachment.contentId
|
|
400
|
+
})), Effect.catchAll((error) => {
|
|
401
|
+
log.catch(error, {
|
|
402
|
+
blobId: attachment.blobId,
|
|
403
|
+
name: attachment.name
|
|
404
|
+
}, {
|
|
405
|
+
"~LogMeta": "~LogMeta",
|
|
406
|
+
F: __dxlog_file,
|
|
407
|
+
L: 332,
|
|
408
|
+
S: this
|
|
409
|
+
});
|
|
410
|
+
return Effect.succeed(void 0);
|
|
411
|
+
})), { concurrency: JMAP_SYNC_CONFIG.fetchConcurrency })).filter((attachment) => attachment !== void 0);
|
|
412
|
+
});
|
|
413
|
+
/**
|
|
414
|
+
* Streams JMAP email ids over a {@link Cursor.Window}: build the query filter (folder scope + date
|
|
415
|
+
* bounds + optional user DSL), then paginate. Forward pages oldest-first from `max` so a capped run
|
|
416
|
+
* advances `max` gap-free instead of jumping to the newest key and stranding the middle; backward pages
|
|
417
|
+
* newest-first from `min` to the horizon. Backward's upper bound is queried 1ms past `min` so a message
|
|
418
|
+
* sharing that exact millisecond is re-queried (and deduped) rather than skipped once `min` passes it.
|
|
419
|
+
* Split from the full-email fetch so a bidirectional run can cap the combined id stream before fetching.
|
|
420
|
+
*/
|
|
421
|
+
var jmapIds = (target, folders, window, options) => Stream.unwrap(Effect.gen(function* () {
|
|
422
|
+
const api = yield* JmapMailApi;
|
|
423
|
+
const userFilter = options.filter ? parseMailQuery(options.filter, {
|
|
424
|
+
now: options.now,
|
|
425
|
+
resolveMailbox: (nameOrRole) => resolveMailboxByNameOrRole(folders, nameOrRole)
|
|
426
|
+
}) : Option.none();
|
|
427
|
+
const scopesMailbox = Option.match(userFilter, {
|
|
428
|
+
onNone: () => false,
|
|
429
|
+
onSome: filterScopesMailbox
|
|
430
|
+
});
|
|
431
|
+
const excludedFolderIds = folders.filter((folder) => folder.role === "trash" || folder.role === "junk" || folder.role === "drafts").map((folder) => folder.id);
|
|
432
|
+
const conditions = [];
|
|
433
|
+
if (!scopesMailbox && excludedFolderIds.length > 0) conditions.push({ inMailboxOtherThan: excludedFolderIds });
|
|
434
|
+
const upperBound = window.direction === "backward" ? new Date(window.end.getTime() + 1) : window.end;
|
|
435
|
+
conditions.push({ after: window.start.toISOString() });
|
|
436
|
+
conditions.push({ before: upperBound.toISOString() });
|
|
437
|
+
if (Option.isSome(userFilter)) conditions.push(userFilter.value);
|
|
438
|
+
const filter = conditions.length === 1 ? conditions[0] : {
|
|
439
|
+
operator: "AND",
|
|
440
|
+
conditions
|
|
441
|
+
};
|
|
442
|
+
log("starting jmap sync", {
|
|
443
|
+
direction: window.direction,
|
|
444
|
+
start: window.start.toISOString(),
|
|
445
|
+
end: window.end.toISOString(),
|
|
446
|
+
conditions: conditions.length
|
|
447
|
+
}, {
|
|
448
|
+
"~LogMeta": "~LogMeta",
|
|
449
|
+
F: __dxlog_file,
|
|
450
|
+
L: 391,
|
|
451
|
+
S: this
|
|
452
|
+
});
|
|
453
|
+
return Stream.paginateChunkEffect(0, (position) => Effect.gen(function* () {
|
|
454
|
+
const { ids } = yield* api.emailQuery(target, {
|
|
455
|
+
filter,
|
|
456
|
+
sort: [{
|
|
457
|
+
property: "receivedAt",
|
|
458
|
+
isAscending: window.direction === "forward"
|
|
459
|
+
}],
|
|
460
|
+
position,
|
|
461
|
+
limit: JMAP_SYNC_CONFIG.listPageSize
|
|
462
|
+
});
|
|
463
|
+
log("jmap sync: queried page", {
|
|
464
|
+
position,
|
|
465
|
+
total: ids.length
|
|
466
|
+
}, {
|
|
467
|
+
"~LogMeta": "~LogMeta",
|
|
468
|
+
F: __dxlog_file,
|
|
469
|
+
L: 406,
|
|
470
|
+
S: this
|
|
471
|
+
});
|
|
472
|
+
options.onEnumerated?.(ids.length);
|
|
473
|
+
const next = ids.length < JMAP_SYNC_CONFIG.listPageSize ? Option.none() : Option.some(position + ids.length);
|
|
474
|
+
return [Chunk.fromIterable(ids), next];
|
|
475
|
+
}));
|
|
476
|
+
}));
|
|
477
|
+
/**
|
|
478
|
+
* Fetches the full JMAP email for each id. Takes a plain id stream (not a window) so a bidirectional
|
|
479
|
+
* run can cap the combined stream once, then fetch full emails for exactly the capped set.
|
|
480
|
+
*/
|
|
481
|
+
var jmapEmailsForIds = (target, ids, options = {}) => ids.pipe(Stream.flatMap((id) => Stream.fromEffect(Effect.gen(function* () {
|
|
482
|
+
const { list } = yield* (yield* JmapMailApi).emailGet(target, [id]);
|
|
483
|
+
options.onRetrieved?.();
|
|
484
|
+
return list[0];
|
|
485
|
+
})).pipe(Stream.filter(Predicate.isNotNullable)), {
|
|
486
|
+
concurrency: JMAP_SYNC_CONFIG.fetchConcurrency,
|
|
487
|
+
bufferSize: 10
|
|
488
|
+
}));
|
|
489
|
+
/**
|
|
490
|
+
* Streams full JMAP emails for one run: the forward source's ids then the backward window's,
|
|
491
|
+
* concatenated and fetched in full. Intentionally UNBOUNDED — the harness caps after dedup (see
|
|
492
|
+
* `runMailSync`). The forward source is either the `max`-anchored forward window (first tick / stale
|
|
493
|
+
* fallback) or, when `forwardIds` is set, the incremental delta's created ids — in which case the
|
|
494
|
+
* backward window still runs, so an incremental tick keeps making backfill progress.
|
|
495
|
+
*
|
|
496
|
+
* `Cursor.skipCommitted` drops ids already in the dedup set before `emailGet`, so re-queried boundary
|
|
497
|
+
* ids aren't downloaded; the harness's post-fetch `Cursor.dedupStage` stays the authority.
|
|
498
|
+
*/
|
|
499
|
+
var jmapEmails = (target, folders, config) => {
|
|
500
|
+
const idsFor = (window) => window ? jmapIds(target, folders, window, {
|
|
501
|
+
filter: config.filter,
|
|
502
|
+
now: config.now,
|
|
503
|
+
onEnumerated: config.onEnumerated
|
|
504
|
+
}) : Stream.empty;
|
|
505
|
+
const forward = config.forwardIds ? Stream.fromIterable(config.forwardIds) : idsFor(config.windows.forward);
|
|
506
|
+
return jmapEmailsForIds(target, Stream.concat(forward, idsFor(config.windows.backward)).pipe(Cursor.skipCommitted("skip-committed", (id) => id)), { onRetrieved: config.onRetrieved });
|
|
507
|
+
};
|
|
508
|
+
//#endregion
|
|
509
|
+
export { jmapMailSyncProvider as t };
|
|
510
|
+
|
|
511
|
+
//# sourceMappingURL=chunk-sync-provider.mjs.map
|