agmission/Development/server/helpers/mailer.js

569 lines
20 KiB
JavaScript

'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 <br/> tags for HTML emails
if (translation) {
return new hbs.SafeString(translation.replace(/\n/g, '<br/>'));
}
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: <the mail subject>, text: <the text body content> }
*/
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 <noreply@agnav.com>',
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 <noreply@agnav.com>'
}
},
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<Object>} 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 <noreply@agnav.com>',
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<Object>} 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<Object>} 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
}