Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tasks): RN-1372: Email templating #5830

Merged
merged 14 commits into from
Aug 8, 2024
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 @@ -13,21 +13,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 @@ -51,12 +43,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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

love it

"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
Loading