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,1443 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { SurfaceError } from "../../lib/errors.js";
|
|
4
|
+
import { assertWriteAllowed, collectWriteRecipients } from "../../lib/write-safety.js";
|
|
5
|
+
import { makeAttachmentId, makeMessageRef, makeThreadRef } from "../../refs.js";
|
|
6
|
+
import { summarizeThread } from "../../summarizer.js";
|
|
7
|
+
import { launchOutlookSession, probeOutlookAuth, promptForOutlookLogin } from "./session.js";
|
|
8
|
+
import { applySearchQuery, applyUnreadFilter, captureOutlookServiceSession, collectSearchConversationIds, collectUnreadConversationIds, fetchConversationBundle, waitForOutlookMailboxReady, } from "./extract.js";
|
|
9
|
+
import { buildOutlookInvite, itemIdData, mailboxFromExchange, mailboxesFromExchange, messageIdentity, normalizeOutlookBody, } from "./normalize.js";
|
|
10
|
+
function outlookProfileDir(context) {
|
|
11
|
+
return join(context.accountPaths.authDir, "profile");
|
|
12
|
+
}
|
|
13
|
+
function threadProviderKey(conversationId) {
|
|
14
|
+
return `outlook-thread:${conversationId}`;
|
|
15
|
+
}
|
|
16
|
+
function messageProviderKey(entry, conversationId) {
|
|
17
|
+
return `outlook-message:${messageIdentity(entry.item, conversationId)}`;
|
|
18
|
+
}
|
|
19
|
+
function attachmentProviderKey(messageKey, attachment, index) {
|
|
20
|
+
const locatorAttachmentId = attachment.locator?.locator.attachment_id;
|
|
21
|
+
return typeof locatorAttachmentId === "string" && locatorAttachmentId
|
|
22
|
+
? `outlook-attachment:${locatorAttachmentId}`
|
|
23
|
+
: `${messageKey}:attachment:${index}:${attachment.filename}:${attachment.size_bytes ?? ""}`;
|
|
24
|
+
}
|
|
25
|
+
function uniqueParticipants(messages) {
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
const participants = [];
|
|
28
|
+
const push = (role, mailbox) => {
|
|
29
|
+
if (!mailbox || (!mailbox.email && !mailbox.name)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const key = `${role}:${mailbox.email}:${mailbox.name}`;
|
|
33
|
+
if (seen.has(key)) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
seen.add(key);
|
|
37
|
+
participants.push({
|
|
38
|
+
role,
|
|
39
|
+
name: mailbox.name,
|
|
40
|
+
email: mailbox.email,
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
for (const message of messages) {
|
|
44
|
+
push("from", message.from);
|
|
45
|
+
for (const mailbox of message.to) {
|
|
46
|
+
push("to", mailbox);
|
|
47
|
+
}
|
|
48
|
+
for (const mailbox of message.cc) {
|
|
49
|
+
push("cc", mailbox);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return participants;
|
|
53
|
+
}
|
|
54
|
+
function inferThreadRsvpStatus(messages) {
|
|
55
|
+
let latest = null;
|
|
56
|
+
for (const message of messages) {
|
|
57
|
+
const subject = (message.envelope.subject ?? "").toLowerCase();
|
|
58
|
+
const timestamp = Date.parse(message.envelope.received_at ?? message.envelope.sent_at ?? "");
|
|
59
|
+
const at = Number.isFinite(timestamp) ? timestamp : 0;
|
|
60
|
+
if (subject.startsWith("accepted:")) {
|
|
61
|
+
if (!latest || at >= latest.at) {
|
|
62
|
+
latest = { status: "accept", at };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (subject.startsWith("declined:")) {
|
|
66
|
+
if (!latest || at >= latest.at) {
|
|
67
|
+
latest = { status: "decline", at };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (subject.startsWith("tentative:")) {
|
|
71
|
+
if (!latest || at >= latest.at) {
|
|
72
|
+
latest = { status: "tentative", at };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return latest?.status ?? null;
|
|
77
|
+
}
|
|
78
|
+
function buildAttachments(entry, messageKey) {
|
|
79
|
+
const rawItem = entry.item;
|
|
80
|
+
const rawAttachments = Array.isArray(rawItem.Attachments)
|
|
81
|
+
? rawItem.Attachments
|
|
82
|
+
: [];
|
|
83
|
+
return rawAttachments.map((attachment, index) => {
|
|
84
|
+
const filename = typeof attachment.Name === "string" ? attachment.Name : `attachment-${index + 1}`;
|
|
85
|
+
const attachmentId = typeof attachment.AttachmentId?.Id === "string"
|
|
86
|
+
? attachment.AttachmentId.Id
|
|
87
|
+
: "";
|
|
88
|
+
return {
|
|
89
|
+
attachment_id: "",
|
|
90
|
+
filename,
|
|
91
|
+
mime_type: typeof attachment.ContentType === "string" ? attachment.ContentType : "application/octet-stream",
|
|
92
|
+
size_bytes: typeof attachment.Size === "number" ? attachment.Size : null,
|
|
93
|
+
inline: Boolean(attachment.IsInline),
|
|
94
|
+
locator: {
|
|
95
|
+
kind: "attachment",
|
|
96
|
+
locator: {
|
|
97
|
+
attachment_id: attachmentId || null,
|
|
98
|
+
filename,
|
|
99
|
+
index,
|
|
100
|
+
message_key: messageKey,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function normalizeMessage(entry, conversationId) {
|
|
107
|
+
const item = entry.item;
|
|
108
|
+
const body = normalizeOutlookBody(item);
|
|
109
|
+
const invite = buildOutlookInvite(item);
|
|
110
|
+
const from = mailboxFromExchange(item.From ?? null)
|
|
111
|
+
?? mailboxFromExchange(item.Sender ?? null);
|
|
112
|
+
const to = mailboxesFromExchange(item.ToRecipients);
|
|
113
|
+
const cc = mailboxesFromExchange(item.CcRecipients);
|
|
114
|
+
const itemId = itemIdData(item.ItemId ?? null);
|
|
115
|
+
const messageId = itemId?.id;
|
|
116
|
+
const messageChangeKey = itemId?.change_key;
|
|
117
|
+
const internetMessageId = typeof item.InternetMessageId === "string" ? item.InternetMessageId : undefined;
|
|
118
|
+
const instanceKey = typeof item.InstanceKey === "string" ? item.InstanceKey : undefined;
|
|
119
|
+
const key = messageProviderKey(entry, conversationId);
|
|
120
|
+
const attachments = buildAttachments(entry, key);
|
|
121
|
+
const envelope = {
|
|
122
|
+
from,
|
|
123
|
+
to,
|
|
124
|
+
cc,
|
|
125
|
+
sent_at: typeof item.DateTimeSent === "string" ? item.DateTimeSent : null,
|
|
126
|
+
received_at: typeof item.DateTimeReceived === "string"
|
|
127
|
+
? item.DateTimeReceived
|
|
128
|
+
: typeof item.ReceivedOrRenewTime === "string"
|
|
129
|
+
? item.ReceivedOrRenewTime
|
|
130
|
+
: null,
|
|
131
|
+
unread: item.IsRead === true ? false : true,
|
|
132
|
+
...(typeof item.Subject === "string" ? { subject: item.Subject } : {}),
|
|
133
|
+
};
|
|
134
|
+
const providerIds = {
|
|
135
|
+
...(messageId ? { message_id: messageId } : {}),
|
|
136
|
+
...(internetMessageId ? { internet_message_id: internetMessageId } : {}),
|
|
137
|
+
};
|
|
138
|
+
const associatedCalendarItem = invite.meeting?.associated_calendar_item ?? null;
|
|
139
|
+
return {
|
|
140
|
+
message_ref: "",
|
|
141
|
+
envelope,
|
|
142
|
+
snippet: typeof item.Preview === "string" ? item.Preview : body.text.slice(0, 240),
|
|
143
|
+
body: {
|
|
144
|
+
text: body.text,
|
|
145
|
+
truncated: false,
|
|
146
|
+
cached: true,
|
|
147
|
+
cached_bytes: Buffer.byteLength(body.text, "utf8"),
|
|
148
|
+
},
|
|
149
|
+
attachments,
|
|
150
|
+
...(invite.is_invite
|
|
151
|
+
? {
|
|
152
|
+
invite: {
|
|
153
|
+
is_invite: invite.is_invite,
|
|
154
|
+
rsvp_supported: invite.rsvp_supported,
|
|
155
|
+
response_status: invite.response_status,
|
|
156
|
+
available_rsvp_responses: invite.available_rsvp_responses,
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
: {}),
|
|
160
|
+
...(Object.keys(providerIds).length > 0 ? { provider_ids: providerIds } : {}),
|
|
161
|
+
locator: {
|
|
162
|
+
kind: "message",
|
|
163
|
+
locator: {
|
|
164
|
+
conversation_id: conversationId,
|
|
165
|
+
message_id: messageId ?? null,
|
|
166
|
+
message_change_key: messageChangeKey ?? null,
|
|
167
|
+
internet_message_id: internetMessageId ?? null,
|
|
168
|
+
instance_key: instanceKey ?? null,
|
|
169
|
+
parent_internet_message_id: entry.nodeMetadata.parentInternetMessageId,
|
|
170
|
+
associated_calendar_item_id: associatedCalendarItem?.id ?? null,
|
|
171
|
+
associated_calendar_change_key: associatedCalendarItem?.change_key ?? null,
|
|
172
|
+
meeting_start: typeof invite.meeting?.start === "string" ? invite.meeting.start : null,
|
|
173
|
+
meeting_end: typeof invite.meeting?.end === "string" ? invite.meeting.end : null,
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function normalizeThread(bundle) {
|
|
179
|
+
const baseMessages = bundle.entries.map((entry) => normalizeMessage(entry, bundle.conversationId));
|
|
180
|
+
const inferredRsvpStatus = inferThreadRsvpStatus(baseMessages);
|
|
181
|
+
const messages = inferredRsvpStatus
|
|
182
|
+
? baseMessages.map((message) => message.invite
|
|
183
|
+
? {
|
|
184
|
+
...message,
|
|
185
|
+
invite: {
|
|
186
|
+
...message.invite,
|
|
187
|
+
response_status: inferredRsvpStatus,
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
: message)
|
|
191
|
+
: baseMessages;
|
|
192
|
+
const latestMessage = messages[0] ?? null;
|
|
193
|
+
const unreadCount = messages.filter((message) => message.envelope.unread).length;
|
|
194
|
+
const hasAttachments = messages.some((message) => message.attachments.length > 0)
|
|
195
|
+
|| bundle.entries.some((entry) => entry.item.HasAttachments === true);
|
|
196
|
+
const labels = unreadCount > 0 ? ["inbox", "unread"] : ["inbox"];
|
|
197
|
+
return {
|
|
198
|
+
thread_ref: "",
|
|
199
|
+
source: {
|
|
200
|
+
provider: "outlook",
|
|
201
|
+
transport: "outlook-web-playwright",
|
|
202
|
+
},
|
|
203
|
+
envelope: {
|
|
204
|
+
subject: latestMessage?.envelope.subject ?? "",
|
|
205
|
+
participants: uniqueParticipants(messages.map((message) => message.envelope)),
|
|
206
|
+
mailbox: "inbox",
|
|
207
|
+
labels,
|
|
208
|
+
received_at: latestMessage?.envelope.received_at ?? null,
|
|
209
|
+
message_count: messages.length,
|
|
210
|
+
unread_count: unreadCount,
|
|
211
|
+
has_attachments: hasAttachments,
|
|
212
|
+
},
|
|
213
|
+
summary: null,
|
|
214
|
+
messages,
|
|
215
|
+
provider_ids: {
|
|
216
|
+
thread_id: bundle.conversationId,
|
|
217
|
+
},
|
|
218
|
+
locator: {
|
|
219
|
+
kind: "thread",
|
|
220
|
+
locator: {
|
|
221
|
+
conversation_id: bundle.conversationId,
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function parseStoredMessage(record) {
|
|
227
|
+
const envelope = {
|
|
228
|
+
from: record.from_name || record.from_email
|
|
229
|
+
? {
|
|
230
|
+
name: record.from_name ?? "",
|
|
231
|
+
email: record.from_email ?? "",
|
|
232
|
+
}
|
|
233
|
+
: null,
|
|
234
|
+
to: JSON.parse(record.to_json),
|
|
235
|
+
cc: JSON.parse(record.cc_json),
|
|
236
|
+
sent_at: record.sent_at,
|
|
237
|
+
received_at: record.received_at,
|
|
238
|
+
unread: Boolean(record.unread),
|
|
239
|
+
...(record.subject ? { subject: record.subject } : {}),
|
|
240
|
+
};
|
|
241
|
+
return {
|
|
242
|
+
envelope,
|
|
243
|
+
body: {
|
|
244
|
+
text: record.body_cache_path && existsSync(record.body_cache_path) ? readFileSync(record.body_cache_path, "utf8") : "",
|
|
245
|
+
truncated: Boolean(record.body_truncated),
|
|
246
|
+
cached: Boolean(record.body_cached),
|
|
247
|
+
cached_bytes: record.body_cached_bytes,
|
|
248
|
+
},
|
|
249
|
+
invite: record.invite_json ? JSON.parse(record.invite_json) : undefined,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function locatorValue(locator, key) {
|
|
253
|
+
const value = locator?.locator[key];
|
|
254
|
+
return typeof value === "string" ? value : "";
|
|
255
|
+
}
|
|
256
|
+
function parseOutlookMessageLocator(locatorJson) {
|
|
257
|
+
const locator = JSON.parse(locatorJson);
|
|
258
|
+
const stringOrNull = (value) => (typeof value === "string" && value ? value : null);
|
|
259
|
+
return {
|
|
260
|
+
conversation_id: typeof locator.conversation_id === "string" ? locator.conversation_id : "",
|
|
261
|
+
message_id: stringOrNull(locator.message_id),
|
|
262
|
+
message_change_key: stringOrNull(locator.message_change_key),
|
|
263
|
+
internet_message_id: stringOrNull(locator.internet_message_id),
|
|
264
|
+
instance_key: stringOrNull(locator.instance_key),
|
|
265
|
+
parent_internet_message_id: stringOrNull(locator.parent_internet_message_id),
|
|
266
|
+
associated_calendar_item_id: stringOrNull(locator.associated_calendar_item_id),
|
|
267
|
+
associated_calendar_change_key: stringOrNull(locator.associated_calendar_change_key),
|
|
268
|
+
meeting_start: stringOrNull(locator.meeting_start),
|
|
269
|
+
meeting_end: stringOrNull(locator.meeting_end),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function parseOutlookAttachmentLocator(locatorJson) {
|
|
273
|
+
const locator = JSON.parse(locatorJson);
|
|
274
|
+
return {
|
|
275
|
+
attachment_id: typeof locator.attachment_id === "string" && locator.attachment_id ? locator.attachment_id : null,
|
|
276
|
+
filename: typeof locator.filename === "string" && locator.filename ? locator.filename : null,
|
|
277
|
+
index: typeof locator.index === "number" ? locator.index : null,
|
|
278
|
+
message_key: typeof locator.message_key === "string" && locator.message_key ? locator.message_key : null,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function sanitizeAttachmentFilename(filename) {
|
|
282
|
+
const sanitized = filename
|
|
283
|
+
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
|
|
284
|
+
.replace(/\s+/g, " ")
|
|
285
|
+
.trim();
|
|
286
|
+
return sanitized || "attachment";
|
|
287
|
+
}
|
|
288
|
+
function participantFromEmail(email) {
|
|
289
|
+
return {
|
|
290
|
+
name: email,
|
|
291
|
+
email,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function recipientsFromInput(input) {
|
|
295
|
+
return {
|
|
296
|
+
to: input.to.map(participantFromEmail),
|
|
297
|
+
cc: input.cc.map(participantFromEmail),
|
|
298
|
+
bcc: input.bcc.map(participantFromEmail),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function recipientsFromStored(stored, fallback) {
|
|
302
|
+
if (!stored) {
|
|
303
|
+
return recipientsFromInput(fallback);
|
|
304
|
+
}
|
|
305
|
+
const parsed = parseStoredMessage(stored);
|
|
306
|
+
return {
|
|
307
|
+
to: parsed.envelope.to.length > 0 ? parsed.envelope.to : fallback.to.map(participantFromEmail),
|
|
308
|
+
cc: parsed.envelope.cc.length > 0 ? parsed.envelope.cc : fallback.cc.map(participantFromEmail),
|
|
309
|
+
bcc: fallback.bcc.map(participantFromEmail),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function normalizeEmailList(values) {
|
|
313
|
+
const deduped = new Set();
|
|
314
|
+
for (const value of values) {
|
|
315
|
+
const normalized = value.trim();
|
|
316
|
+
if (!normalized) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
deduped.add(normalized);
|
|
320
|
+
}
|
|
321
|
+
return [...deduped];
|
|
322
|
+
}
|
|
323
|
+
function sourceInfo(account) {
|
|
324
|
+
return {
|
|
325
|
+
provider: account.provider,
|
|
326
|
+
transport: account.transport,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
async function persistThreads(account, context, threads) {
|
|
330
|
+
return context.db.transaction(() => {
|
|
331
|
+
const persistedThreads = [];
|
|
332
|
+
for (const thread of threads) {
|
|
333
|
+
const conversationId = thread.provider_ids?.thread_id ?? locatorValue(thread.locator, "conversation_id");
|
|
334
|
+
if (!conversationId) {
|
|
335
|
+
throw new SurfaceError("transport_error", "Outlook thread is missing a conversation id.", {
|
|
336
|
+
account: account.name,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
const resolvedThreadRef = context.db.findEntityRefByProviderKey("thread", account.account_id, threadProviderKey(conversationId))
|
|
340
|
+
?? makeThreadRef();
|
|
341
|
+
context.db.upsertThread({
|
|
342
|
+
thread_ref: resolvedThreadRef,
|
|
343
|
+
account_id: account.account_id,
|
|
344
|
+
subject: thread.envelope.subject,
|
|
345
|
+
participants: thread.envelope.participants,
|
|
346
|
+
mailbox: thread.envelope.mailbox,
|
|
347
|
+
labels: thread.envelope.labels,
|
|
348
|
+
received_at: thread.envelope.received_at,
|
|
349
|
+
message_count: thread.envelope.message_count,
|
|
350
|
+
unread_count: thread.envelope.unread_count,
|
|
351
|
+
has_attachments: thread.envelope.has_attachments,
|
|
352
|
+
});
|
|
353
|
+
context.db.upsertProviderLocator({
|
|
354
|
+
entity_kind: "thread",
|
|
355
|
+
entity_ref: resolvedThreadRef,
|
|
356
|
+
account_id: account.account_id,
|
|
357
|
+
provider_key: threadProviderKey(conversationId),
|
|
358
|
+
locator_json: JSON.stringify(thread.locator?.locator ?? { conversation_id: conversationId }),
|
|
359
|
+
});
|
|
360
|
+
const persistedMessages = [];
|
|
361
|
+
const messageRefs = [];
|
|
362
|
+
for (const message of thread.messages) {
|
|
363
|
+
const providerKey = messageProviderKey({
|
|
364
|
+
item: {
|
|
365
|
+
ItemId: { Id: message.provider_ids?.message_id },
|
|
366
|
+
InternetMessageId: message.provider_ids?.internet_message_id,
|
|
367
|
+
InstanceKey: locatorValue(message.locator, "instance_key"),
|
|
368
|
+
DateTimeReceived: message.envelope.received_at,
|
|
369
|
+
Subject: message.envelope.subject ?? thread.envelope.subject,
|
|
370
|
+
},
|
|
371
|
+
nodeMetadata: {
|
|
372
|
+
parentInternetMessageId: locatorValue(message.locator, "parent_internet_message_id") || null,
|
|
373
|
+
hasQuotedText: null,
|
|
374
|
+
isRootNode: null,
|
|
375
|
+
},
|
|
376
|
+
}, conversationId);
|
|
377
|
+
const resolvedMessageRef = context.db.findEntityRefByProviderKey("message", account.account_id, providerKey) ?? makeMessageRef();
|
|
378
|
+
const messageDir = join(context.accountPaths.messagesDir, resolvedMessageRef);
|
|
379
|
+
mkdirSync(messageDir, { recursive: true });
|
|
380
|
+
const bodyCachePath = join(messageDir, "body.txt");
|
|
381
|
+
writeFileSync(bodyCachePath, message.body.text, "utf8");
|
|
382
|
+
context.db.upsertMessage({
|
|
383
|
+
message_ref: resolvedMessageRef,
|
|
384
|
+
account_id: account.account_id,
|
|
385
|
+
thread_ref: resolvedThreadRef,
|
|
386
|
+
subject: message.envelope.subject ?? thread.envelope.subject,
|
|
387
|
+
from_name: message.envelope.from?.name ?? null,
|
|
388
|
+
from_email: message.envelope.from?.email ?? null,
|
|
389
|
+
to_json: JSON.stringify(message.envelope.to),
|
|
390
|
+
cc_json: JSON.stringify(message.envelope.cc),
|
|
391
|
+
sent_at: message.envelope.sent_at,
|
|
392
|
+
received_at: message.envelope.received_at,
|
|
393
|
+
unread: message.envelope.unread,
|
|
394
|
+
snippet: message.snippet,
|
|
395
|
+
body_cache_path: bodyCachePath,
|
|
396
|
+
body_cached: true,
|
|
397
|
+
body_truncated: message.body.truncated,
|
|
398
|
+
body_cached_bytes: Buffer.byteLength(message.body.text, "utf8"),
|
|
399
|
+
invite_json: message.invite ? JSON.stringify(message.invite) : null,
|
|
400
|
+
});
|
|
401
|
+
context.db.upsertProviderLocator({
|
|
402
|
+
entity_kind: "message",
|
|
403
|
+
entity_ref: resolvedMessageRef,
|
|
404
|
+
account_id: account.account_id,
|
|
405
|
+
provider_key: providerKey,
|
|
406
|
+
locator_json: JSON.stringify(message.locator?.locator ?? {}),
|
|
407
|
+
});
|
|
408
|
+
const persistedAttachments = [];
|
|
409
|
+
for (const [index, attachment] of message.attachments.entries()) {
|
|
410
|
+
const providerKeyForAttachment = attachmentProviderKey(providerKey, attachment, index);
|
|
411
|
+
const resolvedAttachmentId = context.db.findEntityRefByProviderKey("attachment", account.account_id, providerKeyForAttachment)
|
|
412
|
+
?? makeAttachmentId();
|
|
413
|
+
context.db.upsertProviderLocator({
|
|
414
|
+
entity_kind: "attachment",
|
|
415
|
+
entity_ref: resolvedAttachmentId,
|
|
416
|
+
account_id: account.account_id,
|
|
417
|
+
provider_key: providerKeyForAttachment,
|
|
418
|
+
locator_json: JSON.stringify(attachment.locator?.locator ?? {}),
|
|
419
|
+
});
|
|
420
|
+
persistedAttachments.push({
|
|
421
|
+
...attachment,
|
|
422
|
+
attachment_id: resolvedAttachmentId,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
context.db.replaceAttachments(resolvedMessageRef, persistedAttachments.map((attachment) => ({
|
|
426
|
+
attachment_id: attachment.attachment_id,
|
|
427
|
+
filename: attachment.filename,
|
|
428
|
+
mime_type: attachment.mime_type,
|
|
429
|
+
size_bytes: attachment.size_bytes,
|
|
430
|
+
inline: attachment.inline,
|
|
431
|
+
saved_to: null,
|
|
432
|
+
})));
|
|
433
|
+
persistedMessages.push({
|
|
434
|
+
...message,
|
|
435
|
+
message_ref: resolvedMessageRef,
|
|
436
|
+
body: {
|
|
437
|
+
...message.body,
|
|
438
|
+
cached: true,
|
|
439
|
+
cached_bytes: Buffer.byteLength(message.body.text, "utf8"),
|
|
440
|
+
},
|
|
441
|
+
attachments: persistedAttachments,
|
|
442
|
+
});
|
|
443
|
+
messageRefs.push(resolvedMessageRef);
|
|
444
|
+
}
|
|
445
|
+
context.db.replaceThreadMessages(resolvedThreadRef, messageRefs);
|
|
446
|
+
const threadInviteStatus = inferThreadRsvpStatus(persistedMessages);
|
|
447
|
+
if (threadInviteStatus) {
|
|
448
|
+
context.db.updateInviteStatusForThread(resolvedThreadRef, threadInviteStatus);
|
|
449
|
+
}
|
|
450
|
+
if (thread.summary) {
|
|
451
|
+
context.db.upsertSummary(resolvedThreadRef, thread.summary);
|
|
452
|
+
}
|
|
453
|
+
persistedThreads.push({
|
|
454
|
+
...thread,
|
|
455
|
+
thread_ref: resolvedThreadRef,
|
|
456
|
+
messages: persistedMessages,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
return persistedThreads;
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
async function maybeSummarizeThreads(threads, context) {
|
|
463
|
+
if (context.config.summarizerBackend === "none") {
|
|
464
|
+
return threads;
|
|
465
|
+
}
|
|
466
|
+
const summarized = [];
|
|
467
|
+
for (const thread of threads) {
|
|
468
|
+
const summary = await summarizeThread(thread, context.config);
|
|
469
|
+
summarized.push({
|
|
470
|
+
...thread,
|
|
471
|
+
summary,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
return summarized;
|
|
475
|
+
}
|
|
476
|
+
async function refreshOutlookConversation(account, conversationId, context) {
|
|
477
|
+
const profileDir = outlookProfileDir(context);
|
|
478
|
+
const session = await launchOutlookSession(profileDir, { headless: true });
|
|
479
|
+
try {
|
|
480
|
+
const capturedSession = await captureOutlookServiceSession(session.context, session.page, {
|
|
481
|
+
timeoutMs: context.config.providerTimeoutMs,
|
|
482
|
+
});
|
|
483
|
+
const bundle = await fetchConversationBundle(session.context.request, capturedSession, conversationId);
|
|
484
|
+
await persistThreads(account, context, [normalizeThread(bundle)]);
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
await session.context.close();
|
|
488
|
+
session.cleanup?.();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async function refreshStoredMessage(account, messageRef, context) {
|
|
492
|
+
const locatorRow = context.db.findProviderLocator("message", messageRef);
|
|
493
|
+
if (!locatorRow) {
|
|
494
|
+
throw new SurfaceError("cache_miss", `No provider locator exists for message '${messageRef}'.`, {
|
|
495
|
+
account: account.name,
|
|
496
|
+
messageRef,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
const locator = parseOutlookMessageLocator(locatorRow.locator_json);
|
|
500
|
+
if (!locator.conversation_id) {
|
|
501
|
+
throw new SurfaceError("transport_error", `Message '${messageRef}' is missing an Outlook conversation id.`, {
|
|
502
|
+
account: account.name,
|
|
503
|
+
messageRef,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
await refreshOutlookConversation(account, locator.conversation_id, context);
|
|
507
|
+
const refreshed = context.db.getStoredMessage(messageRef);
|
|
508
|
+
if (!refreshed) {
|
|
509
|
+
throw new SurfaceError("not_found", `Message '${messageRef}' could not be refreshed from Outlook.`, {
|
|
510
|
+
account: account.name,
|
|
511
|
+
messageRef,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
return refreshed;
|
|
515
|
+
}
|
|
516
|
+
async function resolveRsvpLocator(account, messageRef, context) {
|
|
517
|
+
let stored = context.db.getStoredMessage(messageRef);
|
|
518
|
+
if (!stored) {
|
|
519
|
+
throw new SurfaceError("not_found", `Message '${messageRef}' was not found.`, {
|
|
520
|
+
account: account.name,
|
|
521
|
+
messageRef,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
if (!stored.invite_json) {
|
|
525
|
+
throw new SurfaceError("unsupported", `Message '${messageRef}' is not a meeting invite.`, {
|
|
526
|
+
account: account.name,
|
|
527
|
+
messageRef,
|
|
528
|
+
threadRef: stored.thread_ref,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
let locatorRow = context.db.findProviderLocator("message", messageRef);
|
|
532
|
+
if (!locatorRow) {
|
|
533
|
+
stored = await refreshStoredMessage(account, messageRef, context);
|
|
534
|
+
locatorRow = context.db.findProviderLocator("message", messageRef);
|
|
535
|
+
}
|
|
536
|
+
if (!locatorRow) {
|
|
537
|
+
throw new SurfaceError("cache_miss", `No provider locator exists for message '${messageRef}'.`, {
|
|
538
|
+
account: account.name,
|
|
539
|
+
messageRef,
|
|
540
|
+
threadRef: stored.thread_ref,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
let locator = parseOutlookMessageLocator(locatorRow.locator_json);
|
|
544
|
+
if (!locator.message_id || !locator.message_change_key) {
|
|
545
|
+
stored = await refreshStoredMessage(account, messageRef, context);
|
|
546
|
+
locatorRow = context.db.findProviderLocator("message", messageRef);
|
|
547
|
+
if (!locatorRow) {
|
|
548
|
+
throw new SurfaceError("cache_miss", `No provider locator exists for message '${messageRef}'.`, {
|
|
549
|
+
account: account.name,
|
|
550
|
+
messageRef,
|
|
551
|
+
threadRef: stored.thread_ref,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
locator = parseOutlookMessageLocator(locatorRow.locator_json);
|
|
555
|
+
}
|
|
556
|
+
if (!locator.message_id || !locator.message_change_key) {
|
|
557
|
+
throw new SurfaceError("unsupported", `Message '${messageRef}' does not expose enough Outlook item metadata for RSVP actions.`, {
|
|
558
|
+
account: account.name,
|
|
559
|
+
messageRef,
|
|
560
|
+
threadRef: stored.thread_ref,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
return { locator, stored };
|
|
564
|
+
}
|
|
565
|
+
async function resolveMessageActionTarget(account, messageRef, context) {
|
|
566
|
+
let stored = context.db.getStoredMessage(messageRef);
|
|
567
|
+
if (!stored) {
|
|
568
|
+
throw new SurfaceError("not_found", `Message '${messageRef}' was not found.`, {
|
|
569
|
+
account: account.name,
|
|
570
|
+
messageRef,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
let locatorRow = context.db.findProviderLocator("message", messageRef);
|
|
574
|
+
if (!locatorRow) {
|
|
575
|
+
stored = await refreshStoredMessage(account, messageRef, context);
|
|
576
|
+
locatorRow = context.db.findProviderLocator("message", messageRef);
|
|
577
|
+
}
|
|
578
|
+
if (!locatorRow) {
|
|
579
|
+
throw new SurfaceError("cache_miss", `No provider locator exists for message '${messageRef}'.`, {
|
|
580
|
+
account: account.name,
|
|
581
|
+
messageRef,
|
|
582
|
+
threadRef: stored.thread_ref,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
let locator = parseOutlookMessageLocator(locatorRow.locator_json);
|
|
586
|
+
if (!locator.conversation_id) {
|
|
587
|
+
stored = await refreshStoredMessage(account, messageRef, context);
|
|
588
|
+
locatorRow = context.db.findProviderLocator("message", messageRef);
|
|
589
|
+
if (!locatorRow) {
|
|
590
|
+
throw new SurfaceError("cache_miss", `No provider locator exists for message '${messageRef}'.`, {
|
|
591
|
+
account: account.name,
|
|
592
|
+
messageRef,
|
|
593
|
+
threadRef: stored.thread_ref,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
locator = parseOutlookMessageLocator(locatorRow.locator_json);
|
|
597
|
+
}
|
|
598
|
+
if (!locator.conversation_id) {
|
|
599
|
+
throw new SurfaceError("unsupported", `Message '${messageRef}' does not expose enough Outlook metadata for mail actions.`, {
|
|
600
|
+
account: account.name,
|
|
601
|
+
messageRef,
|
|
602
|
+
threadRef: stored.thread_ref,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
return { locator, stored };
|
|
606
|
+
}
|
|
607
|
+
async function resolveMessageStateTarget(account, messageRef, context) {
|
|
608
|
+
let resolved = await resolveMessageActionTarget(account, messageRef, context);
|
|
609
|
+
if (!resolved.locator.message_id) {
|
|
610
|
+
const refreshed = await refreshStoredMessage(account, messageRef, context);
|
|
611
|
+
const locatorRow = context.db.findProviderLocator("message", messageRef);
|
|
612
|
+
if (!locatorRow) {
|
|
613
|
+
throw new SurfaceError("cache_miss", `No provider locator exists for message '${messageRef}'.`, {
|
|
614
|
+
account: account.name,
|
|
615
|
+
messageRef,
|
|
616
|
+
threadRef: refreshed.thread_ref,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
resolved = {
|
|
620
|
+
stored: refreshed,
|
|
621
|
+
locator: parseOutlookMessageLocator(locatorRow.locator_json),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
if (!resolved.locator.message_id) {
|
|
625
|
+
throw new SurfaceError("unsupported", `Message '${messageRef}' does not expose enough Outlook metadata for read-state actions.`, {
|
|
626
|
+
account: account.name,
|
|
627
|
+
messageRef,
|
|
628
|
+
threadRef: resolved.stored.thread_ref,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
return resolved;
|
|
632
|
+
}
|
|
633
|
+
async function resolveAttachmentDownloadTarget(account, messageRef, attachmentId, context) {
|
|
634
|
+
const message = context.db.findMessageByRef(messageRef);
|
|
635
|
+
if (!message) {
|
|
636
|
+
throw new SurfaceError("not_found", `Message '${messageRef}' was not found.`, {
|
|
637
|
+
account: account.name,
|
|
638
|
+
messageRef,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
const storedAttachment = context.db.findAttachmentById(attachmentId);
|
|
642
|
+
if (!storedAttachment || storedAttachment.message_ref !== messageRef) {
|
|
643
|
+
throw new SurfaceError("not_found", `Attachment '${attachmentId}' was not found on message '${messageRef}'.`, {
|
|
644
|
+
account: account.name,
|
|
645
|
+
messageRef,
|
|
646
|
+
threadRef: message.thread_ref,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
let stored = context.db.getStoredMessage(messageRef);
|
|
650
|
+
if (!stored) {
|
|
651
|
+
throw new SurfaceError("not_found", `Message '${messageRef}' was not found.`, {
|
|
652
|
+
account: account.name,
|
|
653
|
+
messageRef,
|
|
654
|
+
threadRef: message.thread_ref,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
let messageLocatorRow = context.db.findProviderLocator("message", messageRef);
|
|
658
|
+
let attachmentLocatorRow = context.db.findProviderLocator("attachment", attachmentId);
|
|
659
|
+
if (!messageLocatorRow || !attachmentLocatorRow) {
|
|
660
|
+
stored = await refreshStoredMessage(account, messageRef, context);
|
|
661
|
+
messageLocatorRow = context.db.findProviderLocator("message", messageRef);
|
|
662
|
+
attachmentLocatorRow = context.db.findProviderLocator("attachment", attachmentId);
|
|
663
|
+
}
|
|
664
|
+
if (!messageLocatorRow || !attachmentLocatorRow) {
|
|
665
|
+
throw new SurfaceError("cache_miss", `Attachment '${attachmentId}' is missing provider locator data required for download.`, {
|
|
666
|
+
account: account.name,
|
|
667
|
+
messageRef,
|
|
668
|
+
threadRef: stored.thread_ref,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
let messageLocator = parseOutlookMessageLocator(messageLocatorRow.locator_json);
|
|
672
|
+
let attachmentLocator = parseOutlookAttachmentLocator(attachmentLocatorRow.locator_json);
|
|
673
|
+
if (!messageLocator.message_id || !attachmentLocator.attachment_id) {
|
|
674
|
+
stored = await refreshStoredMessage(account, messageRef, context);
|
|
675
|
+
messageLocatorRow = context.db.findProviderLocator("message", messageRef);
|
|
676
|
+
attachmentLocatorRow = context.db.findProviderLocator("attachment", attachmentId);
|
|
677
|
+
if (!messageLocatorRow || !attachmentLocatorRow) {
|
|
678
|
+
throw new SurfaceError("cache_miss", `Attachment '${attachmentId}' is missing provider locator data required for download.`, {
|
|
679
|
+
account: account.name,
|
|
680
|
+
messageRef,
|
|
681
|
+
threadRef: stored.thread_ref,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
messageLocator = parseOutlookMessageLocator(messageLocatorRow.locator_json);
|
|
685
|
+
attachmentLocator = parseOutlookAttachmentLocator(attachmentLocatorRow.locator_json);
|
|
686
|
+
}
|
|
687
|
+
if (!messageLocator.message_id || !attachmentLocator.attachment_id) {
|
|
688
|
+
throw new SurfaceError("unsupported", `Attachment '${attachmentId}' does not expose enough Outlook metadata for download.`, {
|
|
689
|
+
account: account.name,
|
|
690
|
+
messageRef,
|
|
691
|
+
threadRef: stored.thread_ref,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
const attachment = context.db
|
|
695
|
+
.listAttachmentsForMessage(messageRef)
|
|
696
|
+
.find((candidate) => candidate.attachment_id === attachmentId);
|
|
697
|
+
if (!attachment) {
|
|
698
|
+
throw new SurfaceError("not_found", `Attachment '${attachmentId}' was not found on message '${messageRef}'.`, {
|
|
699
|
+
account: account.name,
|
|
700
|
+
messageRef,
|
|
701
|
+
threadRef: stored.thread_ref,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
return {
|
|
705
|
+
stored,
|
|
706
|
+
messageLocator,
|
|
707
|
+
attachment: {
|
|
708
|
+
attachment_id: attachment.attachment_id,
|
|
709
|
+
filename: attachment.filename,
|
|
710
|
+
mime_type: attachment.mime_type,
|
|
711
|
+
size_bytes: attachment.size_bytes,
|
|
712
|
+
inline: Boolean(attachment.inline),
|
|
713
|
+
saved_to: attachment.saved_to,
|
|
714
|
+
},
|
|
715
|
+
attachmentLocator,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
async function openConversationForAction(page, locator, queryText) {
|
|
719
|
+
await page.goto("https://outlook.office.com/mail/", {
|
|
720
|
+
waitUntil: "domcontentloaded",
|
|
721
|
+
timeout: 30_000,
|
|
722
|
+
});
|
|
723
|
+
await waitForOutlookMailboxReady(page, 30_000);
|
|
724
|
+
await applySearchQuery(page, queryText);
|
|
725
|
+
let row = page.locator(`[role="option"][data-convid="${locator.conversation_id}"]`).first();
|
|
726
|
+
if ((await row.count()) === 0) {
|
|
727
|
+
row = page.locator('[role="option"]').filter({ hasText: queryText }).first();
|
|
728
|
+
}
|
|
729
|
+
await row.waitFor({ timeout: 15_000 });
|
|
730
|
+
await row.click();
|
|
731
|
+
await page.waitForTimeout(1_500);
|
|
732
|
+
}
|
|
733
|
+
async function ensureRecipientField(page, label) {
|
|
734
|
+
let field = page.locator(`[aria-label="${label}"][contenteditable="true"]`).first();
|
|
735
|
+
if ((await field.count()) > 0) {
|
|
736
|
+
return field;
|
|
737
|
+
}
|
|
738
|
+
const toggle = page.getByText(label, { exact: true }).last();
|
|
739
|
+
await toggle.click();
|
|
740
|
+
await page.waitForTimeout(400);
|
|
741
|
+
field = page.locator(`[aria-label="${label}"][contenteditable="true"]`).first();
|
|
742
|
+
await field.waitFor({ timeout: 10_000 });
|
|
743
|
+
return field;
|
|
744
|
+
}
|
|
745
|
+
async function fillRecipientField(page, label, recipients) {
|
|
746
|
+
const normalizedRecipients = normalizeEmailList(recipients);
|
|
747
|
+
if (normalizedRecipients.length === 0) {
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const field = await ensureRecipientField(page, label);
|
|
751
|
+
for (const recipient of normalizedRecipients) {
|
|
752
|
+
await field.click();
|
|
753
|
+
await field.type(recipient);
|
|
754
|
+
await field.press("Enter");
|
|
755
|
+
await page.waitForTimeout(150);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
async function fillComposeBody(page, body) {
|
|
759
|
+
const editor = page.locator('[role="textbox"][aria-label="Message body"]').first();
|
|
760
|
+
await editor.waitFor({ timeout: 15_000 });
|
|
761
|
+
await editor.click();
|
|
762
|
+
if (body.trim()) {
|
|
763
|
+
await editor.type(body);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
async function sendCurrentCompose(page) {
|
|
767
|
+
await page.locator('button[aria-label="Send"]').first().click();
|
|
768
|
+
await page.waitForTimeout(4_000);
|
|
769
|
+
}
|
|
770
|
+
async function saveCurrentComposeDraft(page) {
|
|
771
|
+
await page.waitForTimeout(6_000);
|
|
772
|
+
const closeButtons = page.locator('button[aria-label="Close"]:visible');
|
|
773
|
+
if ((await closeButtons.count()) > 0) {
|
|
774
|
+
await closeButtons.first().click();
|
|
775
|
+
await page.waitForTimeout(2_000);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
await page.waitForTimeout(1_000);
|
|
779
|
+
}
|
|
780
|
+
async function finalizeCurrentCompose(page, disposition) {
|
|
781
|
+
if (disposition === "draft") {
|
|
782
|
+
await saveCurrentComposeDraft(page);
|
|
783
|
+
return "drafted";
|
|
784
|
+
}
|
|
785
|
+
await sendCurrentCompose(page);
|
|
786
|
+
return "sent";
|
|
787
|
+
}
|
|
788
|
+
async function clickReplyAllAction(page) {
|
|
789
|
+
const primaryButton = page.getByRole("button", { name: /reply all/i }).first();
|
|
790
|
+
if ((await primaryButton.count()) > 0) {
|
|
791
|
+
await primaryButton.click();
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
await page.locator('button[aria-label="More items"]').last().click();
|
|
795
|
+
await page.waitForTimeout(600);
|
|
796
|
+
const fallback = page.getByRole("menuitem", { name: /reply all/i }).first();
|
|
797
|
+
await fallback.waitFor({ timeout: 10_000 });
|
|
798
|
+
await fallback.click({ force: true });
|
|
799
|
+
await page.waitForTimeout(800);
|
|
800
|
+
const inlineActivator = page.locator('div[aria-label="Reply all"]').last();
|
|
801
|
+
if ((await inlineActivator.count()) > 0) {
|
|
802
|
+
await inlineActivator.click({ force: true });
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
async function findResolvedSearchTarget(account, subject, context) {
|
|
806
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
807
|
+
const threads = await fetchOutlookThreads(account, context, {
|
|
808
|
+
kind: "search",
|
|
809
|
+
queryText: subject,
|
|
810
|
+
limit: 5,
|
|
811
|
+
summarize: false,
|
|
812
|
+
});
|
|
813
|
+
const thread = threads.find((candidate) => candidate.envelope.subject.includes(subject)) ?? threads[0];
|
|
814
|
+
if (thread) {
|
|
815
|
+
const messageRef = thread.messages[0]?.message_ref ?? null;
|
|
816
|
+
return {
|
|
817
|
+
thread_ref: thread.thread_ref,
|
|
818
|
+
message_ref: messageRef,
|
|
819
|
+
stored: messageRef ? context.db.getStoredMessage(messageRef) ?? null : null,
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
823
|
+
}
|
|
824
|
+
return {
|
|
825
|
+
thread_ref: null,
|
|
826
|
+
message_ref: null,
|
|
827
|
+
stored: null,
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
function latestStoredThreadMessage(threadRef, context) {
|
|
831
|
+
const messageRef = context.db.listMessageRefsForThread(threadRef)[0] ?? null;
|
|
832
|
+
return {
|
|
833
|
+
message_ref: messageRef,
|
|
834
|
+
stored: messageRef ? context.db.getStoredMessage(messageRef) ?? null : null,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
function buildSendEnvelope(account, command, status, subject, recipients, result, inReplyToMessageRef) {
|
|
838
|
+
return {
|
|
839
|
+
schema_version: "1",
|
|
840
|
+
command,
|
|
841
|
+
account: account.name,
|
|
842
|
+
source: sourceInfo(account),
|
|
843
|
+
status,
|
|
844
|
+
subject,
|
|
845
|
+
recipients,
|
|
846
|
+
thread_ref: result.thread_ref,
|
|
847
|
+
message_ref: result.message_ref,
|
|
848
|
+
in_reply_to_message_ref: inReplyToMessageRef,
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
function buildArchiveEnvelope(account, messageRef, threadRef) {
|
|
852
|
+
return {
|
|
853
|
+
schema_version: "1",
|
|
854
|
+
command: "archive",
|
|
855
|
+
account: account.name,
|
|
856
|
+
message_ref: messageRef,
|
|
857
|
+
thread_ref: threadRef,
|
|
858
|
+
source: sourceInfo(account),
|
|
859
|
+
status: "archived",
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
function buildMarkMessagesEnvelope(account, command, updated) {
|
|
863
|
+
return {
|
|
864
|
+
schema_version: "1",
|
|
865
|
+
command,
|
|
866
|
+
account: account.name,
|
|
867
|
+
source: sourceInfo(account),
|
|
868
|
+
updated,
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
function buildCreateItemHeaders(headers) {
|
|
872
|
+
return {
|
|
873
|
+
...headers,
|
|
874
|
+
action: "CreateItem",
|
|
875
|
+
"content-type": "application/json; charset=utf-8",
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
function buildOutlookRestHeaders(headers) {
|
|
879
|
+
const restHeaders = {};
|
|
880
|
+
if (headers.authorization) {
|
|
881
|
+
restHeaders.authorization = headers.authorization;
|
|
882
|
+
}
|
|
883
|
+
if (headers.prefer) {
|
|
884
|
+
restHeaders.prefer = headers.prefer;
|
|
885
|
+
}
|
|
886
|
+
return restHeaders;
|
|
887
|
+
}
|
|
888
|
+
function buildOutlookRestAttachmentValueUrl(messageId, attachmentId) {
|
|
889
|
+
return `https://outlook.office.com/api/v2.0/me/messages('${encodeURIComponent(messageId)}')/attachments('${encodeURIComponent(attachmentId)}')/$value`;
|
|
890
|
+
}
|
|
891
|
+
function buildOutlookRestMessageUrl(messageId) {
|
|
892
|
+
return `https://outlook.office.com/api/v2.0/me/messages('${encodeURIComponent(messageId)}')`;
|
|
893
|
+
}
|
|
894
|
+
function buildMeetingResponseType(response) {
|
|
895
|
+
switch (response) {
|
|
896
|
+
case "accept":
|
|
897
|
+
return "AcceptItem";
|
|
898
|
+
case "decline":
|
|
899
|
+
return "DeclineItem";
|
|
900
|
+
case "tentative":
|
|
901
|
+
return "TentativelyAcceptItem";
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
function buildMeetingResponsePayload(locator, response) {
|
|
905
|
+
if (!locator.message_id || !locator.message_change_key) {
|
|
906
|
+
throw new SurfaceError("unsupported", "Outlook RSVP requires a message id and change key.");
|
|
907
|
+
}
|
|
908
|
+
return {
|
|
909
|
+
__type: "CreateItemJsonRequest:#Exchange",
|
|
910
|
+
Header: {
|
|
911
|
+
__type: "JsonRequestHeaders:#Exchange",
|
|
912
|
+
RequestServerVersion: "V2017_08_18",
|
|
913
|
+
TimeZoneContext: {
|
|
914
|
+
__type: "TimeZoneContext:#Exchange",
|
|
915
|
+
TimeZoneDefinition: {
|
|
916
|
+
__type: "TimeZoneDefinitionType:#Exchange",
|
|
917
|
+
Id: "GMT Standard Time",
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
},
|
|
921
|
+
Body: {
|
|
922
|
+
__type: "CreateItemRequest:#Exchange",
|
|
923
|
+
MessageDisposition: "SendAndSaveCopy",
|
|
924
|
+
Items: [
|
|
925
|
+
{
|
|
926
|
+
__type: `${buildMeetingResponseType(response)}:#Exchange`,
|
|
927
|
+
ReferenceItemId: {
|
|
928
|
+
__type: "ItemId:#Exchange",
|
|
929
|
+
Id: locator.message_id,
|
|
930
|
+
ChangeKey: locator.message_change_key,
|
|
931
|
+
},
|
|
932
|
+
},
|
|
933
|
+
],
|
|
934
|
+
},
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
async function performOutlookRsvpAction(session, locator, response, timeoutMs) {
|
|
938
|
+
const capturedSession = await captureOutlookServiceSession(session.context, session.page, {
|
|
939
|
+
timeoutMs,
|
|
940
|
+
});
|
|
941
|
+
const serviceResponse = await session.context.request.post(`${capturedSession.serviceUrl}?action=CreateItem&app=Mail&n=999`, {
|
|
942
|
+
headers: buildCreateItemHeaders(capturedSession.headers),
|
|
943
|
+
data: JSON.stringify(buildMeetingResponsePayload(locator, response)),
|
|
944
|
+
timeout: timeoutMs,
|
|
945
|
+
});
|
|
946
|
+
if (!serviceResponse.ok()) {
|
|
947
|
+
throw new SurfaceError("transport_error", `Outlook RSVP request failed with status ${serviceResponse.status()}.`);
|
|
948
|
+
}
|
|
949
|
+
const data = await serviceResponse.json();
|
|
950
|
+
const responseMessage = data?.Body?.ResponseMessages?.Items?.[0];
|
|
951
|
+
if (responseMessage?.ResponseCode !== "NoError") {
|
|
952
|
+
throw new SurfaceError("transport_error", `Outlook RSVP request failed with response code '${responseMessage?.ResponseCode ?? "UnknownError"}'.`);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
async function performOutlookReadStateAction(session, locator, unread, timeoutMs) {
|
|
956
|
+
if (!locator.message_id) {
|
|
957
|
+
throw new SurfaceError("unsupported", "Outlook read-state mutation requires a message id.");
|
|
958
|
+
}
|
|
959
|
+
const capturedSession = await captureOutlookServiceSession(session.context, session.page, {
|
|
960
|
+
timeoutMs,
|
|
961
|
+
});
|
|
962
|
+
const response = await session.context.request.fetch(buildOutlookRestMessageUrl(locator.message_id), {
|
|
963
|
+
method: "PATCH",
|
|
964
|
+
headers: {
|
|
965
|
+
...buildOutlookRestHeaders(capturedSession.headers),
|
|
966
|
+
"content-type": "application/json",
|
|
967
|
+
},
|
|
968
|
+
data: JSON.stringify({ IsRead: !unread }),
|
|
969
|
+
failOnStatusCode: false,
|
|
970
|
+
timeout: timeoutMs,
|
|
971
|
+
});
|
|
972
|
+
if (!response.ok()) {
|
|
973
|
+
throw new SurfaceError("transport_error", `Outlook read-state update failed with status ${response.status()}.`);
|
|
974
|
+
}
|
|
975
|
+
const data = await response.json();
|
|
976
|
+
if (typeof data?.IsRead !== "boolean" || data.IsRead !== !unread) {
|
|
977
|
+
throw new SurfaceError("transport_error", `Outlook read-state update did not return the expected IsRead=${!unread} state.`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
function buildRsvpEnvelope(account, messageRef, threadRef, response, invite) {
|
|
981
|
+
return {
|
|
982
|
+
schema_version: "1",
|
|
983
|
+
command: "rsvp",
|
|
984
|
+
account: account.name,
|
|
985
|
+
message_ref: messageRef,
|
|
986
|
+
thread_ref: threadRef,
|
|
987
|
+
source: {
|
|
988
|
+
provider: account.provider,
|
|
989
|
+
transport: account.transport,
|
|
990
|
+
},
|
|
991
|
+
response,
|
|
992
|
+
invite: invite ?? null,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
function buildReadEnvelope(account, messageRef, threadRef, parsed, attachments, cacheStatus) {
|
|
996
|
+
return {
|
|
997
|
+
schema_version: "1",
|
|
998
|
+
command: "read",
|
|
999
|
+
account: account.name,
|
|
1000
|
+
message_ref: messageRef,
|
|
1001
|
+
thread_ref: threadRef,
|
|
1002
|
+
source: {
|
|
1003
|
+
provider: account.provider,
|
|
1004
|
+
transport: account.transport,
|
|
1005
|
+
},
|
|
1006
|
+
cache: {
|
|
1007
|
+
status: cacheStatus,
|
|
1008
|
+
truncated: parsed.body.truncated,
|
|
1009
|
+
},
|
|
1010
|
+
message: {
|
|
1011
|
+
envelope: parsed.envelope,
|
|
1012
|
+
body: parsed.body,
|
|
1013
|
+
attachments,
|
|
1014
|
+
...(parsed.invite ? { invite: parsed.invite } : {}),
|
|
1015
|
+
},
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
async function fetchOutlookThreads(account, context, options) {
|
|
1019
|
+
const profileDir = outlookProfileDir(context);
|
|
1020
|
+
if (!existsSync(profileDir)) {
|
|
1021
|
+
throw new SurfaceError("reauth_required", "Outlook profile directory is missing for this account.", {
|
|
1022
|
+
account: account.name,
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
const session = await launchOutlookSession(profileDir, { headless: true });
|
|
1026
|
+
try {
|
|
1027
|
+
const { context: browserContext, page } = session;
|
|
1028
|
+
const capturedSession = await captureOutlookServiceSession(browserContext, page, {
|
|
1029
|
+
timeoutMs: context.config.providerTimeoutMs,
|
|
1030
|
+
});
|
|
1031
|
+
let conversationIds;
|
|
1032
|
+
if (options.kind === "fetch-unread") {
|
|
1033
|
+
await applyUnreadFilter(page);
|
|
1034
|
+
conversationIds = await collectUnreadConversationIds(page, options.limit);
|
|
1035
|
+
}
|
|
1036
|
+
else {
|
|
1037
|
+
await applySearchQuery(page, options.queryText ?? "");
|
|
1038
|
+
conversationIds = await collectSearchConversationIds(page, options.limit);
|
|
1039
|
+
}
|
|
1040
|
+
const bundles = [];
|
|
1041
|
+
for (const conversationId of conversationIds) {
|
|
1042
|
+
bundles.push(await fetchConversationBundle(browserContext.request, capturedSession, conversationId));
|
|
1043
|
+
}
|
|
1044
|
+
const normalized = bundles.map((bundle) => normalizeThread(bundle));
|
|
1045
|
+
const summarized = options.summarize === false ? normalized : await maybeSummarizeThreads(normalized, context);
|
|
1046
|
+
return await persistThreads(account, context, summarized);
|
|
1047
|
+
}
|
|
1048
|
+
finally {
|
|
1049
|
+
await session.context.close();
|
|
1050
|
+
session.cleanup?.();
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
async function mutateOutlookReadState(account, messageRefs, unread, context) {
|
|
1054
|
+
if (messageRefs.length === 0) {
|
|
1055
|
+
throw new SurfaceError("invalid_argument", "At least one message ref is required.", {
|
|
1056
|
+
account: account.name,
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
assertWriteAllowed(context.config, account, { to: [], cc: [], bcc: [] }, { disposition: "non_send" });
|
|
1060
|
+
const targets = [];
|
|
1061
|
+
for (const messageRef of messageRefs) {
|
|
1062
|
+
targets.push(await resolveMessageStateTarget(account, messageRef, context));
|
|
1063
|
+
}
|
|
1064
|
+
const profileDir = outlookProfileDir(context);
|
|
1065
|
+
const session = await launchOutlookSession(profileDir, { headless: true });
|
|
1066
|
+
try {
|
|
1067
|
+
for (const target of targets) {
|
|
1068
|
+
await performOutlookReadStateAction(session, target.locator, unread, context.config.providerTimeoutMs);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
finally {
|
|
1072
|
+
await session.context.close();
|
|
1073
|
+
session.cleanup?.();
|
|
1074
|
+
}
|
|
1075
|
+
const threadRefs = targets.map((target) => target.stored.thread_ref);
|
|
1076
|
+
context.db.updateMessagesUnreadState(messageRefs, unread);
|
|
1077
|
+
context.db.recomputeThreadUnreadCounts(threadRefs);
|
|
1078
|
+
return buildMarkMessagesEnvelope(account, unread ? "mark-unread" : "mark-read", targets.map((target, index) => ({
|
|
1079
|
+
message_ref: messageRefs[index],
|
|
1080
|
+
thread_ref: target.stored.thread_ref,
|
|
1081
|
+
unread,
|
|
1082
|
+
})));
|
|
1083
|
+
}
|
|
1084
|
+
export class OutlookWebPlaywrightAdapter {
|
|
1085
|
+
provider = "outlook";
|
|
1086
|
+
transport = "outlook-web-playwright";
|
|
1087
|
+
async login(account, context) {
|
|
1088
|
+
const profileDir = outlookProfileDir(context);
|
|
1089
|
+
const session = await launchOutlookSession(profileDir, { headless: false });
|
|
1090
|
+
try {
|
|
1091
|
+
await session.page.goto("https://outlook.office.com/mail/", { waitUntil: "domcontentloaded" });
|
|
1092
|
+
await promptForOutlookLogin(profileDir);
|
|
1093
|
+
return await probeOutlookAuth(session.page, { timeoutMs: context.config.providerTimeoutMs });
|
|
1094
|
+
}
|
|
1095
|
+
finally {
|
|
1096
|
+
await session.context.close();
|
|
1097
|
+
session.cleanup?.();
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
async logout(_account, context) {
|
|
1101
|
+
const profileDir = outlookProfileDir(context);
|
|
1102
|
+
rmSync(profileDir, { recursive: true, force: true });
|
|
1103
|
+
return {
|
|
1104
|
+
status: "unauthenticated",
|
|
1105
|
+
detail: "Removed the Outlook persistent profile directory for this account.",
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
async authStatus(_account, context) {
|
|
1109
|
+
const profileDir = outlookProfileDir(context);
|
|
1110
|
+
if (!existsSync(profileDir)) {
|
|
1111
|
+
return { status: "unauthenticated", detail: "No Outlook browser profile found for this account." };
|
|
1112
|
+
}
|
|
1113
|
+
const profileEntries = readdirSync(profileDir);
|
|
1114
|
+
if (profileEntries.length === 0) {
|
|
1115
|
+
return { status: "unauthenticated", detail: "Outlook profile directory exists but is empty." };
|
|
1116
|
+
}
|
|
1117
|
+
let session;
|
|
1118
|
+
try {
|
|
1119
|
+
session = await launchOutlookSession(profileDir, { headless: true });
|
|
1120
|
+
return await probeOutlookAuth(session.page, { timeoutMs: context.config.providerTimeoutMs });
|
|
1121
|
+
}
|
|
1122
|
+
catch (error) {
|
|
1123
|
+
return {
|
|
1124
|
+
status: "unknown",
|
|
1125
|
+
detail: error instanceof Error
|
|
1126
|
+
? `Could not probe Outlook auth state: ${error.message}`
|
|
1127
|
+
: "Could not probe Outlook auth state.",
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
finally {
|
|
1131
|
+
await session?.context.close();
|
|
1132
|
+
session?.cleanup?.();
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
async search(account, query, context) {
|
|
1136
|
+
return fetchOutlookThreads(account, context, {
|
|
1137
|
+
kind: "search",
|
|
1138
|
+
queryText: query.text,
|
|
1139
|
+
limit: query.limit,
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
async fetchUnread(account, query, context) {
|
|
1143
|
+
return fetchOutlookThreads(account, context, {
|
|
1144
|
+
kind: "fetch-unread",
|
|
1145
|
+
limit: query.limit,
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
async readMessage(account, messageRef, refresh, context) {
|
|
1149
|
+
const stored = context.db.getStoredMessage(messageRef);
|
|
1150
|
+
if (!stored) {
|
|
1151
|
+
throw new SurfaceError("not_found", `Message '${messageRef}' was not found.`, {
|
|
1152
|
+
account: account.name,
|
|
1153
|
+
messageRef,
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
const attachments = context.db.listAttachmentsForMessage(messageRef).map((attachment) => ({
|
|
1157
|
+
attachment_id: attachment.attachment_id,
|
|
1158
|
+
filename: attachment.filename,
|
|
1159
|
+
mime_type: attachment.mime_type,
|
|
1160
|
+
size_bytes: attachment.size_bytes,
|
|
1161
|
+
inline: Boolean(attachment.inline),
|
|
1162
|
+
}));
|
|
1163
|
+
const hasReadableCache = Boolean(stored.body_cache_path && existsSync(stored.body_cache_path));
|
|
1164
|
+
if (!refresh && hasReadableCache) {
|
|
1165
|
+
return buildReadEnvelope(account, messageRef, stored.thread_ref, parseStoredMessage(stored), attachments, "hit");
|
|
1166
|
+
}
|
|
1167
|
+
const refreshed = await refreshStoredMessage(account, messageRef, context);
|
|
1168
|
+
const refreshedAttachments = context.db.listAttachmentsForMessage(messageRef).map((attachment) => ({
|
|
1169
|
+
attachment_id: attachment.attachment_id,
|
|
1170
|
+
filename: attachment.filename,
|
|
1171
|
+
mime_type: attachment.mime_type,
|
|
1172
|
+
size_bytes: attachment.size_bytes,
|
|
1173
|
+
inline: Boolean(attachment.inline),
|
|
1174
|
+
}));
|
|
1175
|
+
return buildReadEnvelope(account, messageRef, refreshed.thread_ref, parseStoredMessage(refreshed), refreshedAttachments, "refreshed");
|
|
1176
|
+
}
|
|
1177
|
+
async listAttachments(account, messageRef, context) {
|
|
1178
|
+
const message = context.db.findMessageByRef(messageRef);
|
|
1179
|
+
if (!message) {
|
|
1180
|
+
throw new SurfaceError("not_found", `Message '${messageRef}' was not found.`, {
|
|
1181
|
+
account: account.name,
|
|
1182
|
+
messageRef,
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
return {
|
|
1186
|
+
schema_version: "1",
|
|
1187
|
+
command: "attachment-list",
|
|
1188
|
+
account: account.name,
|
|
1189
|
+
message_ref: messageRef,
|
|
1190
|
+
attachments: context.db.listAttachmentsForMessage(messageRef).map((attachment) => ({
|
|
1191
|
+
attachment_id: attachment.attachment_id,
|
|
1192
|
+
filename: attachment.filename,
|
|
1193
|
+
mime_type: attachment.mime_type,
|
|
1194
|
+
size_bytes: attachment.size_bytes,
|
|
1195
|
+
inline: Boolean(attachment.inline),
|
|
1196
|
+
})),
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
async rsvp(account, messageRef, response, context) {
|
|
1200
|
+
const { locator, stored } = await resolveRsvpLocator(account, messageRef, context);
|
|
1201
|
+
const profileDir = outlookProfileDir(context);
|
|
1202
|
+
const session = await launchOutlookSession(profileDir, { headless: true });
|
|
1203
|
+
try {
|
|
1204
|
+
await performOutlookRsvpAction(session, locator, response, context.config.providerTimeoutMs);
|
|
1205
|
+
}
|
|
1206
|
+
finally {
|
|
1207
|
+
await session.context.close();
|
|
1208
|
+
session.cleanup?.();
|
|
1209
|
+
}
|
|
1210
|
+
const refreshed = await refreshStoredMessage(account, messageRef, context);
|
|
1211
|
+
const parsed = parseStoredMessage(refreshed);
|
|
1212
|
+
return buildRsvpEnvelope(account, messageRef, stored.thread_ref, response, parsed.invite);
|
|
1213
|
+
}
|
|
1214
|
+
async markRead(account, messageRefs, context) {
|
|
1215
|
+
return mutateOutlookReadState(account, messageRefs, false, context);
|
|
1216
|
+
}
|
|
1217
|
+
async markUnread(account, messageRefs, context) {
|
|
1218
|
+
return mutateOutlookReadState(account, messageRefs, true, context);
|
|
1219
|
+
}
|
|
1220
|
+
async sendMessage(account, input, context) {
|
|
1221
|
+
const normalizedInput = {
|
|
1222
|
+
to: normalizeEmailList(input.to),
|
|
1223
|
+
cc: normalizeEmailList(input.cc),
|
|
1224
|
+
bcc: normalizeEmailList(input.bcc),
|
|
1225
|
+
subject: input.subject.trim(),
|
|
1226
|
+
body: input.body,
|
|
1227
|
+
draft: input.draft,
|
|
1228
|
+
};
|
|
1229
|
+
if (normalizedInput.to.length === 0) {
|
|
1230
|
+
throw new SurfaceError("invalid_argument", "Send requires at least one --to recipient.", {
|
|
1231
|
+
account: account.name,
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
if (!normalizedInput.subject) {
|
|
1235
|
+
throw new SurfaceError("invalid_argument", "Send requires a non-empty --subject.", {
|
|
1236
|
+
account: account.name,
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
assertWriteAllowed(context.config, account, collectWriteRecipients(normalizedInput), { disposition: normalizedInput.draft ? "draft" : "send" });
|
|
1240
|
+
let status = "sent";
|
|
1241
|
+
const profileDir = outlookProfileDir(context);
|
|
1242
|
+
const session = await launchOutlookSession(profileDir, { headless: true });
|
|
1243
|
+
try {
|
|
1244
|
+
await session.page.goto("https://outlook.office.com/mail/", {
|
|
1245
|
+
waitUntil: "domcontentloaded",
|
|
1246
|
+
timeout: context.config.providerTimeoutMs,
|
|
1247
|
+
});
|
|
1248
|
+
await waitForOutlookMailboxReady(session.page, context.config.providerTimeoutMs);
|
|
1249
|
+
await session.page.locator('button[aria-label="New email"]').first().click();
|
|
1250
|
+
await session.page.waitForTimeout(1_500);
|
|
1251
|
+
await fillRecipientField(session.page, "To", normalizedInput.to);
|
|
1252
|
+
await fillRecipientField(session.page, "Cc", normalizedInput.cc);
|
|
1253
|
+
await fillRecipientField(session.page, "Bcc", normalizedInput.bcc);
|
|
1254
|
+
await session.page.locator('input[aria-label="Subject"]').fill(normalizedInput.subject);
|
|
1255
|
+
await fillComposeBody(session.page, normalizedInput.body);
|
|
1256
|
+
status = await finalizeCurrentCompose(session.page, normalizedInput.draft ? "draft" : "send");
|
|
1257
|
+
}
|
|
1258
|
+
finally {
|
|
1259
|
+
await session.context.close();
|
|
1260
|
+
session.cleanup?.();
|
|
1261
|
+
}
|
|
1262
|
+
const resolved = await findResolvedSearchTarget(account, normalizedInput.subject, context);
|
|
1263
|
+
return buildSendEnvelope(account, "send", status, normalizedInput.subject, recipientsFromStored(resolved.stored ?? undefined, normalizedInput), resolved, null);
|
|
1264
|
+
}
|
|
1265
|
+
async reply(account, messageRef, input, context) {
|
|
1266
|
+
const target = await resolveMessageActionTarget(account, messageRef, context);
|
|
1267
|
+
const normalizedInput = {
|
|
1268
|
+
cc: normalizeEmailList(input.cc),
|
|
1269
|
+
bcc: normalizeEmailList(input.bcc),
|
|
1270
|
+
body: input.body,
|
|
1271
|
+
draft: input.draft,
|
|
1272
|
+
};
|
|
1273
|
+
assertWriteAllowed(context.config, account, collectWriteRecipients({ to: [], ...normalizedInput }), { disposition: normalizedInput.draft ? "draft" : "send" });
|
|
1274
|
+
let status = "sent";
|
|
1275
|
+
const profileDir = outlookProfileDir(context);
|
|
1276
|
+
const session = await launchOutlookSession(profileDir, { headless: true });
|
|
1277
|
+
try {
|
|
1278
|
+
await openConversationForAction(session.page, target.locator, target.stored.subject ?? target.locator.internet_message_id ?? messageRef);
|
|
1279
|
+
await session.page.locator('button[aria-label="Reply"]').first().click();
|
|
1280
|
+
await session.page.waitForTimeout(1_500);
|
|
1281
|
+
await fillRecipientField(session.page, "Cc", normalizedInput.cc);
|
|
1282
|
+
await fillRecipientField(session.page, "Bcc", normalizedInput.bcc);
|
|
1283
|
+
await fillComposeBody(session.page, normalizedInput.body);
|
|
1284
|
+
status = await finalizeCurrentCompose(session.page, normalizedInput.draft ? "draft" : "send");
|
|
1285
|
+
}
|
|
1286
|
+
finally {
|
|
1287
|
+
await session.context.close();
|
|
1288
|
+
session.cleanup?.();
|
|
1289
|
+
}
|
|
1290
|
+
await refreshOutlookConversation(account, target.locator.conversation_id, context);
|
|
1291
|
+
const latest = latestStoredThreadMessage(target.stored.thread_ref, context);
|
|
1292
|
+
const subject = latest.stored?.subject ?? target.stored.subject ?? "";
|
|
1293
|
+
return buildSendEnvelope(account, "reply", status, subject, recipientsFromStored(latest.stored ?? undefined, { to: [], cc: normalizedInput.cc, bcc: normalizedInput.bcc }), { thread_ref: target.stored.thread_ref, message_ref: latest.message_ref }, messageRef);
|
|
1294
|
+
}
|
|
1295
|
+
async replyAll(account, messageRef, input, context) {
|
|
1296
|
+
const target = await resolveMessageActionTarget(account, messageRef, context);
|
|
1297
|
+
const normalizedInput = {
|
|
1298
|
+
cc: normalizeEmailList(input.cc),
|
|
1299
|
+
bcc: normalizeEmailList(input.bcc),
|
|
1300
|
+
body: input.body,
|
|
1301
|
+
draft: input.draft,
|
|
1302
|
+
};
|
|
1303
|
+
assertWriteAllowed(context.config, account, collectWriteRecipients({ to: [], ...normalizedInput }), { disposition: normalizedInput.draft ? "draft" : "send" });
|
|
1304
|
+
let status = "sent";
|
|
1305
|
+
const profileDir = outlookProfileDir(context);
|
|
1306
|
+
const session = await launchOutlookSession(profileDir, { headless: true });
|
|
1307
|
+
try {
|
|
1308
|
+
await openConversationForAction(session.page, target.locator, target.stored.subject ?? target.locator.internet_message_id ?? messageRef);
|
|
1309
|
+
await clickReplyAllAction(session.page);
|
|
1310
|
+
await session.page.waitForTimeout(1_500);
|
|
1311
|
+
await fillRecipientField(session.page, "Cc", normalizedInput.cc);
|
|
1312
|
+
await fillRecipientField(session.page, "Bcc", normalizedInput.bcc);
|
|
1313
|
+
await fillComposeBody(session.page, normalizedInput.body);
|
|
1314
|
+
status = await finalizeCurrentCompose(session.page, normalizedInput.draft ? "draft" : "send");
|
|
1315
|
+
}
|
|
1316
|
+
finally {
|
|
1317
|
+
await session.context.close();
|
|
1318
|
+
session.cleanup?.();
|
|
1319
|
+
}
|
|
1320
|
+
await refreshOutlookConversation(account, target.locator.conversation_id, context);
|
|
1321
|
+
const latest = latestStoredThreadMessage(target.stored.thread_ref, context);
|
|
1322
|
+
const subject = latest.stored?.subject ?? target.stored.subject ?? "";
|
|
1323
|
+
return buildSendEnvelope(account, "reply-all", status, subject, recipientsFromStored(latest.stored ?? undefined, { to: [], cc: normalizedInput.cc, bcc: normalizedInput.bcc }), { thread_ref: target.stored.thread_ref, message_ref: latest.message_ref }, messageRef);
|
|
1324
|
+
}
|
|
1325
|
+
async forward(account, messageRef, input, context) {
|
|
1326
|
+
const target = await resolveMessageActionTarget(account, messageRef, context);
|
|
1327
|
+
const normalizedInput = {
|
|
1328
|
+
to: normalizeEmailList(input.to),
|
|
1329
|
+
cc: normalizeEmailList(input.cc),
|
|
1330
|
+
bcc: normalizeEmailList(input.bcc),
|
|
1331
|
+
body: input.body,
|
|
1332
|
+
draft: input.draft,
|
|
1333
|
+
};
|
|
1334
|
+
if (normalizedInput.to.length === 0) {
|
|
1335
|
+
throw new SurfaceError("invalid_argument", "Forward requires at least one --to recipient.", {
|
|
1336
|
+
account: account.name,
|
|
1337
|
+
messageRef,
|
|
1338
|
+
threadRef: target.stored.thread_ref,
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
assertWriteAllowed(context.config, account, collectWriteRecipients(normalizedInput), { disposition: normalizedInput.draft ? "draft" : "send" });
|
|
1342
|
+
let forwardedSubject = target.stored.subject ?? "";
|
|
1343
|
+
let status = "sent";
|
|
1344
|
+
const profileDir = outlookProfileDir(context);
|
|
1345
|
+
const session = await launchOutlookSession(profileDir, { headless: true });
|
|
1346
|
+
try {
|
|
1347
|
+
await openConversationForAction(session.page, target.locator, target.stored.subject ?? target.locator.internet_message_id ?? messageRef);
|
|
1348
|
+
await session.page.locator('button[aria-label="Forward"]').first().click();
|
|
1349
|
+
await session.page.waitForTimeout(1_500);
|
|
1350
|
+
await fillRecipientField(session.page, "To", normalizedInput.to);
|
|
1351
|
+
await fillRecipientField(session.page, "Cc", normalizedInput.cc);
|
|
1352
|
+
await fillRecipientField(session.page, "Bcc", normalizedInput.bcc);
|
|
1353
|
+
await fillComposeBody(session.page, normalizedInput.body);
|
|
1354
|
+
forwardedSubject = await session.page.locator('input[aria-label="Subject"]').inputValue();
|
|
1355
|
+
status = await finalizeCurrentCompose(session.page, normalizedInput.draft ? "draft" : "send");
|
|
1356
|
+
}
|
|
1357
|
+
finally {
|
|
1358
|
+
await session.context.close();
|
|
1359
|
+
session.cleanup?.();
|
|
1360
|
+
}
|
|
1361
|
+
const resolved = await findResolvedSearchTarget(account, forwardedSubject, context);
|
|
1362
|
+
return buildSendEnvelope(account, "forward", status, forwardedSubject, recipientsFromStored(resolved.stored ?? undefined, normalizedInput), resolved, messageRef);
|
|
1363
|
+
}
|
|
1364
|
+
async archive(account, messageRef, context) {
|
|
1365
|
+
const target = await resolveMessageActionTarget(account, messageRef, context);
|
|
1366
|
+
assertWriteAllowed(context.config, account, { to: [], cc: [], bcc: [] }, { disposition: "non_send" });
|
|
1367
|
+
const profileDir = outlookProfileDir(context);
|
|
1368
|
+
const session = await launchOutlookSession(profileDir, { headless: true });
|
|
1369
|
+
try {
|
|
1370
|
+
await session.page.goto("https://outlook.office.com/mail/", {
|
|
1371
|
+
waitUntil: "domcontentloaded",
|
|
1372
|
+
timeout: context.config.providerTimeoutMs,
|
|
1373
|
+
});
|
|
1374
|
+
await waitForOutlookMailboxReady(session.page, context.config.providerTimeoutMs);
|
|
1375
|
+
let row = session.page.locator(`[role="option"][data-convid="${target.locator.conversation_id}"]`).first();
|
|
1376
|
+
if ((await row.count()) === 0) {
|
|
1377
|
+
row = session.page
|
|
1378
|
+
.locator('[role="option"]')
|
|
1379
|
+
.filter({ hasText: target.stored.subject ?? target.locator.internet_message_id ?? messageRef })
|
|
1380
|
+
.first();
|
|
1381
|
+
}
|
|
1382
|
+
await row.waitFor({ timeout: 15_000 });
|
|
1383
|
+
await row.click();
|
|
1384
|
+
await session.page.waitForTimeout(1_500);
|
|
1385
|
+
await session.page.locator('button[aria-label="Archive"]').first().click();
|
|
1386
|
+
await session.page.waitForTimeout(2_500);
|
|
1387
|
+
}
|
|
1388
|
+
finally {
|
|
1389
|
+
await session.context.close();
|
|
1390
|
+
session.cleanup?.();
|
|
1391
|
+
}
|
|
1392
|
+
context.db.markThreadArchived(target.stored.thread_ref);
|
|
1393
|
+
return buildArchiveEnvelope(account, messageRef, target.stored.thread_ref);
|
|
1394
|
+
}
|
|
1395
|
+
async downloadAttachment(account, messageRef, attachmentId, context) {
|
|
1396
|
+
const target = await resolveAttachmentDownloadTarget(account, messageRef, attachmentId, context);
|
|
1397
|
+
const profileDir = outlookProfileDir(context);
|
|
1398
|
+
const session = await launchOutlookSession(profileDir, { headless: true });
|
|
1399
|
+
let savedTo = null;
|
|
1400
|
+
try {
|
|
1401
|
+
const capturedSession = await captureOutlookServiceSession(session.context, session.page, {
|
|
1402
|
+
timeoutMs: context.config.providerTimeoutMs,
|
|
1403
|
+
});
|
|
1404
|
+
const response = await session.context.request.get(buildOutlookRestAttachmentValueUrl(target.messageLocator.message_id, target.attachmentLocator.attachment_id), {
|
|
1405
|
+
headers: buildOutlookRestHeaders(capturedSession.headers),
|
|
1406
|
+
failOnStatusCode: false,
|
|
1407
|
+
timeout: context.config.providerTimeoutMs,
|
|
1408
|
+
});
|
|
1409
|
+
if (!response.ok()) {
|
|
1410
|
+
throw new SurfaceError("transport_error", `Outlook attachment download failed with status ${response.status()}.`, {
|
|
1411
|
+
account: account.name,
|
|
1412
|
+
messageRef,
|
|
1413
|
+
threadRef: target.stored.thread_ref,
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
const downloadDir = join(context.accountPaths.downloadsDir, messageRef);
|
|
1417
|
+
mkdirSync(downloadDir, { recursive: true });
|
|
1418
|
+
const filename = sanitizeAttachmentFilename(target.attachment.filename);
|
|
1419
|
+
savedTo = join(downloadDir, `${attachmentId}__${filename}`);
|
|
1420
|
+
writeFileSync(savedTo, await response.body());
|
|
1421
|
+
}
|
|
1422
|
+
finally {
|
|
1423
|
+
await session.context.close();
|
|
1424
|
+
session.cleanup?.();
|
|
1425
|
+
}
|
|
1426
|
+
context.db.updateAttachmentSavedTo(attachmentId, savedTo);
|
|
1427
|
+
return {
|
|
1428
|
+
schema_version: "1",
|
|
1429
|
+
command: "attachment-download",
|
|
1430
|
+
account: account.name,
|
|
1431
|
+
message_ref: messageRef,
|
|
1432
|
+
attachment: {
|
|
1433
|
+
attachment_id: attachmentId,
|
|
1434
|
+
filename: target.attachment.filename,
|
|
1435
|
+
mime_type: target.attachment.mime_type,
|
|
1436
|
+
size_bytes: target.attachment.size_bytes,
|
|
1437
|
+
inline: target.attachment.inline,
|
|
1438
|
+
saved_to: savedTo,
|
|
1439
|
+
},
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
//# sourceMappingURL=adapter.js.map
|