google-tools-mcp 1.2.11 → 2.0.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 (97) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +555 -276
  3. package/dist/cachedToolsList.js +52 -52
  4. package/dist/googleDocsApiHelpers.js +76 -12
  5. package/dist/helpers.js +572 -252
  6. package/dist/httpAuth.js +296 -0
  7. package/dist/index.js +162 -11
  8. package/dist/logger.js +87 -87
  9. package/dist/markdown-transformer/docsToMarkdown.js +222 -21
  10. package/dist/markdown-transformer/index.js +19 -9
  11. package/dist/markdown-transformer/markdownToDocs.js +458 -28
  12. package/dist/readTracker.js +136 -17
  13. package/dist/sessionContext.js +37 -0
  14. package/dist/setup.js +187 -9
  15. package/dist/tools/calendar/getBusy.js +64 -64
  16. package/dist/tools/calendar/getEvents.js +141 -141
  17. package/dist/tools/calendar/getFree.js +225 -225
  18. package/dist/tools/calendar/index.js +19 -19
  19. package/dist/tools/calendar/listCalendars.js +38 -38
  20. package/dist/tools/calendar/listRecurringInstances.js +83 -83
  21. package/dist/tools/calendar/manageCalendar.js +121 -121
  22. package/dist/tools/calendar/manageEvent.js +258 -258
  23. package/dist/tools/calendar/moveEvent.js +66 -66
  24. package/dist/tools/docs/addTab.js +10 -12
  25. package/dist/tools/docs/appendToGoogleDoc.js +9 -4
  26. package/dist/tools/docs/deleteRange.js +9 -4
  27. package/dist/tools/docs/findAndReplace.js +9 -4
  28. package/dist/tools/docs/formatting/applyParagraphStyle.js +4 -1
  29. package/dist/tools/docs/formatting/getFormatting.js +171 -171
  30. package/dist/tools/docs/insertImage.js +15 -2
  31. package/dist/tools/docs/insertPageBreak.js +4 -1
  32. package/dist/tools/docs/insertTable.js +4 -1
  33. package/dist/tools/docs/insertTableWithData.js +4 -1
  34. package/dist/tools/docs/modifyText.js +14 -4
  35. package/dist/tools/docs/modifyText.test.js +84 -84
  36. package/dist/tools/docs/readGoogleDoc.js +71 -11
  37. package/dist/tools/docs/renameTab.js +12 -14
  38. package/dist/tools/drafts.js +165 -165
  39. package/dist/tools/drive/createDocument.js +13 -1
  40. package/dist/tools/drive/downloadFile.js +268 -268
  41. package/dist/tools/drive/getFileInfo.js +48 -48
  42. package/dist/tools/drive/getFilePath.js +50 -50
  43. package/dist/tools/drive/listDriveFiles.js +112 -112
  44. package/dist/tools/drive/listSharedDrives.js +52 -52
  45. package/dist/tools/drive/listSharedWithMe.js +97 -97
  46. package/dist/tools/drive/uploadFile.js +111 -111
  47. package/dist/tools/extras/index.js +9 -9
  48. package/dist/tools/extras/readDriveFile.js +212 -206
  49. package/dist/tools/extras/readFile.js +84 -84
  50. package/dist/tools/extras/searchFileContents.js +81 -81
  51. package/dist/tools/forms/batchUpdateForm.js +81 -81
  52. package/dist/tools/forms/createForm.js +54 -54
  53. package/dist/tools/forms/getForm.js +118 -118
  54. package/dist/tools/forms/getFormResponse.js +45 -45
  55. package/dist/tools/forms/index.js +15 -15
  56. package/dist/tools/forms/listFormResponses.js +56 -56
  57. package/dist/tools/forms/setPublishSettings.js +59 -59
  58. package/dist/tools/gmail/drafts.js +165 -165
  59. package/dist/tools/gmail/labels.js +54 -83
  60. package/dist/tools/gmail/messages.js +444 -441
  61. package/dist/tools/gmail/settings.js +358 -528
  62. package/dist/tools/gmail/threads.js +286 -164
  63. package/dist/tools/index.js +511 -473
  64. package/dist/tools/labels.js +103 -103
  65. package/dist/tools/legacyAliases.js +426 -0
  66. package/dist/tools/maps/directions.js +72 -0
  67. package/dist/tools/maps/geocode.js +16 -0
  68. package/dist/tools/maps/index.js +15 -0
  69. package/dist/tools/maps/mapsClient.js +83 -0
  70. package/dist/tools/maps/placeDetails.js +16 -0
  71. package/dist/tools/maps/reverseGeocode.js +19 -0
  72. package/dist/tools/maps/searchNearby.js +54 -0
  73. package/dist/tools/maps/searchPlaces.js +24 -0
  74. package/dist/tools/messages.js +448 -448
  75. package/dist/tools/settings.js +528 -528
  76. package/dist/tools/slides/createPresentation.js +104 -104
  77. package/dist/tools/slides/createShape.js +92 -92
  78. package/dist/tools/slides/createTextBox.js +82 -82
  79. package/dist/tools/slides/deleteSlide.js +30 -30
  80. package/dist/tools/slides/duplicateSlide.js +37 -37
  81. package/dist/tools/slides/exportThumbnail.js +42 -42
  82. package/dist/tools/slides/formatParagraph.js +72 -72
  83. package/dist/tools/slides/formatText.js +84 -84
  84. package/dist/tools/slides/getPresentation.js +87 -87
  85. package/dist/tools/slides/index.js +33 -33
  86. package/dist/tools/slides/reorderSlides.js +41 -41
  87. package/dist/tools/slides/replaceAllText.js +46 -46
  88. package/dist/tools/slides/setBackground.js +58 -58
  89. package/dist/tools/slides/speakerNotes.js +102 -102
  90. package/dist/tools/slides/styleShape.js +111 -111
  91. package/dist/tools/slides/updatePresentation.js +128 -128
  92. package/dist/tools/threads.js +145 -145
  93. package/dist/tools/utils/appendMarkdownToGoogleDoc.js +31 -7
  94. package/dist/tools/utils/replaceDocumentWithMarkdown.js +72 -9
  95. package/dist/updateCheck.js +202 -0
  96. package/dist/workspace.js +122 -0
  97. package/package.json +82 -80
