CentralBackend/services/email/MailService.js
2026-03-15 18:33:52 +01:00

764 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { BrevoClient } = require('@getbrevo/brevo');
const fs = require('fs');
const path = require('path');
const { logger } = require('../../middleware/logger');
class MailService {
constructor() {
const rawApiKey = process.env.BREVO_API_KEY;
const apiKey = (rawApiKey || '').trim(); // helps catch whitespace/newline issues
this.brevo = new BrevoClient({ apiKey });
logger.info('MailService:brevo_api_key_loaded', {
present: Boolean(apiKey),
length: apiKey.length,
last4: apiKey.length >= 4 ? apiKey.slice(-4) : null
});
this.sender = {
email: process.env.BREVO_SENDER_EMAIL,
name: process.env.BREVO_SENDER_NAME,
};
this.loginUrl = process.env.LOGIN_URL;
this.templatesDir = path.join(__dirname, '..', '..', 'mailTemplates');
}
// Helper to load and render a template
renderTemplate(templateName, variables, lang = 'en') {
logger.info('MailService.renderTemplate:start', { templateName, lang });
// Supported languages
const supportedLangs = ['en', 'de'];
// Fallback to 'en' if unsupported or missing
const chosenLang = supportedLangs.includes(lang) ? lang : 'en';
const templatePath = path.join(this.templatesDir, chosenLang, templateName);
let template;
try {
template = fs.readFileSync(templatePath, 'utf8');
logger.info('MailService.renderTemplate:template_loaded', { templatePath, lang: chosenLang });
} catch (err) {
// Fallback to English if template not found
if (chosenLang !== 'en') {
const fallbackPath = path.join(this.templatesDir, 'en', templateName);
template = fs.readFileSync(fallbackPath, 'utf8');
logger.warn('MailService.renderTemplate:fallback_to_en', { fallbackPath, lang });
} else {
logger.error('MailService.renderTemplate:error', { templatePath, error: err.message });
throw err;
}
}
template = template.replace(/{{(\w+)}}/g, (_, key) => variables[key] || '');
logger.info('MailService.renderTemplate:success', { templateName, lang: chosenLang });
return template;
}
_extractBrevoErrorDetails(error) {
const status = error?.statusCode ?? error?.response?.status;
const data = error?.body ?? error?.response?.data;
let dataSafe;
try {
dataSafe = typeof data === 'string' ? data.slice(0, 2000) : JSON.parse(JSON.stringify(data));
} catch (_) {
dataSafe = '[unserializable_response_data]';
}
return { status, data: dataSafe };
}
async sendRegistrationEmail({ email, firstName, lastName, userType, companyName, lang }) {
logger.info('MailService.sendRegistrationEmail:start', { email, userType, lang });
let subject, text;
const chosenLang = lang || 'en';
if (userType === 'personal') {
subject = chosenLang === 'de'
? `Willkommen bei ProfitPlanet, ${firstName}!`
: `Welcome to ProfitPlanet, ${firstName}!`;
text = this.renderTemplate('registrationPersonal.txt', {
firstName,
lastName,
loginUrl: this.loginUrl
}, chosenLang);
} else if (userType === 'company') {
subject = chosenLang === 'de'
? `Willkommen bei ProfitPlanet, ${companyName}!`
: `Welcome to ProfitPlanet, ${companyName}!`;
text = this.renderTemplate('registrationCompany.txt', {
companyName,
loginUrl: this.loginUrl
}, chosenLang);
} else {
subject = chosenLang === 'de'
? 'Willkommen bei ProfitPlanet!'
: 'Welcome to ProfitPlanet!';
text = chosenLang === 'de'
? `Danke für Ihre Registrierung bei ProfitPlanet!\nSie können sich jetzt hier anmelden: ${this.loginUrl}\n\nMit freundlichen Grüßen,\nProfitPlanet Team`
: `Thank you for registering at ProfitPlanet!\nYou can now log in here: ${this.loginUrl}\n\nBest regards,\nProfitPlanet Team`;
}
try {
const payload = {
sender: this.sender,
to: [{ email }],
subject,
textContent: text
};
const data = await this.brevo.transactionalEmails.sendTransacEmail(payload);
logger.info('MailService.sendRegistrationEmail:email_sent', { email, userType, lang: chosenLang });
return data;
} catch (error) {
const brevoError = this._extractBrevoErrorDetails(error);
logger.error('MailService.sendRegistrationEmail:error', {
email,
userType,
lang: chosenLang,
message: error?.message,
brevoStatus: brevoError.status,
brevoData: brevoError.data
});
throw error;
}
}
async sendVerificationCodeEmail({ email, code, expiresAt, lang }) {
logger.info('MailService.sendVerificationCodeEmail:start', { email, expiresAt, lang }); // don't log code
const chosenLang = lang || 'en';
const subject = chosenLang === 'de'
? 'Ihr ProfitPlanet E-Mail-Verifizierungscode'
: 'Your ProfitPlanet Email Verification Code';
const text = this.renderTemplate('verificationCode.txt', {
code,
expiresAt: expiresAt.toLocaleTimeString()
}, chosenLang);
try {
const payload = {
sender: this.sender,
to: [{ email }],
subject,
textContent: text
};
const data = await this.brevo.transactionalEmails.sendTransacEmail(payload);
logger.info('MailService.sendVerificationCodeEmail:email_sent', { email, lang: chosenLang }); // don't log code
return data;
} catch (error) {
const brevoError = this._extractBrevoErrorDetails(error);
logger.error('MailService.sendVerificationCodeEmail:error', {
email,
lang: chosenLang,
message: error?.message,
brevoStatus: brevoError.status,
brevoData: brevoError.data
});
throw error;
}
}
async sendLoginNotificationEmail({ email, ip, loginTime, userAgent, lang }) {
logger.info('MailService.sendLoginNotificationEmail:start', { email, ip, loginTime, userAgent, lang });
const chosenLang = lang || 'en';
const subject = chosenLang === 'de'
? 'ProfitPlanet: Neue Login-Benachrichtigung'
: 'ProfitPlanet: New Login Notification';
const text = this.renderTemplate('loginNotification.txt', {
email,
ip,
loginTime: loginTime.toLocaleString(),
userAgent
}, chosenLang);
try {
const payload = {
sender: this.sender,
to: [{ email }],
subject,
textContent: text
};
const data = await this.brevo.transactionalEmails.sendTransacEmail(payload);
logger.info('MailService.sendLoginNotificationEmail:email_sent', { email, ip, lang: chosenLang });
return data;
} catch (error) {
const brevoError = this._extractBrevoErrorDetails(error);
logger.error('MailService.sendLoginNotificationEmail:error', {
email,
ip,
lang: chosenLang,
message: error?.message,
brevoStatus: brevoError.status,
brevoData: brevoError.data
});
throw error;
}
}
async sendPasswordResetEmail({ email, firstName, companyName, token, lang }) {
logger.info('MailService.sendPasswordResetEmail:start', { email, token, lang });
const chosenLang = lang || 'en';
const subject = chosenLang === 'de'
? 'ProfitPlanet: Passwort zurücksetzen'
: 'ProfitPlanet: Password Reset';
const resetUrl = `${process.env.PASSWORD_RESET_URL || 'https://profit-planet.partners/password-reset-set'}?token=${token}`;
const text = this.renderTemplate('passwordReset.txt', {
firstName: firstName || '',
companyName: companyName || '',
resetUrl
}, chosenLang);
try {
const payload = {
sender: this.sender,
to: [{ email }],
subject,
textContent: text
};
const data = await this.brevo.transactionalEmails.sendTransacEmail(payload);
logger.info('MailService.sendPasswordResetEmail:email_sent', { email, token, lang: chosenLang });
return data;
} catch (error) {
const brevoError = this._extractBrevoErrorDetails(error);
logger.error('MailService.sendPasswordResetEmail:error', {
email,
token,
lang: chosenLang,
message: error?.message,
brevoStatus: brevoError.status,
brevoData: brevoError.data
});
throw error;
}
}
async sendInvoiceEmail({ email, subject, text, html, lang, attachments = [] }) {
logger.info('MailService.sendInvoiceEmail:start', { email, lang, hasHtml: Boolean(html), attachments: attachments.length });
try {
const payload = {
sender: this.sender,
to: [{ email }],
subject,
textContent: text
};
if (html) {
payload.htmlContent = html;
}
if (Array.isArray(attachments) && attachments.length) {
payload.attachment = attachments;
}
const data = await this.brevo.transactionalEmails.sendTransacEmail(payload);
logger.info('MailService.sendInvoiceEmail:email_sent', { email, lang, hasHtml: Boolean(html), attachments: attachments.length });
return data;
} catch (error) {
const brevoError = this._extractBrevoErrorDetails(error);
logger.error('MailService.sendInvoiceEmail:error', {
email,
lang,
message: error?.message,
brevoStatus: brevoError.status,
brevoData: brevoError.data
});
throw error;
}
}
async sendAboContractEmail({ email, subject, text, html, lang, attachments = [] }) {
logger.info('MailService.sendAboContractEmail:start', { email, lang, hasHtml: Boolean(html), attachments: attachments.length });
try {
const payload = {
sender: this.sender,
to: [{ email }],
subject,
textContent: text
};
if (html) {
payload.htmlContent = html;
}
if (Array.isArray(attachments) && attachments.length) {
payload.attachment = attachments;
}
const data = await this.brevo.transactionalEmails.sendTransacEmail(payload);
logger.info('MailService.sendAboContractEmail:email_sent', { email, lang, hasHtml: Boolean(html), attachments: attachments.length });
return data;
} catch (error) {
const brevoError = this._extractBrevoErrorDetails(error);
logger.error('MailService.sendAboContractEmail:error', {
email,
lang,
message: error?.message,
brevoStatus: brevoError.status,
brevoData: brevoError.data
});
throw error;
}
}
async sendSubscriptionInvitationEmail({ email, inviterName, referralLink, lang = 'en' }) {
logger.info('MailService.sendSubscriptionInvitationEmail:start', { email, lang });
const isDe = lang === 'de';
const subject = isDe
? 'ProfitPlanet: Einladung zum Kaffee-Abonnement'
: 'ProfitPlanet: Invite for Coffee Abonnement';
const text = isDe
? `Hallo,\n\n${inviterName || 'Ein Benutzer'} hat ein Kaffee-Abonnement für Sie erstellt.\nBitte registrieren Sie sich als Gastkunde hier: ${referralLink}\n\nSobald Sie sich registriert haben, können Sie Ihr Abonnement einsehen und verwalten.\n\nViele Grüße\nProfitPlanet Team`
: `Hi,\n\n${inviterName || 'A user'} created a coffee abonnement for you.\nPlease register as a guest customer here: ${referralLink}\n\nOnce registered, you can view and manage your subscription.\n\nBest regards\nProfitPlanet Team`;
const html = `<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:24px;background:#f5f7fb;font-family:Arial,sans-serif;color:#1f2937;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="center">
<table role="presentation" width="620" cellspacing="0" cellpadding="0" style="max-width:620px;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;">
<tr>
<td style="padding:20px 24px;background:#1C2B4A;color:#ffffff;">
<h2 style="margin:0;font-size:22px;">${isDe ? 'Einladung zum Kaffee-Abonnement' : 'Invite for Coffee Abonnement'}</h2>
</td>
</tr>
<tr>
<td style="padding:22px 24px;">
<p style="margin:0 0 12px 0;line-height:1.6;">${isDe
? `${this._escapeForHtml(inviterName || 'Ein Benutzer')} hat ein Kaffee-Abonnement für Sie erstellt.`
: `${this._escapeForHtml(inviterName || 'A user')} created a coffee abonnement for you.`}</p>
<p style="margin:0 0 12px 0;line-height:1.6;">${isDe
? 'Als Gastkunde können Sie Ihr Abonnement einsehen und verwalten.'
: 'As a guest customer, you can view and manage your subscription.'}</p>
<p style="margin:0 0 18px 0;line-height:1.6;">${isDe ? 'Bitte registrieren Sie sich über den folgenden Link:' : 'Please register using the link below:'}</p>
<p style="margin:0;">
<a href="${this._escapeForHtml(referralLink)}" style="display:inline-block;background:#1C2B4A;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:700;font-size:15px;">${isDe ? 'Jetzt als Gastkunde registrieren' : 'Register as Guest Customer'}</a>
</p>
</td>
</tr>
<tr>
<td style="padding:16px 24px;background:#f9fafb;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:12px;color:#6b7280;line-height:1.5;">${isDe
? 'Sie erhalten als Gastkunde Zugang zu Ihrem Abonnement. Weitere Funktionen der Plattform stehen Ihnen nach einem Upgrade zur Verfügung.'
: 'As a guest customer you will have access to your subscription. Other platform features are available after upgrading your account.'}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
try {
const payload = {
sender: this.sender,
to: [{ email }],
subject,
textContent: text,
htmlContent: html,
};
const data = await this.brevo.transactionalEmails.sendTransacEmail(payload);
logger.info('MailService.sendSubscriptionInvitationEmail:email_sent', { email, lang });
return data;
} catch (error) {
const brevoError = this._extractBrevoErrorDetails(error);
logger.error('MailService.sendSubscriptionInvitationEmail:error', {
email,
lang,
message: error?.message,
brevoStatus: brevoError.status,
brevoData: brevoError.data
});
throw error;
}
}
async sendRenewalEmail({ email, customerName, invoiceNumber, totalGross, nextBillingDate, lang = 'en' }) {
logger.info('MailService.sendRenewalEmail:start', { email, lang, invoiceNumber });
const isDe = lang === 'de';
const subject = isDe
? `ProfitPlanet: Ihr Abonnement wurde verlängert Rechnung ${invoiceNumber}`
: `ProfitPlanet: Your subscription has been renewed Invoice ${invoiceNumber}`;
const safeName = this._escapeForHtml(customerName || '');
const safeInvoiceNumber = this._escapeForHtml(invoiceNumber || '');
const safeTotalGross = this._escapeForHtml(totalGross || '-');
const safeNextDate = this._escapeForHtml(nextBillingDate || '-');
const text = isDe
? [
`Hallo ${customerName || ''},`,
'',
'Ihr ProfitPlanet Kaffee-Abonnement wurde automatisch verlängert.',
'',
`Rechnungsnummer: ${invoiceNumber}`,
`Gesamtbetrag: ${totalGross}`,
`Nächste Verlängerung: ${nextBillingDate}`,
'',
'Ihre Rechnung ist als PDF im Anhang der vorherigen E-Mail enthalten.',
'Sie können Ihre Rechnungen auch jederzeit in Ihrem Dashboard einsehen.',
'',
'Falls Sie Ihr Abonnement pausieren oder kündigen möchten, können Sie dies in Ihrem Profil tun.',
'',
'Viele Grüße',
'Ihr ProfitPlanet Team',
].join('\n')
: [
`Hi ${customerName || ''},`,
'',
'Your ProfitPlanet coffee subscription has been automatically renewed.',
'',
`Invoice number: ${invoiceNumber}`,
`Total amount: ${totalGross}`,
`Next renewal: ${nextBillingDate}`,
'',
'Your invoice is attached as a PDF in the previous email.',
'You can also view all your invoices in your dashboard at any time.',
'',
'If you would like to pause or cancel your subscription, you can do so in your profile.',
'',
'Best regards,',
'Your ProfitPlanet Team',
].join('\n');
const logoUrl = process.env.MAIL_LOGO_URL || process.env.BREVO_LOGO_URL || process.env.APP_LOGO_URL || '';
const html = `<!doctype html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f5f7fb;font-family:Arial,sans-serif;color:#1f2937;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f5f7fb;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 8px 24px rgba(0,0,0,0.08);">
<tr>
<td style="padding:24px 28px;background:#111827;color:#ffffff;">
${logoUrl ? `<img src="${this._escapeForHtml(logoUrl)}" alt="ProfitPlanet" style="max-height:44px;display:block;margin-bottom:12px;">` : ''}
<h1 style="margin:0;font-size:22px;line-height:1.3;">${isDe ? 'Ihr Abonnement wurde verlängert' : 'Your subscription has been renewed'}</h1>
</td>
</tr>
<tr>
<td style="padding:24px 28px;">
<p style="margin:0 0 12px 0;font-size:15px;line-height:1.6;">${isDe ? 'Hallo' : 'Hi'} ${safeName},</p>
<p style="margin:0 0 18px 0;font-size:15px;line-height:1.6;">${isDe
? 'Ihr Kaffee-Abonnement wurde automatisch verlängert. Nachfolgend finden Sie die Details:'
: 'Your coffee subscription has been automatically renewed. Here are the details:'}</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;margin-bottom:18px;">
<tr>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;color:#6b7280;">${isDe ? 'Rechnungsnummer' : 'Invoice number'}</td>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;text-align:right;font-weight:700;">${safeInvoiceNumber}</td>
</tr>
<tr>
<td style="padding:12px 14px;font-size:13px;color:#6b7280;">${isDe ? 'Gesamtbetrag' : 'Total amount'}</td>
<td style="padding:12px 14px;font-size:13px;text-align:right;font-weight:700;">${safeTotalGross}</td>
</tr>
<tr>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;color:#6b7280;">${isDe ? 'Nächste Verlängerung' : 'Next renewal'}</td>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;text-align:right;font-weight:700;">${safeNextDate}</td>
</tr>
</table>
<p style="margin:0 0 18px 0;font-size:15px;line-height:1.6;">${isDe
? 'Ihre Rechnung mit PDF wurde Ihnen separat zugesendet. Sie können Ihre Rechnungen auch in Ihrem Dashboard einsehen.'
: 'Your invoice with PDF has been sent to you separately. You can also view your invoices in your dashboard.'}</p>
<p style="margin:0 0 8px 0;font-size:13px;color:#6b7280;line-height:1.5;">${isDe
? 'Falls Sie Ihr Abonnement pausieren oder kündigen möchten, können Sie dies jederzeit in Ihrem Profil tun.'
: 'If you would like to pause or cancel your subscription, you can do so anytime in your profile.'}</p>
</td>
</tr>
<tr>
<td style="padding:16px 28px;background:#f9fafb;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:12px;color:#6b7280;">${isDe ? 'Viele Grüße Ihr ProfitPlanet Team' : 'Best regards Your ProfitPlanet Team'}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
try {
const payload = {
sender: this.sender,
to: [{ email }],
subject,
textContent: text,
htmlContent: html,
};
const data = await this.brevo.transactionalEmails.sendTransacEmail(payload);
logger.info('MailService.sendRenewalEmail:email_sent', { email, lang, invoiceNumber });
return data;
} catch (error) {
const brevoError = this._extractBrevoErrorDetails(error);
logger.error('MailService.sendRenewalEmail:error', {
email,
lang,
invoiceNumber,
message: error?.message,
brevoStatus: brevoError.status,
brevoData: brevoError.data,
});
throw error;
}
}
async sendPaymentReminderEmail({ email, customerName, invoiceNumber, totalGross, daysOverdue, lang = 'en' }) {
logger.info('MailService.sendPaymentReminderEmail:start', { email, lang, invoiceNumber, daysOverdue });
const isDe = lang === 'de';
const subject = isDe
? `ProfitPlanet: Zahlungserinnerung Rechnung ${invoiceNumber}`
: `ProfitPlanet: Payment reminder Invoice ${invoiceNumber}`;
const safeName = this._escapeForHtml(customerName || '');
const safeInvoice = this._escapeForHtml(invoiceNumber || '');
const safeTotal = this._escapeForHtml(totalGross || '-');
const safeDays = this._escapeForHtml(String(daysOverdue || 0));
const text = isDe
? [
`Hallo ${customerName || ''},`,
'',
`wir möchten Sie daran erinnern, dass Ihre Rechnung ${invoiceNumber} noch offen ist.`,
'',
`Rechnungsnummer: ${invoiceNumber}`,
`Offener Betrag: ${totalGross}`,
`Überfällig seit: ${daysOverdue} Tagen`,
'',
'Bitte begleichen Sie den offenen Betrag, damit Ihr Abonnement weiterhin aktiv bleibt.',
'Falls die Zahlung bereits erfolgt ist, können Sie diese Erinnerung ignorieren.',
'',
'Viele Grüße',
'Ihr ProfitPlanet Team',
].join('\n')
: [
`Hi ${customerName || ''},`,
'',
`This is a reminder that your invoice ${invoiceNumber} is still unpaid.`,
'',
`Invoice number: ${invoiceNumber}`,
`Outstanding amount: ${totalGross}`,
`Overdue by: ${daysOverdue} days`,
'',
'Please settle the outstanding amount to keep your subscription active.',
'If payment has already been made, you can disregard this reminder.',
'',
'Best regards,',
'Your ProfitPlanet Team',
].join('\n');
const logoUrl = process.env.MAIL_LOGO_URL || process.env.BREVO_LOGO_URL || process.env.APP_LOGO_URL || '';
const html = `<!doctype html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f5f7fb;font-family:Arial,sans-serif;color:#1f2937;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f5f7fb;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 8px 24px rgba(0,0,0,0.08);">
<tr>
<td style="padding:24px 28px;background:#92400e;color:#ffffff;">
${logoUrl ? `<img src="${this._escapeForHtml(logoUrl)}" alt="ProfitPlanet" style="max-height:44px;display:block;margin-bottom:12px;">` : ''}
<h1 style="margin:0;font-size:22px;line-height:1.3;">${isDe ? 'Zahlungserinnerung' : 'Payment Reminder'}</h1>
</td>
</tr>
<tr>
<td style="padding:24px 28px;">
<p style="margin:0 0 12px 0;font-size:15px;line-height:1.6;">${isDe ? 'Hallo' : 'Hi'} ${safeName},</p>
<p style="margin:0 0 18px 0;font-size:15px;line-height:1.6;">${isDe
? 'wir möchten Sie daran erinnern, dass folgende Rechnung noch offen ist:'
: 'This is a friendly reminder that the following invoice is still unpaid:'}</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;margin-bottom:18px;">
<tr>
<td style="padding:12px 14px;background:#fef3c7;font-size:13px;color:#92400e;">${isDe ? 'Rechnungsnummer' : 'Invoice number'}</td>
<td style="padding:12px 14px;background:#fef3c7;font-size:13px;text-align:right;font-weight:700;color:#92400e;">${safeInvoice}</td>
</tr>
<tr>
<td style="padding:12px 14px;font-size:13px;color:#6b7280;">${isDe ? 'Offener Betrag' : 'Outstanding amount'}</td>
<td style="padding:12px 14px;font-size:13px;text-align:right;font-weight:700;">${safeTotal}</td>
</tr>
<tr>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;color:#6b7280;">${isDe ? 'Überfällig seit' : 'Overdue by'}</td>
<td style="padding:12px 14px;background:#f9fafb;font-size:13px;text-align:right;font-weight:700;">${safeDays} ${isDe ? 'Tagen' : 'days'}</td>
</tr>
</table>
<p style="margin:0 0 8px 0;font-size:13px;color:#6b7280;line-height:1.5;">${isDe
? 'Bitte begleichen Sie den Betrag, damit Ihr Abonnement aktiv bleibt. Bei Nicht-Zahlung wird Ihr Abonnement automatisch pausiert.'
: 'Please settle the amount to keep your subscription active. Your subscription will be automatically paused if payment is not received.'}</p>
</td>
</tr>
<tr>
<td style="padding:16px 28px;background:#f9fafb;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:12px;color:#6b7280;">${isDe ? 'Viele Grüße Ihr ProfitPlanet Team' : 'Best regards Your ProfitPlanet Team'}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
try {
const payload = {
sender: this.sender,
to: [{ email }],
subject,
textContent: text,
htmlContent: html,
};
const data = await this.brevo.transactionalEmails.sendTransacEmail(payload);
logger.info('MailService.sendPaymentReminderEmail:email_sent', { email, lang, invoiceNumber });
return data;
} catch (error) {
const brevoError = this._extractBrevoErrorDetails(error);
logger.error('MailService.sendPaymentReminderEmail:error', {
email, lang, invoiceNumber,
message: error?.message,
brevoStatus: brevoError.status,
brevoData: brevoError.data,
});
throw error;
}
}
async sendSubscriptionPausedEmail({ email, customerName, reason, lang = 'en' }) {
logger.info('MailService.sendSubscriptionPausedEmail:start', { email, lang, reason });
const isDe = lang === 'de';
const subject = isDe
? 'ProfitPlanet: Ihr Abonnement wurde pausiert'
: 'ProfitPlanet: Your subscription has been paused';
const safeName = this._escapeForHtml(customerName || '');
const reasonTextDe = reason === 'no_registered_user'
? 'Der Empfänger hat sich noch nicht als Gastkunde registriert.'
: 'Wir konnten leider keinen Zahlungseingang für Ihre letzte Rechnung feststellen.';
const reasonTextEn = reason === 'no_registered_user'
? 'The recipient has not yet registered as a guest customer.'
: 'We were unable to confirm payment for your latest invoice.';
const text = isDe
? [
`Hallo ${customerName || ''},`,
'',
'Ihr ProfitPlanet Kaffee-Abonnement wurde vorübergehend pausiert.',
'',
`Grund: ${reasonTextDe}`,
'',
reason === 'no_registered_user'
? 'Sobald sich der Empfänger registriert hat, wird das Abonnement automatisch fortgesetzt.'
: 'Sobald die Zahlung eingegangen ist und vor dem 11. des Monats bestätigt wird, wird Ihr Abonnement im aktuellen Monat fortgesetzt. Andernfalls startet es im nächsten Monat.',
'',
'Viele Grüße',
'Ihr ProfitPlanet Team',
].join('\n')
: [
`Hi ${customerName || ''},`,
'',
'Your ProfitPlanet coffee subscription has been temporarily paused.',
'',
`Reason: ${reasonTextEn}`,
'',
reason === 'no_registered_user'
? 'Once the recipient registers, the subscription will automatically resume.'
: 'Once payment is confirmed before the 11th of the month, your subscription will continue in the current month. Otherwise, it will resume in the following month.',
'',
'Best regards,',
'Your ProfitPlanet Team',
].join('\n');
const logoUrl = process.env.MAIL_LOGO_URL || process.env.BREVO_LOGO_URL || process.env.APP_LOGO_URL || '';
const html = `<!doctype html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f5f7fb;font-family:Arial,sans-serif;color:#1f2937;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f5f7fb;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 8px 24px rgba(0,0,0,0.08);">
<tr>
<td style="padding:24px 28px;background:#dc2626;color:#ffffff;">
${logoUrl ? `<img src="${this._escapeForHtml(logoUrl)}" alt="ProfitPlanet" style="max-height:44px;display:block;margin-bottom:12px;">` : ''}
<h1 style="margin:0;font-size:22px;line-height:1.3;">${isDe ? 'Abonnement pausiert' : 'Subscription Paused'}</h1>
</td>
</tr>
<tr>
<td style="padding:24px 28px;">
<p style="margin:0 0 12px 0;font-size:15px;line-height:1.6;">${isDe ? 'Hallo' : 'Hi'} ${safeName},</p>
<p style="margin:0 0 18px 0;font-size:15px;line-height:1.6;">${isDe
? 'Ihr Kaffee-Abonnement wurde vorübergehend pausiert.'
: 'Your coffee subscription has been temporarily paused.'}</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border:1px solid #fecaca;border-radius:8px;overflow:hidden;margin-bottom:18px;">
<tr>
<td style="padding:14px 16px;background:#fef2f2;font-size:14px;color:#991b1b;">
<strong>${isDe ? 'Grund:' : 'Reason:'}</strong> ${this._escapeForHtml(isDe ? reasonTextDe : reasonTextEn)}
</td>
</tr>
</table>
<p style="margin:0 0 8px 0;font-size:14px;line-height:1.6;">${isDe
? (reason === 'no_registered_user'
? 'Sobald sich der Empfänger registriert hat, wird das Abonnement automatisch fortgesetzt.'
: 'Sobald die Zahlung eingegangen ist und vor dem 11. des Monats bestätigt wird, wird Ihr Abonnement im aktuellen Monat fortgesetzt. Andernfalls startet es im nächsten Monat.')
: (reason === 'no_registered_user'
? 'Once the recipient registers, the subscription will automatically resume.'
: 'Once payment is confirmed before the 11th of the month, your subscription will continue in the current month. Otherwise, it will resume in the following month.')}</p>
</td>
</tr>
<tr>
<td style="padding:16px 28px;background:#f9fafb;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:12px;color:#6b7280;">${isDe ? 'Viele Grüße Ihr ProfitPlanet Team' : 'Best regards Your ProfitPlanet Team'}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
try {
const payload = {
sender: this.sender,
to: [{ email }],
subject,
textContent: text,
htmlContent: html,
};
const data = await this.brevo.transactionalEmails.sendTransacEmail(payload);
logger.info('MailService.sendSubscriptionPausedEmail:email_sent', { email, lang, reason });
return data;
} catch (error) {
const brevoError = this._extractBrevoErrorDetails(error);
logger.error('MailService.sendSubscriptionPausedEmail:error', {
email, lang, reason,
message: error?.message,
brevoStatus: brevoError.status,
brevoData: brevoError.data,
});
throw error;
}
}
_escapeForHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
}
module.exports = new MailService();