@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,324 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as HttpClient from '@effect/platform/HttpClient';
6
+ import * as HttpClientRequest from '@effect/platform/HttpClientRequest';
7
+ import * as Effect from 'effect/Effect';
8
+
9
+ import { withAuthorization } from '@dxos/compute-runtime';
10
+
11
+ import { JmapApiError } from '../../errors';
12
+ import { JmapCredentials } from '../../services/jmap-credentials';
13
+ import {
14
+ MAIL_CAPABILITIES,
15
+ REQUEST_RETRY,
16
+ REQUEST_TIMEOUT,
17
+ SUBMISSION_CAPABILITIES,
18
+ getMethodResponse,
19
+ jmapRequest,
20
+ shouldRetry,
21
+ } from '../Jmap/api';
22
+ import {
23
+ type EmailAddress,
24
+ EmailChangesResult,
25
+ EmailGetResult,
26
+ EmailQueryResult,
27
+ EmailSetResult,
28
+ EmailSubmissionSetResult,
29
+ IdentityGetResult,
30
+ MailboxGetResult,
31
+ } from './types';
32
+
33
+ /**
34
+ * Resolved per-request context: the session `apiUrl`, the mail account id, and (optionally) the
35
+ * session's blob `downloadUrl` template (RFC 8620 §6.2). Most operations (mailboxGet, emailQuery,
36
+ * send, delete, …) don't need it and construct a `Target` without it; only the attachment-fetching
37
+ * path populates it from `Jmap.Session.downloadUrl`, so {@link downloadBlob} is the sole consumer
38
+ * that requires it — absent here just means "this call site never populated it", not "the server
39
+ * didn't advertise one" (every real session does).
40
+ */
41
+ export type Target = {
42
+ readonly apiUrl: string;
43
+ readonly accountId: string;
44
+ readonly downloadUrl?: string;
45
+ };
46
+
47
+ /** Expands a JMAP `downloadUrl` URI Template (RFC 6570 level 1) with the given variables. */
48
+ const expandDownloadUrl = (
49
+ template: string,
50
+ variables: { accountId: string; blobId: string; type: string; name: string },
51
+ ): string =>
52
+ template.replace(/\{(\w+)\}/g, (_, key: string) =>
53
+ key in variables ? encodeURIComponent(variables[key as keyof typeof variables]) : '',
54
+ );
55
+
56
+ /**
57
+ * Downloads a blob's raw bytes via the session's `downloadUrl` template. `name`/`type` are advisory
58
+ * (they only affect the response's `Content-Disposition`/`Accept`), not required for correctness.
59
+ */
60
+ export const downloadBlob = Effect.fn('downloadBlob')(function* (
61
+ target: Target,
62
+ blobId: string,
63
+ options: { name?: string; type?: string } = {},
64
+ ) {
65
+ if (!target.downloadUrl) {
66
+ return yield* Effect.fail(new JmapApiError(undefined, 'Session has no downloadUrl.'));
67
+ }
68
+ const url = expandDownloadUrl(target.downloadUrl, {
69
+ accountId: target.accountId,
70
+ blobId,
71
+ name: options.name ?? blobId,
72
+ type: options.type ?? 'application/octet-stream',
73
+ });
74
+
75
+ const { token } = yield* JmapCredentials;
76
+ const httpClient = yield* HttpClient.HttpClient.pipe(Effect.map(withAuthorization(token, 'Bearer')));
77
+
78
+ // Mapped to `JmapApiError` before `Effect.timeout`/`Effect.retry` so `shouldRetry` can actually
79
+ // distinguish a permanent 4xx from a transient failure — mirrors `jmapRequest`.
80
+ const buffer = yield* HttpClientRequest.get(url).pipe(
81
+ httpClient.execute,
82
+ Effect.flatMap((response) => response.arrayBuffer),
83
+ Effect.mapError(asJmapDownloadError),
84
+ Effect.timeout(REQUEST_TIMEOUT),
85
+ Effect.retry({ schedule: REQUEST_RETRY, while: shouldRetry }),
86
+ Effect.scoped,
87
+ Effect.mapError(asJmapDownloadError),
88
+ );
89
+ return new Uint8Array(buffer);
90
+ });
91
+
92
+ /** Collapses transport/decode failures into a typed {@link JmapApiError} (mirrors `Jmap/api.ts`). */
93
+ const asJmapDownloadError = (error: unknown): JmapApiError =>
94
+ error instanceof JmapApiError
95
+ ? error
96
+ : new JmapApiError(undefined, error instanceof Error ? error.message : String(error));
97
+
98
+ /** Email properties fetched by {@link emailGet} — enough for the mapper to build a Message. */
99
+ export const EMAIL_PROPERTIES = [
100
+ 'id',
101
+ 'blobId',
102
+ 'threadId',
103
+ 'mailboxIds',
104
+ 'keywords',
105
+ 'from',
106
+ 'to',
107
+ 'cc',
108
+ 'subject',
109
+ 'receivedAt',
110
+ 'sentAt',
111
+ 'preview',
112
+ 'messageId',
113
+ 'inReplyTo',
114
+ 'references',
115
+ 'bodyValues',
116
+ 'textBody',
117
+ 'htmlBody',
118
+ 'attachments',
119
+ ] as const;
120
+
121
+ /** Lists all folders (mailboxes) in the account (RFC 8621 §2.3). */
122
+ export const mailboxGet = Effect.fn('mailboxGet')(function* (target: Target) {
123
+ const response = yield* jmapRequest(target.apiUrl, {
124
+ using: MAIL_CAPABILITIES,
125
+ methodCalls: [['Mailbox/get', { accountId: target.accountId, ids: null }, '0']],
126
+ });
127
+ return yield* getMethodResponse(response, '0', MailboxGetResult);
128
+ });
129
+
130
+ /** Queries email ids matching a filter (RFC 8621 §4.4). */
131
+ export const emailQuery = Effect.fn('emailQuery')(function* (
132
+ target: Target,
133
+ options: {
134
+ filter?: unknown;
135
+ sort?: readonly { property: string; isAscending?: boolean }[];
136
+ position?: number;
137
+ limit?: number;
138
+ calculateTotal?: boolean;
139
+ } = {},
140
+ ) {
141
+ const args: Record<string, unknown> = { accountId: target.accountId };
142
+ if (options.filter !== undefined) {
143
+ args.filter = options.filter;
144
+ }
145
+ if (options.sort !== undefined) {
146
+ args.sort = options.sort;
147
+ }
148
+ if (options.position !== undefined) {
149
+ args.position = options.position;
150
+ }
151
+ if (options.limit !== undefined) {
152
+ args.limit = options.limit;
153
+ }
154
+ if (options.calculateTotal !== undefined) {
155
+ args.calculateTotal = options.calculateTotal;
156
+ }
157
+
158
+ const response = yield* jmapRequest(target.apiUrl, {
159
+ using: MAIL_CAPABILITIES,
160
+ methodCalls: [['Email/query', args, '0']],
161
+ });
162
+ return yield* getMethodResponse(response, '0', EmailQueryResult);
163
+ });
164
+
165
+ /** Fetches emails by id with decoded HTML and text body values (RFC 8621 §4.2). */
166
+ export const emailGet = Effect.fn('emailGet')(function* (
167
+ target: Target,
168
+ ids: readonly string[],
169
+ properties: readonly string[] = EMAIL_PROPERTIES,
170
+ ) {
171
+ const response = yield* jmapRequest(target.apiUrl, {
172
+ using: MAIL_CAPABILITIES,
173
+ methodCalls: [
174
+ [
175
+ 'Email/get',
176
+ { accountId: target.accountId, ids, properties, fetchTextBodyValues: true, fetchHTMLBodyValues: true },
177
+ '0',
178
+ ],
179
+ ],
180
+ });
181
+ return yield* getMethodResponse(response, '0', EmailGetResult);
182
+ });
183
+
184
+ /**
185
+ * Fetches the delta of email changes since an opaque `sinceState` token (RFC 8621 §4.3). A server that
186
+ * can't compute the delta (state too old / evicted) returns a `cannotCalculateChanges` method error,
187
+ * surfaced as a {@link JmapApiError} with that `type` — the caller's cue to fall back to a full scan.
188
+ * The token to pass comes from a prior `Email/get` `state` (NOT `Email/query` `queryState`).
189
+ */
190
+ export const emailChanges = Effect.fn('emailChanges')(function* (
191
+ target: Target,
192
+ sinceState: string,
193
+ maxChanges?: number,
194
+ ) {
195
+ const args: Record<string, unknown> = { accountId: target.accountId, sinceState };
196
+ if (maxChanges !== undefined) {
197
+ args.maxChanges = maxChanges;
198
+ }
199
+ const response = yield* jmapRequest(target.apiUrl, {
200
+ using: MAIL_CAPABILITIES,
201
+ methodCalls: [['Email/changes', args, '0']],
202
+ });
203
+ return yield* getMethodResponse(response, '0', EmailChangesResult);
204
+ });
205
+
206
+ /** Lists the account's sending identities (RFC 8621 §6.2). */
207
+ export const identityGet = Effect.fn('identityGet')(function* (target: Target) {
208
+ const response = yield* jmapRequest(target.apiUrl, {
209
+ using: SUBMISSION_CAPABILITIES,
210
+ methodCalls: [['Identity/get', { accountId: target.accountId, ids: null }, '0']],
211
+ });
212
+ return yield* getMethodResponse(response, '0', IdentityGetResult);
213
+ });
214
+
215
+ /** Patches a single email (e.g. replace `mailboxIds` to move to Trash). Idempotent, so retryable. */
216
+ export const emailSetUpdate = Effect.fn('emailSetUpdate')(function* (
217
+ target: Target,
218
+ emailId: string,
219
+ patch: Record<string, unknown>,
220
+ ) {
221
+ const response = yield* jmapRequest(target.apiUrl, {
222
+ using: MAIL_CAPABILITIES,
223
+ methodCalls: [['Email/set', { accountId: target.accountId, update: { [emailId]: patch } }, '0']],
224
+ });
225
+ const result = yield* getMethodResponse(response, '0', EmailSetResult);
226
+ const error = result.notUpdated?.[emailId];
227
+ if (error) {
228
+ return yield* Effect.fail(new JmapApiError(undefined, error.description ?? error.type, error.type));
229
+ }
230
+ return result;
231
+ });
232
+
233
+ /**
234
+ * Creates a draft and submits it for delivery in a single batched request. Not retried (a retry
235
+ * could double-send). Returns the created email's id and thread id.
236
+ */
237
+ export const submitEmail = Effect.fn('submitEmail')(function* (
238
+ target: Target,
239
+ args: {
240
+ identityId: string;
241
+ draftsMailboxId: string;
242
+ sentMailboxId: string;
243
+ draft: {
244
+ from: readonly EmailAddress[];
245
+ to: readonly EmailAddress[];
246
+ cc?: readonly EmailAddress[];
247
+ bcc?: readonly EmailAddress[];
248
+ subject?: string;
249
+ inReplyTo?: readonly string[];
250
+ references?: readonly string[];
251
+ text: string;
252
+ };
253
+ },
254
+ ) {
255
+ const { identityId, draftsMailboxId, sentMailboxId, draft } = args;
256
+ const draftCreate: Record<string, unknown> = {
257
+ mailboxIds: { [draftsMailboxId]: true },
258
+ keywords: { $draft: true, $seen: true },
259
+ from: draft.from,
260
+ to: draft.to,
261
+ bodyValues: { body: { value: draft.text } },
262
+ textBody: [{ partId: 'body', type: 'text/plain' }],
263
+ };
264
+ if (draft.cc && draft.cc.length > 0) {
265
+ draftCreate.cc = draft.cc;
266
+ }
267
+ if (draft.bcc && draft.bcc.length > 0) {
268
+ draftCreate.bcc = draft.bcc;
269
+ }
270
+ if (draft.subject !== undefined) {
271
+ draftCreate.subject = draft.subject;
272
+ }
273
+ if (draft.inReplyTo && draft.inReplyTo.length > 0) {
274
+ draftCreate.inReplyTo = draft.inReplyTo;
275
+ }
276
+ if (draft.references && draft.references.length > 0) {
277
+ draftCreate.references = draft.references;
278
+ }
279
+
280
+ const response = yield* jmapRequest(
281
+ target.apiUrl,
282
+ {
283
+ using: SUBMISSION_CAPABILITIES,
284
+ methodCalls: [
285
+ ['Email/set', { accountId: target.accountId, create: { draft: draftCreate } }, '0'],
286
+ [
287
+ 'EmailSubmission/set',
288
+ {
289
+ accountId: target.accountId,
290
+ create: { sub: { emailId: '#draft', identityId } },
291
+ onSuccessUpdateEmail: {
292
+ '#sub': {
293
+ [`mailboxIds/${draftsMailboxId}`]: null,
294
+ [`mailboxIds/${sentMailboxId}`]: true,
295
+ 'keywords/$draft': null,
296
+ },
297
+ },
298
+ },
299
+ '1',
300
+ ],
301
+ ],
302
+ },
303
+ { retry: false },
304
+ );
305
+
306
+ const setResult = yield* getMethodResponse(response, '0', EmailSetResult);
307
+ const created = setResult.created?.draft;
308
+ if (!created) {
309
+ const error = setResult.notCreated?.draft;
310
+ return yield* Effect.fail(
311
+ new JmapApiError(undefined, error?.description ?? error?.type ?? 'Draft was not created.', error?.type),
312
+ );
313
+ }
314
+
315
+ const submissionResult = yield* getMethodResponse(response, '1', EmailSubmissionSetResult);
316
+ if (!submissionResult.created?.sub) {
317
+ const error = submissionResult.notCreated?.sub;
318
+ return yield* Effect.fail(
319
+ new JmapApiError(undefined, error?.description ?? error?.type ?? 'Email submission failed.', error?.type),
320
+ );
321
+ }
322
+
323
+ return { id: created.id, threadId: created.threadId };
324
+ });
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './api';
6
+ export * from './types';
7
+ export * from './query';
@@ -0,0 +1,181 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { subDays, subMonths, subWeeks, subYears } from 'date-fns';
6
+ import * as Option from 'effect/Option';
7
+ import { describe, test } from 'vitest';
8
+
9
+ import { type Filter } from '../Jmap';
10
+ import { filterScopesMailbox, parseMailQuery, resolveMailboxByNameOrRole } from './query';
11
+ import { type Mailbox } from './types';
12
+
13
+ const FOLDERS: Mailbox[] = [
14
+ { id: 'mb-inbox', name: 'Inbox', role: 'inbox' },
15
+ { id: 'mb-sent', name: 'Sent', role: 'sent' },
16
+ { id: 'mb-junk', name: 'Spam', role: 'junk' },
17
+ { id: 'mb-school', name: 'School', role: null },
18
+ ];
19
+
20
+ const NOW = new Date('2026-06-15T00:00:00.000Z');
21
+
22
+ const ctx = { now: NOW, resolveMailbox: (nameOrRole: string) => resolveMailboxByNameOrRole(FOLDERS, nameOrRole) };
23
+
24
+ // Returns the parsed filter, or `null` when the query yields no filter.
25
+ const parse = (query: string): Filter | null => Option.getOrNull(parseMailQuery(query, ctx));
26
+
27
+ describe('parseMailQuery', () => {
28
+ test('empty / whitespace yields no filter', ({ expect }) => {
29
+ expect(parse('')).toBeNull();
30
+ expect(parse(' ')).toBeNull();
31
+ });
32
+
33
+ test('bare words become full-text conditions, AND-ed', ({ expect }) => {
34
+ expect(parse('hello')).toEqual({ text: 'hello' });
35
+ expect(parse('hello world')).toEqual({ operator: 'AND', conditions: [{ text: 'hello' }, { text: 'world' }] });
36
+ });
37
+
38
+ test('quoted phrases stay intact', ({ expect }) => {
39
+ expect(parse('"hello world"')).toEqual({ text: 'hello world' });
40
+ expect(parse('subject:"hello world"')).toEqual({ subject: 'hello world' });
41
+ });
42
+
43
+ test('address and subject/body operators', ({ expect }) => {
44
+ expect(parse('from:alice@x.com')).toEqual({ from: 'alice@x.com' });
45
+ expect(parse('to:bob@x.com')).toEqual({ to: 'bob@x.com' });
46
+ expect(parse('cc:carol@x.com')).toEqual({ cc: 'carol@x.com' });
47
+ expect(parse('bcc:dan@x.com')).toEqual({ bcc: 'dan@x.com' });
48
+ expect(parse('subject:invoice')).toEqual({ subject: 'invoice' });
49
+ expect(parse('body:receipt')).toEqual({ body: 'receipt' });
50
+ });
51
+
52
+ test('label / in resolve to a mailbox id (by name or role/alias)', ({ expect }) => {
53
+ expect(parse('label:School')).toEqual({ inMailbox: 'mb-school' });
54
+ expect(parse('label:school')).toEqual({ inMailbox: 'mb-school' });
55
+ expect(parse('in:inbox')).toEqual({ inMailbox: 'mb-inbox' });
56
+ expect(parse('in:spam')).toEqual({ inMailbox: 'mb-junk' });
57
+ });
58
+
59
+ test('an unknown label drops the term (and yields no filter when alone)', ({ expect }) => {
60
+ expect(parse('label:Nonexistent')).toBeNull();
61
+ expect(parse('label:Nonexistent subject:hi')).toEqual({ subject: 'hi' });
62
+ });
63
+
64
+ test('is: maps to keyword conditions', ({ expect }) => {
65
+ expect(parse('is:unread')).toEqual({ notKeyword: '$seen' });
66
+ expect(parse('is:read')).toEqual({ hasKeyword: '$seen' });
67
+ expect(parse('is:starred')).toEqual({ hasKeyword: '$flagged' });
68
+ expect(parse('is:draft')).toEqual({ hasKeyword: '$draft' });
69
+ expect(parse('is:bogus')).toBeNull();
70
+ });
71
+
72
+ test('has:attachment', ({ expect }) => {
73
+ expect(parse('has:attachment')).toEqual({ hasAttachment: true });
74
+ expect(parse('has:nope')).toBeNull();
75
+ });
76
+
77
+ test('absolute dates (slash or dash) become UTC before/after', ({ expect }) => {
78
+ expect(parse('after:2024/01/01')).toEqual({ after: '2024-01-01T00:00:00.000Z' });
79
+ expect(parse('before:2024-12-31')).toEqual({ before: '2024-12-31T00:00:00.000Z' });
80
+ expect(parse('newer:2024/06/01')).toEqual({ after: '2024-06-01T00:00:00.000Z' });
81
+ expect(parse('after:not-a-date')).toBeNull();
82
+ });
83
+
84
+ test('relative dates compute from `now`', ({ expect }) => {
85
+ expect(parse('newer_than:7d')).toEqual({ after: subDays(NOW, 7).toISOString() });
86
+ expect(parse('newer_than:2w')).toEqual({ after: subWeeks(NOW, 2).toISOString() });
87
+ expect(parse('older_than:3m')).toEqual({ before: subMonths(NOW, 3).toISOString() });
88
+ expect(parse('older_than:1y')).toEqual({ before: subYears(NOW, 1).toISOString() });
89
+ });
90
+
91
+ test('size operators parse units', ({ expect }) => {
92
+ expect(parse('larger:10M')).toEqual({ minSize: 10 * 1024 * 1024 });
93
+ expect(parse('smaller:500k')).toEqual({ maxSize: 500 * 1024 });
94
+ expect(parse('larger:2048')).toEqual({ minSize: 2048 });
95
+ });
96
+
97
+ test('list / deliveredto map to header conditions', ({ expect }) => {
98
+ expect(parse('list:dev.example.com')).toEqual({ header: ['List-Id', 'dev.example.com'] });
99
+ expect(parse('deliveredto:me@x.com')).toEqual({ header: ['Delivered-To', 'me@x.com'] });
100
+ });
101
+
102
+ test('negation wraps a term in NOT', ({ expect }) => {
103
+ expect(parse('-from:noreply@x.com')).toEqual({ operator: 'NOT', conditions: [{ from: 'noreply@x.com' }] });
104
+ });
105
+
106
+ test('OR groups adjacent terms', ({ expect }) => {
107
+ expect(parse('from:a@x.com OR from:b@x.com')).toEqual({
108
+ operator: 'OR',
109
+ conditions: [{ from: 'a@x.com' }, { from: 'b@x.com' }],
110
+ });
111
+ });
112
+
113
+ test('consecutive ORs flatten into a single OR', ({ expect }) => {
114
+ expect(parse('a OR b OR c')).toEqual({
115
+ operator: 'OR',
116
+ conditions: [{ text: 'a' }, { text: 'b' }, { text: 'c' }],
117
+ });
118
+ });
119
+
120
+ test('OR binds tighter than the implicit AND chain', ({ expect }) => {
121
+ expect(parse('from:a OR from:b subject:c')).toEqual({
122
+ operator: 'AND',
123
+ conditions: [{ operator: 'OR', conditions: [{ from: 'a' }, { from: 'b' }] }, { subject: 'c' }],
124
+ });
125
+ });
126
+
127
+ test('a dropped term consumes the pending OR (next term is AND, not OR)', ({ expect }) => {
128
+ // `label:Unknown` drops out; `subject:c` must NOT OR-merge with `from:a`.
129
+ expect(parse('from:a OR label:Unknown subject:c')).toEqual({
130
+ operator: 'AND',
131
+ conditions: [{ from: 'a' }, { subject: 'c' }],
132
+ });
133
+ });
134
+
135
+ test('a realistic combined query', ({ expect }) => {
136
+ expect(parse('label:School is:unread newer_than:14d -from:noreply@x.com')).toEqual({
137
+ operator: 'AND',
138
+ conditions: [
139
+ { inMailbox: 'mb-school' },
140
+ { notKeyword: '$seen' },
141
+ { after: subDays(NOW, 14).toISOString() },
142
+ { operator: 'NOT', conditions: [{ from: 'noreply@x.com' }] },
143
+ ],
144
+ });
145
+ });
146
+
147
+ test('an unknown operator falls back to a literal full-text term', ({ expect }) => {
148
+ expect(parse('foo:bar')).toEqual({ text: 'foo:bar' });
149
+ });
150
+ });
151
+
152
+ describe('resolveMailboxByNameOrRole', () => {
153
+ test('resolves by case-insensitive name', ({ expect }) => {
154
+ expect(Option.getOrNull(resolveMailboxByNameOrRole(FOLDERS, 'school'))).toBe('mb-school');
155
+ expect(Option.getOrNull(resolveMailboxByNameOrRole(FOLDERS, 'SCHOOL'))).toBe('mb-school');
156
+ });
157
+
158
+ test('resolves by role and alias', ({ expect }) => {
159
+ expect(Option.getOrNull(resolveMailboxByNameOrRole(FOLDERS, 'inbox'))).toBe('mb-inbox');
160
+ expect(Option.getOrNull(resolveMailboxByNameOrRole(FOLDERS, 'spam'))).toBe('mb-junk');
161
+ });
162
+
163
+ test('returns None for an unknown name', ({ expect }) => {
164
+ expect(Option.getOrNull(resolveMailboxByNameOrRole(FOLDERS, 'nope'))).toBeNull();
165
+ });
166
+ });
167
+
168
+ describe('filterScopesMailbox', () => {
169
+ test('true when any condition restricts the mailbox', ({ expect }) => {
170
+ expect(filterScopesMailbox({ inMailbox: 'mb-school' })).toBe(true);
171
+ expect(filterScopesMailbox({ inMailboxOtherThan: ['mb-junk'] })).toBe(true);
172
+ expect(filterScopesMailbox({ operator: 'AND', conditions: [{ from: 'a' }, { inMailbox: 'x' }] })).toBe(true);
173
+ });
174
+
175
+ test('false when no condition restricts the mailbox', ({ expect }) => {
176
+ expect(filterScopesMailbox({ from: 'a@x.com' })).toBe(false);
177
+ expect(filterScopesMailbox({ operator: 'OR', conditions: [{ notKeyword: '$seen' }, { subject: 'x' }] })).toBe(
178
+ false,
179
+ );
180
+ });
181
+ });