Skip to content

Commit

Permalink
Feat/send ticket (#261)
Browse files Browse the repository at this point in the history
* feat: new email system

* feat: add General Mail route

* feat: back to street legal

* feat: add sendTemplate

* fix: it's better with .fr

* fix: update email template topics

* fix: add return and fix preview undefined

* fix: change minor mail target

* fix: lint

* fix: lint2

* test: disable mail tests

* test: disable other mail test

---------

Co-authored-by: Noé Landré <[email protected]>
Co-authored-by: Arthur Dodin <[email protected]>
Co-authored-by: Antoine D <[email protected]>
  • Loading branch information
4 people authored Nov 26, 2024
1 parent 9ca2dcc commit 3c8d058
Show file tree
Hide file tree
Showing 40 changed files with 1,019 additions and 895 deletions.
14 changes: 8 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ DATABASE_URL=mysql://root:root@localhost/arena
# Used in mail templates
ARENA_WEBSITE=http://localhost:8080

# SMTP server address (smtp://user:password@host(:port)?)
# You can use Nodemailer App (https://nodemailer.com/app/) or mailtrap.io to test the emails
SMTP_URI=smtp://user:pass@address:25/?pool=true&maxConnections=1
GMAIL=false
GMAIL_USERNAME=[email protected]
GMAIL_PASSWORD=
MAX_MAILS_PER_BATCH=100
EMAIL_HOST=
EMAIL_PORT=
EMAIL_SECURE=
EMAIL_SENDER_NAME=
EMAIL_SENDER_ADDRESS=
EMAIL_AUTH_USER=
EMAIL_AUTH_PASSWORD=
EMAIL_REJECT_UNAUTHORIZED=

# Used to give a discount on tickets
PARTNER_MAILS=utt.fr,utc.fr,utbm.fr
Expand Down
4 changes: 4 additions & 0 deletions src/controllers/admin/emails/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { Router } from 'express';
import getMails from './getMails';
import send from './send';
import sendTemplate from './sendTemplate';
import sendCustom from './sendCustom';

const router = Router();

router.get('/', getMails);
router.post('/', send);
router.post('/template', sendTemplate);
router.post('/custom', sendCustom);

export default router;
121 changes: 7 additions & 114 deletions src/controllers/admin/emails/send.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
/* eslint-disable unicorn/no-nested-ternary */
import { NextFunction, Request, Response } from 'express';
import Joi from 'joi';
import { badRequest, created } from '../../../utils/responses';
import { hasPermission } from '../../../middlewares/authentication';
import { Error as ApiError, MailQuery } from '../../../types';
import { Error as ApiError, MailGeneralQuery } from '../../../types';
import { validateBody } from '../../../middlewares/validation';
import { sendEmail, SerializedMail } from '../../../services/email';
import { serialize } from '../../../services/email/serializer';
import database from '../../../services/database';
import { sendGeneralMail } from '../../../services/email';
import { getRequestInfo } from '../../../utils/users';

export default [
Expand All @@ -16,23 +12,7 @@ export default [
validateBody(
Joi.object({
preview: Joi.boolean().default(false),
locked: Joi.boolean().optional(),
tournamentId: Joi.string().optional(),
subject: Joi.string().required(),
highlight: Joi.object({
title: Joi.string().required(),
intro: Joi.string().required(),
}).required(),
reason: Joi.string().optional(),
content: Joi.array()
.items(
Joi.object({
title: Joi.string().required(),
components: Joi.array().required(),
}).required(),
)
.required()
.error(new Error(ApiError.MalformedMailBody)),
generalMail: Joi.string().required(),
}).error(
(errors) =>
errors.find((error) => error.message === ApiError.MalformedMailBody) ?? new Error(ApiError.InvalidMailOptions),
Expand All @@ -42,100 +22,13 @@ export default [
// Controller
async (request: Request, response: Response, next: NextFunction) => {
try {
const mail = request.body as MailQuery;
const mail = request.body as MailGeneralQuery;
const { user } = getRequestInfo(response);

// Find mail adresses to send the mail to
const mails = await database.user
.findMany({
where: {
registerToken: null,
email: {
not: null,
},
...(mail.preview
? {
id: user.id,
}
: {
team: {
...(mail.locked
? {
NOT: {
lockedAt: null,
},
}
: mail.locked === false
? { lockedAt: null }
: {}),
tournamentId: mail.tournamentId,
},
}),
},
select: {
email: true,
},
})
.then((mailWrappers) => mailWrappers.map((mailWrapper) => mailWrapper.email));
const nbMailSent = await sendGeneralMail(mail.generalMail, mail.preview ? user : null);

// Parallelize mails as it may take time
// As every mail is generated on a user basis, we cannot catch
// a mail format error as simply as usual. This is the reason
// why we track the status of all sent mails.
// If all mails are errored due to invalid syntax, it is most
// likely that the sender did a mistake.
const outgoingMails = await Promise.allSettled(
mails.map(async (adress) => {
let mailContent: SerializedMail;
try {
mailContent = await serialize({
sections: mail.content,
reason: mail.reason,
title: {
banner: mail.subject,
highlight: mail.highlight.title,
short: mail.highlight.intro,
topic: mail.preview ? `[PREVIEW]: ${mail.subject}` : mail.subject,
},
receiver: adress,
});
} catch {
throw ApiError.MalformedMailBody;
}
return sendEmail(mailContent);
}),
);

// Counts mail statuses
const results = outgoingMails.reduce(
(result, state) => {
if (state.status === 'fulfilled')
return {
...result,
delivered: result.delivered + 1,
};
if (state.reason === ApiError.MalformedMailBody)
return {
...result,
malformed: result.malformed + 1,
};
return {
...result,
undelivered: result.undelivered + 1,
};
},
{ malformed: 0, delivered: 0, undelivered: 0 },
);

// Respond to the request with the appropriate response code
if (results.malformed && !results.delivered && !results.undelivered)
return badRequest(response, ApiError.MalformedMailBody);

if (results.delivered || !results.undelivered) return created(response, results);

throw (<PromiseRejectedResult>(
outgoingMails.find((result) => result.status === 'rejected' && result.reason !== ApiError.MalformedMailBody)
)).reason;
// TODO: change return to a created response
return response.json({ message: `Sent ${nbMailSent} emails` });
} catch (error) {
return next(error);
}
Expand Down
142 changes: 142 additions & 0 deletions src/controllers/admin/emails/sendCustom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/* eslint-disable unicorn/no-nested-ternary */
import { NextFunction, Request, Response } from 'express';
import Joi from 'joi';
import { badRequest, created } from '../../../utils/responses';
import { hasPermission } from '../../../middlewares/authentication';
import { Error as ApiError, MailQuery } from '../../../types';
import { validateBody } from '../../../middlewares/validation';
import { sendEmail, SerializedMail, serialize } from '../../../services/email';
import database from '../../../services/database';
import { getRequestInfo } from '../../../utils/users';

export default [
// Middlewares
...hasPermission(),
validateBody(
Joi.object({
preview: Joi.boolean().default(false),
locked: Joi.boolean().optional(),
tournamentId: Joi.string().optional(),
subject: Joi.string().required(),
highlight: Joi.object({
title: Joi.string().required(),
intro: Joi.string().required(),
}).required(),
reason: Joi.string().optional(),
content: Joi.array()
.items(
Joi.object({
title: Joi.string().required(),
components: Joi.array().required(),
}).required(),
)
.required()
.error(new Error(ApiError.MalformedMailBody)),
}).error(
(errors) =>
errors.find((error) => error.message === ApiError.MalformedMailBody) ?? new Error(ApiError.InvalidMailOptions),
),
),

// Controller
async (request: Request, response: Response, next: NextFunction) => {
try {
const mail = request.body as MailQuery;
const { user } = getRequestInfo(response);

// Find mail adresses to send the mail to
const mails = await database.user
.findMany({
where: {
registerToken: null,
email: {
not: null,
},
...(mail.preview
? {
id: user.id,
}
: {
team: {
...(mail.locked
? {
NOT: {
lockedAt: null,
},
}
: mail.locked === false
? { lockedAt: null }
: {}),
tournamentId: mail.tournamentId,
},
}),
},
select: {
email: true,
},
})
.then((mailWrappers) => mailWrappers.map((mailWrapper) => mailWrapper.email));

// Parallelize mails as it may take time
// As every mail is generated on a user basis, we cannot catch
// a mail format error as simply as usual. This is the reason
// why we track the status of all sent mails.
// If all mails are errored due to invalid syntax, it is most
// likely that the sender did a mistake.
const outgoingMails = await Promise.allSettled(
mails.map(async (adress) => {
let mailContent: SerializedMail;
try {
mailContent = await serialize({
sections: mail.content,
reason: mail.reason,
title: {
banner: mail.subject,
highlight: mail.highlight.title,
short: mail.highlight.intro,
topic: mail.preview ? `[PREVIEW]: ${mail.subject}` : mail.subject,
},
receiver: adress,
});
} catch {
throw ApiError.MalformedMailBody;
}
return sendEmail(mailContent);
}),
);

// Counts mail statuses
const results = outgoingMails.reduce(
(result, state) => {
if (state.status === 'fulfilled')
return {
...result,
delivered: result.delivered + 1,
};
if (state.reason === ApiError.MalformedMailBody)
return {
...result,
malformed: result.malformed + 1,
};
return {
...result,
undelivered: result.undelivered + 1,
};
},
{ malformed: 0, delivered: 0, undelivered: 0 },
);

// Respond to the request with the appropriate response code
if (results.malformed && !results.delivered && !results.undelivered)
return badRequest(response, ApiError.MalformedMailBody);

if (results.delivered || !results.undelivered) return created(response, results);

throw (<PromiseRejectedResult>(
outgoingMails.find((result) => result.status === 'rejected' && result.reason !== ApiError.MalformedMailBody)
)).reason;
} catch (error) {
return next(error);
}
},
];
38 changes: 38 additions & 0 deletions src/controllers/admin/emails/sendTemplate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NextFunction, Request, Response } from 'express';
import Joi from 'joi';
import { hasPermission } from '../../../middlewares/authentication';
import { Error as ApiError, MailTemplateQuery } from '../../../types';
import { validateBody } from '../../../middlewares/validation';
import { sendMailsFromTemplate } from '../../../services/email';
import { getRequestInfo } from '../../../utils/users';

export default [
// Middlewares
...hasPermission(),
validateBody(
Joi.object({
preview: Joi.boolean().default(false),
templateMail: Joi.string().required(),
targets: Joi.array().items(Joi.any()).required(),
}).error(
(errors) =>
errors.find((error) => error.message === ApiError.MalformedMailBody) ?? new Error(ApiError.InvalidMailOptions),
),
),

// Controller
async (request: Request, response: Response, next: NextFunction) => {
try {
const mail = request.body as MailTemplateQuery;
const { user } = getRequestInfo(response);

// TODO: Fix as array depends on the template...
await sendMailsFromTemplate(mail.templateMail, mail.preview ? [user] : mail.targets);

// TODO: change return to a created response
return response.json({ message: `Sent ${mail.targets.length} emails` });
} catch (error) {
return next(error);
}
},
];
4 changes: 2 additions & 2 deletions src/controllers/auth/askResetPassword.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as Sentry from '@sentry/node';
import { isNotAuthenticated } from '../../middlewares/authentication';
import { validateBody } from '../../middlewares/validation';
import { fetchUser, generateResetToken } from '../../operations/user';
import { sendPasswordReset } from '../../services/email';
import { sendMailsFromTemplate } from '../../services/email';
import { noContent } from '../../utils/responses';
import * as validators from '../../utils/validators';
import logger from '../../utils/logger';
Expand Down Expand Up @@ -37,7 +37,7 @@ export default [
// Don't wait for mail to be sent as it could take time
// We suppose here that is will pass. If it is not the case, error is
// reported through Sentry and staff may resend the email manually
sendPasswordReset(userWithToken).catch((error) => {
sendMailsFromTemplate('passwordreset', [userWithToken]).catch((error) => {
logger.error(error);
Sentry.captureException(error, {
user: {
Expand Down
Loading

0 comments on commit 3c8d058

Please sign in to comment.