surface-cli 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ function storedAttachments(db, messageRef) {
3
+ return db.listAttachmentsForMessage(messageRef).map((attachment) => ({
4
+ attachment_id: attachment.attachment_id,
5
+ filename: attachment.filename,
6
+ mime_type: attachment.mime_type,
7
+ size_bytes: attachment.size_bytes,
8
+ inline: Boolean(attachment.inline),
9
+ }));
10
+ }
11
+ function storedMessage(record, attachments) {
12
+ const hasReadableCache = Boolean(record.body_cache_path && existsSync(record.body_cache_path));
13
+ return {
14
+ message_ref: record.message_ref,
15
+ envelope: {
16
+ from: record.from_name || record.from_email
17
+ ? {
18
+ name: record.from_name ?? "",
19
+ email: record.from_email ?? "",
20
+ }
21
+ : null,
22
+ to: JSON.parse(record.to_json),
23
+ cc: JSON.parse(record.cc_json),
24
+ sent_at: record.sent_at,
25
+ received_at: record.received_at,
26
+ unread: Boolean(record.unread),
27
+ ...(record.subject ? { subject: record.subject } : {}),
28
+ },
29
+ snippet: record.snippet,
30
+ body: {
31
+ text: hasReadableCache && record.body_cache_path ? readFileSync(record.body_cache_path, "utf8") : "",
32
+ truncated: Boolean(record.body_truncated),
33
+ cached: hasReadableCache && Boolean(record.body_cached),
34
+ cached_bytes: record.body_cached_bytes,
35
+ },
36
+ attachments,
37
+ ...(record.invite_json ? { invite: JSON.parse(record.invite_json) } : {}),
38
+ };
39
+ }
40
+ export function threadHasReadableCache(db, threadRef) {
41
+ const messages = db.listStoredMessagesForThread(threadRef);
42
+ return messages.length > 0 && messages.every((message) => Boolean(message.body_cache_path && existsSync(message.body_cache_path)));
43
+ }
44
+ export function loadStoredThread(db, account, threadRef) {
45
+ const thread = db.getStoredThread(threadRef);
46
+ if (!thread) {
47
+ return null;
48
+ }
49
+ return {
50
+ thread_ref: thread.thread_ref,
51
+ source: {
52
+ provider: account.provider,
53
+ transport: account.transport,
54
+ },
55
+ envelope: {
56
+ subject: thread.subject ?? "",
57
+ participants: JSON.parse(thread.participants_json),
58
+ mailbox: thread.mailbox ?? "inbox",
59
+ labels: JSON.parse(thread.labels_json),
60
+ received_at: thread.received_at,
61
+ message_count: thread.message_count,
62
+ unread_count: thread.unread_count,
63
+ has_attachments: Boolean(thread.has_attachments),
64
+ },
65
+ summary: db.findSummary(threadRef),
66
+ messages: db.listStoredMessagesForThread(threadRef).map((message) => storedMessage(message, storedAttachments(db, message.message_ref))),
67
+ };
68
+ }
69
+ //# sourceMappingURL=stored-mail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stored-mail.js","sourceRoot":"","sources":["../../src/lib/stored-mail.ts"],"names":[],"mappings":"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"}
@@ -1,11 +1,12 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { SurfaceError, notImplemented } from "../../lib/errors.js";
4
+ import { SurfaceError } from "../../lib/errors.js";
5
5
  import { assertWriteAllowed } from "../../lib/write-safety.js";
6
6
  import { makeAttachmentId, makeMessageRef, makeThreadRef } from "../../refs.js";
7
7
  import { summarizeThread } from "../../summarizer.js";
8
- import { createGmailDraft, downloadGmailAttachmentBytes, getGmailThread, listGmailThreads, modifyGmailMessage, modifyGmailThread, sendGmailRawMessage, } from "./api.js";
8
+ import { annotateBodyWithInlineAttachments } from "../shared/inline-attachments.js";
9
+ import { createGmailDraft, downloadGmailAttachmentBytes, getGoogleCalendarEvent, getGmailThread, listGmailThreads, listGoogleCalendarEventsByIcalUid, modifyGmailMessage, modifyGmailThread, patchGoogleCalendarEvent, sendGmailRawMessage, } from "./api.js";
9
10
  import { clearGmailAuthState, gmailAuthStatus, runGmailLogin } from "./oauth.js";
