'use strict'; const nodemailer = require('nodemailer'), Email = require('email-templates'), hbs = require('handlebars'), assert = require('assert'), fs = require('fs-extra'), cloneDeep = require('clone-deep'), path = require('path'), utils = require('./utils'), env = require('./env'), { Errors, DEFAULT_LANG } = require('./constants'), { AppInputError, AppAuthError } = require('./app_error'), debug = require('debug')('agm:mailer'); const TemplateType = Object.freeze({ PUG: 'pug', // default HANDLEBARS: 'hbs', }); const Templates = Object.freeze({ RESET_PASSWORD: 'reset-password', PASSWORD_RESET: 'password-reset', UPDATE_PAYMENT: 'update-payment', UPDATE_BILL_ADDRESS: 'update-address', CURRENT_SUBSCRIPTIONS: 'current-subscriptions', SUB_RENEWAL_REMIND: 'sub-renewal-remind', SUB_TRIAL_END_REMIND: 'sub-trial-end-remind', PROMO_EXPIRED: 'promo-expired', TEMPORARY_CREDENTIAL: 'temporary-credential', EMAIL_VERIFICATION: 'email-verification', NEW_ACCOUNT_WELCOME: 'new-account-welcome' }); const transporter = nodemailer.createTransport({ host: env.SMTP_HOST, port: env.SMTP_PORT, secure: env.SMTP_SECURE, auth: { user: env.SMTP_USR, pass: env.SMTP_PWD }, // debug: true, // logger: true }); function registerHbsPartials(partialsPath) { // Ref handlebars with vars in templates: https://github.com/forwardemail/email-templates/issues/283 // hbs is your own instance of handlebars, with partials // Register all available partials under the emails' partials folder const partialDir = path.join(process.cwd() + partialsPath); // const partialFiles = fs.readdir(partialDir, (err, partialFiles) => { if (!err && !utils.isEmptyArray(partialFiles)) { for (let i = 0; i < partialFiles.length; i++) { const pfile = partialFiles[i]; hbs.registerPartial(path.basename(pfile, '.hbs'), require(path.join(partialDir, pfile))); } // Override consolidate's handlebars instance // email.config.views.options.engineSource.requires.handlebars = hbs; } }); } registerHbsPartials('/emails/partials/'); hbs.registerHelper('$t', function (key, options) { // Translation helper to translate message by key or with hash const translation = options.data.root.t({ phrase: key, locale: options.data.root.locale }, options.hash); // Convert newline characters (\n) to
tags for HTML emails if (translation) { return new hbs.SafeString(translation.replace(/\n/g, '
')); } return ''; }); require('handlebars-helpers')(['comparison']); const agmMailSig = ['Best,', 'AgMision Team']; const debugging = false; const defaultMailOps = { transport: transporter, preview: debugging, send: true, i18n: { locales: ['en', 'es', 'pt'] } }; function init(ops) { if (ops) { Object.assign({}, defaultMailOps, ops); } } function _toLocale(locals) { if (!utils.isEmptyObj(locals) && !utils.hasProperty(locals, 'locale')) { const _locals = cloneDeep(locals); _locals.locale = _locals.lang || DEFAULT_LANG; delete _locals.lang; return _locals; } return locals; } function getAppHostUrl(locals) { if (!locals || !locals.baseUrl) AppInputError.throw(); return `${locals.baseUrl}/${locals.lang || DEFAULT_LANG}/#`; } /** * Send an email with given template and additional info * @param {*} template the template name * @param {*} locals additional metadata * @returns */ async function sendMail(template, locals, to, from) { const email = await createEmail({ from: from, to: to }); return email.send({ template: template, // message: { // to: to, // }, locals: _toLocale(locals) }); } /** * Send a text email from to with the mail options * @param {*} contentOps { subject: , text: } */ async function sendTextMail(contentOps, to, cc) { if (utils.isEmptyObj(contentOps)) { debug('Can not send Text email. No contentOps provided !'); return; } const _mailOps = Object.assign(contentOps, { from: 'AgMission ', to: to || 'agm_admin@agnav.com' }); if (cc) _mailOps.cc = cc; return transporter.sendMail(_mailOps); } /** * Send a text email to the admin. * @param {string} subject - The subject of the email. * @param {string} text - The body of the email. * @param {string} cc - The CC email address. */ async function sendAdminNotification(subject, text, cc) { return await sendTextMail({ subject, text }, '', cc); } /** * Create an Email instance * @param {*} emailOps { from, to } */ async function createEmail(emailOps, templateType = TemplateType.PUG) { let _ops = Object.assign( { message: { from: emailOps && emailOps.from || 'AgMission ' } }, defaultMailOps); if (templateType === 'hbs') { _ops = { ..._ops, views: { options: { extension: 'hbs' }, engineSource: { requires: { handlebars: hbs } } }, /* For relative assets or ones located within the same server */ // juice: true, // juiceResources: { // webResources: { // images: true, // // relativeTo: path.join(process.cwd(), '/emails/assets'), // } // } } } if (emailOps && emailOps.to) { _ops.message.to = emailOps.to; } const email = new Email(_ops); return email; } async function sendHandlebarsEmail(template, locals, to, from = null) { if (env.NO_EMAIL_MODE) { debug('Email sending is disabled in the current mode.'); return { success: false, messageId: null, error: 'Email sending is disabled in the current mode' }; } assert(template, AppInputError.create(Errors.TEMPLATE_NOT_FOUND)); assert(to, AppInputError.create(Errors.TO_NOT_FOUND)); const email = await createEmail({ locale: 'en', to: to }, TemplateType.HANDLEBARS); try { const result = await email.send({ template: template, locals: { ..._toLocale(locals) } }); // Debug the full result structure to find where messageId is located // debug('Email send result structure:', JSON.stringify(result, null, 2)); // Check if the email was accepted by the mail server if (result) { let messageId = null; // Try different possible locations for messageId if (result.originalMessage && result.originalMessage.messageId) { messageId = result.originalMessage.messageId; } else if (result.messageId) { messageId = result.messageId; } else if (result.envelope && result.envelope.messageId) { messageId = result.envelope.messageId; } // debug(`Email sent to ${to} successfully. MessageId: ${messageId || 'undefined'}`); return { success: true, messageId: messageId, result: result // Include the full result for debugging if needed }; } else { debug(`Email to ${to} has unknown status`); return { success: false, error: 'Unknown email status' }; } } catch (error) { debug(`Failed to send email to ${to}: ${error.message}`); // Capture specific nodemailer errors const errorInfo = { success: false, error: error.message, code: error.code, responseCode: error.responseCode, command: error.command }; // Log detailed error for debugging env.LOG_ALL_ERRORS && debug('Email sending error details:', errorInfo); return errorInfo; } } /** * Validate email sending by sending a test email * @param {string} toEmail - The email to test delivery to * @returns {Promise} Result of the test with success/error information */ async function validateEmailDelivery(toEmail) { assert(toEmail, AppInputError.create(Errors.TO_NOT_FOUND)); try { // Try to ping the SMTP server first const pingResult = await transporter.verify(); if (!pingResult) { return { success: false, error: 'Failed to connect to the SMTP server', smtpConnected: false }; } // Send a basic test email const result = await transporter.sendMail({ from: 'AgMission ', to: toEmail, subject: 'Email Validation Test', text: 'This is an automated test email to validate email delivery.', headers: { 'X-Priority': '1', // Set high priority to help avoid spam folders 'X-Test': 'Email-Validation' } }); return { success: true, messageId: result.messageId, smtpConnected: true, response: result.response }; } catch (error) { debug(`Email validation failure for ${toEmail}: ${error.message}`); return { success: false, error: error.message, smtpConnected: error.code !== 'ECONNECTION', code: error.code, responseCode: error.responseCode }; } } /** * Send update payment email notification so the customer can decide to update with a new payment method to continue using services * @param {*} locals { locale: 'en', name, pmType: 'American Visa', pmEnding: '4321', baseUrl: 'https://localhost' } * @param {*} to the destination email address. * @param {*} from (Optional) the sender email address. */ async function _sendUpdatePaymentEmail(locals, to, from) { assert(locals && locals.name && locals.baseUrl, AppInputError.create()); locals.updatePmUrl = `${getAppHostUrl(locals)}/update-pm`; return await sendHandlebarsEmail(Templates.UPDATE_PAYMENT, locals, to, from); } const sendUpdatePaymentEmail = withBaseUrl(_sendUpdatePaymentEmail); /** * Send update Billing Address email notification so the customer can decide to update with a valid address (for tax purpose). * @param {*} locals { locale: 'en', userId, name, address: {line1, line2, city, state, postalCode, Country (country name)}, baseUrl: 'https://localhost' } * @param {*} to the destination email address. * @param {*} from (Optional) the sender email address. */ async function _sendUpdateBillingAddressEmail(locals, to, from) { assert(locals && locals.userId && locals.name && locals.baseUrl && !utils.isEmptyObj(locals.address), AppInputError.create()); locals.updateAddrUrl = `${getAppHostUrl(locals)}/update-bill-address/${locals.userId}`; return await sendHandlebarsEmail(Templates.UPDATE_BILL_ADDRESS, locals, to, from); } const sendUpdateBillingAddressEmail = withBaseUrl(_sendUpdateBillingAddressEmail); /** * Send confirmation email with current subscriptions to the customer email address (normally after any subscription updates) * @param {*} locals { locale: 'en', userId, name, package: [{ name, billCyle, startDate }], addon: [name, quantity, billCyle, startDate], baseUrl: 'https://localhost' } * @param {*} to the destination email address. * @param {*} from (Optional) the sender email address. */ async function _sendCurSubcriptionsEmail(locals, to, from) { assert(locals && locals.name && locals.baseUrl, AppInputError.create()); return await sendHandlebarsEmail(Templates.CURRENT_SUBSCRIPTIONS, locals, to, from); } const sendCurSubcriptionsEmail = withBaseUrl(_sendCurSubcriptionsEmail); /** * Send upcoming renewal reminder of a subscription to the customer (auto-renewal enabled) * @param {*} locals { name, prodsName, upPaymentDate, cardType, cardEnding, userId, baseUrl: 'https://localhost' } * @param {*} to the destination email address. * @param {*} from (Optional) the sender email address. * */ async function _sendSubRenewalRemindEmail(locals, to, from) { assert(locals && locals.name && locals.baseUrl, AppInputError.create()); locals.manageSubUrl = `${getAppHostUrl(locals)}/manage-subscription`; return await sendHandlebarsEmail(Templates.SUB_RENEWAL_REMIND, locals, to, from); } const sendSubRenewalRemindEmail = withBaseUrl(_sendSubRenewalRemindEmail); /** * Send trial ending reminder of a subscription to the customer (auto-renewal enabled) * @param {*} locals { subName, upPmDate, cardType, cardEnding, chargeAmount, baseUrl: 'https://localhost' } * @param {*} to the destination email address. * @param {*} from (Optional) the sender email address. * */ async function _sendSubTrialEndingRemindEmail(locals, to, from) { assert(locals && locals.name && locals.baseUrl, AppInputError.create()); locals.manageSubUrl = `${getAppHostUrl(locals)}/manage-subscription/${locals.userId}`; return await sendHandlebarsEmail(Templates.SUB_TRIAL_END_REMIND, locals, to, from); } const sendSubTrialEndingRemindEmail = withBaseUrl(_sendSubTrialEndingRemindEmail); /** * Send promo expired notification to the customer when their promotional discount has ended. * This is triggered when a subscription schedule completes (phase with coupon ends). * @param {*} locals { name, promoName, subType, promoDiscount, promoStartDate, promoEndDate, newBillingDate, chargeAmount, baseUrl: 'https://localhost' } * @param {*} to the destination email address. * @param {*} from (Optional) the sender email address. */ async function _sendPromoExpiredEmail(locals, to, from) { assert(locals && locals.name && locals.baseUrl, AppInputError.create()); locals.manageSubUrl = `${getAppHostUrl(locals)}/manage-subscription`; return await sendHandlebarsEmail(Templates.PROMO_EXPIRED, locals, to, from); } const sendPromoExpiredEmail = withBaseUrl(_sendPromoExpiredEmail); /** * Send reset password notification email with validation token to the user * @param {*} locals { locale, id, baseUrl, name (name/username), token } * @param {*} to the destination email address. * @param {*} from (Optional) the sender email address. **/ async function _sendResetPasswordEmail(locals, to, from) { assert(locals && locals.id && locals.baseUrl && locals.name && locals.token, AppInputError.create()); locals.resetUrl = `${getAppHostUrl(locals)}/password-reset/${locals.id}/${encodeURIComponent(locals.token)}`; locals.resetPwdUrl = `${getAppHostUrl(locals)}/password-reset`; return await sendHandlebarsEmail(Templates.RESET_PASSWORD, locals, to, from); } const sendResetPasswordEmail = withBaseUrl(_sendResetPasswordEmail); /** * Send password already reset successfully notification email to the user * @param {*} locals { locale, baseUrl, name (name/username), username } * @param {*} to the destination email address. * @param {*} from Optional **/ async function _sendPasswordResetEmail(locals, to, from) { assert(locals && locals.baseUrl && locals.name && locals.username, AppInputError.create()); locals.resetPwdUrl = `${getAppHostUrl(locals)}/password-reset`; return await sendHandlebarsEmail(Templates.PASSWORD_RESET, locals, to, from); } const sendPasswordResetEmail = withBaseUrl(_sendPasswordResetEmail); /** * Send temporary username and password to the customer * @param {*} locals { name, baseUrl, username, password } * @param {*} to the destination email address. * @param {*} from (Optional) the sender email address. * */ async function _sendTempCredential(locals, to, from) { assert(locals && locals.name && locals.baseUrl && locals.username && locals.password, AppInputError.create()); locals.loginUrl = `${getAppHostUrl(locals)}/login`; return await sendHandlebarsEmail(Templates.TEMPORARY_CREDENTIAL, locals, to, from); } const sendTempCredential = withBaseUrl(_sendTempCredential); /** * Send email verification before allowing signup form submission * @param {*} locals { username, baseUrl, token, lang } * @param {*} to the destination email address. * @param {*} from (Optional) the sender email address. * @returns {Promise} Result of the email sending operation */ async function _sendEmailVerificationCode(locals, to, from = null) { assert(locals && locals.username && locals.baseUrl && locals.token, AppInputError.create(Errors.TO_NOT_FOUND)); // Create verification URL with token locals.verificationUrl = `${getAppHostUrl(locals)}/signup/verify-email?token=${encodeURIComponent(locals.token)}`; return await sendHandlebarsEmail(Templates.EMAIL_VERIFICATION, locals, to || locals.username, from); } const sendEmailVerificationCode = withBaseUrl(_sendEmailVerificationCode); /** * Send welcome email to the newly created account * @param {*} locals { locale, name, username, orgName, baseUrl, accountType(optional) } * @param {*} to the destination email address. * @param {*} from (Optional) the sender email address. * @returns {Promise} Result of the email sending operation */ async function _sendWelcomeNewAccEmail(locals, to, from = null) { assert(locals && locals.name && locals.baseUrl && locals.username && locals.orgName, AppInputError.create()); locals.resetPwdUrl = `${getAppHostUrl(locals)}/password-reset`; locals.manualUrl = 'https://www.agnav.com/manuals/'; locals.loginUrl = `${getAppHostUrl(locals)}/login`; return await sendHandlebarsEmail(Templates.NEW_ACCOUNT_WELCOME, locals, to || locals.username, from); } const sendWelcomeNewAccEmail = withBaseUrl(_sendWelcomeNewAccEmail); /** * Decorator function that extracts baseUrl from request and injects it into locals * @param {Function} emailFunction - The original email function to wrap * @returns {Function} - Enhanced function that extracts baseUrl from req parameter */ function withBaseUrl(emailFunction) { return async function(reqOrLocals, to, from) { let locals; // Always use environment variable if set const envBaseUrl = process.env.BASE_URL || process.env.APP_URL; if (!reqOrLocals || typeof reqOrLocals !== 'object') { locals = { baseUrl: envBaseUrl || `https://${env.PRODUCTION ? 'agmission.agnav.com' : 'localhost:4200'}` }; } // If first parameter is a request object (check for common Express request properties) else if (reqOrLocals.protocol || reqOrLocals.headers || reqOrLocals.get || reqOrLocals.hostname || reqOrLocals.method || reqOrLocals.url) { const req = reqOrLocals; let baseUrl = envBaseUrl; if (!baseUrl) { try { if (req.protocol && typeof req.get === 'function') { const host = req.get('host'); if (host) { baseUrl = `${req.protocol}://${host}`; } } else if (req.hostname) { const protocol = req.secure || req.connection?.encrypted ? 'https' : 'http'; const port = typeof req.get === 'function' ? req.get('x-forwarded-port') : null; baseUrl = `${protocol}://${req.hostname}${port && port !== '80' && port !== '443' ? ':' + port : ''}`; } } catch (error) { debug('Error extracting baseUrl from request:', error.message); } } if (!baseUrl) { baseUrl = `https://${env.PRODUCTION ? 'agmission.agnav.com' : 'localhost:4200'}`; } locals = (req.locals && typeof req.locals === 'object') ? { ...req.locals, baseUrl } : { baseUrl }; } else { locals = reqOrLocals || {}; // Always override baseUrl with environment variable if set locals.baseUrl = envBaseUrl || locals.baseUrl || `https://${env.PRODUCTION ? 'agmission.agnav.com' : 'localhost:4200'}`; } return await emailFunction(locals, to, from); }; } // NOTE: For Testing only async function sendTestMail(req, res) { let sent = true; try { // const custId = '5a1d6c05ffb94e7875c68dd9'; // const updateUrl = `https://${req.hostname}/update-pm/${custId}`; // await sendHandlebarsEmail( // Templates.UPDATE_PAYMENT, // { locale: 'en', name: 'Trung Hoang', pmType: 'American Visa', pmEnding: '4321', updatePmUrl: updateUrl }, // 'trungh@agnav.com', // ); return await sendHandlebarsEmail( Templates.CURRENT_SUBSCRIPTIONS, { name: "Trung Hoang", // package: [{ name: "ess_2", billCycle: "year", startDate: utils.timestampToDate(1692720405) }], // addon: [{ name: "addon_1", billCycle: "month", quantity: 1, startDate: utils.timestampToDate(1692720405) }] }, 'trungh@agnav.com, trungduyhoang@gmail.com', ); } catch (err) { debug(err); sent = false; } res.send({ msg: `Message sent ${sent}` }).end(); } module.exports = { agmMailSig, sendTestMail, sendMail, sendTextMail, sendAdminNotification, sendUpdatePaymentEmail, sendUpdateBillingAddressEmail, sendCurSubcriptionsEmail, sendSubTrialEndingRemindEmail, sendSubRenewalRemindEmail, sendTempCredential, sendPasswordResetEmail, sendResetPasswordEmail, validateEmailDelivery, sendEmailVerificationCode, sendWelcomeNewAccEmail, sendPromoExpiredEmail, withBaseUrl }