@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,191 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import { Obj, Ref } from '@dxos/echo';
8
+ import { type Resolver, resolve } from '@dxos/extractor';
9
+ import { log } from '@dxos/log';
10
+ import { ContentBlock, Message, Person } from '@dxos/types';
11
+
12
+ import { JmapMail } from '../../apis';
13
+ import { JMAP_DOMAIN } from '../../constants';
14
+
15
+ /**
16
+ * Result of mapping a JMAP email. `mailboxIds` (the folders the email is in) and `keywords` (the set
17
+ * flags, e.g. `$flagged`) are propagated separately so the caller can apply the corresponding tags to
18
+ * the immutable feed Message — mirrors the Gmail mapper's `labelIds`. Both axes are needed because JMAP
19
+ * splits folder membership (`mailboxIds`) from flags (`keywords`), unlike Gmail's single label list.
20
+ */
21
+ export type MappedEmail = {
22
+ message: Message.Message;
23
+ mailboxIds: readonly string[];
24
+ keywords: readonly string[];
25
+ };
26
+
27
+ /** A JMAP email with its raw HTML and/or plaintext body (JMAP delivers decoded values; no markdown). */
28
+ export type DecodedEmail = {
29
+ raw: JmapMail.Email;
30
+ html?: string;
31
+ plain?: string;
32
+ attachments: readonly AttachmentMetadata[];
33
+ };
34
+
35
+ /** Metadata for an attachment part (`Email.attachments`, distinct from the `textBody`/`htmlBody` parts). */
36
+ export type AttachmentMetadata = {
37
+ readonly blobId: string;
38
+ readonly name?: string;
39
+ readonly mimeType?: string;
40
+ readonly size?: number;
41
+ /** The part's `Content-ID` (angle brackets stripped, if present), if any — matches a `cid:` reference in an HTML body. */
42
+ readonly contentId?: string;
43
+ };
44
+
45
+ /** Attachment parts carry a `blobId` to fetch bytes for; parts without one can't be downloaded. */
46
+ const collectAttachments = (attachments: readonly JmapMail.EmailBodyPart[] | undefined): AttachmentMetadata[] =>
47
+ (attachments ?? []).flatMap((part) =>
48
+ part.blobId
49
+ ? [
50
+ {
51
+ blobId: part.blobId,
52
+ name: part.name ?? undefined,
53
+ mimeType: part.type,
54
+ size: part.size,
55
+ // Servers vary on whether `cid` includes the enclosing `<...>`; strip it defensively so it
56
+ // matches the bare id an HTML body's `cid:` reference uses.
57
+ contentId: part.cid ? part.cid.replace(/^<|>$/g, '') : undefined,
58
+ },
59
+ ]
60
+ : [],
61
+ );
62
+
63
+ /**
64
+ * Extracts the raw HTML and plaintext bodies from a JMAP email (values fetched via
65
+ * `fetchHTMLBodyValues`/`fetchTextBodyValues`). Returns `null` when the email has neither. No
66
+ * HTML→markdown conversion — the markdown/plain views derive from these raw blocks in the component.
67
+ */
68
+ export const decodeBody = (email: JmapMail.Email): DecodedEmail | null => {
69
+ const html = getBodyValue(email, email.htmlBody);
70
+ const plain = getBodyValue(email, email.textBody);
71
+ if (html === undefined && plain === undefined) {
72
+ log('jmap decodeBody: dropping email with no extractable body', {
73
+ id: email.id,
74
+ htmlBodyParts: email.htmlBody?.length ?? 0,
75
+ textBodyParts: email.textBody?.length ?? 0,
76
+ bodyValues: email.bodyValues ? Object.keys(email.bodyValues).length : 0,
77
+ });
78
+ return null;
79
+ }
80
+ return { raw: email, html, plain, attachments: collectAttachments(email.attachments) };
81
+ };
82
+
83
+ /**
84
+ * Builds an ECHO Message from a decoded (already body-normalized) JMAP email and an optional resolved
85
+ * sender contact. Pure — contact resolution is done by the caller. Returns `null` when the email has
86
+ * no sender.
87
+ */
88
+ export const mapToMessage = (decoded: DecodedEmail, contact: Person.Person | undefined): MappedEmail | null => {
89
+ const { raw: email, html, plain } = decoded;
90
+ const fromAddress = email.from?.[0];
91
+ if (!fromAddress) {
92
+ log('jmap mapToMessage: dropping email with no sender', { id: email.id });
93
+ return null;
94
+ }
95
+
96
+ // Omit `contact` entirely when unresolved: an explicit `undefined` round-trips to `null` and
97
+ // fails Message schema validation on queue load (see mapper.test.ts).
98
+ const sender = {
99
+ email: fromAddress.email,
100
+ ...(fromAddress.name ? { name: fromAddress.name } : {}),
101
+ ...(contact ? { contact: Ref.make(contact) } : {}),
102
+ };
103
+
104
+ // Store the raw HTML and plaintext bodies as separately-typed blocks; the markdown view derives
105
+ // from the HTML in-component.
106
+ const blocks: ContentBlock.Text[] = [];
107
+ if (html !== undefined) {
108
+ blocks.push({ _tag: 'text', text: html, mimeType: 'text/html' });
109
+ }
110
+ if (plain !== undefined) {
111
+ blocks.push({ _tag: 'text', text: plain, mimeType: 'text/plain' });
112
+ }
113
+
114
+ const echoMessage = Obj.make(Message.Message, {
115
+ [Obj.Meta]: {
116
+ keys: [{ id: email.id, source: JMAP_DOMAIN }],
117
+ },
118
+
119
+ created: email.receivedAt,
120
+ sender,
121
+ threadId: email.threadId,
122
+
123
+ properties: {
124
+ threadId: email.threadId,
125
+ // `snippet` is the field inbox UI renders for previews; JMAP's `preview` is its analogue.
126
+ snippet: email.preview ?? undefined,
127
+ subject: email.subject ?? undefined,
128
+ messageId: email.messageId?.[0],
129
+ inReplyTo: email.inReplyTo?.[0],
130
+ references: email.references?.join(' '),
131
+ to: formatAddresses(email.to),
132
+ cc: formatAddresses(email.cc),
133
+ },
134
+
135
+ blocks,
136
+ });
137
+
138
+ // `keywords` is a `{ [keyword]: true }` set; keep only the keys currently set.
139
+ const keywords = email.keywords
140
+ ? Object.entries(email.keywords)
141
+ .filter(([, set]) => set)
142
+ .map(([keyword]) => keyword)
143
+ : [];
144
+
145
+ return {
146
+ message: echoMessage,
147
+ mailboxIds: email.mailboxIds ? Object.keys(email.mailboxIds) : [],
148
+ keywords,
149
+ };
150
+ };
151
+
152
+ /**
153
+ * Maps a JMAP email to an ECHO Message, resolving the sender against existing contacts. Composes
154
+ * {@link decodeBody} → {@link mapToMessage}. Returns `null` when the email has no body or no sender.
155
+ */
156
+ export const mapEmail: (email: JmapMail.Email) => Effect.Effect<MappedEmail | null, never, Resolver> =
157
+ Effect.fnUntraced(function* (email) {
158
+ const decoded = decodeBody(email);
159
+ if (!decoded) {
160
+ return null;
161
+ }
162
+ const fromAddress = email.from?.[0];
163
+ const contact = fromAddress ? yield* resolve(Person.Person, { email: fromAddress.email }) : undefined;
164
+ return mapToMessage(decoded, contact ?? undefined);
165
+ });
166
+
167
+ /** Formats a JMAP address as `"Name <email>"`, or just the address when unnamed. */
168
+ const formatAddress = (address: JmapMail.EmailAddress): string =>
169
+ address.name ? `${address.name} <${address.email}>` : address.email;
170
+
171
+ /** Joins an address list into a header-style string, or `undefined` when empty. */
172
+ const formatAddresses = (addresses: readonly JmapMail.EmailAddress[] | null | undefined): string | undefined =>
173
+ addresses && addresses.length > 0 ? addresses.map(formatAddress).join(', ') : undefined;
174
+
175
+ /**
176
+ * Returns the first non-empty decoded value among the given body parts (e.g. `email.htmlBody` or
177
+ * `email.textBody`). `Email/get` is requested with `fetchHTMLBodyValues`/`fetchTextBodyValues`, so the
178
+ * referenced `bodyValues` are already decoded.
179
+ */
180
+ const getBodyValue = (
181
+ email: JmapMail.Email,
182
+ parts: readonly JmapMail.EmailBodyPart[] | undefined,
183
+ ): string | undefined => {
184
+ for (const part of parts ?? []) {
185
+ const value = part.partId ? email.bodyValues?.[part.partId]?.value : undefined;
186
+ if (value && value.trim().length > 0) {
187
+ return value;
188
+ }
189
+ }
190
+ return undefined;
191
+ };
@@ -0,0 +1,41 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import { SyncDatabaseMissingError } from '@dxos/app-toolkit';
8
+ import * as Operation from '@dxos/compute/Operation';
9
+ import { Database, Obj, Ref } from '@dxos/echo';
10
+ import * as InboxOperation from '@dxos/plugin-inbox/InboxOperation';
11
+ import * as Mailbox from '@dxos/plugin-inbox/Mailbox';
12
+
13
+ /**
14
+ * Eagerly materializes a local Mailbox so the sync cursor's target exists before the cursor is
15
+ * created. JMAP is a single-target connector (the account inbox), so a fresh Mailbox is always
16
+ * created; the connection's `accessToken.account` seeds the default name. Mirrors the Gmail
17
+ * materialize-target.
18
+ */
19
+ const handler: Operation.WithHandler<typeof InboxOperation.MaterializeJmapTarget> =
20
+ InboxOperation.MaterializeJmapTarget.pipe(
21
+ Operation.withHandler(
22
+ Effect.fnUntraced(function* ({ connection }) {
23
+ // The operation derives the db from the connection ref's target and provides
24
+ // `Database.layer(db)` itself (composer's invoker is wired without a `databaseResolver`).
25
+ const connectionObj = connection.target;
26
+ const db = connectionObj ? Obj.getDatabase(connectionObj) : undefined;
27
+ if (!connectionObj || !db) {
28
+ return yield* Effect.fail(new SyncDatabaseMissingError());
29
+ }
30
+
31
+ return yield* Effect.gen(function* () {
32
+ const accessToken = yield* Database.load(connectionObj.accessToken);
33
+ const name = accessToken.account ?? 'Inbox';
34
+ const created = yield* Database.add(Mailbox.make({ name }));
35
+ return { target: Ref.make(created) };
36
+ }).pipe(Effect.provide(Database.layer(db)));
37
+ }),
38
+ ),
39
+ );
40
+
41
+ export default handler;
@@ -0,0 +1,116 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as FetchHttpClient from '@effect/platform/FetchHttpClient';
6
+ import * as Effect from 'effect/Effect';
7
+
8
+ import * as Operation from '@dxos/compute/Operation';
9
+ import { log } from '@dxos/log';
10
+ import * as InboxOperation from '@dxos/plugin-inbox/InboxOperation';
11
+ import * as SystemTags from '@dxos/plugin-inbox/SystemTags';
12
+
13
+ import { Jmap, JmapMail } from '../../../apis';
14
+ import { JmapApiError, JmapSendIdentityNotFoundError, JmapSendMessageInvalidError } from '../../../errors';
15
+ import { JmapCredentials } from '../../../services';
16
+
17
+ const MAIL_ACCOUNT_CAPABILITY = 'urn:ietf:params:jmap:mail';
18
+
19
+ export default InboxOperation.JmapSend.pipe(
20
+ Operation.withHandler(({ message, connection: connectionRef }) =>
21
+ Effect.gen(function* () {
22
+ log('sending email via jmap', { connection: connectionRef.uri });
23
+
24
+ const to = message.properties?.to;
25
+ const cc = message.properties?.cc;
26
+ const bcc = message.properties?.bcc;
27
+ const subject = message.properties?.subject;
28
+ const inReplyTo = message.properties?.inReplyTo;
29
+ const references = message.properties?.references;
30
+ const textBlock = message.blocks.find((block) => block._tag === 'text');
31
+ const text = textBlock?._tag === 'text' ? textBlock.text : undefined;
32
+
33
+ if (!to || !text) {
34
+ return yield* Effect.fail(new JmapSendMessageInvalidError());
35
+ }
36
+
37
+ const session = yield* Jmap.getSession;
38
+ const accountId = session.primaryAccounts[MAIL_ACCOUNT_CAPABILITY];
39
+ if (!accountId) {
40
+ return yield* Effect.fail(new JmapApiError(undefined, 'JMAP session has no mail account.'));
41
+ }
42
+ const target: JmapMail.Target = { apiUrl: session.apiUrl, accountId };
43
+
44
+ const credentials = yield* JmapCredentials;
45
+ const account = credentials.account ?? session.username;
46
+
47
+ // The submission requires an identity whose email the server permits as `from`; pick the one
48
+ // matching the connection account (fall back to the first if the account is unknown).
49
+ const { list: identities } = yield* JmapMail.identityGet(target);
50
+ const identity = account
51
+ ? identities.find((candidate) => candidate.email.toLowerCase() === account.toLowerCase())
52
+ : identities[0];
53
+ if (!identity) {
54
+ return yield* Effect.fail(new JmapSendIdentityNotFoundError(account));
55
+ }
56
+
57
+ const { list: folders } = yield* JmapMail.mailboxGet(target);
58
+ const drafts = folders.find((folder) => folder.role === 'drafts');
59
+ const sent = folders.find((folder) => folder.role === 'sent');
60
+ if (!drafts || !sent) {
61
+ return yield* Effect.fail(new JmapApiError(undefined, 'JMAP account is missing a Drafts or Sent folder.'));
62
+ }
63
+
64
+ const fromAddress = identity.name ? { email: identity.email, name: identity.name } : { email: identity.email };
65
+
66
+ const result = yield* JmapMail.submitEmail(target, {
67
+ identityId: identity.id,
68
+ draftsMailboxId: drafts.id,
69
+ sentMailboxId: sent.id,
70
+ draft: {
71
+ from: [fromAddress],
72
+ to: parseAddressList(to),
73
+ ...(cc ? { cc: parseAddressList(cc) } : {}),
74
+ ...(bcc ? { bcc: parseAddressList(bcc) } : {}),
75
+ ...(subject ? { subject } : {}),
76
+ ...(inReplyTo ? { inReplyTo: [inReplyTo] } : {}),
77
+ ...(references ? { references: splitReferences(references) } : {}),
78
+ text,
79
+ },
80
+ });
81
+
82
+ log('email sent via jmap', { id: result.id });
83
+ return {
84
+ id: result.id,
85
+ threadId: result.threadId ?? '',
86
+ // The submission filed the message into the Sent (`sent`-role) folder, which sync maps onto the
87
+ // canonical `sent` system tag; return that same canonical tag so the caller can tag the local
88
+ // draft to match the copy that will sync down.
89
+ sentTag: { ...SystemTags.systemTagKey('sent'), label: SystemTags.SystemTag.sent.label },
90
+ };
91
+ }).pipe(Effect.provide(FetchHttpClient.layer), Effect.provide(JmapCredentials.fromConnection(connectionRef))),
92
+ ),
93
+ Operation.opaqueHandler,
94
+ );
95
+
96
+ /** Parses a comma-separated header value into JMAP structured addresses. */
97
+ const parseAddressList = (value: string): { email: string; name?: string }[] =>
98
+ value
99
+ .split(',')
100
+ .map((part) => part.trim())
101
+ .filter(Boolean)
102
+ .map(parseAddress);
103
+
104
+ /** Parses a single `"Name <email>"` (or bare `email`) into a JMAP structured address. */
105
+ const parseAddress = (value: string): { email: string; name?: string } => {
106
+ const match = value.match(/^\s*(?:"?([^"<]*?)"?\s+)?<([^>]+)>\s*$/);
107
+ if (match) {
108
+ const name = match[1]?.trim();
109
+ const email = match[2].trim();
110
+ return name ? { email, name } : { email };
111
+ }
112
+ return { email: value.trim() };
113
+ };
114
+
115
+ /** Splits a `References` header (whitespace-separated message ids) into an array. */
116
+ const splitReferences = (value: string): string[] => value.split(/\s+/).filter(Boolean);
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export { default } from './handler';
@@ -0,0 +1,52 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as FetchHttpClient from '@effect/platform/FetchHttpClient';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Layer from 'effect/Layer';
8
+
9
+ import * as Operation from '@dxos/compute/Operation';
10
+ import { Obj } from '@dxos/echo';
11
+ import * as InboxResolver from '@dxos/extractor-lib';
12
+ import { Cursor } from '@dxos/link';
13
+ import { log } from '@dxos/log';
14
+ import * as InboxOperation from '@dxos/plugin-inbox/InboxOperation';
15
+ import { runMailSync } from '@dxos/plugin-inbox/sync';
16
+
17
+ import { JmapCredentials, JmapMailApi } from '../../../services';
18
+ import { jmapMailSyncProvider } from './sync-provider';
19
+
20
+ const handler = InboxOperation.JmapSync.pipe(
21
+ Operation.withHandler(({ binding: bindingRef }) =>
22
+ Effect.gen(function* () {
23
+ const bindingObj = bindingRef.target;
24
+ const db = bindingObj ? Obj.getDatabase(bindingObj) : undefined;
25
+ if (!bindingObj || !db || !Cursor.isExternal(bindingObj)) {
26
+ log.warn('jmap sync skipped: missing binding target or database', {
27
+ hasBinding: Boolean(bindingObj),
28
+ hasDatabase: Boolean(db),
29
+ });
30
+ return { newMessages: 0 };
31
+ }
32
+
33
+ const accessTokenRef = bindingObj.spec.source;
34
+ // Layer stack, top-down: the provider needs JmapMailApi + Resolver; JmapMailApi.Live needs the
35
+ // HTTP client + credentials. Chained `Layer.provide` reads as that dependency stack.
36
+ return yield* runMailSync({ binding: bindingRef }).pipe(
37
+ Effect.provide(
38
+ jmapMailSyncProvider().pipe(
39
+ Layer.provide(InboxResolver.Live),
40
+ Layer.provide(JmapMailApi.Live),
41
+ Layer.provide(FetchHttpClient.layer),
42
+ Layer.provide(JmapCredentials.fromAccessToken(accessTokenRef)),
43
+ ),
44
+ ),
45
+ Effect.withSpan('jmap-sync'),
46
+ );
47
+ }),
48
+ ),
49
+ Operation.opaqueHandler,
50
+ );
51
+
52
+ export default handler;
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './sync-provider';
6
+
7
+ export { default } from './handler';
@@ -0,0 +1,150 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, it, test } from '@effect/vitest';
6
+ import * as Effect from 'effect/Effect';
7
+
8
+ import { Obj } from '@dxos/echo';
9
+ import * as InboxResolver from '@dxos/extractor-lib';
10
+ import { Person } from '@dxos/types';
11
+
12
+ import { JmapMail } from '../../../apis';
13
+ import { JMAP_DOMAIN } from '../../../constants';
14
+ import { decodeBody, mapEmail } from '../mapper';
15
+
16
+ const makeJmapEmail = (overrides?: Partial<JmapMail.Email>): JmapMail.Email => ({
17
+ id: 'email-001',
18
+ threadId: 'thread-001',
19
+ mailboxIds: { 'mb-inbox': true },
20
+ keywords: { $seen: true },
21
+ from: [{ name: 'Alice', email: 'alice@unknown.com' }],
22
+ to: [{ name: null, email: 'bob@example.com' }],
23
+ cc: [{ name: 'Carol', email: 'carol@example.com' }],
24
+ subject: 'Test Subject',
25
+ receivedAt: '2026-01-15T10:00:00.000Z',
26
+ preview: 'Hello World preview',
27
+ messageId: ['<msg-001@unknown.com>'],
28
+ inReplyTo: ['<parent-001@unknown.com>'],
29
+ references: ['<ref-1@x.com>', '<ref-2@x.com>'],
30
+ bodyValues: { body: { value: 'Hello World' } },
31
+ textBody: [{ partId: 'body', type: 'text/plain' }],
32
+ ...overrides,
33
+ });
34
+
35
+ describe('mapEmail', () => {
36
+ it.effect(
37
+ 'maps a JMAP email to a Message with structured fields and the JMAP foreign key',
38
+ Effect.fnUntraced(function* ({ expect }) {
39
+ const result = yield* mapEmail(makeJmapEmail());
40
+ if (!result) {
41
+ throw new Error('expected a mapped message');
42
+ }
43
+ const message = result.message;
44
+
45
+ expect(message.created).toBe('2026-01-15T10:00:00.000Z');
46
+ expect(message.sender.email).toBe('alice@unknown.com');
47
+ expect(message.sender.name).toBe('Alice');
48
+
49
+ const properties = message.properties;
50
+ if (properties === undefined) {
51
+ throw new Error('expected message properties');
52
+ }
53
+ expect(properties.subject).toBe('Test Subject');
54
+ expect(properties.snippet).toBe('Hello World preview');
55
+ expect(properties.messageId).toBe('<msg-001@unknown.com>');
56
+ expect(properties.inReplyTo).toBe('<parent-001@unknown.com>');
57
+ expect(properties.references).toBe('<ref-1@x.com> <ref-2@x.com>');
58
+ expect(properties.to).toBe('bob@example.com');
59
+ expect(properties.cc).toBe('Carol <carol@example.com>');
60
+
61
+ const block = message.blocks[0];
62
+ if (block?._tag !== 'text') {
63
+ throw new Error('expected a text block');
64
+ }
65
+ expect(block.text).toContain('Hello World');
66
+
67
+ const foreignId = Obj.getMeta(message).keys.find((key) => key.source === JMAP_DOMAIN)?.id;
68
+ expect(foreignId).toBe('email-001');
69
+
70
+ expect(result.mailboxIds).toEqual(['mb-inbox']);
71
+ // Active keywords are surfaced verbatim; the provider maps them onto canonical system tags.
72
+ expect(result.keywords).toEqual(['$seen']);
73
+ }, Effect.provide(InboxResolver.Mock())),
74
+ );
75
+
76
+ it.effect(
77
+ 'omits sender.contact entirely when no contact resolves',
78
+ Effect.fnUntraced(function* ({ expect }) {
79
+ const result = yield* mapEmail(makeJmapEmail());
80
+ if (!result) {
81
+ throw new Error('expected a mapped message');
82
+ }
83
+
84
+ // An explicit `contact: undefined` round-trips to `null` (protobuf Struct) and fails Message
85
+ // schema validation on queue load; the key must be absent, not undefined.
86
+ expect('contact' in result.message.sender).toBe(false);
87
+ }, Effect.provide(InboxResolver.Mock())),
88
+ );
89
+
90
+ it.effect(
91
+ 'returns null when the email has no sender or no body',
92
+ Effect.fnUntraced(function* ({ expect }) {
93
+ const noSender = yield* mapEmail(makeJmapEmail({ from: null }));
94
+ expect(noSender).toBeNull();
95
+
96
+ const noBody = yield* mapEmail(makeJmapEmail({ bodyValues: undefined, textBody: undefined }));
97
+ expect(noBody).toBeNull();
98
+ }, Effect.provide(InboxResolver.Mock())),
99
+ );
100
+
101
+ it.effect(
102
+ 'resolves the sender contact when the resolver finds a Person',
103
+ Effect.fnUntraced(
104
+ function* ({ expect }) {
105
+ const result = yield* mapEmail(makeJmapEmail());
106
+ if (!result) {
107
+ throw new Error('expected a mapped message');
108
+ }
109
+ expect('contact' in result.message.sender).toBe(true);
110
+ },
111
+ Effect.provide(
112
+ InboxResolver.Mock({
113
+ people: [Obj.make(Person.Person, { emails: [{ value: 'alice@unknown.com' }] })],
114
+ }),
115
+ ),
116
+ ),
117
+ );
118
+ });
119
+
120
+ describe('decodeBody attachments', () => {
121
+ test('collects attachment metadata from Email.attachments', ({ expect }) => {
122
+ const email = makeJmapEmail({
123
+ attachments: [
124
+ { blobId: 'blob-1', name: 'photo.png', type: 'image/png', size: 1234 },
125
+ { partId: 'body-2', type: 'text/plain' }, // a body part with no blobId can't be downloaded.
126
+ ],
127
+ });
128
+ const decoded = decodeBody(email);
129
+ expect(decoded?.attachments).toEqual([{ blobId: 'blob-1', name: 'photo.png', mimeType: 'image/png', size: 1234 }]);
130
+ });
131
+
132
+ test('is empty when the email has no attachments', ({ expect }) => {
133
+ const decoded = decodeBody(makeJmapEmail());
134
+ expect(decoded?.attachments).toEqual([]);
135
+ });
136
+
137
+ test('extracts a Content-ID for inline attachments, stripping enclosing angle brackets if present', ({ expect }) => {
138
+ const email = makeJmapEmail({
139
+ attachments: [
140
+ { blobId: 'blob-1', name: 'signature.png', type: 'image/png', size: 999, cid: '<ii_mrcqn4871>' },
141
+ { blobId: 'blob-2', name: 'bare-cid.png', type: 'image/png', size: 111, cid: 'bare-id' },
142
+ ],
143
+ });
144
+ const decoded = decodeBody(email);
145
+ expect(decoded?.attachments).toEqual([
146
+ { blobId: 'blob-1', name: 'signature.png', mimeType: 'image/png', size: 999, contentId: 'ii_mrcqn4871' },
147
+ { blobId: 'blob-2', name: 'bare-cid.png', mimeType: 'image/png', size: 111, contentId: 'bare-id' },
148
+ ]);
149
+ });
150
+ });
@@ -0,0 +1,63 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as FetchHttpClient from '@effect/platform/FetchHttpClient';
6
+ import { describe, it } from '@effect/vitest';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as Layer from 'effect/Layer';
9
+ import * as Predicate from 'effect/Predicate';
10
+
11
+ import * as InboxResolver from '@dxos/extractor-lib';
12
+
13
+ import { Jmap, JmapMail } from '../../../apis';
14
+ import { JmapCredentials } from '../../../services';
15
+ import { mapEmail } from '../mapper';
16
+
17
+ /**
18
+ * Live JMAP test against a real server (Fastmail by default). Gated on `JMAP_TOKEN`, mirroring the
19
+ * Gmail `GOOGLE_ACCESS_TOKEN` gate. To run:
20
+ *
21
+ * export JMAP_TOKEN="<fastmail api token>" # Settings → Privacy & Security → Integrations
22
+ * export JMAP_HOST="api.fastmail.com" # optional; defaults to api.fastmail.com
23
+ * pnpm vitest sync-e2e.test.ts
24
+ */
25
+ const HOST = process.env.JMAP_HOST ?? 'api.fastmail.com';
26
+ const TOKEN = process.env.JMAP_TOKEN;
27
+
28
+ const TestLayer = Layer.mergeAll(
29
+ FetchHttpClient.layer,
30
+ JmapCredentials.fromValues({ host: HOST, token: TOKEN ?? '' }),
31
+ InboxResolver.Mock(),
32
+ );
33
+
34
+ describe.runIf(TOKEN)('JMAP live', { timeout: 30_000 }, () => {
35
+ it.effect(
36
+ 'discovers the session, queries the inbox, and maps messages',
37
+ Effect.fnUntraced(function* ({ expect }) {
38
+ const session = yield* Jmap.getSession;
39
+ const accountId = session.primaryAccounts['urn:ietf:params:jmap:mail'];
40
+ if (!accountId) {
41
+ throw new Error('JMAP session has no mail account');
42
+ }
43
+ const target: JmapMail.Target = { apiUrl: session.apiUrl, accountId };
44
+
45
+ const { list: folders } = yield* JmapMail.mailboxGet(target);
46
+ const inbox = folders.find((folder) => folder.role === 'inbox');
47
+ if (!inbox) {
48
+ throw new Error('JMAP account has no inbox');
49
+ }
50
+
51
+ const { ids } = yield* JmapMail.emailQuery(target, {
52
+ filter: { inMailbox: inbox.id },
53
+ sort: [{ property: 'receivedAt', isAscending: false }],
54
+ limit: 5,
55
+ });
56
+ const { list: emails } = yield* JmapMail.emailGet(target, ids);
57
+ const mapped = (yield* Effect.forEach(emails, (email) => mapEmail(email))).filter(Predicate.isNotNullable);
58
+
59
+ console.log(`JMAP live: ${folders.length} folders, ${ids.length} inbox ids, ${mapped.length} mapped messages`);
60
+ expect(Array.isArray(ids)).toBe(true);
61
+ }, Effect.provide(TestLayer)),
62
+ );
63
+ });