surface-cli 0.3.4 → 0.3.9
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/README.md +56 -16
- package/dist/cli.js +65 -5
- package/dist/cli.js.map +1 -1
- package/dist/contracts/account.js +1 -1
- package/dist/contracts/account.js.map +1 -1
- package/dist/lib/compose-attachments.js +138 -0
- package/dist/lib/compose-attachments.js.map +1 -0
- package/dist/lib/compose-attachments.test.js +47 -0
- package/dist/lib/compose-attachments.test.js.map +1 -0
- package/dist/lib/skill-install.js +55 -0
- package/dist/lib/skill-install.js.map +1 -0
- package/dist/providers/gmail/adapter.js +9 -30
- package/dist/providers/gmail/adapter.js.map +1 -1
- package/dist/providers/imap/adapter.js +1606 -0
- package/dist/providers/imap/adapter.js.map +1 -0
- package/dist/providers/imap/adapter.test.js +323 -0
- package/dist/providers/imap/adapter.test.js.map +1 -0
- package/dist/providers/imap/auth.js +155 -0
- package/dist/providers/imap/auth.js.map +1 -0
- package/dist/providers/imap/auth.test.js +30 -0
- package/dist/providers/imap/auth.test.js.map +1 -0
- package/dist/providers/index.js +6 -1
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/outlook/adapter.js +83 -2
- package/dist/providers/outlook/adapter.js.map +1 -1
- package/package.json +12 -4
- package/skills/surface-cli/SKILL.md +339 -0
|
@@ -0,0 +1,1606 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { ImapFlow, } from "imapflow";
|
|
6
|
+
import { simpleParser, } from "mailparser";
|
|
7
|
+
import { createTransport } from "nodemailer";
|
|
8
|
+
import { buildRawMimeMessage, composeAttachmentMetas, } from "../../lib/compose-attachments.js";
|
|
9
|
+
import { SurfaceError } from "../../lib/errors.js";
|
|
10
|
+
import { toPublicSentMessage } from "../../lib/public-mail.js";
|
|
11
|
+
import { accountIdentityEmails, messageMatchesRecipient, normalizeComparableEmail, sentMessagesFromStoredThread, } from "../../lib/sent-mail.js";
|
|
12
|
+
import { assertWriteAllowed } from "../../lib/write-safety.js";
|
|
13
|
+
import { makeAttachmentId, makeMessageRef, makeThreadRef } from "../../refs.js";
|
|
14
|
+
import { summarizeAndPersistThreads } from "../../summarizer.js";
|
|
15
|
+
import { htmlToText } from "../shared/html.js";
|
|
16
|
+
import { annotateBodyWithInlineAttachments } from "../shared/inline-attachments.js";
|
|
17
|
+
import { clearImapSmtpAuthState, readImapSmtpAuthState, writeImapSmtpAuthState } from "./auth.js";
|
|
18
|
+
const DEFAULT_MAILBOX = "INBOX";
|
|
19
|
+
const MAX_FETCH_BATCH = 25;
|
|
20
|
+
const ARCHIVE_RECOVERY_SCAN_LIMIT = 100;
|
|
21
|
+
function sourceInfo(account) {
|
|
22
|
+
return {
|
|
23
|
+
provider: account.provider,
|
|
24
|
+
transport: account.transport,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function securityOptions(mode) {
|
|
28
|
+
switch (mode) {
|
|
29
|
+
case "tls":
|
|
30
|
+
return { secure: true };
|
|
31
|
+
case "starttls":
|
|
32
|
+
return { secure: false, doSTARTTLS: true };
|
|
33
|
+
case "none":
|
|
34
|
+
return { secure: false, doSTARTTLS: false };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function withImapClient(account, context, work) {
|
|
38
|
+
const state = readImapSmtpAuthState(account, context);
|
|
39
|
+
const client = new ImapFlow({
|
|
40
|
+
host: state.imap.host,
|
|
41
|
+
port: state.imap.port,
|
|
42
|
+
...securityOptions(state.imap.security),
|
|
43
|
+
auth: {
|
|
44
|
+
user: state.username,
|
|
45
|
+
pass: state.password,
|
|
46
|
+
},
|
|
47
|
+
connectionTimeout: context.config.providerTimeoutMs,
|
|
48
|
+
greetingTimeout: context.config.providerTimeoutMs,
|
|
49
|
+
socketTimeout: context.config.providerTimeoutMs,
|
|
50
|
+
disableAutoIdle: true,
|
|
51
|
+
logger: false,
|
|
52
|
+
});
|
|
53
|
+
try {
|
|
54
|
+
await client.connect();
|
|
55
|
+
return await work(client);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
try {
|
|
59
|
+
if (client.usable) {
|
|
60
|
+
await client.logout();
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
client.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
client.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function withSmtpTransport(account, context, work) {
|
|
72
|
+
const state = readImapSmtpAuthState(account, context);
|
|
73
|
+
const transport = createTransport({
|
|
74
|
+
host: state.smtp.host,
|
|
75
|
+
port: state.smtp.port,
|
|
76
|
+
secure: state.smtp.security === "tls",
|
|
77
|
+
requireTLS: state.smtp.security === "starttls",
|
|
78
|
+
ignoreTLS: state.smtp.security === "none",
|
|
79
|
+
auth: {
|
|
80
|
+
user: state.username,
|
|
81
|
+
pass: state.password,
|
|
82
|
+
},
|
|
83
|
+
connectionTimeout: context.config.providerTimeoutMs,
|
|
84
|
+
greetingTimeout: context.config.providerTimeoutMs,
|
|
85
|
+
socketTimeout: context.config.providerTimeoutMs,
|
|
86
|
+
disableFileAccess: true,
|
|
87
|
+
disableUrlAccess: true,
|
|
88
|
+
});
|
|
89
|
+
try {
|
|
90
|
+
return await work(transport);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
transport.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function normalizeMailboxAlias(value) {
|
|
97
|
+
const normalized = (value ?? "inbox").trim().toLowerCase();
|
|
98
|
+
if (!normalized) {
|
|
99
|
+
return "inbox";
|
|
100
|
+
}
|
|
101
|
+
switch (normalized) {
|
|
102
|
+
case "inbox":
|
|
103
|
+
return "inbox";
|
|
104
|
+
case "sent":
|
|
105
|
+
case "sent-mail":
|
|
106
|
+
case "sent mail":
|
|
107
|
+
case "sent items":
|
|
108
|
+
return "sent";
|
|
109
|
+
case "archive":
|
|
110
|
+
case "archives":
|
|
111
|
+
case "all mail":
|
|
112
|
+
return "archive";
|
|
113
|
+
case "draft":
|
|
114
|
+
case "drafts":
|
|
115
|
+
return "drafts";
|
|
116
|
+
case "trash":
|
|
117
|
+
case "bin":
|
|
118
|
+
case "deleted":
|
|
119
|
+
case "deleted items":
|
|
120
|
+
return "trash";
|
|
121
|
+
case "spam":
|
|
122
|
+
case "junk":
|
|
123
|
+
case "junk mail":
|
|
124
|
+
return "spam";
|
|
125
|
+
default:
|
|
126
|
+
return value?.trim() ?? DEFAULT_MAILBOX;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function specialUseMatches(mailbox, expected) {
|
|
130
|
+
return mailbox.specialUse?.trim().toLowerCase() === expected.toLowerCase();
|
|
131
|
+
}
|
|
132
|
+
function mailboxNameMatches(mailbox, names) {
|
|
133
|
+
const candidates = [mailbox.path, mailbox.name].map((entry) => entry.trim().toLowerCase());
|
|
134
|
+
return names.some((name) => candidates.includes(name.trim().toLowerCase()));
|
|
135
|
+
}
|
|
136
|
+
function mailboxLabel(mailbox) {
|
|
137
|
+
const normalized = mailbox.trim().toLowerCase();
|
|
138
|
+
switch (normalized) {
|
|
139
|
+
case "inbox":
|
|
140
|
+
return "inbox";
|
|
141
|
+
case "sent":
|
|
142
|
+
case "sent mail":
|
|
143
|
+
case "sent items":
|
|
144
|
+
return "sent";
|
|
145
|
+
case "archive":
|
|
146
|
+
case "archives":
|
|
147
|
+
case "all mail":
|
|
148
|
+
return "archive";
|
|
149
|
+
case "draft":
|
|
150
|
+
case "drafts":
|
|
151
|
+
return "drafts";
|
|
152
|
+
case "trash":
|
|
153
|
+
case "bin":
|
|
154
|
+
case "deleted items":
|
|
155
|
+
return "trash";
|
|
156
|
+
case "spam":
|
|
157
|
+
case "junk":
|
|
158
|
+
case "junk mail":
|
|
159
|
+
return "spam";
|
|
160
|
+
default:
|
|
161
|
+
return normalized || "inbox";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function resolveMailboxPath(mailboxes, requested) {
|
|
165
|
+
const alias = normalizeMailboxAlias(requested);
|
|
166
|
+
const specialUsesByAlias = {
|
|
167
|
+
inbox: ["\\Inbox"],
|
|
168
|
+
sent: ["\\Sent"],
|
|
169
|
+
archive: ["\\Archive", "\\All"],
|
|
170
|
+
drafts: ["\\Drafts"],
|
|
171
|
+
trash: ["\\Trash"],
|
|
172
|
+
spam: ["\\Junk"],
|
|
173
|
+
};
|
|
174
|
+
const namesByAlias = {
|
|
175
|
+
inbox: ["inbox"],
|
|
176
|
+
sent: ["sent", "sent mail", "sent items"],
|
|
177
|
+
archive: ["archive", "archives", "all mail"],
|
|
178
|
+
drafts: ["draft", "drafts"],
|
|
179
|
+
trash: ["trash", "bin", "deleted", "deleted items"],
|
|
180
|
+
spam: ["spam", "junk", "junk mail"],
|
|
181
|
+
};
|
|
182
|
+
const specialUses = specialUsesByAlias[alias];
|
|
183
|
+
if (specialUses) {
|
|
184
|
+
const matched = mailboxes.find((mailbox) => specialUses.some((specialUse) => specialUseMatches(mailbox, specialUse)));
|
|
185
|
+
if (matched) {
|
|
186
|
+
return matched.path;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const names = namesByAlias[alias];
|
|
190
|
+
if (names) {
|
|
191
|
+
const matched = mailboxes.find((mailbox) => mailboxNameMatches(mailbox, names));
|
|
192
|
+
if (matched) {
|
|
193
|
+
return matched.path;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const exact = mailboxes.find((mailbox) => mailbox.path.trim().toLowerCase() === alias.trim().toLowerCase());
|
|
197
|
+
if (exact) {
|
|
198
|
+
return exact.path;
|
|
199
|
+
}
|
|
200
|
+
return alias === "inbox" ? DEFAULT_MAILBOX : alias;
|
|
201
|
+
}
|
|
202
|
+
function imapThreadProviderKey(locator) {
|
|
203
|
+
return `imap-thread:${locator.mailbox}:${locator.uid_validity}:${locator.uid}`;
|
|
204
|
+
}
|
|
205
|
+
function imapMessageProviderKey(locator) {
|
|
206
|
+
return `imap-message:${locator.mailbox}:${locator.uid_validity}:${locator.uid}`;
|
|
207
|
+
}
|
|
208
|
+
function imapAttachmentProviderKey(messageLocator, attachment, index) {
|
|
209
|
+
const checksum = attachment.locator?.locator.checksum;
|
|
210
|
+
if (typeof checksum === "string" && checksum) {
|
|
211
|
+
return `imap-attachment:${messageLocator.mailbox}:${messageLocator.uid_validity}:${messageLocator.uid}:${index}:${checksum}`;
|
|
212
|
+
}
|
|
213
|
+
return `imap-attachment:${messageLocator.mailbox}:${messageLocator.uid_validity}:${messageLocator.uid}:${attachment.filename}:${index}`;
|
|
214
|
+
}
|
|
215
|
+
function parseThreadLocator(locatorJson) {
|
|
216
|
+
const parsed = JSON.parse(locatorJson);
|
|
217
|
+
return {
|
|
218
|
+
mailbox: typeof parsed.mailbox === "string" && parsed.mailbox ? parsed.mailbox : DEFAULT_MAILBOX,
|
|
219
|
+
uid_validity: typeof parsed.uid_validity === "string" && parsed.uid_validity ? parsed.uid_validity : "",
|
|
220
|
+
uid: typeof parsed.uid === "number" ? parsed.uid : 0,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function parseMessageLocator(locatorJson) {
|
|
224
|
+
const parsed = JSON.parse(locatorJson);
|
|
225
|
+
return {
|
|
226
|
+
mailbox: typeof parsed.mailbox === "string" && parsed.mailbox ? parsed.mailbox : DEFAULT_MAILBOX,
|
|
227
|
+
uid_validity: typeof parsed.uid_validity === "string" && parsed.uid_validity ? parsed.uid_validity : "",
|
|
228
|
+
uid: typeof parsed.uid === "number" ? parsed.uid : 0,
|
|
229
|
+
message_id: typeof parsed.message_id === "string" && parsed.message_id ? parsed.message_id : null,
|
|
230
|
+
in_reply_to: typeof parsed.in_reply_to === "string" && parsed.in_reply_to ? parsed.in_reply_to : null,
|
|
231
|
+
references: typeof parsed.references === "string" && parsed.references ? parsed.references : null,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function parseAttachmentLocator(locatorJson) {
|
|
235
|
+
const parsed = JSON.parse(locatorJson);
|
|
236
|
+
return {
|
|
237
|
+
mailbox: typeof parsed.mailbox === "string" && parsed.mailbox ? parsed.mailbox : DEFAULT_MAILBOX,
|
|
238
|
+
uid_validity: typeof parsed.uid_validity === "string" && parsed.uid_validity ? parsed.uid_validity : "",
|
|
239
|
+
uid: typeof parsed.uid === "number" ? parsed.uid : 0,
|
|
240
|
+
index: typeof parsed.index === "number" ? parsed.index : -1,
|
|
241
|
+
checksum: typeof parsed.checksum === "string" && parsed.checksum ? parsed.checksum : null,
|
|
242
|
+
filename: typeof parsed.filename === "string" && parsed.filename ? parsed.filename : null,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function uidValidityString(mailbox) {
|
|
246
|
+
return mailbox.uidValidity.toString();
|
|
247
|
+
}
|
|
248
|
+
function participantFromAddress(address) {
|
|
249
|
+
return {
|
|
250
|
+
name: address?.name ?? address?.address ?? "",
|
|
251
|
+
email: address?.address ?? "",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function addressesFromObject(value) {
|
|
255
|
+
const values = Array.isArray(value) ? value : value ? [value] : [];
|
|
256
|
+
return values.flatMap((entry) => entry.value.map(participantFromAddress));
|
|
257
|
+
}
|
|
258
|
+
function firstAddressFromObject(value) {
|
|
259
|
+
const first = value?.value[0];
|
|
260
|
+
if (!first) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
return participantFromAddress(first);
|
|
264
|
+
}
|
|
265
|
+
function uniqueParticipants(messages) {
|
|
266
|
+
const seen = new Set();
|
|
267
|
+
const participants = [];
|
|
268
|
+
const push = (role, mailbox) => {
|
|
269
|
+
if (!mailbox || (!mailbox.email && !mailbox.name)) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const key = `${role}:${mailbox.email}:${mailbox.name}`;
|
|
273
|
+
if (seen.has(key)) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
seen.add(key);
|
|
277
|
+
participants.push({
|
|
278
|
+
role,
|
|
279
|
+
name: mailbox.name,
|
|
280
|
+
email: mailbox.email,
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
for (const message of messages) {
|
|
284
|
+
push("from", message.from);
|
|
285
|
+
for (const mailbox of message.to) {
|
|
286
|
+
push("to", mailbox);
|
|
287
|
+
}
|
|
288
|
+
for (const mailbox of message.cc) {
|
|
289
|
+
push("cc", mailbox);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return participants;
|
|
293
|
+
}
|
|
294
|
+
function flagsToLabels(mailbox, flags) {
|
|
295
|
+
const labels = new Set([mailboxLabel(mailbox)]);
|
|
296
|
+
if (!flags?.has("\\Seen")) {
|
|
297
|
+
labels.add("unread");
|
|
298
|
+
}
|
|
299
|
+
if (flags?.has("\\Flagged")) {
|
|
300
|
+
labels.add("flagged");
|
|
301
|
+
}
|
|
302
|
+
if (flags?.has("\\Answered")) {
|
|
303
|
+
labels.add("answered");
|
|
304
|
+
}
|
|
305
|
+
if (flags?.has("\\Draft")) {
|
|
306
|
+
labels.add("draft");
|
|
307
|
+
}
|
|
308
|
+
return [...labels];
|
|
309
|
+
}
|
|
310
|
+
function referencesToString(value) {
|
|
311
|
+
if (Array.isArray(value)) {
|
|
312
|
+
const joined = value.filter(Boolean).join(" ").trim();
|
|
313
|
+
return joined || null;
|
|
314
|
+
}
|
|
315
|
+
return value?.trim() || null;
|
|
316
|
+
}
|
|
317
|
+
function messageBodyText(parsed) {
|
|
318
|
+
const text = parsed.text?.replace(/\r\n/g, "\n").trim();
|
|
319
|
+
if (text) {
|
|
320
|
+
return text;
|
|
321
|
+
}
|
|
322
|
+
if (typeof parsed.html === "string") {
|
|
323
|
+
return htmlToText(parsed.html);
|
|
324
|
+
}
|
|
325
|
+
return "";
|
|
326
|
+
}
|
|
327
|
+
function snippetFromBody(bodyText, fallback) {
|
|
328
|
+
const normalized = bodyText.replace(/\s+/gu, " ").trim();
|
|
329
|
+
return (normalized || fallback || "").slice(0, 240);
|
|
330
|
+
}
|
|
331
|
+
function normalizeAttachment(attachment, messageLocator, index) {
|
|
332
|
+
const filename = attachment.filename?.trim() || `attachment-${index + 1}`;
|
|
333
|
+
return {
|
|
334
|
+
attachment_id: "",
|
|
335
|
+
filename,
|
|
336
|
+
mime_type: attachment.contentType || "application/octet-stream",
|
|
337
|
+
size_bytes: typeof attachment.size === "number" ? attachment.size : null,
|
|
338
|
+
inline: attachment.contentDisposition?.toLowerCase() === "inline" || attachment.related === true,
|
|
339
|
+
locator: {
|
|
340
|
+
kind: "attachment",
|
|
341
|
+
locator: {
|
|
342
|
+
mailbox: messageLocator.mailbox,
|
|
343
|
+
uid_validity: messageLocator.uid_validity,
|
|
344
|
+
uid: messageLocator.uid,
|
|
345
|
+
index,
|
|
346
|
+
checksum: attachment.checksum || null,
|
|
347
|
+
filename,
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
async function normalizeFetchedMessage(account, mailboxPath, uidValidity, fetched) {
|
|
353
|
+
if (!fetched.source) {
|
|
354
|
+
throw new SurfaceError("transport_error", "IMAP fetch did not return message source.", {
|
|
355
|
+
account: account.name,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
const parsed = await simpleParser(fetched.source);
|
|
359
|
+
const messageLocator = {
|
|
360
|
+
mailbox: mailboxPath,
|
|
361
|
+
uid_validity: uidValidity,
|
|
362
|
+
uid: fetched.uid,
|
|
363
|
+
message_id: parsed.messageId ?? fetched.envelope?.messageId ?? null,
|
|
364
|
+
in_reply_to: parsed.inReplyTo ?? fetched.envelope?.inReplyTo ?? null,
|
|
365
|
+
references: referencesToString(parsed.references),
|
|
366
|
+
};
|
|
367
|
+
const threadLocator = {
|
|
368
|
+
mailbox: mailboxPath,
|
|
369
|
+
uid_validity: uidValidity,
|
|
370
|
+
uid: fetched.uid,
|
|
371
|
+
};
|
|
372
|
+
const bodyWithoutInlineMarkers = messageBodyText(parsed);
|
|
373
|
+
const attachments = parsed.attachments.map((attachment, index) => normalizeAttachment(attachment, messageLocator, index));
|
|
374
|
+
const bodyText = annotateBodyWithInlineAttachments(bodyWithoutInlineMarkers, attachments);
|
|
375
|
+
const sentAt = parsed.date?.toISOString() ?? dateToIso(fetched.envelope?.date) ?? dateToIso(fetched.internalDate);
|
|
376
|
+
const receivedAt = dateToIso(fetched.internalDate) ?? sentAt;
|
|
377
|
+
const subject = parsed.subject ?? fetched.envelope?.subject ?? "";
|
|
378
|
+
const unread = !fetched.flags?.has("\\Seen");
|
|
379
|
+
const labels = flagsToLabels(mailboxPath, fetched.flags);
|
|
380
|
+
const envelope = {
|
|
381
|
+
from: firstAddressFromObject(parsed.from),
|
|
382
|
+
to: addressesFromObject(parsed.to),
|
|
383
|
+
cc: addressesFromObject(parsed.cc),
|
|
384
|
+
sent_at: sentAt,
|
|
385
|
+
received_at: receivedAt,
|
|
386
|
+
unread,
|
|
387
|
+
...(subject ? { subject } : {}),
|
|
388
|
+
};
|
|
389
|
+
const message = {
|
|
390
|
+
message_ref: "",
|
|
391
|
+
envelope,
|
|
392
|
+
snippet: snippetFromBody(bodyText, fetched.envelope?.subject),
|
|
393
|
+
body: {
|
|
394
|
+
text: bodyText,
|
|
395
|
+
truncated: false,
|
|
396
|
+
cached: true,
|
|
397
|
+
cached_bytes: Buffer.byteLength(bodyText, "utf8"),
|
|
398
|
+
},
|
|
399
|
+
attachments,
|
|
400
|
+
provider_ids: {
|
|
401
|
+
message_id: `${mailboxPath}:${uidValidity}:${fetched.uid}`,
|
|
402
|
+
...(messageLocator.message_id ? { internet_message_id: messageLocator.message_id } : {}),
|
|
403
|
+
},
|
|
404
|
+
locator: {
|
|
405
|
+
kind: "message",
|
|
406
|
+
locator: messageLocator,
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
return {
|
|
410
|
+
thread_ref: "",
|
|
411
|
+
source: sourceInfo(account),
|
|
412
|
+
envelope: {
|
|
413
|
+
subject,
|
|
414
|
+
participants: uniqueParticipants([envelope]),
|
|
415
|
+
mailbox: mailboxLabel(mailboxPath),
|
|
416
|
+
labels,
|
|
417
|
+
received_at: receivedAt,
|
|
418
|
+
message_count: 1,
|
|
419
|
+
unread_count: unread ? 1 : 0,
|
|
420
|
+
has_attachments: attachments.length > 0,
|
|
421
|
+
},
|
|
422
|
+
summary: null,
|
|
423
|
+
messages: [message],
|
|
424
|
+
provider_ids: {
|
|
425
|
+
thread_id: `${mailboxPath}:${uidValidity}:${fetched.uid}`,
|
|
426
|
+
},
|
|
427
|
+
locator: {
|
|
428
|
+
kind: "thread",
|
|
429
|
+
locator: threadLocator,
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
function dateToIso(value) {
|
|
434
|
+
if (!value) {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
438
|
+
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
|
439
|
+
}
|
|
440
|
+
function parseStoredMessage(record) {
|
|
441
|
+
const envelope = {
|
|
442
|
+
from: record.from_name || record.from_email
|
|
443
|
+
? {
|
|
444
|
+
name: record.from_name ?? "",
|
|
445
|
+
email: record.from_email ?? "",
|
|
446
|
+
}
|
|
447
|
+
: null,
|
|
448
|
+
to: JSON.parse(record.to_json),
|
|
449
|
+
cc: JSON.parse(record.cc_json),
|
|
450
|
+
sent_at: record.sent_at,
|
|
451
|
+
received_at: record.received_at,
|
|
452
|
+
unread: Boolean(record.unread),
|
|
453
|
+
...(record.subject ? { subject: record.subject } : {}),
|
|
454
|
+
};
|
|
455
|
+
return {
|
|
456
|
+
envelope,
|
|
457
|
+
body: {
|
|
458
|
+
text: record.body_cache_path && existsSync(record.body_cache_path) ? readFileSync(record.body_cache_path, "utf8") : "",
|
|
459
|
+
truncated: Boolean(record.body_truncated),
|
|
460
|
+
cached: Boolean(record.body_cached),
|
|
461
|
+
cached_bytes: record.body_cached_bytes,
|
|
462
|
+
},
|
|
463
|
+
invite: record.invite_json ? JSON.parse(record.invite_json) : undefined,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function buildReadEnvelope(account, messageRef, threadRef, parsed, attachments, cacheStatus) {
|
|
467
|
+
return {
|
|
468
|
+
schema_version: "1",
|
|
469
|
+
command: "read",
|
|
470
|
+
account: account.name,
|
|
471
|
+
message_ref: messageRef,
|
|
472
|
+
thread_ref: threadRef,
|
|
473
|
+
source: sourceInfo(account),
|
|
474
|
+
cache: {
|
|
475
|
+
status: cacheStatus,
|
|
476
|
+
truncated: parsed.body.truncated,
|
|
477
|
+
},
|
|
478
|
+
message: {
|
|
479
|
+
envelope: parsed.envelope,
|
|
480
|
+
body: parsed.body,
|
|
481
|
+
attachments,
|
|
482
|
+
...(parsed.invite ? { invite: parsed.invite } : {}),
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
function buildArchiveEnvelope(account, messageRef, threadRef) {
|
|
487
|
+
return {
|
|
488
|
+
schema_version: "1",
|
|
489
|
+
command: "archive",
|
|
490
|
+
account: account.name,
|
|
491
|
+
message_ref: messageRef,
|
|
492
|
+
thread_ref: threadRef,
|
|
493
|
+
source: sourceInfo(account),
|
|
494
|
+
status: "archived",
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function buildMarkMessagesEnvelope(account, command, updated) {
|
|
498
|
+
return {
|
|
499
|
+
schema_version: "1",
|
|
500
|
+
command,
|
|
501
|
+
account: account.name,
|
|
502
|
+
source: sourceInfo(account),
|
|
503
|
+
updated,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function participantFromEmail(email) {
|
|
507
|
+
return {
|
|
508
|
+
name: email,
|
|
509
|
+
email,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
function recipientsFromInput(input) {
|
|
513
|
+
return {
|
|
514
|
+
to: input.to.map(participantFromEmail),
|
|
515
|
+
cc: input.cc.map(participantFromEmail),
|
|
516
|
+
bcc: input.bcc.map(participantFromEmail),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
function normalizeEmailList(values) {
|
|
520
|
+
const deduped = new Set();
|
|
521
|
+
for (const value of values) {
|
|
522
|
+
const normalized = value?.trim();
|
|
523
|
+
if (!normalized) {
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
deduped.add(normalized);
|
|
527
|
+
}
|
|
528
|
+
return [...deduped];
|
|
529
|
+
}
|
|
530
|
+
function prefixSubject(subject, prefix) {
|
|
531
|
+
const normalized = subject.trim();
|
|
532
|
+
if (!normalized) {
|
|
533
|
+
return `${prefix}:`;
|
|
534
|
+
}
|
|
535
|
+
const matcher = prefix === "Re" ? /^re:\s/i : /^(fwd|fw):\s/i;
|
|
536
|
+
return matcher.test(normalized) ? normalized : `${prefix}: ${normalized}`;
|
|
537
|
+
}
|
|
538
|
+
function quoteLines(text) {
|
|
539
|
+
return text
|
|
540
|
+
.replace(/\r\n/g, "\n")
|
|
541
|
+
.split("\n")
|
|
542
|
+
.map((line) => `> ${line}`)
|
|
543
|
+
.join("\n");
|
|
544
|
+
}
|
|
545
|
+
function buildReplyBody(inputBody, stored) {
|
|
546
|
+
const parsed = parseStoredMessage(stored);
|
|
547
|
+
const originalBody = parsed.body.text.trim();
|
|
548
|
+
const originalFrom = parsed.envelope.from?.email ?? parsed.envelope.from?.name ?? "unknown sender";
|
|
549
|
+
const originalDate = parsed.envelope.sent_at ?? parsed.envelope.received_at ?? "unknown time";
|
|
550
|
+
if (!originalBody) {
|
|
551
|
+
return inputBody;
|
|
552
|
+
}
|
|
553
|
+
return `${inputBody}\n\nOn ${originalDate}, ${originalFrom} wrote:\n${quoteLines(originalBody)}`;
|
|
554
|
+
}
|
|
555
|
+
function buildForwardBody(inputBody, stored) {
|
|
556
|
+
const parsed = parseStoredMessage(stored);
|
|
557
|
+
const originalBody = parsed.body.text.trim();
|
|
558
|
+
const lines = [
|
|
559
|
+
inputBody,
|
|
560
|
+
"",
|
|
561
|
+
"---------- Forwarded message ---------",
|
|
562
|
+
`From: ${parsed.envelope.from?.email ?? parsed.envelope.from?.name ?? ""}`,
|
|
563
|
+
`Date: ${parsed.envelope.sent_at ?? parsed.envelope.received_at ?? ""}`,
|
|
564
|
+
`Subject: ${parsed.envelope.subject ?? ""}`,
|
|
565
|
+
`To: ${parsed.envelope.to.map((mailbox) => mailbox.email).join(", ")}`,
|
|
566
|
+
];
|
|
567
|
+
if (parsed.envelope.cc.length > 0) {
|
|
568
|
+
lines.push(`Cc: ${parsed.envelope.cc.map((mailbox) => mailbox.email).join(", ")}`);
|
|
569
|
+
}
|
|
570
|
+
lines.push("", originalBody);
|
|
571
|
+
return lines.join("\n").trim();
|
|
572
|
+
}
|
|
573
|
+
function makeSmtpMessageId(account) {
|
|
574
|
+
const domain = account.email.split("@")[1]?.trim() || "surface.local";
|
|
575
|
+
return `<surface-${Date.now()}-${randomUUID()}@${domain}>`;
|
|
576
|
+
}
|
|
577
|
+
function buildSendEnvelope(account, command, status, subject, recipients, result, inReplyToMessageRef, attachments = []) {
|
|
578
|
+
return {
|
|
579
|
+
schema_version: "1",
|
|
580
|
+
command,
|
|
581
|
+
account: account.name,
|
|
582
|
+
source: sourceInfo(account),
|
|
583
|
+
status,
|
|
584
|
+
subject,
|
|
585
|
+
recipients,
|
|
586
|
+
attachments,
|
|
587
|
+
thread_ref: result.thread_ref,
|
|
588
|
+
message_ref: result.message_ref,
|
|
589
|
+
in_reply_to_message_ref: inReplyToMessageRef,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
function persistThreads(account, context, threads, overrides = {}) {
|
|
593
|
+
return context.db.transaction(() => {
|
|
594
|
+
const persistedThreads = [];
|
|
595
|
+
for (const thread of threads) {
|
|
596
|
+
const threadLocator = thread.locator?.locator;
|
|
597
|
+
if (!threadLocator?.uid || !threadLocator.uid_validity) {
|
|
598
|
+
throw new SurfaceError("transport_error", "IMAP thread is missing UID locator data.", {
|
|
599
|
+
account: account.name,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
const threadProviderKey = imapThreadProviderKey(threadLocator);
|
|
603
|
+
const resolvedThreadRef = overrides.threadRefsByProviderKey?.get(threadProviderKey)
|
|
604
|
+
?? context.db.findEntityRefByProviderKey("thread", account.account_id, threadProviderKey)
|
|
605
|
+
?? makeThreadRef();
|
|
606
|
+
context.db.upsertThread({
|
|
607
|
+
thread_ref: resolvedThreadRef,
|
|
608
|
+
account_id: account.account_id,
|
|
609
|
+
subject: thread.envelope.subject,
|
|
610
|
+
participants: thread.envelope.participants,
|
|
611
|
+
mailbox: thread.envelope.mailbox,
|
|
612
|
+
labels: thread.envelope.labels,
|
|
613
|
+
received_at: thread.envelope.received_at,
|
|
614
|
+
message_count: thread.envelope.message_count,
|
|
615
|
+
unread_count: thread.envelope.unread_count,
|
|
616
|
+
has_attachments: thread.envelope.has_attachments,
|
|
617
|
+
});
|
|
618
|
+
context.db.upsertProviderLocator({
|
|
619
|
+
entity_kind: "thread",
|
|
620
|
+
entity_ref: resolvedThreadRef,
|
|
621
|
+
account_id: account.account_id,
|
|
622
|
+
provider_key: threadProviderKey,
|
|
623
|
+
locator_json: JSON.stringify(threadLocator),
|
|
624
|
+
});
|
|
625
|
+
const persistedMessages = [];
|
|
626
|
+
const messageRefs = [];
|
|
627
|
+
for (const message of thread.messages) {
|
|
628
|
+
const messageLocator = message.locator?.locator;
|
|
629
|
+
if (!messageLocator?.uid || !messageLocator.uid_validity) {
|
|
630
|
+
throw new SurfaceError("transport_error", "IMAP message is missing UID locator data.", {
|
|
631
|
+
account: account.name,
|
|
632
|
+
threadRef: resolvedThreadRef,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
const messageProviderKey = imapMessageProviderKey(messageLocator);
|
|
636
|
+
const resolvedMessageRef = overrides.messageRefsByProviderKey?.get(messageProviderKey)
|
|
637
|
+
?? context.db.findEntityRefByProviderKey("message", account.account_id, messageProviderKey)
|
|
638
|
+
?? makeMessageRef();
|
|
639
|
+
const messageDir = join(context.accountPaths.messagesDir, resolvedMessageRef);
|
|
640
|
+
mkdirSync(messageDir, { recursive: true });
|
|
641
|
+
const bodyCachePath = join(messageDir, "body.txt");
|
|
642
|
+
writeFileSync(bodyCachePath, message.body.text, "utf8");
|
|
643
|
+
context.db.upsertMessage({
|
|
644
|
+
message_ref: resolvedMessageRef,
|
|
645
|
+
account_id: account.account_id,
|
|
646
|
+
thread_ref: resolvedThreadRef,
|
|
647
|
+
subject: message.envelope.subject ?? thread.envelope.subject,
|
|
648
|
+
from_name: message.envelope.from?.name ?? null,
|
|
649
|
+
from_email: message.envelope.from?.email ?? null,
|
|
650
|
+
to_json: JSON.stringify(message.envelope.to),
|
|
651
|
+
cc_json: JSON.stringify(message.envelope.cc),
|
|
652
|
+
sent_at: message.envelope.sent_at,
|
|
653
|
+
received_at: message.envelope.received_at,
|
|
654
|
+
unread: message.envelope.unread,
|
|
655
|
+
snippet: message.snippet,
|
|
656
|
+
body_cache_path: bodyCachePath,
|
|
657
|
+
body_cached: true,
|
|
658
|
+
body_truncated: message.body.truncated,
|
|
659
|
+
body_cached_bytes: Buffer.byteLength(message.body.text, "utf8"),
|
|
660
|
+
invite_json: message.invite ? JSON.stringify(message.invite) : null,
|
|
661
|
+
});
|
|
662
|
+
context.db.upsertProviderLocator({
|
|
663
|
+
entity_kind: "message",
|
|
664
|
+
entity_ref: resolvedMessageRef,
|
|
665
|
+
account_id: account.account_id,
|
|
666
|
+
provider_key: messageProviderKey,
|
|
667
|
+
locator_json: JSON.stringify(messageLocator),
|
|
668
|
+
});
|
|
669
|
+
const persistedAttachments = [];
|
|
670
|
+
for (const [index, attachment] of message.attachments.entries()) {
|
|
671
|
+
const providerKey = imapAttachmentProviderKey(messageLocator, attachment, index);
|
|
672
|
+
const resolvedAttachmentId = overrides.attachmentRefsByProviderKey?.get(providerKey)
|
|
673
|
+
?? context.db.findEntityRefByProviderKey("attachment", account.account_id, providerKey)
|
|
674
|
+
?? makeAttachmentId();
|
|
675
|
+
const attachmentLocator = attachment.locator?.locator;
|
|
676
|
+
context.db.upsertProviderLocator({
|
|
677
|
+
entity_kind: "attachment",
|
|
678
|
+
entity_ref: resolvedAttachmentId,
|
|
679
|
+
account_id: account.account_id,
|
|
680
|
+
provider_key: providerKey,
|
|
681
|
+
locator_json: JSON.stringify(attachmentLocator ?? {}),
|
|
682
|
+
});
|
|
683
|
+
persistedAttachments.push({
|
|
684
|
+
...attachment,
|
|
685
|
+
attachment_id: resolvedAttachmentId,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
context.db.replaceAttachments(resolvedMessageRef, persistedAttachments.map((attachment) => ({
|
|
689
|
+
attachment_id: attachment.attachment_id,
|
|
690
|
+
filename: attachment.filename,
|
|
691
|
+
mime_type: attachment.mime_type,
|
|
692
|
+
size_bytes: attachment.size_bytes,
|
|
693
|
+
inline: attachment.inline,
|
|
694
|
+
saved_to: null,
|
|
695
|
+
})));
|
|
696
|
+
persistedMessages.push({
|
|
697
|
+
...message,
|
|
698
|
+
message_ref: resolvedMessageRef,
|
|
699
|
+
body: {
|
|
700
|
+
...message.body,
|
|
701
|
+
cached: true,
|
|
702
|
+
cached_bytes: Buffer.byteLength(message.body.text, "utf8"),
|
|
703
|
+
},
|
|
704
|
+
attachments: persistedAttachments,
|
|
705
|
+
});
|
|
706
|
+
messageRefs.push(resolvedMessageRef);
|
|
707
|
+
}
|
|
708
|
+
context.db.replaceThreadMessages(resolvedThreadRef, messageRefs);
|
|
709
|
+
persistedThreads.push({
|
|
710
|
+
...thread,
|
|
711
|
+
thread_ref: resolvedThreadRef,
|
|
712
|
+
messages: persistedMessages,
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
return persistedThreads;
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
async function fetchUidThreads(account, client, mailboxPath, uids) {
|
|
719
|
+
if (uids.length === 0) {
|
|
720
|
+
return [];
|
|
721
|
+
}
|
|
722
|
+
const mailbox = await client.mailboxOpen(mailboxPath);
|
|
723
|
+
const uidValidity = uidValidityString(mailbox);
|
|
724
|
+
const threads = [];
|
|
725
|
+
const batches = [];
|
|
726
|
+
for (let index = 0; index < uids.length; index += MAX_FETCH_BATCH) {
|
|
727
|
+
batches.push(uids.slice(index, index + MAX_FETCH_BATCH));
|
|
728
|
+
}
|
|
729
|
+
for (const batch of batches) {
|
|
730
|
+
for await (const fetched of client.fetch(batch, {
|
|
731
|
+
source: true,
|
|
732
|
+
envelope: true,
|
|
733
|
+
flags: true,
|
|
734
|
+
internalDate: true,
|
|
735
|
+
size: true,
|
|
736
|
+
threadId: true,
|
|
737
|
+
}, { uid: true })) {
|
|
738
|
+
threads.push(await normalizeFetchedMessage(account, mailboxPath, uidValidity, fetched));
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return threads.sort((left, right) => Date.parse(right.envelope.received_at ?? "") - Date.parse(left.envelope.received_at ?? ""));
|
|
742
|
+
}
|
|
743
|
+
function sameIsoInstant(left, right) {
|
|
744
|
+
if (!left || !right) {
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
const leftMs = Date.parse(left);
|
|
748
|
+
const rightMs = Date.parse(right);
|
|
749
|
+
if (!Number.isFinite(leftMs) || !Number.isFinite(rightMs)) {
|
|
750
|
+
return left === right;
|
|
751
|
+
}
|
|
752
|
+
return Math.abs(leftMs - rightMs) <= 1000;
|
|
753
|
+
}
|
|
754
|
+
function archivedThreadMatchesStoredMessage(stored, sourceLocator, thread) {
|
|
755
|
+
const message = thread.messages[0];
|
|
756
|
+
const messageLocator = message?.locator?.locator;
|
|
757
|
+
if (!message || !messageLocator) {
|
|
758
|
+
return false;
|
|
759
|
+
}
|
|
760
|
+
if (sourceLocator.message_id && messageLocator.message_id === sourceLocator.message_id) {
|
|
761
|
+
return true;
|
|
762
|
+
}
|
|
763
|
+
if ((stored.subject ?? "") !== (message.envelope.subject ?? "")) {
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
if (normalizeComparableEmail(stored.from_email) !== normalizeComparableEmail(message.envelope.from?.email)) {
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
const sentAtMatches = sameIsoInstant(stored.sent_at, message.envelope.sent_at);
|
|
770
|
+
const receivedAtMatches = sameIsoInstant(stored.received_at, message.envelope.received_at);
|
|
771
|
+
return sentAtMatches || receivedAtMatches || stored.snippet === message.snippet;
|
|
772
|
+
}
|
|
773
|
+
async function recoverArchivedThreadAfterMove(account, client, archivePath, stored, sourceLocator, movedUid) {
|
|
774
|
+
if (movedUid) {
|
|
775
|
+
const [movedThread] = await fetchUidThreads(account, client, archivePath, [movedUid]);
|
|
776
|
+
return movedThread ?? null;
|
|
777
|
+
}
|
|
778
|
+
if (sourceLocator.message_id) {
|
|
779
|
+
await client.mailboxOpen(archivePath);
|
|
780
|
+
const matches = await client.search({ header: { "message-id": sourceLocator.message_id } }, { uid: true });
|
|
781
|
+
const uids = Array.isArray(matches) ? matches.slice(-ARCHIVE_RECOVERY_SCAN_LIMIT).reverse() : [];
|
|
782
|
+
const threads = await fetchUidThreads(account, client, archivePath, uids);
|
|
783
|
+
const matched = threads.find((thread) => archivedThreadMatchesStoredMessage(stored, sourceLocator, thread));
|
|
784
|
+
if (matched) {
|
|
785
|
+
return matched;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
await client.mailboxOpen(archivePath);
|
|
789
|
+
const all = await client.search({ all: true }, { uid: true });
|
|
790
|
+
const recentUids = Array.isArray(all) ? all.slice(-ARCHIVE_RECOVERY_SCAN_LIMIT).reverse() : [];
|
|
791
|
+
const recentThreads = await fetchUidThreads(account, client, archivePath, recentUids);
|
|
792
|
+
return recentThreads.find((thread) => archivedThreadMatchesStoredMessage(stored, sourceLocator, thread)) ?? null;
|
|
793
|
+
}
|
|
794
|
+
function buildSearchObject(query) {
|
|
795
|
+
const search = { all: true };
|
|
796
|
+
if (query.text?.trim()) {
|
|
797
|
+
search.text = query.text.trim();
|
|
798
|
+
}
|
|
799
|
+
if (query.from?.trim()) {
|
|
800
|
+
search.from = query.from.trim();
|
|
801
|
+
}
|
|
802
|
+
if (query.subject?.trim()) {
|
|
803
|
+
search.subject = query.subject.trim();
|
|
804
|
+
}
|
|
805
|
+
if (query.unread_only || query.labels?.some((label) => label.trim().toLowerCase() === "unread")) {
|
|
806
|
+
search.seen = false;
|
|
807
|
+
}
|
|
808
|
+
if (query.labels?.some((label) => ["read", "seen"].includes(label.trim().toLowerCase()))) {
|
|
809
|
+
search.seen = true;
|
|
810
|
+
}
|
|
811
|
+
if (query.labels?.some((label) => label.trim().toLowerCase() === "flagged")) {
|
|
812
|
+
search.flagged = true;
|
|
813
|
+
}
|
|
814
|
+
return search;
|
|
815
|
+
}
|
|
816
|
+
function buildSentSearch(query) {
|
|
817
|
+
const search = { all: true };
|
|
818
|
+
const fetchLimit = query.recipient?.trim() ? Math.min(Math.max(query.limit * 5, query.limit), 100) : query.limit;
|
|
819
|
+
if (query.recipient?.trim()) {
|
|
820
|
+
search.or = [
|
|
821
|
+
{ to: query.recipient.trim() },
|
|
822
|
+
{ cc: query.recipient.trim() },
|
|
823
|
+
];
|
|
824
|
+
}
|
|
825
|
+
return { search, fetchLimit };
|
|
826
|
+
}
|
|
827
|
+
function filterSentMessagesForQuery(messages, query) {
|
|
828
|
+
return messages
|
|
829
|
+
.filter((message) => messageMatchesRecipient(message, query.recipient))
|
|
830
|
+
.slice(0, query.limit);
|
|
831
|
+
}
|
|
832
|
+
function threadMatchesLabels(thread, labels) {
|
|
833
|
+
if ((labels?.length ?? 0) === 0) {
|
|
834
|
+
return true;
|
|
835
|
+
}
|
|
836
|
+
const available = new Set(thread.envelope.labels.map((label) => label.trim().toLowerCase()));
|
|
837
|
+
return labels?.every((label) => available.has(label.trim().toLowerCase())) ?? true;
|
|
838
|
+
}
|
|
839
|
+
async function searchMailbox(account, context, query) {
|
|
840
|
+
return withImapClient(account, context, async (client) => {
|
|
841
|
+
const mailboxes = await client.list();
|
|
842
|
+
const mailboxPath = resolveMailboxPath(mailboxes, query.mailbox);
|
|
843
|
+
await client.mailboxOpen(mailboxPath);
|
|
844
|
+
const matches = await client.search(buildSearchObject(query), { uid: true });
|
|
845
|
+
const uids = Array.isArray(matches) ? matches.slice(-query.limit).reverse() : [];
|
|
846
|
+
const normalized = await fetchUidThreads(account, client, mailboxPath, uids);
|
|
847
|
+
const persisted = persistThreads(account, context, normalized.filter((thread) => threadMatchesLabels(thread, query.labels)));
|
|
848
|
+
return summarizeAndPersistThreads(persisted, context.config, context.db, context.db.getAccountIdentity(account));
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
async function fetchUnreadMailbox(account, context, query) {
|
|
852
|
+
return withImapClient(account, context, async (client) => {
|
|
853
|
+
const mailboxPath = resolveMailboxPath(await client.list(), "inbox");
|
|
854
|
+
await client.mailboxOpen(mailboxPath);
|
|
855
|
+
const matches = await client.search({ seen: false }, { uid: true });
|
|
856
|
+
const uids = Array.isArray(matches) ? matches.slice(-query.limit).reverse() : [];
|
|
857
|
+
const normalized = await fetchUidThreads(account, client, mailboxPath, uids);
|
|
858
|
+
const persisted = persistThreads(account, context, normalized);
|
|
859
|
+
return summarizeAndPersistThreads(persisted, context.config, context.db, context.db.getAccountIdentity(account));
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
async function refreshUidThread(account, context, locator) {
|
|
863
|
+
return withImapClient(account, context, async (client) => {
|
|
864
|
+
const mailbox = await client.mailboxOpen(locator.mailbox);
|
|
865
|
+
if (uidValidityString(mailbox) !== locator.uid_validity) {
|
|
866
|
+
throw new SurfaceError("cache_miss", `IMAP UIDVALIDITY changed for mailbox '${locator.mailbox}'.`, {
|
|
867
|
+
account: account.name,
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
const fetched = await client.fetchOne(String(locator.uid), {
|
|
871
|
+
source: true,
|
|
872
|
+
envelope: true,
|
|
873
|
+
flags: true,
|
|
874
|
+
internalDate: true,
|
|
875
|
+
size: true,
|
|
876
|
+
threadId: true,
|
|
877
|
+
}, { uid: true });
|
|
878
|
+
if (!fetched) {
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
const normalized = await normalizeFetchedMessage(account, locator.mailbox, locator.uid_validity, fetched);
|
|
882
|
+
const [persisted] = persistThreads(account, context, [normalized]);
|
|
883
|
+
if (persisted) {
|
|
884
|
+
const [summarized] = await summarizeAndPersistThreads([persisted], context.config, context.db, context.db.getAccountIdentity(account));
|
|
885
|
+
return summarized ?? persisted;
|
|
886
|
+
}
|
|
887
|
+
return null;
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
async function refreshStoredMessage(account, messageRef, context) {
|
|
891
|
+
const locatorRow = context.db.findProviderLocator("message", messageRef);
|
|
892
|
+
if (!locatorRow) {
|
|
893
|
+
throw new SurfaceError("cache_miss", `No provider locator exists for message '${messageRef}'.`, {
|
|
894
|
+
account: account.name,
|
|
895
|
+
messageRef,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
const locator = parseMessageLocator(locatorRow.locator_json);
|
|
899
|
+
const refreshed = await refreshUidThread(account, context, locator);
|
|
900
|
+
if (!refreshed) {
|
|
901
|
+
throw new SurfaceError("not_found", `Message '${messageRef}' could not be refreshed from IMAP.`, {
|
|
902
|
+
account: account.name,
|
|
903
|
+
messageRef,
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
const stored = context.db.getStoredMessage(messageRef);
|
|
907
|
+
if (!stored) {
|
|
908
|
+
throw new SurfaceError("not_found", `Message '${messageRef}' was not found after IMAP refresh.`, {
|
|
909
|
+
account: account.name,
|
|
910
|
+
messageRef,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
return stored;
|
|
914
|
+
}
|
|
915
|
+
function requireMessageForAccount(account, messageRef, context) {
|
|
916
|
+
const stored = context.db.getStoredMessage(messageRef);
|
|
917
|
+
if (!stored) {
|
|
918
|
+
throw new SurfaceError("not_found", `Message '${messageRef}' was not found.`, {
|
|
919
|
+
account: account.name,
|
|
920
|
+
messageRef,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
if (stored.account_id !== account.account_id) {
|
|
924
|
+
throw new SurfaceError("invalid_argument", `Message '${messageRef}' does not belong to account '${account.name}'.`, {
|
|
925
|
+
account: account.name,
|
|
926
|
+
messageRef,
|
|
927
|
+
threadRef: stored.thread_ref,
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
return stored;
|
|
931
|
+
}
|
|
932
|
+
function attachmentMetas(context, messageRef) {
|
|
933
|
+
return context.db.listAttachmentsForMessage(messageRef).map((attachment) => ({
|
|
934
|
+
attachment_id: attachment.attachment_id,
|
|
935
|
+
filename: attachment.filename,
|
|
936
|
+
mime_type: attachment.mime_type,
|
|
937
|
+
size_bytes: attachment.size_bytes,
|
|
938
|
+
inline: Boolean(attachment.inline),
|
|
939
|
+
}));
|
|
940
|
+
}
|
|
941
|
+
function attachmentIdentityKeys(locator) {
|
|
942
|
+
const keys = [];
|
|
943
|
+
if (locator.checksum) {
|
|
944
|
+
keys.push(`checksum:${locator.index}:${locator.checksum}`);
|
|
945
|
+
}
|
|
946
|
+
keys.push(`position:${locator.index}:${(locator.filename ?? "").trim().toLowerCase()}`);
|
|
947
|
+
return keys;
|
|
948
|
+
}
|
|
949
|
+
function buildAttachmentRefOverrides(context, messageRef, messageLocator, attachments) {
|
|
950
|
+
const existingRefsByIdentity = new Map();
|
|
951
|
+
for (const existing of context.db.listAttachmentsForMessage(messageRef)) {
|
|
952
|
+
const locatorRow = context.db.findProviderLocator("attachment", existing.attachment_id);
|
|
953
|
+
if (!locatorRow) {
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
const existingLocator = parseAttachmentLocator(locatorRow.locator_json);
|
|
957
|
+
for (const key of attachmentIdentityKeys(existingLocator)) {
|
|
958
|
+
existingRefsByIdentity.set(key, existing.attachment_id);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
const refsByProviderKey = new Map();
|
|
962
|
+
for (const [index, attachment] of attachments.entries()) {
|
|
963
|
+
const providerKey = imapAttachmentProviderKey(messageLocator, attachment, index);
|
|
964
|
+
const attachmentLocator = attachment.locator?.locator;
|
|
965
|
+
if (!attachmentLocator) {
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
const existingRef = attachmentIdentityKeys(attachmentLocator)
|
|
969
|
+
.map((key) => existingRefsByIdentity.get(key))
|
|
970
|
+
.find((ref) => Boolean(ref));
|
|
971
|
+
if (existingRef) {
|
|
972
|
+
refsByProviderKey.set(providerKey, existingRef);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return refsByProviderKey;
|
|
976
|
+
}
|
|
977
|
+
async function fetchSentMessages(account, context, query) {
|
|
978
|
+
if (query.thread_ref) {
|
|
979
|
+
const locatorRow = context.db.findProviderLocator("thread", query.thread_ref);
|
|
980
|
+
if (locatorRow) {
|
|
981
|
+
await refreshUidThread(account, context, parseThreadLocator(locatorRow.locator_json));
|
|
982
|
+
}
|
|
983
|
+
return sentMessagesFromStoredThread(account, context, query);
|
|
984
|
+
}
|
|
985
|
+
return withImapClient(account, context, async (client) => {
|
|
986
|
+
const mailboxes = await client.list();
|
|
987
|
+
const sentPath = resolveMailboxPath(mailboxes, "sent");
|
|
988
|
+
await client.mailboxOpen(sentPath);
|
|
989
|
+
const { search, fetchLimit } = buildSentSearch(query);
|
|
990
|
+
const matches = await client.search(search, { uid: true });
|
|
991
|
+
const uids = Array.isArray(matches) ? matches.slice(-fetchLimit).reverse() : [];
|
|
992
|
+
const normalized = await fetchUidThreads(account, client, sentPath, uids);
|
|
993
|
+
const persisted = persistThreads(account, context, normalized);
|
|
994
|
+
const summarized = await summarizeAndPersistThreads(persisted, context.config, context.db, context.db.getAccountIdentity(account));
|
|
995
|
+
return filterSentMessagesForQuery(summarized.flatMap((thread) => thread.messages.map((message) => toPublicSentMessage(thread, message))), query);
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
function mailboxExists(mailboxes, path) {
|
|
999
|
+
return mailboxes.some((mailbox) => mailbox.path === path);
|
|
1000
|
+
}
|
|
1001
|
+
async function findAppendedThreadByMessageId(account, client, mailboxPath, messageId) {
|
|
1002
|
+
await client.mailboxOpen(mailboxPath);
|
|
1003
|
+
const matches = await client.search({ header: { "message-id": messageId } }, { uid: true });
|
|
1004
|
+
const uids = Array.isArray(matches) ? matches.slice(-1).reverse() : [];
|
|
1005
|
+
const [thread] = await fetchUidThreads(account, client, mailboxPath, uids);
|
|
1006
|
+
return thread ?? null;
|
|
1007
|
+
}
|
|
1008
|
+
function persistMailboxThreadRefs(account, context, thread) {
|
|
1009
|
+
if (!thread) {
|
|
1010
|
+
return null;
|
|
1011
|
+
}
|
|
1012
|
+
const [persisted] = persistThreads(account, context, [thread]);
|
|
1013
|
+
return persisted
|
|
1014
|
+
? {
|
|
1015
|
+
thread_ref: persisted.thread_ref,
|
|
1016
|
+
message_ref: persisted.messages[0]?.message_ref ?? null,
|
|
1017
|
+
}
|
|
1018
|
+
: null;
|
|
1019
|
+
}
|
|
1020
|
+
async function appendMimeToMailbox(account, context, mailboxAlias, rawMime, messageId, flags) {
|
|
1021
|
+
return withImapClient(account, context, async (client) => {
|
|
1022
|
+
const mailboxes = await client.list();
|
|
1023
|
+
const mailboxPath = resolveMailboxPath(mailboxes, mailboxAlias);
|
|
1024
|
+
if (!mailboxExists(mailboxes, mailboxPath)) {
|
|
1025
|
+
throw new SurfaceError("unsupported", `This IMAP account does not expose a ${mailboxAlias} mailbox.`, {
|
|
1026
|
+
account: account.name,
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
const appended = await client.append(mailboxPath, Buffer.from(rawMime, "utf8"), flags);
|
|
1030
|
+
let thread = null;
|
|
1031
|
+
if (appended && typeof appended.uid === "number") {
|
|
1032
|
+
const [fetched] = await fetchUidThreads(account, client, mailboxPath, [appended.uid]);
|
|
1033
|
+
thread = fetched ?? null;
|
|
1034
|
+
}
|
|
1035
|
+
thread ??= await findAppendedThreadByMessageId(account, client, mailboxPath, messageId);
|
|
1036
|
+
return persistMailboxThreadRefs(account, context, thread);
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
async function findMailboxMessageRefsByMessageId(account, context, mailboxAlias, messageId, options = {}) {
|
|
1040
|
+
const attempts = Math.max(1, options.attempts ?? 1);
|
|
1041
|
+
const delayMs = Math.max(0, options.delayMs ?? 0);
|
|
1042
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1043
|
+
const refs = await withImapClient(account, context, async (client) => {
|
|
1044
|
+
const mailboxes = await client.list();
|
|
1045
|
+
const mailboxPath = resolveMailboxPath(mailboxes, mailboxAlias);
|
|
1046
|
+
if (!mailboxExists(mailboxes, mailboxPath)) {
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
1049
|
+
return persistMailboxThreadRefs(account, context, await findAppendedThreadByMessageId(account, client, mailboxPath, messageId));
|
|
1050
|
+
});
|
|
1051
|
+
if (refs || attempt === attempts - 1) {
|
|
1052
|
+
return refs;
|
|
1053
|
+
}
|
|
1054
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1055
|
+
}
|
|
1056
|
+
return null;
|
|
1057
|
+
}
|
|
1058
|
+
const DRAFT_APPEND_FLAGS = ["\\Draft", "\\Seen"];
|
|
1059
|
+
const SENT_APPEND_FLAGS = ["\\Seen"];
|
|
1060
|
+
function buildComposeRaw(input) {
|
|
1061
|
+
const base = {
|
|
1062
|
+
from: input.account.email,
|
|
1063
|
+
to: input.recipients.to,
|
|
1064
|
+
cc: input.recipients.cc,
|
|
1065
|
+
bcc: input.recipients.bcc,
|
|
1066
|
+
subject: input.subject,
|
|
1067
|
+
body: input.body,
|
|
1068
|
+
messageId: input.messageId,
|
|
1069
|
+
inReplyTo: input.inReplyTo,
|
|
1070
|
+
references: input.references,
|
|
1071
|
+
attachments: input.attachments,
|
|
1072
|
+
};
|
|
1073
|
+
return {
|
|
1074
|
+
deliveryRaw: buildRawMimeMessage({ ...base, includeBccHeader: false }),
|
|
1075
|
+
storedRaw: buildRawMimeMessage({ ...base, includeBccHeader: true }),
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
async function sendRawSmtpMessage(account, context, rawMime, recipients) {
|
|
1079
|
+
const envelopeRecipients = [...recipients.to, ...recipients.cc, ...recipients.bcc];
|
|
1080
|
+
const message = {
|
|
1081
|
+
raw: rawMime,
|
|
1082
|
+
envelope: {
|
|
1083
|
+
from: account.email,
|
|
1084
|
+
to: envelopeRecipients,
|
|
1085
|
+
},
|
|
1086
|
+
};
|
|
1087
|
+
await withSmtpTransport(account, context, async (transport) => {
|
|
1088
|
+
await transport.sendMail(message);
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
async function sendOrDraftImapSmtpMessage(input) {
|
|
1092
|
+
const messageId = makeSmtpMessageId(input.account);
|
|
1093
|
+
const raw = buildComposeRaw({
|
|
1094
|
+
account: input.account,
|
|
1095
|
+
recipients: input.recipients,
|
|
1096
|
+
subject: input.subject,
|
|
1097
|
+
body: input.body,
|
|
1098
|
+
messageId,
|
|
1099
|
+
inReplyTo: input.inReplyTo,
|
|
1100
|
+
references: input.references,
|
|
1101
|
+
attachments: input.attachments,
|
|
1102
|
+
});
|
|
1103
|
+
if (input.draft) {
|
|
1104
|
+
const refs = await appendMimeToMailbox(input.account, input.context, "drafts", raw.storedRaw, messageId, DRAFT_APPEND_FLAGS);
|
|
1105
|
+
if (!refs) {
|
|
1106
|
+
throw new SurfaceError("transport_error", "IMAP draft append succeeded but the appended draft could not be resolved.", {
|
|
1107
|
+
account: input.account.name,
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
return { status: "drafted", refs };
|
|
1111
|
+
}
|
|
1112
|
+
await sendRawSmtpMessage(input.account, input.context, raw.deliveryRaw, input.recipients);
|
|
1113
|
+
const providerFiledRefs = await findMailboxMessageRefsByMessageId(input.account, input.context, "sent", messageId, {
|
|
1114
|
+
attempts: 3,
|
|
1115
|
+
delayMs: 1_000,
|
|
1116
|
+
}).catch(() => null);
|
|
1117
|
+
const refs = providerFiledRefs ?? await appendMimeToMailbox(input.account, input.context, "sent", raw.storedRaw, messageId, SENT_APPEND_FLAGS).catch(() => null);
|
|
1118
|
+
return { status: "sent", refs: refs ?? { thread_ref: null, message_ref: null } };
|
|
1119
|
+
}
|
|
1120
|
+
async function resolveComposeTarget(account, context, messageRef) {
|
|
1121
|
+
const stored = requireMessageForAccount(account, messageRef, context);
|
|
1122
|
+
const locatorRow = context.db.findProviderLocator("message", messageRef);
|
|
1123
|
+
if (!locatorRow) {
|
|
1124
|
+
throw new SurfaceError("cache_miss", `No provider locator exists for message '${messageRef}'.`, {
|
|
1125
|
+
account: account.name,
|
|
1126
|
+
messageRef,
|
|
1127
|
+
threadRef: stored.thread_ref,
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
const locator = parseMessageLocator(locatorRow.locator_json);
|
|
1131
|
+
const parsed = await simpleParser(await readMessageSource(account, context, locator));
|
|
1132
|
+
return {
|
|
1133
|
+
stored,
|
|
1134
|
+
parsed,
|
|
1135
|
+
messageId: parsed.messageId ?? locator.message_id,
|
|
1136
|
+
references: referencesToString(parsed.references) ?? locator.references,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
function emailsFromAddressObject(value) {
|
|
1140
|
+
return addressesFromObject(value)
|
|
1141
|
+
.map((address) => address.email)
|
|
1142
|
+
.filter(Boolean);
|
|
1143
|
+
}
|
|
1144
|
+
function withoutAccountEmails(emails, selfEmails) {
|
|
1145
|
+
return emails.filter((email) => !selfEmails.has(normalizeComparableEmail(email)));
|
|
1146
|
+
}
|
|
1147
|
+
function buildReplyRecipients(input) {
|
|
1148
|
+
let to = normalizeEmailList(withoutAccountEmails(input.replyTo, input.selfEmails));
|
|
1149
|
+
if (to.length === 0) {
|
|
1150
|
+
to = normalizeEmailList(withoutAccountEmails(input.from, input.selfEmails));
|
|
1151
|
+
}
|
|
1152
|
+
if (to.length === 0) {
|
|
1153
|
+
to = normalizeEmailList(withoutAccountEmails(input.originalTo, input.selfEmails));
|
|
1154
|
+
}
|
|
1155
|
+
return {
|
|
1156
|
+
to,
|
|
1157
|
+
cc: normalizeEmailList(input.inputCc),
|
|
1158
|
+
bcc: normalizeEmailList(input.inputBcc),
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
function buildReplyAllRecipients(input) {
|
|
1162
|
+
let to = normalizeEmailList(withoutAccountEmails(input.replyTo, input.selfEmails));
|
|
1163
|
+
if (to.length === 0) {
|
|
1164
|
+
to = normalizeEmailList(withoutAccountEmails(input.from, input.selfEmails));
|
|
1165
|
+
}
|
|
1166
|
+
if (to.length === 0) {
|
|
1167
|
+
to = normalizeEmailList(withoutAccountEmails(input.originalTo, input.selfEmails));
|
|
1168
|
+
}
|
|
1169
|
+
if (to.length === 0) {
|
|
1170
|
+
to = normalizeEmailList(input.from);
|
|
1171
|
+
}
|
|
1172
|
+
const toComparable = new Set(to.map(normalizeComparableEmail));
|
|
1173
|
+
const cc = normalizeEmailList([
|
|
1174
|
+
...withoutAccountEmails(input.originalTo, input.selfEmails).filter((email) => !toComparable.has(normalizeComparableEmail(email))),
|
|
1175
|
+
...withoutAccountEmails(input.originalCc, input.selfEmails).filter((email) => !toComparable.has(normalizeComparableEmail(email))),
|
|
1176
|
+
...input.inputCc,
|
|
1177
|
+
]);
|
|
1178
|
+
return {
|
|
1179
|
+
to,
|
|
1180
|
+
cc,
|
|
1181
|
+
bcc: normalizeEmailList(input.inputBcc),
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
function replyReferences(target) {
|
|
1185
|
+
return target.references
|
|
1186
|
+
? `${target.references}${target.messageId ? ` ${target.messageId}` : ""}`.trim()
|
|
1187
|
+
: target.messageId;
|
|
1188
|
+
}
|
|
1189
|
+
function sanitizeAttachmentFilename(filename) {
|
|
1190
|
+
const sanitized = filename.replace(/[\\/:*?"<>|\u0000-\u001F]/gu, "_").trim();
|
|
1191
|
+
return sanitized || "attachment";
|
|
1192
|
+
}
|
|
1193
|
+
async function readMessageSource(account, context, locator) {
|
|
1194
|
+
return withImapClient(account, context, async (client) => {
|
|
1195
|
+
const mailbox = await client.mailboxOpen(locator.mailbox);
|
|
1196
|
+
if (uidValidityString(mailbox) !== locator.uid_validity) {
|
|
1197
|
+
throw new SurfaceError("cache_miss", `IMAP UIDVALIDITY changed for mailbox '${locator.mailbox}'.`, {
|
|
1198
|
+
account: account.name,
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
const fetched = await client.fetchOne(String(locator.uid), { source: true }, { uid: true });
|
|
1202
|
+
if (!fetched || !fetched.source) {
|
|
1203
|
+
throw new SurfaceError("not_found", `IMAP message '${locator.uid}' was not found in '${locator.mailbox}'.`, {
|
|
1204
|
+
account: account.name,
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
return fetched.source;
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
async function mutateSeenFlag(account, context, messageRefs, unread) {
|
|
1211
|
+
const updated = [];
|
|
1212
|
+
const touchedThreads = new Set();
|
|
1213
|
+
await withImapClient(account, context, async (client) => {
|
|
1214
|
+
const targets = messageRefs.map((messageRef) => {
|
|
1215
|
+
const stored = requireMessageForAccount(account, messageRef, context);
|
|
1216
|
+
const locatorRow = context.db.findProviderLocator("message", messageRef);
|
|
1217
|
+
if (!locatorRow) {
|
|
1218
|
+
throw new SurfaceError("cache_miss", `No provider locator exists for message '${messageRef}'.`, {
|
|
1219
|
+
account: account.name,
|
|
1220
|
+
messageRef,
|
|
1221
|
+
threadRef: stored.thread_ref,
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
return { messageRef, stored, locator: parseMessageLocator(locatorRow.locator_json) };
|
|
1225
|
+
});
|
|
1226
|
+
const byMailbox = new Map();
|
|
1227
|
+
for (const target of targets) {
|
|
1228
|
+
byMailbox.set(target.locator.mailbox, [...(byMailbox.get(target.locator.mailbox) ?? []), target]);
|
|
1229
|
+
}
|
|
1230
|
+
for (const [mailboxPath, mailboxTargets] of byMailbox) {
|
|
1231
|
+
const mailbox = await client.mailboxOpen(mailboxPath);
|
|
1232
|
+
for (const target of mailboxTargets) {
|
|
1233
|
+
if (uidValidityString(mailbox) !== target.locator.uid_validity) {
|
|
1234
|
+
throw new SurfaceError("cache_miss", `IMAP UIDVALIDITY changed for mailbox '${mailboxPath}'.`, {
|
|
1235
|
+
account: account.name,
|
|
1236
|
+
messageRef: target.messageRef,
|
|
1237
|
+
threadRef: target.stored.thread_ref,
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
const uids = mailboxTargets.map((target) => target.locator.uid);
|
|
1242
|
+
if (unread) {
|
|
1243
|
+
await client.messageFlagsRemove(uids, ["\\Seen"], { uid: true });
|
|
1244
|
+
}
|
|
1245
|
+
else {
|
|
1246
|
+
await client.messageFlagsAdd(uids, ["\\Seen"], { uid: true });
|
|
1247
|
+
}
|
|
1248
|
+
for (const target of mailboxTargets) {
|
|
1249
|
+
updated.push({
|
|
1250
|
+
message_ref: target.messageRef,
|
|
1251
|
+
thread_ref: target.stored.thread_ref,
|
|
1252
|
+
unread,
|
|
1253
|
+
});
|
|
1254
|
+
touchedThreads.add(target.stored.thread_ref);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
context.db.updateMessagesUnreadState(messageRefs, unread);
|
|
1259
|
+
context.db.recomputeThreadUnreadCounts([...touchedThreads]);
|
|
1260
|
+
return updated;
|
|
1261
|
+
}
|
|
1262
|
+
export const imapAdapterTestHooks = {
|
|
1263
|
+
archivedThreadMatchesStoredMessage,
|
|
1264
|
+
buildReplyAllRecipients,
|
|
1265
|
+
buildReplyRecipients,
|
|
1266
|
+
buildComposeRaw,
|
|
1267
|
+
buildSentSearch,
|
|
1268
|
+
draftAppendFlags: DRAFT_APPEND_FLAGS,
|
|
1269
|
+
filterSentMessagesForQuery,
|
|
1270
|
+
imapAttachmentProviderKey,
|
|
1271
|
+
replyReferences,
|
|
1272
|
+
resolveMailboxPath,
|
|
1273
|
+
sentAppendFlags: SENT_APPEND_FLAGS,
|
|
1274
|
+
};
|
|
1275
|
+
export class ImapSmtpAdapter {
|
|
1276
|
+
provider = "imap";
|
|
1277
|
+
transport = "imap-smtp";
|
|
1278
|
+
async login(account, context) {
|
|
1279
|
+
if (!context.authLoginOptions) {
|
|
1280
|
+
throw new SurfaceError("invalid_argument", "Missing IMAP/SMTP auth login options.", {
|
|
1281
|
+
account: account.name,
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
const state = writeImapSmtpAuthState(account, context, context.authLoginOptions);
|
|
1285
|
+
return {
|
|
1286
|
+
status: "authenticated",
|
|
1287
|
+
detail: `Stored IMAP/SMTP auth for ${state.username} using ${state.imap.host}:${state.imap.port}.`,
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
async logout(_account, context) {
|
|
1291
|
+
clearImapSmtpAuthState(context);
|
|
1292
|
+
return {
|
|
1293
|
+
status: "unauthenticated",
|
|
1294
|
+
detail: "Removed stored IMAP/SMTP auth for this account.",
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
async authStatus(account, context) {
|
|
1298
|
+
try {
|
|
1299
|
+
const state = readImapSmtpAuthState(account, context);
|
|
1300
|
+
return {
|
|
1301
|
+
status: "authenticated",
|
|
1302
|
+
detail: `Stored IMAP/SMTP auth for ${state.username} using ${state.imap.host}:${state.imap.port}.`,
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
catch (error) {
|
|
1306
|
+
if (error instanceof SurfaceError && error.code === "reauth_required") {
|
|
1307
|
+
return {
|
|
1308
|
+
status: "unauthenticated",
|
|
1309
|
+
detail: error.message,
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
throw error;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
async search(account, query, context) {
|
|
1316
|
+
return searchMailbox(account, context, query);
|
|
1317
|
+
}
|
|
1318
|
+
async fetchUnread(account, query, context) {
|
|
1319
|
+
return fetchUnreadMailbox(account, context, query);
|
|
1320
|
+
}
|
|
1321
|
+
async fetchSent(account, query, context) {
|
|
1322
|
+
return fetchSentMessages(account, context, query);
|
|
1323
|
+
}
|
|
1324
|
+
async refreshThread(account, threadRef, context) {
|
|
1325
|
+
const locatorRow = context.db.findProviderLocator("thread", threadRef);
|
|
1326
|
+
if (!locatorRow) {
|
|
1327
|
+
throw new SurfaceError("cache_miss", `No provider locator exists for thread '${threadRef}'.`, {
|
|
1328
|
+
account: account.name,
|
|
1329
|
+
threadRef,
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
await refreshUidThread(account, context, parseThreadLocator(locatorRow.locator_json));
|
|
1333
|
+
}
|
|
1334
|
+
async readMessage(account, messageRef, refresh, context) {
|
|
1335
|
+
const stored = requireMessageForAccount(account, messageRef, context);
|
|
1336
|
+
const attachments = attachmentMetas(context, messageRef);
|
|
1337
|
+
const hasReadableCache = Boolean(stored.body_cache_path && existsSync(stored.body_cache_path));
|
|
1338
|
+
if (!refresh && hasReadableCache) {
|
|
1339
|
+
return buildReadEnvelope(account, messageRef, stored.thread_ref, parseStoredMessage(stored), attachments, "hit");
|
|
1340
|
+
}
|
|
1341
|
+
const refreshed = await refreshStoredMessage(account, messageRef, context);
|
|
1342
|
+
return buildReadEnvelope(account, messageRef, refreshed.thread_ref, parseStoredMessage(refreshed), attachmentMetas(context, messageRef), hasReadableCache ? "refreshed" : "miss");
|
|
1343
|
+
}
|
|
1344
|
+
async listAttachments(account, messageRef, context) {
|
|
1345
|
+
requireMessageForAccount(account, messageRef, context);
|
|
1346
|
+
return {
|
|
1347
|
+
schema_version: "1",
|
|
1348
|
+
command: "attachment-list",
|
|
1349
|
+
account: account.name,
|
|
1350
|
+
message_ref: messageRef,
|
|
1351
|
+
attachments: attachmentMetas(context, messageRef),
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
async downloadAttachment(account, messageRef, attachmentId, context) {
|
|
1355
|
+
requireMessageForAccount(account, messageRef, context);
|
|
1356
|
+
const attachment = context.db.listAttachmentsForMessage(messageRef).find((entry) => entry.attachment_id === attachmentId);
|
|
1357
|
+
if (!attachment) {
|
|
1358
|
+
throw new SurfaceError("not_found", `Attachment '${attachmentId}' was not found for message '${messageRef}'.`, {
|
|
1359
|
+
account: account.name,
|
|
1360
|
+
messageRef,
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
const messageLocatorRow = context.db.findProviderLocator("message", messageRef);
|
|
1364
|
+
const attachmentLocatorRow = context.db.findProviderLocator("attachment", attachmentId);
|
|
1365
|
+
if (!messageLocatorRow || !attachmentLocatorRow) {
|
|
1366
|
+
await refreshStoredMessage(account, messageRef, context);
|
|
1367
|
+
}
|
|
1368
|
+
const refreshedMessageLocatorRow = context.db.findProviderLocator("message", messageRef);
|
|
1369
|
+
const refreshedAttachmentLocatorRow = context.db.findProviderLocator("attachment", attachmentId);
|
|
1370
|
+
if (!refreshedMessageLocatorRow || !refreshedAttachmentLocatorRow) {
|
|
1371
|
+
throw new SurfaceError("cache_miss", `No provider locator exists for attachment '${attachmentId}'.`, {
|
|
1372
|
+
account: account.name,
|
|
1373
|
+
messageRef,
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
const messageLocator = parseMessageLocator(refreshedMessageLocatorRow.locator_json);
|
|
1377
|
+
const attachmentLocator = parseAttachmentLocator(refreshedAttachmentLocatorRow.locator_json);
|
|
1378
|
+
const parsed = await simpleParser(await readMessageSource(account, context, messageLocator));
|
|
1379
|
+
const matched = parsed.attachments.find((candidate, index) => (attachmentLocator.checksum && candidate.checksum === attachmentLocator.checksum)
|
|
1380
|
+
|| (attachmentLocator.index === index && (!attachmentLocator.filename || candidate.filename === attachmentLocator.filename)));
|
|
1381
|
+
if (!matched) {
|
|
1382
|
+
throw new SurfaceError("not_found", `Attachment '${attachmentId}' could not be found in IMAP message '${messageRef}'.`, {
|
|
1383
|
+
account: account.name,
|
|
1384
|
+
messageRef,
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
const targetDir = join(context.accountPaths.downloadsDir, messageRef);
|
|
1388
|
+
mkdirSync(targetDir, { recursive: true });
|
|
1389
|
+
const targetPath = join(targetDir, `${attachmentId}__${sanitizeAttachmentFilename(attachment.filename)}`);
|
|
1390
|
+
writeFileSync(targetPath, matched.content);
|
|
1391
|
+
context.db.updateAttachmentSavedTo(attachmentId, targetPath);
|
|
1392
|
+
return {
|
|
1393
|
+
schema_version: "1",
|
|
1394
|
+
command: "attachment-download",
|
|
1395
|
+
account: account.name,
|
|
1396
|
+
message_ref: messageRef,
|
|
1397
|
+
attachment: {
|
|
1398
|
+
attachment_id: attachmentId,
|
|
1399
|
+
filename: attachment.filename,
|
|
1400
|
+
mime_type: attachment.mime_type,
|
|
1401
|
+
size_bytes: attachment.size_bytes,
|
|
1402
|
+
inline: Boolean(attachment.inline),
|
|
1403
|
+
saved_to: targetPath,
|
|
1404
|
+
},
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
async rsvp(account, messageRef, _response, _context) {
|
|
1408
|
+
throw new SurfaceError("unsupported", `Generic IMAP RSVP is not supported for '${messageRef}'.`, {
|
|
1409
|
+
account: account.name,
|
|
1410
|
+
messageRef,
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
async sendMessage(account, input, context) {
|
|
1414
|
+
const recipients = {
|
|
1415
|
+
to: normalizeEmailList(input.to),
|
|
1416
|
+
cc: normalizeEmailList(input.cc),
|
|
1417
|
+
bcc: normalizeEmailList(input.bcc),
|
|
1418
|
+
};
|
|
1419
|
+
const subject = input.subject.trim();
|
|
1420
|
+
if (recipients.to.length === 0) {
|
|
1421
|
+
throw new SurfaceError("invalid_argument", "IMAP/SMTP send requires at least one --to recipient.", {
|
|
1422
|
+
account: account.name,
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
if (!subject) {
|
|
1426
|
+
throw new SurfaceError("invalid_argument", "IMAP/SMTP send requires a non-empty --subject.", {
|
|
1427
|
+
account: account.name,
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
assertWriteAllowed(context.config, account, recipients, {
|
|
1431
|
+
disposition: input.draft ? "draft" : "send",
|
|
1432
|
+
});
|
|
1433
|
+
const result = await sendOrDraftImapSmtpMessage({
|
|
1434
|
+
account,
|
|
1435
|
+
context,
|
|
1436
|
+
recipients,
|
|
1437
|
+
subject,
|
|
1438
|
+
body: input.body,
|
|
1439
|
+
draft: input.draft,
|
|
1440
|
+
attachments: input.attachments,
|
|
1441
|
+
});
|
|
1442
|
+
return buildSendEnvelope(account, "send", result.status, subject, recipientsFromInput(recipients), result.refs, null, composeAttachmentMetas(input.attachments));
|
|
1443
|
+
}
|
|
1444
|
+
async reply(account, messageRef, input, context) {
|
|
1445
|
+
const target = await resolveComposeTarget(account, context, messageRef);
|
|
1446
|
+
const selfEmails = accountIdentityEmails(account, context);
|
|
1447
|
+
const replyTo = emailsFromAddressObject(target.parsed.replyTo);
|
|
1448
|
+
const from = emailsFromAddressObject(target.parsed.from);
|
|
1449
|
+
const recipients = buildReplyRecipients({
|
|
1450
|
+
replyTo,
|
|
1451
|
+
from,
|
|
1452
|
+
originalTo: emailsFromAddressObject(target.parsed.to),
|
|
1453
|
+
inputCc: input.cc,
|
|
1454
|
+
inputBcc: input.bcc,
|
|
1455
|
+
selfEmails,
|
|
1456
|
+
});
|
|
1457
|
+
if (recipients.to.length === 0) {
|
|
1458
|
+
throw new SurfaceError("unsupported", `Message '${messageRef}' does not expose a reply target.`, {
|
|
1459
|
+
account: account.name,
|
|
1460
|
+
messageRef,
|
|
1461
|
+
threadRef: target.stored.thread_ref,
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
assertWriteAllowed(context.config, account, recipients, {
|
|
1465
|
+
disposition: input.draft ? "draft" : "send",
|
|
1466
|
+
});
|
|
1467
|
+
const subject = prefixSubject(target.stored.subject ?? target.parsed.subject ?? "", "Re");
|
|
1468
|
+
const result = await sendOrDraftImapSmtpMessage({
|
|
1469
|
+
account,
|
|
1470
|
+
context,
|
|
1471
|
+
recipients,
|
|
1472
|
+
subject,
|
|
1473
|
+
body: buildReplyBody(input.body, target.stored),
|
|
1474
|
+
draft: input.draft,
|
|
1475
|
+
inReplyTo: target.messageId,
|
|
1476
|
+
references: replyReferences(target),
|
|
1477
|
+
});
|
|
1478
|
+
return buildSendEnvelope(account, "reply", result.status, subject, recipientsFromInput(recipients), result.refs, messageRef);
|
|
1479
|
+
}
|
|
1480
|
+
async replyAll(account, messageRef, input, context) {
|
|
1481
|
+
const target = await resolveComposeTarget(account, context, messageRef);
|
|
1482
|
+
const selfEmails = accountIdentityEmails(account, context);
|
|
1483
|
+
const replyTo = emailsFromAddressObject(target.parsed.replyTo);
|
|
1484
|
+
const from = emailsFromAddressObject(target.parsed.from);
|
|
1485
|
+
const originalTo = emailsFromAddressObject(target.parsed.to);
|
|
1486
|
+
const originalCc = emailsFromAddressObject(target.parsed.cc);
|
|
1487
|
+
const recipients = buildReplyAllRecipients({
|
|
1488
|
+
replyTo,
|
|
1489
|
+
from,
|
|
1490
|
+
originalTo,
|
|
1491
|
+
originalCc,
|
|
1492
|
+
inputCc: input.cc,
|
|
1493
|
+
inputBcc: input.bcc,
|
|
1494
|
+
selfEmails,
|
|
1495
|
+
});
|
|
1496
|
+
if (recipients.to.length === 0) {
|
|
1497
|
+
throw new SurfaceError("unsupported", `Message '${messageRef}' does not expose reply-all recipients.`, {
|
|
1498
|
+
account: account.name,
|
|
1499
|
+
messageRef,
|
|
1500
|
+
threadRef: target.stored.thread_ref,
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
assertWriteAllowed(context.config, account, recipients, {
|
|
1504
|
+
disposition: input.draft ? "draft" : "send",
|
|
1505
|
+
});
|
|
1506
|
+
const subject = prefixSubject(target.stored.subject ?? target.parsed.subject ?? "", "Re");
|
|
1507
|
+
const result = await sendOrDraftImapSmtpMessage({
|
|
1508
|
+
account,
|
|
1509
|
+
context,
|
|
1510
|
+
recipients,
|
|
1511
|
+
subject,
|
|
1512
|
+
body: buildReplyBody(input.body, target.stored),
|
|
1513
|
+
draft: input.draft,
|
|
1514
|
+
inReplyTo: target.messageId,
|
|
1515
|
+
references: replyReferences(target),
|
|
1516
|
+
});
|
|
1517
|
+
return buildSendEnvelope(account, "reply-all", result.status, subject, recipientsFromInput(recipients), result.refs, messageRef);
|
|
1518
|
+
}
|
|
1519
|
+
async forward(account, messageRef, input, context) {
|
|
1520
|
+
const target = await resolveComposeTarget(account, context, messageRef);
|
|
1521
|
+
const recipients = {
|
|
1522
|
+
to: normalizeEmailList(input.to),
|
|
1523
|
+
cc: normalizeEmailList(input.cc),
|
|
1524
|
+
bcc: normalizeEmailList(input.bcc),
|
|
1525
|
+
};
|
|
1526
|
+
if (recipients.to.length === 0) {
|
|
1527
|
+
throw new SurfaceError("invalid_argument", "IMAP/SMTP forward requires at least one --to recipient.", {
|
|
1528
|
+
account: account.name,
|
|
1529
|
+
messageRef,
|
|
1530
|
+
threadRef: target.stored.thread_ref,
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
assertWriteAllowed(context.config, account, recipients, {
|
|
1534
|
+
disposition: input.draft ? "draft" : "send",
|
|
1535
|
+
});
|
|
1536
|
+
const subject = prefixSubject(target.stored.subject ?? target.parsed.subject ?? "", "Fwd");
|
|
1537
|
+
const result = await sendOrDraftImapSmtpMessage({
|
|
1538
|
+
account,
|
|
1539
|
+
context,
|
|
1540
|
+
recipients,
|
|
1541
|
+
subject,
|
|
1542
|
+
body: buildForwardBody(input.body, target.stored),
|
|
1543
|
+
draft: input.draft,
|
|
1544
|
+
});
|
|
1545
|
+
return buildSendEnvelope(account, "forward", result.status, subject, recipientsFromInput(recipients), result.refs, messageRef);
|
|
1546
|
+
}
|
|
1547
|
+
async archive(account, messageRef, context) {
|
|
1548
|
+
assertWriteAllowed(context.config, account, { to: [], cc: [], bcc: [] }, { disposition: "non_send" });
|
|
1549
|
+
const stored = requireMessageForAccount(account, messageRef, context);
|
|
1550
|
+
const locatorRow = context.db.findProviderLocator("message", messageRef);
|
|
1551
|
+
if (!locatorRow) {
|
|
1552
|
+
throw new SurfaceError("cache_miss", `No provider locator exists for message '${messageRef}'.`, {
|
|
1553
|
+
account: account.name,
|
|
1554
|
+
messageRef,
|
|
1555
|
+
threadRef: stored.thread_ref,
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
const locator = parseMessageLocator(locatorRow.locator_json);
|
|
1559
|
+
await withImapClient(account, context, async (client) => {
|
|
1560
|
+
const mailboxes = await client.list();
|
|
1561
|
+
const archivePath = resolveMailboxPath(mailboxes, "archive");
|
|
1562
|
+
const archiveExists = mailboxes.some((mailbox) => mailbox.path === archivePath);
|
|
1563
|
+
if (!archiveExists) {
|
|
1564
|
+
throw new SurfaceError("unsupported", "This IMAP account does not expose an archive mailbox.", {
|
|
1565
|
+
account: account.name,
|
|
1566
|
+
messageRef,
|
|
1567
|
+
threadRef: stored.thread_ref,
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
const mailbox = await client.mailboxOpen(locator.mailbox);
|
|
1571
|
+
if (uidValidityString(mailbox) !== locator.uid_validity) {
|
|
1572
|
+
throw new SurfaceError("cache_miss", `IMAP UIDVALIDITY changed for mailbox '${locator.mailbox}'.`, {
|
|
1573
|
+
account: account.name,
|
|
1574
|
+
messageRef,
|
|
1575
|
+
threadRef: stored.thread_ref,
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
const moveResult = await client.messageMove(String(locator.uid), archivePath, { uid: true });
|
|
1579
|
+
const newUid = moveResult && moveResult.uidMap ? moveResult.uidMap.get(locator.uid) : undefined;
|
|
1580
|
+
const refreshedThread = await recoverArchivedThreadAfterMove(account, client, archivePath, stored, locator, newUid);
|
|
1581
|
+
const refreshedMessage = refreshedThread?.messages[0];
|
|
1582
|
+
const refreshedThreadLocator = refreshedThread?.locator?.locator;
|
|
1583
|
+
const refreshedMessageLocator = refreshedMessage?.locator?.locator;
|
|
1584
|
+
const [persisted] = refreshedThread && refreshedMessage && refreshedThreadLocator && refreshedMessageLocator
|
|
1585
|
+
? persistThreads(account, context, [refreshedThread], {
|
|
1586
|
+
threadRefsByProviderKey: new Map([[imapThreadProviderKey(refreshedThreadLocator), stored.thread_ref]]),
|
|
1587
|
+
messageRefsByProviderKey: new Map([[imapMessageProviderKey(refreshedMessageLocator), messageRef]]),
|
|
1588
|
+
attachmentRefsByProviderKey: buildAttachmentRefOverrides(context, messageRef, refreshedMessageLocator, refreshedMessage.attachments),
|
|
1589
|
+
})
|
|
1590
|
+
: [];
|
|
1591
|
+
if (!persisted) {
|
|
1592
|
+
context.db.markThreadArchived(stored.thread_ref);
|
|
1593
|
+
}
|
|
1594
|
+
});
|
|
1595
|
+
return buildArchiveEnvelope(account, messageRef, stored.thread_ref);
|
|
1596
|
+
}
|
|
1597
|
+
async markRead(account, messageRefs, context) {
|
|
1598
|
+
assertWriteAllowed(context.config, account, { to: [], cc: [], bcc: [] }, { disposition: "non_send" });
|
|
1599
|
+
return buildMarkMessagesEnvelope(account, "mark-read", await mutateSeenFlag(account, context, messageRefs, false));
|
|
1600
|
+
}
|
|
1601
|
+
async markUnread(account, messageRefs, context) {
|
|
1602
|
+
assertWriteAllowed(context.config, account, { to: [], cc: [], bcc: [] }, { disposition: "non_send" });
|
|
1603
|
+
return buildMarkMessagesEnvelope(account, "mark-unread", await mutateSeenFlag(account, context, messageRefs, true));
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
//# sourceMappingURL=adapter.js.map
|