webspresso 0.0.97 → 0.0.98

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/.agents/ADMIN.md +153 -5
  2. package/.agents/ARCHITECTURE.md +10 -0
  3. package/.agents/CLI.md +195 -1
  4. package/.agents/PLUGINS.md +153 -9
  5. package/.agents/QUEUE.md +43 -0
  6. package/bin/commands/add-esbuild.js +80 -0
  7. package/bin/commands/add-tailwind.js +76 -31
  8. package/bin/commands/analytics-prune.js +57 -0
  9. package/bin/commands/api.js +207 -15
  10. package/bin/commands/build.js +99 -0
  11. package/bin/commands/dev.js +64 -35
  12. package/bin/commands/doctor.js +26 -0
  13. package/bin/commands/email-prune.js +47 -0
  14. package/bin/commands/make-job.js +86 -0
  15. package/bin/commands/make-model.js +250 -0
  16. package/bin/commands/new.js +45 -28
  17. package/bin/commands/openapi.js +102 -0
  18. package/bin/commands/page.js +6 -3
  19. package/bin/commands/queue-prune.js +59 -0
  20. package/bin/commands/routes.js +52 -0
  21. package/bin/commands/service.js +6 -3
  22. package/bin/commands/typegen.js +106 -0
  23. package/bin/commands/upload-prune.js +79 -0
  24. package/bin/webspresso.js +22 -0
  25. package/core/applySchema.js +13 -6
  26. package/core/auth/hash.js +21 -12
  27. package/core/compression/middleware.js +23 -17
  28. package/core/openapi/build-from-api-routes.js +160 -29
  29. package/core/orm/cache/fingerprint.js +15 -1
  30. package/core/orm/cache/memory-provider.js +27 -2
  31. package/core/orm/index.js +12 -7
  32. package/core/orm/model.js +49 -7
  33. package/core/queue/adapters/database.js +94 -0
  34. package/core/queue/adapters/memory.js +62 -0
  35. package/core/queue/adapters/redis.js +80 -0
  36. package/core/queue/manager.js +123 -0
  37. package/core/ssr/stream.js +11 -0
  38. package/index.d.ts +660 -10
  39. package/index.js +44 -96
  40. package/package.json +1 -1
  41. package/plugins/admin-panel/api.js +279 -17
  42. package/plugins/admin-panel/app.js +114 -22
  43. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  44. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  45. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
  46. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  47. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  48. package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
  49. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  50. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  51. package/plugins/admin-panel/core/admin-module.js +13 -7
  52. package/plugins/admin-panel/core/api-extensions.js +30 -2
  53. package/plugins/admin-panel/core/registry.js +96 -16
  54. package/plugins/admin-panel/index.js +96 -0
  55. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  56. package/plugins/admin-panel/modules/admin-users.js +16 -1
  57. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  58. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  59. package/plugins/admin-panel/modules/dashboard.js +283 -0
  60. package/plugins/admin-panel/modules/menu.js +56 -18
  61. package/plugins/admin-panel/modules/user-management.js +24 -6
  62. package/plugins/audit-log/admin-component.js +11 -0
  63. package/plugins/audit-log/api-handlers.js +34 -0
  64. package/plugins/audit-log/index.js +129 -3
  65. package/plugins/audit-log/purge.js +60 -10
  66. package/plugins/dashboard/app.js +152 -27
  67. package/plugins/dashboard/index.js +143 -39
  68. package/plugins/dashboard/styles.js +78 -0
  69. package/plugins/data-exchange/export-xlsx.js +9 -2
  70. package/plugins/data-exchange/import.js +9 -2
  71. package/plugins/data-exchange/index.js +6 -3
  72. package/plugins/email/index.js +132 -2
  73. package/plugins/email/purge.js +60 -10
  74. package/plugins/index.js +4 -0
  75. package/plugins/orm-cache-admin/index.js +4 -2
  76. package/plugins/queue/index.js +11 -0
  77. package/plugins/schema-explorer/admin-component.js +714 -0
  78. package/plugins/schema-explorer/relations-graph.js +205 -0
  79. package/plugins/schema-explorer.js +248 -69
  80. package/plugins/site-analytics/admin-component.js +23 -0
  81. package/plugins/site-analytics/api-handlers.js +42 -0
  82. package/plugins/site-analytics/index.js +147 -2
  83. package/plugins/site-analytics/purge.js +168 -0
  84. package/plugins/swagger.js +261 -13
  85. package/plugins/upload/index.js +26 -0
  86. package/plugins/upload/orphan-pruner.js +292 -0
  87. package/src/api/api-loader.js +3 -1
  88. package/src/bundle/index.js +517 -0
  89. package/src/bundle/manifest.js +165 -0
  90. package/src/dev/banner.js +296 -0
  91. package/src/dev/colors.js +70 -0
  92. package/src/dev/index.js +62 -0
  93. package/src/dev/profiler.js +354 -0
  94. package/src/dev/typegen.js +497 -0
  95. package/src/dev-reload/index.js +611 -0
  96. package/src/file-router.js +210 -62
  97. package/src/helpers.js +958 -98
  98. package/src/njk-frontmatter.js +12 -0
  99. package/src/pages/page-loader.js +61 -11
  100. package/src/server.js +629 -63
