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 `
+
+
+
+ |
+
+
+
+
+
+ |
+
+ |
+
+ |
+
+`
+}
+
+export const renderFooter = () => {
+ const imageWidth = 125
+ const imageRatio = 36 / 125 // 36 is the height, 125 is the width
+ return `
+
+
+
+ |
+
+
+
+
+
+ |
+
+ |
+
+
+ Association Coworking Metz
+ 7, avenue de Blida - 57000 Metz
+ |
+
+ |
+
+ |
+
+`
+}
+
+// @see https://www.cerberusemail.com/
+export const renderHtmlLayout = htmlContent => `
+
+
+
+
+
+
+
+
+
+
+ ${renderHeader()}
+
+ |
+
+
+
+ ${htmlContent}
+ |
+
+
+ |
+
+ ${renderFooter()}
+
+ |
+
+
+
+
+`
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}
+
+
+
+
+
+
+
+ ${header()}
+ ${spacer(24)}
+
+
+ ${htmlContent}
+ |
+
+ ${spacer(28)}
+ ${footer()}
+ ${spacer(28)}
+
+
+
+
+
+`
+
// 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}
+
+
+ ${text}
+ |
+
+ ${append}
+
`
+
+export const alert = (text, prepend = spacer(24), append = spacer(24)) => `
+
+ ${prepend}
+
+
+ ${text}
+ |
+
+ ${append}
+
+`
+
+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 `
|
`
}
-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 `
|
`
}
-// @see https://www.cerberusemail.com/
-export const renderHtmlLayout = htmlContent => `
-
-
-
-
-
-
+const CSS_RESET = ``
+
+const PROGRESSIVE_ENHANCEMENT = ``
-
-
-
- ${renderHeader()}
-
- |
-
-
-
- ${htmlContent}
- |
-
-
- |
-
- ${renderFooter()}
-
- |
-
-
-
-
-`
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