diff --git a/packages/api/src/business/processes/titres-activites-relance-send.test.ts b/packages/api/src/business/processes/titres-activites-relance-send.test.ts index 9277fdbb945e31d38355de7c04e87b3c728e1c3a..f50a4117917ca9d71b839f48a2f32336cfb4445d 100644 --- a/packages/api/src/business/processes/titres-activites-relance-send.test.ts +++ b/packages/api/src/business/processes/titres-activites-relance-send.test.ts @@ -5,6 +5,7 @@ import { vi, describe, expect, test, afterEach } from 'vitest' import { getCurrent, toCaminoDate } from 'camino-common/src/date' import { entrepriseIdValidator } from 'camino-common/src/entreprise' import { activiteIdValidator } from 'camino-common/src/activite' +import { GetEntrepriseUtilisateurs } from '../../api/rest/entreprises.queries' vi.mock('../../tools/api-mailjet/emails', () => ({ __esModule: true, @@ -23,27 +24,45 @@ describe('relance les opérateurs des activités qui vont se fermer automatiquem const date = toCaminoDate('2022-01-01') const email = 'toto.huhu@foo.com' + const secondEmail = 'test@example.org' + const troisiemeEmail = 'test2@example.org' + const fakeEmailsByEntreprise1: GetEntrepriseUtilisateurs[] = [ + { email, role: 'entreprise' }, + { email: 'emailDeBureauDEtudes', role: "bureau d'études" }, + { email: secondEmail, role: 'entreprise' }, + ] + const fakeEmailsByEntreprise2: GetEntrepriseUtilisateurs[] = [{ email: troisiemeEmail, role: 'entreprise' }] + const entrepriseId1 = entrepriseIdValidator.parse('titulaire1') const titresActivites = await checkDateAndSendEmail( - () => - Promise.resolve([ - { email, role: 'entreprise' }, - { email: 'emailDeBureauDEtudes', role: "bureau d'études" }, - ]), + entrepriseId => { + if (entrepriseId === entrepriseId1) { + return Promise.resolve(fakeEmailsByEntreprise1) + } + + return Promise.resolve(fakeEmailsByEntreprise2) + }, toCaminoDate('2022-03-18'), [ { date, id: activiteIdValidator.parse('activiteId'), titre: { - titulaireIds: [entrepriseIdValidator.parse('titulaire1')], + titulaireIds: [entrepriseIdValidator.parse('titulaire2')], + }, + }, + { + date, + id: activiteIdValidator.parse('activiteId'), + titre: { + titulaireIds: [entrepriseId1], }, }, ] ) - - expect(emailsWithTemplateSendMock).toBeCalledWith([email], EmailTemplateId.ACTIVITES_RELANCE, expect.any(Object)) - expect(titresActivites.length).toEqual(1) + expect(emailsWithTemplateSendMock).toHaveBeenCalledOnce() + expect(emailsWithTemplateSendMock).toBeCalledWith([troisiemeEmail, email, secondEmail], EmailTemplateId.ACTIVITES_RELANCE, expect.any(Object)) + expect(titresActivites.length).toEqual(2) }) test('n’envoie pas d’email aux opérateurs', async () => { diff --git a/packages/api/src/business/processes/titres-activites-relance-send.ts b/packages/api/src/business/processes/titres-activites-relance-send.ts index ba5df1285f78965c2572b889ee77cb72a04811dd..6bfaac0242906c0221e9dd9b9d4cf5a1f532ac6e 100644 --- a/packages/api/src/business/processes/titres-activites-relance-send.ts +++ b/packages/api/src/business/processes/titres-activites-relance-send.ts @@ -45,7 +45,7 @@ export const checkDateAndSendEmail = async ( const dateDelai = dateAddDays(aujourdhui, ACTIVITES_DELAI_RELANCE_JOURS) const titresActivitesRelanceToSend = activites.filter(({ date }) => dateDelai === dateAddMonths(date, 3)) - if (titresActivitesRelanceToSend.length) { + if (isNotNullNorUndefinedNorEmpty(titresActivitesRelanceToSend)) { // envoi d’email aux opérateurs pour les relancer ACTIVITES_DELAI_RELANCE_JOURS jours avant la fermeture automatique de l’activité const emails = new Set<string>() for (const activite of titresActivitesRelanceToSend) { @@ -60,16 +60,16 @@ export const checkDateAndSendEmail = async ( } }) } + } - if (emails.size) { - await emailsWithTemplateSend([...emails], EmailTemplateId.ACTIVITES_RELANCE, { - activitesUrl: activitesUrlGet({ - activiteTypesIds, - activiteStatutsIds, - annees: [anneePrecedente(getAnnee(aujourdhui))], - }), - }) - } + if (emails.size > 0) { + await emailsWithTemplateSend([...emails], EmailTemplateId.ACTIVITES_RELANCE, { + activitesUrl: activitesUrlGet({ + activiteTypesIds, + activiteStatutsIds, + annees: [anneePrecedente(getAnnee(aujourdhui))], + }), + }) } console.info('titre / activités (relance) ->', titresActivitesRelanceToSend.map(ta => ta.id).join(', ')) diff --git a/packages/api/src/tools/api-mailjet/__snapshots__/emails.test.ts.snap b/packages/api/src/tools/api-mailjet/__snapshots__/emails.test.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..c3993892bbc8cd90b58a1ba99520b1911e336885 --- /dev/null +++ b/packages/api/src/tools/api-mailjet/__snapshots__/emails.test.ts.snap @@ -0,0 +1,361 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`mailjetSend > group by 1`] = ` +[ + [ + { + "Bcc": [ + { + "Email": "toto0@example.org", + }, + { + "Email": "toto1@example.org", + }, + { + "Email": "toto2@example.org", + }, + { + "Email": "toto3@example.org", + }, + { + "Email": "toto4@example.org", + }, + { + "Email": "toto5@example.org", + }, + { + "Email": "toto6@example.org", + }, + { + "Email": "toto7@example.org", + }, + { + "Email": "toto8@example.org", + }, + { + "Email": "toto9@example.org", + }, + { + "Email": "toto10@example.org", + }, + { + "Email": "toto11@example.org", + }, + { + "Email": "toto12@example.org", + }, + { + "Email": "toto13@example.org", + }, + { + "Email": "toto14@example.org", + }, + { + "Email": "toto15@example.org", + }, + { + "Email": "toto16@example.org", + }, + { + "Email": "toto17@example.org", + }, + { + "Email": "toto18@example.org", + }, + { + "Email": "toto19@example.org", + }, + { + "Email": "toto20@example.org", + }, + { + "Email": "toto21@example.org", + }, + { + "Email": "toto22@example.org", + }, + { + "Email": "toto23@example.org", + }, + { + "Email": "toto24@example.org", + }, + { + "Email": "toto25@example.org", + }, + { + "Email": "toto26@example.org", + }, + { + "Email": "toto27@example.org", + }, + { + "Email": "toto28@example.org", + }, + { + "Email": "toto29@example.org", + }, + { + "Email": "toto30@example.org", + }, + { + "Email": "toto31@example.org", + }, + { + "Email": "toto32@example.org", + }, + { + "Email": "toto33@example.org", + }, + { + "Email": "toto34@example.org", + }, + { + "Email": "toto35@example.org", + }, + { + "Email": "toto36@example.org", + }, + { + "Email": "toto37@example.org", + }, + { + "Email": "toto38@example.org", + }, + { + "Email": "toto39@example.org", + }, + { + "Email": "toto40@example.org", + }, + { + "Email": "toto41@example.org", + }, + { + "Email": "toto42@example.org", + }, + { + "Email": "toto43@example.org", + }, + { + "Email": "toto44@example.org", + }, + { + "Email": "toto45@example.org", + }, + { + "Email": "toto46@example.org", + }, + { + "Email": "toto47@example.org", + }, + { + "Email": "toto48@example.org", + }, + ], + "Subject": "Subject", + "TextPart": "This is a message", + "To": [ + { + "Email": "plop@plop.plop", + }, + ], + }, + ], + [ + { + "Bcc": [ + { + "Email": "toto49@example.org", + }, + { + "Email": "toto50@example.org", + }, + { + "Email": "toto51@example.org", + }, + { + "Email": "toto52@example.org", + }, + { + "Email": "toto53@example.org", + }, + { + "Email": "toto54@example.org", + }, + { + "Email": "toto55@example.org", + }, + { + "Email": "toto56@example.org", + }, + { + "Email": "toto57@example.org", + }, + { + "Email": "toto58@example.org", + }, + { + "Email": "toto59@example.org", + }, + { + "Email": "toto60@example.org", + }, + { + "Email": "toto61@example.org", + }, + { + "Email": "toto62@example.org", + }, + { + "Email": "toto63@example.org", + }, + { + "Email": "toto64@example.org", + }, + { + "Email": "toto65@example.org", + }, + { + "Email": "toto66@example.org", + }, + { + "Email": "toto67@example.org", + }, + { + "Email": "toto68@example.org", + }, + { + "Email": "toto69@example.org", + }, + { + "Email": "toto70@example.org", + }, + { + "Email": "toto71@example.org", + }, + { + "Email": "toto72@example.org", + }, + { + "Email": "toto73@example.org", + }, + { + "Email": "toto74@example.org", + }, + { + "Email": "toto75@example.org", + }, + { + "Email": "toto76@example.org", + }, + { + "Email": "toto77@example.org", + }, + { + "Email": "toto78@example.org", + }, + { + "Email": "toto79@example.org", + }, + { + "Email": "toto80@example.org", + }, + { + "Email": "toto81@example.org", + }, + { + "Email": "toto82@example.org", + }, + { + "Email": "toto83@example.org", + }, + { + "Email": "toto84@example.org", + }, + { + "Email": "toto85@example.org", + }, + { + "Email": "toto86@example.org", + }, + { + "Email": "toto87@example.org", + }, + { + "Email": "toto88@example.org", + }, + { + "Email": "toto89@example.org", + }, + { + "Email": "toto90@example.org", + }, + { + "Email": "toto91@example.org", + }, + { + "Email": "toto92@example.org", + }, + { + "Email": "toto93@example.org", + }, + { + "Email": "toto94@example.org", + }, + { + "Email": "toto95@example.org", + }, + { + "Email": "toto96@example.org", + }, + { + "Email": "toto97@example.org", + }, + ], + "Subject": "Subject", + "TextPart": "This is a message", + "To": [ + { + "Email": "plop@plop.plop", + }, + ], + }, + ], + [ + { + "Bcc": [ + { + "Email": "toto98@example.org", + }, + { + "Email": "toto99@example.org", + }, + ], + "Subject": "Subject", + "TextPart": "This is a message", + "To": [ + { + "Email": "plop@plop.plop", + }, + ], + }, + ], +] +`; + +exports[`mailjetSend > no group by 1`] = ` +[ + [ + { + "Subject": "Subject", + "TextPart": "This is a message", + "To": [ + { + "Email": "toto@example.org", + }, + ], + }, + ], +] +`; diff --git a/packages/api/src/tools/api-mailjet/emails.test.ts b/packages/api/src/tools/api-mailjet/emails.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..593a5c043794bdb39fafaa3bb917ea79afe5ed71 --- /dev/null +++ b/packages/api/src/tools/api-mailjet/emails.test.ts @@ -0,0 +1,30 @@ +import { test, vi, expect, describe, afterEach } from 'vitest' +import { mailjetSendForTestsOnly } from './emails' + +import { mailJetSendMail } from './index' +const mailJetSendMailMock = vi.mocked(mailJetSendMail, true) + +vi.mock('./index', () => ({ + __esModule: true, + mailjetAddContactsToGuyaneList: vi.fn().mockImplementation(a => a), + mailJetSendMail: vi.fn().mockImplementation(a => a), +})) + +describe('mailjetSend', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + test('group by', async () => { + await mailjetSendForTestsOnly( + [...Array(100).keys()].map(i => `toto${i}@example.org`), + { Subject: 'Subject', TextPart: 'This is a message' } + ) + expect(mailJetSendMailMock).toHaveBeenCalledTimes(3) + expect(mailJetSendMailMock.mock.calls).toMatchSnapshot() + }) + test('no group by', async () => { + await mailjetSendForTestsOnly(['toto@example.org'], { Subject: 'Subject', TextPart: 'This is a message' }) + expect(mailJetSendMailMock).toHaveBeenCalledOnce() + expect(mailJetSendMailMock.mock.calls).toMatchSnapshot() + }) +}) diff --git a/packages/api/src/tools/api-mailjet/emails.ts b/packages/api/src/tools/api-mailjet/emails.ts index a292a0a3e78d1c2873c6ad10529cdfa51fdafe82..0c946497654387c9fe248d8e489317a10aa588d3 100644 --- a/packages/api/src/tools/api-mailjet/emails.ts +++ b/packages/api/src/tools/api-mailjet/emails.ts @@ -4,32 +4,51 @@ import { EmailTemplateId } from './types' import { emailCheck } from '../email-check' import { config } from '../../config/index' import { isNotNullNorUndefined, OmitDistributive, onlyUnique } from 'camino-common/src/typescript-tools' +import { Chunk, Effect, Stream } from 'effect' +import { callAndExit } from '../fp-tools' -export const mailjetSend = async (emails: readonly string[], message: OmitDistributive<CaminoMailMessage, 'To'>): Promise<void> => { - try { - if (!Array.isArray(emails)) { - throw new Error(`un tableau d'emails est attendu ${emails}`) - } +const NOMBRE_DE_MAILS_EN_ENVOI_GROUPE = 49 - emails.forEach(to => { - if (!emailCheck(to)) { - throw new Error(`adresse email invalide ${to}`) - } - }) - - emails = emails.filter(isNotNullNorUndefined).filter(onlyUnique) - // si on est pas sur le serveur de prod - // l'adresse email du destinataire est remplacée - if (config().NODE_ENV !== 'production' || config().ENV !== 'prod') { - emails = [config().ADMIN_EMAIL] - } +export const mailjetSend = async (emails: readonly string[], message: OmitDistributive<CaminoMailMessage, 'To' | 'Bcc'>): Promise<void> => { + if (!Array.isArray(emails)) { + throw new Error(`un tableau d'emails est attendu ${emails}`) + } - const sendTo: MailjetPostMessageRecipient[] = emails.map(Email => ({ Email, Name: Email })) - const fullMessage: CaminoMailMessage = { - ...message, - To: sendTo, + emails.forEach(to => { + if (!emailCheck(to)) { + throw new Error(`adresse email invalide ${to}`) } - await mailJetSendMail(fullMessage) + }) + + // si on est pas sur le serveur de prod + // l'adresse email du destinataire est remplacée + if (config().NODE_ENV !== 'production' || config().ENV !== 'prod') { + await mailjetSendForTestsOnly([config().ADMIN_EMAIL], message) + } else { + await mailjetSendForTestsOnly(emails.filter(isNotNullNorUndefined).filter(onlyUnique), message) + } +} +export const mailjetSendForTestsOnly = async (emails: readonly string[], message: OmitDistributive<CaminoMailMessage, 'To' | 'Bcc'>): Promise<void> => { + try { + const sendTo: MailjetPostMessageRecipient[] = emails.map(Email => ({ Email })) + + await callAndExit( + Stream.runDrain( + Stream.fromIterable(sendTo).pipe( + Stream.grouped(NOMBRE_DE_MAILS_EN_ENVOI_GROUPE), + Stream.flatMap(emailsChunk => { + const emails = Chunk.toArray(emailsChunk) + return Effect.tryPromise(async () => { + const fullMessage: CaminoMailMessage = { + ...message, + ...(emails.length === 1 ? { To: emails } : { To: [{ Email: config().ADMIN_EMAIL }], Bcc: emails }), + } + await mailJetSendMail(fullMessage) + }) + }) + ) + ) + ) } catch (e: any) { console.error('erreur: emailsSend', e) throw new Error(e) diff --git a/packages/api/src/tools/api-mailjet/index.ts b/packages/api/src/tools/api-mailjet/index.ts index ace3034562d9991fb4c20f0b9737249fa93e3cf3..1bfb47427cb207d3603a416fb42d07c14389af1d 100644 --- a/packages/api/src/tools/api-mailjet/index.ts +++ b/packages/api/src/tools/api-mailjet/index.ts @@ -5,13 +5,20 @@ const basicCreds = Buffer.from(`${config().API_MAILJET_KEY}:${config().API_MAILJ export interface MailjetPostMessageRecipient { Email: string - Name: string } -interface MailjetPostMessageCommon { +type MailjetPostRecipients = + | { + To: MailjetPostMessageRecipient[] + Bcc?: MailjetPostMessageRecipient[] + } + | { + To?: MailjetPostMessageRecipient[] + Bcc: MailjetPostMessageRecipient[] + } +type MailjetPostMessageCommon = { From: MailjetPostMessageRecipient - ReplyTo: Omit<MailjetPostMessageRecipient, 'Name'> - To: MailjetPostMessageRecipient[] -} + ReplyTo: MailjetPostMessageRecipient +} & MailjetPostRecipients interface MailjetPostBodyMessage { Subject: string @@ -24,7 +31,7 @@ interface MailjetPostTemplateMessage { Variables: Record<string, string> } -export type CaminoMailMessage = { To: MailjetPostMessageRecipient[] } & (MailjetPostBodyMessage | MailjetPostTemplateMessage) +export type CaminoMailMessage = MailjetPostRecipients & (MailjetPostBodyMessage | MailjetPostTemplateMessage) type MailjetPostMessage = MailjetPostMessageCommon & (MailjetPostBodyMessage | MailjetPostTemplateMessage) interface MailjetPost { @@ -47,10 +54,9 @@ interface MailjetSendMailResponse { const From: MailjetPostMessageRecipient = { Email: config().API_MAILJET_EMAIL, - Name: 'Camino - le cadastre minier', } -const ReplyTo: Omit<MailjetPostMessageRecipient, 'Name'> = { +const ReplyTo: MailjetPostMessageRecipient = { Email: config().API_MAILJET_REPLY_TO_EMAIL, } @@ -114,7 +120,8 @@ export const mailJetSendMail = async (post: CaminoMailMessage): Promise<void> => if (values.Messages.find(message => message.Status !== 'success')) { console.warn(`Quelque chose s'est mal passé durant l'envoi des mails, réponse: ${JSON.stringify(values)}`) } else { - console.info(`Messages envoyés: ${post.To.map(({ Email }) => Email).join(', ')}, MessageIDs: ${values.Messages.flatMap(m => m.To.flatMap(to => to.MessageID)).join(', ')}`) + const recipients = [...(post.To ?? []), ...(post.Bcc ?? [])] + console.info(`Messages envoyés: ${recipients.map(({ Email }) => Email).join(', ')}, MessageIDs: ${values.Messages.flatMap(m => m.To.flatMap(to => to.MessageID)).join(', ')}`) } } else { console.error(`Une erreur est survenue lors de l'envoi des mails ${await result.text()}`) diff --git a/packages/api/tests/vitestSetup.ts b/packages/api/tests/vitestSetup.ts index 536cfaa034a68ec9dd0bf232fdc8b025f5165fe2..0ef18b4a976d274ca5ac251818bd45dd2c11ed5a 100644 --- a/packages/api/tests/vitestSetup.ts +++ b/packages/api/tests/vitestSetup.ts @@ -1,10 +1,5 @@ import { Request, Response } from 'express' import { vi } from 'vitest' -vi.mock('../src/tools/api-mailjet/emails', () => ({ - __esModule: true, - emailsSend: vi.fn().mockImplementation(a => a), - emailsWithTemplateSend: vi.fn().mockImplementation(a => a), -})) vi.mock('../src/tools/api-mailjet/index', () => ({ __esModule: true,