@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 +3 -0
- package/package.json +3 -3
- package/worker/jmap/methods.ts +678 -0
- package/worker/jmap/routes.ts +176 -0
- package/worker/mailbox/index.ts +45 -0
- package/worker/mailbox/jmap-store.ts +238 -0
- package/worker/module.ts +2 -0
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.
|
|
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.
|
|
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.
|
|
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"));
|
package/worker/mailbox/index.ts
CHANGED
|
@@ -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);
|