From 2dae6562df72f82d3596e18ed782d66094b57a99 Mon Sep 17 00:00:00 2001 From: Matthieu Petit Date: Fri, 22 Mar 2024 15:36:34 +0100 Subject: [PATCH 1/5] feat(emails): add greenmail and roundcube to docker-compose --- README.md | 23 ++++++++++++++++++++--- docker-compose.yml | 24 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0737115..10a3f5f 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,7 @@ Or if you want some live-reload: yarn dev ``` -## Useful scripts - -### Send daily notification emails +## Email notifications Tickets bundles a script to send notification emails to members. You should consider executing this script once a day, at the end of the day. @@ -111,6 +109,25 @@ node scripts/send-notification-emails.js Pro-tips: use `crontab -e` +When improving emails and to make sure the email is properly rendered, you can start [Greenmail](https://greenmail-mail-test.github.io/greenmail/) and [Roundcube](https://roundcube.net/) with `docker-compose up`. +This will setup a SMTP/IMAP server and a WebUI for test purposes. + +Then properly set your local environment: +``` +SMTP_HOST=localhost +SMTP_PORT=33025 +SMTP_USER=anyUser +SMTP_PASS=anyPassword +``` + +You should use the following command to take into account the local config when sending emails: +```bash +node --env-file .env scripts/send-notification-emails.js +``` + +Once emails have been sent, you can read them at http://localhost:38000. +Enter the receiver email and any password as credentials to check its mailbox. + ## License This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details diff --git a/docker-compose.yml b/docker-compose.yml index 22ab4e2..c56970c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,5 +11,29 @@ services: volumes: - mongodb_data:/data/db + greenmail: + image: greenmail/standalone:1.6.14 + container_name: tickets-backend-greenmail + environment: + - GREENMAIL_OPTS=-Dgreenmail.hostname=0.0.0.0 -Dgreenmail.setup.test.smtp -Dgreenmail.setup.test.imap -Dgreenmail.auth.disabled + ports: + - "33025:3025" # SMTP + - "33143:3143" # IMAP + + roundcube: + image: roundcube/roundcubemail:1.6.1-apache + container_name: tickets-backend-roundcube + depends_on: + - greenmail + ports: + - "38000:80" + - "39000:9000" + environment: + ROUNDCUBEMAIL_DEFAULT_HOST: greenmail # IMAP server + ROUNDCUBEMAIL_DEFAULT_PORT: 3143 # IMAP port + ROUNDCUBEMAIL_SMTP_SERVER: greenmail # SMTP server + ROUNDCUBEMAIL_SMTP_PORT: 3025 # SMTP port + + volumes: mongodb_data: From 4511140d6db2dac3c13f87891ea59c54a9949e63 Mon Sep 17 00:00:00 2001 From: Matthieu Petit Date: Fri, 22 Mar 2024 15:41:40 +0100 Subject: [PATCH 2/5] feat(emails): add email layout to make them prettier --- lib/emails/fin-abonnement.js | 22 ++++--- lib/emails/layout.js | 116 ++++++++++++++++++++++++++++++++++ lib/emails/plus-de-tickets.js | 22 ++++--- 3 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 lib/emails/layout.js diff --git a/lib/emails/fin-abonnement.js b/lib/emails/fin-abonnement.js index 92c405c..bf4d2d9 100644 --- a/lib/emails/fin-abonnement.js +++ b/lib/emails/fin-abonnement.js @@ -1,17 +1,19 @@ +import {renderHtmlLayout} from './layout.js' + function render() { return { subject: 'Dernier jour de l\'abonnement', - text: `Bonjour, - -Votre dernier jour d’abonnement est arrivé à échéance. - + html: renderHtmlLayout(`Bonjour,
+
+Votre dernier jour d'abonnement est arrivé à échéance.
+
Comme je sais que nous allons être amenés à nous recroiser prochainement, voici le lien pour rempiler en abonnement ou en tickets : -https://www.coworking-metz.fr/la-boutique/ - -Marie-Poule du Poulailler - -PS: n’hésitez pas à nous contacter si vous rencontrez le moindre problème ! -` +https://www.coworking-metz.fr/la-boutique/
+
+Marie-Poule du Poulailler
+
+PS: n'hésitez pas à nous contacter si vous rencontrez le moindre problème ! +`) } } diff --git a/lib/emails/layout.js b/lib/emails/layout.js new file mode 100644 index 0000000..8e86c1b --- /dev/null +++ b/lib/emails/layout.js @@ -0,0 +1,116 @@ +// https://www.color-name.com/ +export const theme = { + meatBrown: '#EAB234', + maizeCrayola: '#EEC15D', + peachYellow: '#F7E0AE', + papayaWhip: '#FBF0D6', + darkVanilla: '#D9CB9E', + onyx: '#374140', + charlestonGreen: '#2A2C2B', + silverSand: '#BDC3C7', + blueCrayola: '#2962FF', + frenchSkyBlue: '#7FA1FF', + babyBlueEyes: '#A9C0FF', + azureishWhite: '#D4E0FF' +} + +const style = { + tableDelete: 'border:0; border-spacing:0; mso-table-lspace:0; mso-table-rspace:0; border-collapse:collapse', + imageDelete: 'border:0 none; line-height:100%; margin:0; outline:none; padding:0; text-decoration:none; vertical-align:bottom' +} + +export const renderHeader = () => { + const imageWidth = 192 + const imageRatio = 111 / 300 // 111 is the height, 300 is the width + return ` + + + + + + + + +
+ + Le Poulailler - Coworking Metz + +
+ + +` +} + +export const renderFooter = () => { + const imageWidth = 125 + const imageRatio = 36 / 125 // 36 is the height, 125 is the width + return ` + + + + + + + + + + + + +
+ + Le Poulailler - Coworking Metz + +
+ Association Coworking Metz
+ 7, avenue de Blida - 57000 Metz +
+ + +` +} + +// @see https://www.cerberusemail.com/ +export const renderHtmlLayout = htmlContent => ` + + + + + + + + +
+ + ${renderHeader()} + + + + + + + + + + ${renderFooter()} + + + +
+ ${htmlContent} +
+
+ +` diff --git a/lib/emails/plus-de-tickets.js b/lib/emails/plus-de-tickets.js index 81c5907..de7fdc6 100644 --- a/lib/emails/plus-de-tickets.js +++ b/lib/emails/plus-de-tickets.js @@ -1,17 +1,19 @@ +import {renderHtmlLayout} from './layout.js' + function render() { return { subject: 'Plus de ticket', - text: `Bonjour, - -Vous n’avez plus de tickets... - + html: renderHtmlLayout(`Bonjour,
+
+Vous n'avez plus de tickets...
+
Comme je sais que nous allons être amenés à nous recroiser prochainement, voici le lien pour rempiler en tickets ou en abonnement : -https://www.coworking-metz.fr/la-boutique/ - -Marie-Poule du Poulailler - -PS: n’hésitez pas à nous contacter si vous rencontrez le moindre problème ! -` +https://www.coworking-metz.fr/la-boutique/
+
+Marie-Poule du Poulailler
+
+PS: n'hésitez pas à nous contacter si vous rencontrez le moindre problème ! +`) } } From 67854c1f8429f62926672443fd0c2770815c3d17 Mon Sep 17 00:00:00 2001 From: Matthieu Petit Date: Fri, 12 Apr 2024 18:19:14 +0200 Subject: [PATCH 3/5] feat(emails): improve content to be nicer --- README.md | 2 + lib/emails/fin-abonnement.js | 22 ++- lib/emails/layout.js | 299 +++++++++++++++++++++++++++++----- lib/emails/plus-de-tickets.js | 21 ++- 4 files changed, 281 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 10a3f5f..9d4c816 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,8 @@ node --env-file .env scripts/send-notification-emails.js Once emails have been sent, you can read them at http://localhost:38000. Enter the receiver email and any password as credentials to check its mailbox. +If you want to quickly test the rendered result on multiple email client, go to https://testi.at/. + ## License This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details diff --git a/lib/emails/fin-abonnement.js b/lib/emails/fin-abonnement.js index bf4d2d9..d9069dc 100644 --- a/lib/emails/fin-abonnement.js +++ b/lib/emails/fin-abonnement.js @@ -1,18 +1,22 @@ -import {renderHtmlLayout} from './layout.js' +import process from 'node:process' +import {renderHtmlLayout, button, alert, spacer, theme} from './layout.js' + +const WORDPRESS_BASE_URL = process.env.WORDPRESS_BASE_URL || 'https://www.coworking-metz.fr' function render() { return { - subject: 'Dernier jour de l\'abonnement', + subject: 'Abonnement arrivé à échéance', html: renderHtmlLayout(`Bonjour,

-Votre dernier jour d'abonnement est arrivé à échéance.
-
-Comme je sais que nous allons être amenés à nous recroiser prochainement, voici le lien pour rempiler en abonnement ou en tickets : -https://www.coworking-metz.fr/la-boutique/
-
-Marie-Poule du Poulailler
+Vous continuez de venir au Coworking et nous apprécions votre visite.
+Cependant, il semble que votre abonnement soit arrivé à échéance. +Vous pouvez le renouveler en quelques clics à partir de la boutique : +${button('RENOUVELER MON ABONNEMENT', new URL('/boutique/pass-resident/', WORDPRESS_BASE_URL).toString())} +Merci pour votre compréhension et votre soutien 🫶

-PS: n'hésitez pas à nous contacter si vous rencontrez le moindre problème ! +À bientôt,
+L'équipe du Poulailler +${alert(`🙋‍♀️ Si vous rencontrez le moindre problème, contactez-nous à contact@coworking-metz.fr ou répondez à cet e-mail. Nous sommes là pour vous aider.`, spacer(20), '')} `) } } diff --git a/lib/emails/layout.js b/lib/emails/layout.js index 8e86c1b..6e6ee49 100644 --- a/lib/emails/layout.js +++ b/lib/emails/layout.js @@ -1,3 +1,57 @@ +// @see https://www.cerberusemail.com/ +export const renderHtmlLayout = (htmlContent, title) => ` + + + + + + + + ${title ? `${title}` : ''} + + + + + ${CSS_RESET} + ${PROGRESSIVE_ENHANCEMENT} + + + + + +` + // https://www.color-name.com/ export const theme = { meatBrown: '#EAB234', @@ -11,7 +65,9 @@ export const theme = { blueCrayola: '#2962FF', frenchSkyBlue: '#7FA1FF', babyBlueEyes: '#A9C0FF', - azureishWhite: '#D4E0FF' + azureishWhite: '#D4E0FF', + white: '#FFFFFF', + black: '#000000' } const style = { @@ -19,14 +75,43 @@ const style = { imageDelete: 'border:0 none; line-height:100%; margin:0; outline:none; padding:0; text-decoration:none; vertical-align:bottom' } -export const renderHeader = () => { +export const spacer = height => ` + +   + +` + +export const button = (text, href, prepend = spacer(24), append = spacer(24)) => ` + + ${prepend} + + + + ${append} +
+ ${text} +
` + +export const alert = (text, prepend = spacer(24), append = spacer(24)) => ` + + ${prepend} + + + + ${append} +
+

${text}

+
+` + +const header = () => { const imageWidth = 192 - const imageRatio = 111 / 300 // 111 is the height, 300 is the width + const imageRatio = 111 / 300 // Height by width return ` - + ${spacer(16)} - + ${spacer(16)}
@@ -40,21 +125,21 @@ export const renderHeader = () => {
` } -export const renderFooter = () => { - const imageWidth = 125 - const imageRatio = 36 / 125 // 36 is the height, 125 is the width +const footer = () => { + const imageWidth = 128 + const imageRatio = 87 / 300 // Height by width return ` - + ${spacer(16)} - + ${spacer(12)} - - + ${spacer(16)}
@@ -62,55 +147,179 @@ export const renderFooter = () => { title="Le Poulailler - Coworking Metz logo" width="${imageWidth}" height="${imageWidth * imageRatio}" - src="https://www.coworking-metz.fr/wp-content/uploads/2020/06/logo-lepoulailler-mobile.png" + src="https://www.coworking-metz.fr/wp-content/uploads/2016/05/logo-lepoulailler-300x87.png" style="${style.imageDelete}; width: 100%; max-width: ${imageWidth}px; height: auto; max-height: ${imageWidth * imageRatio}px" valign="bottom" />
+ Association Coworking Metz
- 7, avenue de Blida - 57000 Metz + 7 avenue de Blida, 57000 Metz
` } -// @see https://www.cerberusemail.com/ -export const renderHtmlLayout = htmlContent => ` - - - - - - +const CSS_RESET = `` + +const PROGRESSIVE_ENHANCEMENT = `` - -
- - ${renderHeader()} - - - - - - - - - - ${renderFooter()} - - - -
- ${htmlContent} -
-
- -` diff --git a/lib/emails/plus-de-tickets.js b/lib/emails/plus-de-tickets.js index de7fdc6..e0248cb 100644 --- a/lib/emails/plus-de-tickets.js +++ b/lib/emails/plus-de-tickets.js @@ -1,18 +1,21 @@ -import {renderHtmlLayout} from './layout.js' +import process from 'node:process' +import {button, renderHtmlLayout, alert, spacer, theme} from './layout.js' + +const WORDPRESS_BASE_URL = process.env.WORDPRESS_BASE_URL || 'https://www.coworking-metz.fr' function render() { return { - subject: 'Plus de ticket', + subject: 'Solde de ticket épuisé', html: renderHtmlLayout(`Bonjour,

-Vous n'avez plus de tickets...
-
-Comme je sais que nous allons être amenés à nous recroiser prochainement, voici le lien pour rempiler en tickets ou en abonnement : -https://www.coworking-metz.fr/la-boutique/
-
-Marie-Poule du Poulailler
+Nous vous remercions de votre venue au Poulailler.
+Cependant, votre solde de tickets est actuellement épuisé. +Pour continuer à profiter du lieu, nous vous invitons à vous rendre sur la boutique afin d'ajuster votre situation : +${button('CONSULTER LA BOUTIQUE', new URL('/la-boutique/', WORDPRESS_BASE_URL).toString())} +Merci pour votre compréhension et à très bientôt !

-PS: n'hésitez pas à nous contacter si vous rencontrez le moindre problème ! +L'équipe du Poulailler +${alert(`🙋‍♀️ Si vous rencontrez le moindre problème, contactez-nous à contact@coworking-metz.fr ou répondez à cet e-mail. Nous sommes là pour vous aider.`, spacer(24), '')} `) } } From f767a9edb16ab2808eed656531b87e2868fdad05 Mon Sep 17 00:00:00 2001 From: Matthieu Petit Date: Mon, 15 Apr 2024 20:06:03 +0200 Subject: [PATCH 4/5] feat(members): notify user balance depletion on arrival --- lib/models/member.js | 42 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/lib/models/member.js b/lib/models/member.js index d5ba91e..ec62b8a 100644 --- a/lib/models/member.js +++ b/lib/models/member.js @@ -1,15 +1,17 @@ import pMap from 'p-map' import {maxBy, sumBy, sortBy, chain, pick} from 'lodash-es' import {customAlphabet} from 'nanoid' -import {differenceInMinutes, sub} from 'date-fns' +import {add, differenceInMinutes, isSameDay, sub} from 'date-fns' import mongo from '../util/mongo.js' import {buildPictureUrl, getUser as getWpUser} from '../util/wordpress.js' import {computeSubcriptionEndDate, computeBalance} from '../calc.js' +import renderPlusDeTickets from '../emails/plus-de-tickets.js' import * as Device from './device.js' import * as Activity from './activity.js' +import {sendMail} from '../util/sendmail.js' const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz') export const LAST_SEEN_DELAY = 10 // Since when a member is marked as attending the location, in minutes @@ -217,10 +219,14 @@ export async function heartbeatMembers(memberIds, referenceDate) { throw new Error('Missing referenceDate') } - await mongo.db.collection('users').updateMany( - {_id: {$in: memberIds}}, - {$set: {'profile.heartbeat': referenceDate.toISOString()}} - ) + await Promise.all(memberIds.map(async memberId => { + const updatedUser = await mongo.db.collection('users').findOneAndUpdate( + {_id: memberId}, + {$set: {'profile.heartbeat': referenceDate.toISOString()}} + ) + + notifyUserBalanceDepletionOnArrival(updatedUser.value, referenceDate) + })) } /* Helpers */ @@ -310,3 +316,29 @@ export async function computeMemberFromUser(user, options = {}) { return member } + +/** + * Notify if the user is arriving + * and has no more tickets + * and has no ongoing subscription + */ +async function notifyUserBalanceDepletionOnArrival(user, referenceDate) { + const isArriving = !isSameDay(new Date(user.profile.heartbeat), referenceDate) + const isBalanceDepleted = user.profile.balance <= 0 + if (isArriving && isBalanceDepleted) { + // Check if there is a ongoing subscription + const nextDay = add(referenceDate, {days: 1}).toISOString().slice(0, 10) + const oneMonthBeforeNextDay = sub(new Date(nextDay), {months: 1}).toISOString().slice(0, 10) + const isSubscriptionOngoing = user.profile.abos.some( + abo => oneMonthBeforeNextDay < abo.aboStart && abo.aboStart <= nextDay + ) + + if (!isSubscriptionOngoing) { + // Send a notification email + sendMail( + renderPlusDeTickets(), + [user.email] + ) + } + } +} From 18818f00f62425764035c5cc57080928c18f77c3 Mon Sep 17 00:00:00 2001 From: Matthieu Petit Date: Wed, 24 Apr 2024 16:08:37 +0200 Subject: [PATCH 5/5] feat(members): use `isPresenceDuringAbo` to check ongoing subscription --- lib/models/member.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/models/member.js b/lib/models/member.js index ec62b8a..b47fcb4 100644 --- a/lib/models/member.js +++ b/lib/models/member.js @@ -1,12 +1,12 @@ import pMap from 'p-map' import {maxBy, sumBy, sortBy, chain, pick} from 'lodash-es' import {customAlphabet} from 'nanoid' -import {add, differenceInMinutes, isSameDay, sub} from 'date-fns' +import {differenceInMinutes, isSameDay, sub} from 'date-fns' import mongo from '../util/mongo.js' import {buildPictureUrl, getUser as getWpUser} from '../util/wordpress.js' -import {computeSubcriptionEndDate, computeBalance} from '../calc.js' +import {computeSubcriptionEndDate, computeBalance, isPresenceDuringAbo} from '../calc.js' import renderPlusDeTickets from '../emails/plus-de-tickets.js' import * as Device from './device.js' @@ -327,11 +327,7 @@ async function notifyUserBalanceDepletionOnArrival(user, referenceDate) { const isBalanceDepleted = user.profile.balance <= 0 if (isArriving && isBalanceDepleted) { // Check if there is a ongoing subscription - const nextDay = add(referenceDate, {days: 1}).toISOString().slice(0, 10) - const oneMonthBeforeNextDay = sub(new Date(nextDay), {months: 1}).toISOString().slice(0, 10) - const isSubscriptionOngoing = user.profile.abos.some( - abo => oneMonthBeforeNextDay < abo.aboStart && abo.aboStart <= nextDay - ) + const isSubscriptionOngoing = isPresenceDuringAbo(referenceDate, user.profile.abos) if (!isSubscriptionOngoing) { // Send a notification email