Skip to content

Commit 2b3c72c

Browse files
committed
feat: mail pooling, refactor of notification lib and configs
1 parent 73340f2 commit 2b3c72c

12 files changed

Lines changed: 506 additions & 289 deletions

File tree

README.md

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,16 @@ SMTP_SECURE=false
6060
SMTP_USER=notifications@example.com
6161
SMTP_PASSWORD=change-me
6262
SMTP_FROM=notifications@example.com
63+
SMTP_POOL_MAX_CONNECTIONS=1
64+
SMTP_POOL_MAX_MESSAGES=1000
6365
EMAIL_MAX_RETRIES=5
6466
```
6567

66-
`SMTP_SECURE=true` enables implicit TLS. Port `465` also enables it automatically. Failed sends are retried by the notification worker until `EMAIL_MAX_RETRIES` is reached.
68+
`SMTP_SECURE=true` enables implicit TLS. Port `465` also enables it automatically. SMTP pooling reuses authenticated connections; one connection can send up to `SMTP_POOL_MAX_MESSAGES` messages before reconnecting. Failed sends are retried by the notification worker until `EMAIL_MAX_RETRIES` is reached.
6769

68-
Email HTML templates live in `config/templates/email/*.ejs`. Their i18next translations live in `config/templates/email/locales/<language>.json`; the recipient's saved language is used with `FALLBACKLANG` as fallback.
70+
Notification registrations live in `config/notifications/*.js`. Email HTML templates live in `config/templates/email/*.ejs`. Core email translations live in `config/templates/email/locales/<language>.json`; feature translations can use `config/templates/email/locales/<featureName>/<language>.json`. The recipient's saved language is used with `FALLBACKLANG` as fallback.
6971

70-
Features can register additional queued email types while their startup module is loaded:
72+
Notification definitions are channel-independent. Current channel is `email`; future channels can be added without changing notification callers. Definitions use category `system` or `newsletter`. Newsletter registrations automatically appear in user settings.
7173

7274
Place feature-owned templates inside the feature package:
7375

@@ -83,36 +85,32 @@ config/templates/email/FoodOrderReady.ejs
8385
```
8486

8587
```js
86-
const {
87-
registerNotificationType,
88-
sendNotification,
89-
} = require('@lib/notifications');
90-
91-
registerNotificationType({
92-
type: 'FoodOrderReady',
93-
constant: 'FOOD_ORDER_READY',
94-
templatePath: 'config/templates/email/FoodOrderReady.ejs',
95-
translations: {
96-
de: {
97-
emails: {
98-
FoodOrderReady: {
99-
subject: 'Deine Bestellung ist fertig',
100-
heading: 'Bestellung abholbereit',
101-
},
88+
// installed_features/foodorders/config/notifications/foodorders.js
89+
module.exports = [
90+
{
91+
type: 'FoodOrderReady',
92+
constant: 'FOOD_ORDER_READY',
93+
category: 'newsletter',
94+
preferenceKey: 'foodorder-ready',
95+
translationKeyBase: 'FoodOrders.Notifications.Ready',
96+
requiresMessage: true,
97+
channels: {
98+
email: {
99+
templatePath: 'config/templates/email/FoodOrderReady.ejs',
100+
buildContext: (task) => ({
101+
order: JSON.parse(task.custom_message),
102+
}),
103+
buildText: (context) => `${context.name}, deine Bestellung ist fertig.`,
102104
},
103105
},
104106
},
105-
requiresMessage: true,
106-
buildContext: (task) => ({
107-
order: JSON.parse(task.custom_message),
108-
}),
109-
buildText: (context) => `${context.name}, deine Bestellung ist fertig.`,
110-
});
107+
];
111108

109+
const { sendNotification } = require('@lib/notifications');
112110
await sendNotification(userId, 0, 'FoodOrderReady', JSON.stringify(order));
113111
```
114112

115-
The type is stored in `email_tasks.type`. `templatePath` may be absolute or relative to the application root. Copied template files are tracked in the feature manifest and removed by the feature uninstaller. Feature API modules are loaded before the email worker starts, so their registrations are available when queued tasks are processed.
113+
The notification type is stored in `email_tasks.type`; the user preference channel is stored in `user_notifications.type`. `templatePath` may be absolute or relative to the application root. Copied registration, locale, and template files are tracked in the feature manifest and removed by the feature uninstaller. Notification config files are loaded before the email worker starts.
116114

117115
## OAuth Login
118116

@@ -206,16 +204,28 @@ Each feature folder needs a `feature.json` or `config.json` manifest:
206204

207205
On startup the app scans `installed_features/<featureName>`. Folder presence enables the feature. If the feature version is newer than `config/features/<featureName>.json`, or if the installed config does not exist yet, the feature files are copied into the application.
208206

209-
Supported feature folders include application folders such as `api`, `lib`, `src`, `views`, `public`, and `config`. A top-level `templates` folder is installed into `config/templates`. Feature translations live in:
207+
Supported feature folders include application folders such as `api`, `lib`, `src`, `views`, `public`, and `config`. A top-level `templates` folder is installed into `config/templates`. Feature page translations live in:
210208

211209
```text
212-
installed_features/<featureName>/local/<language>/<featureName>.json
210+
installed_features/<featureName>/locales/<language>/features/<featureName>.json
213211
```
214212

215213
and are installed to:
216214

217215
```text
218-
config/features/local/<language>/<featureName>.json
216+
config/locales/<language>/features/<featureName>.json
217+
```
218+
219+
Feature email translations live in:
220+
221+
```text
222+
installed_features/<featureName>/templates/email/locales/<featureName>/<language>.json
223+
```
224+
225+
and are installed to:
226+
227+
```text
228+
config/templates/email/locales/<featureName>/<language>.json
219229
```
220230

221231
Feature public files can also be served from:

api/users.js

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ const { getUserNotifications, setUserNotificationState } = require('@lib/sqlite/
66
const { getAllUserSessions, deleteAllWebtokensForUser } = require('@lib/sqlite/webtokens');
77
const { checkIfSettingTrue, getSetting } = require('@lib/sqlite/settings');
88
const { isEBGOAuthEnabled } = require('@lib/oauth');
9-
const { NOTIFICATION_TYPES, sendNotification } = require('@lib/notifications');
9+
const {
10+
NOTIFICATION_TYPES,
11+
NOTIFICATION_CHANNELS,
12+
getNewsletterNotifications,
13+
canSetNotificationPreference,
14+
sendNotification,
15+
} = require('@lib/notifications');
1016
const Joi = require('@lib/sanitizer');
1117
const bcrypt = require('bcrypt');
1218
const express = require('ultimate-express');
@@ -53,7 +59,7 @@ const notificationStateSchema = Joi.object({
5359

5460
const notificationParamsSchema = Joi.object({
5561
key: Joi.fullysanitizedString().pattern(/^[a-z0-9_-]+$/).min(1).max(64).required(),
56-
type: Joi.fullysanitizedString().valid('email').required()
62+
type: Joi.fullysanitizedString().valid(...Object.values(NOTIFICATION_CHANNELS)).required()
5763
});
5864

5965
const validateUUID = Joi.object({
@@ -132,7 +138,14 @@ router.post('/', limiter(20), async (req, res) => {
132138

133139
router.get('/', verifyRequest('web.user.read'), limiter(1), async (req, res) => {
134140
const user_data = await getUser(req.user.user_data.id);
135-
user_data.notifications = getUserNotifications(req.user.user_data.id);
141+
const preferences = getUserNotifications(req.user.user_data.id);
142+
user_data.notifications = preferences;
143+
user_data.notificationSettings = getNewsletterNotifications().map((definition) => ({
144+
...definition,
145+
enabled: preferences.find((preference) =>
146+
preference.key === definition.key && preference.type === definition.channel
147+
)?.state !== false,
148+
}));
136149
return res.json(user_data)
137150
});
138151

@@ -187,6 +200,9 @@ router.put('/language', verifyRequest('app.user.settings.language.write'), limit
187200
router.put('/notifications/:key/:type', verifyRequest('app.user.settings.email.write'), limiter(10), async (req, res) => {
188201
const params = await notificationParamsSchema.validateAsync(req.params);
189202
const body = await notificationStateSchema.validateAsync(req.body);
203+
if (!canSetNotificationPreference(params.key, params.type)) {
204+
return res.status(404).json({ error: 'Notification preference not found' });
205+
}
190206
setUserNotificationState(req.user.user_data.id, params.key, params.type, body.enabled);
191207
return res.json({ message: 'Notification preference updated successfully' });
192208
});

config/locals_map.js

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,14 +119,6 @@ module.exports = {
119119
"Error",
120120
"Page"
121121
],
122-
"/settings": [
123-
"Settings",
124-
"Error",
125-
"Page",
126-
"Setup",
127-
"Navbar",
128-
"Categories"
129-
],
130122
"/setup": [
131123
"Setup",
132124
"Error",

config/notifications/core.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
const path = require('node:path');
2+
3+
const emailTemplateDir = path.join(__dirname, '..', 'templates', 'email');
4+
5+
module.exports = [
6+
{
7+
type: 'RegMail',
8+
constant: 'REG_MAIL',
9+
category: 'system',
10+
channels: {
11+
email: { templatePath: path.join(emailTemplateDir, 'RegMail.ejs') },
12+
},
13+
},
14+
{
15+
type: 'DeleteAcc',
16+
constant: 'DELETE_ACCOUNT',
17+
category: 'system',
18+
channels: {
19+
email: { templatePath: path.join(emailTemplateDir, 'DeleteAcc.ejs') },
20+
},
21+
},
22+
{
23+
type: 'Custom',
24+
constant: 'CUSTOM',
25+
category: 'system',
26+
requiresMessage: true,
27+
channels: {
28+
email: { templatePath: path.join(emailTemplateDir, 'Custom.ejs') },
29+
},
30+
},
31+
{
32+
type: 'Discounts',
33+
constant: 'DISCOUNTS',
34+
category: 'newsletter',
35+
preferenceKey: 'discount',
36+
translationKeyBase: 'Settings.DiscountEmails',
37+
requiresMessage: true,
38+
channels: {
39+
email: {
40+
templatePath: path.join(emailTemplateDir, 'Discounts.ejs'),
41+
buildContext: (task) => ({
42+
discountItems: JSON.parse(task.custom_message).items || [],
43+
}),
44+
buildText: (context) => [
45+
context.t('emails.greeting', { name: context.name }),
46+
'',
47+
context.t('emails.Discounts.body'),
48+
...context.discountItems.map((item) =>
49+
`${item.name}: ${Number(item.discount_price).toFixed(2)} € ` +
50+
`(${context.t('emails.Discounts.until', {
51+
date: new Date(item.discount_until).toLocaleString(context.language),
52+
})})`
53+
),
54+
'',
55+
context.domain,
56+
].join('\n'),
57+
},
58+
},
59+
},
60+
];

index.js

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ const { log } = require('@lib/logger');
88

99
const fs = require('node:fs');
1010
const path = require('node:path');
11+
const { deepMerge } = require('@lib/utils');
1112

1213
process.log = {};
1314
process.log = log;
1415

15-
const { installFeatures, featureLocalesDir } = require('@lib/features');
16+
const { installFeatures } = require('@lib/features');
1617
installFeatures();
1718

1819
// Render Templates
@@ -21,25 +22,44 @@ installFeatures();
2122
const localesDir = path.join(__dirname, 'config', 'locales');
2223

2324
let availableLanguages = {};
24-
let availableFeatureLanguages = {};
2525
let countryConfig = {};
2626

27+
const mergeLanguageBundles = (target, bundleDir) => {
28+
fs.readdirSync(bundleDir, { withFileTypes: true }).forEach(entry => {
29+
const entryPath = path.join(bundleDir, entry.name);
30+
if (entry.isDirectory()) {
31+
mergeLanguageBundles(target, entryPath);
32+
return;
33+
}
34+
if (!entry.name.endsWith('.json')) return;
35+
36+
deepMerge(target, JSON.parse(fs.readFileSync(entryPath, 'utf8')));
37+
});
38+
};
39+
2740
// Recursive loader for language components
2841
const loadLanguageComponents = (langDir) => {
2942
const components = {};
3043
const files = fs.readdirSync(langDir);
44+
const featureBundlesDir = path.join(langDir, 'features');
3145

3246
files.forEach(file => {
3347
const filePath = path.join(langDir, file);
48+
if (filePath === featureBundlesDir) return;
49+
3450
if (fs.lstatSync(filePath).isDirectory()) {
35-
components[file] = loadLanguageComponents(filePath); // Recurse into subdirectories
51+
components[file] = loadLanguageComponents(filePath);
3652
} else if (file.endsWith('.json')) {
3753
const componentName = path.basename(file, '.json');
3854
const fileContents = fs.readFileSync(filePath, 'utf8');
3955
components[componentName] = JSON.parse(fileContents);
4056
}
4157
});
4258

59+
if (fs.existsSync(featureBundlesDir)) {
60+
mergeLanguageBundles(components, featureBundlesDir);
61+
}
62+
4363
return components;
4464
}
4565

@@ -49,24 +69,12 @@ languages.forEach(langCode => {
4969
const langDir = path.join(localesDir, langCode);
5070
if (fs.lstatSync(langDir).isDirectory()) {
5171
availableLanguages[langCode] = loadLanguageComponents(langDir);
52-
const featureLangDir = path.join(featureLocalesDir, langCode);
53-
availableFeatureLanguages[langCode] = {};
54-
if (fs.existsSync(featureLangDir) && fs.lstatSync(featureLangDir).isDirectory()) {
55-
fs.readdirSync(featureLangDir)
56-
.filter(file => file.endsWith('.json'))
57-
.forEach(file => {
58-
const featureName = path.basename(file, '.json');
59-
const fileContents = fs.readFileSync(path.join(featureLangDir, file), 'utf8');
60-
availableFeatureLanguages[langCode][featureName] = JSON.parse(fileContents);
61-
});
62-
}
6372
countryConfig[langCode] = availableLanguages[langCode].LocalLanguages.local;
6473
}
6574
});
6675

6776
// Expose globals
6877
process.availableLanguages = availableLanguages; // Used for template rendering for different languages
69-
process.availableFeatureLanguages = availableFeatureLanguages; // Feature-owned translations, injected only when enabled
7078
process.countryConfig = countryConfig; // Used for language dropdowns
7179
process.localsMap = require('@config/locals_map.js'); // Used for template rendering for different languages
7280
process.permissions_config = require('@config/permissions.js');

lib/features.js

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ const path = require('node:path');
44
const appRoot = path.join(__dirname, '..');
55
const installedFeaturesDir = path.join(appRoot, 'installed_features');
66
const featureConfigDir = path.join(appRoot, 'config', 'features');
7-
const featureLocalesDir = path.join(featureConfigDir, 'local');
87
const featureMigrationRoot = path.join(appRoot, 'migrations', 'features');
98
const featureSeedRoot = path.join(appRoot, 'seeds', 'features');
109
const localsMapPath = path.join(appRoot, 'config', 'locals_map.js');
@@ -206,8 +205,8 @@ const copyRecursive = (sourcePath, targetPath, installedFiles) => {
206205

207206
/**
208207
* Copies all installable files from a feature package into their application locations.
209-
* Special folders are routed to shared feature config locations:
210-
* local -> config/features/local, templates -> config/templates,
208+
* Special folders are routed to shared application locations:
209+
* locales -> config/locales, templates -> config/templates,
211210
* migrations -> migrations/features/<feature>, seeds -> seeds/features/<feature>.
212211
* Other top-level entries are copied relative to the app root.
213212
* @param {String} featureDir Source directory under installed_features.
@@ -225,8 +224,8 @@ const copyFeatureFiles = (featureDir, featureName, manifest) => {
225224
const sourcePath = path.join(featureDir, dirent.name);
226225
let targetPath;
227226

228-
if (dirent.name === 'local') {
229-
targetPath = path.join(featureLocalesDir);
227+
if (dirent.name === 'locales') {
228+
targetPath = path.join(appRoot, 'config', 'locales');
230229
} else if (dirent.name === 'templates') {
231230
targetPath = path.join(appRoot, 'config', 'templates');
232231
} else if (dirent.name === 'migrations') {
@@ -278,7 +277,6 @@ const applyFeatureLocalsMap = (featureName, manifest) => {
278277
const installFeatures = () => {
279278
fs.mkdirSync(installedFeaturesDir, { recursive: true });
280279
fs.mkdirSync(featureConfigDir, { recursive: true });
281-
fs.mkdirSync(featureLocalesDir, { recursive: true });
282280

283281
const installed = [];
284282

@@ -362,7 +360,6 @@ const getFeaturePublicFilePath = (featureName, publicPath) => {
362360
module.exports = {
363361
installedFeaturesDir,
364362
featureConfigDir,
365-
featureLocalesDir,
366363
installFeatures,
367364
loadFeatureDefinitions,
368365
getFeaturePublicFilePath,

0 commit comments

Comments
 (0)