- Added RenewalCronService to handle automatic subscription renewals and reactivations. - Introduced listPausedAutoRenew method in AbonemmentRepository to fetch paused subscriptions eligible for reactivation. - Created test script for renewal cron job to simulate subscription renewal scenarios. - Updated MailService to send renewal confirmation and payment reminder emails. - Enhanced EmailVerificationService to auto-grant 'can_subscribe' permission upon email verification. - Modified createAdminUser script to allow different admin email configurations. - Added node-cron dependency for scheduling tasks.
730 lines
32 KiB
JavaScript
730 lines
32 KiB
JavaScript
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;
|
||
}
|
||
}
|
||
|
||
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
}
|
||
|
||
module.exports = new MailService(); |