surface-cli 0.1.1 → 0.3.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.
Files changed (41) hide show
  1. package/README.md +42 -15
  2. package/dist/cli.js +236 -21
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.js +2 -2
  5. package/dist/config.js.map +1 -1
  6. package/dist/contracts/account.js +8 -0
  7. package/dist/contracts/account.js.map +1 -1
  8. package/dist/e2e/gmail-v1.js +31 -0
  9. package/dist/e2e/gmail-v1.js.map +1 -1
  10. package/dist/e2e/outlook-v1.js +11 -0
  11. package/dist/e2e/outlook-v1.js.map +1 -1
  12. package/dist/lib/remote-auth.js +18 -2
  13. package/dist/lib/remote-auth.js.map +1 -1
  14. package/dist/lib/stored-mail.js +69 -0
  15. package/dist/lib/stored-mail.js.map +1 -0
  16. package/dist/paths.js +2 -0
  17. package/dist/paths.js.map +1 -1
  18. package/dist/providers/gmail/adapter.js +336 -53
  19. package/dist/providers/gmail/adapter.js.map +1 -1
  20. package/dist/providers/gmail/api.js +68 -0
  21. package/dist/providers/gmail/api.js.map +1 -1
  22. package/dist/providers/gmail/normalize.js.map +1 -1
  23. package/dist/providers/gmail/oauth.js +4 -0
  24. package/dist/providers/gmail/oauth.js.map +1 -1
  25. package/dist/providers/outlook/adapter.js +185 -97
  26. package/dist/providers/outlook/adapter.js.map +1 -1
  27. package/dist/providers/outlook/extract.js +7 -0
  28. package/dist/providers/outlook/extract.js.map +1 -1
  29. package/dist/providers/shared/inline-attachments.js +17 -0
  30. package/dist/providers/shared/inline-attachments.js.map +1 -0
  31. package/dist/refs.js +3 -0
  32. package/dist/refs.js.map +1 -1
  33. package/dist/session-daemon.js +218 -0
  34. package/dist/session-daemon.js.map +1 -0
  35. package/dist/session.js +283 -0
  36. package/dist/session.js.map +1 -0
  37. package/dist/state/database.js +542 -8
  38. package/dist/state/database.js.map +1 -1
  39. package/dist/summarizer.js +259 -76
  40. package/dist/summarizer.js.map +1 -1
  41. package/package.json +1 -1
@@ -3,13 +3,33 @@ import { join } from "node:path";
3
3
  import { SurfaceError } from "../../lib/errors.js";
4
4
  import { assertWriteAllowed, collectWriteRecipients } from "../../lib/write-safety.js";
5
5
  import { makeAttachmentId, makeMessageRef, makeThreadRef } from "../../refs.js";
6
- import { summarizeThread } from "../../summarizer.js";
6
+ import { summarizeAndPersistThreads } from "../../summarizer.js";
7
+ import { annotateBodyWithInlineAttachments } from "../shared/inline-attachments.js";
7
8
  import { launchOutlookSession, probeOutlookAuth, promptForOutlookLogin } from "./session.js";
8
- import { applySearchQuery, applyUnreadFilter, captureOutlookServiceSession, collectSearchConversationIds, collectUnreadConversationIds, fetchConversationBundle, waitForOutlookMailboxReady, } from "./extract.js";
9
+ import { applySearchQuery, applyUnreadFilter, captureOutlookServiceSession, collectCurrentConversationIds, collectSearchConversationIds, collectUnreadConversationIds, fetchConversationBundle, waitForOutlookMailboxReady, } from "./extract.js";
9
10
  import { buildOutlookInvite, itemIdData, mailboxFromExchange, mailboxesFromExchange, messageIdentity, normalizeOutlookBody, } from "./normalize.js";
10
11
  function outlookProfileDir(context) {
11
12
  return join(context.accountPaths.authDir, "profile");
12
13
  }
