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.
- package/LICENSE +21 -21
- package/README.md +555 -276
- package/dist/cachedToolsList.js +52 -52
- package/dist/googleDocsApiHelpers.js +76 -12
- package/dist/helpers.js +572 -252
- package/dist/httpAuth.js +296 -0
- package/dist/index.js +162 -11
- package/dist/logger.js +87 -87
- package/dist/markdown-transformer/docsToMarkdown.js +222 -21
- package/dist/markdown-transformer/index.js +19 -9
- package/dist/markdown-transformer/markdownToDocs.js +458 -28
- package/dist/readTracker.js +136 -17
- package/dist/sessionContext.js +37 -0
- package/dist/setup.js +187 -9
- package/dist/tools/calendar/getBusy.js +64 -64
- package/dist/tools/calendar/getEvents.js +141 -141
- package/dist/tools/calendar/getFree.js +225 -225
- package/dist/tools/calendar/index.js +19 -19
- package/dist/tools/calendar/listCalendars.js +38 -38
- package/dist/tools/calendar/listRecurringInstances.js +83 -83
- package/dist/tools/calendar/manageCalendar.js +121 -121
- package/dist/tools/calendar/manageEvent.js +258 -258
- package/dist/tools/calendar/moveEvent.js +66 -66
- package/dist/tools/docs/addTab.js +10 -12
- package/dist/tools/docs/appendToGoogleDoc.js +9 -4
- package/dist/tools/docs/deleteRange.js +9 -4
- package/dist/tools/docs/findAndReplace.js +9 -4
- package/dist/tools/docs/formatting/applyParagraphStyle.js +4 -1
- package/dist/tools/docs/formatting/getFormatting.js +171 -171
- package/dist/tools/docs/insertImage.js +15 -2
- package/dist/tools/docs/insertPageBreak.js +4 -1
- package/dist/tools/docs/insertTable.js +4 -1
- package/dist/tools/docs/insertTableWithData.js +4 -1
- package/dist/tools/docs/modifyText.js +14 -4
- package/dist/tools/docs/modifyText.test.js +84 -84
- package/dist/tools/docs/readGoogleDoc.js +71 -11
- package/dist/tools/docs/renameTab.js +12 -14
- package/dist/tools/drafts.js +165 -165
- package/dist/tools/drive/createDocument.js +13 -1
- package/dist/tools/drive/downloadFile.js +268 -268
- package/dist/tools/drive/getFileInfo.js +48 -48
- package/dist/tools/drive/getFilePath.js +50 -50
- package/dist/tools/drive/listDriveFiles.js +112 -112
- package/dist/tools/drive/listSharedDrives.js +52 -52
- package/dist/tools/drive/listSharedWithMe.js +97 -97
- package/dist/tools/drive/uploadFile.js +111 -111
- package/dist/tools/extras/index.js +9 -9
- package/dist/tools/extras/readDriveFile.js +212 -206
- package/dist/tools/extras/readFile.js +84 -84
- package/dist/tools/extras/searchFileContents.js +81 -81
- package/dist/tools/forms/batchUpdateForm.js +81 -81
- package/dist/tools/forms/createForm.js +54 -54
- package/dist/tools/forms/getForm.js +118 -118
- package/dist/tools/forms/getFormResponse.js +45 -45
- package/dist/tools/forms/index.js +15 -15
- package/dist/tools/forms/listFormResponses.js +56 -56
- package/dist/tools/forms/setPublishSettings.js +59 -59
- package/dist/tools/gmail/drafts.js +165 -165
- package/dist/tools/gmail/labels.js +54 -83
- package/dist/tools/gmail/messages.js +444 -441
- package/dist/tools/gmail/settings.js +358 -528
- package/dist/tools/gmail/threads.js +286 -164
- package/dist/tools/index.js +511 -473
- package/dist/tools/labels.js +103 -103
- package/dist/tools/legacyAliases.js +426 -0
- package/dist/tools/maps/directions.js +72 -0
- package/dist/tools/maps/geocode.js +16 -0
- package/dist/tools/maps/index.js +15 -0
- package/dist/tools/maps/mapsClient.js +83 -0
- package/dist/tools/maps/placeDetails.js +16 -0
- package/dist/tools/maps/reverseGeocode.js +19 -0
- package/dist/tools/maps/searchNearby.js +54 -0
- package/dist/tools/maps/searchPlaces.js +24 -0
- package/dist/tools/messages.js +448 -448
- package/dist/tools/settings.js +528 -528
- package/dist/tools/slides/createPresentation.js +104 -104
- package/dist/tools/slides/createShape.js +92 -92
- package/dist/tools/slides/createTextBox.js +82 -82
- package/dist/tools/slides/deleteSlide.js +30 -30
- package/dist/tools/slides/duplicateSlide.js +37 -37
- package/dist/tools/slides/exportThumbnail.js +42 -42
- package/dist/tools/slides/formatParagraph.js +72 -72
- package/dist/tools/slides/formatText.js +84 -84
- package/dist/tools/slides/getPresentation.js +87 -87
- package/dist/tools/slides/index.js +33 -33
- package/dist/tools/slides/reorderSlides.js +41 -41
- package/dist/tools/slides/replaceAllText.js +46 -46
- package/dist/tools/slides/setBackground.js +58 -58
- package/dist/tools/slides/speakerNotes.js +102 -102
- package/dist/tools/slides/styleShape.js +111 -111
- package/dist/tools/slides/updatePresentation.js +128 -128
- package/dist/tools/threads.js +145 -145
- package/dist/tools/utils/appendMarkdownToGoogleDoc.js +31 -7
- package/dist/tools/utils/replaceDocumentWithMarkdown.js +72 -9
- package/dist/updateCheck.js +202 -0
- package/dist/workspace.js +122 -0
- 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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
messagePart.
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
headers
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
+
};
|