mjml EMail
This commit is contained in:
@@ -12,6 +12,7 @@ const { formatDate, formatMoney } = require('../utils/helpers');
|
||||
const { getBrowser, generatePdfFromHtml, getLogoHtml, renderInvoiceItems, formatAddressLines } = require('../services/pdf-service');
|
||||
const { exportInvoiceToQbo, syncInvoiceToQbo } = require('../services/qbo-service');
|
||||
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
||||
const { sendInvoiceEmail } = require('../services/email-service');
|
||||
|
||||
function calculateNextRecurringDate(invoiceDate, interval) {
|
||||
const d = new Date(invoiceDate);
|
||||
@@ -816,5 +817,65 @@ router.get('/:id/html', async (req, res) => {
|
||||
res.status(500).json({ error: 'Error generating HTML' });
|
||||
}
|
||||
});
|
||||
router.post('/:id/send-email', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { recipientEmail, customText, melioLink } = req.body;
|
||||
|
||||
if (!recipientEmail) {
|
||||
return res.status(400).json({ error: 'Recipient email is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Rechnungsdaten und Items laden (analog zu deiner PDF-Route)
|
||||
const invoiceResult = await pool.query(`
|
||||
SELECT i.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number,
|
||||
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
|
||||
FROM invoices i
|
||||
LEFT JOIN customers c ON i.customer_id = c.id
|
||||
WHERE i.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (invoiceResult.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' });
|
||||
const invoice = invoiceResult.rows[0];
|
||||
|
||||
const itemsResult = await pool.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [id]);
|
||||
|
||||
// 2. PDF generieren, aber nur im Speicher halten
|
||||
const templatePath = path.join(__dirname, '..', '..', 'templates', 'invoice-template.html');
|
||||
let html = await fs.readFile(templatePath, 'utf-8');
|
||||
|
||||
const logoHTML = await getLogoHtml();
|
||||
const itemsHTML = renderInvoiceItems(itemsResult.rows, invoice);
|
||||
const authHTML = invoice.auth_code ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
|
||||
const streetBlock = formatAddressLines(invoice.line1, invoice.line2, invoice.line3, invoice.line4, invoice.customer_name);
|
||||
|
||||
html = html
|
||||
.replace('{{LOGO_HTML}}', logoHTML)
|
||||
.replace('{{CUSTOMER_NAME}}', invoice.bill_to_name || invoice.customer_name || '')
|
||||
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
||||
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
|
||||
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
|
||||
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
|
||||
.replace('{{INVOICE_NUMBER}}', invoice.invoice_number || '')
|
||||
.replace('{{ACCOUNT_NUMBER}}', invoice.account_number || '')
|
||||
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
|
||||
.replace('{{TERMS}}', invoice.terms)
|
||||
.replace('{{AUTHORIZATION}}', authHTML)
|
||||
.replace('{{ITEMS}}', itemsHTML);
|
||||
|
||||
const pdfBuffer = await generatePdfFromHtml(html);
|
||||
|
||||
// 3. E-Mail über SES versenden
|
||||
const info = await sendInvoiceEmail(invoice, recipientEmail, customText, melioLink, pdfBuffer);
|
||||
|
||||
// 4. (Optional) Status in der DB aktualisieren
|
||||
//await pool.query('UPDATE invoices SET email_status = $1 WHERE id = $2', ['sent', id]);
|
||||
|
||||
res.json({ success: true, messageId: info.messageId });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending invoice email:', error);
|
||||
res.status(500).json({ error: 'Failed to send email: ' + error.message });
|
||||
}
|
||||
});
|
||||
module.exports = router;
|
||||
|
||||
112
src/services/email-service.js
Normal file
112
src/services/email-service.js
Normal file
@@ -0,0 +1,112 @@
|
||||
// src/services/email-service.js
|
||||
const { SESv2Client, SendEmailCommand } = require('@aws-sdk/client-sesv2');
|
||||
const nodemailer = require('nodemailer');
|
||||
const mjml2html = require('mjml');
|
||||
|
||||
const sesClient = new SESv2Client({
|
||||
region: process.env.AWS_REGION || 'us-east-2'
|
||||
});
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
SES: {
|
||||
sesClient,
|
||||
SendEmailCommand
|
||||
}
|
||||
});
|
||||
|
||||
function generateInvoiceEmailHtml(invoice, customText, melioLink) {
|
||||
const formattedText = customText || '';
|
||||
|
||||
const buttonMjml = melioLink
|
||||
? `<mj-button background-color="#2563eb" color="white" border-radius="6px" href="${melioLink}" font-weight="600" font-size="16px" padding-top="25px">
|
||||
Pay Now (Free ACH)
|
||||
</mj-button>`
|
||||
: '';
|
||||
|
||||
const template = `
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" />
|
||||
</mj-attributes>
|
||||
<mj-style inline="inline">
|
||||
.email-body p {
|
||||
margin: 0 0 14px 0 !important;
|
||||
}
|
||||
.email-body p:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
</mj-style>
|
||||
</mj-head>
|
||||
<mj-body background-color="#f4f4f5">
|
||||
|
||||
<mj-section padding="0">
|
||||
<mj-column>
|
||||
<mj-spacer height="20px" />
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section background-color="#ffffff" padding="30px" border-radius="8px 8px 0 0">
|
||||
<mj-column>
|
||||
<mj-text font-size="22px" font-weight="700" color="#1e3a8a" padding="0">
|
||||
Bay Area Affiliates, Inc.
|
||||
</mj-text>
|
||||
<mj-text font-size="15px" color="#64748b" padding="5px 0 0 0">
|
||||
Invoice #${invoice.invoice_number || invoice.id}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section background-color="#ffffff" padding="0 30px 30px 30px">
|
||||
<mj-column>
|
||||
<mj-text css-class="email-body" font-size="15px" color="#334155" line-height="1.5" padding="0">
|
||||
${formattedText}
|
||||
</mj-text>
|
||||
|
||||
${buttonMjml}
|
||||
|
||||
<mj-divider border-color="#e2e8f0" border-width="1px" padding-top="30px" padding-bottom="20px" />
|
||||
|
||||
<mj-text font-size="14px" color="#64748b" line-height="1.5" padding="0">
|
||||
<strong>Prefer to pay by check?</strong><br/>
|
||||
Please make checks payable to Bay Area Affiliates, Inc. and mail to:<br/>
|
||||
1001 Blucher Street<br/>
|
||||
Corpus Christi, Texas 78401
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
`;
|
||||
|
||||
// validationLevel: 'strict' fängt falsche Attribute ab, bevor sie an den Kunden gehen
|
||||
const result = mjml2html(template, { validationLevel: 'strict' });
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
console.error('MJML Parse Errors:', result.errors);
|
||||
}
|
||||
|
||||
return result.html;
|
||||
}
|
||||
|
||||
async function sendInvoiceEmail(invoice, recipientEmail, customText, melioLink, pdfBuffer) {
|
||||
const htmlContent = generateInvoiceEmailHtml(invoice, customText, melioLink);
|
||||
|
||||
const mailOptions = {
|
||||
from: '"Bay Area Affiliates Inc. Accounting" <accounting@bayarea-cc.com>',
|
||||
to: recipientEmail,
|
||||
subject: `Invoice #${invoice.invoice_number || invoice.id} from Bay Area Affiliates, Inc.`,
|
||||
html: htmlContent,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Invoice_${invoice.invoice_number || invoice.id}_BayAreaAffiliates.pdf`,
|
||||
content: pdfBuffer,
|
||||
contentType: 'application/pdf'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return await transporter.sendMail(mailOptions);
|
||||
}
|
||||
|
||||
module.exports = { sendInvoiceEmail };
|
||||
@@ -33,8 +33,9 @@ async function generatePdfFromHtml(html, options = {}) {
|
||||
}
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||
|
||||
//await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||
await page.setContent(html, { waitUntil: 'load', timeout: 5000 });
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format,
|
||||
printBackground,
|
||||
|
||||
Reference in New Issue
Block a user