14
+ async function withManagedOutlookSession(context, browserSession, work) {
15
+ if (browserSession) {
16
+ return work(browserSession);
17
+ }
18
+ const profileDir = outlookProfileDir(context);
19
+ if (!existsSync(profileDir)) {
20
+ throw new SurfaceError("reauth_required", "Outlook profile directory is missing for this account.", {
21
+ account: null,
22
+ });
23
+ }
24
+ const session = await launchOutlookSession(profileDir, { headless: true });
25
+ try {
26
+ return await work(session);
27
+ }
28
+ finally {
29
+ await session.context.close();
30
+ session.cleanup?.();
31
+ }
32
+ }
13
33
  function threadProviderKey(conversationId) {
14
34
  return `outlook-thread:${conversationId}`;
15
35
  }
@@ -51,6 +71,38 @@ function uniqueParticipants(messages) {
51
71
  }
52
72
  return participants;
53
73
  }
74
+ function quoteOutlookSearchValue(value) {
75
+ const normalized = value.trim();
76
+ if (!normalized) {
77
+ return '""';
78
+ }
79
+ return /[\s"]/u.test(normalized) ? `"${normalized.replace(/"/g, '""')}"` : normalized;
80
+ }
81
+ function buildOutlookSearchQuery(query) {
82
+ const parts = [];
83
+ if (query.text?.trim()) {
84
+ parts.push(query.text.trim());
85
+ }
86
+ if (query.from?.trim()) {
87
+ parts.push(`from:${quoteOutlookSearchValue(query.from)}`);
88
+ }
89
+ if (query.subject?.trim()) {
90
+ parts.push(`subject:${quoteOutlookSearchValue(query.subject)}`);
91
+ }
92
+ return parts.length > 0 ? parts.join(" AND ") : undefined;
93
+ }
94
+ function threadMatchesStructuredFilters(thread, query) {
95
+ if (query.mailbox?.trim() && thread.envelope.mailbox.trim().toLowerCase() !== query.mailbox.trim().toLowerCase()) {
96
+ return false;
97
+ }
98
+ if ((query.labels?.length ?? 0) > 0) {
99
+ const available = new Set(thread.envelope.labels.map((label) => label.trim().toLowerCase()));
100
+ if ((query.labels ?? []).some((label) => !available.has(label.trim().toLowerCase()))) {
101
+ return false;
102
+ }
103
+ }
104
+ return true;
105
+ }
54
106
  function inferThreadRsvpStatus(messages) {
55
107
  let latest = null;
56
108
  for (const message of messages) {
@@ -105,7 +157,6 @@ function buildAttachments(entry, messageKey) {
105
157
  }
106
158
  function normalizeMessage(entry, conversationId) {
107
159
  const item = entry.item;
108
- const body = normalizeOutlookBody(item);
109
160
  const invite = buildOutlookInvite(item);
110
161
  const from = mailboxFromExchange(item.From ?? null)
111
162
  ?? mailboxFromExchange(item.Sender ?? null);
@@ -118,6 +169,8 @@ function normalizeMessage(entry, conversationId) {
118
169
  const instanceKey = typeof item.InstanceKey === "string" ? item.InstanceKey : undefined;
119
170
  const key = messageProviderKey(entry, conversationId);
120
171
  const attachments = buildAttachments(entry, key);
172
+ const body = normalizeOutlookBody(item);
173
+ const bodyText = annotateBodyWithInlineAttachments(body.text, attachments);
121
174
  const envelope = {
122
175
  from,
123
176
  to,
@@ -139,12 +192,12 @@ function normalizeMessage(entry, conversationId) {
139
192
  return {
140
193
  message_ref: "",
141
194
  envelope,
142
- snippet: typeof item.Preview === "string" ? item.Preview : body.text.slice(0, 240),
195
+ snippet: typeof item.Preview === "string" ? item.Preview : bodyText.slice(0, 240),
143
196
  body: {
144
- text: body.text,
197
+ text: bodyText,
145
198
  truncated: false,
146
199
  cached: true,
147
- cached_bytes: Buffer.byteLength(body.text, "utf8"),
200
+ cached_bytes: Buffer.byteLength(bodyText, "utf8"),
148
201
  },
149
202
  attachments,
150
203
  ...(invite.is_invite
@@ -269,6 +322,12 @@ function parseOutlookMessageLocator(locatorJson) {
269
322
  meeting_end: stringOrNull(locator.meeting_end),
270
323
  };
271
324
  }
325
+ function parseOutlookThreadLocator(locatorJson) {
326
+ const locator = JSON.parse(locatorJson);
327
+ return {
328
+ conversation_id: typeof locator.conversation_id === "string" ? locator.conversation_id : "",
329
+ };
330
+ }
272
331
  function parseOutlookAttachmentLocator(locatorJson) {
273
332
  const locator = JSON.parse(locatorJson);
274
333
  return {
@@ -447,9 +506,6 @@ async function persistThreads(account, context, threads) {
447
506
  if (threadInviteStatus) {
448
507
  context.db.updateInviteStatusForThread(resolvedThreadRef, threadInviteStatus);
449
508
  }
450
- if (thread.summary) {
451
- context.db.upsertSummary(resolvedThreadRef, thread.summary);
452
- }
453
509
  persistedThreads.push({
454
510
  ...thread,
455
511
  thread_ref: resolvedThreadRef,
@@ -459,36 +515,18 @@ async function persistThreads(account, context, threads) {
459
515
  return persistedThreads;
460
516
  });
461
517
  }
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;
518
+ async function refreshOutlookConversationWithSession(account, conversationId, context, browserSession) {
519
+ const capturedSession = await captureOutlookServiceSession(browserSession.context, browserSession.page, {
520
+ timeoutMs: context.config.providerTimeoutMs,
521
+ });
522
+ const bundle = await fetchConversationBundle(browserSession.context.request, capturedSession, conversationId);
523
+ const persisted = await persistThreads(account, context, [normalizeThread(bundle)]);
524
+ await summarizeAndPersistThreads(persisted, context.config, context.db, context.db.getAccountIdentity(account));
475
525
  }
476
526
  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
- }
527
+ await withManagedOutlookSession(context, undefined, (session) => refreshOutlookConversationWithSession(account, conversationId, context, session));
490
528
  }
491
- async function refreshStoredMessage(account, messageRef, context) {
529
+ async function refreshStoredMessageWithSession(account, messageRef, context, browserSession) {
492
530
  const locatorRow = context.db.findProviderLocator("message", messageRef);
493
531
  if (!locatorRow) {
494
532
  throw new SurfaceError("cache_miss", `No provider locator exists for message '${messageRef}'.`, {
@@ -503,7 +541,7 @@ async function refreshStoredMessage(account, messageRef, context) {
503
541
  messageRef,
504
542
  });
505
543
  }
506
- await refreshOutlookConversation(account, locator.conversation_id, context);
544
+ await refreshOutlookConversationWithSession(account, locator.conversation_id, context, browserSession);
507
545
  const refreshed = context.db.getStoredMessage(messageRef);
508
546
  if (!refreshed) {
509
547
  throw new SurfaceError("not_found", `Message '${messageRef}' could not be refreshed from Outlook.`, {
@@ -513,6 +551,9 @@ async function refreshStoredMessage(account, messageRef, context) {
513
551
  }
514
552
  return refreshed;
515
553
  }
554
+ async function refreshStoredMessage(account, messageRef, context) {
555
+ return withManagedOutlookSession(context, undefined, (session) => refreshStoredMessageWithSession(account, messageRef, context, session));
556
+ }
516
557
  async function resolveRsvpLocator(account, messageRef, context) {
517
558
  let stored = context.db.getStoredMessage(messageRef);
518
559
  if (!stored) {
@@ -1015,6 +1056,35 @@ function buildReadEnvelope(account, messageRef, threadRef, parsed, attachments,
1015
1056
  },
1016
1057
  };
1017
1058
  }
1059
+ async function fetchOutlookThreadsWithSession(account, context, browserSession, options) {
1060
+ const { context: playwrightContext, page } = browserSession;
1061
+ const capturedSession = await captureOutlookServiceSession(playwrightContext, page, {
1062
+ timeoutMs: context.config.providerTimeoutMs,
1063
+ });
1064
+ let conversationIds;
1065
+ if (options.kind === "fetch-unread") {
1066
+ await applyUnreadFilter(page);
1067
+ conversationIds = await collectUnreadConversationIds(page, options.fetchLimit ?? options.limit);
1068
+ }
1069
+ else if (options.kind === "browse-current-folder") {
1070
+ conversationIds = await collectCurrentConversationIds(page, options.fetchLimit ?? options.limit);
1071
+ }
1072
+ else {
1073
+ await applySearchQuery(page, options.queryText ?? "");
1074
+ conversationIds = await collectSearchConversationIds(page, options.fetchLimit ?? options.limit);
1075
+ }
1076
+ const bundles = [];
1077
+ for (const conversationId of conversationIds) {
1078
+ bundles.push(await fetchConversationBundle(playwrightContext.request, capturedSession, conversationId));
1079
+ }
1080
+ const normalized = bundles.map((bundle) => normalizeThread(bundle));
1081
+ const filtered = options.postFilter ? normalized.filter(options.postFilter) : normalized;
1082
+ const limited = filtered.slice(0, options.limit);
1083
+ const persisted = await persistThreads(account, context, limited);
1084
+ return options.summarize === false
1085
+ ? persisted
1086
+ : await summarizeAndPersistThreads(persisted, context.config, context.db, context.db.getAccountIdentity(account));
1087
+ }
1018
1088
  async function fetchOutlookThreads(account, context, options) {
1019
1089
  const profileDir = outlookProfileDir(context);
1020
1090
  if (!existsSync(profileDir)) {
@@ -1022,33 +1092,81 @@ async function fetchOutlookThreads(account, context, options) {
1022
1092
  account: account.name,
1023
1093
  });
1024
1094
  }
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,
1095
+ return withManagedOutlookSession(context, undefined, (session) => fetchOutlookThreadsWithSession(account, context, session, options));
1096
+ }
1097
+ export async function searchOutlookWithSession(account, query, context, browserSession) {
1098
+ const queryText = buildOutlookSearchQuery(query);
1099
+ const normalizedLabels = new Set((query.labels ?? []).map((label) => label.trim().toLowerCase()).filter(Boolean));
1100
+ const postFilter = (thread) => threadMatchesStructuredFilters(thread, query);
1101
+ if (!queryText && normalizedLabels.has("unread")) {
1102
+ const threads = await withManagedOutlookSession(context, browserSession, (session) => fetchOutlookThreadsWithSession(account, context, session, {
1103
+ kind: "fetch-unread",
1104
+ limit: query.limit,
1105
+ fetchLimit: Math.min(Math.max(query.limit * 3, query.limit), 100),
1106
+ postFilter,
1107
+ }));
1108
+ return threads.slice(0, query.limit);
1109
+ }
1110
+ const threads = await withManagedOutlookSession(context, browserSession, (session) => fetchOutlookThreadsWithSession(account, context, session, {
1111
+ kind: queryText ? "search" : "browse-current-folder",
1112
+ ...(queryText ? { queryText } : {}),
1113
+ limit: query.limit,
1114
+ fetchLimit: Math.min(Math.max(query.limit * 3, query.limit), 100),
1115
+ postFilter,
1116
+ }));
1117
+ return threads.slice(0, query.limit);
1118
+ }
1119
+ export async function fetchUnreadOutlookWithSession(account, query, context, browserSession) {
1120
+ return withManagedOutlookSession(context, browserSession, (session) => fetchOutlookThreadsWithSession(account, context, session, {
1121
+ kind: "fetch-unread",
1122
+ limit: query.limit,
1123
+ }));
1124
+ }
1125
+ export async function refreshOutlookThreadWithSession(account, threadRef, context, browserSession) {
1126
+ const locatorRow = context.db.findProviderLocator("thread", threadRef);
1127
+ if (!locatorRow) {
1128
+ throw new SurfaceError("cache_miss", `No provider locator exists for thread '${threadRef}'.`, {
1129
+ account: account.name,
1130
+ threadRef,
1030
1131
  });
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
1132
  }
1048
- finally {
1049
- await session.context.close();
1050
- session.cleanup?.();
1133
+ const locator = parseOutlookThreadLocator(locatorRow.locator_json);
1134
+ if (!locator.conversation_id) {
1135
+ throw new SurfaceError("transport_error", `Thread '${threadRef}' is missing an Outlook conversation id.`, {
1136
+ account: account.name,
1137
+ threadRef,
1138
+ });
1139
+ }
1140
+ await withManagedOutlookSession(context, browserSession, (session) => refreshOutlookConversationWithSession(account, locator.conversation_id, context, session));
1141
+ }
1142
+ export async function readOutlookMessageWithSession(account, messageRef, refresh, context, browserSession) {
1143
+ const stored = context.db.getStoredMessage(messageRef);
1144
+ if (!stored) {
1145
+ throw new SurfaceError("not_found", `Message '${messageRef}' was not found.`, {
1146
+ account: account.name,
1147
+ messageRef,
1148
+ });
1051
1149
  }
1150
+ const attachments = context.db.listAttachmentsForMessage(messageRef).map((attachment) => ({
1151
+ attachment_id: attachment.attachment_id,
1152
+ filename: attachment.filename,
1153
+ mime_type: attachment.mime_type,
1154
+ size_bytes: attachment.size_bytes,
1155
+ inline: Boolean(attachment.inline),
1156
+ }));
1157
+ const hasReadableCache = Boolean(stored.body_cache_path && existsSync(stored.body_cache_path));
1158
+ if (!refresh && hasReadableCache) {
1159
+ return buildReadEnvelope(account, messageRef, stored.thread_ref, parseStoredMessage(stored), attachments, "hit");
1160
+ }
1161
+ const refreshed = await withManagedOutlookSession(context, browserSession, (session) => refreshStoredMessageWithSession(account, messageRef, context, session));
1162
+ const refreshedAttachments = context.db.listAttachmentsForMessage(messageRef).map((attachment) => ({
1163
+ attachment_id: attachment.attachment_id,
1164
+ filename: attachment.filename,
1165
+ mime_type: attachment.mime_type,
1166
+ size_bytes: attachment.size_bytes,
1167
+ inline: Boolean(attachment.inline),
1168
+ }));
1169
+ return buildReadEnvelope(account, messageRef, refreshed.thread_ref, parseStoredMessage(refreshed), refreshedAttachments, "refreshed");
1052
1170
  }
1053
1171
  async function mutateOutlookReadState(account, messageRefs, unread, context) {
1054
1172
  if (messageRefs.length === 0) {
@@ -1133,46 +1251,16 @@ export class OutlookWebPlaywrightAdapter {
1133
1251
  }
1134
1252
  }
1135
1253
  async search(account, query, context) {
1136
- return fetchOutlookThreads(account, context, {
1137
- kind: "search",
1138
- queryText: query.text,
1139
- limit: query.limit,
1140
- });
1254
+ return searchOutlookWithSession(account, query, context);
1141
1255
  }
1142
1256
  async fetchUnread(account, query, context) {
1143
- return fetchOutlookThreads(account, context, {
1144
- kind: "fetch-unread",
1145
- limit: query.limit,
1146
- });
1257
+ return fetchUnreadOutlookWithSession(account, query, context);
1258
+ }
1259
+ async refreshThread(account, threadRef, context) {
1260
+ await refreshOutlookThreadWithSession(account, threadRef, context);
1147
1261
  }
1148
1262
  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");
1263
+ return readOutlookMessageWithSession(account, messageRef, refresh, context);
1176
1264
  }
1177
1265
  async listAttachments(account, messageRef, context) {
1178
1266
  const message = context.db.findMessageByRef(messageRef);