Skip to content

Commit

Permalink
feat(tasks): RN-1372: Email templating (#5830)
Browse files Browse the repository at this point in the history
* Working template setup

* v1

* Generic template working

* Request country access

* Delete account request

* Rename templates

* Password reset template

* Verify email

* Permission granted email

* email after timeout

* dashboard subscription emails

* Handle no template name

* Make subfolder for content
  • Loading branch information
alexd-bes authored Aug 8, 2024
1 parent afda72a commit 4ecbadb
Show file tree
Hide file tree
Showing 22 changed files with 438 additions and 123 deletions.
13 changes: 7 additions & 6 deletions packages/central-server/src/apiV2/deleteAccount.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,24 @@
*/
import { requireEnv, respond } from '@tupaia/utils';
import { sendEmail } from '@tupaia/server-utils';
import { getUserInfoInString } from './utilities';

const sendRequest = userInfo => {
const sendRequest = user => {
const TUPAIA_ADMIN_EMAIL_ADDRESS = requireEnv('TUPAIA_ADMIN_EMAIL_ADDRESS');

const emailText = `${userInfo} has requested to delete their account`;
return sendEmail(TUPAIA_ADMIN_EMAIL_ADDRESS, {
subject: 'Tupaia Account Deletion Request',
text: emailText,
templateName: 'deleteAccount',
templateContext: {
user,
},
});
};

export const deleteAccount = async (req, res) => {
const { userId: requestUserId, params, models } = req;
const userId = requestUserId || params.userId;
const userInfo = await getUserInfoInString(userId, models);
await sendRequest(userInfo);
const user = await models.user.findById(userId);
await sendRequest(user);

respond(res, { message: 'Account deletion requested.' }, 200);
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,37 @@ Any responses not listed here have been successfully imported, and can be remove
return message;
};

const constructMessage = responseBody => {
const constructTemplateContextMessage = responseBody => {
const { error, failures = [] } = responseBody;

// global error, whole import has failed
if (error) {
return `Unfortunately, your survey response import failed.
return {
message: `Unfortunately, your survey response import failed.
${error}`;
${error}`,

title: 'Import Failed',
};
}

// at least one response failed, but import finished processing
if (failures.length > 0) {
return constructFailuresMessage(failures);
return {
message: constructFailuresMessage(failures),
title: 'Import Finished with Failures',
};
}

return 'Your survey responses have been successfully imported.';
return {
message: 'Your survey responses have been successfully imported.',
title: 'Import Successful',
};
};

export const constructImportEmail = responseBody => {
return { subject: 'Tupaia Survey Response Import', message: constructMessage(responseBody) };
return {
subject: 'Tupaia Survey Response Import',
templateContext: constructTemplateContextMessage(responseBody),
};
};
38 changes: 19 additions & 19 deletions packages/central-server/src/apiV2/requestCountryAccess.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import { requireEnv, respond, UnauthenticatedError, ValidationError } from '@tupaia/utils';
import { sendEmail } from '@tupaia/server-utils';
import { getTokenClaimsFromBearerAuth } from '@tupaia/auth';
import { getUserInfoInString } from './utilities';

const checkUserPermission = (req, userId) => {
const authHeader = req.headers.authorization;
Expand All @@ -17,26 +16,28 @@ const checkUserPermission = (req, userId) => {
}
};

const sendRequest = (userInfo, countryNames, message, project) => {
const sendRequest = async (userId, models, countries, message, project) => {
const user = await models.user.findById(userId);

const TUPAIA_ADMIN_EMAIL_ADDRESS = requireEnv('TUPAIA_ADMIN_EMAIL_ADDRESS');

const emailText = `
${userInfo} has requested access to countries:
${countryNames.map(n => ` - ${n}`).join('\n')}
${
project
? `
For the project ${project.code} (linked to permission groups: ${project.permission_groups.join(
', ',
)})
`
: ''
}
With the message: '${message}'
`;
return sendEmail(TUPAIA_ADMIN_EMAIL_ADDRESS, {
subject: 'Tupaia Country Access Request',
text: emailText,
templateName: 'requestCountryAccess',
templateContext: {
title: 'You have a new country request!',
cta: {
url: `${process.env.ADMIN_PANEL_URL}/users/access-requests/${userId}`,
text: 'Approve or deny request',
},
countries,
message,
project: {
code: project.code,
permissionGroups: project.permission_groups.join(', '),
},
user,
},
});
};

Expand Down Expand Up @@ -79,13 +80,12 @@ export const requestCountryAccess = async (req, res) => {
} catch (error) {
throw new UnauthenticatedError(error.message);
}
const userInfo = await getUserInfoInString(userId, models);

const project = projectCode && (await models.project.findOne({ code: projectCode }));
await createAccessRequests(models, userId, entities, message, project);

const countryNames = entities.map(e => e.name);
await sendRequest(userInfo, countryNames, message, project);
await sendRequest(userId, models, countryNames, message, project);

respond(res, { message: 'Country access requested' }, 200);
};
21 changes: 11 additions & 10 deletions packages/central-server/src/apiV2/requestPasswordReset.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,18 @@ export const requestPasswordReset = async (req, res) => {
resetPasswordUrl || process.env.TUPAIA_FRONT_END_URL
}/reset-password?passwordResetToken={token}`;
const resetUrl = passwordResetUrl.replace('{token}', token);
const emailText = `Dear ${user.fullName},

You are receiving this email because someone requested a password reset for
this user account on Tupaia.org. To reset your password follow the link below.
${resetUrl}
If you believe this email was sent to you in error, please contact us immediately at
admin@tupaia.org.`;

sendEmail(user.email, { subject: 'Password reset on Tupaia.org', text: emailText });
sendEmail(user.email, {
subject: 'Password reset on Tupaia.org',
templateName: 'passwordReset',
templateContext: {
userName: user.fullName,
cta: {
text: 'Reset your password',
url: resetUrl,
},
},
});

respond(res, {
success: true,
Expand Down
38 changes: 20 additions & 18 deletions packages/central-server/src/apiV2/utilities/emailVerification.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,46 +10,48 @@ import { requireEnv } from '@tupaia/utils';
const EMAILS = {
tupaia: {
subject: 'Tupaia email verification',
body: (token, url) =>
'Thank you for registering with tupaia.org.\n' +
'Please click on the following link to register your email address.\n\n' +
`${url}/verify-email?verifyEmailToken=${token}\n\n` +
'If you believe this email was sent to you in error, please contact us immediately at admin@tupaia.org.\n',
platformName: 'tupaia.org',
},
datatrak: {
subject: 'Tupaia Datatrak email verification',
body: (token, url) =>
'Thank you for registering with datatrak.tupaia.org.\n' +
'Please click on the following link to register your email address.\n\n' +
`${url}/verify-email?verifyEmailToken=${token}\n\n` +
'If you believe this email was sent to you in error, please contact us immediately at admin@tupaia.org.\n',
platformName: 'datatrak.tupaia.org',
},
lesmis: {
subject: 'LESMIS email verification',
body: (token, url) =>
'Thank you for registering with lesmis.la.\n' +
'Please click on the following link to register your email address.\n\n' +
`${url}/en/verify-email?verifyEmailToken=${token}\n\n` +
'If you believe this email was sent to you in error, please contact us immediately at admin@tupaia.org.\n',
signOff: 'Best regards,\nThe LESMIS Team',
platformName: 'lesmis.la',
},
};

export const sendEmailVerification = async user => {
const token = encryptPassword(user.email + user.password_hash, user.password_salt);
const platform = user.primary_platform ? user.primary_platform : 'tupaia';
const { subject, body, signOff } = EMAILS[platform];
const { subject, signOff, platformName } = EMAILS[platform];
const TUPAIA_FRONT_END_URL = requireEnv('TUPAIA_FRONT_END_URL');
const LESMIS_FRONT_END_URL = requireEnv('LESMIS_FRONT_END_URL');
const DATATRAK_FRONT_END_URL = requireEnv('DATATRAK_FRONT_END_URL');

const url = {
tupaia: TUPAIA_FRONT_END_URL,
datatrak: DATATRAK_FRONT_END_URL,
lesmis: LESMIS_FRONT_END_URL,
lesmis: `${LESMIS_FRONT_END_URL}/en`,
}[platform];

return sendEmail(user.email, { subject, text: body(token, url), signOff });
const fullUrl = `${url}/verify-email?verifyEmailToken=${token}`;

return sendEmail(user.email, {
subject,
signOff,
templateName: 'verifyEmail',
templateContext: {
title: 'Verify your email address',
platform: platformName,
cta: {
text: 'Verify email',
url: fullUrl,
},
},
});
};

export const verifyEmailHelper = async (models, searchCondition, token) => {
Expand Down
27 changes: 13 additions & 14 deletions packages/central-server/src/database/models/UserEntityPermission.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,13 @@ export class UserEntityPermissionModel extends CommonUserEntityPermissionModel {
const EMAILS = {
tupaia: {
subject: 'Tupaia Permission Granted',
body: (userName, permissionGroupName, entityName) =>
`Hi ${userName},\n\n` +
`This is just to let you know that you've been added to the ${permissionGroupName} access group for ${entityName}. ` +
'This allows you to collect surveys through the Tupaia data collection app, and to see reports and map overlays on Tupaia.org.\n\n' +
"Please note that you'll need to log out and then log back in to get access to the new permissions.\n\n" +
'Have fun exploring Tupaia, and feel free to get in touch if you have any questions.\n',
description:
'This allows you to collect surveys through the Tupaia data collection app, and to see reports and map overlays on <a href="https://tupaia.org">Tupaia.org.</a>',
},
lesmis: {
subject: 'LESMIS Permission Granted',
body: (userName, permissionGroupName, entityName) =>
`Hi ${userName},\n\n` +
`This is just to let you know that you've been added to the ${permissionGroupName} access group for ${entityName}. ` +
'This allows you to see reports and map overlays on lesmis.la.\n\n' +
"Please note that you'll need to log out and then log back in to get access to the new permissions.\n\n" +
'Feel free to get in touch if you have any questions.\n',
description:
'This allows you to see reports and map overlays on <a href="https://lesmis.la">lesmis.la.</a>',
signOff: 'Best regards,\nThe LESMIS Team',
},
};
Expand All @@ -55,12 +47,19 @@ async function onUpsertSendPermissionGrantEmail(
const permissionGroup = await models.permissionGroup.findById(newRecord.permission_group_id);
const platform = user.primary_platform ? user.primary_platform : 'tupaia';

const { subject, body, signOff } = EMAILS[platform];
const { subject, description, signOff } = EMAILS[platform];

sendEmail(user.email, {
subject,
text: body(user.first_name, permissionGroup.name, entity.name),
signOff,
templateName: 'permissionGranted',
templateContext: {
title: 'Permission Granted',
description,
userName: user.first_name,
entityName: entity.name,
permissionGroupName: permissionGroup.name,
},
});
}

