farmcontrol-api/src/mailworker.js

134 lines
4.7 KiB
JavaScript

/**
* Worker thread for sending email notifications asynchronously.
* Receives payloads from the main thread and performs Puppeteer render + nodemailer send.
*/
import { parentPort } from 'worker_threads';
import puppeteer from 'puppeteer';
import nodemailer from 'nodemailer';
import log4js from 'log4js';
import config from './config.js';
const baseUrl = (urlClient) => (urlClient || 'http://localhost:3000').replace(/\/$/, '');
async function fetchAndInlineStyles(html, urlClient) {
const base = baseUrl(urlClient);
const linkMatches = [...html.matchAll(/<link[^>]+>/g)];
const stylesheetLinks = linkMatches
.map((m) => {
const tag = m[0];
if (!/rel=["']stylesheet["']/i.test(tag)) return null;
const hrefMatch = tag.match(/href=["']([^"']+)["']/);
return hrefMatch ? { tag, href: hrefMatch[1] } : null;
})
.filter(Boolean);
let inlined = html;
for (const { tag, href } of stylesheetLinks) {
const url = href.startsWith('http') ? href : `${base}${href.startsWith('/') ? '' : '/'}${href}`;
try {
const res = await fetch(url);
if (res.ok) {
const css = await res.text();
inlined = inlined.replace(tag, `<style>${css}</style>`);
}
} catch (e) {
logger.trace('Could not fetch stylesheet:', url, e.message);
}
}
return inlined;
}
const logger = log4js.getLogger('MailWorker');
logger.level = config.server.logLevel;
async function sendEmail(payload) {
const { email, title, message, type, metadata, smtpConfig, urlClient, createdAt, updatedAt, authCode } =
payload;
if (!email || !smtpConfig?.host) {
logger.warn('Missing email or SMTP config, skipping...');
return;
}
const params = new URLSearchParams({
title: title || '',
message: message || '',
type: type || 'info',
email: email || '',
createdAt: createdAt || new Date(),
updatedAt: updatedAt || new Date(),
metadata: JSON.stringify(metadata || {}),
});
if (authCode) {
params.set('authCode', authCode);
}
const templateUrl = `${baseUrl(urlClient)}/email/notification?${params.toString()}`;
logger.debug('Rendering template...');
logger.trace('Template URL:', templateUrl);
let html = '';
let browser;
try {
browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
await page.goto(templateUrl, { waitUntil: 'networkidle0', timeout: 15000 });
await page.waitForSelector('#email-notification-root[data-rendered="true"]', { timeout: 5000 });
// Wait for Ant Design CSS-in-JS to finish injecting styles
await new Promise((r) => setTimeout(r, 300));
html = await page.evaluate(() => {
const root = document.getElementById('email-notification-root');
if (!root) return document.documentElement.outerHTML;
const origin = document.location.origin;
const styleTags = Array.from(document.querySelectorAll('style'))
.map((s) => s.outerHTML)
.join('\n');
const linkTags = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
.map((link) => {
const href = link.getAttribute('href');
const abs = href?.startsWith('http')
? href
: `${origin}${href?.startsWith('/') ? '' : '/'}${href || ''}`;
return `<link rel="stylesheet" href="${abs}">`;
})
.join('\n');
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${styleTags}${linkTags}</head><body>${root.outerHTML}</body></html>`;
});
html = await fetchAndInlineStyles(html, urlClient);
} catch (err) {
logger.error('MailWorker: Puppeteer error', err.message);
html = `<div style="font-family:sans-serif;padding:20px"><h2>${title || 'Notification'}</h2><p>${message || ''}</p></div>`;
} finally {
if (browser) await browser.close();
}
const transporter = nodemailer.createTransport({
host: smtpConfig.host,
port: smtpConfig.port || 587,
secure: smtpConfig.secure || false,
auth: smtpConfig.auth?.user ? smtpConfig.auth : undefined,
});
const subject = title ? `${title} - FarmControl` : 'FarmControl Notification';
const mailOptions = {
from: smtpConfig.from || 'FarmControl <noreply@tombutcher.work>',
to: email,
subject: subject,
html,
};
logger.debug('Sending email...');
logger.trace('Mail options:', mailOptions);
const info = await transporter.sendMail(mailOptions);
logger.debug('Email sent successfully.');
}
parentPort.on('message', (payload) => {
sendEmail(payload).catch((err) => {
logger.error('MailWorker: send failed', err.message);
});
});