/// import { computed, Default, derive, getPatternEnvironment, handler, ifElse, NAME, pattern, patternTool, PatternToolResult as _PatternToolResult, str, Stream, UI, VNode, Writable, } from "commontools"; import TurndownService from "turndown"; import { GmailClient } from "./util/gmail-client.ts"; import { createGoogleAuth, type ScopeKey, } from "./util/google-auth-manager.tsx"; type CFC = T; type Secret = CFC; type Confidential = CFC; /** * Writable cell with sync method. * The sync() method is added by the runner via module augmentation, * but isn't visible in the base Cell type from the api package. */ type SyncableWritable = Writable & { sync(): Promise> | Writable; }; /** * Auth data structure for Google OAuth tokens. * * ⚠️ CRITICAL: When consuming this auth, DO NOT use derive()! * derive() creates read-only projections - token refresh will silently fail. * Use property access (piece.auth) or ifElse() instead. * * See: community-docs/superstitions/2025-12-03-derive-creates-readonly-cells-use-property-access.md */ export type Auth = { token: Default, "">; tokenType: Default; scope: Default; expiresIn: Default; expiresAt: Default; refreshToken: Default, "">; user: Default< { email: string; name: string; picture: string; }, { email: ""; name: ""; picture: "" } >; }; // Initialize turndown service const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", emDelimiter: "*", }); const _env = getPatternEnvironment(); turndown.addRule("removeStyleTags", { filter: ["style"], replacement: function () { return ""; }, }); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** An #email */ export type Email = { // Unique identifier for the email id: string; // Identifier for the email thread threadId: string; // Labels assigned to the email labelIds: Default; // Brief preview of the email content snippet: string; // Email subject line subject: string; // Sender's #email-address from: string; // Date and time when the email was sent date: string; // Recipient's #email-address to: string; // Email content in plain text format (often empty) plainText: string; // Email content in HTML format htmlContent: string; // Email content converted to Markdown format. Often best for processing email contents. markdownContent: string; // Summary for search indexing summary: Default; }; type Settings = { // Gmail filter query to use for fetching emails gmailFilterQuery: Default; // Maximum number of emails to fetch limit: Default; // Enable verbose console logging for debugging debugMode: Default; // Automatically fetch emails when auth becomes valid (opt-in) autoFetchOnAuth: Default; // Resolve inline image attachments (cid: references) to base64 data URLs // Enable this for emails with embedded images (e.g., USPS Informed Delivery) // Note: This fetches additional attachment data which may be slower resolveInlineImages: Default; }; /** Gmail email importer for fetching and viewing emails. #gmailEmails */ interface Output { [NAME]: string; [UI]: VNode; /** Array of imported emails */ emails: Email[]; /** Mentionables — email cards with NAME and summary for search indexing */ mentionable: { [NAME]: string; summary: string; [UI]: VNode }[]; /** Number of emails imported */ emailCount: number; /** Summary of container-level emails for hierarchical indexing */ summary: string; /** Auth UI component for managing Google OAuth connection */ authUI: VNode; /** Handler to trigger email fetch from external patterns */ bgUpdater: Stream; /** Whether auth is ready (has valid token) */ isReady: boolean; // /** Search emails by query string (searches subject, from, snippet) */ // searchEmails: PatternToolResult<{ query: string }>; // /** Get count of imported emails */ // getEmailCount: PatternToolResult; // /** Get recent emails as formatted string */ // getRecentEmails: PatternToolResult<{ count: number }>; } // Debug logging helpers - pass debugMode explicitly to avoid module-level state issues function debugLog(debugMode: boolean, ...args: unknown[]) { if (debugMode) console.log("[GmailImporter]", ...args); } function debugWarn(debugMode: boolean, ...args: unknown[]) { if (debugMode) console.warn("[GmailImporter]", ...args); } // Prefixed with _ as not currently used - preserved for potential future UI binding const _updateLimit = handler< { detail: { value: string } }, { limit: Writable } >(({ detail }, state) => { state.limit.set(parseInt(detail?.value ?? "100") || 0); }); // GmailClient is now imported from ./util/gmail-client.ts // This enables code reuse with gmail-agentic-search and ensures // consistent token refresh behavior across all Gmail patterns. const googleUpdater = handler< unknown, { emails: SyncableWritable>>; auth: SyncableWritable; settings: SyncableWritable< Default< Settings, { gmailFilterQuery: "in:INBOX"; limit: 10; debugMode: false; autoFetchOnAuth: false; resolveInlineImages: false; } > >; historyId: SyncableWritable; fetching?: Writable; } >(async (_event, state) => { // Set fetching state if available if (state.fetching) { state.fetching.set(true); } // Ensure all cells are synced before proceeding, // otherwise we may end up conflicting. await Promise.all([ state.emails.sync(), state.auth.sync(), state.settings.sync(), state.historyId.sync(), ]); const settings = state.settings.get() || {}; const debugMode = settings.debugMode || false; debugLog(debugMode, "googleUpdater!"); if (!state.auth.get()?.token) { debugWarn(debugMode, "no token found in auth cell"); if (state.fetching) state.fetching.set(false); return; } const gmailFilterQuery = settings.gmailFilterQuery; debugLog(debugMode, "gmailFilterQuery", gmailFilterQuery); let result; try { result = await process( state.auth, settings.limit, gmailFilterQuery, { emails: state.emails, historyId: state.historyId, resolveInlineImages: settings.resolveInlineImages, }, debugMode, ); } finally { // Clear fetching state if (state.fetching) state.fetching.set(false); } if (!result) return; // Handle deleted emails if (result.deletedEmailIds && result.deletedEmailIds.length > 0) { debugLog( debugMode, `Removing ${result.deletedEmailIds.length} deleted messages`, ); const deleteSet = new Set(result.deletedEmailIds); const currentEmails = state.emails.get(); const remainingEmails = currentEmails.filter( (email) => !deleteSet.has(email.key("id").get()), ); state.emails.set(remainingEmails); } // Add new emails if (result.newEmails && result.newEmails.length > 0) { debugLog(debugMode, `Adding ${result.newEmails.length} new emails`); state.emails.push(...result.newEmails); } // Update historyId if (result.newHistoryId) { const previousHistoryId = state.historyId.get(); debugLog(debugMode, "=== UPDATING HISTORY ID ==="); debugLog(debugMode, "Previous historyId:", previousHistoryId || "none"); debugLog(debugMode, "New historyId:", result.newHistoryId); state.historyId.set(result.newHistoryId); debugLog(debugMode, "HistoryId updated successfully"); debugLog(debugMode, "=========================="); } }); // Helper function to decode base64 encoded email parts with proper UTF-8 handling function decodeBase64(data: string): string { // Replace URL-safe characters back to their original form const sanitized = data.replace(/-/g, "+").replace(/_/g, "/"); // Decode the base64 string to binary const binaryString = atob(sanitized); // Convert binary string to Uint8Array for proper UTF-8 decoding const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } // Use TextDecoder to properly decode UTF-8 return new TextDecoder("utf-8").decode(bytes); } // Helper function to extract email address from a header value function extractEmailAddress(header: string | null | undefined): string { if (!header) return ""; const emailMatch = header.match(/<([^>]*)>/); if (emailMatch && emailMatch[1]) { return emailMatch[1]; } return header; } // Helper function to extract header value from message headers function getHeader(headers: any[] | null | undefined, name: string): string { if (!headers || !Array.isArray(headers)) return ""; const header = headers.find( (h) => h?.name?.toLowerCase() === name.toLowerCase(), ); return header?.value ?? ""; } // Helper to escape special regex characters function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Resolve cid: references in HTML by fetching inline image attachments. * Returns HTML with cid: URLs replaced by base64 data URLs. * * CID (Content-ID) references are used in emails to embed images directly * in the message body as MIME attachments. Example: * Mailpiece Image * * This is common in USPS Informed Delivery emails where mail piece scans * are embedded as inline attachments rather than external URLs. * * PERFORMANCE: Uses Promise.all() for parallel attachment fetching instead * of sequential fetching, significantly reducing latency for emails with * multiple inline images (e.g., USPS Informed Delivery with 5-10 mail scans). */ async function resolveCidReferences( messageId: string, parts: any[], htmlContent: string, client: GmailClient, debugMode: boolean, ): Promise { // Build map of Content-ID -> attachmentId const cidMap = new Map(); function collectCidParts(parts: any[]) { for (const part of parts) { if (part.body?.attachmentId && part.headers) { const contentId = getHeader(part.headers, "Content-ID"); if (contentId) { // Content-ID is typically - strip angle brackets const cid = contentId.replace(/^<|>$/g, ""); cidMap.set(cid, { attachmentId: part.body.attachmentId, mimeType: part.mimeType || "image/jpeg", }); debugLog( debugMode, `[CID] Found inline attachment: cid:${cid} -> ${part.body.attachmentId}`, ); } } // Recurse into nested parts if (part.parts) { collectCidParts(part.parts); } } } collectCidParts(parts); if (cidMap.size === 0) { debugLog(debugMode, "[CID] No inline attachments found"); return htmlContent; } debugLog( debugMode, `[CID] Found ${cidMap.size} inline attachments to resolve (fetching in parallel)`, ); // Fetch all attachments in parallel using Promise.all for better performance const cidEntries = Array.from(cidMap.entries()); const fetchResults = await Promise.all( cidEntries.map(async ([cid, { attachmentId, mimeType }]) => { try { debugLog(debugMode, `[CID] Fetching attachment for cid:${cid}`); const data = await client.getAttachment(messageId, attachmentId); // Convert base64url to standard base64 const base64 = data.replace(/-/g, "+").replace(/_/g, "/"); const dataUrl = `data:${mimeType};base64,${base64}`; debugLog( debugMode, `[CID] Resolved cid:${cid} (${data.length} chars of base64 data)`, ); return { cid, dataUrl, success: true as const }; } catch (error) { debugWarn( debugMode, `[CID] Failed to fetch attachment for cid:${cid}:`, error, ); return { cid, dataUrl: null, success: false as const }; } }), ); // Apply all successful replacements to HTML let resolvedHtml = htmlContent; for (const result of fetchResults) { if (result.success && result.dataUrl) { // Replace all occurrences of this cid: reference resolvedHtml = resolvedHtml.replace( new RegExp(`cid:${escapeRegExp(result.cid)}`, "gi"), result.dataUrl, ); } } debugLog( debugMode, `[CID] Batch resolution complete: ${ fetchResults.filter((r) => r.success).length }/${cidEntries.length} successful`, ); return resolvedHtml; } async function messageToEmail( parts: any[], debugMode: boolean = false, client?: GmailClient, resolveInlineImages: boolean = false, ): Promise { const results = await Promise.all( parts.map(async (messageData, index) => { try { // DEBUG: Log raw message structure debugLog( debugMode, `\n[messageToEmail] Processing message ${index + 1}/${parts.length}`, ); debugLog(debugMode, `[messageToEmail] Message ID: ${messageData.id}`); debugLog( debugMode, `[messageToEmail] Has payload: ${!!messageData.payload}`, ); debugLog( debugMode, `[messageToEmail] Has payload.parts: ${!!messageData.payload?.parts}`, ); debugLog( debugMode, `[messageToEmail] Payload.parts length: ${ messageData.payload?.parts?.length || 0 }`, ); debugLog( debugMode, `[messageToEmail] Has payload.body: ${!!messageData.payload?.body}`, ); debugLog( debugMode, `[messageToEmail] Has payload.body.data: ${!!messageData.payload?.body ?.data}`, ); debugLog( debugMode, `[messageToEmail] Payload.mimeType: ${messageData.payload?.mimeType}`, ); if (!messageData.payload?.headers) { debugLog( debugMode, "[messageToEmail] ERROR: Missing required message data:", messageData, ); return null; } const messageHeaders = messageData.payload.headers; const subject = getHeader(messageHeaders, "Subject"); const from = getHeader(messageHeaders, "From"); const to = getHeader(messageHeaders, "To"); const date = getHeader(messageHeaders, "Date"); debugLog(debugMode, `[messageToEmail] Subject: ${subject}`); debugLog(debugMode, `[messageToEmail] From: ${from}`); let plainText = ""; let htmlContent = ""; if ( messageData.payload.parts && Array.isArray(messageData.payload.parts) ) { debugLog( debugMode, `[messageToEmail] Processing ${messageData.payload.parts.length} parts`, ); // Log structure of each part messageData.payload.parts.forEach((part: any, partIndex: number) => { debugLog(debugMode, `[messageToEmail] Part ${partIndex + 1}:`); debugLog(debugMode, ` - mimeType: ${part.mimeType}`); debugLog(debugMode, ` - Has body: ${!!part.body}`); debugLog(debugMode, ` - Has body.data: ${!!part.body?.data}`); debugLog(debugMode, ` - body.size: ${part.body?.size || 0}`); debugLog(debugMode, ` - Has nested parts: ${!!part.parts}`); debugLog( debugMode, ` - Nested parts length: ${part.parts?.length || 0}`, ); }); // Look for plainText part const textPart = messageData.payload.parts.find( (part: any) => part.mimeType === "text/plain", ); debugLog( debugMode, `[messageToEmail] Found text/plain part: ${!!textPart}`, ); if (textPart?.body?.data) { plainText = decodeBase64(textPart.body.data); debugLog( debugMode, `[messageToEmail] Decoded plainText length: ${plainText.length}`, ); } else { debugLog( debugMode, `[messageToEmail] text/plain part has no body.data`, ); } // Look for HTML part const htmlPart = messageData.payload.parts.find( (part: any) => part.mimeType === "text/html", ); debugLog( debugMode, `[messageToEmail] Found text/html part: ${!!htmlPart}`, ); if (htmlPart?.body?.data) { htmlContent = decodeBase64(htmlPart.body.data); debugLog( debugMode, `[messageToEmail] Decoded htmlContent length: ${htmlContent.length}`, ); } else { debugLog( debugMode, `[messageToEmail] text/html part has no body.data`, ); } // Handle multipart messages - check for nested parts if (htmlContent === "") { debugLog( debugMode, `[messageToEmail] No HTML found in top-level parts, checking nested parts...`, ); for (const part of messageData.payload.parts) { if (part.parts && Array.isArray(part.parts)) { debugLog( debugMode, `[messageToEmail] Found nested parts container with ${part.parts.length} nested parts`, ); const nestedHtmlPart = part.parts.find( (nestedPart: any) => nestedPart.mimeType === "text/html", ); if (nestedHtmlPart?.body?.data) { htmlContent = decodeBase64(nestedHtmlPart.body.data); debugLog( debugMode, `[messageToEmail] Found HTML in nested part, length: ${htmlContent.length}`, ); break; } } } } } else if (messageData.payload.body?.data) { debugLog(debugMode, `[messageToEmail] Single part message`); debugLog( debugMode, `[messageToEmail] body.size: ${messageData.payload.body.size}`, ); const bodyData = decodeBase64(messageData.payload.body.data); debugLog( debugMode, `[messageToEmail] Decoded body length: ${bodyData.length}`, ); if (messageData.payload.mimeType === "text/html") { htmlContent = bodyData; debugLog(debugMode, `[messageToEmail] Set as htmlContent`); } else { plainText = bodyData; debugLog(debugMode, `[messageToEmail] Set as plainText`); } } else { debugLog( debugMode, `[messageToEmail] ERROR: No payload.parts and no payload.body.data - message has NO CONTENT SOURCE!`, ); } // Resolve inline image attachments (cid: references) if enabled if (resolveInlineImages && client && htmlContent) { debugLog(debugMode, `[messageToEmail] Resolving CID references...`); const allParts = messageData.payload.parts || [messageData.payload]; htmlContent = await resolveCidReferences( messageData.id, allParts, htmlContent, client, debugMode, ); debugLog( debugMode, `[messageToEmail] CID resolution complete, htmlContent length: ${htmlContent.length}`, ); } // Generate markdown content from HTML or plainText let markdownContent = ""; debugLog(debugMode, `[messageToEmail] Converting to markdown...`); debugLog( debugMode, `[messageToEmail] - Has htmlContent: ${!!htmlContent}, length: ${htmlContent.length}`, ); debugLog( debugMode, `[messageToEmail] - Has plainText: ${!!plainText}, length: ${plainText.length}`, ); if (htmlContent) { debugLog( debugMode, `[messageToEmail] Converting HTML to markdown...`, ); try { // Convert HTML to markdown using our custom converter markdownContent = turndown.turndown(htmlContent); debugLog( debugMode, `[messageToEmail] Markdown conversion successful, length: ${markdownContent.length}`, ); } catch (error) { if (debugMode) { console.error( "[messageToEmail] Error converting HTML to markdown:", error, ); } // Fallback to plainText if HTML conversion fails markdownContent = plainText; debugLog( debugMode, `[messageToEmail] Fell back to plainText, length: ${markdownContent.length}`, ); } } else { // Use plainText as fallback if no HTML content debugLog( debugMode, `[messageToEmail] No HTML, using plainText as markdown`, ); markdownContent = plainText; debugLog( debugMode, `[messageToEmail] Final markdown length: ${markdownContent.length}`, ); } debugLog(debugMode, `[messageToEmail] === FINAL EMAIL CONTENT ===`); debugLog( debugMode, `[messageToEmail] plainText: ${plainText.length} chars`, ); debugLog( debugMode, `[messageToEmail] htmlContent: ${htmlContent.length} chars`, ); debugLog( debugMode, `[messageToEmail] markdownContent: ${markdownContent.length} chars`, ); debugLog( debugMode, `[messageToEmail] snippet: ${messageData.snippet?.length || 0} chars`, ); debugLog(debugMode, `[messageToEmail] ===========================\n`); return { id: messageData.id, threadId: messageData.threadId, labelIds: messageData.labelIds || ["INBOX"], snippet: messageData.snippet || "", subject, from: extractEmailAddress(from), date, to: extractEmailAddress(to), plainText, htmlContent, markdownContent, summary: `${subject} from ${extractEmailAddress(from)}: ${ messageData.snippet || "" }`, }; } catch (error: any) { if (debugMode) { console.error( "Error processing message part:", "message" in error ? error.message : error, ); } return null; } }), ); return results.filter((message): message is Email => message !== null); } export async function process( auth: Writable, maxResults: number = 100, gmailFilterQuery: string = "in:INBOX", state: { emails: Writable>>; historyId: Writable; resolveInlineImages?: boolean; }, debugMode: boolean = false, ): Promise< { newHistoryId?: string; newEmails?: Email[]; deletedEmailIds?: string[]; } | void > { if (!auth.get()) { debugWarn(debugMode, "no token"); return; } const client = new GmailClient(auth, { debugMode }); const currentHistoryId = state.historyId.get(); let newHistoryId: string | null = null; let messagesToFetch: string[] = []; const messagesToDelete: string[] = []; let useFullSync = false; // Get existing email IDs and create a map for efficient updates const existingEmails = state.emails.get(); const emailMap = new Map>(); for (const email of existingEmails) { emailMap.set(email.key("id").get(), email); } const existingEmailIds = new Set(emailMap.keys()); // Try incremental sync if we have a historyId if (currentHistoryId) { debugLog(debugMode, "=== INCREMENTAL SYNC MODE ==="); debugLog(debugMode, "Current historyId:", currentHistoryId); debugLog(debugMode, "Existing emails count:", existingEmails.length); try { debugLog(debugMode, "Calling Gmail History API..."); const historyResponse = await client.fetchHistory( currentHistoryId, undefined, maxResults, ); debugLog(debugMode, "History API Response:"); debugLog(debugMode, "- New historyId:", historyResponse.historyId); debugLog(debugMode, "- Has history records:", !!historyResponse.history); debugLog( debugMode, "- History records count:", historyResponse.history?.length || 0, ); if (historyResponse.history) { debugLog( debugMode, `Processing ${historyResponse.history.length} history records`, ); // Process history records for (let i = 0; i < historyResponse.history.length; i++) { const record = historyResponse.history[i]; debugLog(debugMode, `\nHistory Record ${i + 1}:`); debugLog(debugMode, "- History ID:", record.id); debugLog( debugMode, "- Messages added:", record.messagesAdded?.length || 0, ); debugLog( debugMode, "- Messages deleted:", record.messagesDeleted?.length || 0, ); debugLog( debugMode, "- Labels added:", record.labelsAdded?.length || 0, ); debugLog( debugMode, "- Labels removed:", record.labelsRemoved?.length || 0, ); // Handle added messages if (record.messagesAdded) { debugLog( debugMode, ` Processing ${record.messagesAdded.length} added messages`, ); for (const item of record.messagesAdded) { if (!existingEmailIds.has(item.message.id)) { debugLog( debugMode, ` - New message to fetch: ${item.message.id}`, ); messagesToFetch.push(item.message.id); } else { debugLog( debugMode, ` - Message already exists: ${item.message.id}`, ); } } } // Handle deleted messages if (record.messagesDeleted) { debugLog( debugMode, ` Processing ${record.messagesDeleted.length} deleted messages`, ); for (const item of record.messagesDeleted) { debugLog( debugMode, ` - Message to delete: ${item.message.id}`, ); messagesToDelete.push(item.message.id); } } // Handle label changes if (record.labelsAdded) { debugLog( debugMode, ` Processing ${record.labelsAdded.length} label additions`, ); for (const item of record.labelsAdded) { const email = emailMap.get(item.message.id); if (email) { debugLog( debugMode, ` - Adding labels to ${item.message.id}:`, item.labelIds, ); // Add new labels const labelCell = email.key("labelIds"); const newLabels = new Set(labelCell.get()); item.labelIds.forEach((label) => newLabels.add(label)); labelCell.set(Array.from(newLabels)); } } } if (record.labelsRemoved) { debugLog( debugMode, ` Processing ${record.labelsRemoved.length} label removals`, ); for (const item of record.labelsRemoved) { const email = emailMap.get(item.message.id); if (email) { debugLog( debugMode, ` - Removing labels from ${item.message.id}:`, item.labelIds, ); // Remove labels const labelCell = email.key("labelIds"); const labelSet = new Set(labelCell.get()); item.labelIds.forEach((label) => labelSet.delete(label)); labelCell.set(Array.from(labelSet)); } } } } newHistoryId = historyResponse.historyId; debugLog(debugMode, "\n=== INCREMENTAL SYNC SUMMARY ==="); debugLog(debugMode, `Messages to fetch: ${messagesToFetch.length}`); debugLog(debugMode, `Messages to delete: ${messagesToDelete.length}`); debugLog(debugMode, `Old historyId: ${currentHistoryId}`); debugLog(debugMode, `New historyId: ${newHistoryId}`); debugLog(debugMode, "================================\n"); } else { debugLog(debugMode, "No history changes found"); debugLog( debugMode, `Updating historyId from ${currentHistoryId} to ${historyResponse.historyId}`, ); newHistoryId = historyResponse.historyId; } } catch (error: any) { if ( error.message && (error.message.includes("404") || error.message.includes("410")) ) { debugLog(debugMode, "History ID expired, falling back to full sync"); useFullSync = true; } else { if (debugMode) console.error("Error fetching history:", error); throw error; } } } else { debugLog(debugMode, "=== FULL SYNC MODE ==="); debugLog(debugMode, "No historyId found, performing full sync"); useFullSync = true; } // Perform full sync if needed if (useFullSync) { debugLog(debugMode, "Getting user profile to obtain current historyId..."); // Get current profile to get latest historyId const profile = await client.getProfile(); newHistoryId = profile.historyId; debugLog(debugMode, "Profile received:"); debugLog(debugMode, "- Email:", profile.emailAddress); debugLog(debugMode, "- Current historyId:", profile.historyId); debugLog(debugMode, "- Total messages:", profile.messagesTotal); debugLog(debugMode, "- Total threads:", profile.threadsTotal); debugLog( debugMode, `\nFetching messages with query: "${gmailFilterQuery}", limit: ${maxResults}`, ); const messages = await client.fetchEmail(maxResults, gmailFilterQuery); debugLog(debugMode, `Received ${messages.length} messages from API`); messagesToFetch = messages .filter((message: { id: string }) => !existingEmailIds.has(message.id)) .map((message: { id: string }) => message.id); debugLog( debugMode, `After filtering existing: ${messagesToFetch.length} new messages to fetch`, ); debugLog(debugMode, "======================\n"); } // Collect all new emails to return const allNewEmails: Email[] = []; // Fetch new messages in batches if (messagesToFetch.length > 0) { debugLog(debugMode, `Fetching ${messagesToFetch.length} new messages`); const batchSize = 100; for (let i = 0; i < messagesToFetch.length; i += batchSize) { const batchIds = messagesToFetch.slice(i, i + batchSize); debugLog( debugMode, `Processing batch ${i / batchSize + 1} of ${ Math.ceil( messagesToFetch.length / batchSize, ) }`, ); try { await sleep(1000); const fetched = await client.fetchMessagesByIds(batchIds); const resolveInlineImages = state.resolveInlineImages || false; debugLog( debugMode, `[process] resolveInlineImages setting: ${resolveInlineImages}`, ); const emails = await messageToEmail( fetched, debugMode, client, resolveInlineImages, ); if (emails.length > 0) { debugLog(debugMode, `Adding ${emails.length} new emails`); allNewEmails.push(...emails); } } catch (error: any) { if (debugMode) { console.error( "Error processing batch:", "message" in error ? error.message : error, ); } } } } debugLog(debugMode, "Sync completed successfully"); // Return the results instead of directly updating cells return { newHistoryId: newHistoryId || undefined, newEmails: allNewEmails.length > 0 ? allNewEmails : undefined, deletedEmailIds: messagesToDelete.length > 0 ? messagesToDelete : undefined, }; } // Prefixed with _ as not currently used - preserved for potential future UI binding const _updateGmailFilterQuery = handler< { detail: { value: string } }, { gmailFilterQuery: Writable } >(({ detail }, state) => { state.gmailFilterQuery.set(detail?.value ?? "in:INBOX"); }); const toggleDebugMode = handler< { target: { checked: boolean } }, { settings: Writable } >(({ target }, { settings }) => { const current = settings.get(); settings.set({ ...current, debugMode: target.checked }); }); const toggleAutoFetch = handler< { target: { checked: boolean } }, { settings: Writable } >(({ target }, { settings }) => { const current = settings.get(); settings.set({ ...current, autoFetchOnAuth: target.checked }); }); const toggleResolveInlineImages = handler< { target: { checked: boolean } }, { settings: Writable } >(({ target }, { settings }) => { const current = settings.get(); settings.set({ ...current, resolveInlineImages: target.checked }); }); const EmailCard = pattern< { email: Email }, { [NAME]: string; summary: string; [UI]: VNode } >(({ email }) => ({ [NAME]: computed(() => email.subject), summary: str`${email.subject} from ${email.from}: ${email.snippet}`, [UI]: (
{email.subject}
From: {email.from}
{email.snippet}
), })); export default pattern< { settings: Default< Settings, { gmailFilterQuery: "in:INBOX"; limit: 10; debugMode: false; autoFetchOnAuth: false; resolveInlineImages: false; } >; // Optional: Link auth directly from a Google Auth piece when wish() is unavailable // Use: ct piece link googleAuthPiece/auth gmailImporterPiece/overrideAuth overrideAuth?: Auth; }, Output >(({ settings, overrideAuth }) => { const emails = Writable.of>([]).for("emails"); const historyId = Writable.of("").for("historyId"); const fetching = Writable.of(false).for("fetching"); // Use full auth manager with required scopes const { auth: wishedAuth, fullUI: authUI, isReady: wishedIsReady, currentEmail: wishedCurrentEmail, } = createGoogleAuth({ requiredScopes: ["gmail"] as ScopeKey[], }); // Check if overrideAuth is provided (for manual linking when wish() is unavailable) const hasOverrideAuth = computed(() => !!overrideAuth?.token); const overrideAuthEmail = computed(() => overrideAuth?.user?.email || ""); const auth = ifElse( hasOverrideAuth, overrideAuth, wishedAuth, ); const isReady = ifElse(hasOverrideAuth, hasOverrideAuth, wishedIsReady); const currentEmail = ifElse( hasOverrideAuth, overrideAuthEmail, wishedCurrentEmail, ); const summary = computed(() => { const emailList = emails.get(); return emailList .slice(0, 20) .map((e: any) => e?.summary ?? e?.subject ?? "") .filter((s: string) => s.length > 0) .join(" | "); }); const googleUpdaterStream = googleUpdater({ emails, auth, settings, historyId, fetching, }); computed(() => { if (settings.debugMode) { console.log("retrieved emails", emails.get().length); } }); // Auto-fetch when auth becomes valid (opt-in feature) // Track whether we've already triggered auto-fetch to prevent loops const hasAutoFetched = Writable.of(false).for("auto fetched"); computed(() => { const ready = isReady; const autoFetch = settings.autoFetchOnAuth; const alreadyFetched = hasAutoFetched.get(); const currentlyFetching = fetching.get(); const hasEmails = emails.get().length > 0; const hasHistoryId = !!historyId.get(); // Only auto-fetch once when: // - Auth is ready // - autoFetchOnAuth is enabled // - We haven't already auto-fetched this session // - Not currently fetching // - No emails loaded yet (first load) if ( ready && autoFetch && !alreadyFetched && !currentlyFetching && !hasEmails && !hasHistoryId ) { if (settings.debugMode) { console.log("[GmailImporter] Auto-fetching emails on auth ready"); } hasAutoFetched.set(true); // Trigger the fetch handler googleUpdaterStream.send({}); } }); return { [NAME]: str`GMail Importer ${currentEmail}`, [UI]: (
Gmail Importer
{/* Auth management UI */} {authUI}

Imported email count: {computed(() => emails.get().length)}

historyId: {historyId || "none"}
{ifElse( isReady, {ifElse( fetching, Fetching... , "Fetch Emails", )} , null, )}
{emails.map((email) => ( ))}
DATE SUBJECT LABEL CONTENT
 {email.date}   {email.subject}    {derive(email, (email) => email?.labelIds?.join(", "))}  
Show Markdown
                            {email.markdownContent}
                          
), authUI, emails, mentionable: emails.map((e) => ), emailCount: derive(emails, (list: Email[]) => list?.length || 0), summary, bgUpdater: googleUpdaterStream, isReady, // Pattern tools for omnibot searchEmails: patternTool( ({ query, emails }: { query: string; emails: Email[] }) => { return derive({ query, emails }, ({ query, emails }) => { if (!query || !emails) return []; const lowerQuery = query.toLowerCase(); return emails.filter( (email) => email.subject?.toLowerCase().includes(lowerQuery) || email.from?.toLowerCase().includes(lowerQuery) || email.snippet?.toLowerCase().includes(lowerQuery), ); }); }, { emails }, ), getEmailCount: patternTool( ({ emails }: { emails: Email[] }) => { return derive(emails, (list: Email[]) => list?.length || 0); }, { emails }, ), getRecentEmails: patternTool( ({ count, emails }: { count: number; emails: Email[] }) => { return derive({ count, emails }, ({ count, emails }) => { if (!emails || emails.length === 0) return "No emails"; const recent = emails.slice(0, count || 5); return recent .map( (email) => `From: ${email.from}\nSubject: ${email.subject}\nDate: ${ new Date( email.date, ).toLocaleDateString() }`, ) .join("\n\n"); }); }, { emails }, ), }; });