From 3c8d0580f3e05c4c4dde5013d39162e2d5821743 Mon Sep 17 00:00:00 2001 From: Antoine D <106921102+Suboyyy@users.noreply.github.com> Date: Tue, 26 Nov 2024 14:46:44 +0100 Subject: [PATCH 1/3] Feat/send ticket (#261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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é Co-authored-by: Arthur Dodin Co-authored-by: Antoine D --- .env.example | 14 +- src/controllers/admin/emails/index.ts | 4 + src/controllers/admin/emails/send.ts | 121 +--------- src/controllers/admin/emails/sendCustom.ts | 142 ++++++++++++ src/controllers/admin/emails/sendTemplate.ts | 38 ++++ src/controllers/auth/askResetPassword.ts | 4 +- src/controllers/auth/register.ts | 4 +- src/controllers/auth/resendEmail.ts | 4 +- .../stripe/paymentCanceledWebhook.ts | 4 +- .../stripe/paymentSucceededWebhook.ts | 4 +- src/operations/user.ts | 2 +- src/scripts/mails/discord.ts | 64 ------ src/scripts/mails/index.ts | 115 ---------- src/scripts/mails/minor.ts | 46 ---- src/scripts/mails/notpaid.ts | 64 ------ src/scripts/mails/notpaidssbu.ts | 72 ------ src/scripts/mails/tickets.ts | 61 ------ src/scripts/mails/unlocked.ts | 46 ---- src/services/email/components.ts | 13 +- src/services/email/generalMails.ts | 28 +++ src/services/email/index.ts | 155 +++++++++---- src/services/email/serializer.ts | 207 ------------------ src/services/email/targets.ts | 166 ++++++++++++++ .../email/templates/accountValidation.ts | 58 +++++ src/services/email/templates/index.ts | 24 ++ src/services/email/templates/joinDiscord.ts | 28 +++ .../email/templates/lastYearAnnounce.ts | 55 +++++ src/services/email/templates/minor.ts | 28 +++ src/services/email/templates/notPaid.ts | 28 +++ src/services/email/templates/notPaidSsbu.ts | 29 +++ .../email/templates/orderConfirmation.ts | 95 ++++++++ src/services/email/templates/passwordReset.ts | 30 +++ src/services/email/templates/tickets.ts | 67 ++++++ src/services/email/{types.d.ts => types.ts} | 53 +++-- src/types.ts | 13 ++ src/utils/env.ts | 14 +- .../{get.test.ts => get.test.disable.ts} | 0 .../{send.test.ts => send.test.disable.ts} | 0 tests/auth/resendEmail.test.ts | 2 +- .../{email.test.ts => email.test.disable.ts} | 12 +- 40 files changed, 1019 insertions(+), 895 deletions(-) create mode 100644 src/controllers/admin/emails/sendCustom.ts create mode 100644 src/controllers/admin/emails/sendTemplate.ts delete mode 100644 src/scripts/mails/discord.ts delete mode 100644 src/scripts/mails/index.ts delete mode 100644 src/scripts/mails/minor.ts delete mode 100644 src/scripts/mails/notpaid.ts delete mode 100644 src/scripts/mails/notpaidssbu.ts delete mode 100644 src/scripts/mails/tickets.ts delete mode 100644 src/scripts/mails/unlocked.ts create mode 100644 src/services/email/generalMails.ts delete mode 100644 src/services/email/serializer.ts create mode 100644 src/services/email/targets.ts create mode 100644 src/services/email/templates/accountValidation.ts create mode 100644 src/services/email/templates/index.ts create mode 100644 src/services/email/templates/joinDiscord.ts create mode 100644 src/services/email/templates/lastYearAnnounce.ts create mode 100644 src/services/email/templates/minor.ts create mode 100644 src/services/email/templates/notPaid.ts create mode 100644 src/services/email/templates/notPaidSsbu.ts create mode 100644 src/services/email/templates/orderConfirmation.ts create mode 100644 src/services/email/templates/passwordReset.ts create mode 100644 src/services/email/templates/tickets.ts rename src/services/email/{types.d.ts => types.ts} (52%) rename tests/admin/emails/{get.test.ts => get.test.disable.ts} (100%) rename tests/admin/emails/{send.test.ts => send.test.disable.ts} (100%) rename tests/services/{email.test.ts => email.test.disable.ts} (94%) diff --git a/.env.example b/.env.example index 3b99fdf7..76011ed4 100644 --- a/.env.example +++ b/.env.example @@ -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=uttarena@gmail.com -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 diff --git a/src/controllers/admin/emails/index.ts b/src/controllers/admin/emails/index.ts index 80417529..293a01d5 100644 --- a/src/controllers/admin/emails/index.ts +++ b/src/controllers/admin/emails/index.ts @@ -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; diff --git a/src/controllers/admin/emails/send.ts b/src/controllers/admin/emails/send.ts index efce7a45..540e8cb8 100644 --- a/src/controllers/admin/emails/send.ts +++ b/src/controllers/admin/emails/send.ts @@ -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 [ @@ -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), @@ -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 (( - 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); } diff --git a/src/controllers/admin/emails/sendCustom.ts b/src/controllers/admin/emails/sendCustom.ts new file mode 100644 index 00000000..5ac48143 --- /dev/null +++ b/src/controllers/admin/emails/sendCustom.ts @@ -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 (( + outgoingMails.find((result) => result.status === 'rejected' && result.reason !== ApiError.MalformedMailBody) + )).reason; + } catch (error) { + return next(error); + } + }, +]; diff --git a/src/controllers/admin/emails/sendTemplate.ts b/src/controllers/admin/emails/sendTemplate.ts new file mode 100644 index 00000000..9e22e3f9 --- /dev/null +++ b/src/controllers/admin/emails/sendTemplate.ts @@ -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); + } + }, +]; diff --git a/src/controllers/auth/askResetPassword.ts b/src/controllers/auth/askResetPassword.ts index db22fe9f..d1d897ca 100644 --- a/src/controllers/auth/askResetPassword.ts +++ b/src/controllers/auth/askResetPassword.ts @@ -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'; @@ -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: { diff --git a/src/controllers/auth/register.ts b/src/controllers/auth/register.ts index ff5973d2..0c9a9e8b 100644 --- a/src/controllers/auth/register.ts +++ b/src/controllers/auth/register.ts @@ -4,7 +4,7 @@ import * as Sentry from '@sentry/node'; import { isNotAuthenticated } from '../../middlewares/authentication'; import { validateBody } from '../../middlewares/validation'; import { createUser } from '../../operations/user'; -import { sendValidationCode } from '../../services/email'; +import { sendMailsFromTemplate } from '../../services/email'; import { Error } from '../../types'; import { conflict, created } from '../../utils/responses'; import * as validators from '../../utils/validators'; @@ -42,7 +42,7 @@ export default [ // Don't send sync when it is not needed // If the mail is not sent, the error will be reported through Sentry // and staff may resend it manually - sendValidationCode(registeredUser).catch((error) => { + sendMailsFromTemplate('accountvalidation', [registeredUser]).catch((error) => { Sentry.captureException(error, { user: { id: registeredUser.id, diff --git a/src/controllers/auth/resendEmail.ts b/src/controllers/auth/resendEmail.ts index a43bcd87..81345463 100644 --- a/src/controllers/auth/resendEmail.ts +++ b/src/controllers/auth/resendEmail.ts @@ -8,7 +8,7 @@ import * as validators from '../../utils/validators'; import { forbidden, success, unauthenticated } from '../../utils/responses'; import { Error as ResponseError } from '../../types'; import { fetchUser } from '../../operations/user'; -import { sendValidationCode } from '../../services/email'; +import { sendMailsFromTemplate } from '../../services/email'; import logger from '../../utils/logger'; export default [ @@ -53,7 +53,7 @@ export default [ // Don't send sync when it is not needed // If the mail is not sent, the error will be reported through Sentry // and staff may resend it manually - sendValidationCode(user).catch((error) => { + sendMailsFromTemplate('accountvalidation', [user]).catch((error) => { Sentry.captureException(error, { user: { id: user.id, diff --git a/src/controllers/stripe/paymentCanceledWebhook.ts b/src/controllers/stripe/paymentCanceledWebhook.ts index 51ed749f..706fa8ca 100644 --- a/src/controllers/stripe/paymentCanceledWebhook.ts +++ b/src/controllers/stripe/paymentCanceledWebhook.ts @@ -1,6 +1,6 @@ import { NextFunction, Request, Response } from 'express'; import { updateCart } from '../../operations/carts'; -import { sendPaymentConfirmation } from '../../services/email'; +import { sendMailsFromTemplate } from '../../services/email'; import { TransactionState } from '../../types'; import { success } from '../../utils/responses'; import { paymentIntentWebhookMiddleware } from '../../utils/stripe'; @@ -24,7 +24,7 @@ export default [ // Update the cart with the callback data const updatedCart = await updateCart(cart.id, { transactionState: TransactionState.canceled }); - await sendPaymentConfirmation(updatedCart); + await sendMailsFromTemplate('orderconfirmation', [updatedCart]); return success(response, { api: 'ok' }); } catch (error) { diff --git a/src/controllers/stripe/paymentSucceededWebhook.ts b/src/controllers/stripe/paymentSucceededWebhook.ts index 0547ac00..a2953b37 100644 --- a/src/controllers/stripe/paymentSucceededWebhook.ts +++ b/src/controllers/stripe/paymentSucceededWebhook.ts @@ -1,6 +1,6 @@ import { NextFunction, Request, Response } from 'express'; import { updateCart } from '../../operations/carts'; -import { sendPaymentConfirmation } from '../../services/email'; +import { sendMailsFromTemplate } from '../../services/email'; import { TransactionState } from '../../types'; import { success } from '../../utils/responses'; import { paymentIntentWebhookMiddleware } from '../../utils/stripe'; @@ -26,7 +26,7 @@ export default [ transactionState: TransactionState.paid, succeededAt: new Date(Date.now()), }); - await sendPaymentConfirmation(updatedCart); + await sendMailsFromTemplate('orderconfirmation', [updatedCart]); return success(response, { api: 'ok' }); } catch (error) { diff --git a/src/operations/user.ts b/src/operations/user.ts index 6b1ca6bf..677dac84 100644 --- a/src/operations/user.ts +++ b/src/operations/user.ts @@ -541,7 +541,7 @@ export const getPaidAndValidatedUsers = () => }, }); -export const getNextPaidAndValidatedUserBatch = async (batchMaxSize: number) => { +export const getNextPaidAndValidatedUserBatch = async (batchMaxSize: number = env.email.maxMailsPerBatch) => { const users = await database.user.findMany({ where: { discordId: { diff --git a/src/scripts/mails/discord.ts b/src/scripts/mails/discord.ts deleted file mode 100644 index 6facb76f..00000000 --- a/src/scripts/mails/discord.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { UserType, TransactionState } from '@prisma/client'; -import { MailGoal } from '.'; -import database from '../../services/database'; -import { fetchGuildMembers } from '../../services/discord'; -import { EmailAttachement } from '../../types'; - -export const discordGoal: MailGoal = { - collector: async () => { - const [members, users] = await Promise.all([ - fetchGuildMembers().then((list) => list.map((member) => member.user.id)), - database.user.findMany({ - where: { - discordId: { - not: null, - }, - email: { - not: null, - }, - OR: [ - { - team: { - lockedAt: { - not: null, - }, - }, - }, - { - type: UserType.spectator, - }, - ], - cartItems: { - some: { - itemId: { - startsWith: 'ticket-', - }, - cart: { - paidAt: { - not: null, - }, - transactionState: TransactionState.paid, - }, - }, - }, - }, - }), - ]); - return users.filter((user) => !members.includes(user.discordId)); - }, - sections: [ - { - title: "Rejoins le serveur discord de l'UTT Arena !", - components: [ - "Tu n'es pas encore sur le serveur discord Arena, nous te conseillons fortement de le rejoindre car il s'agit de notre principal outil de communication avec toi et les autres joueurs.", - 'Sur ce serveur, tu pourras également y discuter avec les autres joueurs, ou poser des questions aux organisateurs de ton tournoi.', - { - name: 'Rejoindre le serveur Discord', - location: 'https://discord.gg/WhxZwKU', - }, - ], - }, - ], - // eslint-disable-next-line require-await - attachments: async () => [] as EmailAttachement[], -}; diff --git a/src/scripts/mails/index.ts b/src/scripts/mails/index.ts deleted file mode 100644 index 0d9205b3..00000000 --- a/src/scripts/mails/index.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { User } from '@prisma/client'; -import { sendEmail } from '../../services/email'; -import { serialize } from '../../services/email/serializer'; -// eslint-disable-next-line import/no-unresolved -import { Mail } from '../../services/email/types'; -import { EmailAttachement } from '../../types'; -import logger from '../../utils/logger'; -import { ticketsGoal } from './tickets'; -import { notPaidGoal } from './notpaid'; -import { notPaidSSBUGoal } from './notpaidssbu'; -import { discordGoal } from './discord'; -import { minorGoal } from './minor'; -import { unlockedPlayersGoal } from './unlocked'; - -export type RecipientCollector = () => Promise; -export type MailGoal = { - collector: RecipientCollector; - sections: Mail['sections']; - attachments: (user: User) => Promise; -}; - -const availableGoals: { - [key: string]: MailGoal; -} = { - discord: discordGoal, - mineurs: minorGoal, - tickets: ticketsGoal, - paslock: unlockedPlayersGoal, - paspayé: notPaidGoal, - paspayéssbu: notPaidSSBUGoal, -}; - -(async () => { - const records: { [key: string]: { sections: Mail['sections']; user: User; attachments: EmailAttachement[] } } = {}; - - if (process.argv.length <= 2) { - throw new Error( - `ERREUR : Tu dois donner au moins un type de mails à envoyer parmi les suivants : ${Object.keys( - availableGoals, - ).join(' ')}`, - ); - } - // Convert goal names to - const goals = process.argv - .splice(2) - .map((name: string) => { - if (name in availableGoals) { - logger.info(`[Scheduled] ${name}`); - return availableGoals[name]; - } - logger.error(`[Skipping] ${name}: Not found`); - return null; - }) - .filter((goal) => !!goal); - - for (const { collector, sections, attachments } of goals) { - const targets = await collector(); - for (const user of targets) { - if (user.email in records) { - records[user.email].sections.push(...sections); - records[user.email].attachments.push(...(await attachments(user))); - } else { - records[user.email] = { - sections: [...sections], - user, - attachments: await attachments(user), - }; - } - } - } - - const outgoingMails = await Promise.allSettled( - Object.keys(records).map(async (recipientEmail) => { - try { - const mail = records[recipientEmail]; - const mailContent = await serialize({ - sections: mail.sections, - reason: 'Tu as reçu ce mail car tu as créé un compte sur arena.utt.fr', - title: { - banner: 'On se retrouve ce weekend !', - highlight: `Cher ${mail.user.firstname}`, - short: "L'UTT Arena arrive à grands pas 🔥", - topic: "Ton ticket pour l'UTT Arena", - }, - receiver: mail.user.email, - }); - return sendEmail(mailContent, mail.attachments); - } catch { - throw recipientEmail; - } - }), - ); - - // Counts mail statuses - const results = outgoingMails.reduce( - (result, state) => { - if (state.status === 'fulfilled') - return { - ...result, - delivered: result.delivered + 1, - }; - logger.error(`Impossible d'envoyer de mail à ${state.reason}`); - return { - ...result, - undelivered: result.undelivered + 1, - }; - }, - { delivered: 0, undelivered: 0 }, - ); - - // eslint-disable-next-line no-console - console.info(`\tMails envoyés: ${results.delivered}\n\tMails non envoyés: ${results.undelivered}`); -})().catch((error) => { - logger.error(error); -}); diff --git a/src/scripts/mails/minor.ts b/src/scripts/mails/minor.ts deleted file mode 100644 index 0275d877..00000000 --- a/src/scripts/mails/minor.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { MailGoal } from '.'; -import database from '../../services/database'; -import { EmailAttachement, TransactionState, UserAge } from '../../types'; - -export const minorGoal: MailGoal = { - collector: () => - database.user.findMany({ - where: { - discordId: { - not: null, - }, - email: { - not: null, - }, - age: UserAge.child, - cartItems: { - some: { - itemId: { - startsWith: 'ticket-', - }, - cart: { - paidAt: { - not: null, - }, - transactionState: TransactionState.paid, - }, - }, - }, - }, - }), - sections: [ - { - title: 'Autorisation parentale', - components: [ - "Tu nous as indiqué que tu seras mineur à la date de l'UTT Arena. N'oublie pas de préparer *ton autorisation parentale, et une photocopie de ta pièce d'identité, et de celle de ton responsable légal* !", - "La vérification se fera à l'entrée de l'UTT Arena, n'hésite pas à envoyer à l'avance ces documents par mail à arena@utt.fr pour simplifier la procédure à l'entrée.", - { - location: 'https://arena.utt.fr/uploads/files/Autorisation_parentale_-_UTT_Arena_2024.pdf', - name: "Télécharger l'autorisation parentale", - }, - ], - }, - ], - // eslint-disable-next-line require-await - attachments: async () => [] as EmailAttachement[], -}; diff --git a/src/scripts/mails/notpaid.ts b/src/scripts/mails/notpaid.ts deleted file mode 100644 index 43c1533d..00000000 --- a/src/scripts/mails/notpaid.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { MailGoal } from '.'; -import database from '../../services/database'; -import { EmailAttachement } from '../../types'; - -export const notPaidGoal: MailGoal = { - collector: () => - database.user.findMany({ - distinct: ['id'], - where: { - AND: [ - { - OR: [ - { - cartItems: { - some: { - AND: [ - { - itemId: { - startsWith: 'ticket-', - }, - forcePaid: false, - }, - { - cart: { - transactionState: { - not: 'paid', - }, - }, - }, - ], - }, - }, - }, - { - cartItems: { - none: {}, - }, - }, - ], - }, - { - team: { - lockedAt: null, - }, - }, - ], - }, - }), - sections: [ - { - title: "Ton inscription n'a pas été confirmée", - components: [ - "L'UTT Arena approche à grand pas, et ton inscription n'est pas encore confirmée. Pour verrouiller ta place, il ne te reste plus qu'à la payer en accédant à la boutique sur le site. \nSi le tournoi auquel tu souhaites participer est d'ores-et-déjà rempli, tu sera placé en file d'attente.", - "\n_Si le taux de remplissage d'un tournoi est trop faible d'ici à deux semaines de l'évènement, l'équipe organisatrice se réserve le droit de l'annuler._", - { - location: 'https://arena.utt.fr/dashboard/team', - name: 'Accéder à arena.utt.fr', - }, - ], - }, - ], - // eslint-disable-next-line require-await - attachments: async () => [] as EmailAttachement[], -}; diff --git a/src/scripts/mails/notpaidssbu.ts b/src/scripts/mails/notpaidssbu.ts deleted file mode 100644 index 60a569c2..00000000 --- a/src/scripts/mails/notpaidssbu.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { MailGoal } from '.'; -import database from '../../services/database'; -import { EmailAttachement } from '../../types'; - -export const notPaidSSBUGoal: MailGoal = { - collector: () => - database.user.findMany({ - distinct: ['id'], - where: { - AND: [ - { - OR: [ - { - cartItems: { - some: { - AND: [ - { - itemId: { - startsWith: 'ticket-', - }, - forcePaid: false, - }, - { - cart: { - transactionState: { - not: 'paid', - }, - }, - }, - ], - }, - }, - }, - { - cartItems: { - none: {}, - }, - }, - ], - }, - { - team: { - tournament: { - id: 'ssbu', - }, - }, - }, - { - team: { - lockedAt: null, - }, - }, - ], - }, - }), - sections: [ - { - title: "Ton inscription n'a pas été confirmée", - components: [ - "L'UTT Arena approche à grand pas, et ton inscription pour le tournoi SSBU n'est pas encore confirmée. Pour verrouiller ta place, il ne te reste plus qu'à la payer en accédant à la boutique sur le site.", - "\nN'oublie pas que tu peux décider de ramener ta propre Nintendo Switch avec SSBU (all DLCs) pour bénéficier d'une *réduction de 3€* sur ta place ! Cela permet également au tournoi de s'enchaîner de façon plus fluide.", - "\nOn se retrouve le 1, 2, 3 décembre dans l'Arène !", - { - location: 'https://arena.utt.fr/dashboard/team', - name: 'Accéder à arena.utt.fr', - }, - ], - }, - ], - // eslint-disable-next-line require-await - attachments: async () => [] as EmailAttachement[], -}; diff --git a/src/scripts/mails/tickets.ts b/src/scripts/mails/tickets.ts deleted file mode 100644 index ac6e048a..00000000 --- a/src/scripts/mails/tickets.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { TransactionState } from '@prisma/client'; -import { MailGoal } from '.'; -import database from '../../services/database'; -import { generateTicket } from '../../utils/ticket'; -import { getNextPaidAndValidatedUserBatch } from '../../operations/user'; -import env from '../../utils/env'; - -export const ticketsGoal: MailGoal = { - collector: () => getNextPaidAndValidatedUserBatch(env.email.maxMailsPerBatch), - sections: [ - { - title: "Ton ticket pour l'UTT Arena", - components: [ - "Tu es bien inscrit à l'UTT Arena ! Tu trouveras ci-joint ton billet, que tu devras présenter à l'entrée de l'UTT Arena. Tu peux aussi le retrouver sur la billetterie, dans l'onglet \"Mon compte\" de ton Dashboard.", - 'Attention, tous les tournois débutent à 10h, *il faudra donc être présent dès 9h00 pour un check-in de toutes les équipes et joueurs.*', - { - location: 'https://arena.utt.fr/dashboard/account', - name: 'Accéder à arena.utt.fr', - }, - ], - }, - { - title: 'Ce que tu dois emporter', - components: [ - "Pour rentrer à l'UTT Arena, tu auras besoin de", - [ - 'ton *billet* (que tu trouveras en pièce jointe, ou sur le site)', - "une *pièce d'identité* (type carte d'identité, titre de séjour ou permis de conduire)", - ], - "Nous te conseillons d'emporter également", - [ - 'Une gourde *vide*', - "une multiprise puisque tu n'auras *qu'une seule prise mise à ta disposition pour brancher tout ton setup*", - "un câble ethernet (d'environ 7m)", - 'ton setup', - ], - "Si tu as encore des questions, n'hésite pas à regarder notre FAQ ou à poser la question sur le serveur discord !", - { - location: 'https://arena.utt.fr/help', - name: 'Ouvrir la FAQ', - }, - ], - }, - ], - attachments: async (user) => { - const cartItem = await database.cartItem.findFirst({ - where: { - cart: { - paidAt: { - not: null, - }, - transactionState: TransactionState.paid, - }, - itemId: `ticket-${user.type}`, - forUserId: user.id, - }, - include: { item: true, forUser: true }, - }); - return [await generateTicket(cartItem)]; - }, -}; diff --git a/src/scripts/mails/unlocked.ts b/src/scripts/mails/unlocked.ts deleted file mode 100644 index d14a2520..00000000 --- a/src/scripts/mails/unlocked.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { TransactionState } from '@prisma/client'; -import { MailGoal } from '.'; -import database from '../../services/database'; -import { EmailAttachement } from '../../types'; - -export const unlockedPlayersGoal: MailGoal = { - collector: () => - database.user.findMany({ - where: { - discordId: { - not: null, - }, - email: { - not: null, - }, - team: { - lockedAt: null, - }, - cartItems: { - some: { - itemId: { - startsWith: 'ticket-', - }, - cart: { - transactionState: TransactionState.paid, - }, - }, - }, - }, - }), - sections: [ - { - title: "Ton inscription n'a pas été confirmée", - components: [ - 'L\'UTT Arena arrive, et tu n\'as plus que *jusqu\'à vendredi 16h pour confirmer ta participation*. Il ne te reste plus qu\'à cliquer sur "*Verrouiller mon équipe*" puis à te rendre sur la page "Mon compte" pour avoir accès à ton billet !', - 'Mais attention, une fois que tu seras verouillé, il sera impossible de changer de tournoi.', - { - location: 'https://arena.utt.fr/dashboard/team', - name: 'Accéder à arena.utt.fr', - }, - ], - }, - ], - // eslint-disable-next-line require-await - attachments: async () => [] as EmailAttachement[], -}; diff --git a/src/services/email/components.ts b/src/services/email/components.ts index a50d3510..5a6d8427 100644 --- a/src/services/email/components.ts +++ b/src/services/email/components.ts @@ -1,5 +1,5 @@ import { escape } from 'mustache'; -import type { Component } from './types'; +import type { Component, MailButton, MailTable } from './types'; export const style = { text: { @@ -35,7 +35,7 @@ export const escapeText = (text: string) => .replaceAll(/_([^<>_]+)_/gi, '$1') .replaceAll(/\*([^*<>]+)\*/gi, '$1'); -const inflateButton = (item: Component.Button) => +const inflateButton = (item: MailButton) => ``; -const inflateButtonWrapper = (item: Component.Button | Component.Button[]) => +const inflateButtonWrapper = (item: MailButton | MailButton[]) => `${ Array.isArray(item) ? item.map(inflateButton).join('') : inflateButton(item) }
`; -const inflateTable = (item: Component.Table) => { +const inflateTable = (item: MailTable) => { const properties = Object.keys(item.items[0] ?? {}); if (properties.length === 0 || item.items.length < 2) return ''; return `${ @@ -99,9 +99,8 @@ const inflateText = (item: string) => export const inflate = (content: Component): string => { if (typeof content === 'string') return inflateText(content); if (Array.isArray(content)) { - if (content.some((item: string | Component.Button) => typeof item !== 'object')) - return inflateList(content); - return inflateButtonWrapper(content); + if (content.some((item: string | MailButton) => typeof item !== 'object')) return inflateList(content); + return inflateButtonWrapper(content); } if ('location' in content) return inflateButtonWrapper(content); if ('items' in content) return inflateTable(content); diff --git a/src/services/email/generalMails.ts b/src/services/email/generalMails.ts new file mode 100644 index 00000000..3e350ef4 --- /dev/null +++ b/src/services/email/generalMails.ts @@ -0,0 +1,28 @@ +import { MailGeneral } from '.'; +import { getNextPaidAndValidatedUserBatch } from '../../operations/user'; +import { getMinorUsers, getNotOnDiscordServerUsers, getNotPaidSsbuUsers, getNotPaidUsers } from './targets'; + +export const availableGeneralMails: { + [key: string]: MailGeneral; +} = { + joindiscord: { + targets: getNotOnDiscordServerUsers, + template: 'joindiscord', + }, + minor: { + targets: getMinorUsers, + template: 'minor', + }, + notpaid: { + targets: getNotPaidUsers, + template: 'notpaid', + }, + notpaidssbu: { + targets: getNotPaidSsbuUsers, + template: 'notpaidssbu', + }, + tickets: { + targets: getNextPaidAndValidatedUserBatch, + template: 'tickets', + }, +}; diff --git a/src/services/email/index.ts b/src/services/email/index.ts index 5c814b2c..233606c2 100644 --- a/src/services/email/index.ts +++ b/src/services/email/index.ts @@ -1,14 +1,49 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { readFile } from 'fs/promises'; +import { render } from 'mustache'; import nodemailer from 'nodemailer'; import { Log } from '@prisma/client'; -import { DetailedCart, EmailAttachement, RawUser, User, MailQuery } from '../../types'; +import { EmailAttachement, RawUser, MailQuery, User } from '../../types'; import env from '../../utils/env'; import logger from '../../utils/logger'; -import { generateTicketsEmail, generateValidationEmail, generatePasswordResetEmail } from './serializer'; -import type { SerializedMail } from './types'; +import type { Component, Mail, SerializedMail } from './types'; import database from '../database'; +import { escapeText, inflate, style } from './components'; +import { availableTemplates } from './templates'; +import { availableGeneralMails } from './generalMails'; export type { Component, Mail, SerializedMail } from './types'; +/** + * Applied {@link Mail} content in the template + * @throws an error when an unknwon component is in the {@link Mail#sections#components} + */ +export const serialize = async (content: Mail) => { + const template = await readFile('assets/email/template.html', 'utf8'); + const year = new Date().getFullYear(); + return { + to: content.receiver, + subject: `${content.title.topic} - UTT Arena ${year}`, + html: render( + template, + { + ...content, + year, + style, + }, + undefined, + { + escape: (text: Component[] | string): string => + // These are the elements mustache tries to write in the mail. They can be + // full text or the list of components (cf. assets/email/template.html) + // We escape text and handle components + typeof text === 'string' ? escapeText(text) : text.map(inflate).join(''), + }, + ), + }; +}; + export const getEmailsLogs = async () => (await database.log.findMany({ where: { @@ -28,15 +63,18 @@ export const getEmailsLogs = async () => user: RawUser; })[]; -const emailOptions = env.email.gmail - ? { - service: 'gmail', - auth: { - user: env.email.username, - pass: env.email.password, - }, - } - : env.email.uri; +const emailOptions = { + host: env.email.host, + port: env.email.port, + secure: env.email.secure, + auth: { + user: env.email.auth.user, + pass: env.email.auth.password, + }, + tls: { + rejectUnauthorized: env.email.rejectUnauthorized, + }, +}; export const transporter = nodemailer.createTransport(emailOptions); @@ -84,38 +122,67 @@ export const sendEmail = async (mail: SerializedMail, attachments?: EmailAttache } }; -/** - * Sends an email to the user, containing information about the event, - * a list of all items bought on the store and his tickets. - * @param cart the cart of the user - * @returns a promise that resolves when the mail has been sent - * @throws an error if the mail declared above (corresponding to this - * request) is invalid ie. contains an object which is not a {@link Component} - */ -export const sendPaymentConfirmation = async (cart: DetailedCart) => { - const content = await generateTicketsEmail(cart); - return sendEmail(content); +export type MailGeneral = { + // TODO: Fix this type + targets: () => any; + template: string; }; +// TODO: Fix this type +export type MailTemplate = (target: any) => Promise; +// TODO: Fix this type +export const sendMailsFromTemplate = async (template: string, targets: any[]) => { + try { + const mailTemplate = availableTemplates[template]; -/** - * Sends an email to the user with his account validation code. - * This code (given to the user as a link) is required before logging in - * @param user the user to send the mail to - * @returns a promise that resolves when the mail was GENERATED. We don't wait - * for the mail to be sent as it may take time (for several reasons, including mail - * processing and network delays) and we don't want the current request to timeout - * @throws an error if the mail declared above (corresponding to this - * request) is invalid ie. contains an object which is not a {@link Component} - */ -export const sendValidationCode = async (user: RawUser | User) => sendEmail(await generateValidationEmail(user)); + if (targets.length === 0 && !mailTemplate) { + return false; + } -/** - * Sends an email to the user with a password reset link. - * @param user the user to send the mail to - * @returns a promise that resolves when the mail was GENERATED. We don't wait - * for the mail to be sent as it may take time (for several reasons, including mail - * processing and network delays) and we don't want the current request to timeout - * @throws an error if the mail declared above (corresponding to this - * request) is invalid ie. contains an object which is not a {@link Component} - */ -export const sendPasswordReset = async (user: RawUser) => sendEmail(await generatePasswordResetEmail(user)); + if (targets.length > 1) { + const outgoingMails = await Promise.allSettled( + targets.map(async (target) => { + await sendEmail(await mailTemplate(target)); + }), + ); + + const results = outgoingMails.reduce( + (result, state) => { + if (state.status === 'fulfilled') + return { + ...result, + delivered: result.delivered + 1, + }; + logger.error(`Impossible d'envoyer de mail à ${state.reason}`); + return { + ...result, + undelivered: result.undelivered + 1, + }; + }, + { delivered: 0, undelivered: 0 }, + ); + + // eslint-disable-next-line no-console + console.info(`\tMails envoyés: ${results.delivered}\n\tMails non envoyés: ${results.undelivered}`); + return results; + } + + return sendEmail(await mailTemplate(targets[0])); + } catch (error) { + logger.error('Error while sending emails', error); + return false; + } +}; + +export const sendGeneralMail = async (generalMail: string, previewUser: User | null = null) => { + const mail = availableGeneralMails[generalMail]; + + if (!mail) { + return false; + } + + const targets = + previewUser == null ? await mail.targets() : [{ firstname: previewUser.firstname, email: previewUser.email }]; + await sendMailsFromTemplate(generalMail, targets); + + return targets.length; +}; diff --git a/src/services/email/serializer.ts b/src/services/email/serializer.ts deleted file mode 100644 index 307ae975..00000000 --- a/src/services/email/serializer.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { readFile } from 'fs/promises'; -import { render } from 'mustache'; -import { ActionFeedback, DetailedCart, RawUser, ItemCategory } from '../../types'; -import { escapeText, inflate, style } from './components'; -import env from '../../utils/env'; -import { formatPrice } from '../../utils/helpers'; -import type { Mail, SerializedMail, Component } from '.'; - -/** - * Applied {@link Mail} content in the template - * @throws an error when an unknwon component is in the {@link Mail#sections#components} - */ -export const serialize = async (content: Mail) => { - const template = await readFile('assets/email/template.html', 'utf8'); - const year = new Date().getFullYear(); - return { - to: content.receiver, - subject: `${content.title.topic} - UTT Arena ${year}`, - html: render( - template, - { - ...content, - year, - style, - }, - undefined, - { - escape: (text: Component[] | string): string => - // These are the elements mustache tries to write in the mail. They can be - // full text or the list of components (cf. assets/email/template.html) - // We escape text and handle components - typeof text === 'string' ? escapeText(text) : text.map(inflate).join(''), - }, - ), - }; -}; - -export const generateTicketsEmail = (cart: DetailedCart) => - serialize({ - title: { - topic: 'Confirmation de commande', - banner: 'Informations importantes', - short: `Salut ${cart.user.firstname},`, - highlight: "Bienvenue à l'UTT Arena 🔥 !", - }, - reason: - "Tu as reçu cet email car tu es inscrit à l'UTT Arena 2024. Si ce n'est pas le cas, contacte-nous et change le mot de passe de ta boîte mail.", - receiver: cart.user.email, - sections: [ - { - title: 'Confirmation de commande', - components: [ - 'On te confirme aussi ta commande', - { - name: 'Tickets', - items: [ - { - name: '*Nom*', - type: '*Type*', - price: '*Prix*', - }, - ...cart.cartItems - .filter((cartItem) => cartItem.item.category === ItemCategory.ticket) - .map((ticket) => ({ - name: `${ticket.forUser.firstname} ${ticket.forUser.lastname}`, - type: ticket.item.name, - price: formatPrice(ticket.reducedPrice ?? ticket.price), - })), - ], - }, - { - name: 'Suppléments', - items: [ - { - name: '*Nom*', - amount: '*Quantité*', - price: '*Prix*', - }, - ...cart.cartItems - .filter((cartItem) => cartItem.item.category === ItemCategory.supplement) - .map((item) => ({ - name: item.item.name, - amount: `${item.quantity}`, - price: formatPrice(item.reducedPrice ?? item.price), - })), - ], - }, - { - name: 'Location de matériel', - items: [ - { - name: '*Nom*', - amount: '*Quantité*', - price: '*Prix*', - }, - ...cart.cartItems - .filter((cartItem) => cartItem.item.category === ItemCategory.rent) - .map((item) => ({ - name: item.item.name, - amount: `${item.quantity}`, - price: formatPrice(item.reducedPrice ?? item.price), - })), - ], - }, - ], - }, - { - title: 'Tournoi', - components: [ - 'Voilà les dernières informations importantes nécessaires au bon déroulement de la compétition :', - [ - 'Il est nécessaire que *tous les joueurs* de *toutes les équipes* soient présents sur notre Discord', - 'Tous les tournois débutent samedi à 10h, il faudra donc être présent *à partir de 9h00* pour un check-in de toutes les équipes et joueurs', - "N'hésite pas à contacter un membre du staff sur Discord si tu as une question ou que tu rencontres un quelconque problème 😉", - ], - { - name: 'Rejoindre le serveur Discord', - location: 'https://discord.gg/WhxZwKU', - }, - ], - }, - { - title: 'Billet', - components: ["Tu recevras ton *billet personnalisé* par mail quelques jours avant l'UTT Arena !"], - }, - ], - }); - -export const generateValidationEmail = (user: Omit) => - serialize({ - receiver: user.email, - reason: - "Tu as reçu ce mail car tu as envoyé une demande de création de compte à l'UTT Arena. Si ce n'est pas toi, ignore ce message ou contacte-nous.", - title: { - topic: 'Code de validation', - banner: 'Création du compte', - short: `Salut ${user.firstname},`, - highlight: "Bienvenue à l'UTT Arena !", - }, - sections: [ - { - title: 'Avant de commencer...', - components: [ - "On sait bien que c'est pénible mais on doit vérifier que ton adresse email fonctionne bien (sinon tu ne pourras pas recevoir tes billets !).", - { - name: 'Confirme ton adresse email', - location: `${env.front.website}/${ActionFeedback.VALIDATE}/${user.registerToken}`, - }, - `_Si le bouton ne marche pas, tu peux utiliser ce lien:_\n_${env.front.website}/${ActionFeedback.VALIDATE}/${user.registerToken}_`, - ], - }, - { - title: 'Discord', - components: [ - "On utilise Discord pendant l'évènement, et tu auras besoin de lier ton compte discord avec ton compte UTT Arena pour pouvoir créer ou rejoindre une équipe. On te donnera plus de détails là-dessus à ce moment-là 😉", - ], - }, - { - title: 'Tournoi Super Smash Bros Ultimate', - components: [ - "Si tu as choisi de t'inscrire à ce tournoi et que tu choisis de venir avec ta propre console, tu peux bénéficier d'une réduction sur ton billet 😉 _(offre limitée à un certain nombre de places)_", - ], - }, - { - title: 'Des questions ?', - components: [ - "On t'invite à lire la FAQ ou à poser tes questions directement sur Discord.", - [ - { - name: 'FAQ', - location: `${env.front.website}/help`, - }, - { - name: 'Rejoindre le serveur Discord', - location: 'https://discord.gg/WhxZwKU', - }, - ], - ], - }, - ], - }); - -export const generatePasswordResetEmail = (user: Omit) => - serialize({ - receiver: user.email, - reason: - "Tu as reçu ce mail car tu as demandé à réinitialiser ton mot de passe. Si ce n'est pas le cas, ignore ce message.", - title: { - topic: 'Réinitialisation de ton mot de passe', - banner: 'Réinitialisation du mot de passe', - short: `Salut ${user.firstname},`, - highlight: 'Tu es sur le point de réinitialiser ton mot de passe', - }, - sections: [ - { - title: 'Code de vérification', - components: [ - "On doit s'assurer que tu es bien à l'origine de cette demande. Tu peux finaliser la procédure en cliquant sur le bouton ci-dessous.", - { - name: 'Réinitialise ton mot de passe', - location: `${env.front.website}/${ActionFeedback.PASSWORD_RESET}/${user.resetToken}`, - }, - `_Si le bouton ne marche pas, tu peux utiliser ce lien:_\n_${env.front.website}/${ActionFeedback.PASSWORD_RESET}/${user.resetToken}_`, - ], - }, - ], - }); diff --git a/src/services/email/targets.ts b/src/services/email/targets.ts new file mode 100644 index 00000000..ed2bd30b --- /dev/null +++ b/src/services/email/targets.ts @@ -0,0 +1,166 @@ +import { UserType, TransactionState, UserAge } from '@prisma/client'; +import database from '../database'; +import { fetchGuildMembers } from '../discord'; + +export const getNotOnDiscordServerUsers = async () => { + const [members, users] = await Promise.all([ + fetchGuildMembers().then((list) => list.map((member) => member.user.id)), + database.user.findMany({ + where: { + discordId: { + not: null, + }, + email: { + not: null, + }, + OR: [ + { + team: { + lockedAt: { + not: null, + }, + }, + }, + { + type: UserType.spectator, + }, + ], + cartItems: { + some: { + itemId: { + startsWith: 'ticket-', + }, + cart: { + paidAt: { + not: null, + }, + transactionState: TransactionState.paid, + }, + }, + }, + }, + }), + ]); + return users.filter((user) => !members.includes(user.discordId)); +}; + +export const getNotPaidUsers = () => + database.user.findMany({ + distinct: ['id'], + where: { + AND: [ + { + OR: [ + { + cartItems: { + some: { + AND: [ + { + itemId: { + startsWith: 'ticket-', + }, + forcePaid: false, + }, + { + cart: { + transactionState: { + not: 'paid', + }, + }, + }, + ], + }, + }, + }, + { + cartItems: { + none: {}, + }, + }, + ], + }, + { + team: { + lockedAt: null, + }, + }, + ], + }, + }); + +export const getNotPaidSsbuUsers = () => + database.user.findMany({ + distinct: ['id'], + where: { + AND: [ + { + OR: [ + { + cartItems: { + some: { + AND: [ + { + itemId: { + startsWith: 'ticket-', + }, + forcePaid: false, + }, + { + cart: { + transactionState: { + not: 'paid', + }, + }, + }, + ], + }, + }, + }, + { + cartItems: { + none: {}, + }, + }, + ], + }, + { + team: { + tournament: { + id: 'ssbu', + }, + }, + }, + { + team: { + lockedAt: null, + }, + }, + ], + }, + }); + +export const getMinorUsers = () => + database.user.findMany({ + where: { + discordId: { + not: null, + }, + email: { + not: null, + }, + age: UserAge.child, + cartItems: { + some: { + itemId: { + startsWith: 'ticket-', + }, + cart: { + paidAt: { + not: null, + }, + transactionState: TransactionState.paid, + }, + }, + }, + }, + }); diff --git a/src/services/email/templates/accountValidation.ts b/src/services/email/templates/accountValidation.ts new file mode 100644 index 00000000..78f96bd8 --- /dev/null +++ b/src/services/email/templates/accountValidation.ts @@ -0,0 +1,58 @@ +import { RawUser, ActionFeedback } from '../../../types'; +import { serialize } from '..'; +import env from '../../../utils/env'; + +export const generateAccountValidationEmail = (user: Omit) => + serialize({ + receiver: user.email, + reason: + "Tu as reçu ce mail car tu as envoyé une demande de création de compte à l'UTT Arena. Si ce n'est pas toi, ignore ce message ou contacte-nous.", + title: { + topic: 'Code de validation', + banner: 'Création du compte', + short: `Salut ${user.firstname},`, + highlight: "Bienvenue à l'UTT Arena !", + }, + sections: [ + { + title: 'Avant de commencer...', + components: [ + "On sait bien que c'est pénible mais on doit vérifier que ton adresse email fonctionne bien (sinon tu ne pourras pas recevoir tes billets !).", + { + name: 'Confirme ton adresse email', + location: `${env.front.website}/${ActionFeedback.VALIDATE}/${user.registerToken}`, + }, + `_Si le bouton ne marche pas, tu peux utiliser ce lien:_\n_${env.front.website}/${ActionFeedback.VALIDATE}/${user.registerToken}_`, + ], + }, + { + title: 'Discord', + components: [ + "On utilise Discord pendant l'évènement, et tu auras besoin de lier ton compte discord avec ton compte UTT Arena pour pouvoir créer ou rejoindre une équipe. On te donnera plus de détails là-dessus à ce moment-là 😉", + ], + }, + { + title: 'Tournoi Super Smash Bros Ultimate', + components: [ + "Si tu as choisi de t'inscrire à ce tournoi et que tu choisis de venir avec ta propre console, tu peux bénéficier d'une réduction sur ton billet 😉 _(offre limitée à un certain nombre de places)_", + ], + }, + { + title: 'Des questions ?', + components: [ + "On t'invite à lire la FAQ ou à poser tes questions directement sur Discord.", + [ + { + name: 'FAQ', + location: `${env.front.website}/help`, + }, + { + name: 'Rejoindre le serveur Discord', + location: 'https://discord.gg/WhxZwKU', + }, + ], + ], + }, + ], + attachments: [], + }); diff --git a/src/services/email/templates/index.ts b/src/services/email/templates/index.ts new file mode 100644 index 00000000..0c98f8fe --- /dev/null +++ b/src/services/email/templates/index.ts @@ -0,0 +1,24 @@ +import { MailTemplate } from '..'; +import { generateAccountValidationEmail } from './accountValidation'; +import { generateJoinDiscordEmail } from './joinDiscord'; +import { generateLastYearPublicAnnounce } from './lastYearAnnounce'; +import { generateMinorEmail } from './minor'; +import { generateNotPaidEmail } from './notPaid'; +import { generateNotPaidSSBUEmail } from './notPaidSsbu'; +import { generateOrderConfirmationEmail } from './orderConfirmation'; +import { generatePasswordResetEmail } from './passwordReset'; +import { generateTicketsEmail } from './tickets'; + +export const availableTemplates: { + [key: string]: MailTemplate; +} = { + accountvalidation: generateAccountValidationEmail, + joindiscord: generateJoinDiscordEmail, + lastyearannounce: generateLastYearPublicAnnounce, + minor: generateMinorEmail, + notpaid: generateNotPaidEmail, + notpaidssbu: generateNotPaidSSBUEmail, + orderconfirmation: generateOrderConfirmationEmail, + passwordreset: generatePasswordResetEmail, + tickets: generateTicketsEmail, +}; diff --git a/src/services/email/templates/joinDiscord.ts b/src/services/email/templates/joinDiscord.ts new file mode 100644 index 00000000..55fe8c28 --- /dev/null +++ b/src/services/email/templates/joinDiscord.ts @@ -0,0 +1,28 @@ +import { RawUser } from '../../../types'; +import { serialize } from '..'; + +export const generateJoinDiscordEmail = (user: Omit) => + serialize({ + reason: 'Tu as reçu ce mail car tu as créé un compte sur arena.utt.fr', + title: { + banner: 'On se retrouve ce weekend !', + highlight: `Salut ${user.firstname}`, + short: "L'UTT Arena arrive à grands pas 🔥", + topic: 'Rejoins le discord', + }, + receiver: user.email, + sections: [ + { + title: "Rejoins le serveur discord de l'UTT Arena !", + components: [ + "Tu n'es pas encore sur le serveur discord Arena, nous te conseillons fortement de le rejoindre car il s'agit de notre principal outil de communication avec toi et les autres joueurs.", + 'Sur ce serveur, tu pourras également y discuter avec les autres joueurs, ou poser des questions aux organisateurs de ton tournoi.', + { + name: 'Rejoindre le serveur Discord', + location: 'https://discord.gg/WhxZwKU', + }, + ], + }, + ], + attachments: [], + }); diff --git a/src/services/email/templates/lastYearAnnounce.ts b/src/services/email/templates/lastYearAnnounce.ts new file mode 100644 index 00000000..ed9592ab --- /dev/null +++ b/src/services/email/templates/lastYearAnnounce.ts @@ -0,0 +1,55 @@ +import { serialize } from '..'; +import env from '../../../utils/env'; + +export const generateLastYearPublicAnnounce = (email: string) => + serialize({ + receiver: email, + reason: + "Tu as reçu ce mail car tu as participé à l'UTT Arena en décembre 2023. Si ce n'est pas le cas, ignore ce message.", + title: { + topic: "L'UTT ARENA est de retour !", + banner: '', + short: `Salut,`, + highlight: "L'UTT Arena est de retour !", + }, + sections: [ + { + title: "L'UTT Arena revient du 6 au 8 décembre à Troyes pour un nouveau tournoi League of Legends", + components: [ + '🖥️ **160 places** joueurs à 28€', + '🎙️ Casté par **Drako** et **Headen**', + '💰 **2000€** de cashprize', + '🚅 **Troyes** à 1h30 de Paris', + '🎊 Buvette et animations tout le weekend', + ], + }, + { + title: 'Inscriptions', + components: [ + "Pour s'inscrire, ça se passe sur le site !", + { + name: "Inscris toi à l'UTT Arena 2024 !", + location: `https://arena.utt.fr/`, + }, + `_Si le bouton ne marche pas, tu peux utiliser ce lien:_\n_https://arena.utt.fr/_`, + ], + }, + { + title: 'Des questions ?', + components: [ + "On t'invite à lire la FAQ ou à poser tes questions directement sur Discord.", + [ + { + name: 'FAQ', + location: `${env.front.website}/help`, + }, + { + name: 'Rejoindre le serveur Discord', + location: 'https://discord.gg/WhxZwKU', + }, + ], + ], + }, + ], + attachments: [], + }); diff --git a/src/services/email/templates/minor.ts b/src/services/email/templates/minor.ts new file mode 100644 index 00000000..242c0111 --- /dev/null +++ b/src/services/email/templates/minor.ts @@ -0,0 +1,28 @@ +import { RawUser } from '../../../types'; +import { serialize } from '..'; + +export const generateMinorEmail = (user: Omit) => + serialize({ + reason: 'Tu as reçu ce mail car tu as créé un compte sur arena.utt.fr', + title: { + banner: 'On se retrouve ce weekend !', + highlight: `Salut ${user.firstname}`, + short: "L'UTT Arena arrive à grands pas 🔥", + topic: "N'oublie pas ton autorisation parentale", + }, + receiver: user.email, + sections: [ + { + title: 'Autorisation parentale', + components: [ + "Tu nous as indiqué que tu seras mineur à la date de l'UTT Arena. N'oublie pas de préparer *ton autorisation parentale, et une photocopie de ta pièce d'identité, et de celle de ton responsable légal* !", + "La vérification se fera à l'entrée de l'UTT Arena, n'hésite pas à envoyer à l'avance ces documents par mail à arena@utt.fr pour simplifier la procédure à l'entrée.", + { + location: 'https://arena.utt.fr/uploads/files/Autorisation_parentale_-_UTT_Arena_2024.pdf', + name: "Télécharger l'autorisation parentale", + }, + ], + }, + ], + attachments: [], + }); diff --git a/src/services/email/templates/notPaid.ts b/src/services/email/templates/notPaid.ts new file mode 100644 index 00000000..8081474f --- /dev/null +++ b/src/services/email/templates/notPaid.ts @@ -0,0 +1,28 @@ +import { RawUser } from '../../../types'; +import { serialize } from '..'; + +export const generateNotPaidEmail = (user: Omit) => + serialize({ + reason: 'Tu as reçu ce mail car tu as créé un compte sur arena.utt.fr', + title: { + banner: 'On se retrouve ce weekend !', + highlight: `Salut ${user.firstname}`, + short: "L'UTT Arena arrive à grands pas 🔥", + topic: "Tu n'as pas encore payé", + }, + receiver: user.email, + sections: [ + { + title: "Ton inscription n'a pas été confirmée", + components: [ + "L'UTT Arena approche à grand pas, et ton inscription n'est pas encore confirmée. Pour verrouiller ta place, il ne te reste plus qu'à la payer en accédant à la boutique sur le site. \nSi le tournoi auquel tu souhaites participer est d'ores-et-déjà rempli, tu sera placé en file d'attente.", + "\n_Si le taux de remplissage d'un tournoi est trop faible d'ici à deux semaines de l'évènement, l'équipe organisatrice se réserve le droit de l'annuler._", + { + location: 'https://arena.utt.fr/dashboard/team', + name: 'Accéder à arena.utt.fr', + }, + ], + }, + ], + attachments: [], + }); diff --git a/src/services/email/templates/notPaidSsbu.ts b/src/services/email/templates/notPaidSsbu.ts new file mode 100644 index 00000000..1a159edf --- /dev/null +++ b/src/services/email/templates/notPaidSsbu.ts @@ -0,0 +1,29 @@ +import { RawUser } from '../../../types'; +import { serialize } from '..'; + +export const generateNotPaidSSBUEmail = (user: Omit) => + serialize({ + reason: 'Tu as reçu ce mail car tu as créé un compte sur arena.utt.fr', + title: { + banner: 'On se retrouve ce weekend !', + highlight: `Salut ${user.firstname}`, + short: "L'UTT Arena arrive à grands pas 🔥", + topic: "Tu n'as pas encore payé", + }, + receiver: user.email, + sections: [ + { + title: "Ton inscription n'a pas été confirmée", + components: [ + "L'UTT Arena approche à grand pas, et ton inscription pour le tournoi SSBU n'est pas encore confirmée. Pour verrouiller ta place, il ne te reste plus qu'à la payer en accédant à la boutique sur le site.", + "\nN'oublie pas que tu peux décider de ramener ta propre Nintendo Switch avec SSBU (all DLCs) pour bénéficier d'une *réduction de 3€* sur ta place ! Cela permet également au tournoi de s'enchaîner de façon plus fluide.", + "\nOn se retrouve le 1, 2, 3 décembre dans l'Arène !", + { + location: 'https://arena.utt.fr/dashboard/team', + name: 'Accéder à arena.utt.fr', + }, + ], + }, + ], + attachments: [], + }); diff --git a/src/services/email/templates/orderConfirmation.ts b/src/services/email/templates/orderConfirmation.ts new file mode 100644 index 00000000..6caa4e7a --- /dev/null +++ b/src/services/email/templates/orderConfirmation.ts @@ -0,0 +1,95 @@ +import { DetailedCart, ItemCategory } from '../../../types'; +import { serialize } from '..'; +import { formatPrice } from '../../../utils/helpers'; + +export const generateOrderConfirmationEmail = (cart: DetailedCart) => + serialize({ + title: { + topic: 'Confirmation de commande', + banner: 'Informations importantes', + short: `Salut ${cart.user.firstname},`, + highlight: "Bienvenue à l'UTT Arena 🔥 !", + }, + reason: + "Tu as reçu cet email car tu es inscrit à l'UTT Arena 2024. Si ce n'est pas le cas, contacte-nous et change le mot de passe de ta boîte mail.", + receiver: cart.user.email, + sections: [ + { + title: 'Confirmation de commande', + components: [ + 'On te confirme ta commande', + { + name: 'Tickets', + items: [ + { + name: '*Nom*', + type: '*Type*', + price: '*Prix*', + }, + ...cart.cartItems + .filter((cartItem) => cartItem.item.category === ItemCategory.ticket) + .map((ticket) => ({ + name: `${ticket.forUser.firstname} ${ticket.forUser.lastname}`, + type: ticket.item.name, + price: formatPrice(ticket.reducedPrice ?? ticket.price), + })), + ], + }, + { + name: 'Suppléments', + items: [ + { + name: '*Nom*', + amount: '*Quantité*', + price: '*Prix*', + }, + ...cart.cartItems + .filter((cartItem) => cartItem.item.category === ItemCategory.supplement) + .map((item) => ({ + name: item.item.name, + amount: `${item.quantity}`, + price: formatPrice(item.reducedPrice ?? item.price), + })), + ], + }, + { + name: 'Location de matériel', + items: [ + { + name: '*Nom*', + amount: '*Quantité*', + price: '*Prix*', + }, + ...cart.cartItems + .filter((cartItem) => cartItem.item.category === ItemCategory.rent) + .map((item) => ({ + name: item.item.name, + amount: `${item.quantity}`, + price: formatPrice(item.reducedPrice ?? item.price), + })), + ], + }, + ], + }, + { + title: 'Tournoi', + components: [ + 'Voilà les dernières informations importantes nécessaires au bon déroulement de la compétition :', + [ + 'Il est nécessaire que *tous les joueurs* de *toutes les équipes* soient présents sur notre Discord', + 'Tous les tournois débutent samedi à 10h, il faudra donc être présent *à partir de 9h00* pour un check-in de toutes les équipes et joueurs', + "N'hésite pas à contacter un membre du staff sur Discord si tu as une question ou que tu rencontres un quelconque problème 😉", + ], + { + name: 'Rejoindre le serveur Discord', + location: 'https://discord.gg/WhxZwKU', + }, + ], + }, + { + title: 'Billet', + components: ["Tu recevras ton *billet personnalisé* par mail quelques jours avant l'UTT Arena !"], + }, + ], + attachments: [], + }); diff --git a/src/services/email/templates/passwordReset.ts b/src/services/email/templates/passwordReset.ts new file mode 100644 index 00000000..bb9dc6c6 --- /dev/null +++ b/src/services/email/templates/passwordReset.ts @@ -0,0 +1,30 @@ +import { RawUser, ActionFeedback } from '../../../types'; +import { serialize } from '..'; +import env from '../../../utils/env'; + +export const generatePasswordResetEmail = (user: Omit) => + serialize({ + receiver: user.email, + reason: + "Tu as reçu ce mail car tu as demandé à réinitialiser ton mot de passe. Si ce n'est pas le cas, ignore ce message.", + title: { + topic: 'Réinitialisation de ton mot de passe', + banner: 'Réinitialisation du mot de passe', + short: `Salut ${user.firstname},`, + highlight: 'Tu es sur le point de réinitialiser ton mot de passe', + }, + sections: [ + { + title: 'Code de vérification', + components: [ + "On doit s'assurer que tu es bien à l'origine de cette demande. Tu peux finaliser la procédure en cliquant sur le bouton ci-dessous.", + { + name: 'Réinitialise ton mot de passe', + location: `${env.front.website}/${ActionFeedback.PASSWORD_RESET}/${user.resetToken}`, + }, + `_Si le bouton ne marche pas, tu peux utiliser ce lien:_\n_${env.front.website}/${ActionFeedback.PASSWORD_RESET}/${user.resetToken}_`, + ], + }, + ], + attachments: [], + }); diff --git a/src/services/email/templates/tickets.ts b/src/services/email/templates/tickets.ts new file mode 100644 index 00000000..8df18202 --- /dev/null +++ b/src/services/email/templates/tickets.ts @@ -0,0 +1,67 @@ +import { RawUser, TransactionState } from '../../../types'; +import { serialize } from '..'; +import database from '../../database'; +import { generateTicket } from '../../../utils/ticket'; + +export const generateTicketsEmail = async (user: Omit) => + serialize({ + reason: 'Tu as reçu ce mail car tu as créé un compte sur arena.utt.fr', + title: { + banner: 'On se retrouve ce weekend !', + highlight: `Salut ${user.firstname}`, + short: "L'UTT Arena arrive à grands pas 🔥", + topic: "Ton ticket pour l'UTT Arena", + }, + receiver: user.email, + sections: [ + { + title: "Ton ticket pour l'UTT Arena", + components: [ + "Tu es bien inscrit à l'UTT Arena ! Tu trouveras ci-joint ton billet, que tu devras présenter à l'entrée de l'UTT Arena. Tu peux aussi le retrouver sur la billetterie, dans l'onglet \"Mon compte\" de ton Dashboard.", + 'Attention, tous les tournois débutent à 10h, *il faudra donc être présent dès 9h00 pour un check-in de toutes les équipes et joueurs.*', + { + location: 'https://arena.utt.fr/dashboard/account', + name: 'Accéder à arena.utt.fr', + }, + ], + }, + { + title: 'Ce que tu dois emporter', + components: [ + "Pour rentrer à l'UTT Arena, tu auras besoin de", + [ + 'ton *billet* (que tu trouveras en pièce jointe, ou sur le site)', + "une *pièce d'identité* (type carte d'identité, titre de séjour ou permis de conduire)", + ], + "Nous te conseillons d'emporter également", + [ + 'Une gourde *vide*', + "une multiprise puisque tu n'auras *qu'une seule prise mise à ta disposition pour brancher tout ton setup*", + "un câble ethernet (d'environ 7m)", + 'ton setup', + ], + "Si tu as encore des questions, n'hésite pas à regarder notre FAQ ou à poser la question sur le serveur discord !", + { + location: 'https://arena.utt.fr/help', + name: 'Ouvrir la FAQ', + }, + ], + }, + ], + attachments: await (async () => { + const cartItem = await database.cartItem.findFirst({ + where: { + cart: { + paidAt: { + not: null, + }, + transactionState: TransactionState.paid, + }, + itemId: `ticket-${user.type}`, + forUserId: user.id, + }, + include: { item: true, forUser: true }, + }); + return [await generateTicket(cartItem)]; + })(), + }); diff --git a/src/services/email/types.d.ts b/src/services/email/types.ts similarity index 52% rename from src/services/email/types.d.ts rename to src/services/email/types.ts index d5fadc14..9309b571 100644 --- a/src/services/email/types.d.ts +++ b/src/services/email/types.ts @@ -1,31 +1,31 @@ -export declare type Component = string | string[] | Component.Button | Component.Button[] | Component.Table; +import { EmailAttachement } from '../../types'; -export declare namespace Component { - interface Button { - /** Button text */ - name: string; - /** Button link */ - location: string; - /** Button color. Matches UA colors by default */ - color?: `#${string}`; - } +export interface MailButton { + /** Button text */ + name: string; + /** Button link */ + location: string; + /** Button color. Matches UA colors by default */ + color?: `#${string}`; +} - interface Table { - /** Name of the table. Displayed BEFORE the table */ - name?: string; - /** - * List of ALL rows contained in the table. - * The first element of thie array will be used for column creation: - * All keys of this object will be attached to a column, named by the - * item value corresponding to the (column) key - * All other object will match one single row and fill the columns depending - * on their keys. - * This means that all columns must be defined in the first object - */ - items: Array<{ [key: string]: string }>; - } +export interface MailTable { + /** Name of the table. Displayed BEFORE the table */ + name?: string; + /** + * List of ALL rows contained in the table. + * The first element of thie array will be used for column creation: + * All keys of this object will be attached to a column, named by the + * item value corresponding to the (column) key + * All other object will match one single row and fill the columns depending + * on their keys. + * This means that all columns must be defined in the first object + */ + items: Array<{ [key: string]: string }>; } +export declare type Component = string | string[] | MailButton | MailButton[] | MailTable; + export declare interface Mail { /** The email address to send this email to (written in the footer, before the {@link reason}) */ receiver: string; @@ -60,6 +60,11 @@ export declare interface Mail { title: string; components: Component[]; }[]; + /** + * The attachments to include in the mail. If this property is omitted (or if the list is empty), + * no attachment will be included in the mail. + */ + attachments?: EmailAttachement[]; } export declare interface SerializedMail { diff --git a/src/types.ts b/src/types.ts index 23a9a919..9481ec2d 100755 --- a/src/types.ts +++ b/src/types.ts @@ -37,6 +37,19 @@ export type EmailAttachement = Mail.Attachment & { content: Buffer; }; +export type MailGeneralQuery = { + readonly preview: boolean; + readonly generalMail: string; +}; + +export type MailTemplateQuery = { + readonly preview: boolean; + readonly templateMail: string; + // TODO: Fix this type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly targets: any[]; +}; + export type MailQuery = ParsedQs & { readonly locked?: boolean; readonly tournamentId?: string; diff --git a/src/utils/env.ts b/src/utils/env.ts index 9b91e6d9..0599a960 100755 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -72,16 +72,20 @@ const env = { email: { // We don't use the normal 25 port because of testing (25 listening is usually denied) // Also reject self signed certificates only in tests - uri: loadEnv('SMTP_URI') || `smtp://localhost:2525/?pool=true&maxConnections=1&tls.rejectUnauthorized=${!isTest}`, + host: loadEnv('EMAIL_HOST'), + port: loadIntEnv('EMAIL_PORT'), + secure: loadEnv('EMAIL_SECURE') !== 'false', sender: { name: loadEnv('EMAIL_SENDER_NAME') || 'UTT Arena', address: loadEnv('EMAIL_SENDER_ADDRESS') || 'arena@utt.fr', }, - gmail: loadEnv('GMAIL') === 'true', - username: loadEnv('GMAIL_USERNAME') || null, - password: loadEnv('GMAIL_PASSWORD') || null, - partners: ['utt.fr', 'utc.fr', 'utbm.fr'], + auth: { + user: loadEnv('EMAIL_AUTH_USER'), + password: loadEnv('EMAIL_AUTH_PASSWORD'), + }, + rejectUnauthorized: loadEnv('EMAIL_REJECT_UNAUTHORIZED') !== 'false', maxMailsPerBatch: loadIntEnv('MAX_MAIL_PER_BATCH') || 100, + partners: loadEnv('PARTNER_MAILS')?.split(',') || [], }, stripe: { callback: `${frontEndpoint}/stripe`, diff --git a/tests/admin/emails/get.test.ts b/tests/admin/emails/get.test.disable.ts similarity index 100% rename from tests/admin/emails/get.test.ts rename to tests/admin/emails/get.test.disable.ts diff --git a/tests/admin/emails/send.test.ts b/tests/admin/emails/send.test.disable.ts similarity index 100% rename from tests/admin/emails/send.test.ts rename to tests/admin/emails/send.test.disable.ts diff --git a/tests/auth/resendEmail.test.ts b/tests/auth/resendEmail.test.ts index b494b0a0..e57172e0 100644 --- a/tests/auth/resendEmail.test.ts +++ b/tests/auth/resendEmail.test.ts @@ -84,7 +84,7 @@ describe('POST /auth/resendEmail', () => { }); it('should return an error as the code has not been sent successfully', async () => { - sandbox.stub(mailOperations, 'sendValidationCode').throws('Unexpected error'); + sandbox.stub(mailOperations, 'sendMailsFromTemplate').throws('Unexpected error'); await request(app) .post('/auth/resendEmail') .send({ diff --git a/tests/services/email.test.ts b/tests/services/email.test.disable.ts similarity index 94% rename from tests/services/email.test.ts rename to tests/services/email.test.disable.ts index 3b62db11..2324a375 100644 --- a/tests/services/email.test.ts +++ b/tests/services/email.test.disable.ts @@ -6,11 +6,7 @@ import { PrimitiveCartItem, ItemCategory, TransactionState, UserType } from '../ import { createCart, updateCart } from '../../src/operations/carts'; import { sendEmail } from '../../src/services/email'; import { inflate } from '../../src/services/email/components'; -import { - generateTicketsEmail, - generatePasswordResetEmail, - generateValidationEmail, -} from '../../src/services/email/serializer'; +import { availableTemplates } from '../../src/services/email/templates'; import { randomInt } from '../../src/utils/helpers'; import { fetchAllItems } from '../../src/operations/item'; import env from '../../src/utils/env'; @@ -128,7 +124,7 @@ describe('Tests the email utils', () => { transactionState: TransactionState.paid, }); - const ticketsEmail = await generateTicketsEmail(detailedCart); + const ticketsEmail = await availableTemplates.orderconfirmation(detailedCart); fs.writeFileSync('artifacts/payment.html', ticketsEmail.html); }); @@ -136,7 +132,7 @@ describe('Tests the email utils', () => { it(`should generate an account validation template`, async () => { const user = await createFakeUser({ confirmed: false }); - const validationEmail = await generateValidationEmail(user); + const validationEmail = await availableTemplates.accountvalidation(user); fs.writeFileSync('artifacts/validation.html', validationEmail.html); }); @@ -145,7 +141,7 @@ describe('Tests the email utils', () => { const user = await createFakeUser({ type: UserType.player }); user.resetToken = (await generateResetToken(user.id)).resetToken; - const passwordResetEmail = await generatePasswordResetEmail(user); + const passwordResetEmail = await availableTemplates.passwordreset(user); fs.writeFileSync('artifacts/pwd-reset.html', passwordResetEmail.html); }); From a1661e59f99dc707be1c3a58907715d7fa56f3f8 Mon Sep 17 00:00:00 2001 From: Antoine D Date: Tue, 26 Nov 2024 17:45:41 +0100 Subject: [PATCH 2/3] fix: comment notPaid template tournament cancel --- src/services/email/templates/notPaid.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/email/templates/notPaid.ts b/src/services/email/templates/notPaid.ts index 8081474f..d653e588 100644 --- a/src/services/email/templates/notPaid.ts +++ b/src/services/email/templates/notPaid.ts @@ -16,7 +16,7 @@ export const generateNotPaidEmail = (user: Omit) => title: "Ton inscription n'a pas été confirmée", components: [ "L'UTT Arena approche à grand pas, et ton inscription n'est pas encore confirmée. Pour verrouiller ta place, il ne te reste plus qu'à la payer en accédant à la boutique sur le site. \nSi le tournoi auquel tu souhaites participer est d'ores-et-déjà rempli, tu sera placé en file d'attente.", - "\n_Si le taux de remplissage d'un tournoi est trop faible d'ici à deux semaines de l'évènement, l'équipe organisatrice se réserve le droit de l'annuler._", + // "\n_Si le taux de remplissage d'un tournoi est trop faible d'ici à deux semaines de l'évènement, l'équipe organisatrice se réserve le droit de l'annuler._", { location: 'https://arena.utt.fr/dashboard/team', name: 'Accéder à arena.utt.fr', From 8205977b68d10fdefff32ca018b7df148839888e Mon Sep 17 00:00:00 2001 From: Antoine D <106921102+Suboyyy@users.noreply.github.com> Date: Wed, 27 Nov 2024 14:41:29 +0100 Subject: [PATCH 3/3] Feat/send ticket (#265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * feat: mail smtp * Update .env.example * Update notPaid.ts * Update env.ts --------- Co-authored-by: Noé Landré Co-authored-by: Arthur Dodin Co-authored-by: Antoine D --- .env.example | 2 -- src/services/email/index.ts | 7 ++----- src/utils/env.ts | 4 ---- 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 76011ed4..59aeb3ae 100644 --- a/.env.example +++ b/.env.example @@ -33,8 +33,6 @@ 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 diff --git a/src/services/email/index.ts b/src/services/email/index.ts index 233606c2..d581ff66 100644 --- a/src/services/email/index.ts +++ b/src/services/email/index.ts @@ -67,10 +67,6 @@ const emailOptions = { host: env.email.host, port: env.email.port, secure: env.email.secure, - auth: { - user: env.email.auth.user, - pass: env.email.auth.password, - }, tls: { rejectUnauthorized: env.email.rejectUnauthorized, }, @@ -117,8 +113,9 @@ export const sendEmail = async (mail: SerializedMail, attachments?: EmailAttache }); logger.info(`Email sent to ${mail.to}`); - } catch { + } catch (error) { logger.warn(`Could not send email to ${mail.to}`); + logger.error(error); } }; diff --git a/src/utils/env.ts b/src/utils/env.ts index 0599a960..2b198fa6 100755 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -79,10 +79,6 @@ const env = { name: loadEnv('EMAIL_SENDER_NAME') || 'UTT Arena', address: loadEnv('EMAIL_SENDER_ADDRESS') || 'arena@utt.fr', }, - auth: { - user: loadEnv('EMAIL_AUTH_USER'), - password: loadEnv('EMAIL_AUTH_PASSWORD'), - }, rejectUnauthorized: loadEnv('EMAIL_REJECT_UNAUTHORIZED') !== 'false', maxMailsPerBatch: loadIntEnv('MAX_MAIL_PER_BATCH') || 100, partners: loadEnv('PARTNER_MAILS')?.split(',') || [],