@@ -1,29 +1,92 @@
1
+ 'use strict';
2
+
1
3
  /**
2
4
  * Webspresso Dashboard Plugin
3
- * Development dashboard for monitoring routes, plugins, and configuration
5
+ * Development dashboard for monitoring routes, ORM models, services, plugins, and configuration
4
6
  * Uses Mithril.js for the SPA interface
7
+ * @module plugins/dashboard
5
8
  */
6
9
 
7
10
  const styles = require('./styles');
8
11
  const appScript = require('./app');
12
+ const { getAllModels } = require('../../core/orm/model');
13
+ const { getServiceRegistry, hasServiceRegistry, hasDb, getDb } = require('../../src/app-context');
9
14
 
10
15
  /**
11
16
  * Filter sensitive environment variables
17
+ * @param {Object} env
18
+ * @returns {Object}
12
19
  */
13
20
  function filterSensitiveEnv(env) {
14
21
  const sensitiveKeys = ['SECRET', 'PASSWORD', 'KEY', 'TOKEN', 'CREDENTIAL', 'PRIVATE'];
15
22
  const filtered = {};
16
-
23
+
17
24
  for (const [key, value] of Object.entries(env)) {
18
- const isSensitive = sensitiveKeys.some(s => key.toUpperCase().includes(s));
25
+ const isSensitive = sensitiveKeys.some((s) => key.toUpperCase().includes(s));
19
26
  filtered[key] = isSensitive ? '••••••••' : value;
20
27
  }
21
-
28
+
22
29
  return filtered;
23
30
  }
24
31
 
