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 = `

${isDe ? 'Einladung zum Kaffee-Abonnement' : 'Invite for Coffee Abonnement'}

${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.`}

${isDe ? 'Als Gastkunde können Sie Ihr Abonnement einsehen und verwalten.' : 'As a guest customer, you can view and manage your subscription.'}

${isDe ? 'Bitte registrieren Sie sich über den folgenden Link:' : 'Please register using the link below:'}

${isDe ? 'Jetzt als Gastkunde registrieren' : 'Register as Guest Customer'}

${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.'}

`; 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 = `
${logoUrl ? `ProfitPlanet` : ''}

${isDe ? 'Ihr Abonnement wurde verlängert' : 'Your subscription has been renewed'}

${isDe ? 'Hallo' : 'Hi'} ${safeName},

${isDe ? 'Ihr Kaffee-Abonnement wurde automatisch verlängert. Nachfolgend finden Sie die Details:' : 'Your coffee subscription has been automatically renewed. Here are the details:'}

${isDe ? 'Rechnungsnummer' : 'Invoice number'} ${safeInvoiceNumber}
${isDe ? 'Gesamtbetrag' : 'Total amount'} ${safeTotalGross}
${isDe ? 'Nächste Verlängerung' : 'Next renewal'} ${safeNextDate}

${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.'}

${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.'}

${isDe ? 'Viele Grüße – Ihr ProfitPlanet Team' : 'Best regards – Your ProfitPlanet Team'}

`; 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, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } } module.exports = new MailService();