10
11
  import { decodeBase64UrlBytes, decodePartData, headerDateToIso, headerIndex, internalDateToIso, isCalendarPart, iterParts, normalizeGmailBody, parseCalendarInvite, parseMailbox, parseMailboxes, } from "./normalize.js";
11
12
  function sourceInfo(account) {
@@ -76,6 +77,88 @@ function gmailMailbox(labels) {
76
77
  }
77
78
  return "archive";
78
79
  }
80
+ function quoteGmailSearchValue(value) {
81
+ const normalized = value.trim();
82
+ if (!normalized) {
83
+ return '""';
84
+ }
85
+ return /[\s"]/u.test(normalized) ? `"${normalized.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : normalized;
86
+ }
87
+ function gmailMailboxSearchOperator(mailbox) {
88
+ const normalized = mailbox.trim().toLowerCase();
89
+ switch (normalized) {
90
+ case "drafts":
91
+ return "in:drafts";
92
+ case "sent":
93
+ return "in:sent";
94
+ case "trash":
95
+ return "in:trash";
96
+ case "spam":
97
+ return "in:spam";
98
+ case "inbox":
99
+ return "in:inbox";
100
+ case "archive":
101
+ return "in:archive";
102
+ default:
103
+ return `in:${quoteGmailSearchValue(normalized)}`;
104
+ }
105
+ }
106
+ function gmailLabelSearchOperator(label) {
107
+ const normalized = normalizeLabel(label);
108
+ switch (normalized) {
109
+ case "unread":
110
+ return "is:unread";
111
+ case "read":
112
+ return "is:read";
113
+ case "starred":
114
+ return "is:starred";
115
+ case "important":
116
+ return "label:important";
117
+ case "inbox":
118
+ case "sent":
119
+ case "drafts":
120
+ case "trash":
121
+ case "spam":
122
+ case "archive":
123
+ return gmailMailboxSearchOperator(normalized);
124
+ default:
125
+ return `label:${quoteGmailSearchValue(normalized)}`;
126
+ }
127
+ }
128
+ function buildGmailSearchQuery(query) {
129
+ const parts = [];
130
+ if (query.text?.trim()) {
131
+ parts.push(query.text.trim());
132
+ }
133
+ if (query.from?.trim()) {
134
+ parts.push(`from:${quoteGmailSearchValue(query.from)}`);
135
+ }
136
+ if (query.subject?.trim()) {
137
+ parts.push(`subject:${quoteGmailSearchValue(query.subject)}`);
138
+ }
139
+ if (query.mailbox?.trim()) {
140
+ parts.push(gmailMailboxSearchOperator(query.mailbox));
141
+ }
142
+ for (const label of query.labels ?? []) {
143
+ if (!label.trim()) {
144
+ continue;
145
+ }
146
+ parts.push(gmailLabelSearchOperator(label));
147
+ }
148
+ return parts.length > 0 ? parts.join(" ").trim() : undefined;
149
+ }
150
+ function threadMatchesStructuredFilters(thread, query) {
151
+ if (query.mailbox?.trim() && thread.envelope.mailbox.trim().toLowerCase() !== query.mailbox.trim().toLowerCase()) {
152
+ return false;
153
+ }
154
+ if ((query.labels?.length ?? 0) > 0) {
155
+ const available = new Set(thread.envelope.labels.map((label) => label.trim().toLowerCase()));
156
+ if ((query.labels ?? []).some((label) => !available.has(label.trim().toLowerCase()))) {
157
+ return false;
158
+ }
159
+ }
160
+ return true;
161
+ }
79
162
  function partSizeBytes(part) {
80
163
  if (typeof part.body?.size === "number") {
81
164
  return part.body.size;
@@ -152,6 +235,126 @@ function mapCalendarPartstat(value) {
152
235
  return null;
153
236
  }
154
237
  }
238
+ function mapGoogleCalendarResponseStatus(value) {
239
+ const normalized = (value ?? "").trim().toLowerCase();
240
+ switch (normalized) {
241
+ case "accepted":
242
+ return "accept";
243
+ case "declined":
244
+ return "decline";
245
+ case "tentative":
246
+ return "tentative";
247
+ case "needsaction":
248
+ return "needs_response";
249
+ default:
250
+ return null;
251
+ }
252
+ }
253
+ function googleCalendarResponseStatusForRsvp(response) {
254
+ switch (response) {
255
+ case "accept":
256
+ return "accepted";
257
+ case "decline":
258
+ return "declined";
259
+ case "tentative":
260
+ return "tentative";
261
+ }
262
+ }
263
+ function googleCalendarEventStart(event) {
264
+ return event.start?.dateTime ?? event.start?.date ?? null;
265
+ }
266
+ function normalizeComparableDatePrefix(value) {
267
+ if (!value) {
268
+ return null;
269
+ }
270
+ const match = value.match(/^(\d{4}-\d{2}-\d{2})/);
271
+ return match?.[1] ?? null;
272
+ }
273
+ function chooseGoogleCalendarEvent(events, meetingStart) {
274
+ if (events.length === 0) {
275
+ return null;
276
+ }
277
+ const targetDate = normalizeComparableDatePrefix(meetingStart);
278
+ if (targetDate) {
279
+ const match = events.find((event) => normalizeComparableDatePrefix(googleCalendarEventStart(event)) === targetDate);
280
+ if (match) {
281
+ return match;
282
+ }
283
+ }
284
+ return events[0] ?? null;
285
+ }
286
+ function pickCalendarAttendee(event, attendeeEmail, fallbackEmail) {
287
+ const normalizedTarget = (attendeeEmail ?? fallbackEmail).trim().toLowerCase();
288
+ const normalizedFallback = fallbackEmail.trim().toLowerCase();
289
+ const attendees = event.attendees ?? [];
290
+ const chosen = attendees.find((attendee) => attendee.self === true)
291
+ ?? attendees.find((attendee) => (attendee.email ?? "").trim().toLowerCase() === normalizedTarget)
292
+ ?? attendees.find((attendee) => (attendee.email ?? "").trim().toLowerCase() === normalizedFallback);
293
+ if (!chosen?.email) {
294
+ return normalizedTarget
295
+ ? { email: normalizedTarget, response_status: null }
296
+ : null;
297
+ }
298
+ return {
299
+ email: chosen.email,
300
+ response_status: mapGoogleCalendarResponseStatus(chosen.responseStatus),
301
+ };
302
+ }
303
+ async function hydrateGmailInviteMetadataFromCalendar(account, context, metadata) {
304
+ if (!metadata.calendar_uid) {
305
+ return metadata;
306
+ }
307
+ try {
308
+ const events = await listGoogleCalendarEventsByIcalUid(account, context, "primary", metadata.calendar_uid);
309
+ const event = chooseGoogleCalendarEvent(events, metadata.meeting_start);
310
+ if (!event) {
311
+ return metadata;
312
+ }
313
+ const attendee = pickCalendarAttendee(event, metadata.attendee_email, account.email);
314
+ return {
315
+ ...metadata,
316
+ attendee_email: attendee?.email ?? metadata.attendee_email,
317
+ invite: {
318
+ ...metadata.invite,
319
+ rsvp_supported: true,
320
+ response_status: attendee?.response_status ?? metadata.invite.response_status,
321
+ available_rsvp_responses: ["accept", "decline", "tentative"],
322
+ },
323
+ };
324
+ }
325
+ catch (error) {
326
+ if (error instanceof SurfaceError && (error.code === "reauth_required" || error.code === "transport_error")) {
327
+ return metadata;
328
+ }
329
+ throw error;
330
+ }
331
+ }
332
+ async function extractGmailInviteMetadata(account, context, message, participants) {
333
+ const calendarText = await extractCalendarText(account, context, message);
334
+ if (!calendarText) {
335
+ return null;
336
+ }
337
+ const parsedInvite = parseCalendarInvite(calendarText, {
338
+ mailboxEmail: account.email,
339
+ recipientEmails: [...participants.to, ...participants.cc].map((mailbox) => mailbox.email),
340
+ });
341
+ const meeting = parsedInvite.meeting;
342
+ if (!meeting) {
343
+ return null;
344
+ }
345
+ const metadata = {
346
+ invite: {
347
+ is_invite: true,
348
+ rsvp_supported: Boolean(meeting.uid),
349
+ response_status: mapCalendarPartstat(meeting.response_type),
350
+ available_rsvp_responses: meeting.uid ? ["accept", "decline", "tentative"] : [],
351
+ },
352
+ calendar_uid: meeting.uid ?? null,
353
+ attendee_email: meeting.attendee?.email ?? null,
354
+ meeting_start: meeting.start ?? null,
355
+ };
356
+ return hydrateGmailInviteMetadataFromCalendar(account, context, metadata);
357
+ }
155
358
  async function normalizeGmailMessage(account, context, message) {
156
359
  const indexedHeaders = headerIndex(message.payload?.headers);
157
360
  const subject = indexedHeaders.subject ?? "";
@@ -163,22 +366,9 @@ async function normalizeGmailMessage(account, context, message) {
163
366
  const unread = (message.labelIds ?? []).includes("UNREAD");
164
367
  const body = normalizeGmailBody(message.payload, message.snippet ?? "");
165
368
  const attachments = extractAttachmentRecords(message);
166
- let invite;
167
- const calendarText = await extractCalendarText(account, context, message);
168
- if (calendarText) {
169
- const parsedInvite = parseCalendarInvite(calendarText, {
170
- mailboxEmail: account.email,
171
- recipientEmails: [...to, ...cc].map((mailbox) => mailbox.email),
172
- });
173
- if (parsedInvite.meeting) {
174
- invite = {
175
- is_invite: true,
176
- rsvp_supported: false,
177
- response_status: mapCalendarPartstat(parsedInvite.meeting.response_type),
178
- available_rsvp_responses: [],
179
- };
180
- }
181
- }
369
+ const bodyText = annotateBodyWithInlineAttachments(body.text, attachments);
370
+ const inviteMetadata = await extractGmailInviteMetadata(account, context, message, { to, cc });
371
+ const invite = inviteMetadata?.invite;
182
372
  return {
183
373
  message_ref: "",
184
374
  envelope: {
@@ -190,12 +380,12 @@ async function normalizeGmailMessage(account, context, message) {
190
380
  unread,
191
381
  ...(subject ? { subject } : {}),
192
382
  },
193
- snippet: message.snippet ?? body.text.slice(0, 240),
383
+ snippet: message.snippet ?? bodyText.slice(0, 240),
194
384
  body: {
195
- text: body.text,
385
+ text: bodyText,
196
386
  truncated: false,
197
387
  cached: true,
198
- cached_bytes: Buffer.byteLength(body.text, "utf8"),
388
+ cached_bytes: Buffer.byteLength(bodyText, "utf8"),
199
389
  },
200
390
  attachments,
201
391
  ...(invite ? { invite } : {}),
@@ -208,6 +398,9 @@ async function normalizeGmailMessage(account, context, message) {
208
398
  locator: {
209
399
  thread_id: message.threadId ?? null,
210
400
  message_id: message.id ?? null,
401
+ calendar_uid: inviteMetadata?.calendar_uid ?? null,
402
+ attendee_email: inviteMetadata?.attendee_email ?? null,
403
+ meeting_start: inviteMetadata?.meeting_start ?? null,
211
404
  },
212
405
  },
213
406
  };
@@ -368,6 +561,9 @@ function parseMessageLocator(locatorJson) {
368
561
  return {
369
562
  thread_id: typeof parsed.thread_id === "string" && parsed.thread_id ? parsed.thread_id : null,
370
563
  message_id: typeof parsed.message_id === "string" && parsed.message_id ? parsed.message_id : null,
564
+ calendar_uid: typeof parsed.calendar_uid === "string" && parsed.calendar_uid ? parsed.calendar_uid : null,
565
+ attendee_email: typeof parsed.attendee_email === "string" && parsed.attendee_email ? parsed.attendee_email : null,
566
+ meeting_start: typeof parsed.meeting_start === "string" && parsed.meeting_start ? parsed.meeting_start : null,
371
567
  };
372
568
  }
373
569
  function latestStoredThreadMessage(threadRef, context) {
@@ -411,6 +607,18 @@ function buildMarkMessagesEnvelope(account, command, updated) {
411
607
  updated,
412
608
  };
413
609
  }
610
+ function buildRsvpEnvelope(account, messageRef, threadRef, response, invite) {
611
+ return {
612
+ schema_version: "1",
613
+ command: "rsvp",
614
+ account: account.name,
615
+ message_ref: messageRef,
616
+ thread_ref: threadRef,
617
+ source: sourceInfo(account),
618
+ response,
619
+ invite: invite ?? null,
620
+ };
621
+ }
414
622
  function buildReadEnvelope(account, messageRef, threadRef, parsed, attachments, cacheStatus) {
415
623
  return {
416
624
  schema_version: "1",
@@ -654,6 +862,72 @@ async function resolveGmailMessageContext(account, messageRef, context) {
654
862
  headers: headerIndex(message.payload?.headers),
655
863
  };
656
864
  }
865
+ async function resolveGmailRsvpTarget(account, messageRef, context) {
866
+ const target = await resolveGmailMessageContext(account, messageRef, context);
867
+ const inviteMetadata = await extractGmailInviteMetadata(account, context, target.message, {
868
+ to: parseMailboxes(target.headers.to),
869
+ cc: parseMailboxes(target.headers.cc),
870
+ });
871
+ if (!inviteMetadata?.calendar_uid) {
872
+ throw new SurfaceError("unsupported", `Message '${messageRef}' is not a Gmail calendar invite with a resolvable UID.`, {
873
+ account: account.name,
874
+ messageRef,
875
+ });
876
+ }
877
+ if (!inviteMetadata.invite.rsvp_supported) {
878
+ throw new SurfaceError("unsupported", `Message '${messageRef}' is not a Gmail invite Surface can RSVP to.`, {
879
+ account: account.name,
880
+ messageRef,
881
+ });
882
+ }
883
+ const attendeeEmail = inviteMetadata.attendee_email?.trim().toLowerCase()
884
+ || account.email.trim().toLowerCase();
885
+ if (!attendeeEmail) {
886
+ throw new SurfaceError("unsupported", `Message '${messageRef}' does not expose an attendee email for RSVP.`, {
887
+ account: account.name,
888
+ messageRef,
889
+ });
890
+ }
891
+ return {
892
+ stored: target.stored,
893
+ message: target.message,
894
+ threadRef: target.stored.thread_ref,
895
+ calendarUid: inviteMetadata.calendar_uid,
896
+ attendeeEmail,
897
+ meetingStart: inviteMetadata.meeting_start,
898
+ invite: inviteMetadata.invite,
899
+ };
900
+ }
901
+ async function performGmailRsvp(account, context, target, response) {
902
+ const events = await listGoogleCalendarEventsByIcalUid(account, context, "primary", target.calendarUid);
903
+ const event = chooseGoogleCalendarEvent(events, target.meetingStart);
904
+ if (!event?.id) {
905
+ throw new SurfaceError("not_found", `Google Calendar could not find an event matching invite UID '${target.calendarUid}'.`, { account: account.name });
906
+ }
907
+ const attendee = pickCalendarAttendee(event, target.attendeeEmail, account.email);
908
+ if (!attendee?.email) {
909
+ throw new SurfaceError("unsupported", `Google Calendar event '${event.id}' does not expose an attendee that Surface can RSVP as.`, { account: account.name });
910
+ }
911
+ const patched = await patchGoogleCalendarEvent(account, context, "primary", event.id, {
912
+ attendeesOmitted: true,
913
+ attendees: [
914
+ {
915
+ email: attendee.email,
916
+ responseStatus: googleCalendarResponseStatusForRsvp(response),
917
+ },
918
+ ],
919
+ });
920
+ const refreshed = patched.id
921
+ ? await getGoogleCalendarEvent(account, context, "primary", patched.id)
922
+ : patched;
923
+ const refreshedAttendee = pickCalendarAttendee(refreshed, attendee.email, account.email);
924
+ return {
925
+ is_invite: true,
926
+ rsvp_supported: true,
927
+ response_status: refreshedAttendee?.response_status ?? mapGoogleCalendarResponseStatus(googleCalendarResponseStatusForRsvp(response)),
928
+ available_rsvp_responses: ["accept", "decline", "tentative"],
929
+ };
930
+ }
657
931
  async function refreshRefsFromGmailResponse(account, context, response) {
658
932
  const threadId = response.threadId ?? null;
659
933
  if (threadId) {
@@ -754,11 +1028,13 @@ export class GmailApiAdapter {
754
1028
  : { status: "unauthenticated", detail: status.detail };
755
1029
  }
756
1030
  async search(account, query, context) {
757
- return fetchGmailThreads(account, context, {
1031
+ const queryText = buildGmailSearchQuery(query);
1032
+ const threads = await fetchGmailThreads(account, context, {
758
1033
  kind: "search",
759
- queryText: query.text,
1034
+ ...(queryText ? { queryText } : {}),
760
1035
  limit: query.limit,
761
1036
  });
1037
+ return threads.filter((thread) => threadMatchesStructuredFilters(thread, query)).slice(0, query.limit);
762
1038
  }
763
1039
  async fetchUnread(account, query, context) {
764
1040
  return fetchGmailThreads(account, context, {
@@ -766,6 +1042,23 @@ export class GmailApiAdapter {
766
1042
  limit: query.limit,
767
1043
  });
768
1044
  }
1045
+ async refreshThread(account, threadRef, context) {
1046
+ const locatorRow = context.db.findProviderLocator("thread", threadRef);
1047
+ if (!locatorRow) {
1048
+ throw new SurfaceError("cache_miss", `No provider locator exists for thread '${threadRef}'.`, {
1049
+ account: account.name,
1050
+ threadRef,
1051
+ });
1052
+ }
1053
+ const locator = JSON.parse(locatorRow.locator_json);
1054
+ if (!locator.thread_id) {
1055
+ throw new SurfaceError("transport_error", `Thread '${threadRef}' is missing a Gmail thread id.`, {
1056
+ account: account.name,
1057
+ threadRef,
1058
+ });
1059
+ }
1060
+ await fetchAndPersistGmailThread(account, context, locator.thread_id);
1061
+ }
769
1062
  async readMessage(account, messageRef, refresh, context) {
770
1063
  const stored = context.db.getStoredMessage(messageRef);
771
1064
  if (!stored) {
@@ -884,8 +1177,16 @@ export class GmailApiAdapter {
884
1177
  },
885
1178
  };
886
1179
  }
887
- async rsvp(account, _messageRef, _response) {
888
- notImplemented("Gmail RSVP is deferred pending explicit Google Calendar integration.", account.name);
1180
+ async rsvp(account, messageRef, response, context) {
1181
+ const target = await resolveGmailRsvpTarget(account, messageRef, context);
1182
+ const invite = await performGmailRsvp(account, context, target, response);
1183
+ context.db.updateInviteForThread(target.threadRef, {
1184
+ is_invite: true,
1185
+ rsvp_supported: invite.rsvp_supported,
1186
+ response_status: invite.response_status,
1187
+ available_rsvp_responses: invite.available_rsvp_responses,
1188
+ });
1189
+ return buildRsvpEnvelope(account, messageRef, target.threadRef, response, invite);
889
1190
  }
890
1191
  async sendMessage(account, input, context) {
891
1192
  const recipients = {