32
+ /**
33
+ * Safely collects all registered ORM models
34
+ * @returns {Array<Object>}
35
+ */
36
+ function collectModels() {
37
+ try {
38
+ const models = getAllModels();
39
+ const result = [];
40
+ for (const [name, model] of models.entries()) {
41
+ result.push({
42
+ name,
43
+ table: model.table || name.toLowerCase(),
44
+ primaryKey: model.primaryKey || 'id',
45
+ columns: model.schema && model.schema.shape ? Object.keys(model.schema.shape) : [],
46
+ columnCount: model.schema && model.schema.shape ? Object.keys(model.schema.shape).length : 0,
47
+ relations: Object.keys(model.relations || {}),
48
+ scopes: Object.keys(model.scopes || {}),
49
+ softDelete: Boolean(model.scopes?.softDelete || model.scopes?.notDeleted || model.softDelete || model.paranoid),
50
+ cache: model.cache || false,
51
+ });
52
+ }
53
+ return result;
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Safely collects all registered business services
61
+ * @returns {Array<Object>}
62
+ */
63
+ function collectServices() {
64
+ try {
65
+ if (!hasServiceRegistry()) return [];
66
+ const registry = getServiceRegistry();
67
+ const result = [];
68
+ if (registry && registry.services) {
69
+ for (const [name, def] of registry.services.entries()) {
70
+ result.push({
71
+ name,
72
+ transaction: Boolean(def.transaction),
73
+ auth: def.auth !== undefined ? def.auth : false,
74
+ timeout: def.timeout || null,
75
+ hasSchema: Boolean(def.schema),
76
+ cache: def.cache ? true : false,
77
+ });
78
+ }
79
+ }
80
+ return result;
81
+ } catch {
82
+ return [];
83
+ }
84
+ }
85
+
25
86
  /**
26
87
  * Generate the dashboard HTML
88
+ * @param {Object} data
89
+ * @returns {string}
27
90
  */
28
91
  function generateDashboardHtml(data) {
29
92
  return `<!DOCTYPE html>
@@ -48,25 +111,25 @@ function generateDashboardHtml(data) {
48
111
  /**
49
112
  * Create the dashboard plugin
50
113
  * @param {Object} options - Plugin options
51
- * @param {string} options.path - Dashboard path (default: '/_webspresso')
52
- * @param {boolean} options.enabled - Force enable/disable (default: auto based on NODE_ENV)
114
+ * @param {string} [options.path='/_webspresso'] - Dashboard path
115
+ * @param {boolean} [options.enabled] - Force enable/disable (default: auto based on NODE_ENV)
53
116
  */
54
117
  function dashboardPlugin(options = {}) {
55
118
  const {
56
119
  path: dashboardPath = '/_webspresso',
57
- enabled
120
+ enabled,
58
121
  } = options;
59
-
122
+
60
123
  // Determine if enabled (default: only in development)
61
- const isEnabled = enabled !== undefined
62
- ? enabled
124
+ const isEnabled = enabled !== undefined
125
+ ? enabled
63
126
  : process.env.NODE_ENV !== 'production';
64
-
127
+
65
128
  return {
66
129
  name: 'dashboard',
67
130
  version: '1.0.0',
68
- description: 'Development dashboard for monitoring routes and configuration',
69
-
131
+ description: 'Development dashboard for monitoring routes, models, services, and configuration',
132
+
70
133
  /**
71
134
  * Called after routes are mounted
72
135
  */
@@ -75,68 +138,109 @@ function dashboardPlugin(options = {}) {
75
138
  if (!isEnabled) {
76
139
  return;
77
140
  }
78
-
79
- const { app, routes, options: appOptions } = ctx;
80
-
141
+
142
+ const { app, routes = [], options: appOptions = {} } = ctx;
143
+
81
144
  // Collect plugin info
82
- const pluginManager = ctx.pluginManager || (appOptions && appOptions.pluginManager);
145
+ const pluginManager = ctx.pluginManager || appOptions.pluginManager;
83
146
  const plugins = [];
84
-
147
+
85
148
  // Get plugins from plugin manager if available
86
149
  if (pluginManager && pluginManager.plugins) {
87
150
  for (const [name, plugin] of pluginManager.plugins) {
88
151
  plugins.push({
89
152
  name: plugin.name || name,
90
153
  version: plugin.version || '0.0.0',
91
- description: plugin.description || ''
154
+ description: plugin.description || '',
92
155
  });
93
156
  }
94
157
  }
95
-
158
+
159
+ // Collect models and services
160
+ const models = collectModels();
161
+ const services = collectServices();
162
+
96
163
  // Build config data
97
164
  const config = {
98
165
  env: filterSensitiveEnv({
99
166
  NODE_ENV: process.env.NODE_ENV || 'development',
100
167
  PORT: process.env.PORT || '3000',
101
- BASE_URL: process.env.BASE_URL || 'http://localhost:3000'
168
+ BASE_URL: process.env.BASE_URL || 'http://localhost:3000',
102
169
  }),
103
170
  i18n: {
104
171
  defaultLocale: process.env.DEFAULT_LOCALE || 'en',
105
- supportedLocales: (process.env.SUPPORTED_LOCALES || 'en').split(',')
172
+ supportedLocales: (process.env.SUPPORTED_LOCALES || 'en').split(','),
106
173
  },
107
174
  server: {
108
175
  port: process.env.PORT || '3000',
109
- baseUrl: process.env.BASE_URL || 'http://localhost:3000'
110
- }
176
+ baseUrl: process.env.BASE_URL || 'http://localhost:3000',
177
+ },
178
+ database: {
179
+ connected: hasDb(),
180
+ dialect: hasDb() ? (getDb().client || getDb().knex?.client?.config?.client || 'knex') : 'none',
181
+ },
111
182
  };
112
-
113
- // Dashboard HTML endpoint
183
+
184
+ const overview = {
185
+ routesCount: routes.length,
186
+ modelsCount: models.length,
187
+ servicesCount: services.length,
188
+ pluginsCount: plugins.length,
189
+ uptime: process.uptime(),
190
+ memoryUsage: process.memoryUsage(),
191
+ };
192
+
193
+ // 1. Dashboard HTML endpoint
114
194
  ctx.addRoute('get', dashboardPath, (req, res) => {
115
- const data = { routes, plugins, config };
195
+ const data = { routes, plugins, config, models, services, overview };
116
196
  res.type('text/html');
117
197
  res.send(generateDashboardHtml(data));
118
198
  });
119
-
120
- // JSON API endpoints
121
- ctx.addRoute('get', dashboardPath + '/api/routes', (req, res) => {
199
+
200
+ // 2. JSON API endpoints
201
+ ctx.addRoute('get', `${dashboardPath}/api/routes`, (req, res) => {
122
202
  res.json(routes);
123
203
  });
124
-
125
- ctx.addRoute('get', dashboardPath + '/api/plugins', (req, res) => {
204
+
205
+ ctx.addRoute('get', `${dashboardPath}/api/plugins`, (req, res) => {
126
206
  res.json(plugins);
127
207
  });
128
-
129
- ctx.addRoute('get', dashboardPath + '/api/config', (req, res) => {
208
+
209
+ ctx.addRoute('get', `${dashboardPath}/api/config`, (req, res) => {
130
210
  res.json(config);
131
211
  });
132
-
212
+
213
+ ctx.addRoute('get', `${dashboardPath}/api/models`, (req, res) => {
214
+ res.json(collectModels());
215
+ });
216
+
217
+ ctx.addRoute('get', `${dashboardPath}/api/services`, (req, res) => {
218
+ res.json(collectServices());
219
+ });
220
+
221
+ ctx.addRoute('get', `${dashboardPath}/api/overview`, (req, res) => {
222
+ res.json({
223
+ routesCount: (ctx.routes || []).length,
224
+ modelsCount: collectModels().length,
225
+ servicesCount: collectServices().length,
226
+ pluginsCount: plugins.length,
227
+ nodeVersion: process.version,
228
+ platform: process.platform,
229
+ uptime: process.uptime(),
230
+ memoryUsage: process.memoryUsage(),
231
+ });
232
+ });
233
+
133
234
  // Log dashboard URL
134
235
  console.log(`\n📊 Dashboard available at: http://localhost:${process.env.PORT || 3000}${dashboardPath}\n`);
135
- }
236
+ },
136
237
  };
