CentralBackend/services/email/MailService.js
2026-03-16 16:04:36 +01:00

489 lines
20 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 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;
}
}
_escapeForHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
}
module.exports = new MailService();