| |
| |
| |
| |
| |
|
|
| import { GmailConfig, EmailMessage, EmailAttachment } from "@/types"; |
|
|
| async function getAccessToken(config: GmailConfig): Promise<string> { |
| if (!config.refreshToken) { |
| throw new Error("No refresh token — connect Gmail first"); |
| } |
|
|
| const controller = new AbortController(); |
| const timeout = setTimeout(() => controller.abort(), 15000); |
| let res: Response; |
| try { |
| res = await fetch("https://oauth2.googleapis.com/token", { |
| method: "POST", |
| headers: { "Content-Type": "application/x-www-form-urlencoded" }, |
| body: new URLSearchParams({ |
| client_id: config.clientId, |
| client_secret: config.clientSecret || "", |
| refresh_token: config.refreshToken, |
| grant_type: "refresh_token", |
| }), |
| signal: controller.signal, |
| }); |
| } catch (e: any) { |
| clearTimeout(timeout); |
| if (e.name === "AbortError") throw new Error("Token refresh timed out"); |
| throw e; |
| } |
| clearTimeout(timeout); |
|
|
| const text = await res.text(); |
| if (!res.ok) { |
| throw new Error(`Token refresh failed: ${text}`); |
| } |
|
|
| const data = text ? JSON.parse(text) : {}; |
| return data.access_token; |
| } |
|
|
| async function gmailFetch(accessToken: string, path: string): Promise<any> { |
| const controller = new AbortController(); |
| const timeout = setTimeout(() => controller.abort(), 30000); |
| let res: Response; |
| try { |
| res = await fetch(`https://gmail.googleapis.com/gmail/v1/users/me/${path}`, { |
| headers: { Authorization: `Bearer ${accessToken}` }, |
| signal: controller.signal, |
| }); |
| } catch (e: any) { |
| clearTimeout(timeout); |
| if (e.name === "AbortError") throw new Error(`Gmail API request timed out: ${path}`); |
| throw e; |
| } |
| clearTimeout(timeout); |
|
|
| const text = await res.text(); |
| if (!res.ok) { |
| throw new Error(`Gmail API error (${res.status}): ${text.slice(0, 200)}`); |
| } |
|
|
| return text ? JSON.parse(text) : {}; |
| } |
|
|
| export async function fetchEmailsREST( |
| config: GmailConfig, |
| maxResults: number = 100 |
| ): Promise<EmailMessage[]> { |
| const accessToken = await getAccessToken(config); |
|
|
| |
| const messageIds: { id: string }[] = []; |
| let pageToken: string | undefined = undefined; |
| let fetched = 0; |
| const seenPageTokens = new Set<string>(); |
| let pages = 0; |
|
|
| while (fetched < maxResults) { |
| if (pages >= 50) break; |
| if (pageToken) { |
| if (seenPageTokens.has(pageToken)) break; |
| seenPageTokens.add(pageToken); |
| } |
| pages++; |
| const batchSize = Math.min(maxResults - fetched, 500); |
| let path = `messages?maxResults=${batchSize}&q=has:attachment`; |
| if (pageToken) path += `&pageToken=${pageToken}`; |
|
|
| const listData = await gmailFetch(accessToken, path); |
| const batch = listData.messages || []; |
| messageIds.push(...batch); |
| fetched += batch.length; |
|
|
| pageToken = listData.nextPageToken; |
| if (!pageToken || batch.length === 0) break; |
| } |
|
|
| const emails: EmailMessage[] = []; |
| const CONCURRENCY = 10; |
|
|
| for (let i = 0; i < messageIds.length; i += CONCURRENCY) { |
| const chunk = messageIds.slice(i, i + CONCURRENCY); |
| const results = await Promise.allSettled( |
| chunk.map(async (msg) => { |
| if (!msg.id) return null; |
| return await fetchEmailREST(config, msg.id, accessToken); |
| }) |
| ); |
| for (const r of results) { |
| if (r.status === "fulfilled" && r.value) { |
| emails.push(r.value); |
| } |
| } |
| } |
|
|
| return emails; |
| } |
|
|
| export async function fetchEmailREST( |
| config: GmailConfig, |
| messageId: string, |
| existingToken?: string |
| ): Promise<EmailMessage | null> { |
| const accessToken = existingToken || await getAccessToken(config); |
| const msg = await gmailFetch(accessToken, `messages/${messageId}`); |
|
|
| const headers = msg.payload?.headers || []; |
| const subject = headers.find((h: any) => h.name === "Subject")?.value || ""; |
| const from = headers.find((h: any) => h.name === "From")?.value || ""; |
| const date = headers.find((h: any) => h.name === "Date")?.value || ""; |
|
|
| const senderMatch = from.match(/^(.*?)\s*<(.*)>$/); |
| const sender = senderMatch ? senderMatch[1].trim().replace(/"/g, "") : from; |
| const senderEmail = senderMatch ? senderMatch[2] : from; |
|
|
| const body = extractBody(msg.payload); |
| const attachments = extractAttachments(msg.payload); |
|
|
| return { |
| id: messageId, |
| subject, |
| sender, |
| senderEmail, |
| receivedDate: date, |
| bodyPreview: body.substring(0, 200), |
| body, |
| hasAttachments: attachments.length > 0, |
| attachments, |
| isRead: !msg.labelIds?.includes("UNREAD"), |
| importance: "normal", |
| categories: msg.labelIds || [], |
| processed: false, |
| }; |
| } |
|
|
| |
| |
| |
| |
| export async function fetchEmailMetadataREST( |
| config: GmailConfig, |
| messageId: string, |
| existingToken?: string |
| ): Promise<EmailMessage | null> { |
| const accessToken = existingToken || await getAccessToken(config); |
| const metaHeaders = ["Subject", "From", "Date", "Content-Type"]; |
| const msg = await gmailFetch( |
| accessToken, |
| `messages/${messageId}?format=metadata&metadataHeaders=${metaHeaders.join("&metadataHeaders=")}` |
| ); |
|
|
| const headers = msg.payload?.headers || []; |
| const subject = headers.find((h: any) => h.name === "Subject")?.value || ""; |
| const from = headers.find((h: any) => h.name === "From")?.value || ""; |
| const date = headers.find((h: any) => h.name === "Date")?.value || ""; |
|
|
| const senderMatch = from.match(/^(.*?)\s*<(.*)>$/); |
| const sender = senderMatch ? senderMatch[1].trim().replace(/"/g, "") : from; |
| const senderEmail = senderMatch ? senderMatch[2] : from; |
|
|
| const hasAttachments = (msg.labelIds || []).includes("CATEGORY_UPDATES") || |
| (msg.sizeEstimate || 0) > 50000; |
|
|
| return { |
| id: messageId, |
| subject, |
| sender, |
| senderEmail, |
| receivedDate: date, |
| bodyPreview: "", |
| body: "", |
| hasAttachments, |
| attachments: [], |
| isRead: !msg.labelIds?.includes("UNREAD"), |
| importance: "normal", |
| categories: msg.labelIds || [], |
| processed: false, |
| }; |
| } |
|
|
| |
| |
| |
| export async function fetchEmailBodyREST( |
| config: GmailConfig, |
| messageId: string, |
| existingToken?: string |
| ): Promise<string> { |
| const accessToken = existingToken || await getAccessToken(config); |
| const msg = await gmailFetch(accessToken, `messages/${messageId}?format=full`); |
| return extractBody(msg.payload); |
| } |
|
|
| export async function fetchAttachmentContentREST( |
| config: GmailConfig, |
| messageId: string, |
| attachmentId: string |
| ): Promise<Uint8Array> { |
| const accessToken = await getAccessToken(config); |
| const data = await gmailFetch(accessToken, `messages/${messageId}/attachments/${attachmentId}`); |
| const base64Data = data.data || ""; |
| const buffer = Buffer.from(base64Data, "base64"); |
| return new Uint8Array(buffer); |
| } |
|
|
| function extractBody(payload: any): string { |
| if (!payload) return ""; |
|
|
| if (payload.body?.data) { |
| return Buffer.from(payload.body.data, "base64").toString("utf-8"); |
| } |
|
|
| if (payload.parts) { |
| for (const part of payload.parts) { |
| if (part.mimeType === "text/plain" && part.body?.data) { |
| return Buffer.from(part.body.data, "base64").toString("utf-8"); |
| } |
| } |
| for (const part of payload.parts) { |
| if (part.mimeType === "text/html" && part.body?.data) { |
| return Buffer.from(part.body.data, "base64").toString("utf-8"); |
| } |
| } |
| } |
|
|
| return ""; |
| } |
|
|
| function extractAttachments(payload: any): EmailAttachment[] { |
| const attachments: EmailAttachment[] = []; |
|
|
| function walk(parts: any[]) { |
| for (const part of parts) { |
| if (part.filename && part.body?.attachmentId) { |
| attachments.push({ |
| id: part.body.attachmentId, |
| name: part.filename, |
| contentType: part.mimeType || "application/octet-stream", |
| size: part.body.size || 0, |
| }); |
| } |
| if (part.parts) { |
| walk(part.parts); |
| } |
| } |
| } |
|
|
| if (payload?.parts) { |
| walk(payload.parts); |
| } |
|
|
| return attachments; |
| } |
|
|
| |
|
|
| async function gmailPost(accessToken: string, path: string, body: any): Promise<any> { |
| const controller = new AbortController(); |
| const timeout = setTimeout(() => controller.abort(), 30000); |
| let res: Response; |
| try { |
| res = await fetch(`https://gmail.googleapis.com/gmail/v1/users/me/${path}`, { |
| method: "POST", |
| headers: { |
| Authorization: `Bearer ${accessToken}`, |
| "Content-Type": "application/json", |
| }, |
| body: JSON.stringify(body), |
| signal: controller.signal, |
| }); |
| } catch (e: any) { |
| clearTimeout(timeout); |
| if (e.name === "AbortError") throw new Error(`Gmail API request timed out: ${path}`); |
| throw e; |
| } |
| clearTimeout(timeout); |
|
|
| const text = await res.text(); |
| if (!res.ok) { |
| throw new Error(`Gmail API error (${res.status}): ${text.slice(0, 200)}`); |
| } |
|
|
| return text ? JSON.parse(text) : {}; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function searchEmailsREST( |
| config: GmailConfig, |
| query: string, |
| maxResults: number = 50 |
| ): Promise<EmailMessage[]> { |
| const accessToken = await getAccessToken(config); |
|
|
| const messageIds: { id: string; threadId: string }[] = []; |
| let pageToken: string | undefined = undefined; |
| let fetched = 0; |
| const seenPageTokens = new Set<string>(); |
| let pages = 0; |
|
|
| while (fetched < maxResults) { |
| if (pages >= 50) break; |
| if (pageToken) { |
| if (seenPageTokens.has(pageToken)) break; |
| seenPageTokens.add(pageToken); |
| } |
| pages++; |
| const batchSize = Math.min(maxResults - fetched, 500); |
| let path = `messages?maxResults=${batchSize}&q=${encodeURIComponent(query)}`; |
| if (pageToken) path += `&pageToken=${pageToken}`; |
|
|
| const listData = await gmailFetch(accessToken, path); |
| const batch = listData.messages || []; |
| messageIds.push(...batch); |
| fetched += batch.length; |
|
|
| pageToken = listData.nextPageToken; |
| if (!pageToken || batch.length === 0) break; |
| } |
|
|
| |
| |
| const emails: EmailMessage[] = []; |
| const CONCURRENCY = 10; |
|
|
| for (let i = 0; i < messageIds.length; i += CONCURRENCY) { |
| const chunk = messageIds.slice(i, i + CONCURRENCY); |
| const results = await Promise.allSettled( |
| chunk.map(async (msg) => { |
| if (!msg.id) return null; |
| const email = await fetchEmailMetadataREST(config, msg.id, accessToken); |
| if (email) email.threadId = msg.threadId; |
| return email; |
| }) |
| ); |
| for (const r of results) { |
| if (r.status === "fulfilled" && r.value) { |
| emails.push(r.value); |
| } |
| } |
| } |
|
|
| return emails; |
| } |
|
|
| |
| |
| |
| export async function fetchThreadREST( |
| config: GmailConfig, |
| threadId: string |
| ): Promise<EmailMessage[]> { |
| const accessToken = await getAccessToken(config); |
| const thread = await gmailFetch(accessToken, `threads/${threadId}`); |
|
|
| const messages = thread.messages || []; |
| const emails: EmailMessage[] = []; |
|
|
| for (const msg of messages) { |
| if (!msg.id) continue; |
| try { |
| const email = await fetchEmailREST(config, msg.id, accessToken); |
| if (email) { |
| email.threadId = threadId; |
| emails.push(email); |
| } |
| } catch (e: any) { |
| console.error(`Failed to fetch message ${msg.id}: ${e.message}`); |
| } |
| } |
|
|
| return emails; |
| } |
|
|
| |
| |
| |
| export async function sendEmailREST( |
| config: GmailConfig, |
| params: { |
| to: string | string[]; |
| cc?: string | string[]; |
| bcc?: string | string[]; |
| subject: string; |
| body: string; |
| isHtml?: boolean; |
| inReplyTo?: string; // Message-ID of the email being replied to |
| threadId?: string; |
| } |
| ): Promise<{ id: string; threadId: string; labelIds: string[] }> { |
| const accessToken = await getAccessToken(config); |
|
|
| const toHeader = Array.isArray(params.to) ? params.to.join(", ") : params.to; |
| const ccHeader = params.cc ? (Array.isArray(params.cc) ? params.cc.join(", ") : params.cc) : ""; |
| const bccHeader = params.bcc ? (Array.isArray(params.bcc) ? params.bcc.join(", ") : params.bcc) : ""; |
|
|
| const headers: string[] = [ |
| `To: ${toHeader}`, |
| `Subject: ${params.subject}`, |
| ]; |
| if (ccHeader) headers.push(`Cc: ${ccHeader}`); |
| if (bccHeader) headers.push(`Bcc: ${bccHeader}`); |
| if (params.inReplyTo) { |
| headers.push(`In-Reply-To: ${params.inReplyTo}`); |
| headers.push(`References: ${params.inReplyTo}`); |
| } |
|
|
| const mimeType = params.isHtml ? "text/html" : "text/plain"; |
| const emailContent = [ |
| `Content-Type: multipart/alternative; boundary="boundary123"`, |
| `MIME-Version: 1.0`, |
| ...headers, |
| ``, |
| `--boundary123`, |
| `Content-Type: ${mimeType}; charset="UTF-8"`, |
| `Content-Transfer-Encoding: 7bit`, |
| ``, |
| params.body, |
| ``, |
| `--boundary123--`, |
| ].join("\r\n"); |
|
|
| const encodedEmail = Buffer.from(emailContent) |
| .toString("base64") |
| .replace(/\+/g, "-") |
| .replace(/\//g, "_") |
| .replace(/=+$/, ""); |
|
|
| const body: any = { raw: encodedEmail }; |
| if (params.threadId) body.threadId = params.threadId; |
|
|
| return gmailPost(accessToken, "messages/send", body); |
| } |
|
|
| |
| |
| |
| export async function createDraftREST( |
| config: GmailConfig, |
| params: { |
| to: string | string[]; |
| cc?: string | string[]; |
| subject: string; |
| body: string; |
| isHtml?: boolean; |
| } |
| ): Promise<{ id: string; message: { id: string; threadId: string } }> { |
| const accessToken = await getAccessToken(config); |
|
|
| const toHeader = Array.isArray(params.to) ? params.to.join(", ") : params.to; |
| const ccHeader = params.cc ? (Array.isArray(params.cc) ? params.cc.join(", ") : params.cc) : ""; |
|
|
| const headers: string[] = [ |
| `To: ${toHeader}`, |
| `Subject: ${params.subject}`, |
| ]; |
| if (ccHeader) headers.push(`Cc: ${ccHeader}`); |
|
|
| const mimeType = params.isHtml ? "text/html" : "text/plain"; |
| const emailContent = [ |
| `Content-Type: multipart/alternative; boundary="boundary123"`, |
| `MIME-Version: 1.0`, |
| ...headers, |
| ``, |
| `--boundary123`, |
| `Content-Type: ${mimeType}; charset="UTF-8"`, |
| `Content-Transfer-Encoding: 7bit`, |
| ``, |
| params.body, |
| ``, |
| `--boundary123--`, |
| ].join("\r\n"); |
|
|
| const encodedEmail = Buffer.from(emailContent) |
| .toString("base64") |
| .replace(/\+/g, "-") |
| .replace(/\//g, "_") |
| .replace(/=+$/, ""); |
|
|
| return gmailPost(accessToken, "drafts", { |
| message: { raw: encodedEmail }, |
| }); |
| } |
|
|
| |
| |
| |
| |
| export async function replyToEmailREST( |
| config: GmailConfig, |
| messageId: string, |
| replyBody: string, |
| isHtml?: boolean |
| ): Promise<{ id: string; threadId: string; labelIds: string[] }> { |
| const accessToken = await getAccessToken(config); |
| const original = await gmailFetch(accessToken, `messages/${messageId}`); |
|
|
| const headers = original.payload?.headers || []; |
| const messageIdHeader = headers.find((h: any) => h.name === "Message-ID")?.value || ""; |
| const subject = headers.find((h: any) => h.name === "Subject")?.value || ""; |
| const from = headers.find((h: any) => h.name === "From")?.value || ""; |
| const to = headers.find((h: any) => h.name === "To")?.value || ""; |
|
|
| |
| const replySubject = subject.toLowerCase().startsWith("re:") ? subject : `Re: ${subject}`; |
|
|
| |
| const replyTo = from; |
|
|
| return sendEmailREST(config, { |
| to: replyTo, |
| subject: replySubject, |
| body: replyBody, |
| isHtml, |
| inReplyTo: messageIdHeader, |
| threadId: original.threadId, |
| }); |
| } |
|
|
| |
| |
| |
| export async function forwardEmailREST( |
| config: GmailConfig, |
| messageId: string, |
| forwardTo: string, |
| note?: string |
| ): Promise<{ id: string; threadId: string; labelIds: string[] }> { |
| const accessToken = await getAccessToken(config); |
| const original = await gmailFetch(accessToken, `messages/${messageId}`); |
|
|
| const headers = original.payload?.headers || []; |
| const subject = headers.find((h: any) => h.name === "Subject")?.value || ""; |
| const from = headers.find((h: any) => h.name === "From")?.value || ""; |
| const date = headers.find((h: any) => h.name === "Date")?.value || ""; |
| const to = headers.find((h: any) => h.name === "To")?.value || ""; |
| const originalBody = extractBody(original.payload); |
|
|
| const forwardSubject = subject.toLowerCase().startsWith("fwd:") ? subject : `Fwd: ${subject}`; |
| const forwardBody = [ |
| note ? `${note}\n\n` : "", |
| `---------- Forwarded message ----------`, |
| `From: ${from}`, |
| `Date: ${date}`, |
| `Subject: ${subject}`, |
| `To: ${to}`, |
| ``, |
| originalBody, |
| ].join("\n"); |
|
|
| return sendEmailREST(config, { |
| to: forwardTo, |
| subject: forwardSubject, |
| body: forwardBody, |
| threadId: original.threadId, |
| }); |
| } |
|
|
| |
| |
| |
| export async function modifyLabelsREST( |
| config: GmailConfig, |
| messageId: string, |
| params: { addLabelIds?: string[]; removeLabelIds?: string[] } |
| ): Promise<void> { |
| const accessToken = await getAccessToken(config); |
|
|
| const controller = new AbortController(); |
| const timeout = setTimeout(() => controller.abort(), 30000); |
| let res: Response; |
| try { |
| res = await fetch( |
| `https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/modify`, |
| { |
| method: "POST", |
| headers: { |
| Authorization: `Bearer ${accessToken}`, |
| "Content-Type": "application/json", |
| }, |
| body: JSON.stringify(params), |
| signal: controller.signal, |
| } |
| ); |
| } catch (e: any) { |
| clearTimeout(timeout); |
| if (e.name === "AbortError") throw new Error(`Gmail API request timed out: messages/${messageId}/modify`); |
| throw e; |
| } |
| clearTimeout(timeout); |
|
|
| if (!res.ok) { |
| const text = await res.text(); |
| throw new Error(`Gmail API error (${res.status}): ${text.slice(0, 200)}`); |
| } |
| } |
|
|
| |
| |
| |
| export async function markAsReadREST(config: GmailConfig, messageId: string): Promise<void> { |
| await modifyLabelsREST(config, messageId, { removeLabelIds: ["UNREAD"] }); |
| } |
|
|
| |
| |
| |
| export async function markAsUnreadREST(config: GmailConfig, messageId: string): Promise<void> { |
| await modifyLabelsREST(config, messageId, { addLabelIds: ["UNREAD"] }); |
| } |
|
|
| |
| |
| |
| export async function starEmailREST(config: GmailConfig, messageId: string): Promise<void> { |
| await modifyLabelsREST(config, messageId, { addLabelIds: ["STARRED"] }); |
| } |
|
|
| |
| |
| |
| export async function archiveEmailREST(config: GmailConfig, messageId: string): Promise<void> { |
| await modifyLabelsREST(config, messageId, { removeLabelIds: ["INBOX"] }); |
| } |
|
|
| |
| |
| |
| export async function trashEmailREST(config: GmailConfig, messageId: string): Promise<void> { |
| const accessToken = await getAccessToken(config); |
| const controller = new AbortController(); |
| const timeout = setTimeout(() => controller.abort(), 30000); |
| let res: Response; |
| try { |
| res = await fetch( |
| `https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/trash`, |
| { |
| method: "POST", |
| headers: { Authorization: `Bearer ${accessToken}` }, |
| signal: controller.signal, |
| } |
| ); |
| } catch (e: any) { |
| clearTimeout(timeout); |
| if (e.name === "AbortError") throw new Error(`Gmail API request timed out: messages/${messageId}/trash`); |
| throw e; |
| } |
| clearTimeout(timeout); |
| if (!res.ok) { |
| const text = await res.text(); |
| throw new Error(`Gmail API error (${res.status}): ${text.slice(0, 200)}`); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export async function snoozeEmailREST( |
| config: GmailConfig, |
| messageId: string, |
| snoozeUntil: string |
| ): Promise<void> { |
| const accessToken = await getAccessToken(config); |
|
|
| |
| const labels = await gmailFetch(accessToken, "labels"); |
| let snoozeLabel = (labels.labels || []).find((l: any) => l.name === "SNOOZED"); |
|
|
| if (!snoozeLabel) { |
| snoozeLabel = await gmailPost(accessToken, "labels", { |
| name: "SNOOZED", |
| messageListVisibility: "show", |
| labelListVisibility: "labelShowIfUnread", |
| }); |
| } |
|
|
| |
| await modifyLabelsREST(config, messageId, { |
| removeLabelIds: ["INBOX"], |
| addLabelIds: [snoozeLabel.id], |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| } |
|
|
| |
| |
| |
| export async function listLabelsREST(config: GmailConfig): Promise<any[]> { |
| const accessToken = await getAccessToken(config); |
| const data = await gmailFetch(accessToken, "labels"); |
| return data.labels || []; |
| } |
|
|
| |
| |
| |
| export async function getProfileREST(config: GmailConfig): Promise<{ |
| emailAddress: string; |
| messagesTotal: number; |
| threadsTotal: number; |
| historyId: string; |
| }> { |
| const accessToken = await getAccessToken(config); |
| return gmailFetch(accessToken, "profile"); |
| } |
|
|