137
238
  }
138
239
 
139
240
  module.exports = dashboardPlugin;
140
-
141
-
142
-
241
+ module.exports.dashboardPlugin = dashboardPlugin;
242
+ module.exports.devToolbarPlugin = dashboardPlugin;
243
+ module.exports.devtoolsPlugin = dashboardPlugin;
244
+ module.exports.generateDashboardHtml = generateDashboardHtml;
245
+ module.exports.collectModels = collectModels;
246
+ module.exports.collectServices = collectServices;
@@ -378,6 +378,84 @@ tr:hover td {
378
378
  animation: spin 0.8s linear infinite;
379
379
  margin-right: 12px;
380
380
  }
381
+
382
+ /* Header Actions */
383
+ .header-actions {
384
+ display: flex;
385
+ align-items: center;
386
+ gap: 8px;
387
+ }
388
+
389
+ .action-link {
390
+ display: inline-flex;
391
+ align-items: center;
392
+ gap: 6px;
393
+ padding: 6px 12px;
394
+ border-radius: 6px;
395
+ font-size: 13px;
396
+ font-weight: 500;
397
+ text-decoration: none;
398
+ background: var(--bg-tertiary);
399
+ border: 1px solid var(--border);
400
+ color: var(--text-primary);
401
+ transition: all 0.15s ease;
402
+ }
403
+
404
+ .action-link:hover {
405
+ background: var(--border);
406
+ border-color: var(--accent);
407
+ color: var(--text-primary);
408
+ }
409
+
410
+ /* Badges */
411
+ .badge {
412
+ display: inline-block;
413
+ padding: 2px 8px;
414
+ border-radius: 10px;
415
+ font-size: 11px;
416
+ font-weight: 600;
417
+ text-transform: uppercase;
418
+ }
419
+
420
+ .badge-green {
421
+ background: rgba(63, 185, 80, 0.15);
422
+ color: var(--green);
423
+ border: 1px solid rgba(63, 185, 80, 0.3);
424
+ }
425
+
426
+ .badge-blue {
427
+ background: rgba(88, 166, 255, 0.15);
428
+ color: var(--accent);
429
+ border: 1px solid rgba(88, 166, 255, 0.3);
430
+ }
431
+
432
+ .badge-purple {
433
+ background: rgba(163, 113, 247, 0.15);
434
+ color: var(--purple);
435
+ border: 1px solid rgba(163, 113, 247, 0.3);
436
+ }
437
+
438
+ .badge-orange {
439
+ background: rgba(210, 153, 34, 0.15);
440
+ color: var(--orange);
441
+ border: 1px solid rgba(210, 153, 34, 0.3);
442
+ }
443
+
444
+ .badge-gray {
445
+ background: rgba(110, 118, 129, 0.15);
446
+ color: var(--text-muted);
447
+ border: 1px solid rgba(110, 118, 129, 0.3);
448
+ }
449
+
450
+ .code-badge {
451
+ font-family: 'SF Mono', SFMono-Regular, ui-monospace, monospace;
452
+ font-size: 12px;
453
+ background: var(--bg-tertiary);
454
+ padding: 2px 6px;
455
+ border-radius: 4px;
456
+ border: 1px solid var(--border);
457
+ color: var(--accent);
458
+ }
381
459
  `;
382
460
 
383
461
 
@@ -3,7 +3,13 @@
3
3
  * @module plugins/data-exchange/export-xlsx
4
4
  */
5
5
 
6
- const ExcelJS = require('exceljs');
6
+ let ExcelJS = null;
7
+ function getExcelJS() {
8
+ if (!ExcelJS) {
9
+ ExcelJS = require('exceljs');
10
+ }
11
+ return ExcelJS;
12
+ }
7
13
  const { sanitizeForOutput } = require('../../core/orm/utils');
8
14
  const { resolveExportRecords } = require('./record-selection');
9
15
 
@@ -42,7 +48,8 @@ async function buildXlsxBuffer(model, records) {
42
48
  const clean = sanitizeForOutput(records, model);
43
49
  const columns = getModelExportColumns(model, clean);
44
50
 
45
- const wb = new ExcelJS.Workbook();
51
+ const ExcelLib = getExcelJS();
52
+ const wb = new ExcelLib.Workbook();
46
53
  const ws = wb.addWorksheet('Export', {
47
54
  views: [{ state: 'frozen', ySplit: 1 }],
48
55
  });
@@ -4,7 +4,13 @@
4
4
  */
5
5
 
6
6
  const multer = require('multer');
7
- const ExcelJS = require('exceljs');
7
+ let ExcelJS = null;
8
+ function getExcelJS() {
9
+ if (!ExcelJS) {
10
+ ExcelJS = require('exceljs');
11
+ }
12
+ return ExcelJS;
13
+ }
8
14
  const { parse: parseCsv } = require('csv-parse/sync');
9
15
  const { buildHeaderMapping, dataRowsToObjects } = require('./parse-table');
10
16
 
@@ -132,7 +138,8 @@ function parseCsvToRows(buffer) {
132
138
  }
133
139
 
134
140
  async function parseXlsxToRows(buffer) {
135
- const wb = new ExcelJS.Workbook();
141
+ const ExcelLib = getExcelJS();
142
+ const wb = new ExcelLib.Workbook();
136
143
  await wb.xlsx.load(buffer);
137
144
  const ws = wb.worksheets[0];
138
145
  if (!ws) return [];
@@ -76,14 +76,17 @@ function dataExchangePlugin(options = {}) {
76
76
  models: '*',
77
77
  confirm: false,
78
78
  handler: async (records, modelName, { db: d }) => {
79
+ const targetModel = typeof modelName === 'object' && modelName !== null
80
+ ? (modelName.name || modelName.modelName || modelName.model || String(modelName))
81
+ : String(modelName);
79
82
  const { getModel } = require('../../core/orm/model');
80
- const model = d.getModel ? d.getModel(modelName) : getModel(modelName);
83
+ const model = d.getModel ? d.getModel(targetModel) : getModel(targetModel);
81
84
  const pk = model?.primaryKey || 'id';
82
85
  const ids = records.map((r) => r[pk]).join(',');
83
86
  return {
84
87
  download: true,
85
- url: `${adminPath}/api/data-exchange/export/${modelName}?ids=${encodeURIComponent(ids)}`,
86
- filename: `${modelName}_export.xlsx`,
88
+ url: `${adminPath}/api/data-exchange/export/${targetModel}?ids=${encodeURIComponent(ids)}`,
89
+ filename: `${targetModel}_export.xlsx`,
87
90
  };
88
91
  },
89
92
  });
@@ -29,10 +29,21 @@ const { emailPluginOptionsFromManifest } = require('./manifest');
29
29
  * @param {Object} [options.authEmails] - Auth email bridge config
30
30
  * @param {Object} [options.emailTemplates] - Precompiled templates from build manifest
31
31
  * @param {Object} [options.manifest] - Full build manifest (uses emailTemplates field)
32
+ * @param {Object|boolean} [options.retention] - Retention policy configuration (set false to disable)
33
+ * @param {number} [options.retention.days=90] - Days to retain email logs
34
+ * @param {number} [options.retention.intervalMs=86400000] - Auto-cleanup timer frequency (24h)
35
+ * @param {boolean} [options.retention.runOnStart=true] - Run initial purge shortly after startup
36
+ * @param {number} [options.retentionDays=90] - Shorthand for retention.days
37
+ * @param {number} [options.cleanupIntervalMs=86400000] - Shorthand for retention.intervalMs
38
+ * @param {boolean} [options.autoCleanup=true] - Enable automatic background cleanup
39
+ * @param {boolean} [options.cleanupOnStart=true] - Run initial purge on start
40
+ * @param {number} [options.cleanupStartDelayMs] - Delay in ms before start purge
32
41
  */
33
42
  function emailPlugin(options = {}) {
34
43
  const {
35
44
  db,
45
+ title,
46
+ adminTitle,
36
47
  transport,
37
48
  smtp,
38
49
  defaults = {},
@@ -48,6 +59,8 @@ function emailPlugin(options = {}) {
48
59
  manifest,
49
60
  } = options;
50
61
 
62
+ const pageTitle = title || adminTitle || 'Email';
63
+
51
64
  const logToDb = options.logToDb !== undefined ? options.logToDb : !!db;
52
65
  const knex = db ? (db.knex || db) : null;
53
66
 
@@ -55,6 +68,90 @@ function emailPlugin(options = {}) {
55
68
  console.warn('[email] logToDb is enabled but no db was passed — logging disabled');
56
69
  }
57
70
 
71
+ // Retention configuration
72
+ const retentionOption = options.retention;
73
+ const isRetentionDisabled =
74
+ retentionOption === false ||
75
+ (typeof retentionOption === 'object' && retentionOption.enabled === false) ||
76
+ options.retentionDays === null ||
77
+ options.retentionDays === 0 ||
78
+ !logToDb ||
79
+ !knex;
80
+
81
+ const autoCleanupEnabled =
82
+ !isRetentionDisabled &&
83
+ options.autoCleanup !== false &&
84
+ (typeof retentionOption !== 'object' || retentionOption.autoCleanup !== false);
85
+
86
+ const retentionConfig = {
87
+ enabled: !isRetentionDisabled,
88
+ autoCleanup: autoCleanupEnabled,
89
+ days: (typeof options.retentionDays === 'number' && options.retentionDays > 0)
90
+ ? options.retentionDays
91
+ : (typeof retentionOption === 'object' && retentionOption?.days ? retentionOption.days : 90),
92
+ intervalMs: (typeof options.cleanupIntervalMs === 'number' && options.cleanupIntervalMs > 0)
93
+ ? options.cleanupIntervalMs
94
+ : (typeof retentionOption === 'object' && retentionOption?.intervalMs ? retentionOption.intervalMs : 24 * 60 * 60 * 1000),
95
+ runOnStart: (typeof retentionOption === 'object' && retentionOption?.runOnStart !== undefined)
96
+ ? !!retentionOption.runOnStart
97
+ : (options.cleanupOnStart !== undefined ? !!options.cleanupOnStart : true),
98
+ };
99
+
100
+ let cleanupInterval = null;
101
+ let cleanupStartTimeout = null;
102
+
103
+ async function runCleanup() {
104
+ if (!knex) return 0;
105
+ try {
106
+ return await purgeEmailLogs(knex, {
107
+ tableName,
108
+ days: retentionConfig.days,
109
+ });
110
+ } catch (err) {
111
+ if (process.env.NODE_ENV !== 'test') {
112
+ console.warn('[email] Failed to run retention cleanup:', err.message);
113
+ }
114
+ }
115
+ }
116
+
117
+ function startCleanup(customIntervalMs) {
118
+ stopCleanup();
119
+ if (!retentionConfig.enabled || !knex) return;
120
+ if (typeof customIntervalMs === 'number' && customIntervalMs > 0) {
121
+ retentionConfig.intervalMs = customIntervalMs;
122
+ }
123
+
124
+ if (retentionConfig.runOnStart) {
125
+ const delayMs = typeof options.cleanupStartDelayMs === 'number'
126
+ ? options.cleanupStartDelayMs
127
+ : (process.env.NODE_ENV === 'test' ? 50 : 5000);
128
+ cleanupStartTimeout = setTimeout(() => {
129
+ runCleanup();
130
+ }, delayMs);
131
+ if (cleanupStartTimeout && cleanupStartTimeout.unref) {
132
+ cleanupStartTimeout.unref();
133
+ }
134
+ }
135
+
136
+ cleanupInterval = setInterval(() => {
137
+ runCleanup();
138
+ }, retentionConfig.intervalMs);
139
+ if (cleanupInterval && cleanupInterval.unref) {
140
+ cleanupInterval.unref();
141
+ }
142
+ }
143
+
144
+ function stopCleanup() {
145
+ if (cleanupStartTimeout) {
146
+ clearTimeout(cleanupStartTimeout);
147
+ cleanupStartTimeout = null;
148
+ }
149
+ if (cleanupInterval) {
150
+ clearInterval(cleanupInterval);
151
+ cleanupInterval = null;
152
+ }
153
+ }
154
+
58
155
  const registry = new TemplateRegistry();
59
156
 
60
157
  const manifestChunks = emailTemplatesOpt
@@ -81,7 +178,7 @@ function emailPlugin(options = {}) {
81
178
  return {
82
179
  name: 'email',
83
180
  version: '1.0.0',
84
- description: 'MJML + Nodemailer email sending with optional admin UI and auth integration',
181
+ description: 'MJML + Nodemailer email sending with optional admin UI, auth integration, and retention cleanup',
85
182
  dependencies: includeAdminPage ? { 'admin-panel': '*' } : {},
86
183
 
87
184
  api: {
@@ -92,13 +189,29 @@ function emailPlugin(options = {}) {
92
189
  listTemplates: () => registry.list(),
93
190
  verifyConnection: () => emailService.verifyConnection(),
94
191
  queryLogs: (filters) => emailService.queryLogs(filters),
192
+ purge: (opts = {}) => {
193
+ if (!knex) throw new Error('Database is required for purge');
194
+ return purgeEmailLogs(knex, {
195
+ tableName: opts.tableName || tableName,
196
+ days: opts.days !== undefined ? opts.days : (opts.retentionDays !== undefined ? opts.retentionDays : retentionConfig.days),
197
+ olderThan: opts.olderThan,
198
+ ...opts,
199
+ });
200
+ },
95
201
  purgeLogs: (opts = {}) => {
96
202
  if (!knex) throw new Error('Database is required for purgeLogs');
97
203
  return purgeEmailLogs(knex, {
98
204
  tableName: opts.tableName || tableName,
99
205
  olderThan: opts.olderThan,
206
+ days: opts.days !== undefined ? opts.days : (opts.retentionDays !== undefined ? opts.retentionDays : retentionConfig.days),
207
+ ...opts,
100
208
  });
101
209
  },
210
+ startCleanup,
211
+ stopCleanup,
212
+ getRetentionConfig() {
213
+ return { ...retentionConfig };
214
+ },
102
215
  getMigrationTemplate: (name) => generateEmailLogsMigration(name || tableName),
103
216
  isTransportReady: () => emailService.isTransportReady(),
104
217
  loadFromManifest: (chunks) => registry.loadFromManifest(chunks),
@@ -120,6 +233,22 @@ function emailPlugin(options = {}) {
120
233
  }
121
234
  }
122
235
  }
236
+
237
+ if (retentionConfig.enabled && autoCleanupEnabled) {
238
+ startCleanup();
239
+ }
240
+
241
+ return () => {
242
+ stopCleanup();
243
+ };
244
+ },
245
+
246
+ dispose() {
247
+ stopCleanup();
248
+ },
249
+
250
+ shutdown() {
251
+ stopCleanup();
123
252
  },
124
253
 
125
254
  onRoutesReady(ctx) {
@@ -159,10 +288,11 @@ function emailPlugin(options = {}) {
159
288
 
160
289
  adminApi.registerModule({
161
290
  id: 'email',
291
+ title: pageTitle,
162
292
 
163
293
  pages: [{
164
294
  id: 'email',
165
- title: 'Email',
295
+ title: pageTitle,
166
296
  path: '/email',
167
297
  icon: 'mail',
168
298
  component: generateEmailComponent({ apiPrefix }),