1/** 2 * Gmail API operations via GAPI (gapi.client.gmail). 3 * 4 * Mirrors graph/outlook-mail.js: list, detail, search, download. 5 * All calls wrapped with googleAuth.callWithTokenRefresh for auto-retry 6 * on expired tokens. 7 * 8 * Usage: 9 * import { gmailApi } from '../google/gmail.js'; 10 * const { messages, nextPageToken } = await gmailApi.getInbox(); 11 * const detail = await gmailApi.getDetail(messageId); 12 */ 13 14import { googleAuth } from './auth.js'; 15import { sanitizeEmailHtml, cleanupEmailRenderingHtml } from '../utils/html-sanitizer.js'; 16import { downloadBlob } from '../utils/download.js'; 17 18// âââ Helpers âââââââââââââââââââââââââââââââââââââââââ 19 20function getHeader(headers, name) { 21 return headers?.find(h => h.name.toLowerCase() === name.toLowerCase())?.value ?? ''; 22} 23 24/** 25 * Decode Gmail's base64url encoding to a UTF-8 string. 26 * Gmail uses URL-safe base64: - instead of +, _ instead of / 27 */ 28function decodeBase64Url(data) { 29 if (!data) return ''; 30 const base64 = data.replace(/-/g, '+').replace(/_/g, '/'); 31 try { 32 return decodeURIComponent(escape(atob(base64))); 33 } catch { 34 return atob(base64); // Fallback for non-UTF8 35 } 36} 37 38/** 39 * Recursively extract the message body from payload parts. 40 */ 41function getBody(payload, mimeType = 'text/html') { 42 if (!payload.parts && payload.body?.data) { 43 return decodeBase64Url(payload.body.data); 44 } 45 if (payload.parts) { 46 for (const part of payload.parts) { 47 if (part.mimeType === mimeType && part.body?.data) { 48 return decodeBase64Url(part.body.data); 49 } 50 if (part.parts) { 51 const nested = getBody(part, mimeType); 52 if (nested) return nested; 53 } 54 } 55 } 56 return ''; 57} 58 59/** 60 * Recursively extract attachments from message parts. 61 */ 62function extractAttachments(parts, result = []) { 63 if (!parts) return result; 64 for (const part of parts) { 65 if (part.filename && part.body?.attachmentId) { 66 result.push({ 67 id: part.body.attachmentId, 68 filename: part.filename, 69 mimeType: part.mimeType, 70 size: part.body.size || 0, 71 }); 72 } 73 if (part.parts) extractAttachments(part.parts, result); 74 } 75 return result; 76} 77 78/** 79 * Build an RFC 2822 MIME message + URL-safe base64 encode it (Gmail's `raw` 80 * format). Used by gmailApi.send / gmailApi.reply. 81 * 82 * @param {{to: string, cc?: string, subject: string, html: string, 83 * inReplyTo?: string, references?: string}} parts 84 */ 85function encodeRfc2822({ to, cc, subject, html, inReplyTo, references }) { 86 const headers = [ 87 `To: ${to}`, 88 ]; 89 if (cc) headers.push(`Cc: ${cc}`); 90 headers.push(`Subject: ${subject}`); 91 if (inReplyTo) headers.push(`In-Reply-To: ${inReplyTo}`); 92 if (references) headers.push(`References: ${references}`); 93 headers.push('MIME-Version: 1.0'); 94 headers.push('Content-Type: text/html; charset=UTF-8'); 95 96 const mime = `${headers.join('\r\n')}\r\n\r\n${html}`; 97 98 // RFC 2822 â base64url. btoa handles standard base64; convert to URL-safe. 99 // unescape(encodeURIComponent(...)) preserves UTF-8 â btoa-compatible bytes. 100 const base64 = btoa(unescape(encodeURIComponent(mime))); 101 return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 102} 103 104/** 105 * Get the currently authenticated user's Gmail address. Cached after first 106 * sign-in by google/auth.js â falls back to a profile fetch if not cached. 107 */ 108async function getCurrentGmailAddress() { 109 const cached = googleAuth.getConnectedEmail?.(); 110 if (cached) return cached; 111 try { 112 const profile = await googleAuth.callWithTokenRefresh(() => 113 gapi.client.gmail.users.getProfile({ userId: 'me' }) 114 ); 115 return profile?.result?.emailAddress ?? ''; 116 } catch { 117 return ''; 118 } 119} 120 121/** 122 * Parse "Name <email>" format into { name, email }. 123 */ 124function parseEmailAddress(str) { 125 if (!str) return { name: '', email: '' }; 126 const match = str.match(/^(.*?)\s*<(.+?)>$/); 127 if (match) return { name: match[1].trim().replace(/^"|"$/g, ''), email: match[2] }; 128 return { name: str, email: str }; 129} 130 131// âââ Mappers âââââââââââââââââââââââââââââââââââââââââ 132 133function mapToListItem(msg) { 134 const headers = msg.payload?.headers || msg.headers || []; 135 const parsed = parseEmailAddress(getHeader(headers, 'From')); 136 return { 137 id: msg.id, 138 source: 'gmail', 139 from: parsed.name || parsed.email, 140 fromEmail: parsed.email, 141 subject: getHeader(headers, 'Subject') || '(no subject)', 142 preview: msg.snippet || '', 143 timestamp: getHeader(headers, 'Date'), 144 isRead: !(msg.labelIds?.includes('UNREAD')), 145 hasAttachments: (msg.payload?.parts || []).some(p => p.filename && p.body?.attachmentId), 146 importance: 'normal', 147 }; 148} 149 150function mapToDetail(msg, sanitizedBody, attachments) { 151 const headers = msg.payload.headers; 152 const parsed = parseEmailAddress(getHeader(headers, 'From')); 153 const toRaw = getHeader(headers, 'To'); 154 const ccRaw = getHeader(headers, 'Cc'); 155 156 return { 157 id: msg.id, 158 source: 'gmail', 159 from: parsed.name || parsed.email, 160 fromEmail: parsed.email, 161 subject: getHeader(headers, 'Subject') || '(no subject)', 162 body: sanitizedBody, 163 preview: msg.snippet, 164 timestamp: getHeader(headers, 'Date'), 165 isRead: !(msg.labelIds?.includes('UNREAD')), 166 hasAttachments: attachments.length > 0, 167 importance: 'normal', 168 toRecipients: toRaw ? toRaw.split(',').map(s => parseEmailAddress(s.trim())) : [], 169 ccRecipients: ccRaw ? ccRaw.split(',').map(s => parseEmailAddress(s.trim())) : [], 170 bccRecipients: [], 171 internetMessageId: getHeader(headers, 'Message-ID'), 172 threadId: msg.threadId, 173 attachments, 174 }; 175} 176 177// âââ Public API ââââââââââââââââââââââââââââââââââââââ 178 179export const gmailApi = { 180 181 /** 182 * Get inbox messages with pagination. 183 * @param {number} pageSize 184 * @param {string | null} pageToken 185 * @returns {Promise<{ messages: Array, nextPageToken: string | null }>} 186 */ 187 async getInbox(pageSize = 20, pageToken = null) { 188 const params = { 189 userId: 'me', 190 labelIds: 'INBOX', 191 maxResults: pageSize, 192 includeSpamTrash: false, 193 }; 194 if (pageToken) params.pageToken = pageToken; 195 196 const listResp = await googleAuth.callWithTokenRefresh(() => 197 gapi.client.gmail.users.messages.list(params) 198 ); 199 200 const messageIds = listResp.result.messages ?? []; 201 const nextPageToken = listResp.result.nextPageToken ?? null; 202 203 if (messageIds.length === 0) { 204 return { messages: [], nextPageToken: null }; 205 } 206 207 // Batch fetch metadata (much faster than sequential) 208 const details = await Promise.all( 209 messageIds.map(msg => 210 googleAuth.callWithTokenRefresh(() => 211 gapi.client.gmail.users.messages.get({ 212 userId: 'me', 213 id: msg.id, 214 format: 'metadata', 215 metadataHeaders: ['From', 'Subject', 'Date', 'To', 'Cc'], 216 }) 217 ).then(r => r.result) 218 ) 219 ); 220 221 return { messages: details.map(mapToListItem), nextPageToken }; 222 }, 223 224 /** 225 * Get a single message with full body and attachments. 226 * @param {string} messageId 227 * @returns {Promise<Object>} 228 */ 229 async getDetail(messageId) { 230 const resp = await googleAuth.callWithTokenRefresh(() => 231 gapi.client.gmail.users.messages.get({ 232 userId: 'me', 233 id: messageId, 234 format: 'full', 235 }) 236 ); 237 238 const msg = resp.result; 239 const htmlBody = getBody(msg.payload, 'text/html'); 240 const textBody = getBody(msg.payload, 'text/plain'); 241 // Sanitize (security) then clean up rendering (quality) â see html-sanitizer.js 242 const sanitizedBody = cleanupEmailRenderingHtml( 243 sanitizeEmailHtml(htmlBody || textBody) 244 ); 245 const attachments = extractAttachments(msg.payload.parts ?? []); 246 247 return mapToDetail(msg, sanitizedBody, attachments); 248 }, 249 250 /** 251 * Search messages using Gmail query syntax. 252 * @param {string} query â Same syntax as Gmail search bar 253 * @param {number} maxResults 254 * @returns {Promise<{ messages: Array, nextPageToken: string | null }>} 255 */ 256 async search(query, maxResults = 25, pageToken = null) { 257 const listResp = await googleAuth.callWithTokenRefresh(() => 258 gapi.client.gmail.users.messages.list({ 259 userId: 'me', 260 q: query, 261 maxResults, 262 ...(pageToken ? { pageToken } : {}), 263 }) 264 ); 265 266 const messageIds = listResp.result.messages ?? []; 267 if (messageIds.length === 0) { 268 return { messages: [], nextPageToken: null }; 269 } 270 271 const details = await Promise.all( 272 messageIds.map(msg => 273 googleAuth.callWithTokenRefresh(() => 274 gapi.client.gmail.users.messages.get({ 275 userId: 'me', 276 id: msg.id, 277 format: 'metadata', 278 metadataHeaders: ['From', 'Subject', 'Date', 'To', 'Cc'], 279 }) 280 ).then(r => r.result) 281 ) 282 ); 283 284 return { 285 messages: details.map(mapToListItem),
286 nextPageToken: listResp.result.nextPageToken ?? null, 287 }; 288 }, 289 290 /** 291 * Send a new email through Gmail (gmail.users.messages.send). 292 * Gmail doesn't have a "from JSON object" endpoint â we build an RFC 2822 293 * MIME message ourselves and base64url-encode it. Matches the shape of 294 * outlookMail.send so callers can be source-agnostic. 295 * 296 * @param {{ to: string[], cc?: string[], subject: string, body: string }} payload 297 * `body` is treated as HTML. 298 * @returns {Promise<{ id: string, threadId: string }>} 299 */ 300 async send({ to, cc, subject, body }) { 301 const raw = encodeRfc2822({ 302 to: (to || []).join(', '), 303 cc: (cc || []).join(', '), 304 subject: subject || '', 305 html: body || '', 306 }); 307 308 const resp = await googleAuth.callWithTokenRefresh(() => 309 gapi.client.gmail.users.messages.send({ 310 userId: 'me', 311 resource: { raw }, 312 }) 313 ); 314 return resp.result; 315 }, 316 317 /** 318 * Reply to an existing Gmail thread. Mirrors outlookMail.reply â callers 319 * pass the message ID + comment HTML and the client handles the RFC 2822 320 * construction (In-Reply-To, References, threadId) so the reply lands in 321 * the same conversation in the user's Gmail. 322 * 323 * @param {string} messageId 324 * @param {string} comment â HTML reply body 325 * @param {boolean} replyAll â include original To+Cc minus our own address 326 * @returns {Promise<{ id: string, threadId: string }>} 327 */ 328 async reply(messageId, comment, replyAll = false) { 329 // Fetch original headers so we can set In-Reply-To / References / threadId 330 const original = await this.getDetail(messageId); 331 if (!original) throw new Error('Gmail message not found for reply'); 332 333 // Build recipient list 334 const ownEmail = await getCurrentGmailAddress(); 335 const recipients = new Set(); 336 recipients.add(original.fromEmail); 337 if (replyAll) { 338 for (const r of original.toRecipients || []) { 339 if (r.email && r.email !== ownEmail) recipients.add(r.email); 340 } 341 for (const r of original.ccRecipients || []) { 342 if (r.email && r.email !== ownEmail) recipients.add(r.email); 343 } 344 } 345 recipients.delete(ownEmail); 346 const toList = [...recipients].join(', '); 347 348 // Compose subject with "Re: " prefix if not already present 349 const subj = original.subject?.startsWith('Re:') 350 ? original.subject 351 : `Re: ${original.subject || ''}`; 352 353 const raw = encodeRfc2822({ 354 to: toList, 355 subject: subj, 356 html: comment || '', 357 inReplyTo: original.internetMessageId, 358 references: original.internetMessageId, 359 }); 360 361 const resp = await googleAuth.callWithTokenRefresh(() => 362 gapi.client.gmail.users.messages.send({ 363 userId: 'me', 364 resource: { 365 raw, 366 threadId: original.threadId, 367 }, 368 }) 369 ); 370 return resp.result; 371 }, 372 373 /** 374 * Mark a Gmail message as read by removing the UNREAD label. 375 * Mirrors outlookMail.markAsRead. 376 * 377 * @param {string} messageId 378 */ 379 async markAsRead(messageId) { 380 await googleAuth.callWithTokenRefresh(() => 381 gapi.client.gmail.users.messages.modify({ 382 userId: 'me', 383 id: messageId, 384 resource: { removeLabelIds: ['UNREAD'] }, 385 }) 386 ); 387 }, 388 389 /** 390 * Download an attachment as a file. 391 * @param {string} messageId 392 * @param {string} attachmentId 393 * @param {string} filename 394 * @param {string} mimeType 395 */ 396 async downloadAttachment(messageId, attachmentId, filename, mimeType) { 397 const resp = await googleAuth.callWithTokenRefresh(() => 398 gapi.client.gmail.users.messages.attachments.get({ 399 userId: 'me', 400 messageId, 401 id: attachmentId, 402 }) 403 ); 404 405 // Decode base64url -> binary -> Blob 406 const base64 = resp.result.data.replace(/-/g, '+').replace(/_/g, '/'); 407 const binary = atob(base64); 408 const bytes = new Uint8Array(binary.length); 409 for (let i = 0; i < binary.length; i++) {
410 bytes[i] = binary.charCodeAt(i); 411 } 412 413 const blob = new Blob([bytes], { type: mimeType || 'application/octet-stream' }); 414 downloadBlob(blob, filename); 415 }, 416};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.