interacmanagernew / packages /server /src /services /screenshotService.ts
Heaven K
perf: faster app startup β€” lazy-load puppeteer, disable screenshots
6eb7675
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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, '&lt;').replace(/>/g, '&gt;')}</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);