Expand Down
34 changes: 25 additions & 9 deletions packages/server-boilerplate/src/utils/emailAfterTimeout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,39 @@ import { sendEmail } from '@tupaia/server-utils';
import { UserAccount } from '@tupaia/types';
import { respond } from '@tupaia/utils';

type TemplateContext = {
title: string;
message: string;
cta?: {
text: string;
url: string;
};
};

type ConstructEmailFromResponseT = (
responseBody: any,
req: any,
) => Promise<{
subject: string;
message: string;
attachments?: { filename: string; content: Buffer }[];
templateContext: TemplateContext;
}>;

const sendResponseAsEmail = (
user: UserAccount,
subject: string,
message: string,
templateContext: TemplateContext,
attachments?: { filename: string; content: Buffer }[],
) => {
const text = `Hi ${user.first_name},
${message}
`;
sendEmail(user.email, { subject, text, attachments });
sendEmail(user.email, {
subject,
attachments,
templateName: 'emailAfterTimeout',
templateContext: {
...templateContext,
userName: user.first_name,
},
});
};

const setupEmailResponse = async (
Expand Down Expand Up @@ -54,8 +67,11 @@ const setupEmailResponse = async (
// override the respond function so that when the endpoint handler finishes (or throws an error),
// the response is sent via email
res.overrideRespond = async (responseBody: any) => {
const { subject, message, attachments } = await constructEmailFromResponse(responseBody, req);
sendResponseAsEmail(user, subject, message, attachments);
const { subject, attachments, templateContext } = await constructEmailFromResponse(
responseBody,
req,
);
sendResponseAsEmail(user, subject, templateContext, attachments);
};
};

Expand Down
7 changes: 5 additions & 2 deletions packages/server-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "rm -rf dist && npm run --prefix ../../ package:build:ts",
"copy-templates": "copyfiles -u 1 src/email/templates/**/**/* ./dist",
"build": "rm -rf dist && npm run --prefix ../../ package:build:ts && npm run copy-templates",
"build-dev": "npm run build",
"lint": "yarn package:lint",
"lint:fix": "yarn lint --fix",
Expand All @@ -25,13 +26,15 @@
"@aws-sdk/lib-storage": "^3.348.0",
"@tupaia/utils": "workspace:*",
"cookie": "^0.5.0",
"copyfiles": "^2.4.1",
"dotenv": "^16.4.5",
"handlebars": "^4.7.8",
"nodemailer": "^6.9.12",
"puppeteer": "^15.4.0",
"sha256": "^0.2.0"
},
"devDependencies": {
"@types/nodemailer": "^6.4.13",
"@types/nodemailer": "^6.4.15",
"@types/sha256": "^0.2.2"
}
}
Loading

0 comments on commit 4ecbadb

Please sign in to comment.