345 lines
12 KiB
JavaScript
345 lines
12 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 zur Registrierung'
|
|
: 'ProfitPlanet: Invitation to register';
|
|
|
|
const text = isDe
|
|
? `Hallo,\n\n${inviterName || 'Ein Benutzer'} hat ein Kaffee-Abonnement für Sie erstellt.\nBitte registrieren Sie sich hier: ${referralLink}\n\nViele Grüße\nProfitPlanet Team`
|
|
: `Hi,\n\n${inviterName || 'A user'} created a coffee subscription for you.\nPlease register here: ${referralLink}\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:#111827;color:#ffffff;">
|
|
<h2 style="margin:0;font-size:22px;">${isDe ? 'Einladung zur Registrierung' : 'Invitation to register'}</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 subscription for you.`}</p>
|
|
<p style="margin:0 0 18px 0;line-height:1.6;">${isDe ? 'Bitte schließen Sie Ihre Registrierung über den folgenden Link ab:' : 'Please complete your registration using the link below:'}</p>
|
|
<p style="margin:0;">
|
|
<a href="${this._escapeForHtml(referralLink)}" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;padding:10px 16px;border-radius:8px;font-weight:700;">${isDe ? 'Jetzt registrieren' : 'Register now'}</a>
|
|
</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;
|
|
}
|
|
}
|
|
|
|
_escapeForHtml(value) {
|
|
return String(value ?? '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
}
|
|
|
|
module.exports = new MailService(); |