package/dist/helpers.js CHANGED
@@ -1,252 +1,572 @@
1
- // Gmail message processing helpers.
2
- // Ported from @shinzolabs/gmail-mcp with minimal changes.
3
-
4
- const RESPONSE_HEADERS_LIST = [
5
- 'Date',
6
- 'From',
7
- 'To',
8
- 'Subject',
9
- 'Message-ID',
10
- 'In-Reply-To',
11
- 'References'
12
- ];
13
-
14
- const decodedBody = (body) => {
15
- if (!body?.data) return body;
16
- const decodedData = Buffer.from(body.data, 'base64').toString('utf-8');
17
- return {
18
- data: decodedData,
19
- size: body.data.length,
20
- attachmentId: body.attachmentId
21
- };
22
- };
23
-
24
- export const processMessagePart = (messagePart, includeBodyHtml = false) => {
25
- if ((messagePart.mimeType !== 'text/html' || includeBodyHtml) && messagePart.body) {
26
- messagePart.body = decodedBody(messagePart.body);
27
- }
28
- if (messagePart.parts) {
29
- messagePart.parts = messagePart.parts.map(part => processMessagePart(part, includeBodyHtml));
30
- }
31
- if (messagePart.headers) {
32
- messagePart.headers = messagePart.headers.filter(header => RESPONSE_HEADERS_LIST.includes(header.name || ''));
33
- }
34
- return messagePart;
35
- };
36
-
37
- export const getNestedHistory = (messagePart, level = 1) => {
38
- if (messagePart.mimeType === 'text/plain' && messagePart.body?.data) {
39
- const { data } = decodedBody(messagePart.body);
40
- if (!data) return '';
41
- return data.split('\n').map(line => '>' + (line.startsWith('>') ? '' : ' ') + line).join('\n');
42
- }
43
- return (messagePart.parts || []).map(p => getNestedHistory(p, level + 1)).filter(p => p).join('\n');
44
- };
45
-
46
- export const findHeader = (headers, name) => {
47
- if (!headers || !Array.isArray(headers) || !name) return undefined;
48
- return headers.find(h => h?.name?.toLowerCase() === name.toLowerCase())?.value ?? undefined;
49
- };
50
-
51
- export const formatEmailList = (emailList) => {
52
- if (!emailList) return [];
53
- return emailList.split(',').map(email => email.trim());
54
- };
55
-
56
- const getQuotedContent = (thread) => {
57
- if (!thread.messages?.length) return '';
58
- const sentMessages = thread.messages.filter(msg =>
59
- msg.labelIds?.includes('SENT') ||
60
- (!msg.labelIds?.includes('DRAFT') && findHeader(msg.payload?.headers || [], 'date'))
61
- );
62
- if (!sentMessages.length) return '';
63
- const lastMessage = sentMessages[sentMessages.length - 1];
64
- if (!lastMessage?.payload) return '';
65
- let quotedContent = [];
66
- if (lastMessage.payload.headers) {
67
- const fromHeader = findHeader(lastMessage.payload.headers || [], 'from');
68
- const dateHeader = findHeader(lastMessage.payload.headers || [], 'date');
69
- if (fromHeader && dateHeader) {
70
- quotedContent.push('');
71
- quotedContent.push(`On ${dateHeader} ${fromHeader} wrote:`);
72
- quotedContent.push('');
73
- }
74
- }
75
- const nestedHistory = getNestedHistory(lastMessage.payload);
76
- if (nestedHistory) {
77
- quotedContent.push(nestedHistory);
78
- quotedContent.push('');
79
- }
80
- return quotedContent.join('\n');
81
- };
82
-
83
- const getThreadHeaders = (thread) => {
84
- let headers = [];
85
- if (!thread.messages?.length) return headers;
86
- const lastMessage = thread.messages[thread.messages.length - 1];
87
- const references = [];
88
- let subjectHeader = findHeader(lastMessage.payload?.headers || [], 'subject');
89
- if (subjectHeader) {
90
- if (!subjectHeader.toLowerCase().startsWith('re:')) {
91
- subjectHeader = `Re: ${subjectHeader}`;
92
- }
93
- headers.push(`Subject: ${subjectHeader}`);
94
- }
95
- const messageIdHeader = findHeader(lastMessage.payload?.headers || [], 'message-id');
96
- if (messageIdHeader) {
97
- headers.push(`In-Reply-To: ${messageIdHeader}`);
98
- references.push(messageIdHeader);
99
- }
100
- const referencesHeader = findHeader(lastMessage.payload?.headers || [], 'references');
101
- if (referencesHeader) references.unshift(...referencesHeader.split(' '));
102
- if (references.length > 0) headers.push(`References: ${references.join(' ')}`);
103
- return headers;
104
- };
105
-
106
- export const wrapTextBody = (text) => text.split('\n').map(line => {
107
- if (line.length <= 76) return line;
108
- const chunks = line.match(/.{1,76}/g) || [];
109
- return chunks.join('=\n');
110
- }).join('\n');
111
-
112
- export const isHtmlBody = (text) => /<\/?[a-z][\s\S]*?>/i.test(text);
113
-
114
- export const constructRawMessage = async (gmail, params) => {
115
- let thread = null;
116
- if (params.threadId) {
117
- const { data } = await gmail.users.threads.get({ userId: 'me', id: params.threadId, format: 'full' });
118
- thread = data;
119
- }
120
- const message = [];
121
- if (params.to?.length) message.push(`To: ${wrapTextBody(params.to.join(', '))}`);
122
- if (params.cc?.length) message.push(`Cc: ${wrapTextBody(params.cc.join(', '))}`);
123
- if (params.bcc?.length) message.push(`Bcc: ${wrapTextBody(params.bcc.join(', '))}`);
124
- if (thread) {
125
- message.push(...getThreadHeaders(thread).map(header => wrapTextBody(header)));
126
- } else if (params.subject) {
127
- message.push(`Subject: ${wrapTextBody(params.subject)}`);
128
- } else {
129
- message.push('Subject: (No Subject)');
130
- }
131
- const htmlMode = params.body && isHtmlBody(params.body);
132
- message.push(`Content-Type: ${htmlMode ? 'text/html' : 'text/plain'}; charset="UTF-8"`);
133
- message.push('Content-Transfer-Encoding: quoted-printable');
134
- message.push('MIME-Version: 1.0');
135
- message.push('');
136
- if (params.body) message.push(htmlMode ? params.body : wrapTextBody(params.body));
137
- if (thread) {
138
- const quotedContent = getQuotedContent(thread);
139
- if (quotedContent) {
140
- message.push('');
141
- message.push(wrapTextBody(quotedContent));
142
- }
143
- }
144
- return Buffer.from(message.join('\r\n')).toString('base64url').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
145
- };
146
-
147
- export const constructRawMessageWithAttachments = async (gmail, params) => {
148
- let thread = null;
149
- if (params.threadId) {
150
- const { data } = await gmail.users.threads.get({ userId: 'me', id: params.threadId, format: 'full' });
151
- thread = data;
152
- }
153
- const boundary = `boundary_${Date.now()}_${Math.random().toString(36).slice(2)}`;
154
- const headers = [];
155
- if (params.to?.length) headers.push(`To: ${params.to.join(', ')}`);
156
- if (params.cc?.length) headers.push(`Cc: ${params.cc.join(', ')}`);
157
- if (params.bcc?.length) headers.push(`Bcc: ${params.bcc.join(', ')}`);
158
- if (thread) {
159
- headers.push(...getThreadHeaders(thread));
160
- } else if (params.subject) {
161
- headers.push(`Subject: ${params.subject}`);
162
- } else {
163
- headers.push('Subject: (No Subject)');
164
- }
165
- headers.push('MIME-Version: 1.0');
166
- headers.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
167
- const parts = [];
168
- // Text body part
169
- let bodyText = params.body || '';
170
- if (thread) {
171
- const quotedContent = getQuotedContent(thread);
172
- if (quotedContent) bodyText += '\n\n' + quotedContent;
173
- }
174
- const htmlMode = isHtmlBody(bodyText);
175
- parts.push([
176
- `--${boundary}`,
177
- `Content-Type: ${htmlMode ? 'text/html' : 'text/plain'}; charset="UTF-8"`,
178
- 'Content-Transfer-Encoding: base64',
179
- '',
180
- Buffer.from(bodyText).toString('base64'),
181
- ].join('\r\n'));
182
- // Attachment parts
183
- for (const att of params.attachments) {
184
- const attHeaders = [
185
- `--${boundary}`,
186
- `Content-Type: ${att.mimeType}; name="${att.filename}"`,
187
- 'Content-Transfer-Encoding: base64',
188
- `Content-Disposition: attachment; filename="${att.filename}"`,
189
- '',
190
- att.base64Data,
191
- ];
192
- parts.push(attHeaders.join('\r\n'));
193
- }
194
- const raw = [
195
- headers.join('\r\n'),
196
- '',
197
- parts.join('\r\n'),
198
- `--${boundary}--`,
199
- ].join('\r\n');
200
- return Buffer.from(raw).toString('base64url').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
201
- };
202
-
203
- export const getPlainTextBody = (messagePart) => {
204
- if (messagePart.mimeType === 'text/plain' && messagePart.body?.data) {
205
- return Buffer.from(messagePart.body.data, 'base64').toString('utf-8');
206
- }
207
- if (messagePart.parts) {
208
- for (const part of messagePart.parts) {
209
- const text = getPlainTextBody(part);
210
- if (text) return text;
211
- }
212
- }
213
- return '';
214
- };
215
-
216
- export const formatMessageClean = (message, maxBodyChars = 3000) => {
217
- const headers = message.payload?.headers || [];
218
- const get = (name) => findHeader(headers, name);
219
- let body = getPlainTextBody(message.payload) || '';
220
- const totalChars = body.length;
221
- const truncated = maxBodyChars > 0 && body.length > maxBodyChars;
222
- if (truncated) body = body.slice(0, maxBodyChars);
223
- return {
224
- id: message.id,
225
- threadId: message.threadId,
226
- labelIds: message.labelIds,
227
- snippet: message.snippet,
228
- from: get('from'),
229
- to: get('to'),
230
- cc: get('cc'),
231
- subject: get('subject'),
232
- date: get('date'),
233
- body,
234
- ...(truncated ? { bodyTruncated: true, totalChars } : {}),
235
- };
236
- };
237
-
238
- export const formatMessageMetadata = (message) => {
239
- const headers = message.payload?.headers || [];
240
- const get = (name) => findHeader(headers, name);
241
- return {
242
- id: message.id,
243
- threadId: message.threadId,
244
- labelIds: message.labelIds,
245
- snippet: message.snippet,
246
- from: get('from'),
247
- to: get('to'),
248
- cc: get('cc'),
249
- subject: get('subject'),
250
- date: get('date'),
251
- };
252
- };
1
+ // Gmail message processing helpers.
2
+ // Ported from @shinzolabs/gmail-mcp with minimal changes.
3
+
4
+ const RESPONSE_HEADERS_LIST = [
5
+ 'Date',
6
+ 'From',
7
+ 'To',
8
+ 'Subject',
9
+ 'Message-ID',
10
+ 'In-Reply-To',
11
+ 'References'
12
+ ];
13
+
14
+ const decodedBody = (body, maxBodyChars = 0) => {
15
+ if (!body?.data) return body;
16
+ const decodedData = Buffer.from(body.data, 'base64').toString('utf-8');
17
+ const truncated = maxBodyChars > 0 && decodedData.length > maxBodyChars;
18
+ return {
19
+ data: truncated ? decodedData.slice(0, maxBodyChars) : decodedData,
20
+ size: body.data.length,
21
+ attachmentId: body.attachmentId,
22
+ ...(truncated ? { bodyTruncated: true, totalChars: decodedData.length } : {}),
23
+ };
24
+ };
25
+
26
+ export const processMessagePart = (messagePart, includeBodyHtml = false, maxBodyChars = 0) => {
27
+ if ((messagePart.mimeType !== 'text/html' || includeBodyHtml) && messagePart.body) {
28
+ const bodyLimit = messagePart.mimeType?.startsWith('text/') ? maxBodyChars : 0;
29
+ messagePart.body = decodedBody(messagePart.body, bodyLimit);
30
+ } else if (messagePart.body?.data && maxBodyChars > 0) {
31
+ // Parts left undecoded (e.g. text/html without includeBodyHtml) still
32
+ // carry their full base64 payload — the main size offender in full
33
+ // mode. We can't slice raw base64 to bound it: cutting at an arbitrary
34
+ // offset yields invalid, non-portable base64 and a totalChars measured
35
+ // in encoded (not decoded) characters. Instead, decode just to measure,
36
+ // and when the body exceeds the cap drop the payload entirely, leaving
37
+ // valid truncation metadata (decoded totalChars) in its place.
38
+ //
39
+ // The cap has to consider both measures. What the client actually
40
+ // receives is the base64 string, which runs about four characters per
41
+ // three bytes of body, and non-ASCII text costs several bytes per
42
+ // character. So 3,000 characters of CJK HTML decode well under a
43
+ // 10,000 character cap while returning roughly 12,000 characters of
44
+ // base64. Gate on whichever measure is larger.
45
+ const encodedChars = messagePart.body.data.length;
46
+ const decodedChars = Buffer.from(messagePart.body.data, 'base64').toString('utf-8').length;
47
+ if (decodedChars > maxBodyChars || encodedChars > maxBodyChars) {
48
+ messagePart.body = {
49
+ size: messagePart.body.size ?? encodedChars,
50
+ attachmentId: messagePart.body.attachmentId,
51
+ bodyOmitted: true,
52
+ totalChars: decodedChars,
53
+ encodedChars,
54
+ };
55
+ }
56
+ }
57
+ if (messagePart.parts) {
58
+ messagePart.parts = messagePart.parts.map(part => processMessagePart(part, includeBodyHtml, maxBodyChars));
59
+ }
60
+ if (messagePart.headers) {
61
+ messagePart.headers = messagePart.headers.filter(header => RESPONSE_HEADERS_LIST.includes(header.name || ''));
62
+ }
63
+ return messagePart;
64
+ };
65
+
66
+ export const getNestedHistory = (messagePart, level = 1) => {
67
+ if (messagePart.mimeType === 'text/plain' && messagePart.body?.data) {
68
+ const { data } = decodedBody(messagePart.body);
69
+ if (!data) return '';
70
+ return data.split('\n').map(line => '>' + (line.startsWith('>') ? '' : ' ') + line).join('\n');
71
+ }
72
+ return (messagePart.parts || []).map(p => getNestedHistory(p, level + 1)).filter(p => p).join('\n');
73
+ };
74
+
75
+ export const findHeader = (headers, name) => {
76
+ if (!headers || !Array.isArray(headers) || !name) return undefined;
77
+ return headers.find(h => h?.name?.toLowerCase() === name.toLowerCase())?.value ?? undefined;
78
+ };
79
+
80
+ export const formatEmailList = (emailList) => {
81
+ if (!emailList) return [];
82
+ return emailList.split(',').map(email => email.trim());
83
+ };
84
+
85
+ const getQuotedContent = (thread) => {
86
+ if (!thread.messages?.length) return '';
87
+ const sentMessages = thread.messages.filter(msg =>
88
+ msg.labelIds?.includes('SENT') ||
89
+ (!msg.labelIds?.includes('DRAFT') && findHeader(msg.payload?.headers || [], 'date'))
90
+ );
91
+ if (!sentMessages.length) return '';
92
+ const lastMessage = sentMessages[sentMessages.length - 1];
93
+ if (!lastMessage?.payload) return '';
94
+ let quotedContent = [];
95
+ if (lastMessage.payload.headers) {
96
+ const fromHeader = findHeader(lastMessage.payload.headers || [], 'from');
97
+ const dateHeader = findHeader(lastMessage.payload.headers || [], 'date');
98
+ if (fromHeader && dateHeader) {
99
+ quotedContent.push('');
100
+ quotedContent.push(`On ${dateHeader} ${fromHeader} wrote:`);
101
+ quotedContent.push('');
102
+ }
103
+ }
104
+ const nestedHistory = getNestedHistory(lastMessage.payload);
105
+ if (nestedHistory) {
106
+ quotedContent.push(nestedHistory);
107
+ quotedContent.push('');
108
+ }
109
+ return quotedContent.join('\n');
110
+ };
111
+
112
+ const getThreadHeaders = (thread) => {
113
+ let headers = [];
114
+ if (!thread.messages?.length) return headers;
115
+ const lastMessage = thread.messages[thread.messages.length - 1];
116
+ const references = [];
117
+ let subjectHeader = findHeader(lastMessage.payload?.headers || [], 'subject');
118
+ if (subjectHeader) {
119
+ if (!subjectHeader.toLowerCase().startsWith('re:')) {
120
+ subjectHeader = `Re: ${subjectHeader}`;
121
+ }
122
+ headers.push(`Subject: ${subjectHeader}`);
123
+ }
124
+ const messageIdHeader = findHeader(lastMessage.payload?.headers || [], 'message-id');
125
+ if (messageIdHeader) {
126
+ headers.push(`In-Reply-To: ${messageIdHeader}`);
127
+ references.push(messageIdHeader);
128
+ }
129
+ const referencesHeader = findHeader(lastMessage.payload?.headers || [], 'references');
130
+ if (referencesHeader) references.unshift(...referencesHeader.split(' '));
131
+ if (references.length > 0) headers.push(`References: ${references.join(' ')}`);
132
+ return headers;
133
+ };
134
+
135
+ export const wrapTextBody = (text) => text.split('\n').map(line => {
136
+ if (line.length <= 76) return line;
137
+ const chunks = line.match(/.{1,76}/g) || [];
138
+ return chunks.join('=\n');
139
+ }).join('\n');
140
+
141
+ // RFC 5322 line limits are measured in OCTETS, not UTF-16 code units. The raw
142
+ // message is serialized as UTF-8, so a non-ASCII subject or display name can
143
+ // blow past the 998-octet hard limit while `string.length` stays small.
144
+ // Measure with byte length so wrapping decisions account for multi-byte UTF-8
145
+ // octets.
146
+ //
147
+ // Folding (a CRLF followed by WSP) is only legal at a point where folding
148
+ // white space is already allowed (RFC 5322 §2.2.3, §3.2.2). A run of
149
+ // characters with no internal whitespace has no such point: a message-id
150
+ // atom "does not have internal CFWS anywhere in the message identifier"
151
+ // (§3.6.4), an address atom is likewise unbreakable, and an RFC 2047
152
+ // encoded-word's encoded-text "MUST NOT be continued from one encoded-word
153
+ // to another" (RFC 2047 §2). Even for a plain unstructured run (e.g. a CJK
154
+ // or emoji subject with no spaces), inserting a fold is not safe: §2.2.3
155
+ // defines unfolding as "simply removing any CRLF that is immediately
156
+ // followed by WSP" - the CRLF is removed, but the WSP is NOT, so an
157
+ // injected fold leaves a permanent extra space in the decoded value that
158
+ // was never in the original. The only RFC-safe behavior for a wordless,
159
+ // over-length token is to leave it unfolded on its own line, even if that
160
+ // line then exceeds the 998-octet hard limit: an overlong line is a
161
+ // robustness concern (§2.1.1, "Individual implementations MAY choose to
162
+ // include higher limits"), whereas splitting the token would corrupt a
163
+ // structured value (breaking Message-ID/References matching, or DKIM
164
+ // signatures over the raw header bytes) or silently change an unstructured
165
+ // one.
166
+ const byteLen = (str) => Buffer.byteLength(str, 'utf8');
167
+ const SOFT_LIMIT = 78; // recommended max octets per line (RFC 5322 §2.1.1)
168
+
169
+ export const foldHeader = (name, value) => {
170
+ const prefix = `${name}: `;
171
+ const normalizedValue = String(value).replace(/(?:\r\n?|\n)[ \t]*/g, ' ');
172
+ const unfolded = prefix + normalizedValue;
173
+ if (byteLen(unfolded) <= SOFT_LIMIT) return unfolded;
174
+
175
+ const lines = [];
176
+ let line = prefix;
177
+ const tokens = normalizedValue.match(/\S+(?:[ \t]+|$)/g) || [];
178
+ for (const token of tokens) {
179
+ const minBytes = line === prefix ? prefix.length : 1;
180
+ if (byteLen(line) > minBytes && byteLen(line) + byteLen(token) > SOFT_LIMIT) {
181
+ lines.push(line.trimEnd());
182
+ line = ' ';
183
+ }
184
+ // Tokens are only ever joined at existing whitespace (see the regex
185
+ // above), which is the one place FWS is unconditionally legal. A
186
+ // single token that is itself over-length (a long message-id, an
187
+ // address, an encoded-word, or a wordless CJK/emoji run) is never
188
+ // split internally - it just becomes a long line, folded away from
189
+ // its neighbors on the next token boundary.
190
+ line += token;
191
+ }
192
+ lines.push(line.trimEnd());
193
+ return lines.join('\r\n');
194
+ };
195
+
196
+ // getThreadHeaders returns pre-joined "Name: value" strings; split on the
197
+ // first colon (header field-names are always ASCII and never contain one)
198
+ // and re-fold the value so threading headers (Subject/In-Reply-To/References)
199
+ // get the same RFC 5322 folding as To/Cc/Bcc/Subject above.
200
+ const foldThreadHeader = (header) => {
201
+ const separator = header.indexOf(':');
202
+ return foldHeader(header.slice(0, separator), header.slice(separator + 2));
203
+ };
204
+
205
+ export const isHtmlBody = (text) => /<\/?[a-z][\s\S]*?>/i.test(text);
206
+
207
+ export const constructRawMessage = async (gmail, params) => {
208
+ let thread = null;
209
+ if (params.threadId) {
210
+ const { data } = await gmail.users.threads.get({ userId: 'me', id: params.threadId, format: 'full' });
211
+ thread = data;
212
+ }
213
+ const message = [];
214
+ if (params.to?.length) message.push(foldHeader('To', params.to.join(', ')));
215
+ if (params.cc?.length) message.push(foldHeader('Cc', params.cc.join(', ')));
216
+ if (params.bcc?.length) message.push(foldHeader('Bcc', params.bcc.join(', ')));
217
+ if (thread) {
218
+ message.push(...getThreadHeaders(thread).map(foldThreadHeader));
219
+ } else if (params.subject) {
220
+ message.push(foldHeader('Subject', params.subject));
221
+ } else {
222
+ message.push('Subject: (No Subject)');
223
+ }
224
+ const htmlMode = params.body && isHtmlBody(params.body);
225
+ message.push(`Content-Type: ${htmlMode ? 'text/html' : 'text/plain'}; charset="UTF-8"`);
226
+ message.push('Content-Transfer-Encoding: quoted-printable');
227
+ message.push('MIME-Version: 1.0');
228
+ message.push('');
229
+ if (params.body) message.push(htmlMode ? params.body : wrapTextBody(params.body));
230
+ if (thread) {
231
+ const quotedContent = getQuotedContent(thread);
232
+ if (quotedContent) {
233
+ message.push('');
234
+ message.push(wrapTextBody(quotedContent));
235
+ }
236
+ }
237
+ return Buffer.from(message.join('\r\n')).toString('base64url').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
238
+ };
239
+
240
+ export const constructRawMessageWithAttachments = async (gmail, params) => {
241
+ let thread = null;
242
+ if (params.threadId) {
243
+ const { data } = await gmail.users.threads.get({ userId: 'me', id: params.threadId, format: 'full' });
244
+ thread = data;
245
+ }
246
+ const boundary = `boundary_${Date.now()}_${Math.random().toString(36).slice(2)}`;
247
+ const headers = [];
248
+ if (params.to?.length) headers.push(foldHeader('To', params.to.join(', ')));
249
+ if (params.cc?.length) headers.push(foldHeader('Cc', params.cc.join(', ')));
250
+ if (params.bcc?.length) headers.push(foldHeader('Bcc', params.bcc.join(', ')));
251
+ if (thread) {
252
+ headers.push(...getThreadHeaders(thread).map(foldThreadHeader));
253
+ } else if (params.subject) {
254
+ headers.push(foldHeader('Subject', params.subject));
255
+ } else {
256
+ headers.push('Subject: (No Subject)');
257
+ }
258
+ headers.push('MIME-Version: 1.0');
259
+ headers.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
260
+ const parts = [];
261
+ // Text body part
262
+ let bodyText = params.body || '';
263
+ if (thread) {
264
+ const quotedContent = getQuotedContent(thread);
265
+ if (quotedContent) bodyText += '\n\n' + quotedContent;
266
+ }
267
+ const htmlMode = isHtmlBody(bodyText);
268
+ parts.push([
269
+ `--${boundary}`,
270
+ `Content-Type: ${htmlMode ? 'text/html' : 'text/plain'}; charset="UTF-8"`,
271
+ 'Content-Transfer-Encoding: base64',
272
+ '',
273
+ Buffer.from(bodyText).toString('base64'),
274
+ ].join('\r\n'));
275
+ // Attachment parts
276
+ for (const att of params.attachments) {
277
+ const attHeaders = [
278
+ `--${boundary}`,
279
+ `Content-Type: ${att.mimeType}; name="${att.filename}"`,
280
+ 'Content-Transfer-Encoding: base64',
281
+ `Content-Disposition: attachment; filename="${att.filename}"`,
282
+ '',
283
+ att.base64Data,
284
+ ];
285
+ parts.push(attHeaders.join('\r\n'));
286
+ }
287
+ const raw = [
288
+ headers.join('\r\n'),
289
+ '',
290
+ parts.join('\r\n'),
291
+ `--${boundary}--`,
292
+ ].join('\r\n');
293
+ return Buffer.from(raw).toString('base64url').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
294
+ };
295
+
296
+ export const getPlainTextBody = (messagePart) => {
297
+ if (messagePart.mimeType === 'text/plain' && messagePart.body?.data) {
298
+ return Buffer.from(messagePart.body.data, 'base64').toString('utf-8');
299
+ }
300
+ if (messagePart.parts) {
301
+ for (const part of messagePart.parts) {
302
+ const text = getPlainTextBody(part);
303
+ if (text) return text;
304
+ }
305
+ }
306
+ return '';
307
+ };
308
+
309
+ // Returns { text, quotedHistoryAmbiguous }. text is the input unchanged
310
+ // unless quotedness could be positively established, in which case the
311
+ // quoted tail is removed. quotedHistoryAmbiguous is true only when an
312
+ // unambiguous Outlook-style marker was found but the tail could not be
313
+ // confirmed as quoted, so the caller knows history was detected and left in
314
+ // place rather than silently kept or silently guessed away.
315
+ export const stripQuotedHistory = (text) => {
316
+ if (!text) return { text, quotedHistoryAmbiguous: false };
317
+ const lines = text.split(/\r?\n/);
318
+ // A bare ">" line is deliberately NOT a marker: senders legitimately author
319
+ // ">"-prefixed excerpts (pasted shell output, manual quotes). Only a real
320
+ // attribution marker opens a strip.
321
+ let stripFrom = -1;
322
+ // A hard delimiter is Outlook's "-----Original Message-----" separator.
323
+ // Unlike the soft markers below, this marker itself is unambiguous: it
324
+ // only appears when a mail client inserted a quoted original. What is
325
+ // ambiguous is where that quoted original ends, since Outlook's pasted
326
+ // body carries no ">" prefix. We used to strip this tail unconditionally,
327
+ // which silently deleted any real reply typed below it; a later attempt
328
+ // to detect a trailing reply by paragraph position instead leaked whole
329
+ // multi-paragraph quotes into the clean body on the common case, since
330
+ // Outlook puts a blank line right after the header block. Both are wrong
331
+ // in different directions. See the shared verification below: this marker
332
+ // is only treated as resolved, and stripped, when the tail can be
333
+ // positively confirmed as quoted the same way a soft marker's tail is.
334
+ let hardDelimiter = false;
335
+ for (let i = 0; i < lines.length; i++) {
336
+ if (/^\s*-{2,}\s*Original Message\s*-{2,}\s*$/i.test(lines[i])) {
337
+ stripFrom = i;
338
+ hardDelimiter = true;
339
+ break;
340
+ }
341
+ if (/^\s*On .+ wrote:\s*$/i.test(lines[i])) {
342
+ let next = i + 1;
343
+ while (next < lines.length && /^\s*$/.test(lines[next])) next++;
344
+ if (next < lines.length && /^\s*>/.test(lines[next])) {
345
+ stripFrom = i;
346
+ break;
347
+ }
348
+ }
349
+ if (/^\s*From:\s*.+/i.test(lines[i])) {
350
+ let next = i + 1;
351
+ while (next < lines.length && /^\s*$/.test(lines[next])) next++;
352
+ if (next < lines.length && /^\s*Sent:\s*.+/i.test(lines[next])) {
353
+ stripFrom = i;
354
+ break;
355
+ }
356
+ }
357
+ }
358
+ if (stripFrom === -1) return { text, quotedHistoryAmbiguous: false };
359
+ // Quotedness is only established when everything after the marker is
360
+ // blank, attribution-like, or ">"-quoted, AND at least one line is
361
+ // actually ">"-quoted. Inline repliers and bottom-posters put real
362
+ // content below or between quoted blocks, and a trailing block of bare
363
+ // header-like lines (a pasted invite, a signature) is not reply history,
364
+ // stripping either would silently lose content. This same check applies
365
+ // to both the hard delimiter and the soft markers: it is what already
366
+ // protected the soft-marker path, and the hard delimiter gets no special
367
+ // exemption from it.
368
+ const attributionLine = /^\s*(On .+ wrote:|-{2,}\s*Original Message\s*-{2,}|(From|Sent|To|Cc|Subject|Date):\s.*)\s*$/i;
369
+ let sawQuotedLine = false;
370
+ let quotednessEstablished = true;
371
+ for (let i = stripFrom; i < lines.length; i++) {
372
+ if (/^\s*>/.test(lines[i])) {
373
+ sawQuotedLine = true;
374
+ continue;
375
+ }
376
+ if (/^\s*$/.test(lines[i]) || attributionLine.test(lines[i])) continue;
377
+ quotednessEstablished = false;
378
+ break;
379
+ }
380
+ if (!quotednessEstablished || !sawQuotedLine) {
381
+ // Outlook's own pasted body has no ">" prefix, so this is exactly
382
+ // where the hard delimiter fails the check by construction: we
383
+ // cannot tell the quoted original from any authored text around or
384
+ // within it. Rather than guess (either direction has already proven
385
+ // wrong), keep the full body intact and, for the hard delimiter only,
386
+ // say so: the marker unambiguously means quoted history is present,
387
+ // even though we could not safely separate it. Soft markers are
388
+ // themselves only a guess at being a quote (e.g. a bare "From:" line
389
+ // could be a pasted invite), so an unconfirmed tail there is left
390
+ // alone with no extra reporting, same as before.
391
+ return { text, quotedHistoryAmbiguous: hardDelimiter };
392
+ }
393
+ return { text: lines.slice(0, stripFrom).join('\n').replace(/\s+$/, ''), quotedHistoryAmbiguous: false };
394
+ };
395
+
396
+ export const formatMessageClean = (message, maxBodyChars = 3000, includeQuoted = false) => {
397
+ const headers = message.payload?.headers || [];
398
+ const get = (name) => findHeader(headers, name);
399
+ let body = getPlainTextBody(message.payload) || '';
400
+ let quotedHistoryStripped = false;
401
+ let quotedHistoryAmbiguous = false;
402
+ if (!includeQuoted) {
403
+ const stripped = stripQuotedHistory(body);
404
+ quotedHistoryStripped = stripped.text !== body;
405
+ quotedHistoryAmbiguous = stripped.quotedHistoryAmbiguous;
406
+ body = stripped.text;
407
+ }
408
+ const totalChars = body.length;
409
+ const truncated = maxBodyChars > 0 && body.length > maxBodyChars;
410
+ if (truncated) body = body.slice(0, maxBodyChars);
411
+ return {
412
+ id: message.id,
413
+ threadId: message.threadId,
414
+ labelIds: message.labelIds,
415
+ snippet: message.snippet,
416
+ from: get('from'),
417
+ to: get('to'),
418
+ cc: get('cc'),
419
+ subject: get('subject'),
420
+ date: get('date'),
421
+ body,
422
+ ...(quotedHistoryStripped ? { quotedHistoryStripped: true } : {}),
423
+ ...(quotedHistoryAmbiguous ? {
424
+ quotedHistoryAmbiguous: true,
425
+ quotedHistoryNote: 'Outlook-style quoted history ("-----Original Message-----") was detected but could not be safely separated from any authored text around it, since the quoted original has no ">" prefix marking where it ends. The full body is returned unstripped rather than guessed at. Pass includeQuoted: true to suppress this check and always get the full body.',
426
+ } : {}),
427
+ ...(truncated ? { bodyTruncated: true, totalChars } : {}),
428
+ };
429
+ };
430
+
431
+ // Default whole-response character budget for thread tools (getThread,
432
+ // listThreads, batchGetThreads). maxBodyChars only bounds each message body
433
+ // independently, so a thread with many messages (or a list/batch call
434
+ // spanning many threads) had no ceiling on total serialized size — a 100-reply
435
+ // thread at the 3,000-char default body cap alone runs to roughly 300,000
436
+ // body characters before headers/MIME metadata. This budget is a real cap on
437
+ // the aggregate JSON size of the array being returned, applied after the
438
+ // per-item caps above. 0 disables it (opt-out, matching maxBodyChars).
439
+ export const DEFAULT_MAX_RESPONSE_CHARS = 100000;
440
+
441
+ // Produces a bounded stand-in for a single item that is too large to include
442
+ // even alone, within maxChars. Adapts to whatever room is actually available
443
+ // instead of assuming a fixed size: the id is capped to a safe length, and
444
+ // the human-readable reason is truncated to whatever space remains after the
445
+ // id and the required fields. This keeps the stub itself from ever being the
446
+ // thing that blows the budget, down to the floor of the bare skeleton
447
+ // ({"id":"...","responseOmitted":true}), which is the minimum needed to tell
448
+ // the caller anything was omitted at all.
449
+ export const makeOmissionStub = (item, maxChars, describe) => {
450
+ const id = String(item?.id ?? '').slice(0, 200);
451
+ const skeleton = { id, responseOmitted: true };
452
+ const skeletonSize = JSON.stringify(skeleton).length;
453
+ // Reserve a little extra for the "omittedReason" key, its quotes, and the
454
+ // comma joining it to the skeleton.
455
+ const roomForReason = Math.max(0, (maxChars || 0) - skeletonSize - 20);
456
+ if (roomForReason <= 0) return skeleton;
457
+ return { ...skeleton, omittedReason: describe(id).slice(0, roomForReason) };
458
+ };
459
+
460
+ // Drops items from an array (oldest-first via 'start', or lowest-priority-last
461
+ // via 'end') until the serialized array fits within maxChars. Used to bound
462
+ // the aggregate size of a messages array (within one thread) or a
463
+ // threads/results array (across a list/batch call). Re-stringifying on every
464
+ // drop is O(n^2) in item count, which is fine for the message/thread counts
465
+ // these tools realistically return; it keeps the logic exact instead of
466
+ // estimating from average item size.
467
+ //
468
+ // A single remaining item, whether because only one was ever given or every
469
+ // other item was dropped, can still exceed maxChars on its own (a full MIME
470
+ // message, oversized headers). Without a makeStub function, that item is kept
471
+ // as-is, unbounded, matching the original behavior for callers that have not
472
+ // opted in. Every production call site in this codebase now passes a
473
+ // makeStub, so the item is replaced with a bounded omission stub instead:
474
+ // shipping it unbounded would defeat the point of the budget, and dropping to
475
+ // zero items would tell the caller nothing about what was cut.
476
+ export const capArrayByResponseBudget = (items, maxChars, dropFrom = 'start', makeStub) => {
477
+ const totalCount = items?.length ?? 0;
478
+ if (!maxChars || maxChars <= 0 || !totalCount) {
479
+ return { items: items || [], truncated: false, totalCount, includedCount: totalCount };
480
+ }
481
+ if (JSON.stringify(items).length <= maxChars) {
482
+ return { items, truncated: false, totalCount, includedCount: totalCount };
483
+ }
484
+ const kept = items.slice();
485
+ while (kept.length > 1 && JSON.stringify(kept).length > maxChars) {
486
+ if (dropFrom === 'start') kept.shift();
487
+ else kept.pop();
488
+ }
489
+ if (makeStub && kept.length === 1 && JSON.stringify(kept).length > maxChars) {
490
+ // The stub itself will be measured wrapped in a one-element array
491
+ // (the "[" and "]" JSON.stringify adds around it), so give the
492
+ // stub-maker 2 fewer characters than the full budget to work with.
493
+ kept[0] = makeStub(kept[0], Math.max(0, maxChars - 2));
494
+ }
495
+ return { items: kept, truncated: true, totalCount, includedCount: kept.length };
496
+ };
497
+
498
+ // Caps `items` against the real, final serialized size of whatever payload
499
+ // the caller actually returns, not the array in isolation. Truncation
500
+ // metadata (counts, notes) is attached after capping and adds its own bytes;
501
+ // reserving room for it by guessing a fixed constant is fragile, since the
502
+ // note text length and the tool's own attachment shape (top-level fields for
503
+ // getThread/listThreads, mutating the last element of a bare array for
504
+ // batchGetThreads) both vary. Instead this measures the real payload size
505
+ // on each attempt and shrinks the array's own budget by the exact overshoot,
506
+ // converging on a final payload that fits maxChars including its metadata.
507
+ //
508
+ // attachMetadata(cappedItems, truncated, totalCount, includedCount, maxChars)
509
+ // must return the exact value the caller will JSON.stringify and return: a
510
+ // full object for getThread/listThreads, or a full array for
511
+ // batchGetThreads. It receives the target maxChars too, so it can measure
512
+ // its own note text against whatever room is actually left (the same
513
+ // "measure, don't guess" approach makeOmissionStub uses for its reason text)
514
+ // instead of always emitting a fixed-length note that might not fit once the
515
+ // array is down to a single stubbed item.
516
+ //
517
+ // maxChars here is the caller's real, external budget. It must never be
518
+ // confused with the internal per-attempt array budget below: capArrayByResponseBudget
519
+ // treats <= 0 as "no cap" (the documented opt-out), so once the internal
520
+ // budget is squeezed toward 0 while chasing an oversized metadata cost, handing
521
+ // it 0 or a negative number would silently flip back into "unlimited" and
522
+ // return the oversized item in full, the inverse of what a tight budget asked
523
+ // for. The loop below floors the internal budget at 1 specifically so it can
524
+ // never collide with that sentinel; only the caller's original maxChars is
525
+ // ever checked against <= 0 for the real opt-out.
526
+ //
527
+ // If even the smallest achievable payload (every item stubbed to its bare
528
+ // skeleton, metadata note dropped to nothing) still exceeds maxChars, there is
529
+ // no honest payload that both fits and means anything. Returning it anyway
530
+ // would silently exceed the caller's budget, in the worst case by orders of
531
+ // magnitude relative to what they asked for. This returns { ok: false,
532
+ // minimumViableChars } instead so the caller can reject the request with a
533
+ // concrete number rather than pretend the budget was honored.
534
+ export const capToResponseBudget = (items, maxChars, dropFrom, makeStub, attachMetadata) => {
535
+ const totalCount = items?.length ?? 0;
536
+ const untouched = attachMetadata(items || [], false, totalCount, totalCount, maxChars);
537
+ if (!maxChars || maxChars <= 0 || !totalCount || JSON.stringify(untouched).length <= maxChars) {
538
+ return { ok: true, payload: untouched };
539
+ }
540
+ let arrayBudget = maxChars;
541
+ let payload = untouched;
542
+ let size = JSON.stringify(untouched).length;
543
+ // Six attempts is generous headroom: each attempt measures the real
544
+ // overshoot and shrinks the array's budget by exactly that much, so this
545
+ // converges in one or two attempts in practice (metadata size only
546
+ // shifts by a digit or two between attempts) and never loops needlessly.
547
+ for (let attempt = 0; attempt < 6; attempt++) {
548
+ const budget = capArrayByResponseBudget(items, arrayBudget, dropFrom, makeStub);
549
+ payload = attachMetadata(budget.items, true, totalCount, budget.includedCount, maxChars);
550
+ size = JSON.stringify(payload).length;
551
+ if (size <= maxChars) return { ok: true, payload };
552
+ if (arrayBudget <= 1) break; // already at the floor; further looping cannot shrink this further
553
+ arrayBudget = Math.max(1, arrayBudget - (size - maxChars));
554
+ }
555
+ return { ok: false, minimumViableChars: size, payload };
556
+ };
557
+
558
+ export const formatMessageMetadata = (message) => {
559
+ const headers = message.payload?.headers || [];
560
+ const get = (name) => findHeader(headers, name);
561
+ return {
562
+ id: message.id,
563
+ threadId: message.threadId,
564
+ labelIds: message.labelIds,
565
+ snippet: message.snippet,
566
+ from: get('from'),
567
+ to: get('to'),
568
+ cc: get('cc'),
569
+ subject: get('subject'),
570
+ date: get('date'),
571
+ };
572
+ };