// packages/server/src/services/screenshotService.ts // // Takes REAL screenshots of Interac emails using Puppeteer (headless Chrome). // During the scan pipeline, after fetching email HTML from Gmail: // 1. Renders the full email HTML in a headless browser // 2. Takes a full-page screenshot → "Courriel original" // 3. Takes a screenshot of just the Interac visual body → "Aperçu du virement" // 4. Saves both as PNG files on disk // 5. Returns the file paths for storage in the database import path from 'path'; import fs from 'fs'; // Lazy-load puppeteer — it may not be installed in production let puppeteer: any = null; type Browser = any; async function loadPuppeteer() { if (!puppeteer) { try { puppeteer = (await import('puppeteer')).default; } catch { return null; } } return puppeteer; } // ═══════════════════════════════════════════ // CONFIGURATION // ═══════════════════════════════════════════ const SCREENSHOTS_DIR = process.env.SCREENSHOTS_DIR || path.join(process.cwd(), 'data', 'screenshots'); // Viewport dimensions to render the email at const VIEWPORT = { width: 680, height: 900, deviceScaleFactor: 2 }; // 2x for retina quality // Reuse a single browser instance for performance let browserInstance: Browser | null = null; async function getBrowser(): Promise { if (!browserInstance || !browserInstance.connected) { const pptr = await loadPuppeteer(); if (!pptr) return null; browserInstance = await pptr.launch({ headless: true, args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--disable-web-security', // Allow loading inline images '--font-render-hinting=none', // Consistent font rendering ], }); } return browserInstance; } // ═══════════════════════════════════════════ // MAIN FUNCTION — Take screenshots of an email // ═══════════════════════════════════════════ export interface EmailScreenshots { originalPath: string; // Full email screenshot → "Courriel original" previewPath: string; // Interac visual body → "Aperçu du virement" } /** * Renders the email HTML in a headless browser and takes two screenshots: * - original: The full email as it appears (complete page) * - preview: Just the Interac body/visual section (cropped) * * @param emailHtml - The raw HTML content of the email (from Gmail API) * @param emailText - The plain text version of the email * @param emailId - Unique email ID (used for filename) * @returns Paths to both screenshot files */ export async function captureEmailScreenshots( emailHtml: string, emailText: string, emailId: string ): Promise { const safeId = emailId.replace(/[^a-zA-Z0-9_-]/g, '_'); const originalPath = path.join(SCREENSHOTS_DIR, `${safeId}_original.png`); const previewPath = path.join(SCREENSHOTS_DIR, `${safeId}_preview.png`); // If puppeteer is not available, return empty paths const pptr = await loadPuppeteer(); if (!pptr) { return { originalPath: '', previewPath: '' }; } // Ensure screenshots directory exists if (!fs.existsSync(SCREENSHOTS_DIR)) { fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); } const browser = await getBrowser(); if (!browser) return { originalPath: '', previewPath: '' }; let page = null; try { page = await browser.newPage(); await page.setViewport(VIEWPORT); // ───────────────────────────────────── // SCREENSHOT 1: "Courriel original" // Full email as-is from Gmail // ───────────────────────────────────── if (emailHtml && emailHtml.trim().length > 50) { // Render the actual HTML email const wrappedHtml = wrapEmailHtml(emailHtml); await page.setContent(wrappedHtml, { waitUntil: 'domcontentloaded', timeout: 5000 }); } else { // Fallback: render plain text in a nice container const textHtml = wrapPlainText(emailText); await page.setContent(textHtml, { waitUntil: 'domcontentloaded', timeout: 10000 }); } // Brief wait for images (max 1.5s) await page.evaluate(() => { return new Promise((resolve) => { const images = document.querySelectorAll('img'); if (images.length === 0) return resolve(); let loaded = 0; const total = images.length; const done = () => { loaded++; if (loaded >= total) resolve(); }; images.forEach((img) => { if (img.complete) done(); else { img.onload = done; img.onerror = done; } }); setTimeout(resolve, 1500); }); }); // Take full-page screenshot await page.screenshot({ path: originalPath, fullPage: true, type: 'png', }); console.log(`[Screenshot] Original saved: ${originalPath}`); // ───────────────────────────────────── // SCREENSHOT 2: "Aperçu du virement" // Just the Interac visual body section // ───────────────────────────────────── if (emailHtml && emailHtml.trim().length > 50) { // Try to find the main Interac visual container const bodyBounds = await page.evaluate(() => { const selectors = [ 'table[align="center"]', 'table[width="600"]', 'table[width="580"]', 'div[style*="max-width"]', 'center > table', 'body > table', 'body > div > table', '.email-body', '#email-content', ]; for (const sel of selectors) { const el = document.querySelector(sel); if (el) { const rect = el.getBoundingClientRect(); if (rect.height > 100) { return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; } } } // Fallback: screenshot the entire body const body = document.body; const rect = body.getBoundingClientRect(); return { x: 0, y: 0, width: rect.width, height: rect.height }; }); // Take a cropped screenshot of just the Interac body await page.screenshot({ path: previewPath, type: 'png', clip: { x: Math.max(0, bodyBounds.x - 10), y: Math.max(0, bodyBounds.y), width: Math.min(bodyBounds.width + 20, VIEWPORT.width), height: bodyBounds.height, }, }); } else { // No HTML — use the same screenshot for preview fs.copyFileSync(originalPath, previewPath); } console.log(`[Screenshot] Preview saved: ${previewPath}`); } catch (error: any) { console.error(`[Screenshot] Error capturing ${emailId}:`, error.message); // Create fallback screenshots if capture fails if (!fs.existsSync(originalPath)) { await createFallbackScreenshot(originalPath, emailText, 'original'); } if (!fs.existsSync(previewPath)) { await createFallbackScreenshot(previewPath, emailText, 'preview'); } } finally { if (page) { await page.close().catch(() => {}); } } return { originalPath, previewPath }; } // ═══════════════════════════════════════════ // HTML WRAPPERS // ═══════════════════════════════════════════ function wrapEmailHtml(html: string): string { return ` ${html} `; } function wrapPlainText(text: string): string { const escaped = text .replace(/&/g, '&') .replace(//g, '>') .replace(/\n/g, '
'); return ` ${escaped} `; } // ═══════════════════════════════════════════ // FALLBACK — When Puppeteer fails, create a // simple text-based image via canvas // ═══════════════════════════════════════════ async function createFallbackScreenshot( filePath: string, text: string, type: 'original' | 'preview' ): Promise { const browser = await getBrowser(); let page = null; try { page = await browser.newPage(); await page.setViewport({ width: 640, height: 400, deviceScaleFactor: 2 }); const label = type === 'original' ? 'Courriel Original' : 'Aperçu du Virement'; const html = `
${label} — Capture non disponible
${text.substring(0, 500).replace(//g, '>')}
`; await page.setContent(html, { waitUntil: 'domcontentloaded' }); await page.screenshot({ path: filePath, fullPage: true, type: 'png' }); } catch { // Last resort: write a 1x1 transparent PNG const emptyPng = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64' ); fs.writeFileSync(filePath, emptyPng); } finally { if (page) await page.close().catch(() => {}); } } // ═══════════════════════════════════════════ // CLEANUP — Close browser on shutdown // ═══════════════════════════════════════════ export async function closeScreenshotBrowser(): Promise { if (browserInstance) { await browserInstance.close().catch(() => {}); browserInstance = null; } } // Close browser on process exit process.on('SIGTERM', closeScreenshotBrowser); process.on('SIGINT', closeScreenshotBrowser);