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

Merge master #262

Merged
merged 2 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
[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
Loading