@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,231 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { subDays, subMonths, subWeeks, subYears } from 'date-fns';
6
+ import * as Match from 'effect/Match';
7
+ import * as Option from 'effect/Option';
8
+
9
+ import { type Filter, type FilterCondition, type FilterOperator } from '../Jmap';
10
+ import { type Mailbox } from './types';
11
+
12
+ /** Context for {@link parseMailQuery}: a clock (for relative dates) and a label/folder resolver. */
13
+ export type ParseContext = {
14
+ readonly now: Date;
15
+ /** Resolves a `label:`/`in:` name or system role to a JMAP mailbox id. */
16
+ readonly resolveMailbox: (nameOrRole: string) => Option.Option<string>;
17
+ };
18
+
19
+ type Token =
20
+ | { readonly _tag: 'or' }
21
+ | { readonly _tag: 'term'; readonly negate: boolean; readonly op: Option.Option<string>; readonly value: string };
22
+
23
+ /**
24
+ * Translates a Gmail-like query string into a JMAP `Email/query` filter (RFC 8621 §4.4.1).
25
+ *
26
+ * Supported operators: `from:`/`to:`/`cc:`/`bcc:`/`subject:`/`body:`, `label:`/`in:` (resolved to a
27
+ * mailbox id), `is:unread|read|starred|flagged|unstarred|draft|answered`, `has:attachment`,
28
+ * `after:`/`before:` (`YYYY/MM/DD` or `YYYY-MM-DD`), `newer_than:`/`older_than:` (e.g. `7d`, `2w`,
29
+ * `3m`, `1y`), `larger:`/`smaller:` (e.g. `10M`, `500k`), `list:`/`deliveredto:` (header match),
30
+ * quoted phrases, `-` negation, `OR` groups, and bare words (full-text). Space-separated terms are
31
+ * AND-ed. Returns `None` for an empty query, or one whose terms all fail to resolve (e.g. an unknown
32
+ * label).
33
+ */
34
+ export const parseMailQuery = (query: string, ctx: ParseContext): Option.Option<Filter> =>
35
+ buildFilter(tokenize(query), ctx);
36
+
37
+ /** Resolves a label/folder name (case-insensitive) or a system role/alias to a mailbox id. */
38
+ export const resolveMailboxByNameOrRole = (folders: readonly Mailbox[], nameOrRole: string): Option.Option<string> => {
39
+ const lowered = nameOrRole.toLowerCase();
40
+ const role = ROLE_ALIASES[lowered] ?? lowered;
41
+ const match =
42
+ folders.find((folder) => folder.role === role) ?? folders.find((folder) => folder.name.toLowerCase() === lowered);
43
+ return Option.fromNullable(match?.id);
44
+ };
45
+
46
+ //
47
+ // Tokenizer.
48
+ //
49
+
50
+ // A token is an optional `-`, optional `op:` prefix, then a `"quoted"` value or a non-space run.
51
+ const TOKEN_RE = /-?(?:[A-Za-z_]+:)?"[^"]*"|\S+/g;
52
+
53
+ const tokenize = (query: string): readonly Token[] => (query.match(TOKEN_RE) ?? []).map(parseToken);
54
+
55
+ const parseToken = (raw: string): Token => {
56
+ if (raw === 'OR') {
57
+ return { _tag: 'or' };
58
+ }
59
+ const negate = raw.startsWith('-');
60
+ const rest = negate ? raw.slice(1) : raw;
61
+ const colon = rest.indexOf(':');
62
+ if (colon > 0 && /^[A-Za-z_]+$/.test(rest.slice(0, colon))) {
63
+ return {
64
+ _tag: 'term',
65
+ negate,
66
+ op: Option.some(rest.slice(0, colon).toLowerCase()),
67
+ value: stripQuotes(rest.slice(colon + 1)),
68
+ };
69
+ }
70
+ return { _tag: 'term', negate, op: Option.none(), value: stripQuotes(rest) };
71
+ };
72
+
73
+ const stripQuotes = (value: string): string =>
74
+ value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
75
+
76
+ //
77
+ // Folding tokens into a filter tree. Space-separated terms AND together; an `OR` token merges the
78
+ // next term with the previous operand. Terms that resolve to `None` (unknown label, empty value) are
79
+ // dropped.
80
+ //
81
+
82
+ const buildFilter = (tokens: readonly Token[], ctx: ParseContext): Option.Option<Filter> => {
83
+ const { operands } = tokens.reduce<{ operands: readonly Filter[]; pendingOr: boolean }>(
84
+ (state, token) => {
85
+ if (token._tag === 'or') {
86
+ return { ...state, pendingOr: state.operands.length > 0 };
87
+ }
88
+ const leaf = buildTerm(token, ctx);
89
+ if (Option.isNone(leaf)) {
90
+ // A dropped term (unknown label, empty value) consumes any pending OR: the next valid term
91
+ // must not merge with the previous operand unless its own `or` token immediately precedes it.
92
+ return { ...state, pendingOr: false };
93
+ }
94
+ const previous = state.pendingOr ? state.operands[state.operands.length - 1] : undefined;
95
+ return previous !== undefined
96
+ ? { operands: [...state.operands.slice(0, -1), mergeOr(previous, leaf.value)], pendingOr: false }
97
+ : { operands: [...state.operands, leaf.value], pendingOr: false };
98
+ },
99
+ { operands: [], pendingOr: false },
100
+ );
101
+
102
+ return Match.value(operands.length).pipe(
103
+ Match.when(0, () => Option.none<Filter>()),
104
+ Match.when(1, () => Option.fromNullable(operands[0])),
105
+ Match.orElse(() => Option.some<Filter>({ operator: 'AND', conditions: operands })),
106
+ );
107
+ };
108
+
109
+ const mergeOr = (previous: Filter, next: Filter): FilterOperator =>
110
+ isOperator(previous) && previous.operator === 'OR'
111
+ ? { operator: 'OR', conditions: [...previous.conditions, next] }
112
+ : { operator: 'OR', conditions: [previous, next] };
113
+
114
+ const isOperator = (filter: Filter): filter is FilterOperator => 'operator' in filter;
115
+
116
+ /** Whether a filter restricts the mailbox scope (`inMailbox`/`inMailboxOtherThan`), recursively. */
117
+ export const filterScopesMailbox = (filter: Filter): boolean =>
118
+ isOperator(filter)
119
+ ? filter.conditions.some(filterScopesMailbox)
120
+ : filter.inMailbox !== undefined || filter.inMailboxOtherThan !== undefined;
121
+
122
+ const buildTerm = (token: Extract<Token, { _tag: 'term' }>, ctx: ParseContext): Option.Option<Filter> =>
123
+ buildLeaf(token.op, token.value, ctx).pipe(Option.map((leaf) => (token.negate ? negate(leaf) : leaf)));
124
+
125
+ const negate = (filter: Filter): FilterOperator => ({ operator: 'NOT', conditions: [filter] });
126
+
127
+ //
128
+ // Leaf construction (operator → JMAP FilterCondition).
129
+ //
130
+
131
+ const buildLeaf = (op: Option.Option<string>, value: string, ctx: ParseContext): Option.Option<Filter> =>
132
+ op.pipe(
133
+ Option.match({
134
+ onNone: () => textLeaf(value),
135
+ onSome: (operator) => buildOperatorLeaf(operator, value, ctx),
136
+ }),
137
+ );
138
+
139
+ const buildOperatorLeaf = (operator: string, value: string, ctx: ParseContext): Option.Option<Filter> =>
140
+ Match.value(operator).pipe(
141
+ Match.whenOr('from', 'to', 'cc', 'bcc', 'subject', 'body', (field) =>
142
+ condLeaf(value, STRING_FIELD_LEAVES[field](value)),
143
+ ),
144
+ Match.whenOr('label', 'in', () => ctx.resolveMailbox(value).pipe(Option.map((id) => ({ inMailbox: id })))),
145
+ Match.when('is', () => Option.fromNullable(IS_LEAVES[value.toLowerCase()])),
146
+ Match.when('has', () =>
147
+ value.toLowerCase() === 'attachment' ? Option.some<Filter>({ hasAttachment: true }) : Option.none<Filter>(),
148
+ ),
149
+ Match.whenOr('after', 'newer', () => dateLeaf(value, 'after')),
150
+ Match.whenOr('before', 'older', () => dateLeaf(value, 'before')),
151
+ Match.when('newer_than', () => relativeDateLeaf(value, ctx.now, 'after')),
152
+ Match.when('older_than', () => relativeDateLeaf(value, ctx.now, 'before')),
153
+ Match.when('larger', () => sizeLeaf(value, 'minSize')),
154
+ Match.when('smaller', () => sizeLeaf(value, 'maxSize')),
155
+ Match.when('list', () => headerLeaf('List-Id', value)),
156
+ Match.when('deliveredto', () => headerLeaf('Delivered-To', value)),
157
+ // Unknown operator: keep the literal `op:value` as a full-text term (mirrors Gmail's fallback).
158
+ Match.orElse(() => textLeaf(`${operator}:${value}`)),
159
+ );
160
+
161
+ type StringField = 'from' | 'to' | 'cc' | 'bcc' | 'subject' | 'body';
162
+
163
+ const STRING_FIELD_LEAVES: Record<StringField, (value: string) => FilterCondition> = {
164
+ from: (value) => ({ from: value }),
165
+ to: (value) => ({ to: value }),
166
+ cc: (value) => ({ cc: value }),
167
+ bcc: (value) => ({ bcc: value }),
168
+ subject: (value) => ({ subject: value }),
169
+ body: (value) => ({ body: value }),
170
+ };
171
+
172
+ const IS_LEAVES: Record<string, Filter> = {
173
+ unread: { notKeyword: '$seen' },
174
+ read: { hasKeyword: '$seen' },
175
+ starred: { hasKeyword: '$flagged' },
176
+ flagged: { hasKeyword: '$flagged' },
177
+ unstarred: { notKeyword: '$flagged' },
178
+ unflagged: { notKeyword: '$flagged' },
179
+ draft: { hasKeyword: '$draft' },
180
+ answered: { hasKeyword: '$answered' },
181
+ replied: { hasKeyword: '$answered' },
182
+ };
183
+
184
+ const ROLE_ALIASES: Record<string, string> = { spam: 'junk', bin: 'trash', deleted: 'trash' };
185
+
186
+ const SIZE_UNITS: Record<string, number> = { '': 1, 'k': 1024, 'm': 1024 * 1024, 'g': 1024 * 1024 * 1024 };
187
+ const DATE_RE = /^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$/;
188
+ const RELATIVE_RE = /^(\d+)([dwmy])$/;
189
+ const SIZE_RE = /^(\d+)([kmg]?)b?$/i;
190
+
191
+ /** Wraps a built condition in `Some`, or `None` when the value is blank. */
192
+ const condLeaf = (value: string, condition: Filter): Option.Option<Filter> =>
193
+ value.trim().length === 0 ? Option.none() : Option.some(condition);
194
+
195
+ const textLeaf = (value: string): Option.Option<Filter> => condLeaf(value, { text: value });
196
+
197
+ const headerLeaf = (name: string, value: string): Option.Option<Filter> => condLeaf(value, { header: [name, value] });
198
+
199
+ const dateLeaf = (value: string, bound: 'after' | 'before'): Option.Option<Filter> => {
200
+ const match = DATE_RE.exec(value);
201
+ if (!match) {
202
+ return Option.none();
203
+ }
204
+ const iso = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))).toISOString();
205
+ return Option.some(bound === 'after' ? { after: iso } : { before: iso });
206
+ };
207
+
208
+ const relativeDateLeaf = (value: string, now: Date, bound: 'after' | 'before'): Option.Option<Filter> => {
209
+ const match = RELATIVE_RE.exec(value.toLowerCase());
210
+ if (!match) {
211
+ return Option.none();
212
+ }
213
+ const amount = Number(match[1]);
214
+ const date = Match.value(match[2]).pipe(
215
+ Match.when('w', () => subWeeks(now, amount)),
216
+ Match.when('m', () => subMonths(now, amount)),
217
+ Match.when('y', () => subYears(now, amount)),
218
+ Match.orElse(() => subDays(now, amount)),
219
+ );
220
+ const iso = date.toISOString();
221
+ return Option.some(bound === 'after' ? { after: iso } : { before: iso });
222
+ };
223
+
224
+ const sizeLeaf = (value: string, bound: 'minSize' | 'maxSize'): Option.Option<Filter> => {
225
+ const match = SIZE_RE.exec(value.toLowerCase());
226
+ if (!match) {
227
+ return Option.none();
228
+ }
229
+ const bytes = Number(match[1]) * (SIZE_UNITS[match[2] ?? ''] ?? 1);
230
+ return Option.some(bound === 'minSize' ? { minSize: bytes } : { maxSize: bytes });
231
+ };
@@ -0,0 +1,196 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Schema from 'effect/Schema';
6
+
7
+ /** Structured email address — JMAP gives `{ name, email }` directly (no header parsing). */
8
+ export const EmailAddress = Schema.Struct({
9
+ name: Schema.optional(Schema.NullOr(Schema.String)),
10
+ email: Schema.String,
11
+ });
12
+ export type EmailAddress = Schema.Schema.Type<typeof EmailAddress>;
13
+
14
+ /** A folder. `role` is a well-known role (`inbox`, `drafts`, `sent`, `trash`, …) or null for custom folders. */
15
+ export const Mailbox = Schema.Struct({
16
+ id: Schema.String,
17
+ name: Schema.String,
18
+ role: Schema.optional(Schema.NullOr(Schema.String)),
19
+ parentId: Schema.optional(Schema.NullOr(Schema.String)),
20
+ sortOrder: Schema.optional(Schema.Number),
21
+ totalEmails: Schema.optional(Schema.Number),
22
+ unreadEmails: Schema.optional(Schema.Number),
23
+ });
24
+ export type Mailbox = Schema.Schema.Type<typeof Mailbox>;
25
+
26
+ /** A decoded body part value (when fetched with `fetchTextBodyValues`). */
27
+ export const BodyValue = Schema.Struct({
28
+ value: Schema.String,
29
+ isEncodingProblem: Schema.optional(Schema.Boolean),
30
+ isTruncated: Schema.optional(Schema.Boolean),
31
+ });
32
+ export type BodyValue = Schema.Schema.Type<typeof BodyValue>;
33
+
34
+ /** A body part reference (the `partId` keys into `Email.bodyValues`; `blobId` fetches raw bytes). */
35
+ export const EmailBodyPart = Schema.Struct({
36
+ partId: Schema.optional(Schema.NullOr(Schema.String)),
37
+ blobId: Schema.optional(Schema.NullOr(Schema.String)),
38
+ size: Schema.optional(Schema.Number),
39
+ type: Schema.optional(Schema.String),
40
+ charset: Schema.optional(Schema.NullOr(Schema.String)),
41
+ /** Attachment filename, present on parts listed in `Email.attachments`. */
42
+ name: Schema.optional(Schema.NullOr(Schema.String)),
43
+ /** MIME `Content-Disposition` (`attachment` or `inline`), present on parts listed in `Email.attachments`. */
44
+ disposition: Schema.optional(Schema.NullOr(Schema.String)),
45
+ /** The part's `Content-ID` header (RFC 8621 §4.1.4), if any — matches a `cid:` reference in an HTML body. */
46
+ cid: Schema.optional(Schema.NullOr(Schema.String)),
47
+ });
48
+ export type EmailBodyPart = Schema.Schema.Type<typeof EmailBodyPart>;
49
+
50
+ /** An email (RFC 8621 §4). Only the properties requested in `Email/get` are present. */
51
+ export const Email = Schema.Struct({
52
+ id: Schema.String,
53
+ blobId: Schema.optional(Schema.String),
54
+ // Server-set and always present on a fetched Email (RFC 8621 §4.1.1); `EMAIL_PROPERTIES` requests it.
55
+ threadId: Schema.String,
56
+ mailboxIds: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Boolean })),
57
+ keywords: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Boolean })),
58
+ from: Schema.optional(Schema.NullOr(Schema.Array(EmailAddress))),
59
+ to: Schema.optional(Schema.NullOr(Schema.Array(EmailAddress))),
60
+ cc: Schema.optional(Schema.NullOr(Schema.Array(EmailAddress))),
61
+ bcc: Schema.optional(Schema.NullOr(Schema.Array(EmailAddress))),
62
+ subject: Schema.optional(Schema.NullOr(Schema.String)),
63
+ receivedAt: Schema.String,
64
+ sentAt: Schema.optional(Schema.NullOr(Schema.String)),
65
+ preview: Schema.optional(Schema.NullOr(Schema.String)),
66
+ messageId: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))),
67
+ inReplyTo: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))),
68
+ references: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))),
69
+ bodyValues: Schema.optional(Schema.Record({ key: Schema.String, value: BodyValue })),
70
+ textBody: Schema.optional(Schema.Array(EmailBodyPart)),
71
+ htmlBody: Schema.optional(Schema.Array(EmailBodyPart)),
72
+ /** Non-body parts (RFC 8621 §4.1.4) — the attachments proper, distinct from `textBody`/`htmlBody`. */
73
+ attachments: Schema.optional(Schema.Array(EmailBodyPart)),
74
+ });
75
+ export type Email = Schema.Schema.Type<typeof Email>;
76
+
77
+ /** A sending identity (RFC 8621 §6); `email` is an address the server permits as `from`. */
78
+ export const Identity = Schema.Struct({
79
+ id: Schema.String,
80
+ name: Schema.optional(Schema.NullOr(Schema.String)),
81
+ email: Schema.String,
82
+ });
83
+ export type Identity = Schema.Schema.Type<typeof Identity>;
84
+
85
+ //
86
+ // Method-result schemas.
87
+ //
88
+
89
+ export const MailboxGetResult = Schema.Struct({
90
+ accountId: Schema.optional(Schema.String),
91
+ state: Schema.optional(Schema.String),
92
+ list: Schema.Array(Mailbox),
93
+ notFound: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))),
94
+ });
95
+ export type MailboxGetResult = Schema.Schema.Type<typeof MailboxGetResult>;
96
+
97
+ export const EmailQueryResult = Schema.Struct({
98
+ accountId: Schema.optional(Schema.String),
99
+ queryState: Schema.optional(Schema.String),
100
+ position: Schema.optional(Schema.Number),
101
+ total: Schema.optional(Schema.Number),
102
+ limit: Schema.optional(Schema.Number),
103
+ ids: Schema.Array(Schema.String),
104
+ });
105
+ export type EmailQueryResult = Schema.Schema.Type<typeof EmailQueryResult>;
106
+
107
+ export const EmailGetResult = Schema.Struct({
108
+ accountId: Schema.optional(Schema.String),
109
+ state: Schema.optional(Schema.String),
110
+ list: Schema.Array(Email),
111
+ notFound: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))),
112
+ });
113
+ export type EmailGetResult = Schema.Schema.Type<typeof EmailGetResult>;
114
+
115
+ /** `Email/changes` delta since an opaque `sinceState` token (RFC 8621 §4.3 / RFC 8620 §5.2). */
116
+ export const EmailChangesResult = Schema.Struct({
117
+ accountId: Schema.optional(Schema.String),
118
+ oldState: Schema.String,
119
+ newState: Schema.String,
120
+ hasMoreChanges: Schema.Boolean,
121
+ created: Schema.Array(Schema.String),
122
+ updated: Schema.Array(Schema.String),
123
+ destroyed: Schema.Array(Schema.String),
124
+ });
125
+ export type EmailChangesResult = Schema.Schema.Type<typeof EmailChangesResult>;
126
+
127
+ export const IdentityGetResult = Schema.Struct({
128
+ accountId: Schema.optional(Schema.String),
129
+ state: Schema.optional(Schema.String),
130
+ list: Schema.Array(Identity),
131
+ notFound: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))),
132
+ });
133
+ export type IdentityGetResult = Schema.Schema.Type<typeof IdentityGetResult>;
134
+
135
+ export const EmailSetResult = Schema.Struct({
136
+ accountId: Schema.optional(Schema.String),
137
+ oldState: Schema.optional(Schema.NullOr(Schema.String)),
138
+ newState: Schema.optional(Schema.String),
139
+ created: Schema.optional(
140
+ Schema.NullOr(
141
+ Schema.Record({
142
+ key: Schema.String,
143
+ value: Schema.Struct({
144
+ id: Schema.String,
145
+ blobId: Schema.optional(Schema.String),
146
+ threadId: Schema.optional(Schema.String),
147
+ size: Schema.optional(Schema.Number),
148
+ }),
149
+ }),
150
+ ),
151
+ ),
152
+ updated: Schema.optional(Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.Unknown }))),
153
+ destroyed: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))),
154
+ notCreated: Schema.optional(
155
+ Schema.NullOr(
156
+ Schema.Record({
157
+ key: Schema.String,
158
+ value: Schema.Struct({ type: Schema.String, description: Schema.optional(Schema.NullOr(Schema.String)) }),
159
+ }),
160
+ ),
161
+ ),
162
+ notUpdated: Schema.optional(
163
+ Schema.NullOr(
164
+ Schema.Record({
165
+ key: Schema.String,
166
+ value: Schema.Struct({ type: Schema.String, description: Schema.optional(Schema.NullOr(Schema.String)) }),
167
+ }),
168
+ ),
169
+ ),
170
+ notDestroyed: Schema.optional(
171
+ Schema.NullOr(
172
+ Schema.Record({
173
+ key: Schema.String,
174
+ value: Schema.Struct({ type: Schema.String, description: Schema.optional(Schema.NullOr(Schema.String)) }),
175
+ }),
176
+ ),
177
+ ),
178
+ });
179
+ export type EmailSetResult = Schema.Schema.Type<typeof EmailSetResult>;
180
+
181
+ export const EmailSubmissionSetResult = Schema.Struct({
182
+ accountId: Schema.optional(Schema.String),
183
+ newState: Schema.optional(Schema.String),
184
+ created: Schema.optional(
185
+ Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.Struct({ id: Schema.String }) })),
186
+ ),
187
+ notCreated: Schema.optional(
188
+ Schema.NullOr(
189
+ Schema.Record({
190
+ key: Schema.String,
191
+ value: Schema.Struct({ type: Schema.String, description: Schema.optional(Schema.NullOr(Schema.String)) }),
192
+ }),
193
+ ),
194
+ ),
195
+ });
196
+ export type EmailSubmissionSetResult = Schema.Schema.Type<typeof EmailSubmissionSetResult>;
@@ -0,0 +1,8 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // Jmap: shared JMAP core — session, transport, Filter/Session/Response types.
6
+ export * as Jmap from './Jmap';
7
+ // JmapMail: mail-specific helpers and types (mailboxGet, emailQuery, Email, Mailbox, …).
8
+ export * as JmapMail from './JmapMail';