@open-workspace/email 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/alchemy.ts CHANGED
@@ -28,6 +28,8 @@ export type EmailInfraProps = {
28
28
  bucketName?: string;
29
29
  /** Queue names default to `<name>-<role>`; set to keep existing queues. */
30
30
  queuePrefix?: string;
31
+ /** Name of an existing Office preview container application to adopt. */
32
+ containerName?: string;
31
33
  };
32
34
 
33
35
  /**
@@ -63,6 +65,7 @@ export function Email(props: EmailInfraProps = {}): (name: string) => WorkspaceM
63
65
  MAILBOX: Cloudflare.DurableObject("MAILBOX", { className: "MailboxDO" }),
64
66
  OFFICE_PREVIEW_CONTAINER: Cloudflare.Container("OfficePreview", {
65
67
  className: "OfficePreviewContainer",
68
+ ...(props.containerName ? { name: props.containerName } : {}),
66
69
  context: join(packageDir, "containers", "office-preview"),
67
70
  maxInstances: 2,
68
71
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-workspace/email",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Team mail for OpenWorkspace: personal and shared inboxes, mailing lists, previews, safety, and AI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -20,7 +20,7 @@
20
20
  "db:generate": "drizzle-kit generate && drizzle-kit generate --config=drizzle.mailbox.config.ts && bun scripts/embed-mailbox-migrations.ts"
21
21
  },
22
22
  "peerDependencies": {
23
- "@open-workspace/core": "0.1.0",
23
+ "@open-workspace/core": "0.2.0",
24
24
  "alchemy": "2.0.0-beta.84",
25
25
  "@alchemy.run/cloudflare": "2.0.0-beta.84",
26
26
  "effect": "4.0.7"
@@ -42,7 +42,7 @@
42
42
  "@base-ui/react": "1.7.0",
43
43
  "@effect/platform-bun": "4.0.7",
44
44
  "@effect/platform-node": "4.0.7",
45
- "@open-workspace/core": "0.1.0",
45
+ "@open-workspace/core": "0.2.0",
46
46
  "@simplewebauthn/browser": "13.3.0",
47
47
  "@tanstack/react-query": "5.102.8",
48
48
  "@tiptap/extension-image": "3.30.5",
@@ -0,0 +1,678 @@
1
+ import { mailboxStub } from "../mailbox";
2
+ import type { Email } from "../mailbox/schema";
3
+ import type { JmapEmailFilter, JmapMailboxRow } from "../mailbox/jmap-store";
4
+ import { submitOutgoing } from "../mail/outbound-service";
5
+
6
+ const CORE = "urn:ietf:params:jmap:core";
7
+ const MAX_OBJECTS = 500;
8
+
9
+ export type JmapAccount = {
10
+ id: string;
11
+ address: string;
12
+ displayName: string;
13
+ ownerUserId: string | null;
14
+ isPrimary: boolean;
15
+ canSend: boolean;
16
+ isPersonal: boolean;
17
+ };
18
+
19
+ export type JmapContext = {
20
+ env: Env;
21
+ user: { id: string; name: string };
22
+ accounts: JmapAccount[];
23
+ requestUrl: string;
24
+ defer: (task: Promise<unknown>) => void;
25
+ };
26
+
27
+ type Args = Record<string, unknown>;
28
+ type Invocation = [string, Args, string];
29
+
30
+ /** A request-level error (RFC 8620 §3.6.1), answered as a problem document. */
31
+ export class JmapError extends Error {
32
+ constructor(readonly type: string, message: string) {
33
+ super(message);
34
+ }
35
+ }
36
+
37
+ /** A method-level error (RFC 8620 §3.6.2), answered in place of the method's response. */
38
+ class MethodError extends Error {
39
+ constructor(readonly type: string, message?: string) {
40
+ super(message ?? type);
41
+ }
42
+ }
43
+
44
+ /** A draft from Email/set create; it lives only for the request that sends it. */
45
+ type Draft = {
46
+ accountId: string;
47
+ to: string[];
48
+ cc: string[];
49
+ bcc: string[];
50
+ subject: string;
51
+ bodyText: string;
52
+ bodyHtml?: string;
53
+ };
54
+
55
+ type RequestState = {
56
+ context: JmapContext;
57
+ responses: Invocation[];
58
+ createdIds: Record<string, string>;
59
+ drafts: Map<string, Draft>;
60
+ /** Responses a method adds after its own, e.g. an implicit Email/set. */
61
+ after: Invocation[];
62
+ };
63
+
64
+ function isRecord(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === "object" && value !== null && !Array.isArray(value);
66
+ }
67
+
68
+ // ---------------------------------------------------------------- references
69
+
70
+ /** RFC 8620 §3.7: a JSON Pointer where `*` maps over an array. */
71
+ function evaluatePointer(value: unknown, path: string): unknown {
72
+ if (path === "") return value;
73
+ const [head, ...rest] = path.slice(1).split("/").map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"));
74
+ const tail = rest.length ? `/${rest.join("/")}` : "";
75
+ if (head === "*" && Array.isArray(value)) {
76
+ return value.flatMap((item) => {
77
+ const result = evaluatePointer(item, tail);
78
+ return Array.isArray(result) ? result : [result];
79
+ });
80
+ }
81
+ if (Array.isArray(value) && /^\d+$/u.test(head!)) return evaluatePointer(value[Number(head)], tail);
82
+ if (isRecord(value) && head! in value) return evaluatePointer(value[head!], tail);
83
+ throw new MethodError("invalidResultReference", `Path ${path} does not resolve`);
84
+ }
85
+
86
+ function resolveReferences(state: RequestState, args: Args): Args {
87
+ const resolved: Args = {};
88
+ for (const [key, value] of Object.entries(args)) {
89
+ if (!key.startsWith("#")) {
90
+ resolved[key] = value;
91
+ continue;
92
+ }
93
+ const name = key.slice(1);
94
+ if (name in args) throw new MethodError("invalidArguments", `Both ${name} and #${name} given`);
95
+ if (!isRecord(value) || typeof value.resultOf !== "string" || typeof value.name !== "string" || typeof value.path !== "string") {
96
+ throw new MethodError("invalidResultReference");
97
+ }
98
+ const source = state.responses.find(([method, , callId]) => callId === value.resultOf && method === value.name);
99
+ if (!source) throw new MethodError("invalidResultReference", `No ${value.name} result for ${value.resultOf}`);
100
+ resolved[name] = evaluatePointer(source[1], value.path);
101
+ }
102
+ return resolved;
103
+ }
104
+
105
+ // ------------------------------------------------------------------ helpers
106
+
107
+ function account(state: RequestState, args: Args) {
108
+ const found = state.context.accounts.find((item) => item.id === args.accountId);
109
+ if (!found) throw new MethodError("accountNotFound");
110
+ return found;
111
+ }
112
+
113
+ function stringIds(value: unknown, name = "ids") {
114
+ if (!Array.isArray(value) || value.some((id) => typeof id !== "string")) {
115
+ throw new MethodError("invalidArguments", `${name} must be a list of ids`);
116
+ }
117
+ if (value.length > MAX_OBJECTS) throw new MethodError("requestTooLarge");
118
+ return value as string[];
119
+ }
120
+
121
+ function pick<T extends Record<string, unknown>>(object: T, properties: unknown) {
122
+ if (!Array.isArray(properties)) return object;
123
+ return Object.fromEntries(Object.entries(object).filter(([key]) => key === "id" || properties.includes(key)));
124
+ }
125
+
126
+ /** We cannot list changes, only say whether anything changed. */
127
+ function changes(accountId: string, sinceState: unknown, state: string) {
128
+ if (sinceState !== state) throw new MethodError("cannotCalculateChanges");
129
+ return { accountId, oldState: state, newState: state, hasMoreChanges: false, created: [], updated: [], destroyed: [] };
130
+ }
131
+
132
+ const addresses = (list: Array<{ address: string; name?: string | null }> | null | undefined) =>
133
+ list?.length ? list.map((item) => ({ name: item.name || null, email: item.address })) : null;
134
+
135
+ // ------------------------------------------------------------------ mailbox
136
+
137
+ function mailboxObject(row: JmapMailboxRow, canSend: boolean) {
138
+ return {
139
+ id: row.id,
140
+ name: row.name,
141
+ parentId: null,
142
+ role: row.role,
143
+ sortOrder: row.sortOrder,
144
+ totalEmails: row.totalEmails,
145
+ unreadEmails: row.unreadEmails,
146
+ totalThreads: row.totalThreads,
147
+ unreadThreads: row.unreadThreads,
148
+ myRights: {
149
+ mayReadItems: true,
150
+ mayAddItems: row.role !== "sent",
151
+ mayRemoveItems: true,
152
+ maySetSeen: true,
153
+ maySetKeywords: false,
154
+ mayCreateChild: false,
155
+ mayRename: false,
156
+ mayDelete: false,
157
+ maySubmit: canSend,
158
+ },
159
+ isSubscribed: true,
160
+ };
161
+ }
162
+
163
+ async function mailboxGet(state: RequestState, args: Args) {
164
+ const owner = account(state, args);
165
+ const { state: mailboxState, mailboxes } = await mailboxStub(state.context.env, owner.id).jmapMailboxes(state.context.user.id);
166
+ const ids = args.ids == null ? mailboxes.map((row) => row.id) : stringIds(args.ids);
167
+ const byId = new Map(mailboxes.map((row) => [row.id, row]));
168
+ return {
169
+ accountId: owner.id,
170
+ state: mailboxState,
171
+ list: ids.flatMap((id) => {
172
+ const row = byId.get(id);
173
+ return row ? [pick(mailboxObject(row, owner.canSend), args.properties)] : [];
174
+ }),
175
+ notFound: ids.filter((id) => !byId.has(id)),
176
+ };
177
+ }
178
+
179
+ async function mailboxQuery(state: RequestState, args: Args) {
180
+ const owner = account(state, args);
181
+ const { state: mailboxState, mailboxes } = await mailboxStub(state.context.env, owner.id).jmapMailboxes(state.context.user.id);
182
+ const filter = isRecord(args.filter) ? args.filter : {};
183
+ const ids = mailboxes
184
+ .filter((row) => !("role" in filter) || row.role === filter.role)
185
+ .filter((row) => typeof filter.name !== "string" || row.name.toLowerCase().includes(filter.name.toLowerCase()))
186
+ .map((row) => row.id);
187
+ return { accountId: owner.id, queryState: mailboxState, canCalculateChanges: false, position: 0, ids, total: ids.length };
188
+ }
189
+
190
+ // -------------------------------------------------------------------- email
191
+
192
+ function emailFilter(value: unknown): JmapEmailFilter {
193
+ if (value == null) return {};
194
+ if (!isRecord(value)) throw new MethodError("unsupportedFilter");
195
+ if ("operator" in value) {
196
+ if (value.operator !== "AND" || !Array.isArray(value.conditions)) {
197
+ throw new MethodError("unsupportedFilter", "Only AND filters are supported");
198
+ }
199
+ return Object.assign({}, ...value.conditions.map(emailFilter));
200
+ }
201
+ const supported = ["inMailbox", "inMailboxOtherThan", "before", "after", "text", "from", "to", "subject", "hasKeyword", "notKeyword", "hasAttachment"];
202
+ const unknown = Object.keys(value).filter((key) => !supported.includes(key));
203
+ if (unknown.length) throw new MethodError("unsupportedFilter", `Unsupported filter: ${unknown.join(", ")}`);
204
+ for (const keyword of [value.hasKeyword, value.notKeyword]) {
205
+ if (keyword !== undefined && keyword !== "$seen") {
206
+ throw new MethodError("unsupportedFilter", "Only the $seen keyword is supported");
207
+ }
208
+ }
209
+ return {
210
+ ...(typeof value.inMailbox === "string" ? { inMailbox: value.inMailbox } : {}),
211
+ ...(Array.isArray(value.inMailboxOtherThan) ? { inMailboxOtherThan: value.inMailboxOtherThan.map(String) } : {}),
212
+ ...(typeof value.before === "string" ? { before: Date.parse(value.before) } : {}),
213
+ ...(typeof value.after === "string" ? { after: Date.parse(value.after) } : {}),
214
+ ...(typeof value.text === "string" ? { text: value.text } : {}),
215
+ ...(typeof value.from === "string" ? { from: value.from } : {}),
216
+ ...(typeof value.to === "string" ? { to: value.to } : {}),
217
+ ...(typeof value.subject === "string" ? { subject: value.subject } : {}),
218
+ ...(typeof value.hasKeyword === "string" ? { hasKeyword: value.hasKeyword } : {}),
219
+ ...(typeof value.notKeyword === "string" ? { notKeyword: value.notKeyword } : {}),
220
+ ...(typeof value.hasAttachment === "boolean" ? { hasAttachment: value.hasAttachment } : {}),
221
+ };
222
+ }
223
+
224
+ async function emailQuery(state: RequestState, args: Args) {
225
+ const owner = account(state, args);
226
+ const sort = Array.isArray(args.sort) ? args.sort : [];
227
+ for (const comparator of sort) {
228
+ if (!isRecord(comparator) || !["receivedAt", "sentAt"].includes(String(comparator.property))) {
229
+ throw new MethodError("unsupportedSort", "Emails sort by receivedAt or sentAt");
230
+ }
231
+ }
232
+ const ascending = isRecord(sort[0]) && sort[0].isAscending === true;
233
+ const position = Math.max(0, Number(args.position ?? 0) || 0);
234
+ const limit = Math.min(MAX_OBJECTS, Math.max(0, Number(args.limit ?? 100) || 100));
235
+ const result = await mailboxStub(state.context.env, owner.id).jmapQueryEmails({
236
+ userId: state.context.user.id,
237
+ filter: emailFilter(args.filter),
238
+ ascending,
239
+ position,
240
+ limit,
241
+ collapseThreads: args.collapseThreads === true,
242
+ });
243
+ return {
244
+ accountId: owner.id,
245
+ queryState: result.state,
246
+ canCalculateChanges: false,
247
+ position,
248
+ ids: result.ids,
249
+ total: result.total,
250
+ limit,
251
+ };
252
+ }
253
+
254
+ const defaultEmailProperties = [
255
+ "id", "blobId", "threadId", "mailboxIds", "keywords", "size", "receivedAt", "messageId",
256
+ "inReplyTo", "references", "sender", "from", "to", "cc", "bcc", "replyTo", "subject",
257
+ "sentAt", "hasAttachment", "preview", "bodyValues", "textBody", "htmlBody", "attachments",
258
+ ];
259
+
260
+ async function emailObject(
261
+ env: Env,
262
+ row: { email: Email; mailboxId: string; seen: boolean },
263
+ args: Args,
264
+ ) {
265
+ const { email } = row;
266
+ const raw = `raw~${email.id}`;
267
+ const textBody = [{
268
+ partId: "text", blobId: raw, size: email.bodyText?.length ?? 0, name: null,
269
+ type: "text/plain", charset: "utf-8", disposition: null, cid: null,
270
+ }];
271
+ const htmlBody = email.bodyHtmlR2Key
272
+ ? [{ partId: "html", blobId: raw, size: 0, name: null, type: "text/html", charset: "utf-8", disposition: null, cid: null }]
273
+ : textBody;
274
+ const attachments = email.attachmentsJson.map((attachment) => ({
275
+ partId: `att-${attachment.id}`,
276
+ blobId: `att~${email.id}~${attachment.id}`,
277
+ size: attachment.size,
278
+ name: attachment.filename,
279
+ type: attachment.contentType,
280
+ charset: null,
281
+ disposition: attachment.disposition,
282
+ cid: attachment.contentId,
283
+ }));
284
+ const properties = Array.isArray(args.properties) ? args.properties as string[] : defaultEmailProperties;
285
+ const maxBytes = Number(args.maxBodyValueBytes ?? 0) || 0;
286
+ const bodyValue = (value: string) => ({
287
+ value: maxBytes && value.length > maxBytes ? value.slice(0, maxBytes) : value,
288
+ isEncodingProblem: false,
289
+ isTruncated: Boolean(maxBytes && value.length > maxBytes),
290
+ });
291
+ const bodyValues: Record<string, ReturnType<typeof bodyValue>> = {};
292
+ if (properties.includes("bodyValues")) {
293
+ const wantText = args.fetchTextBodyValues === true || args.fetchAllBodyValues === true;
294
+ const wantHtml = args.fetchHTMLBodyValues === true || args.fetchAllBodyValues === true;
295
+ if (wantText || (wantHtml && !email.bodyHtmlR2Key)) bodyValues.text = bodyValue(email.bodyText ?? "");
296
+ if (wantHtml && email.bodyHtmlR2Key) {
297
+ const object = await env.MAIL_STORAGE.get(email.bodyHtmlR2Key);
298
+ bodyValues.html = bodyValue(object ? await object.text() : "");
299
+ }
300
+ }
301
+ const timestamp = email.timelineAt.toISOString();
302
+ const object = {
303
+ id: email.id,
304
+ blobId: raw,
305
+ threadId: email.conversationId,
306
+ mailboxIds: { [row.mailboxId]: true },
307
+ keywords: row.seen ? { $seen: true } : {},
308
+ size: (email.bodyText?.length ?? 0) + email.attachmentsJson.reduce((total, attachment) => total + attachment.size, 0),
309
+ receivedAt: timestamp,
310
+ messageId: email.messageIdHeader ? [email.messageIdHeader.replace(/^<|>$/gu, "")] : null,
311
+ inReplyTo: email.inReplyToJson?.length ? email.inReplyToJson : null,
312
+ references: email.referencesJson?.length ? email.referencesJson : null,
313
+ sender: null,
314
+ from: addresses(email.fromJson),
315
+ to: addresses(email.toJson),
316
+ cc: addresses(email.ccJson),
317
+ bcc: addresses(email.bccJson),
318
+ replyTo: addresses(email.replyToJson),
319
+ subject: email.subject,
320
+ sentAt: timestamp,
321
+ hasAttachment: attachments.some((attachment) => attachment.disposition === "attachment"),
322
+ preview: email.preview,
323
+ bodyValues,
324
+ textBody,
325
+ htmlBody,
326
+ attachments,
327
+ bodyStructure: { partId: null, type: "multipart/mixed", subParts: [...htmlBody, ...attachments] },
328
+ };
329
+ return Object.fromEntries(Object.entries(object).filter(([key]) => key === "id" || properties.includes(key)));
330
+ }
331
+
332
+ async function emailGet(state: RequestState, args: Args) {
333
+ const owner = account(state, args);
334
+ if (args.ids == null) throw new MethodError("requestTooLarge", "Ask for emails by id");
335
+ const ids = stringIds(args.ids);
336
+ const result = await mailboxStub(state.context.env, owner.id).jmapEmails(state.context.user.id, ids);
337
+ const found = new Set(result.emails.map((row) => row.email.id));
338
+ return {
339
+ accountId: owner.id,
340
+ state: result.state,
341
+ list: await Promise.all(result.emails.map((row) => emailObject(state.context.env, row as never, args))),
342
+ notFound: ids.filter((id) => !found.has(id)),
343
+ };
344
+ }
345
+
346
+ /** The keyword and mailbox changes in an Email/set update patch. */
347
+ function emailPatch(patch: Args) {
348
+ let seen: boolean | undefined;
349
+ let target: string | undefined;
350
+ for (const [key, value] of Object.entries(patch)) {
351
+ if (key === "keywords" && isRecord(value)) {
352
+ const keywords = Object.keys(value);
353
+ if (keywords.some((keyword) => keyword !== "$seen")) return { error: "Only the $seen keyword is stored" };
354
+ seen = value.$seen === true;
355
+ } else if (key === "keywords/$seen") {
356
+ seen = value === true;
357
+ } else if (key.startsWith("keywords/")) {
358
+ return { error: "Only the $seen keyword is stored" };
359
+ } else if (key === "mailboxIds" && isRecord(value)) {
360
+ const ids = Object.keys(value).filter((id) => value[id] === true);
361
+ if (ids.length !== 1) return { error: "An email is in exactly one mailbox" };
362
+ target = ids[0];
363
+ } else if (key.startsWith("mailboxIds/")) {
364
+ if (value === true) target = key.slice("mailboxIds/".length);
365
+ } else {
366
+ return { error: `${key} cannot be changed` };
367
+ }
368
+ }
369
+ return { seen, target };
370
+ }
371
+
372
+ function draftFrom(owner: JmapAccount, input: Args): Draft | string {
373
+ const list = (value: unknown) =>
374
+ Array.isArray(value) ? value.flatMap((item) => isRecord(item) && typeof item.email === "string" ? [item.email] : []) : [];
375
+ const bodyValues = isRecord(input.bodyValues) ? input.bodyValues : {};
376
+ const partText = (parts: unknown) => {
377
+ if (!Array.isArray(parts) || !isRecord(parts[0])) return undefined;
378
+ const value = bodyValues[String(parts[0].partId)];
379
+ return isRecord(value) && typeof value.value === "string" ? value.value : undefined;
380
+ };
381
+ const bodyText = partText(input.textBody);
382
+ const bodyHtml = partText(input.htmlBody);
383
+ if (bodyText === undefined && bodyHtml === undefined) return "Give the message a textBody or htmlBody with bodyValues";
384
+ return {
385
+ accountId: owner.id,
386
+ to: list(input.to),
387
+ cc: list(input.cc),
388
+ bcc: list(input.bcc),
389
+ subject: typeof input.subject === "string" ? input.subject : "",
390
+ bodyText: bodyText ?? "",
391
+ ...(bodyHtml !== undefined ? { bodyHtml } : {}),
392
+ };
393
+ }
394
+
395
+ async function emailSet(state: RequestState, args: Args) {
396
+ const owner = account(state, args);
397
+ const stub = mailboxStub(state.context.env, owner.id);
398
+ const oldState = await stub.jmapState();
399
+ if (args.ifInState !== undefined && args.ifInState !== oldState) throw new MethodError("stateMismatch");
400
+ const created: Args = {};
401
+ const notCreated: Args = {};
402
+ for (const [creationId, input] of Object.entries(isRecord(args.create) ? args.create : {})) {
403
+ const draft = isRecord(input) ? draftFrom(owner, input) : "Invalid email";
404
+ if (typeof draft === "string") {
405
+ notCreated[creationId] = { type: "invalidProperties", description: draft };
406
+ continue;
407
+ }
408
+ // Drafts are not stored: one is sent by EmailSubmission/set in the same request.
409
+ const id = `draft-${crypto.randomUUID()}`;
410
+ state.drafts.set(id, draft);
411
+ state.createdIds[creationId] = id;
412
+ created[creationId] = { id, blobId: `draft~${id}`, threadId: id, size: draft.bodyText.length };
413
+ }
414
+
415
+ const updated: Args = {};
416
+ const notUpdated: Args = {};
417
+ const reads: Array<[string, boolean]> = [];
418
+ const moves = new Map<string, string[]>();
419
+ for (const [id, patch] of Object.entries(isRecord(args.update) ? args.update : {})) {
420
+ const change = isRecord(patch) ? emailPatch(patch) : { error: "Invalid patch" };
421
+ if ("error" in change && change.error) {
422
+ notUpdated[id] = { type: "invalidProperties", description: change.error };
423
+ continue;
424
+ }
425
+ const { seen, target } = change as { seen?: boolean; target?: string };
426
+ if (seen !== undefined) reads.push([id, seen]);
427
+ if (target) moves.set(target, [...(moves.get(target) ?? []), id]);
428
+ updated[id] = null;
429
+ }
430
+ for (const [id, seen] of reads) {
431
+ if (!(await stub.setMessageRead(state.context.user.id, id, seen))) {
432
+ delete updated[id];
433
+ notUpdated[id] = { type: "notFound" };
434
+ }
435
+ }
436
+ for (const [target, ids] of moves) {
437
+ if (!(await stub.jmapMoveEmails(ids, target))) {
438
+ for (const id of ids) {
439
+ delete updated[id];
440
+ notUpdated[id] = { type: "invalidProperties", description: `Emails cannot be moved to ${target}` };
441
+ }
442
+ }
443
+ }
444
+
445
+ const destroyed: string[] = [];
446
+ const notDestroyed: Args = {};
447
+ const destroyIds = args.destroy == null ? [] : stringIds(args.destroy, "destroy");
448
+ if (destroyIds.length) {
449
+ const { emails } = await stub.jmapEmails(state.context.user.id, destroyIds);
450
+ const inTrash = emails.filter((row) => row.mailboxId === "trash");
451
+ const elsewhere = emails.filter((row) => row.mailboxId !== "trash");
452
+ // Mail goes to Trash first; destroying it there deletes its conversation.
453
+ if (elsewhere.length) await stub.jmapMoveEmails(elsewhere.map((row) => row.email.id), "trash");
454
+ if (inTrash.length) {
455
+ await stub.permanentlyDeleteConversations([...new Set(inTrash.map((row) => row.email.conversationId))]);
456
+ }
457
+ const found = new Set(emails.map((row) => row.email.id));
458
+ for (const id of destroyIds) {
459
+ if (found.has(id)) destroyed.push(id);
460
+ else notDestroyed[id] = { type: "notFound" };
461
+ }
462
+ }
463
+ return {
464
+ accountId: owner.id,
465
+ oldState,
466
+ newState: await stub.jmapState(),
467
+ created: Object.keys(created).length ? created : null,
468
+ updated: Object.keys(updated).length ? updated : null,
469
+ destroyed: destroyed.length ? destroyed : null,
470
+ notCreated: Object.keys(notCreated).length ? notCreated : null,
471
+ notUpdated: Object.keys(notUpdated).length ? notUpdated : null,
472
+ notDestroyed: Object.keys(notDestroyed).length ? notDestroyed : null,
473
+ };
474
+ }
475
+
476
+ // --------------------------------------------------- threads and identities
477
+
478
+ async function threadGet(state: RequestState, args: Args) {
479
+ const owner = account(state, args);
480
+ const ids = stringIds(args.ids);
481
+ const result = await mailboxStub(state.context.env, owner.id).jmapThreads(ids);
482
+ const threads = new Map(result.threads as Array<[string, string[]]>);
483
+ return {
484
+ accountId: owner.id,
485
+ state: result.state,
486
+ list: ids.flatMap((id) => threads.has(id) ? [{ id, emailIds: threads.get(id)! }] : []),
487
+ notFound: ids.filter((id) => !threads.has(id)),
488
+ };
489
+ }
490
+
491
+ function identityGet(state: RequestState, args: Args) {
492
+ const owner = account(state, args);
493
+ const list = owner.canSend
494
+ ? [{
495
+ id: owner.id,
496
+ name: owner.displayName,
497
+ email: owner.address,
498
+ replyTo: null,
499
+ bcc: null,
500
+ textSignature: "",
501
+ htmlSignature: "",
502
+ mayDelete: false,
503
+ }]
504
+ : [];
505
+ const ids = args.ids == null ? list.map((identity) => identity.id) : stringIds(args.ids);
506
+ return {
507
+ accountId: owner.id,
508
+ state: "1",
509
+ list: list.filter((identity) => ids.includes(identity.id)),
510
+ notFound: ids.filter((id) => id !== owner.id || !owner.canSend),
511
+ };
512
+ }
513
+
514
+ async function submissionSet(state: RequestState, args: Args, callId: string) {
515
+ const owner = account(state, args);
516
+ if (!owner.canSend) throw new MethodError("forbidden", "This account cannot send");
517
+ const created: Args = {};
518
+ const notCreated: Args = {};
519
+ const sentDrafts = new Map<string, string>();
520
+ for (const [creationId, input] of Object.entries(isRecord(args.create) ? args.create : {})) {
521
+ if (!isRecord(input) || typeof input.emailId !== "string") {
522
+ notCreated[creationId] = { type: "invalidProperties", description: "emailId is required" };
523
+ continue;
524
+ }
525
+ const emailId = input.emailId.startsWith("#")
526
+ ? state.createdIds[input.emailId.slice(1)]
527
+ : input.emailId;
528
+ const draft = emailId ? state.drafts.get(emailId) : undefined;
529
+ if (!draft || draft.accountId !== owner.id) {
530
+ notCreated[creationId] = {
531
+ type: "invalidProperties",
532
+ description: "Send an email created by Email/set earlier in the same request",
533
+ };
534
+ continue;
535
+ }
536
+ if (input.identityId !== undefined && input.identityId !== owner.id) {
537
+ notCreated[creationId] = { type: "invalidProperties", description: "Unknown identity" };
538
+ continue;
539
+ }
540
+ try {
541
+ const submission = await submitOutgoing({
542
+ env: state.context.env,
543
+ requestUrl: state.context.requestUrl,
544
+ compose: {
545
+ requestId: crypto.randomUUID(),
546
+ mailboxId: owner.id,
547
+ to: draft.to,
548
+ cc: draft.cc,
549
+ bcc: draft.bcc,
550
+ subject: draft.subject,
551
+ bodyText: draft.bodyText,
552
+ ...(draft.bodyHtml !== undefined ? { bodyHtml: draft.bodyHtml } : {}),
553
+ attachments: [],
554
+ userId: state.context.user.id,
555
+ },
556
+ conversationId: `conv_${crypto.randomUUID().replaceAll("-", "")}`,
557
+ related: null,
558
+ forwarded: null,
559
+ includeRelatedContext: false,
560
+ fromAddress: owner.address,
561
+ fromName: owner.displayName,
562
+ defer: state.context.defer,
563
+ });
564
+ const id = `sub-${submission.email.id}`;
565
+ state.createdIds[creationId] = id;
566
+ sentDrafts.set(id, emailId!);
567
+ created[creationId] = {
568
+ id,
569
+ threadId: submission.email.conversationId,
570
+ emailId: submission.email.id,
571
+ sendAt: new Date().toISOString(),
572
+ undoStatus: "final",
573
+ };
574
+ } catch (error) {
575
+ notCreated[creationId] = {
576
+ type: "forbiddenToSend",
577
+ description: error instanceof Error ? error.message : "The message could not be sent",
578
+ };
579
+ }
580
+ }
581
+ // The sent copy is already in Sent; drafts were never stored, so the
582
+ // follow-up Email/set only reports them gone.
583
+ const followUp = [args.onSuccessDestroyEmail, args.onSuccessUpdateEmail ? Object.keys(args.onSuccessUpdateEmail as Args) : null]
584
+ .flatMap((value) => Array.isArray(value) ? value as string[] : [])
585
+ .map((reference) => reference.startsWith("#") ? state.createdIds[reference.slice(1)] : reference)
586
+ .flatMap((id) => id && sentDrafts.has(id) ? [sentDrafts.get(id)!] : []);
587
+ if (followUp.length) {
588
+ const stub = mailboxStub(state.context.env, owner.id);
589
+ const current = await stub.jmapState();
590
+ state.after.push(["Email/set", {
591
+ accountId: owner.id, oldState: current, newState: current,
592
+ created: null, updated: null, destroyed: followUp,
593
+ notCreated: null, notUpdated: null, notDestroyed: null,
594
+ }, callId]);
595
+ }
596
+ return {
597
+ accountId: owner.id,
598
+ oldState: "1",
599
+ newState: "1",
600
+ created: Object.keys(created).length ? created : null,
601
+ notCreated: Object.keys(notCreated).length ? notCreated : null,
602
+ updated: null,
603
+ destroyed: null,
604
+ notUpdated: null,
605
+ notDestroyed: null,
606
+ };
607
+ }
608
+
609
+ // ----------------------------------------------------------------- dispatch
610
+
611
+ const methods: Record<string, (state: RequestState, args: Args, callId: string) => unknown> = {
612
+ "Core/echo": (_state, args) => args,
613
+ "Mailbox/get": mailboxGet,
614
+ "Mailbox/query": mailboxQuery,
615
+ "Mailbox/changes": async (state, args) => {
616
+ const owner = account(state, args);
617
+ return changes(owner.id, args.sinceState, await mailboxStub(state.context.env, owner.id).jmapState());
618
+ },
619
+ "Email/query": emailQuery,
620
+ "Email/get": emailGet,
621
+ "Email/set": emailSet,
622
+ "Email/changes": async (state, args) => {
623
+ const owner = account(state, args);
624
+ return changes(owner.id, args.sinceState, await mailboxStub(state.context.env, owner.id).jmapState());
625
+ },
626
+ "Thread/get": threadGet,
627
+ "Thread/changes": async (state, args) => {
628
+ const owner = account(state, args);
629
+ return changes(owner.id, args.sinceState, await mailboxStub(state.context.env, owner.id).jmapState());
630
+ },
631
+ "SearchSnippet/get": (state, args) => {
632
+ const owner = account(state, args);
633
+ const ids = stringIds(args.emailIds, "emailIds");
634
+ return { accountId: owner.id, list: ids.map((emailId) => ({ emailId, subject: null, preview: null })), notFound: [] };
635
+ },
636
+ "Identity/get": identityGet,
637
+ "EmailSubmission/set": submissionSet,
638
+ };
639
+
640
+ export async function runJmapRequest(context: JmapContext, body: unknown) {
641
+ if (!isRecord(body) || !Array.isArray(body.using) || !Array.isArray(body.methodCalls)) {
642
+ throw new JmapError("urn:ietf:params:jmap:error:notRequest", "A JMAP request has using and methodCalls");
643
+ }
644
+ if (!body.using.includes(CORE)) {
645
+ throw new JmapError("urn:ietf:params:jmap:error:unknownCapability", `${CORE} is required`);
646
+ }
647
+ if (body.methodCalls.length > 32) {
648
+ throw new JmapError("urn:ietf:params:jmap:error:limit", "At most 32 method calls per request");
649
+ }
650
+ const state: RequestState = {
651
+ context,
652
+ responses: [],
653
+ createdIds: isRecord(body.createdIds) ? { ...body.createdIds as Record<string, string> } : {},
654
+ drafts: new Map(),
655
+ after: [],
656
+ };
657
+ for (const call of body.methodCalls) {
658
+ if (!Array.isArray(call) || typeof call[0] !== "string" || !isRecord(call[1]) || typeof call[2] !== "string") {
659
+ throw new JmapError("urn:ietf:params:jmap:error:notRequest", "Each method call is [name, arguments, id]");
660
+ }
661
+ const [name, rawArgs, callId] = call as Invocation;
662
+ const method = methods[name];
663
+ try {
664
+ if (!method) throw new MethodError("unknownMethod");
665
+ const args = resolveReferences(state, rawArgs);
666
+ state.responses.push([name, await method(state, args, callId) as Args, callId]);
667
+ state.responses.push(...state.after.splice(0));
668
+ } catch (error) {
669
+ if (!(error instanceof MethodError)) throw error;
670
+ state.responses.push(["error", { type: error.type, description: error.message }, callId]);
671
+ }
672
+ }
673
+ return {
674
+ methodResponses: state.responses,
675
+ ...(isRecord(body.createdIds) ? { createdIds: state.createdIds } : {}),
676
+ sessionState: String(context.accounts.length),
677
+ };
678
+ }
@@ -0,0 +1,176 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import { Hono, type Context } from "hono";
3
+ import { JMAP_SCOPE } from "@open-workspace/core/shared/oidc.ts";
4
+ import { bearerToken } from "@open-workspace/core/worker/auth/middleware.ts";
5
+ import { createDb } from "@open-workspace/core/worker/db/client.ts";
6
+ import { mailboxAddressSql } from "@open-workspace/core/worker/db/mailboxes.ts";
7
+ import { domains, mailboxAccess, mailboxes } from "@open-workspace/core/worker/db/schema.ts";
8
+ import type { AppEnv } from "@open-workspace/core/worker/env.ts";
9
+ import {
10
+ authenticateAccessToken,
11
+ jmapProtectedResourceMetadataUrl,
12
+ } from "@open-workspace/core/worker/oidc/mcp-clients.ts";
13
+ import { mailboxStub } from "../mailbox";
14
+ import { JmapError, runJmapRequest, type JmapAccount, type JmapContext } from "./methods";
15
+
16
+ const CAPABILITIES = {
17
+ "urn:ietf:params:jmap:core": {
18
+ maxSizeUpload: 0,
19
+ maxConcurrentUpload: 1,
20
+ maxSizeRequest: 10_000_000,
21
+ maxConcurrentRequests: 4,
22
+ maxCallsInRequest: 32,
23
+ maxObjectsInGet: 500,
24
+ maxObjectsInSet: 500,
25
+ collationAlgorithms: ["i;unicode-casemap"],
26
+ },
27
+ "urn:ietf:params:jmap:mail": {},
28
+ "urn:ietf:params:jmap:submission": {},
29
+ };
30
+
31
+ type JmapEnv = AppEnv & { Variables: AppEnv["Variables"] & { jmapUser: { id: string; name: string } } };
32
+
33
+ function problem(c: Context, status: 400 | 401 | 403 | 404 | 501, type: string, detail: string) {
34
+ return c.json({ type, status, detail }, status, { "content-type": "application/problem+json" });
35
+ }
36
+
37
+ /** The mailboxes this person can open; each is a JMAP account. */
38
+ async function jmapAccounts(env: Env, userId: string): Promise<JmapAccount[]> {
39
+ const rows = await createDb(env.DB)
40
+ .select({
41
+ id: mailboxes.id,
42
+ address: mailboxAddressSql,
43
+ displayName: mailboxes.displayName,
44
+ ownerUserId: mailboxes.ownerUserId,
45
+ isPrimary: mailboxes.isPrimary,
46
+ canSend: mailboxAccess.canSend,
47
+ })
48
+ .from(mailboxAccess)
49
+ .innerJoin(mailboxes, eq(mailboxAccess.mailboxId, mailboxes.id))
50
+ .innerJoin(domains, eq(mailboxes.domainId, domains.id))
51
+ .where(and(eq(mailboxAccess.userId, userId), eq(mailboxes.delivery, "inbox")));
52
+ return rows.map((row) => ({
53
+ ...row,
54
+ isPersonal: row.ownerUserId === userId,
55
+ }));
56
+ }
57
+
58
+ function sessionObject(origin: string, user: { id: string; name: string }, accounts: JmapAccount[]) {
59
+ const primary = accounts.find((account) => account.isPersonal && account.isPrimary)
60
+ ?? accounts.find((account) => account.isPersonal)
61
+ ?? accounts[0];
62
+ return {
63
+ capabilities: CAPABILITIES,
64
+ accounts: Object.fromEntries(accounts.map((account) => [account.id, {
65
+ name: account.address,
66
+ isPersonal: account.isPersonal,
67
+ isReadOnly: false,
68
+ accountCapabilities: {
69
+ "urn:ietf:params:jmap:mail": {
70
+ maxMailboxesPerEmail: 1,
71
+ maxMailboxDepth: 1,
72
+ maxSizeMailboxName: 80,
73
+ maxSizeAttachmentsPerEmail: 0,
74
+ emailQuerySortOptions: ["receivedAt", "sentAt"],
75
+ mayCreateTopLevelMailbox: false,
76
+ },
77
+ ...(account.canSend
78
+ ? { "urn:ietf:params:jmap:submission": { maxDelayedSend: 0, submissionExtensions: {} } }
79
+ : {}),
80
+ },
81
+ }])),
82
+ primaryAccounts: primary
83
+ ? {
84
+ "urn:ietf:params:jmap:mail": primary.id,
85
+ "urn:ietf:params:jmap:submission": primary.id,
86
+ }
87
+ : {},
88
+ username: primary?.address ?? user.name,
89
+ apiUrl: `${origin}/jmap/api`,
90
+ downloadUrl: `${origin}/jmap/download/{accountId}/{blobId}/{name}?type={type}`,
91
+ uploadUrl: `${origin}/jmap/upload/{accountId}`,
92
+ eventSourceUrl: `${origin}/jmap/eventsource?types={types}&closeafter={closeafter}&ping={ping}`,
93
+ state: String(accounts.length),
94
+ };
95
+ }
96
+
97
+ const requireJmapAuth = async (c: Context<JmapEnv>, next: () => Promise<void>) => {
98
+ const token = bearerToken(c.req.raw);
99
+ const identity = token
100
+ ? await authenticateAccessToken(createDb(c.env.DB), token, JMAP_SCOPE)
101
+ : null;
102
+ if (!identity) {
103
+ c.header("www-authenticate", [
104
+ `Bearer resource_metadata="${jmapProtectedResourceMetadataUrl(new URL(c.req.url).origin)}"`,
105
+ `scope="${JMAP_SCOPE}"`,
106
+ ...(token ? ['error="invalid_token"'] : []),
107
+ ].join(", "));
108
+ return problem(c, 401, "about:blank", token ? "The access token is invalid or expired" : "Sign in with OAuth");
109
+ }
110
+ c.set("jmapUser", { id: identity.user.id, name: identity.user.name });
111
+ await next();
112
+ };
113
+
114
+ /**
115
+ * JMAP (RFC 8620) for mail (RFC 8621) with OAuth bearer tokens carrying the
116
+ * jmap scope. Each mailbox the person can open is a JMAP account.
117
+ */
118
+ export const jmapRoutes = new Hono<JmapEnv>()
119
+ .get("/.well-known/jmap", (c) => c.redirect("/jmap/session", 302))
120
+ .use("/jmap/*", requireJmapAuth)
121
+ .get("/jmap/session", async (c) => {
122
+ const user = c.get("jmapUser");
123
+ return c.json(sessionObject(new URL(c.req.url).origin, user, await jmapAccounts(c.env, user.id)));
124
+ })
125
+ .post("/jmap/api", async (c) => {
126
+ if (!c.req.header("content-type")?.toLowerCase().startsWith("application/json")) {
127
+ return problem(c, 400, "urn:ietf:params:jmap:error:notJSON", "The request must be JSON");
128
+ }
129
+ let body: unknown;
130
+ try {
131
+ body = await c.req.json();
132
+ } catch {
133
+ return problem(c, 400, "urn:ietf:params:jmap:error:notJSON", "The request is not valid JSON");
134
+ }
135
+ const user = c.get("jmapUser");
136
+ const context: JmapContext = {
137
+ env: c.env,
138
+ user,
139
+ accounts: await jmapAccounts(c.env, user.id),
140
+ requestUrl: c.req.url,
141
+ defer: (task) => c.executionCtx.waitUntil(task),
142
+ };
143
+ try {
144
+ return c.json(await runJmapRequest(context, body));
145
+ } catch (error) {
146
+ if (error instanceof JmapError) return problem(c, 400, error.type, error.message);
147
+ throw error;
148
+ }
149
+ })
150
+ .get("/jmap/download/:accountId/:blobId/:name", async (c) => {
151
+ const user = c.get("jmapUser");
152
+ const account = (await jmapAccounts(c.env, user.id)).find((item) => item.id === c.req.param("accountId"));
153
+ if (!account) return problem(c, 404, "about:blank", "Account not found");
154
+ const blob = /^(raw|att)~([^~]+)(?:~(.+))?$/u.exec(c.req.param("blobId"));
155
+ if (!blob) return problem(c, 404, "about:blank", "Blob not found");
156
+ const [, kind, emailId, attachmentId] = blob;
157
+ const stub = mailboxStub(c.env, account.id);
158
+ const email = await stub.getEmail(emailId!);
159
+ const key = kind === "raw"
160
+ ? email?.rawMimeR2Key
161
+ : email?.attachmentsJson.find((attachment) => attachment.id === attachmentId)?.r2Key;
162
+ const object = key ? await c.env.MAIL_STORAGE.get(key) : null;
163
+ if (!object) return problem(c, 404, "about:blank", "Blob not found");
164
+ const type = c.req.query("type") || (kind === "raw" ? "message/rfc822" : "application/octet-stream");
165
+ return new Response(object.body, {
166
+ headers: {
167
+ "content-type": type,
168
+ "content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(c.req.param("name"))}`,
169
+ "cache-control": "private, immutable, max-age=31536000",
170
+ },
171
+ });
172
+ })
173
+ .post("/jmap/upload/:accountId", (c) =>
174
+ problem(c, 501, "about:blank", "Uploads are not supported yet; send attachments from the app"))
175
+ .get("/jmap/eventsource", (c) =>
176
+ problem(c, 501, "about:blank", "Push over EventSource is not supported yet"));
@@ -4,6 +4,15 @@ import { drizzle, type DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlit
4
4
  import { migrate } from "drizzle-orm/durable-sqlite/migrator";
5
5
  import migrations from "./migrations";
6
6
  import { upgradeLegacyMailbox } from "./legacy-migrations";
7
+ import {
8
+ bumpJmapState,
9
+ emailPlacement as jmapEmailPlacement,
10
+ jmapState,
11
+ mailboxes as jmapMailboxRows,
12
+ moveEmails as jmapMoveEmails,
13
+ queryEmails as queryJmapEmails,
14
+ threadEmails as jmapThreadEmails,
15
+ } from "./jmap-store";
7
16
  import { officePreviewExtension, officePreviewKey } from "../../shared/office-preview";
8
17
  import {
9
18
  dispatchOfficePreviewJobs,
@@ -497,6 +506,7 @@ export class MailboxDO extends DurableObject<Env> {
497
506
  }
498
507
 
499
508
  private publishUpdate() {
509
+ bumpJmapState(this.state.storage);
500
510
  this.state.waitUntil(this.broadcastUpdate().catch((error) => {
501
511
  console.error("Could not authorize realtime delivery", error);
502
512
  }));
@@ -852,6 +862,41 @@ export class MailboxDO extends DurableObject<Env> {
852
862
  return this.getMailboxAiSettings();
853
863
  }
854
864
 
865
+ jmapState() {
866
+ return jmapState(this.state.storage);
867
+ }
868
+
869
+ jmapMailboxes(userId: string) {
870
+ return { state: jmapState(this.state.storage), mailboxes: jmapMailboxRows(this.state.storage.sql, userId) };
871
+ }
872
+
873
+ jmapQueryEmails(input: Parameters<typeof queryJmapEmails>[1]) {
874
+ return { state: jmapState(this.state.storage), ...queryJmapEmails(this.state.storage.sql, input) };
875
+ }
876
+
877
+ /** Emails with their JMAP Mailbox and this user's $seen keyword; unknown ids are left out. */
878
+ jmapEmails(userId: string, ids: string[]) {
879
+ const placement = jmapEmailPlacement(this.state.storage.sql, userId, ids);
880
+ return {
881
+ state: jmapState(this.state.storage),
882
+ emails: ids.flatMap((id) => {
883
+ const email = this.getEmail(id);
884
+ const place = placement.get(id);
885
+ return email && place ? [{ email, ...place }] : [];
886
+ }),
887
+ };
888
+ }
889
+
890
+ jmapThreads(ids: string[]) {
891
+ return { state: jmapState(this.state.storage), threads: [...jmapThreadEmails(this.state.storage.sql, ids)] };
892
+ }
893
+
894
+ jmapMoveEmails(emailIds: string[], target: string) {
895
+ const moved = jmapMoveEmails(this.state.storage.sql, emailIds, target);
896
+ if (moved) this.publishUpdate();
897
+ return moved;
898
+ }
899
+
855
900
  getEmail(id: string) {
856
901
  return this.db.select().from(emails).where(eq(emails.id, id)).all()[0] ?? null;
857
902
  }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * JMAP views over a mailbox's SQLite store (RFC 8621). A JMAP Mailbox is a
3
+ * system folder or a custom folder; an email sits in exactly one, derived
4
+ * from its conversation's state and its own direction.
5
+ */
6
+
7
+ /** Which JMAP Mailbox an email (alias e) of conversation (alias c) is in. */
8
+ export const EMAIL_MAILBOX_SQL = `case
9
+ when c.mailbox_state = 'trash' then 'trash'
10
+ when c.mailbox_state = 'spam' then 'spam'
11
+ when c.mailbox_state = 'archive' then 'archive'
12
+ when e.direction = 'outgoing' then 'sent'
13
+ when c.folder_id is not null then c.folder_id
14
+ else 'inbox'
15
+ end`;
16
+
17
+ const SYSTEM_MAILBOXES = [
18
+ { id: "inbox", name: "Inbox", role: "inbox", sortOrder: 0 },
19
+ { id: "sent", name: "Sent", role: "sent", sortOrder: 1_000 },
20
+ { id: "archive", name: "Archive", role: "archive", sortOrder: 1_010 },
21
+ { id: "spam", name: "Spam", role: "junk", sortOrder: 1_020 },
22
+ { id: "trash", name: "Trash", role: "trash", sortOrder: 1_030 },
23
+ ] as const;
24
+
25
+ export type JmapMailboxRow = {
26
+ id: string;
27
+ name: string;
28
+ role: string | null;
29
+ sortOrder: number;
30
+ totalEmails: number;
31
+ unreadEmails: number;
32
+ totalThreads: number;
33
+ unreadThreads: number;
34
+ };
35
+
36
+ export type JmapEmailFilter = {
37
+ inMailbox?: string;
38
+ inMailboxOtherThan?: string[];
39
+ before?: number;
40
+ after?: number;
41
+ text?: string;
42
+ from?: string;
43
+ to?: string;
44
+ subject?: string;
45
+ hasKeyword?: string;
46
+ notKeyword?: string;
47
+ hasAttachment?: boolean;
48
+ };
49
+
50
+ const STATE_KEY = "jmap:state";
51
+
52
+ /** A counter bumped on every change; JMAP clients compare it to resync. */
53
+ export function jmapState(storage: DurableObjectStorage) {
54
+ return String(storage.kv.get<number>(STATE_KEY) ?? 0);
55
+ }
56
+
57
+ export function bumpJmapState(storage: DurableObjectStorage) {
58
+ storage.kv.put(STATE_KEY, (storage.kv.get<number>(STATE_KEY) ?? 0) + 1);
59
+ }
60
+
61
+ const unread = (userId: string) => ({
62
+ sql: "not exists (select 1 from email_read_states r where r.email_id = e.id and r.user_id = ?)",
63
+ params: [userId],
64
+ });
65
+
66
+ export function mailboxes(sql: SqlStorage, userId: string): JmapMailboxRow[] {
67
+ const isUnread = unread(userId);
68
+ const counts = new Map(
69
+ sql.exec<{ mailbox: string; total: number; unread: number; threads: number; unread_threads: number }>(`
70
+ select mailbox,
71
+ count(*) as total,
72
+ sum(is_unread) as unread,
73
+ count(distinct conversation_id) as threads,
74
+ count(distinct case when is_unread then conversation_id end) as unread_threads
75
+ from (
76
+ select ${EMAIL_MAILBOX_SQL} as mailbox, e.conversation_id, ${isUnread.sql} as is_unread
77
+ from emails e join conversations c on c.id = e.conversation_id
78
+ )
79
+ group by mailbox`, ...isUnread.params).toArray().map((row) => [row.mailbox, row]),
80
+ );
81
+ const custom = sql.exec<{ id: string; name: string; sort_order: number }>(
82
+ "select id, name, sort_order from folders order by sort_order, name",
83
+ ).toArray();
84
+ const rows = [
85
+ ...SYSTEM_MAILBOXES.map((mailbox) => ({ ...mailbox })),
86
+ ...custom.map((folder) => ({ id: folder.id, name: folder.name, role: null, sortOrder: 10 + folder.sort_order })),
87
+ ];
88
+ return rows.map((mailbox) => {
89
+ const count = counts.get(mailbox.id);
90
+ return {
91
+ ...mailbox,
92
+ totalEmails: count?.total ?? 0,
93
+ unreadEmails: count?.unread ?? 0,
94
+ totalThreads: count?.threads ?? 0,
95
+ unreadThreads: count?.unread_threads ?? 0,
96
+ };
97
+ });
98
+ }
99
+
100
+ function ftsQuery(text: string) {
101
+ // Each word must appear; quoting keeps FTS syntax out of user input.
102
+ return text
103
+ .split(/\s+/u)
104
+ .filter(Boolean)
105
+ .map((word) => `"${word.replaceAll('"', '""')}"`)
106
+ .join(" ");
107
+ }
108
+
109
+ export function queryEmails(
110
+ sql: SqlStorage,
111
+ input: {
112
+ userId: string;
113
+ filter: JmapEmailFilter;
114
+ ascending: boolean;
115
+ position: number;
116
+ limit: number;
117
+ collapseThreads: boolean;
118
+ },
119
+ ) {
120
+ const where: string[] = [];
121
+ const params: unknown[] = [];
122
+ const { filter } = input;
123
+ if (filter.inMailbox) {
124
+ where.push(`${EMAIL_MAILBOX_SQL} = ?`);
125
+ params.push(filter.inMailbox);
126
+ }
127
+ if (filter.inMailboxOtherThan?.length) {
128
+ where.push(`${EMAIL_MAILBOX_SQL} not in (${filter.inMailboxOtherThan.map(() => "?").join(", ")})`);
129
+ params.push(...filter.inMailboxOtherThan);
130
+ }
131
+ if (filter.before !== undefined) {
132
+ where.push("e.timeline_at < ?");
133
+ params.push(filter.before);
134
+ }
135
+ if (filter.after !== undefined) {
136
+ where.push("e.timeline_at >= ?");
137
+ params.push(filter.after);
138
+ }
139
+ if (filter.text?.trim()) {
140
+ where.push("e.id in (select email_id from email_search where email_search match ?)");
141
+ params.push(ftsQuery(filter.text));
142
+ }
143
+ for (const [field, column] of [["from", "e.from_json"], ["subject", "e.subject"]] as const) {
144
+ const value = filter[field];
145
+ if (value) {
146
+ where.push(`lower(${column}) like ?`);
147
+ params.push(`%${value.toLowerCase()}%`);
148
+ }
149
+ }
150
+ if (filter.to) {
151
+ where.push("(lower(e.to_json) like ? or lower(e.cc_json) like ?)");
152
+ params.push(`%${filter.to.toLowerCase()}%`, `%${filter.to.toLowerCase()}%`);
153
+ }
154
+ const isUnread = unread(input.userId);
155
+ if (filter.hasKeyword === "$seen") {
156
+ where.push(`not ${isUnread.sql}`);
157
+ params.push(...isUnread.params);
158
+ }
159
+ if (filter.notKeyword === "$seen") {
160
+ where.push(isUnread.sql);
161
+ params.push(...isUnread.params);
162
+ }
163
+ if (filter.hasAttachment !== undefined) {
164
+ where.push(filter.hasAttachment
165
+ ? "json_array_length(e.attachments_json) > 0"
166
+ : "json_array_length(e.attachments_json) = 0");
167
+ }
168
+ const direction = input.ascending ? "asc" : "desc";
169
+ const filtered = `
170
+ select e.id, e.conversation_id, e.timeline_at,
171
+ row_number() over (partition by e.conversation_id order by e.timeline_at desc, e.id desc) as thread_rank
172
+ from emails e join conversations c on c.id = e.conversation_id
173
+ ${where.length ? `where ${where.join(" and ")}` : ""}`;
174
+ const rows = sql.exec<{ id: string; total: number }>(`
175
+ select id, count(*) over () as total from (${filtered})
176
+ ${input.collapseThreads ? "where thread_rank = 1" : ""}
177
+ order by timeline_at ${direction}, id ${direction}
178
+ limit ? offset ?`, ...params, input.limit, input.position).toArray();
179
+ const total = rows[0]?.total ?? sql.exec<{ total: number }>(
180
+ `select count(*) as total from (${filtered}) ${input.collapseThreads ? "where thread_rank = 1" : ""}`,
181
+ ...params,
182
+ ).one().total;
183
+ return { ids: rows.map((row) => row.id), total };
184
+ }
185
+
186
+ /** For each email: its JMAP Mailbox, and whether this user has read it. */
187
+ export function emailPlacement(sql: SqlStorage, userId: string, ids: string[]) {
188
+ if (!ids.length) return new Map<string, { mailboxId: string; seen: boolean }>();
189
+ const isUnread = unread(userId);
190
+ return new Map(
191
+ sql.exec<{ id: string; mailbox: string; is_unread: number }>(`
192
+ select e.id, ${EMAIL_MAILBOX_SQL} as mailbox, ${isUnread.sql} as is_unread
193
+ from emails e join conversations c on c.id = e.conversation_id
194
+ where e.id in (${ids.map(() => "?").join(", ")})`, ...isUnread.params, ...ids)
195
+ .toArray()
196
+ .map((row) => [row.id, { mailboxId: row.mailbox, seen: !row.is_unread }]),
197
+ );
198
+ }
199
+
200
+ export function threadEmails(sql: SqlStorage, threadIds: string[]) {
201
+ const threads = new Map<string, string[]>();
202
+ if (!threadIds.length) return threads;
203
+ for (const row of sql.exec<{ id: string; conversation_id: string }>(
204
+ `select id, conversation_id from emails where conversation_id in (${threadIds.map(() => "?").join(", ")})
205
+ order by timeline_at, id`,
206
+ ...threadIds,
207
+ )) {
208
+ threads.set(row.conversation_id, [...(threads.get(row.conversation_id) ?? []), row.id]);
209
+ }
210
+ return threads;
211
+ }
212
+
213
+ /**
214
+ * Moves the conversations of `emailIds` into a JMAP Mailbox. Conversations
215
+ * move as a whole, as they do in the app. Returns false for an unknown or
216
+ * read-only target.
217
+ */
218
+ export function moveEmails(sql: SqlStorage, emailIds: string[], target: string) {
219
+ if (!emailIds.length) return true;
220
+ let state: string;
221
+ let folderId: string | null = null;
222
+ if (target === "inbox") state = "active";
223
+ else if (target === "archive" || target === "spam" || target === "trash") state = target;
224
+ else if (sql.exec("select 1 from folders where id = ?", target).toArray().length) {
225
+ state = "active";
226
+ folderId = target;
227
+ } else {
228
+ return false;
229
+ }
230
+ sql.exec(
231
+ `update conversations set mailbox_state = ?, folder_id = ?
232
+ where id in (select conversation_id from emails where id in (${emailIds.map(() => "?").join(", ")}))`,
233
+ state,
234
+ folderId,
235
+ ...emailIds,
236
+ );
237
+ return true;
238
+ }
package/worker/module.ts CHANGED
@@ -16,6 +16,7 @@ import { mailRoutes } from "./mail/routes";
16
16
  import { emailWebhookData } from "./mail/webhook-source";
17
17
  import { seedEmailDemo } from "./mailbox/demo-seed";
18
18
  import { registerMailTools } from "./mcp/tools";
19
+ import { jmapRoutes } from "./jmap/routes";
19
20
 
20
21
  /** Routes the browser and MCP use under /api. */
21
22
  export const mailAccountApi = new Hono<AppEnv>()
@@ -23,6 +24,7 @@ export const mailAccountApi = new Hono<AppEnv>()
23
24
  .route("/admin", reportAdminRoutes);
24
25
 
25
26
  const mailPublicRoutes = new Hono<AppEnv>()
27
+ .route("/", jmapRoutes as unknown as Hono<AppEnv>)
26
28
  .route("/api/downloads", mailDownloadRoutes)
27
29
  .route("/api/demo", demoRoutes)
28
30
  .route("/api/notifications", pushNotificationRoutes);