Spaces:
Sleeping
Sleeping
| // 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<Browser | null> { | |
| 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<EmailScreenshots> { | |
| 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<void>((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 `<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <style> | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| background: #f5f5f5; | |
| font-family: Arial, Helvetica, sans-serif; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| img { max-width: 100%; height: auto; } | |
| table { max-width: 100% !important; } | |
| a { color: #1a73e8; text-decoration: none; } | |
| </style> | |
| </head> | |
| <body> | |
| ${html} | |
| </body> | |
| </html>`; | |
| } | |
| function wrapPlainText(text: string): string { | |
| const escaped = text | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/\n/g, '<br>'); | |
| return `<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <style> | |
| body { | |
| margin: 0; | |
| padding: 32px; | |
| background: #ffffff; | |
| font-family: 'Courier New', Courier, monospace; | |
| font-size: 13px; | |
| line-height: 1.7; | |
| color: #333; | |
| max-width: 640px; | |
| } | |
| .header-line { | |
| color: #1a73e8; | |
| font-weight: 600; | |
| } | |
| </style> | |
| </head> | |
| <body>${escaped}</body> | |
| </html>`; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| // FALLBACK β When Puppeteer fails, create a | |
| // simple text-based image via canvas | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| async function createFallbackScreenshot( | |
| filePath: string, | |
| text: string, | |
| type: 'original' | 'preview' | |
| ): Promise<void> { | |
| 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 = `<!DOCTYPE html> | |
| <html><head><meta charset="UTF-8"><style> | |
| body { margin:0; padding:40px; background:#f8f9fa; font-family:Arial,sans-serif; } | |
| .box { background:#fff; border:1px solid #e0e0e0; border-radius:12px; padding:32px; max-width:560px; margin:0 auto; } | |
| .title { font-size:14px; color:#666; margin-bottom:16px; text-align:center; } | |
| .text { font-size:12px; color:#333; line-height:1.6; white-space:pre-wrap; font-family:monospace; } | |
| </style></head><body> | |
| <div class="box"> | |
| <div class="title">${label} β Capture non disponible</div> | |
| <div class="text">${text.substring(0, 500).replace(/</g, '<').replace(/>/g, '>')}</div> | |
| </div> | |
| </body></html>`; | |
| 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<void> { | |
| if (browserInstance) { | |
| await browserInstance.close().catch(() => {}); | |
| browserInstance = null; | |
| } | |
| } | |
| // Close browser on process exit | |
| process.on('SIGTERM', closeScreenshotBrowser); | |
| process.on('SIGINT', closeScreenshotBrowser); | |