add support for transactional html/text emails

- Add support for welcome email on user creation (both OAuth and password users)
- Add support for html/text templating on verify email.
- Add support for html/text templating on reset password.
- Add handlers for converting Handlebars templates to HTML and text (template agnostic).
- Add helper function sendEmail() for compiling and sending templates via Email.send() (SMTP).
This commit is contained in:
cleverbeagle 2017-09-07 17:23:08 -05:00
parent aa14a26bba
commit 379f7dcd76
17 changed files with 2234 additions and 804 deletions

View file

@ -5,7 +5,7 @@ import editProfile from './edit-profile';
import rateLimit from '../../../modules/rate-limit';
Meteor.methods({
'users.sendVerificationEmail': function usersResendVerification() {
'users.sendVerificationEmail': function usersSendVerificationEmail() {
return Accounts.sendVerificationEmail(this.userId);
},
'users.editProfile': function usersEditProfile(profile) {

View file

@ -0,0 +1,25 @@
import sendEmail from '../../../modules/server/send-email';
import getOAuthProfile from '../../../modules/get-oauth-profile';
export default (options, user) => {
const OAuthProfile = getOAuthProfile(options, user);
const applicationName = 'Application Name';
const firstName = OAuthProfile ? OAuthProfile.name.first : options.profile.name.first;
const emailAddress = OAuthProfile ? OAuthProfile.email : options.email;
return sendEmail({
to: emailAddress,
from: `${applicationName} <support@application.com>`,
subject: `[${applicationName}] Welcome, ${firstName}!`,
template: 'welcome',
templateVars: {
applicationName,
firstName,
welcomeUrl: Meteor.absoluteUrl('documents'), // e.g., returns http://localhost:3000/documents
},
})
.catch((error) => {
throw new Meteor.Error('500', `${error}`);
});
};

View file

@ -0,0 +1,42 @@
const parseGoogleData = service => {
return {
email: service.email,
name: {
first: service.given_name,
last: service.family_name,
},
};
};
const parseGithubData = (profile, service) => {
const name = profile.name.split(' ');
return {
email: service.email,
name: {
first: name[0],
last: name[1],
},
};
};
const parseFacebookData = service => {
return {
email: service.email,
name: {
first: service.first_name,
last: service.last_name,
},
};
};
const getDataForService = (profile, services) => {
if (services.facebook) return parseFacebookData(services.facebook);
if (services.github) return parseGithubData(profile, services.github);
if (services.google) return parseGoogleData(services.google);
};
export default (options, user) => {
const isOAuth = !options.password;
const serviceData = isOAuth ? getDataForService(options.profile, user.services) : null;
return isOAuth ? serviceData : null;
};

View file

@ -0,0 +1,11 @@
import handlebars from 'handlebars';
import juice from 'juice';
export default (handlebarsMarkup, context, options) => {
if (handlebarsMarkup && context) {
const template = handlebars.compile(handlebarsMarkup);
return options && !options.inlineCss ? template(context) : juice(template(context)); // Use juice to inline CSS <style></style> styles from <head> unless disabled.
}
throw new Error('Please pass Handlebars markup to compile and a context object with data mapping to the Handlebars expressions used in your template.');
};

View file

@ -0,0 +1,10 @@
import handlebars from 'handlebars';
export default (handlebarsMarkup, context) => {
if (handlebarsMarkup && context) {
const template = handlebars.compile(handlebarsMarkup);
return template(context);
}
throw new Error('Please pass Handlebars markup to compile and a context object with data mapping to the Handlebars expressions used in your template.');
};

View file

@ -0,0 +1,26 @@
import { Email } from 'meteor/email';
import getPrivateFile from './get-private-file';
import templateToText from './handlebars-email-to-text';
import templateToHTML from './handlebars-email-to-html';
const sendEmail = (options, { resolve, reject }) => {
try {
Meteor.defer(() => Email.send(options));
if (callback) resolve();
} catch (exception) {
reject(exception);
}
};
export default ({ text, html, template, templateVars, ...rest }, callback) => {
if (text || html || template) {
return new Promise((resolve, reject) => {
sendEmail({
...rest,
text: template ? templateToText(getPrivateFile(`email-templates/${template}.txt`), (templateVars || {})) : text,
html: template ? templateToHTML(getPrivateFile(`email-templates/${template}.html`), (templateVars || {})) : html,
}, { resolve, reject });
});
}
throw new Error('Please pass an HTML string, text, or template name to compile for your message\'s body.');
};

View file

@ -1,5 +1,8 @@
import { Meteor } from 'meteor/meteor';
import { Accounts } from 'meteor/accounts-base';
import getPrivateFile from '../../../modules/server/get-private-file';
import templateToHTML from '../../../modules/server/handlebars-email-to-html';
import templateToText from '../../../modules/server/handlebars-email-to-text';
const name = 'Application Name';
const email = '<support@application.com>';
@ -13,11 +16,21 @@ emailTemplates.verifyEmail = {
subject() {
return `[${name}] Verify Your Email Address`;
},
html(user, url) {
return templateToHTML(getPrivateFile('email-templates/verify-email.html'), {
applicationName: name,
firstName: user.profile.name.first,
verifyUrl: url.replace('#/', '')
});
},
text(user, url) {
const userEmail = user.emails[0].address;
const urlWithoutHash = url.replace('#/', '');
if (Meteor.isDevelopment) console.info(`Verify Email Link: ${urlWithoutHash}`); // eslint-disable-line
return `Hey, ${user.profile.name.first}! Welcome to ${name}.\n\nTo verify your email address (${userEmail}), click the link below:\n\n${urlWithoutHash}\n\nIf you feel something is wrong, please contact our support team: ${email}.`;
return templateToText(getPrivateFile('email-templates/verify-email.txt'), {
applicationName: name,
firstName: user.profile.name.first,
verifyUrl: urlWithoutHash,
});
},
};
@ -25,10 +38,22 @@ emailTemplates.resetPassword = {
subject() {
return `[${name}] Reset Your Password`;
},
html(user, url) {
return templateToHTML(getPrivateFile('email-templates/reset-password.html'), {
firstName: user.profile.name.first,
applicationName: name,
emailAddress: user.emails[0].address,
resetUrl: url.replace('#/', ''),
});
},
text(user, url) {
const userEmail = user.emails[0].address;
const urlWithoutHash = url.replace('#/', '');
if (Meteor.isDevelopment) console.info(`Reset Password Link: ${urlWithoutHash}`); // eslint-disable-line
return `A password reset has been requested for the account related to this address (${userEmail}).\n\nTo reset the password, visit the following link: \n\n${urlWithoutHash}\n\n If you did not request this reset, please ignore this email. If you feel something is wrong, please contact our support team: ${email}.`;
return templateToText(getPrivateFile('email-templates/reset-password.txt'), {
firstName: user.profile.name.first,
applicationName: name,
emailAddress: user.emails[0].address,
resetUrl: urlWithoutHash,
});
},
};

View file

@ -1,7 +1,9 @@
import { Accounts } from 'meteor/accounts-base';
import sendWelcomeEmail from '../../../api/Users/server/send-welcome-email';
Accounts.onCreateUser((options, user) => {
const userToCreate = user;
if (options.profile) userToCreate.profile = options.profile;
sendWelcomeEmail(options, user);
return userToCreate;
});

View file

@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from 'react-bootstrap';
import { Meteor } from 'meteor/meteor';
import { Accounts } from 'meteor/accounts-base';
import { Bert } from 'meteor/themeteorchef:bert';
@ -20,7 +21,7 @@ class VerifyEmail extends React.Component {
setTimeout(() => {
Bert.alert('All set, thanks!', 'success');
history.push('/documents');
}, 2000);
}, 1500);
}
});
}