PageSourceSearch

https://smrtbld.ai/js/google/gmail.js

js smrtbld.ai collected 2026-10-05 15:11:07 UTC 14,660 bytes, 416 lines download raw bytes

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.