Email verification (#369)

* Re-introduce Email Verification code

Revert "Revert "Email verification""
This reverts commit d154d8bff259350523a0f139e844db96c43d2ee1.

* Uses MJML to generate Reset Password email

* Sends Password Reset and Email Confirmation emails using MJML template

* Sends verified status along with user data

* API endpoint for resending email verification confirmation

* Displays verification status on Account page and allows resending

* Send back error string

* Passes email address through to sign/verify helper

* Uses enum-style object to set verified state

* Sends minimal info when user verifies since it can be done without login

* Provides /verify UI and sends confirmation token to API

* Better name for JWT secret token env var

* Adds mail config variables to Readme

* Encrypts email address in JWT

The JWT sent as the token in the Confirm Password URL
can be unencoded by anyone, although it's signature can only
be verified by us. To ensure that no passwords are leaked,
we encrypt the email address before creating the token.

* Removes unused mail templates

* Resets verified flag when email is changed and sends another email

* Moves email confirmation functions next to each other

* Extracts random token generator to helper

* Moves email confirmation actions into Redux

- updates the AccountForm label with a message to check inbox
- show status when verifying email token

* Uses generated token stored in DB for email confirmation

* Sets email confirmation status to verified if logging in from Github

* Sends email using new method on account creation

* Fixes linting errors

* Removes replyTo config
This commit is contained in:
Andrew Nicolaou 2017-06-26 18:48:28 +02:00 committed by Cassie Tarakajian
parent 7403b2b2d6
commit 1dc0c22cb7
21 changed files with 554 additions and 43 deletions

View File

@ -55,6 +55,10 @@ The automatic redirection to HTTPS is turned off by default in development. If y
S3_BUCKET=<your-s3-bucket>
GITHUB_ID=<your-github-client-id>
GITHUB_SECRET=<your-github-client-secret>
EMAIL_SENDER=<email-address-to-send-from>
MAILGUN_KEY=<mailgun-api-key>
MAILGUN_DOMAIN=<mailgun-domain>
EMAIL_VERIFY_SECRET_TOKEN=whatever_you_want_this_to_be_it_only_matters_for_production
```
For production, you will need to have real Github and Amazon credentions. Refer to [this gist](https://gist.github.com/catarak/70c9301f0fd1ac2d6b58de03f61997e3) for creating an S3 bucket for testing.

View File

@ -102,6 +102,11 @@ export const RESET_PASSWORD_INITIATE = 'RESET_PASSWORD_INITIATE';
export const RESET_PASSWORD_RESET = 'RESET_PASSWORD_RESET';
export const INVALID_RESET_PASSWORD_TOKEN = 'INVALID_RESET_PASSWORD_TOKEN';
export const EMAIL_VERIFICATION_INITIATE = 'EMAIL_VERIFICATION_INITIATE';
export const EMAIL_VERIFICATION_VERIFY = 'EMAIL_VERIFICATION_VERIFY';
export const EMAIL_VERIFICATION_VERIFIED = 'EMAIL_VERIFICATION_VERIFIED';
export const EMAIL_VERIFICATION_INVALID = 'EMAIL_VERIFICATION_INVALID';
// eventually, handle errors more specifically and better
export const ERROR = 'ERROR';

View File

@ -130,6 +130,41 @@ export function initiateResetPassword(formValues) {
};
}
export function initiateVerification() {
return (dispatch) => {
dispatch({
type: ActionTypes.EMAIL_VERIFICATION_INITIATE
});
axios.post(`${ROOT_URL}/verify/send`, {}, { withCredentials: true })
.then(() => {
// do nothing
})
.catch(response => dispatch({
type: ActionTypes.ERROR,
message: response.data
}));
};
}
export function verifyEmailConfirmation(token) {
return (dispatch) => {
dispatch({
type: ActionTypes.EMAIL_VERIFICATION_VERIFY,
state: 'checking',
});
return axios.get(`${ROOT_URL}/verify?t=${token}`, {}, { withCredentials: true })
.then(response => dispatch({
type: ActionTypes.EMAIL_VERIFICATION_VERIFIED,
message: response.data,
}))
.catch(response => dispatch({
type: ActionTypes.EMAIL_VERIFICATION_INVALID,
message: response.data
}));
};
}
export function resetPasswordReset() {
return {
type: ActionTypes.RESET_PASSWORD_RESET

View File

@ -4,11 +4,19 @@ import { domOnlyProps } from '../../../utils/reduxFormUtils';
function AccountForm(props) {
const {
fields: { username, email, currentPassword, newPassword },
user,
handleSubmit,
initiateVerification,
submitting,
invalid,
pristine
} = props;
const handleInitiateVerification = (evt) => {
evt.preventDefault();
initiateVerification();
};
return (
<form className="form" onSubmit={handleSubmit(props.updateSettings)}>
<p className="form__field">
@ -22,6 +30,26 @@ function AccountForm(props) {
/>
{email.touched && email.error && <span className="form-error">{email.error}</span>}
</p>
{
user.verified !== 'verified' &&
(
<p className="form__context">
<span className="form__status">Unconfirmed.</span>
{
user.emailVerificationInitiate === true ?
(
<span className="form__status"> Confirmation sent, check your email.</span>
) :
(
<button
className="form__action"
onClick={handleInitiateVerification}
>Resend confirmation email</button>
)
}
</p>
)
}
<p className="form__field">
<label htmlFor="username" className="form__label">User Name</label>
<input
@ -75,9 +103,14 @@ AccountForm.propTypes = {
username: PropTypes.object.isRequired,
email: PropTypes.object.isRequired,
currentPassword: PropTypes.object.isRequired,
newPassword: PropTypes.object.isRequired
newPassword: PropTypes.object.isRequired,
}).isRequired,
user: PropTypes.shape({
verified: PropTypes.number.isRequired,
emailVerificationInitiate: PropTypes.bool.isRequired,
}).isRequired,
handleSubmit: PropTypes.func.isRequired,
initiateVerification: PropTypes.func.isRequired,
updateSettings: PropTypes.func.isRequired,
submitting: PropTypes.bool,
invalid: PropTypes.bool,

View File

@ -4,7 +4,7 @@ import { bindActionCreators } from 'redux';
import { browserHistory } from 'react-router';
import InlineSVG from 'react-inlinesvg';
import axios from 'axios';
import { updateSettings } from '../actions';
import { updateSettings, initiateVerification } from '../actions';
import AccountForm from '../components/AccountForm';
import { validateSettings } from '../../../utils/reduxFormUtils';
import GithubButton from '../components/GithubButton';
@ -59,7 +59,7 @@ function mapStateToProps(state) {
}
function mapDispatchToProps(dispatch) {
return bindActionCreators({ updateSettings }, dispatch);
return bindActionCreators({ updateSettings, initiateVerification }, dispatch);
}
function asyncValidate(formProps, dispatch, props) {
@ -81,7 +81,7 @@ function asyncValidate(formProps, dispatch, props) {
}
AccountView.propTypes = {
previousPath: PropTypes.string.isRequired
previousPath: PropTypes.string.isRequired,
};
export default reduxForm({

View File

@ -0,0 +1,110 @@
import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { browserHistory } from 'react-router';
import InlineSVG from 'react-inlinesvg';
import get from 'lodash/get';
import { verifyEmailConfirmation } from '../actions';
const exitUrl = require('../../../images/exit.svg');
const logoUrl = require('../../../images/p5js-logo.svg');
class EmailVerificationView extends React.Component {
static defaultProps = {
emailVerificationTokenState: null,
}
constructor(props) {
super(props);
this.closeLoginPage = this.closeLoginPage.bind(this);
this.gotoHomePage = this.gotoHomePage.bind(this);
this.state = {
error: null,
};
}
componentWillMount() {
const verificationToken = this.verificationToken();
if (verificationToken != null) {
this.props.verifyEmailConfirmation(verificationToken);
}
}
verificationToken = () => get(this.props, 'location.query.t', null);
closeLoginPage() {
browserHistory.push(this.props.previousPath);
}
gotoHomePage() {
browserHistory.push('/');
}
render() {
let status = null;
const {
emailVerificationTokenState,
} = this.props;
if (this.verificationToken() == null) {
status = (
<p>That link is invalid</p>
);
} else if (emailVerificationTokenState === 'checking') {
status = (
<p>Validating token, please wait...</p>
);
} else if (emailVerificationTokenState === 'verified') {
status = (
<p>All done, your email address has been verified.</p>
);
} else if (emailVerificationTokenState === 'invalid') {
status = (
<p>Something went wrong.</p>
);
}
return (
<div className="form-container">
<div className="form-container__header">
<button className="form-container__logo-button" onClick={this.gotoHomePage}>
<InlineSVG src={logoUrl} alt="p5js Logo" />
</button>
<button className="form-container__exit-button" onClick={this.closeLoginPage}>
<InlineSVG src={exitUrl} alt="Close Login Page" />
</button>
</div>
<div className="form-container__content">
<h2 className="form-container__title">Verify your email</h2>
{status}
</div>
</div>
);
}
}
function mapStateToProps(state) {
return {
emailVerificationTokenState: state.user.emailVerificationTokenState,
previousPath: state.ide.previousPath
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators({
verifyEmailConfirmation,
}, dispatch);
}
EmailVerificationView.propTypes = {
previousPath: PropTypes.string.isRequired,
emailVerificationTokenState: PropTypes.oneOf([
'checking', 'verified', 'invalid'
]),
verifyEmailConfirmation: PropTypes.func.isRequired,
};
export default connect(mapStateToProps, mapDispatchToProps)(EmailVerificationView);

View File

@ -19,6 +19,14 @@ const user = (state = { authenticated: false }, action) => {
return Object.assign({}, state, { resetPasswordInitiate: false });
case ActionTypes.INVALID_RESET_PASSWORD_TOKEN:
return Object.assign({}, state, { resetPasswordInvalid: true });
case ActionTypes.EMAIL_VERIFICATION_INITIATE:
return Object.assign({}, state, { emailVerificationInitiate: true });
case ActionTypes.EMAIL_VERIFICATION_VERIFY:
return Object.assign({}, state, { emailVerificationTokenState: 'checking' });
case ActionTypes.EMAIL_VERIFICATION_VERIFIED:
return Object.assign({}, state, { emailVerificationTokenState: 'verified' });
case ActionTypes.EMAIL_VERIFICATION_INVALID:
return Object.assign({}, state, { emailVerificationTokenState: 'invalid' });
case ActionTypes.SETTINGS_UPDATED:
return { ...state, ...action.user };
default:

View File

@ -7,6 +7,7 @@ import FullView from './modules/IDE/pages/FullView';
import LoginView from './modules/User/pages/LoginView';
import SignupView from './modules/User/pages/SignupView';
import ResetPasswordView from './modules/User/pages/ResetPasswordView';
import EmailVerificationView from './modules/User/pages/EmailVerificationView';
import NewPasswordView from './modules/User/pages/NewPasswordView';
import AccountView from './modules/User/pages/AccountView';
// import SketchListView from './modules/Sketch/pages/SketchListView';
@ -38,6 +39,7 @@ const routes = (store) => {
<Route path="/login" component={forceToHttps(LoginView)} />
<Route path="/signup" component={forceToHttps(SignupView)} />
<Route path="/reset-password" component={forceToHttps(ResetPasswordView)} />
<Route path="/verify" component={forceToHttps(EmailVerificationView)} />
<Route
path="/reset-password/:reset_password_token"
component={forceToHttps(NewPasswordView)}

View File

@ -33,6 +33,15 @@
border-color: $secondary-form-title-color;
}
.form__context {
text-align: left;
margin-top: #{15 / $base-font-size}rem;
}
.form__status {
color: $form-navigation-options-color;
}
.form input[type="submit"] {
@extend %forms-button;
}

View File

@ -79,12 +79,16 @@
"express": "^4.13.4",
"express-session": "^1.13.0",
"file-type": "^3.8.0",
"fs-promise": "^1.0.0",
"htmlhint": "^0.9.13",
"is_js": "^0.9.0",
"js-beautify": "^1.6.4",
"jsdom": "^9.8.3",
"jshint": "^2.9.4",
"jsonwebtoken": "^7.2.1",
"lodash": "^4.16.4",
"loop-protect": "git+https://git@github.com/catarak/loop-protect.git",
"mjml": "^3.3.2",
"moment": "^2.14.1",
"mongoose": "^4.4.16",
"node-uuid": "^1.4.7",
@ -94,6 +98,7 @@
"passport-github": "^1.1.0",
"passport-local": "^1.0.0",
"project-name-generator": "^2.1.3",
"pug": "^2.0.0-beta6",
"q": "^1.4.1",
"react": "^15.1.0",
"react-dom": "^15.1.0",

View File

@ -84,6 +84,7 @@ passport.use(new GitHubStrategy({
existingEmailUser.username = existingEmailUser.username || profile.username;
existingEmailUser.tokens.push({ kind: 'github', accessToken });
existingEmailUser.name = existingEmailUser.name || profile.displayName;
existingEmailUser.verified = User.EmailConfirmation.Verified;
existingEmailUser.save(saveErr => done(null, existingEmailUser));
} else {
const user = new User();
@ -92,6 +93,7 @@ passport.use(new GitHubStrategy({
user.username = profile.username;
user.tokens.push({ kind: 'github', accessToken });
user.name = profile.displayName;
user.verified = User.EmailConfirmation.Verified;
user.save(saveErr => done(null, user));
}
});

View File

@ -13,6 +13,7 @@ export function createSession(req, res, next) {
email: req.user.email,
username: req.user.username,
preferences: req.user.preferences,
verified: req.user.verified,
id: req.user._id
});
});
@ -25,6 +26,7 @@ export function getSession(req, res) {
email: req.user.email,
username: req.user.username,
preferences: req.user.preferences,
verified: req.user.verified,
id: req.user._id
});
}

View File

@ -1,17 +1,34 @@
import crypto from 'crypto';
import async from 'async';
import nodemailer from 'nodemailer';
import mg from 'nodemailer-mailgun-transport';
import User from '../models/user';
import mail from '../utils/mail';
import {
renderEmailConfirmation,
renderResetPassword,
} from '../views/mail';
const random = (done) => {
crypto.randomBytes(20, (err, buf) => {
const token = buf.toString('hex');
done(err, token);
});
};
const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + (3600000 * 24); // 24 hours
export function createUser(req, res, next) {
const user = new User({
username: req.body.username,
email: req.body.email,
password: req.body.password
});
random((tokenError, token) => {
const user = new User({
username: req.body.username,
email: req.body.email,
password: req.body.password,
verified: User.EmailConfirmation.Sent,
verifiedToken: token,
verifiedTokenExpires: EMAIL_VERIFY_TOKEN_EXPIRY_TIME,
});
User.findOne({ email: req.body.email },
User.findOne({ email: req.body.email },
(err, existingUser) => {
if (err) {
res.status(404).send({ error: err });
@ -32,15 +49,28 @@ export function createUser(req, res, next) {
next(loginErr);
return;
}
res.json({
email: req.user.email,
username: req.user.username,
preferences: req.user.preferences,
id: req.user._id
const mailOptions = renderEmailConfirmation({
body: {
domain: `http://${req.headers.host}`,
link: `http://${req.headers.host}/verify?t=${token}`
},
to: req.user.email,
});
mail.send(mailOptions, (mailErr, result) => { // eslint-disable-line no-unused-vars
res.json({
email: req.user.email,
username: req.user.username,
preferences: req.user.preferences,
verified: req.user.verified,
id: req.user._id
});
});
});
});
});
});
}
export function duplicateUserCheck(req, res) {
@ -90,12 +120,7 @@ export function updatePreferences(req, res) {
export function resetPasswordInitiate(req, res) {
async.waterfall([
(done) => {
crypto.randomBytes(20, (err, buf) => {
const token = buf.toString('hex');
done(err, token);
});
},
random,
(token, done) => {
User.findOne({ email: req.body.email }, (err, user) => {
if (!user) {
@ -111,27 +136,15 @@ export function resetPasswordInitiate(req, res) {
});
},
(token, user, done) => {
const auth = {
auth: {
api_key: process.env.MAILGUN_KEY,
domain: process.env.MAILGUN_DOMAIN
}
};
const transporter = nodemailer.createTransport(mg(auth));
const message = {
const mailOptions = renderResetPassword({
body: {
domain: `http://${req.headers.host}`,
link: `http://${req.headers.host}/reset-password/${token}`,
},
to: user.email,
from: 'p5.js Web Editor <noreply@p5js.org>',
subject: 'p5.js Web Editor Password Reset',
text: `You are receiving this email because you (or someone else) have requested the reset of the password for your account.
\n\nPlease click on the following link, or paste this into your browser to complete the process:
\n\nhttp://${req.headers.host}/reset-password/${token}
\n\nIf you did not request this, please ignore this email and your password will remain unchanged.
\n\nThanks for using the p5.js Web Editor!\n`
};
transporter.sendMail(message, (error) => {
done(error);
});
mail.send(mailOptions, done);
}
], (err) => {
if (err) {
@ -153,6 +166,75 @@ export function validateResetPasswordToken(req, res) {
});
}
export function emailVerificationInitiate(req, res) {
async.waterfall([
random,
(token, done) => {
User.findById(req.user.id, (err, user) => {
if (err) {
res.status(500).json({ error: err });
return;
}
if (!user) {
res.status(404).json({ error: 'Document not found' });
return;
}
if (user.verified === User.EmailConfirmation.Verified) {
res.status(409).json({ error: 'Email already verified' });
return;
}
const mailOptions = renderEmailConfirmation({
body: {
domain: `http://${req.headers.host}`,
link: `http://${req.headers.host}/verify?t=${token}`
},
to: user.email,
});
mail.send(mailOptions, (mailErr, result) => { // eslint-disable-line no-unused-vars
if (mailErr != null) {
res.status(500).send({ error: 'Error sending mail' });
} else {
user.verified = User.EmailConfirmation.Resent;
user.verifiedToken = token;
user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours
user.save();
res.json({
email: req.user.email,
username: req.user.username,
preferences: req.user.preferences,
verified: user.verified,
id: req.user._id
});
}
});
});
},
]);
}
export function verifyEmail(req, res) {
const token = req.query.t;
User.findOne({ verifiedToken: token, verifiedTokenExpires: { $gt: Date.now() } }, (err, user) => {
if (!user) {
res.status(401).json({ success: false, message: 'Token is invalid or has expired.' });
return;
}
user.verified = User.EmailConfirmation.Verified;
user.verifiedToken = null;
user.verifiedTokenExpires = null;
user.save()
.then((result) => { // eslint-disable-line
res.json({ success: true });
});
});
}
export function updatePassword(req, res) {
User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } }, (err, user) => {
if (!user) {
@ -205,7 +287,6 @@ export function updateSettings(req, res) {
return;
}
user.email = req.body.email;
user.username = req.body.username;
if (req.body.currentPassword) {
@ -218,6 +299,27 @@ export function updateSettings(req, res) {
user.password = req.body.newPassword;
saveUser(res, user);
});
} else if (user.email !== req.body.email) {
user.verified = User.EmailConfirmation.Sent;
user.email = req.body.email;
random((error, token) => {
user.verifiedToken = token;
user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME;
saveUser(res, user);
const mailOptions = renderEmailConfirmation({
body: {
domain: `http://${req.headers.host}`,
link: `http://${req.headers.host}/verify?t=${token}`
},
to: user.email,
});
mail.send(mailOptions);
});
} else {
saveUser(res, user);
}

View File

@ -2,6 +2,12 @@ import mongoose from 'mongoose';
const bcrypt = require('bcrypt-nodejs');
const EmailConfirmationStates = {
Verified: 'verified',
Sent: 'sent',
Resent: 'resent',
};
const Schema = mongoose.Schema;
const userSchema = new Schema({
@ -10,6 +16,9 @@ const userSchema = new Schema({
password: { type: String },
resetPasswordToken: String,
resetPasswordExpires: Date,
verified: { type: String },
verifiedToken: String,
verifiedTokenExpires: Date,
github: { type: String },
email: { type: String, unique: true },
tokens: Array,
@ -73,4 +82,6 @@ userSchema.statics.findByMailOrName = function findByMailOrName(email) {
return this.findOne(query).exec();
};
userSchema.statics.EmailConfirmation = EmailConfirmationStates;
export default mongoose.model('User', userSchema);

View File

@ -40,6 +40,10 @@ router.route('/reset-password/:reset_password_token').get((req, res) => {
res.send(renderIndex());
});
router.route('/verify').get((req, res) => {
res.send(renderIndex());
});
router.route('/sketches').get((req, res) => {
res.send(renderIndex());
});

View File

@ -17,4 +17,8 @@ router.route('/reset-password/:token').post(UserController.updatePassword);
router.route('/account').put(UserController.updateSettings);
router.route('/verify/send').post(UserController.emailVerificationInitiate);
router.route('/verify').get(UserController.verifyEmail);
export default router;

48
server/utils/mail.js Normal file
View File

@ -0,0 +1,48 @@
/**
* Mail service wrapping around mailgun
*/
import nodemailer from 'nodemailer';
import mg from 'nodemailer-mailgun-transport';
const auth = {
api_key: process.env.MAILGUN_KEY,
domain: process.env.MAILGUN_DOMAIN,
};
class Mail {
constructor() {
this.client = nodemailer.createTransport(mg({ auth }));
this.sendOptions = {
from: process.env.EMAIL_SENDER,
};
}
sendMail(mailOptions) {
return new Promise((resolve, reject) => {
this.client.sendMail(mailOptions, (err, info) => {
resolve(err, info);
});
});
}
dispatchMail(data, callback) {
const mailOptions = {
to: data.to,
subject: data.subject,
from: this.sendOptions.from,
html: data.html,
};
return this.sendMail(mailOptions)
.then((err, res) => {
callback(err, res);
});
}
send(data, callback) {
return this.dispatchMail(data, callback);
}
}
export default new Mail();

View File

@ -0,0 +1,12 @@
import { mjml2html } from 'mjml';
export default (template) => {
try {
const output = mjml2html(template);
return output.html;
} catch (e) {
// fall through to null
}
return null;
};

56
server/views/mail.js Normal file
View File

@ -0,0 +1,56 @@
import renderMjml from '../utils/renderMjml';
import mailLayout from './mailLayout';
export const renderResetPassword = (data) => {
const subject = 'p5.js Web Editor Password Reset';
const templateOptions = {
domain: data.body.domain,
headingText: 'Reset your password',
greetingText: 'Hello,',
messageText: 'We received a request to reset the password for your account. To reset your password, click on the button below:', // eslint-disable-line max-len
link: data.body.link,
buttonText: 'Reset password',
directLinkText: 'Or copy and paste the URL into your browser:',
noteText: 'If you did not request this, please ignore this email and your password will remain unchanged. Thanks for using the p5.js Web Editor!', // eslint-disable-line max-len
};
// Return MJML string
const template = mailLayout(templateOptions);
// Render MJML to HTML string
const html = renderMjml(template);
// Return options to send mail
return Object.assign(
{},
data,
{ html, subject },
);
};
export const renderEmailConfirmation = (data) => {
const subject = 'p5.js Email Verification';
const templateOptions = {
domain: data.body.domain,
headingText: 'Email Verification',
greetingText: 'Hello,',
messageText: 'To verify your email, click on the button below:',
link: data.body.link,
buttonText: 'Verify Email',
directLinkText: 'Or copy and paste the URL into your browser:',
noteText: 'This link is only valid for the next 24 hours. Thanks for using the p5.js Web Editor!',
};
// Return MJML string
const template = mailLayout(templateOptions);
// Render MJML to HTML string
const html = renderMjml(template);
// Return options to send mail
return Object.assign(
{},
data,
{ html, subject },
);
};

View File

@ -0,0 +1,59 @@
export default ({
domain,
headingText,
greetingText,
messageText,
link,
buttonText,
directLinkText,
noteText,
}) => (
`
<mjml>
<mj-body>
<mj-container>
<mj-section>
<mj-column>
<mj-image width="192" src="${domain}/images/p5js-square-logo.png" alt="p5.js" />
<mj-divider border-color="#ed225d"></mj-divider>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text font-size="20px" color="#333333" font-family="sans-serif">
${headingText}
</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text color="#333333">
${greetingText}
</mj-text>
<mj-text color="#333333">
${messageText}
</mj-text>
<mj-button background-color="#ed225d" href="${link}">
${buttonText}
</mj-button>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text color="#333333">
${directLinkText}
</mj-text>
<mj-text align="center" color="#333333"><a href="${link}">${link}</a></mj-text>
<mj-text color="#333333">
${noteText}
</mj-text>
</mj-column>
</mj-section>
</mj-container>
</mj-body>
</mjml>
`
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB