surface-cli 0.1.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/README.md +307 -0
- package/dist/cli.js +521 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.js +156 -0
- package/dist/config.js.map +1 -0
- package/dist/contracts/account.js +9 -0
- package/dist/contracts/account.js.map +1 -0
- package/dist/contracts/mail.js +2 -0
- package/dist/contracts/mail.js.map +1 -0
- package/dist/e2e/gmail-v1.js +247 -0
- package/dist/e2e/gmail-v1.js.map +1 -0
- package/dist/e2e/outlook-v1.js +179 -0
- package/dist/e2e/outlook-v1.js.map +1 -0
- package/dist/lib/errors.js +47 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/json.js +4 -0
- package/dist/lib/json.js.map +1 -0
- package/dist/lib/public-mail.js +29 -0
- package/dist/lib/public-mail.js.map +1 -0
- package/dist/lib/remote-auth.js +407 -0
- package/dist/lib/remote-auth.js.map +1 -0
- package/dist/lib/time.js +4 -0
- package/dist/lib/time.js.map +1 -0
- package/dist/lib/write-safety.js +34 -0
- package/dist/lib/write-safety.js.map +1 -0
- package/dist/paths.js +29 -0
- package/dist/paths.js.map +1 -0
- package/dist/providers/gmail/adapter.js +1102 -0
- package/dist/providers/gmail/adapter.js.map +1 -0
- package/dist/providers/gmail/api.js +99 -0
- package/dist/providers/gmail/api.js.map +1 -0
- package/dist/providers/gmail/normalize.js +336 -0
- package/dist/providers/gmail/normalize.js.map +1 -0
- package/dist/providers/gmail/oauth.js +328 -0
- package/dist/providers/gmail/oauth.js.map +1 -0
- package/dist/providers/index.js +12 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/outlook/adapter.js +1443 -0
- package/dist/providers/outlook/adapter.js.map +1 -0
- package/dist/providers/outlook/extract.js +416 -0
- package/dist/providers/outlook/extract.js.map +1 -0
- package/dist/providers/outlook/normalize.js +126 -0
- package/dist/providers/outlook/normalize.js.map +1 -0
- package/dist/providers/outlook/session.js +178 -0
- package/dist/providers/outlook/session.js.map +1 -0
- package/dist/providers/shared/html.js +88 -0
- package/dist/providers/shared/html.js.map +1 -0
- package/dist/providers/shared/types.js +2 -0
- package/dist/providers/shared/types.js.map +1 -0
- package/dist/providers/types.js +2 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/refs.js +18 -0
- package/dist/refs.js.map +1 -0
- package/dist/runtime.js +23 -0
- package/dist/runtime.js.map +1 -0
- package/dist/state/database.js +731 -0
- package/dist/state/database.js.map +1 -0
- package/dist/summarizer.js +217 -0
- package/dist/summarizer.js.map +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { SurfaceError, notImplemented } from "../../lib/errors.js";
|
|
5
|
+
import { assertWriteAllowed } from "../../lib/write-safety.js";
|
|
6
|
+
import { makeAttachmentId, makeMessageRef, makeThreadRef } from "../../refs.js";
|
|
7
|
+
import { summarizeThread } from "../../summarizer.js";
|
|
8
|
+
import { createGmailDraft, downloadGmailAttachmentBytes, getGmailThread, listGmailThreads, modifyGmailMessage, modifyGmailThread, sendGmailRawMessage, } from "./api.js";
|
|
9
|
+
import { clearGmailAuthState, gmailAuthStatus, runGmailLogin } from "./oauth.js";
|
|
10
|
+
import { decodeBase64UrlBytes, decodePartData, headerDateToIso, headerIndex, internalDateToIso, isCalendarPart, iterParts, normalizeGmailBody, parseCalendarInvite, parseMailbox, parseMailboxes, } from "./normalize.js";
|
|
11
|
+
function sourceInfo(account) {
|
|
12
|
+
return {
|
|
13
|
+
provider: account.provider,
|
|
14
|
+
transport: account.transport,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function gmailThreadProviderKey(threadId) {
|
|
18
|
+
return `gmail-thread:${threadId}`;
|
|
19
|
+
}
|
|
20
|
+
function gmailMessageProviderKey(messageId) {
|
|
21
|
+
return `gmail-message:${messageId}`;
|
|
22
|
+
}
|
|
23
|
+
function gmailAttachmentProviderKey(messageId, attachment, index) {
|
|
24
|
+
const attachmentId = attachment.locator?.locator.attachment_id;
|
|
25
|
+
return typeof attachmentId === "string" && attachmentId
|
|
26
|
+
? `gmail-attachment:${messageId}:${attachmentId}`
|
|
27
|
+
: `gmail-attachment:${messageId}:${attachment.filename}:${index}`;
|
|
28
|
+
}
|
|
29
|
+
function uniqueParticipants(messages) {
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
const participants = [];
|
|
32
|
+
const push = (role, mailbox) => {
|
|
33
|
+
if (!mailbox || (!mailbox.email && !mailbox.name)) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const key = `${role}:${mailbox.email}:${mailbox.name}`;
|
|
37
|
+
if (seen.has(key)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
seen.add(key);
|
|
41
|
+
participants.push({
|
|
42
|
+
role,
|
|
43
|
+
name: mailbox.name,
|
|
44
|
+
email: mailbox.email,
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
for (const message of messages) {
|
|
48
|
+
push("from", message.from);
|
|
49
|
+
for (const mailbox of message.to) {
|
|
50
|
+
push("to", mailbox);
|
|
51
|
+
}
|
|
52
|
+
for (const mailbox of message.cc) {
|
|
53
|
+
push("cc", mailbox);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return participants;
|
|
57
|
+
}
|
|
58
|
+
function normalizeLabel(label) {
|
|
59
|
+
return label.trim().toLowerCase();
|
|
60
|
+
}
|
|
61
|
+
function gmailMailbox(labels) {
|
|
62
|
+
if (labels.includes("DRAFT")) {
|
|
63
|
+
return "drafts";
|
|
64
|
+
}
|
|
65
|
+
if (labels.includes("SENT")) {
|
|
66
|
+
return "sent";
|
|
67
|
+
}
|
|
68
|
+
if (labels.includes("TRASH")) {
|
|
69
|
+
return "trash";
|
|
70
|
+
}
|
|
71
|
+
if (labels.includes("SPAM")) {
|
|
72
|
+
return "spam";
|
|
73
|
+
}
|
|
74
|
+
if (labels.includes("INBOX")) {
|
|
75
|
+
return "inbox";
|
|
76
|
+
}
|
|
77
|
+
return "archive";
|
|
78
|
+
}
|
|
79
|
+
function partSizeBytes(part) {
|
|
80
|
+
if (typeof part.body?.size === "number") {
|
|
81
|
+
return part.body.size;
|
|
82
|
+
}
|
|
83
|
+
if (part.body?.data) {
|
|
84
|
+
return decodeBase64UrlBytes(part.body.data).byteLength;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
function extractAttachmentRecords(message) {
|
|
89
|
+
const messageId = message.id ?? "";
|
|
90
|
+
const attachments = [];
|
|
91
|
+
let index = 0;
|
|
92
|
+
for (const part of iterParts(message.payload)) {
|
|
93
|
+
if (!(part.filename ?? "").trim()) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const attachmentId = part.body?.attachmentId;
|
|
97
|
+
const inlineData = part.body?.data;
|
|
98
|
+
attachments.push({
|
|
99
|
+
attachment_id: "",
|
|
100
|
+
filename: part.filename ?? `attachment-${index + 1}`,
|
|
101
|
+
mime_type: part.mimeType ?? "application/octet-stream",
|
|
102
|
+
size_bytes: partSizeBytes(part),
|
|
103
|
+
inline: Boolean(part.headers && headerIndex(part.headers)["content-disposition"]?.toLowerCase().includes("inline")),
|
|
104
|
+
locator: {
|
|
105
|
+
kind: "attachment",
|
|
106
|
+
locator: {
|
|
107
|
+
message_id: messageId,
|
|
108
|
+
attachment_id: attachmentId ?? null,
|
|
109
|
+
part_id: part.partId ?? null,
|
|
110
|
+
inline_data: inlineData ?? null,
|
|
111
|
+
filename: part.filename ?? null,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
index += 1;
|
|
116
|
+
}
|
|
117
|
+
return attachments;
|
|
118
|
+
}
|
|
119
|
+
async function extractCalendarText(account, context, message) {
|
|
120
|
+
const messageId = message.id ?? "";
|
|
121
|
+
if (!messageId) {
|
|
122
|
+
return "";
|
|
123
|
+
}
|
|
124
|
+
for (const part of iterParts(message.payload)) {
|
|
125
|
+
if (!isCalendarPart(part)) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (part.body?.data) {
|
|
129
|
+
return decodePartData(part);
|
|
130
|
+
}
|
|
131
|
+
if (part.body?.attachmentId) {
|
|
132
|
+
const payload = await downloadGmailAttachmentBytes(account, context, messageId, part.body.attachmentId);
|
|
133
|
+
if (payload.data) {
|
|
134
|
+
return Buffer.from(decodeBase64UrlBytes(payload.data)).toString("utf8");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return "";
|
|
139
|
+
}
|
|
140
|
+
function mapCalendarPartstat(value) {
|
|
141
|
+
const normalized = typeof value === "string" ? value.toUpperCase() : "";
|
|
142
|
+
switch (normalized) {
|
|
143
|
+
case "ACCEPTED":
|
|
144
|
+
return "accept";
|
|
145
|
+
case "DECLINED":
|
|
146
|
+
return "decline";
|
|
147
|
+
case "TENTATIVE":
|
|
148
|
+
return "tentative";
|
|
149
|
+
case "NEEDS-ACTION":
|
|
150
|
+
return "needs_response";
|
|
151
|
+
default:
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function normalizeGmailMessage(account, context, message) {
|
|
156
|
+
const indexedHeaders = headerIndex(message.payload?.headers);
|
|
157
|
+
const subject = indexedHeaders.subject ?? "";
|
|
158
|
+
const from = parseMailbox(indexedHeaders.from);
|
|
159
|
+
const to = parseMailboxes(indexedHeaders.to);
|
|
160
|
+
const cc = parseMailboxes(indexedHeaders.cc);
|
|
161
|
+
const sentAt = headerDateToIso(indexedHeaders.date) ?? internalDateToIso(message.internalDate);
|
|
162
|
+
const receivedAt = internalDateToIso(message.internalDate) ?? headerDateToIso(indexedHeaders.date);
|
|
163
|
+
const unread = (message.labelIds ?? []).includes("UNREAD");
|
|
164
|
+
const body = normalizeGmailBody(message.payload, message.snippet ?? "");
|
|
165
|
+
const attachments = extractAttachmentRecords(message);
|
|
166
|
+
let invite;
|
|
167
|
+
const calendarText = await extractCalendarText(account, context, message);
|
|
168
|
+
if (calendarText) {
|
|
169
|
+
const parsedInvite = parseCalendarInvite(calendarText, {
|
|
170
|
+
mailboxEmail: account.email,
|
|
171
|
+
recipientEmails: [...to, ...cc].map((mailbox) => mailbox.email),
|
|
172
|
+
});
|
|
173
|
+
if (parsedInvite.meeting) {
|
|
174
|
+
invite = {
|
|
175
|
+
is_invite: true,
|
|
176
|
+
rsvp_supported: false,
|
|
177
|
+
response_status: mapCalendarPartstat(parsedInvite.meeting.response_type),
|
|
178
|
+
available_rsvp_responses: [],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
message_ref: "",
|
|
184
|
+
envelope: {
|
|
185
|
+
from,
|
|
186
|
+
to,
|
|
187
|
+
cc,
|
|
188
|
+
sent_at: sentAt,
|
|
189
|
+
received_at: receivedAt,
|
|
190
|
+
unread,
|
|
191
|
+
...(subject ? { subject } : {}),
|
|
192
|
+
},
|
|
193
|
+
snippet: message.snippet ?? body.text.slice(0, 240),
|
|
194
|
+
body: {
|
|
195
|
+
text: body.text,
|
|
196
|
+
truncated: false,
|
|
197
|
+
cached: true,
|
|
198
|
+
cached_bytes: Buffer.byteLength(body.text, "utf8"),
|
|
199
|
+
},
|
|
200
|
+
attachments,
|
|
201
|
+
...(invite ? { invite } : {}),
|
|
202
|
+
provider_ids: {
|
|
203
|
+
...(message.id ? { message_id: message.id } : {}),
|
|
204
|
+
...(indexedHeaders["message-id"] ? { internet_message_id: indexedHeaders["message-id"] } : {}),
|
|
205
|
+
},
|
|
206
|
+
locator: {
|
|
207
|
+
kind: "message",
|
|
208
|
+
locator: {
|
|
209
|
+
thread_id: message.threadId ?? null,
|
|
210
|
+
message_id: message.id ?? null,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
async function normalizeGmailThread(account, context, thread) {
|
|
216
|
+
const normalizedMessages = await Promise.all((thread.messages ?? []).map((message) => normalizeGmailMessage(account, context, message)));
|
|
217
|
+
const messages = normalizedMessages
|
|
218
|
+
.slice()
|
|
219
|
+
.sort((left, right) => Date.parse(right.envelope.received_at ?? right.envelope.sent_at ?? "") - Date.parse(left.envelope.received_at ?? left.envelope.sent_at ?? ""));
|
|
220
|
+
const latestMessage = messages[0] ?? null;
|
|
221
|
+
const unreadCount = messages.filter((message) => message.envelope.unread).length;
|
|
222
|
+
const labels = [...new Set((thread.messages?.flatMap((message) => message.labelIds ?? []) ?? []).map(normalizeLabel))];
|
|
223
|
+
return {
|
|
224
|
+
thread_ref: "",
|
|
225
|
+
source: sourceInfo(account),
|
|
226
|
+
envelope: {
|
|
227
|
+
subject: latestMessage?.envelope.subject ?? "",
|
|
228
|
+
participants: uniqueParticipants(messages.map((message) => message.envelope)),
|
|
229
|
+
mailbox: gmailMailbox(thread.messages?.[0]?.labelIds ?? []),
|
|
230
|
+
labels,
|
|
231
|
+
received_at: latestMessage?.envelope.received_at ?? null,
|
|
232
|
+
message_count: messages.length,
|
|
233
|
+
unread_count: unreadCount,
|
|
234
|
+
has_attachments: messages.some((message) => message.attachments.length > 0),
|
|
235
|
+
},
|
|
236
|
+
summary: null,
|
|
237
|
+
messages,
|
|
238
|
+
provider_ids: {
|
|
239
|
+
...(thread.id ? { thread_id: thread.id } : {}),
|
|
240
|
+
},
|
|
241
|
+
locator: {
|
|
242
|
+
kind: "thread",
|
|
243
|
+
locator: {
|
|
244
|
+
thread_id: thread.id ?? null,
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function parseStoredMessage(record) {
|
|
250
|
+
const envelope = {
|
|
251
|
+
from: record.from_name || record.from_email
|
|
252
|
+
? {
|
|
253
|
+
name: record.from_name ?? "",
|
|
254
|
+
email: record.from_email ?? "",
|
|
255
|
+
}
|
|
256
|
+
: null,
|
|
257
|
+
to: JSON.parse(record.to_json),
|
|
258
|
+
cc: JSON.parse(record.cc_json),
|
|
259
|
+
sent_at: record.sent_at,
|
|
260
|
+
received_at: record.received_at,
|
|
261
|
+
unread: Boolean(record.unread),
|
|
262
|
+
...(record.subject ? { subject: record.subject } : {}),
|
|
263
|
+
};
|
|
264
|
+
return {
|
|
265
|
+
envelope,
|
|
266
|
+
body: {
|
|
267
|
+
text: record.body_cache_path && existsSync(record.body_cache_path) ? readFileSync(record.body_cache_path, "utf8") : "",
|
|
268
|
+
truncated: Boolean(record.body_truncated),
|
|
269
|
+
cached: Boolean(record.body_cached),
|
|
270
|
+
cached_bytes: record.body_cached_bytes,
|
|
271
|
+
},
|
|
272
|
+
invite: record.invite_json ? JSON.parse(record.invite_json) : undefined,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function participantFromEmail(email) {
|
|
276
|
+
return {
|
|
277
|
+
name: email,
|
|
278
|
+
email,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function recipientsFromInput(input) {
|
|
282
|
+
return {
|
|
283
|
+
to: input.to.map(participantFromEmail),
|
|
284
|
+
cc: input.cc.map(participantFromEmail),
|
|
285
|
+
bcc: input.bcc.map(participantFromEmail),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
function normalizeEmailList(values) {
|
|
289
|
+
const deduped = new Set();
|
|
290
|
+
for (const value of values) {
|
|
291
|
+
const normalized = value?.trim();
|
|
292
|
+
if (!normalized) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
deduped.add(normalized);
|
|
296
|
+
}
|
|
297
|
+
return [...deduped];
|
|
298
|
+
}
|
|
299
|
+
function sanitizeHeaderValue(value) {
|
|
300
|
+
return value.replace(/\r?\n/g, " ").trim();
|
|
301
|
+
}
|
|
302
|
+
function prefixSubject(subject, prefix) {
|
|
303
|
+
const normalized = subject.trim();
|
|
304
|
+
if (!normalized) {
|
|
305
|
+
return `${prefix}:`;
|
|
306
|
+
}
|
|
307
|
+
const matcher = prefix === "Re" ? /^re:\s/i : /^(fwd|fw):\s/i;
|
|
308
|
+
return matcher.test(normalized) ? normalized : `${prefix}: ${normalized}`;
|
|
309
|
+
}
|
|
310
|
+
function quoteLines(text) {
|
|
311
|
+
return text
|
|
312
|
+
.replace(/\r\n/g, "\n")
|
|
313
|
+
.split("\n")
|
|
314
|
+
.map((line) => `> ${line}`)
|
|
315
|
+
.join("\n");
|
|
316
|
+
}
|
|
317
|
+
function buildReplyBody(inputBody, stored) {
|
|
318
|
+
const parsed = parseStoredMessage(stored);
|
|
319
|
+
const originalBody = parsed.body.text.trim();
|
|
320
|
+
const originalFrom = parsed.envelope.from?.email ?? parsed.envelope.from?.name ?? "unknown sender";
|
|
321
|
+
const originalDate = parsed.envelope.sent_at ?? parsed.envelope.received_at ?? "unknown time";
|
|
322
|
+
if (!originalBody) {
|
|
323
|
+
return inputBody;
|
|
324
|
+
}
|
|
325
|
+
return `${inputBody}\n\nOn ${originalDate}, ${originalFrom} wrote:\n${quoteLines(originalBody)}`;
|
|
326
|
+
}
|
|
327
|
+
function buildForwardBody(inputBody, stored) {
|
|
328
|
+
const parsed = parseStoredMessage(stored);
|
|
329
|
+
const originalBody = parsed.body.text.trim();
|
|
330
|
+
const lines = [
|
|
331
|
+
inputBody,
|
|
332
|
+
"",
|
|
333
|
+
"---------- Forwarded message ---------",
|
|
334
|
+
`From: ${parsed.envelope.from?.email ?? parsed.envelope.from?.name ?? ""}`,
|
|
335
|
+
`Date: ${parsed.envelope.sent_at ?? parsed.envelope.received_at ?? ""}`,
|
|
336
|
+
`Subject: ${parsed.envelope.subject ?? ""}`,
|
|
337
|
+
`To: ${parsed.envelope.to.map((mailbox) => mailbox.email).join(", ")}`,
|
|
338
|
+
];
|
|
339
|
+
if (parsed.envelope.cc.length > 0) {
|
|
340
|
+
lines.push(`Cc: ${parsed.envelope.cc.map((mailbox) => mailbox.email).join(", ")}`);
|
|
341
|
+
}
|
|
342
|
+
lines.push("", originalBody);
|
|
343
|
+
return lines.join("\n").trim();
|
|
344
|
+
}
|
|
345
|
+
function encodeMimeBase64Url(mime) {
|
|
346
|
+
return Buffer.from(mime, "utf8").toString("base64url");
|
|
347
|
+
}
|
|
348
|
+
function buildRawMimeMessage(input) {
|
|
349
|
+
const lines = [
|
|
350
|
+
`From: ${sanitizeHeaderValue(input.from)}`,
|
|
351
|
+
...(input.to.length > 0 ? [`To: ${input.to.map(sanitizeHeaderValue).join(", ")}`] : []),
|
|
352
|
+
...(input.cc.length > 0 ? [`Cc: ${input.cc.map(sanitizeHeaderValue).join(", ")}`] : []),
|
|
353
|
+
...(input.bcc.length > 0 ? [`Bcc: ${input.bcc.map(sanitizeHeaderValue).join(", ")}`] : []),
|
|
354
|
+
`Subject: ${sanitizeHeaderValue(input.subject)}`,
|
|
355
|
+
...(input.inReplyTo ? [`In-Reply-To: ${sanitizeHeaderValue(input.inReplyTo)}`] : []),
|
|
356
|
+
...(input.references ? [`References: ${sanitizeHeaderValue(input.references)}`] : []),
|
|
357
|
+
"MIME-Version: 1.0",
|
|
358
|
+
'Content-Type: text/plain; charset="UTF-8"',
|
|
359
|
+
"Content-Transfer-Encoding: 8bit",
|
|
360
|
+
"",
|
|
361
|
+
input.body.replace(/\r\n/g, "\n"),
|
|
362
|
+
"",
|
|
363
|
+
];
|
|
364
|
+
return lines.join("\r\n");
|
|
365
|
+
}
|
|
366
|
+
function parseMessageLocator(locatorJson) {
|
|
367
|
+
const parsed = JSON.parse(locatorJson);
|
|
368
|
+
return {
|
|
369
|
+
thread_id: typeof parsed.thread_id === "string" && parsed.thread_id ? parsed.thread_id : null,
|
|
370
|
+
message_id: typeof parsed.message_id === "string" && parsed.message_id ? parsed.message_id : null,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function latestStoredThreadMessage(threadRef, context) {
|
|
374
|
+
const messageRef = context.db.listMessageRefsForThread(threadRef)[0] ?? null;
|
|
375
|
+
return {
|
|
376
|
+
message_ref: messageRef,
|
|
377
|
+
stored: messageRef ? context.db.getStoredMessage(messageRef) ?? null : null,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function buildSendEnvelope(account, command, status, subject, recipients, result, inReplyToMessageRef) {
|
|
381
|
+
return {
|
|
382
|
+
schema_version: "1",
|
|
383
|
+
command,
|
|
384
|
+
account: account.name,
|
|
385
|
+
source: sourceInfo(account),
|
|
386
|
+
status,
|
|
387
|
+
subject,
|
|
388
|
+
recipients,
|
|
389
|
+
thread_ref: result.thread_ref,
|
|
390
|
+
message_ref: result.message_ref,
|
|
391
|
+
in_reply_to_message_ref: inReplyToMessageRef,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function buildArchiveEnvelope(account, messageRef, threadRef) {
|
|
395
|
+
return {
|
|
396
|
+
schema_version: "1",
|
|
397
|
+
command: "archive",
|
|
398
|
+
account: account.name,
|
|
399
|
+
message_ref: messageRef,
|
|
400
|
+
thread_ref: threadRef,
|
|
401
|
+
source: sourceInfo(account),
|
|
402
|
+
status: "archived",
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
function buildMarkMessagesEnvelope(account, command, updated) {
|
|
406
|
+
return {
|
|
407
|
+
schema_version: "1",
|
|
408
|
+
command,
|
|
409
|
+
account: account.name,
|
|
410
|
+
source: sourceInfo(account),
|
|
411
|
+
updated,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
function buildReadEnvelope(account, messageRef, threadRef, parsed, attachments, cacheStatus) {
|
|
415
|
+
return {
|
|
416
|
+
schema_version: "1",
|
|
417
|
+
command: "read",
|
|
418
|
+
account: account.name,
|
|
419
|
+
message_ref: messageRef,
|
|
420
|
+
thread_ref: threadRef,
|
|
421
|
+
source: sourceInfo(account),
|
|
422
|
+
cache: {
|
|
423
|
+
status: cacheStatus,
|
|
424
|
+
truncated: parsed.body.truncated,
|
|
425
|
+
},
|
|
426
|
+
message: {
|
|
427
|
+
envelope: parsed.envelope,
|
|
428
|
+
body: parsed.body,
|
|
429
|
+
attachments,
|
|
430
|
+
...(parsed.invite ? { invite: parsed.invite } : {}),
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
async function maybeSummarizeThreads(threads, context) {
|
|
435
|
+
if (context.config.summarizerBackend === "none") {
|
|
436
|
+
return threads;
|
|
437
|
+
}
|
|
438
|
+
const summarized = [];
|
|
439
|
+
for (const thread of threads) {
|
|
440
|
+
summarized.push({
|
|
441
|
+
...thread,
|
|
442
|
+
summary: await summarizeThread(thread, context.config),
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
return summarized;
|
|
446
|
+
}
|
|
447
|
+
async function persistThreads(account, context, threads) {
|
|
448
|
+
return context.db.transaction(() => {
|
|
449
|
+
const persistedThreads = [];
|
|
450
|
+
for (const thread of threads) {
|
|
451
|
+
const threadId = thread.provider_ids?.thread_id;
|
|
452
|
+
if (!threadId) {
|
|
453
|
+
throw new SurfaceError("transport_error", "Gmail thread is missing a thread id.", {
|
|
454
|
+
account: account.name,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
const resolvedThreadRef = context.db.findEntityRefByProviderKey("thread", account.account_id, gmailThreadProviderKey(threadId))
|
|
458
|
+
?? makeThreadRef();
|
|
459
|
+
context.db.upsertThread({
|
|
460
|
+
thread_ref: resolvedThreadRef,
|
|
461
|
+
account_id: account.account_id,
|
|
462
|
+
subject: thread.envelope.subject,
|
|
463
|
+
participants: thread.envelope.participants,
|
|
464
|
+
mailbox: thread.envelope.mailbox,
|
|
465
|
+
labels: thread.envelope.labels,
|
|
466
|
+
received_at: thread.envelope.received_at,
|
|
467
|
+
message_count: thread.envelope.message_count,
|
|
468
|
+
unread_count: thread.envelope.unread_count,
|
|
469
|
+
has_attachments: thread.envelope.has_attachments,
|
|
470
|
+
});
|
|
471
|
+
context.db.upsertProviderLocator({
|
|
472
|
+
entity_kind: "thread",
|
|
473
|
+
entity_ref: resolvedThreadRef,
|
|
474
|
+
account_id: account.account_id,
|
|
475
|
+
provider_key: gmailThreadProviderKey(threadId),
|
|
476
|
+
locator_json: JSON.stringify(thread.locator?.locator ?? { thread_id: threadId }),
|
|
477
|
+
});
|
|
478
|
+
const persistedMessages = [];
|
|
479
|
+
const messageRefs = [];
|
|
480
|
+
for (const message of thread.messages) {
|
|
481
|
+
const messageId = message.provider_ids?.message_id;
|
|
482
|
+
if (!messageId) {
|
|
483
|
+
throw new SurfaceError("transport_error", "Gmail message is missing a message id.", {
|
|
484
|
+
account: account.name,
|
|
485
|
+
threadRef: resolvedThreadRef,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
const resolvedMessageRef = context.db.findEntityRefByProviderKey("message", account.account_id, gmailMessageProviderKey(messageId))
|
|
489
|
+
?? makeMessageRef();
|
|
490
|
+
const messageDir = join(context.accountPaths.messagesDir, resolvedMessageRef);
|
|
491
|
+
mkdirSync(messageDir, { recursive: true });
|
|
492
|
+
const bodyCachePath = join(messageDir, "body.txt");
|
|
493
|
+
writeFileSync(bodyCachePath, message.body.text, "utf8");
|
|
494
|
+
context.db.upsertMessage({
|
|
495
|
+
message_ref: resolvedMessageRef,
|
|
496
|
+
account_id: account.account_id,
|
|
497
|
+
thread_ref: resolvedThreadRef,
|
|
498
|
+
subject: message.envelope.subject ?? thread.envelope.subject,
|
|
499
|
+
from_name: message.envelope.from?.name ?? null,
|
|
500
|
+
from_email: message.envelope.from?.email ?? null,
|
|
501
|
+
to_json: JSON.stringify(message.envelope.to),
|
|
502
|
+
cc_json: JSON.stringify(message.envelope.cc),
|
|
503
|
+
sent_at: message.envelope.sent_at,
|
|
504
|
+
received_at: message.envelope.received_at,
|
|
505
|
+
unread: message.envelope.unread,
|
|
506
|
+
snippet: message.snippet,
|
|
507
|
+
body_cache_path: bodyCachePath,
|
|
508
|
+
body_cached: true,
|
|
509
|
+
body_truncated: message.body.truncated,
|
|
510
|
+
body_cached_bytes: Buffer.byteLength(message.body.text, "utf8"),
|
|
511
|
+
invite_json: message.invite ? JSON.stringify(message.invite) : null,
|
|
512
|
+
});
|
|
513
|
+
context.db.upsertProviderLocator({
|
|
514
|
+
entity_kind: "message",
|
|
515
|
+
entity_ref: resolvedMessageRef,
|
|
516
|
+
account_id: account.account_id,
|
|
517
|
+
provider_key: gmailMessageProviderKey(messageId),
|
|
518
|
+
locator_json: JSON.stringify(message.locator?.locator ?? {
|
|
519
|
+
thread_id: threadId,
|
|
520
|
+
message_id: messageId,
|
|
521
|
+
}),
|
|
522
|
+
});
|
|
523
|
+
const persistedAttachments = [];
|
|
524
|
+
for (const [index, attachment] of message.attachments.entries()) {
|
|
525
|
+
const providerKey = gmailAttachmentProviderKey(messageId, attachment, index);
|
|
526
|
+
const resolvedAttachmentId = context.db.findEntityRefByProviderKey("attachment", account.account_id, providerKey) ?? makeAttachmentId();
|
|
527
|
+
context.db.upsertProviderLocator({
|
|
528
|
+
entity_kind: "attachment",
|
|
529
|
+
entity_ref: resolvedAttachmentId,
|
|
530
|
+
account_id: account.account_id,
|
|
531
|
+
provider_key: providerKey,
|
|
532
|
+
locator_json: JSON.stringify(attachment.locator?.locator ?? {}),
|
|
533
|
+
});
|
|
534
|
+
persistedAttachments.push({
|
|
535
|
+
...attachment,
|
|
536
|
+
attachment_id: resolvedAttachmentId,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
context.db.replaceAttachments(resolvedMessageRef, persistedAttachments.map((attachment) => ({
|
|
540
|
+
attachment_id: attachment.attachment_id,
|
|
541
|
+
filename: attachment.filename,
|
|
542
|
+
mime_type: attachment.mime_type,
|
|
543
|
+
size_bytes: attachment.size_bytes,
|
|
544
|
+
inline: attachment.inline,
|
|
545
|
+
saved_to: null,
|
|
546
|
+
})));
|
|
547
|
+
persistedMessages.push({
|
|
548
|
+
...message,
|
|
549
|
+
message_ref: resolvedMessageRef,
|
|
550
|
+
body: {
|
|
551
|
+
...message.body,
|
|
552
|
+
cached: true,
|
|
553
|
+
cached_bytes: Buffer.byteLength(message.body.text, "utf8"),
|
|
554
|
+
},
|
|
555
|
+
attachments: persistedAttachments,
|
|
556
|
+
});
|
|
557
|
+
messageRefs.push(resolvedMessageRef);
|
|
558
|
+
}
|
|
559
|
+
context.db.replaceThreadMessages(resolvedThreadRef, messageRefs);
|
|
560
|
+
if (thread.summary) {
|
|
561
|
+
context.db.upsertSummary(resolvedThreadRef, thread.summary);
|
|
562
|
+
}
|
|
563
|
+
persistedThreads.push({
|
|
564
|
+
...thread,
|
|
565
|
+
thread_ref: resolvedThreadRef,
|
|
566
|
+
messages: persistedMessages,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
return persistedThreads;
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
async function fetchAndPersistGmailThread(account, context, threadId) {
|
|
573
|
+
const thread = await getGmailThread(account, context, threadId);
|
|
574
|
+
const normalized = await normalizeGmailThread(account, context, thread);
|
|
575
|
+
const withSummary = (await maybeSummarizeThreads([normalized], context))[0];
|
|
576
|
+
await persistThreads(account, context, [withSummary]);
|
|
577
|
+
}
|
|
578
|
+
async function refreshStoredMessage(account, messageRef, context) {
|
|
579
|
+
const locatorRow = context.db.findProviderLocator("message", messageRef);
|
|
580
|
+
if (!locatorRow) {
|
|
581
|
+
throw new SurfaceError("cache_miss", `No provider locator exists for message '${messageRef}'.`, {
|
|
582
|
+
account: account.name,
|
|
583
|
+
messageRef,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
const locator = JSON.parse(locatorRow.locator_json);
|
|
587
|
+
if (!locator.thread_id) {
|
|
588
|
+
throw new SurfaceError("transport_error", `Message '${messageRef}' is missing a Gmail thread id.`, {
|
|
589
|
+
account: account.name,
|
|
590
|
+
messageRef,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
await fetchAndPersistGmailThread(account, context, locator.thread_id);
|
|
594
|
+
const refreshed = context.db.getStoredMessage(messageRef);
|
|
595
|
+
if (!refreshed) {
|
|
596
|
+
throw new SurfaceError("not_found", `Message '${messageRef}' could not be refreshed from Gmail.`, {
|
|
597
|
+
account: account.name,
|
|
598
|
+
messageRef,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
return refreshed;
|
|
602
|
+
}
|
|
603
|
+
async function resolveGmailMessageContext(account, messageRef, context) {
|
|
604
|
+
let stored = context.db.getStoredMessage(messageRef);
|
|
605
|
+
if (!stored) {
|
|
606
|
+
throw new SurfaceError("not_found", `Message '${messageRef}' was not found.`, {
|
|
607
|
+
account: account.name,
|
|
608
|
+
messageRef,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
let locatorRow = context.db.findProviderLocator("message", messageRef);
|
|
612
|
+
if (!locatorRow) {
|
|
613
|
+
stored = await refreshStoredMessage(account, messageRef, context);
|
|
614
|
+
locatorRow = context.db.findProviderLocator("message", messageRef);
|
|
615
|
+
}
|
|
616
|
+
if (!locatorRow) {
|
|
617
|
+
throw new SurfaceError("cache_miss", `No provider locator exists for message '${messageRef}'.`, {
|
|
618
|
+
account: account.name,
|
|
619
|
+
messageRef,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
let locator = parseMessageLocator(locatorRow.locator_json);
|
|
623
|
+
if (!locator.thread_id || !locator.message_id) {
|
|
624
|
+
stored = await refreshStoredMessage(account, messageRef, context);
|
|
625
|
+
locatorRow = context.db.findProviderLocator("message", messageRef);
|
|
626
|
+
if (!locatorRow) {
|
|
627
|
+
throw new SurfaceError("cache_miss", `No provider locator exists for message '${messageRef}'.`, {
|
|
628
|
+
account: account.name,
|
|
629
|
+
messageRef,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
locator = parseMessageLocator(locatorRow.locator_json);
|
|
633
|
+
}
|
|
634
|
+
if (!locator.thread_id || !locator.message_id) {
|
|
635
|
+
throw new SurfaceError("transport_error", `Message '${messageRef}' is missing Gmail thread or message ids.`, {
|
|
636
|
+
account: account.name,
|
|
637
|
+
messageRef,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
const thread = await getGmailThread(account, context, locator.thread_id);
|
|
641
|
+
const message = (thread.messages ?? []).find((entry) => entry.id === locator.message_id);
|
|
642
|
+
if (!message) {
|
|
643
|
+
throw new SurfaceError("not_found", `Gmail could not find message '${messageRef}' in thread '${locator.thread_id}'.`, {
|
|
644
|
+
account: account.name,
|
|
645
|
+
messageRef,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
return {
|
|
649
|
+
stored,
|
|
650
|
+
threadId: locator.thread_id,
|
|
651
|
+
messageId: locator.message_id,
|
|
652
|
+
thread,
|
|
653
|
+
message,
|
|
654
|
+
headers: headerIndex(message.payload?.headers),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
async function refreshRefsFromGmailResponse(account, context, response) {
|
|
658
|
+
const threadId = response.threadId ?? null;
|
|
659
|
+
if (threadId) {
|
|
660
|
+
await fetchAndPersistGmailThread(account, context, threadId);
|
|
661
|
+
}
|
|
662
|
+
const threadRef = threadId
|
|
663
|
+
? context.db.findEntityRefByProviderKey("thread", account.account_id, gmailThreadProviderKey(threadId)) ?? null
|
|
664
|
+
: null;
|
|
665
|
+
const messageRef = response.id
|
|
666
|
+
? context.db.findEntityRefByProviderKey("message", account.account_id, gmailMessageProviderKey(response.id)) ?? null
|
|
667
|
+
: null;
|
|
668
|
+
if (threadRef && !messageRef) {
|
|
669
|
+
const latest = latestStoredThreadMessage(threadRef, context);
|
|
670
|
+
return {
|
|
671
|
+
thread_ref: threadRef,
|
|
672
|
+
message_ref: latest.message_ref,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
thread_ref: threadRef,
|
|
677
|
+
message_ref: messageRef,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
async function fetchGmailThreads(account, context, options) {
|
|
681
|
+
const queryParts = [];
|
|
682
|
+
if (options.kind === "fetch-unread") {
|
|
683
|
+
queryParts.push("is:unread");
|
|
684
|
+
}
|
|
685
|
+
if (options.queryText) {
|
|
686
|
+
queryParts.push(options.queryText);
|
|
687
|
+
}
|
|
688
|
+
const threadStubs = await listGmailThreads(account, context, {
|
|
689
|
+
maxResults: options.limit,
|
|
690
|
+
...(queryParts.length > 0 ? { q: queryParts.join(" ").trim() } : {}),
|
|
691
|
+
});
|
|
692
|
+
if (threadStubs.length === 0) {
|
|
693
|
+
return [];
|
|
694
|
+
}
|
|
695
|
+
const hydrated = await Promise.all(threadStubs
|
|
696
|
+
.map((thread) => thread.id)
|
|
697
|
+
.filter((threadId) => Boolean(threadId))
|
|
698
|
+
.map((threadId) => getGmailThread(account, context, threadId)));
|
|
699
|
+
const normalized = await Promise.all(hydrated.map((thread) => normalizeGmailThread(account, context, thread)));
|
|
700
|
+
const summarized = await maybeSummarizeThreads(normalized, context);
|
|
701
|
+
return persistThreads(account, context, summarized);
|
|
702
|
+
}
|
|
703
|
+
async function sendOrDraftGmailMessage(account, context, payload) {
|
|
704
|
+
if (payload.draft) {
|
|
705
|
+
const draft = await createGmailDraft(account, context, {
|
|
706
|
+
raw: payload.raw,
|
|
707
|
+
threadId: payload.threadId ?? null,
|
|
708
|
+
});
|
|
709
|
+
return {
|
|
710
|
+
status: "drafted",
|
|
711
|
+
refs: await refreshRefsFromGmailResponse(account, context, draft.message ?? {}),
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
const message = await sendGmailRawMessage(account, context, {
|
|
715
|
+
raw: payload.raw,
|
|
716
|
+
threadId: payload.threadId ?? null,
|
|
717
|
+
});
|
|
718
|
+
return {
|
|
719
|
+
status: "sent",
|
|
720
|
+
refs: await refreshRefsFromGmailResponse(account, context, message),
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
export class GmailApiAdapter {
|
|
724
|
+
provider = "gmail";
|
|
725
|
+
transport = "gmail-api";
|
|
726
|
+
async login(account, context) {
|
|
727
|
+
const result = await runGmailLogin(account, context);
|
|
728
|
+
if (result.authenticatedEmail && result.authenticatedEmail !== account.email) {
|
|
729
|
+
context.db.upsertAccount({
|
|
730
|
+
name: account.name,
|
|
731
|
+
provider: account.provider,
|
|
732
|
+
transport: account.transport,
|
|
733
|
+
email: result.authenticatedEmail,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
return {
|
|
737
|
+
status: "authenticated",
|
|
738
|
+
detail: result.authenticatedEmail
|
|
739
|
+
? `Authenticated as ${result.authenticatedEmail}.`
|
|
740
|
+
: "Gmail OAuth complete.",
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
async logout(_account, context) {
|
|
744
|
+
clearGmailAuthState(context);
|
|
745
|
+
return {
|
|
746
|
+
status: "unauthenticated",
|
|
747
|
+
detail: "Removed the stored Gmail token and copied client secret for this account.",
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
async authStatus(account, context) {
|
|
751
|
+
const status = await gmailAuthStatus(account, context);
|
|
752
|
+
return status.authenticated
|
|
753
|
+
? { status: "authenticated", detail: status.detail }
|
|
754
|
+
: { status: "unauthenticated", detail: status.detail };
|
|
755
|
+
}
|
|
756
|
+
async search(account, query, context) {
|
|
757
|
+
return fetchGmailThreads(account, context, {
|
|
758
|
+
kind: "search",
|
|
759
|
+
queryText: query.text,
|
|
760
|
+
limit: query.limit,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
async fetchUnread(account, query, context) {
|
|
764
|
+
return fetchGmailThreads(account, context, {
|
|
765
|
+
kind: "fetch-unread",
|
|
766
|
+
limit: query.limit,
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
async readMessage(account, messageRef, refresh, context) {
|
|
770
|
+
const stored = context.db.getStoredMessage(messageRef);
|
|
771
|
+
if (!stored) {
|
|
772
|
+
throw new SurfaceError("not_found", `Message '${messageRef}' was not found.`, {
|
|
773
|
+
account: account.name,
|
|
774
|
+
messageRef,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
const attachments = context.db.listAttachmentsForMessage(messageRef).map((attachment) => ({
|
|
778
|
+
attachment_id: attachment.attachment_id,
|
|
779
|
+
filename: attachment.filename,
|
|
780
|
+
mime_type: attachment.mime_type,
|
|
781
|
+
size_bytes: attachment.size_bytes,
|
|
782
|
+
inline: Boolean(attachment.inline),
|
|
783
|
+
}));
|
|
784
|
+
const hasReadableCache = Boolean(stored.body_cache_path && existsSync(stored.body_cache_path));
|
|
785
|
+
if (!refresh && hasReadableCache) {
|
|
786
|
+
return buildReadEnvelope(account, messageRef, stored.thread_ref, parseStoredMessage(stored), attachments, "hit");
|
|
787
|
+
}
|
|
788
|
+
const refreshed = await refreshStoredMessage(account, messageRef, context);
|
|
789
|
+
const refreshedAttachments = context.db.listAttachmentsForMessage(messageRef).map((attachment) => ({
|
|
790
|
+
attachment_id: attachment.attachment_id,
|
|
791
|
+
filename: attachment.filename,
|
|
792
|
+
mime_type: attachment.mime_type,
|
|
793
|
+
size_bytes: attachment.size_bytes,
|
|
794
|
+
inline: Boolean(attachment.inline),
|
|
795
|
+
}));
|
|
796
|
+
return buildReadEnvelope(account, messageRef, refreshed.thread_ref, parseStoredMessage(refreshed), refreshedAttachments, "refreshed");
|
|
797
|
+
}
|
|
798
|
+
async listAttachments(account, messageRef, context) {
|
|
799
|
+
const message = context.db.findMessageByRef(messageRef);
|
|
800
|
+
if (!message) {
|
|
801
|
+
throw new SurfaceError("not_found", `Message '${messageRef}' was not found.`, {
|
|
802
|
+
account: account.name,
|
|
803
|
+
messageRef,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
return {
|
|
807
|
+
schema_version: "1",
|
|
808
|
+
command: "attachment-list",
|
|
809
|
+
account: account.name,
|
|
810
|
+
message_ref: messageRef,
|
|
811
|
+
attachments: context.db.listAttachmentsForMessage(messageRef).map((attachment) => ({
|
|
812
|
+
attachment_id: attachment.attachment_id,
|
|
813
|
+
filename: attachment.filename,
|
|
814
|
+
mime_type: attachment.mime_type,
|
|
815
|
+
size_bytes: attachment.size_bytes,
|
|
816
|
+
inline: Boolean(attachment.inline),
|
|
817
|
+
})),
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
async downloadAttachment(account, messageRef, attachmentId, context) {
|
|
821
|
+
const storedMessage = context.db.findMessageByRef(messageRef);
|
|
822
|
+
if (!storedMessage) {
|
|
823
|
+
throw new SurfaceError("not_found", `Message '${messageRef}' was not found.`, {
|
|
824
|
+
account: account.name,
|
|
825
|
+
messageRef,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
const attachment = context.db.listAttachmentsForMessage(messageRef).find((entry) => entry.attachment_id === attachmentId);
|
|
829
|
+
if (!attachment) {
|
|
830
|
+
throw new SurfaceError("not_found", `Attachment '${attachmentId}' was not found for message '${messageRef}'.`, {
|
|
831
|
+
account: account.name,
|
|
832
|
+
messageRef,
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
let locatorRow = context.db.findProviderLocator("attachment", attachmentId);
|
|
836
|
+
if (!locatorRow) {
|
|
837
|
+
await refreshStoredMessage(account, messageRef, context);
|
|
838
|
+
locatorRow = context.db.findProviderLocator("attachment", attachmentId);
|
|
839
|
+
}
|
|
840
|
+
if (!locatorRow) {
|
|
841
|
+
throw new SurfaceError("cache_miss", `No provider locator exists for attachment '${attachmentId}'.`, {
|
|
842
|
+
account: account.name,
|
|
843
|
+
messageRef,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
const locator = JSON.parse(locatorRow.locator_json);
|
|
847
|
+
let bytes = null;
|
|
848
|
+
if (locator.inline_data) {
|
|
849
|
+
bytes = decodeBase64UrlBytes(locator.inline_data);
|
|
850
|
+
}
|
|
851
|
+
else if (locator.message_id && locator.attachment_id) {
|
|
852
|
+
const payload = await downloadGmailAttachmentBytes(account, context, locator.message_id, locator.attachment_id);
|
|
853
|
+
if (!payload.data) {
|
|
854
|
+
throw new SurfaceError("transport_error", `Gmail attachment '${attachmentId}' returned no data.`, {
|
|
855
|
+
account: account.name,
|
|
856
|
+
messageRef,
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
bytes = decodeBase64UrlBytes(payload.data);
|
|
860
|
+
}
|
|
861
|
+
if (!bytes) {
|
|
862
|
+
throw new SurfaceError("unsupported", `Attachment '${attachmentId}' does not expose downloadable Gmail bytes.`, {
|
|
863
|
+
account: account.name,
|
|
864
|
+
messageRef,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
const targetDir = join(context.accountPaths.downloadsDir, messageRef);
|
|
868
|
+
mkdirSync(targetDir, { recursive: true });
|
|
869
|
+
const targetPath = join(targetDir, `${attachmentId}__${attachment.filename}`);
|
|
870
|
+
writeFileSync(targetPath, bytes);
|
|
871
|
+
context.db.updateAttachmentSavedTo(attachmentId, targetPath);
|
|
872
|
+
return {
|
|
873
|
+
schema_version: "1",
|
|
874
|
+
command: "attachment-download",
|
|
875
|
+
account: account.name,
|
|
876
|
+
message_ref: messageRef,
|
|
877
|
+
attachment: {
|
|
878
|
+
attachment_id: attachmentId,
|
|
879
|
+
filename: attachment.filename,
|
|
880
|
+
mime_type: attachment.mime_type,
|
|
881
|
+
size_bytes: attachment.size_bytes,
|
|
882
|
+
inline: Boolean(attachment.inline),
|
|
883
|
+
saved_to: targetPath,
|
|
884
|
+
},
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
async rsvp(account, _messageRef, _response) {
|
|
888
|
+
notImplemented("Gmail RSVP is deferred pending explicit Google Calendar integration.", account.name);
|
|
889
|
+
}
|
|
890
|
+
async sendMessage(account, input, context) {
|
|
891
|
+
const recipients = {
|
|
892
|
+
to: normalizeEmailList(input.to),
|
|
893
|
+
cc: normalizeEmailList(input.cc),
|
|
894
|
+
bcc: normalizeEmailList(input.bcc),
|
|
895
|
+
};
|
|
896
|
+
if (recipients.to.length === 0) {
|
|
897
|
+
throw new SurfaceError("invalid_argument", "Gmail send requires at least one --to recipient.", {
|
|
898
|
+
account: account.name,
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
assertWriteAllowed(context.config, account, recipients, {
|
|
902
|
+
disposition: input.draft ? "draft" : "send",
|
|
903
|
+
});
|
|
904
|
+
const raw = encodeMimeBase64Url(buildRawMimeMessage({
|
|
905
|
+
from: account.email,
|
|
906
|
+
to: recipients.to,
|
|
907
|
+
cc: recipients.cc,
|
|
908
|
+
bcc: recipients.bcc,
|
|
909
|
+
subject: input.subject,
|
|
910
|
+
body: input.body,
|
|
911
|
+
}));
|
|
912
|
+
const result = await sendOrDraftGmailMessage(account, context, {
|
|
913
|
+
raw,
|
|
914
|
+
draft: input.draft,
|
|
915
|
+
});
|
|
916
|
+
return buildSendEnvelope(account, "send", result.status, input.subject, recipientsFromInput(recipients), result.refs, null);
|
|
917
|
+
}
|
|
918
|
+
async reply(account, messageRef, input, context) {
|
|
919
|
+
const target = await resolveGmailMessageContext(account, messageRef, context);
|
|
920
|
+
const selfEmail = account.email.trim().toLowerCase();
|
|
921
|
+
const replyTo = parseMailbox(target.headers["reply-to"]);
|
|
922
|
+
const from = parseMailbox(target.headers.from);
|
|
923
|
+
let to = normalizeEmailList([replyTo?.email, from?.email]).filter((email) => email.trim().toLowerCase() !== selfEmail);
|
|
924
|
+
if (to.length === 0) {
|
|
925
|
+
to = normalizeEmailList(parseMailboxes(target.headers.to)
|
|
926
|
+
.map((mailbox) => mailbox.email)
|
|
927
|
+
.filter((email) => email.trim().toLowerCase() !== selfEmail));
|
|
928
|
+
}
|
|
929
|
+
const recipients = {
|
|
930
|
+
to,
|
|
931
|
+
cc: normalizeEmailList(input.cc),
|
|
932
|
+
bcc: normalizeEmailList(input.bcc),
|
|
933
|
+
};
|
|
934
|
+
if (recipients.to.length === 0) {
|
|
935
|
+
throw new SurfaceError("unsupported", `Message '${messageRef}' does not expose a reply target.`, {
|
|
936
|
+
account: account.name,
|
|
937
|
+
messageRef,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
assertWriteAllowed(context.config, account, recipients, {
|
|
941
|
+
disposition: input.draft ? "draft" : "send",
|
|
942
|
+
});
|
|
943
|
+
const originalMessageId = target.headers["message-id"] ?? null;
|
|
944
|
+
const references = target.headers.references
|
|
945
|
+
? `${target.headers.references}${originalMessageId ? ` ${originalMessageId}` : ""}`.trim()
|
|
946
|
+
: originalMessageId;
|
|
947
|
+
const subject = prefixSubject(target.stored.subject ?? target.headers.subject ?? "", "Re");
|
|
948
|
+
const raw = encodeMimeBase64Url(buildRawMimeMessage({
|
|
949
|
+
from: account.email,
|
|
950
|
+
to: recipients.to,
|
|
951
|
+
cc: recipients.cc,
|
|
952
|
+
bcc: recipients.bcc,
|
|
953
|
+
subject,
|
|
954
|
+
body: buildReplyBody(input.body, target.stored),
|
|
955
|
+
inReplyTo: originalMessageId,
|
|
956
|
+
references,
|
|
957
|
+
}));
|
|
958
|
+
const result = await sendOrDraftGmailMessage(account, context, {
|
|
959
|
+
raw,
|
|
960
|
+
threadId: target.threadId,
|
|
961
|
+
draft: input.draft,
|
|
962
|
+
});
|
|
963
|
+
return buildSendEnvelope(account, "reply", result.status, subject, recipientsFromInput(recipients), result.refs, messageRef);
|
|
964
|
+
}
|
|
965
|
+
async replyAll(account, messageRef, input, context) {
|
|
966
|
+
const target = await resolveGmailMessageContext(account, messageRef, context);
|
|
967
|
+
const selfEmail = account.email.trim().toLowerCase();
|
|
968
|
+
const replyTo = parseMailbox(target.headers["reply-to"]);
|
|
969
|
+
const from = parseMailbox(target.headers.from);
|
|
970
|
+
const originalTo = parseMailboxes(target.headers.to).map((mailbox) => mailbox.email);
|
|
971
|
+
const originalCc = parseMailboxes(target.headers.cc).map((mailbox) => mailbox.email);
|
|
972
|
+
let to = normalizeEmailList([
|
|
973
|
+
replyTo?.email && replyTo.email.trim().toLowerCase() !== selfEmail ? replyTo.email : null,
|
|
974
|
+
from?.email && from.email.trim().toLowerCase() !== selfEmail ? from.email : null,
|
|
975
|
+
]);
|
|
976
|
+
if (to.length === 0) {
|
|
977
|
+
to = normalizeEmailList(originalTo.filter((email) => email.trim().toLowerCase() !== selfEmail));
|
|
978
|
+
}
|
|
979
|
+
if (to.length === 0 && from?.email) {
|
|
980
|
+
to = normalizeEmailList([from.email]);
|
|
981
|
+
}
|
|
982
|
+
const cc = normalizeEmailList([
|
|
983
|
+
...originalTo.filter((email) => !to.includes(email) && email.trim().toLowerCase() !== selfEmail),
|
|
984
|
+
...originalCc.filter((email) => !to.includes(email) && email.trim().toLowerCase() !== selfEmail),
|
|
985
|
+
...input.cc,
|
|
986
|
+
]);
|
|
987
|
+
const bcc = normalizeEmailList(input.bcc);
|
|
988
|
+
const recipients = { to, cc, bcc };
|
|
989
|
+
if (recipients.to.length === 0) {
|
|
990
|
+
throw new SurfaceError("unsupported", `Message '${messageRef}' does not expose reply-all recipients.`, {
|
|
991
|
+
account: account.name,
|
|
992
|
+
messageRef,
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
assertWriteAllowed(context.config, account, recipients, {
|
|
996
|
+
disposition: input.draft ? "draft" : "send",
|
|
997
|
+
});
|
|
998
|
+
const originalMessageId = target.headers["message-id"] ?? null;
|
|
999
|
+
const references = target.headers.references
|
|
1000
|
+
? `${target.headers.references}${originalMessageId ? ` ${originalMessageId}` : ""}`.trim()
|
|
1001
|
+
: originalMessageId;
|
|
1002
|
+
const subject = prefixSubject(target.stored.subject ?? target.headers.subject ?? "", "Re");
|
|
1003
|
+
const raw = encodeMimeBase64Url(buildRawMimeMessage({
|
|
1004
|
+
from: account.email,
|
|
1005
|
+
to: recipients.to,
|
|
1006
|
+
cc: recipients.cc,
|
|
1007
|
+
bcc: recipients.bcc,
|
|
1008
|
+
subject,
|
|
1009
|
+
body: buildReplyBody(input.body, target.stored),
|
|
1010
|
+
inReplyTo: originalMessageId,
|
|
1011
|
+
references,
|
|
1012
|
+
}));
|
|
1013
|
+
const result = await sendOrDraftGmailMessage(account, context, {
|
|
1014
|
+
raw,
|
|
1015
|
+
threadId: target.threadId,
|
|
1016
|
+
draft: input.draft,
|
|
1017
|
+
});
|
|
1018
|
+
return buildSendEnvelope(account, "reply-all", result.status, subject, recipientsFromInput(recipients), result.refs, messageRef);
|
|
1019
|
+
}
|
|
1020
|
+
async forward(account, messageRef, input, context) {
|
|
1021
|
+
const target = await resolveGmailMessageContext(account, messageRef, context);
|
|
1022
|
+
const recipients = {
|
|
1023
|
+
to: normalizeEmailList(input.to),
|
|
1024
|
+
cc: normalizeEmailList(input.cc),
|
|
1025
|
+
bcc: normalizeEmailList(input.bcc),
|
|
1026
|
+
};
|
|
1027
|
+
if (recipients.to.length === 0) {
|
|
1028
|
+
throw new SurfaceError("invalid_argument", "Gmail forward requires at least one --to recipient.", {
|
|
1029
|
+
account: account.name,
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
assertWriteAllowed(context.config, account, recipients, {
|
|
1033
|
+
disposition: input.draft ? "draft" : "send",
|
|
1034
|
+
});
|
|
1035
|
+
const subject = prefixSubject(target.stored.subject ?? target.headers.subject ?? "", "Fwd");
|
|
1036
|
+
const raw = encodeMimeBase64Url(buildRawMimeMessage({
|
|
1037
|
+
from: account.email,
|
|
1038
|
+
to: recipients.to,
|
|
1039
|
+
cc: recipients.cc,
|
|
1040
|
+
bcc: recipients.bcc,
|
|
1041
|
+
subject,
|
|
1042
|
+
body: buildForwardBody(input.body, target.stored),
|
|
1043
|
+
}));
|
|
1044
|
+
const result = await sendOrDraftGmailMessage(account, context, {
|
|
1045
|
+
raw,
|
|
1046
|
+
draft: input.draft,
|
|
1047
|
+
});
|
|
1048
|
+
return buildSendEnvelope(account, "forward", result.status, subject, recipientsFromInput(recipients), result.refs, messageRef);
|
|
1049
|
+
}
|
|
1050
|
+
async archive(account, messageRef, context) {
|
|
1051
|
+
assertWriteAllowed(context.config, account, { to: [], cc: [], bcc: [] }, { disposition: "non_send" });
|
|
1052
|
+
const target = await resolveGmailMessageContext(account, messageRef, context);
|
|
1053
|
+
await modifyGmailThread(account, context, target.threadId, {
|
|
1054
|
+
removeLabelIds: ["INBOX"],
|
|
1055
|
+
});
|
|
1056
|
+
await fetchAndPersistGmailThread(account, context, target.threadId);
|
|
1057
|
+
return buildArchiveEnvelope(account, messageRef, target.stored.thread_ref);
|
|
1058
|
+
}
|
|
1059
|
+
async markRead(account, messageRefs, context) {
|
|
1060
|
+
assertWriteAllowed(context.config, account, { to: [], cc: [], bcc: [] }, { disposition: "non_send" });
|
|
1061
|
+
const touchedThreadIds = new Set();
|
|
1062
|
+
const updated = [];
|
|
1063
|
+
for (const messageRef of messageRefs) {
|
|
1064
|
+
const target = await resolveGmailMessageContext(account, messageRef, context);
|
|
1065
|
+
await modifyGmailMessage(account, context, target.messageId, {
|
|
1066
|
+
removeLabelIds: ["UNREAD"],
|
|
1067
|
+
});
|
|
1068
|
+
touchedThreadIds.add(target.threadId);
|
|
1069
|
+
updated.push({
|
|
1070
|
+
message_ref: messageRef,
|
|
1071
|
+
thread_ref: target.stored.thread_ref,
|
|
1072
|
+
unread: false,
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
for (const threadId of touchedThreadIds) {
|
|
1076
|
+
await fetchAndPersistGmailThread(account, context, threadId);
|
|
1077
|
+
}
|
|
1078
|
+
return buildMarkMessagesEnvelope(account, "mark-read", updated);
|
|
1079
|
+
}
|
|
1080
|
+
async markUnread(account, messageRefs, context) {
|
|
1081
|
+
assertWriteAllowed(context.config, account, { to: [], cc: [], bcc: [] }, { disposition: "non_send" });
|
|
1082
|
+
const touchedThreadIds = new Set();
|
|
1083
|
+
const updated = [];
|
|
1084
|
+
for (const messageRef of messageRefs) {
|
|
1085
|
+
const target = await resolveGmailMessageContext(account, messageRef, context);
|
|
1086
|
+
await modifyGmailMessage(account, context, target.messageId, {
|
|
1087
|
+
addLabelIds: ["UNREAD"],
|
|
1088
|
+
});
|
|
1089
|
+
touchedThreadIds.add(target.threadId);
|
|
1090
|
+
updated.push({
|
|
1091
|
+
message_ref: messageRef,
|
|
1092
|
+
thread_ref: target.stored.thread_ref,
|
|
1093
|
+
unread: true,
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
for (const threadId of touchedThreadIds) {
|
|
1097
|
+
await fetchAndPersistGmailThread(account, context, threadId);
|
|
1098
|
+
}
|
|
1099
|
+
return buildMarkMessagesEnvelope(account, "mark-unread", updated);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
//# sourceMappingURL=adapter.js.map
|