webspresso 0.0.99 → 0.0.101

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 (108) hide show
  1. package/.agents/ADMIN.md +47 -4
  2. package/.agents/AGENTS.md +4 -2
  3. package/.agents/AUTH.md +309 -0
  4. package/.agents/CLI.md +128 -0
  5. package/.agents/EMAIL.md +84 -2
  6. package/.agents/ERRORS.md +59 -0
  7. package/.agents/EVENTS.md +179 -0
  8. package/.agents/ORM.md +149 -0
  9. package/.agents/PLUGINS.md +44 -7
  10. package/.agents/QUEUE.md +18 -10
  11. package/.agents/ROUTING.md +97 -1
  12. package/.agents/STREAMING.md +35 -0
  13. package/AGENTS.md +2 -0
  14. package/README.md +145 -93
  15. package/bin/commands/add-docker.js +467 -0
  16. package/bin/commands/db-makemigrations.js +83 -0
  17. package/bin/commands/deploy-check.js +339 -0
  18. package/bin/commands/list.js +96 -0
  19. package/bin/commands/make-command.js +93 -0
  20. package/bin/commands/make-event.js +80 -0
  21. package/bin/commands/make-factory.js +72 -0
  22. package/bin/commands/make-mail.js +93 -0
  23. package/bin/commands/make-policy.js +106 -0
  24. package/bin/commands/run.js +195 -0
  25. package/bin/webspresso.js +21 -0
  26. package/core/auth/bouncer.js +196 -0
  27. package/core/auth/index.js +73 -1
  28. package/core/auth/magic-link.js +170 -0
  29. package/core/auth/manager.js +343 -3
  30. package/core/auth/middleware.js +45 -6
  31. package/core/auth/session-manager.js +255 -0
  32. package/core/auth/sessions.js +351 -0
  33. package/core/auth/tokens.js +1 -0
  34. package/core/auth/totp.js +4 -1
  35. package/core/auth/user-agent.js +161 -0
  36. package/core/commands/define-command.js +48 -0
  37. package/core/commands/index.js +18 -0
  38. package/core/commands/registry.js +156 -0
  39. package/core/commands/runner.js +76 -0
  40. package/core/compression/compressible.js +1 -1
  41. package/core/compression/middleware.js +35 -251
  42. package/core/content/service.js +8 -2
  43. package/core/events/discovery.js +142 -0
  44. package/core/events/dispatcher.js +552 -0
  45. package/core/events/index.js +23 -0
  46. package/core/events/signal.js +92 -0
  47. package/core/forms/flash.js +234 -0
  48. package/core/forms/form-builder.js +214 -0
  49. package/core/forms/index.js +16 -0
  50. package/core/mail/index.js +23 -0
  51. package/core/mail/mailable.js +250 -0
  52. package/core/mail/mailer.js +184 -0
  53. package/core/mail/transports.js +238 -0
  54. package/core/orm/factory.js +250 -0
  55. package/core/orm/index.js +8 -1
  56. package/core/orm/migrations/diff.js +185 -0
  57. package/core/orm/migrations/index.js +22 -4
  58. package/core/orm/model.js +55 -1
  59. package/core/orm/repository.js +283 -13
  60. package/core/orm/scopes.js +3 -2
  61. package/core/orm/types.js +6 -1
  62. package/core/queue/cron.js +252 -39
  63. package/core/queue/manager.js +20 -2
  64. package/core/queue/scheduler.js +115 -59
  65. package/core/ssr/stream.js +62 -2
  66. package/index.d.ts +540 -14
  67. package/index.js +44 -0
  68. package/package.json +11 -5
  69. package/plugins/admin-panel/api.js +509 -3
  70. package/plugins/admin-panel/index.js +17 -0
  71. package/plugins/admin-panel/modules/admin-users.js +1 -1
  72. package/plugins/audit-log/admin-component.js +54 -54
  73. package/plugins/audit-log/index.js +2 -2
  74. package/plugins/content/client/inline-edit.js +2 -46
  75. package/plugins/content/models/content-entry.js +4 -3
  76. package/plugins/content/models/content-type.js +4 -3
  77. package/plugins/cors.js +15 -4
  78. package/plugins/email/auth-bridge.js +66 -0
  79. package/plugins/email/bundled-templates.js +15 -0
  80. package/plugins/email/index.js +48 -0
  81. package/plugins/email/preview-handler.js +555 -0
  82. package/plugins/email/service.js +23 -12
  83. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  84. package/plugins/email/transport.js +30 -11
  85. package/plugins/file-manager/admin-component.js +49 -49
  86. package/plugins/file-manager/index.js +4 -4
  87. package/plugins/index.js +2 -0
  88. package/plugins/redirect/index.js +1 -1
  89. package/plugins/webhooks/admin-component.js +608 -0
  90. package/plugins/webhooks/api-handlers.js +237 -0
  91. package/plugins/webhooks/crypto.js +144 -0
  92. package/plugins/webhooks/dispatcher.js +708 -0
  93. package/plugins/webhooks/index.js +263 -0
  94. package/plugins/webhooks/matcher.js +113 -0
  95. package/plugins/webhooks/migration-template.js +75 -0
  96. package/plugins/webhooks/models.js +98 -0
  97. package/plugins/webhooks/sender.js +176 -0
  98. package/src/api/api-loader.js +1 -1
  99. package/src/app-context.js +48 -6
  100. package/src/bundle/index.js +2 -1
  101. package/src/dev-reload/index.js +9 -1
  102. package/src/discovery/scan-routes.js +10 -1
  103. package/src/file-router.js +173 -4
  104. package/src/helpers.js +150 -1
  105. package/src/njk-frontmatter.js +3 -0
  106. package/src/pages/page-loader.js +1 -1
  107. package/src/routing/cascading.js +120 -4
  108. package/src/server.js +180 -26
@@ -11,6 +11,9 @@ const { generateEmailLogsMigration } = require('./migration-template');
11
11
  const { purgeEmailLogs } = require('./purge');
12
12
  const { wireAuthEmails, registerAuthEmailRoutes } = require('./auth-bridge');
13
13
  const { emailPluginOptionsFromManifest } = require('./manifest');
14
+ const { Mailable } = require('../../core/mail/mailable');
15
+ const { createMailer } = require('../../core/mail/mailer');
16
+ const { setAppContext } = require('../../src/app-context');
14
17
 
15
18
  /**
16
19
  * @param {Object} options
@@ -173,6 +176,17 @@ function emailPlugin(options = {}) {
173
176
  tableName,
174
177
  });
175
178
 
179
+ const mailer = {
180
+ send: (opts) => emailService.send(opts),
181
+ sendTemplate: (name, opts) => emailService.sendTemplate(name, opts),
182
+ preview: (opts, data) => emailService.preview(opts, data),
183
+ getOutbox: () => emailService.getOutbox(),
184
+ clearOutbox: () => emailService.clearOutbox(),
185
+ lastMessage: () => emailService.lastMessage(),
186
+ verify: () => emailService.verifyConnection(),
187
+ getTransport: () => emailService.getTransport(),
188
+ };
189
+
176
190
  let wiredAuthEmailsConfig = null;
177
191
 
178
192
  return {
@@ -182,9 +196,14 @@ function emailPlugin(options = {}) {
182
196
  dependencies: includeAdminPage ? { 'admin-panel': '*' } : {},
183
197
 
184
198
  api: {
199
+ mailer,
200
+ Mailable,
185
201
  send: (opts) => emailService.send(opts),
186
202
  sendTemplate: (name, opts) => emailService.sendTemplate(name, opts),
187
203
  preview: (nameOrOpts, data) => emailService.preview(nameOrOpts, data),
204
+ getOutbox: () => emailService.getOutbox(),
205
+ clearOutbox: () => emailService.clearOutbox(),
206
+ lastMessage: () => emailService.lastMessage(),
188
207
  registerTemplate: (id, source) => registry.register(id, source),
189
208
  listTemplates: () => registry.list(),
190
209
  verifyConnection: () => emailService.verifyConnection(),
@@ -219,6 +238,18 @@ function emailPlugin(options = {}) {
219
238
  },
220
239
 
221
240
  register(ctx) {
241
+ setAppContext({ mailer });
242
+ if (ctx.app) {
243
+ ctx.app.mailer = mailer;
244
+ if (typeof ctx.app.use === 'function') {
245
+ ctx.app.use((req, res, next) => {
246
+ req.mailer = mailer;
247
+ next();
248
+ });
249
+ }
250
+ }
251
+ ctx.mailer = mailer;
252
+
222
253
  if (ctx.app && ctx.app.serviceRegistry) {
223
254
  const { createMailServices } = require('../../src/services/builtins/mail');
224
255
  const mailServices = createMailServices({
@@ -271,6 +302,21 @@ function emailPlugin(options = {}) {
271
302
  console.warn('[email] authEmails.enabled but no auth manager found — pass auth to createApp or emailPlugin');
272
303
  }
273
304
 
305
+ // Mount Dev Mail Preview & Inbox
306
+ const enableDevInbox = options.devInbox !== false && (process.env.NODE_ENV !== 'production' || options.devInbox === true);
307
+ if (enableDevInbox && typeof ctx.addRoute === 'function') {
308
+ const { createMailPreviewHandlers } = require('./preview-handler');
309
+ const previewPath = options.previewPath || '/_webspresso/mail-preview';
310
+ const previewHandlers = createMailPreviewHandlers({ mailer, basePath: previewPath });
311
+
312
+ ctx.addRoute('get', previewPath, previewHandlers.indexHandler);
313
+ ctx.addRoute('get', `${previewPath}/api/messages`, previewHandlers.listMessagesHandler);
314
+ ctx.addRoute('get', `${previewPath}/api/messages/:id`, previewHandlers.getMessageHandler);
315
+ ctx.addRoute('get', `${previewPath}/api/messages/:id/html`, previewHandlers.getMessageHtmlHandler);
316
+ ctx.addRoute('post', `${previewPath}/api/clear`, previewHandlers.clearMessagesHandler);
317
+ ctx.addRoute('post', `${previewPath}/api/preview`, previewHandlers.previewHandler);
318
+ }
319
+
274
320
  if (!includeAdminPage) return;
275
321
 
276
322
  const adminApi = ctx.usePlugin('admin-panel');
@@ -324,6 +370,8 @@ function emailPlugin(options = {}) {
324
370
 
325
371
  module.exports = emailPlugin;
326
372
  module.exports.emailPlugin = emailPlugin;
373
+ module.exports.Mailable = Mailable;
374
+ module.exports.createMailer = createMailer;
327
375
  module.exports.generateEmailLogsMigration = generateEmailLogsMigration;
328
376
  module.exports.purgeEmailLogs = purgeEmailLogs;
329
377
  module.exports.emailPluginOptionsFromManifest = emailPluginOptionsFromManifest;
@@ -0,0 +1,555 @@
1
+ /**
2
+ * Developer Mail Preview & Dev Inbox (AdonisJS & Laravel-inspired)
3
+ * Mounted at /_webspresso/mail-preview in development
4
+ * @module plugins/email/preview-handler
5
+ */
6
+
7
+ /**
8
+ * Generate standalone responsive HTML for Dev Mail Preview UI
9
+ * @param {Array<Object>} messages
10
+ * @param {string} [basePath='/_webspresso/mail-preview']
11
+ * @returns {string}
12
+ */
13
+ function renderPreviewHtml(messages = [], basePath = '/_webspresso/mail-preview') {
14
+ const safeJson = JSON.stringify(messages).replace(/</g, '\\u003c');
15
+
16
+ return `<!DOCTYPE html>
17
+ <html lang="en">
18
+ <head>
19
+ <meta charset="UTF-8">
20
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
21
+ <title>Mail Preview & Dev Inbox — Webspresso</title>
22
+ <link rel="preconnect" href="https://fonts.googleapis.com">
23
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
24
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
25
+ <style>
26
+ :root {
27
+ --bg: #090d16;
28
+ --surface: #111827;
29
+ --surface-hover: #1f2937;
30
+ --border: #1e293b;
31
+ --text: #f1f5f9;
32
+ --text-muted: #94a3b8;
33
+ --accent: #6366f1;
34
+ --accent-hover: #4f46e5;
35
+ --accent-glow: rgba(99, 102, 241, 0.15);
36
+ --success: #10b981;
37
+ --danger: #ef4444;
38
+ }
39
+ * { box-sizing: border-box; margin: 0; padding: 0; }
40
+ body {
41
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
42
+ background: var(--bg);
43
+ color: var(--text);
44
+ display: flex;
45
+ flex-direction: column;
46
+ height: 100vh;
47
+ overflow: hidden;
48
+ }
49
+ header {
50
+ height: 56px;
51
+ background: var(--surface);
52
+ border-bottom: 1px solid var(--border);
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: space-between;
56
+ padding: 0 20px;
57
+ z-index: 10;
58
+ }
59
+ .logo-group {
60
+ display: flex;
61
+ align-items: center;
62
+ gap: 12px;
63
+ }
64
+ .logo-badge {
65
+ background: linear-gradient(135deg, #6366f1, #a855f7);
66
+ color: #fff;
67
+ font-weight: 700;
68
+ font-size: 14px;
69
+ padding: 4px 10px;
70
+ border-radius: 8px;
71
+ letter-spacing: 0.5px;
72
+ }
73
+ .header-title {
74
+ font-size: 15px;
75
+ font-weight: 600;
76
+ color: var(--text);
77
+ }
78
+ .header-count {
79
+ font-size: 12px;
80
+ color: var(--text-muted);
81
+ background: rgba(255,255,255,0.06);
82
+ padding: 2px 8px;
83
+ border-radius: 999px;
84
+ }
85
+ .header-actions {
86
+ display: flex;
87
+ align-items: center;
88
+ gap: 10px;
89
+ }
90
+ .btn {
91
+ background: rgba(255,255,255,0.06);
92
+ color: var(--text);
93
+ border: 1px solid var(--border);
94
+ padding: 6px 14px;
95
+ border-radius: 6px;
96
+ font-size: 13px;
97
+ font-weight: 500;
98
+ cursor: pointer;
99
+ transition: all 0.2s;
100
+ display: inline-flex;
101
+ align-items: center;
102
+ gap: 6px;
103
+ }
104
+ .btn:hover { background: var(--surface-hover); }
105
+ .btn-danger { color: #fca5a5; }
106
+ .btn-danger:hover { background: rgba(239, 68, 68, 0.15); border-color: var(--danger); }
107
+ .main-layout {
108
+ display: flex;
109
+ flex: 1;
110
+ height: calc(100vh - 56px);
111
+ overflow: hidden;
112
+ }
113
+ .sidebar {
114
+ width: 360px;
115
+ background: var(--surface);
116
+ border-right: 1px solid var(--border);
117
+ overflow-y: auto;
118
+ display: flex;
119
+ flex-direction: column;
120
+ }
121
+ .email-item {
122
+ padding: 14px 16px;
123
+ border-bottom: 1px solid var(--border);
124
+ cursor: pointer;
125
+ transition: background 0.15s;
126
+ }
127
+ .email-item:hover { background: rgba(255,255,255,0.03); }
128
+ .email-item.active {
129
+ background: var(--accent-glow);
130
+ border-left: 3px solid var(--accent);
131
+ }
132
+ .email-to {
133
+ font-size: 13px;
134
+ font-weight: 600;
135
+ color: var(--text);
136
+ display: flex;
137
+ justify-content: space-between;
138
+ margin-bottom: 4px;
139
+ }
140
+ .email-time {
141
+ font-size: 11px;
142
+ font-weight: 400;
143
+ color: var(--text-muted);
144
+ }
145
+ .email-subject {
146
+ font-size: 13px;
147
+ color: #cbd5e1;
148
+ white-space: nowrap;
149
+ overflow: hidden;
150
+ text-overflow: ellipsis;
151
+ margin-bottom: 4px;
152
+ }
153
+ .email-preview {
154
+ font-size: 12px;
155
+ color: var(--text-muted);
156
+ white-space: nowrap;
157
+ overflow: hidden;
158
+ text-overflow: ellipsis;
159
+ }
160
+ .content-area {
161
+ flex: 1;
162
+ display: flex;
163
+ flex-direction: column;
164
+ background: var(--bg);
165
+ overflow: hidden;
166
+ }
167
+ .viewer-header {
168
+ background: var(--surface);
169
+ border-bottom: 1px solid var(--border);
170
+ padding: 16px 24px;
171
+ }
172
+ .vh-subject {
173
+ font-size: 18px;
174
+ font-weight: 700;
175
+ margin-bottom: 8px;
176
+ }
177
+ .vh-meta {
178
+ display: flex;
179
+ flex-wrap: wrap;
180
+ gap: 16px;
181
+ font-size: 12px;
182
+ color: var(--text-muted);
183
+ }
184
+ .vh-meta strong { color: var(--text); }
185
+ .viewer-toolbar {
186
+ padding: 8px 24px;
187
+ background: rgba(17, 24, 39, 0.6);
188
+ border-bottom: 1px solid var(--border);
189
+ display: flex;
190
+ justify-content: space-between;
191
+ align-items: center;
192
+ }
193
+ .tab-group, .viewport-group {
194
+ display: flex;
195
+ gap: 6px;
196
+ }
197
+ .tab-btn, .vp-btn {
198
+ padding: 4px 10px;
199
+ border-radius: 4px;
200
+ font-size: 12px;
201
+ background: transparent;
202
+ border: 1px solid transparent;
203
+ color: var(--text-muted);
204
+ cursor: pointer;
205
+ }
206
+ .tab-btn.active, .vp-btn.active {
207
+ background: rgba(255,255,255,0.08);
208
+ border-color: var(--border);
209
+ color: var(--text);
210
+ font-weight: 600;
211
+ }
212
+ .viewport-container {
213
+ flex: 1;
214
+ display: flex;
215
+ justify-content: center;
216
+ align-items: stretch;
217
+ padding: 20px;
218
+ overflow-y: auto;
219
+ background: #020617;
220
+ }
221
+ .preview-frame-wrapper {
222
+ width: 100%;
223
+ height: 100%;
224
+ background: #ffffff;
225
+ border-radius: 8px;
226
+ overflow: hidden;
227
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
228
+ transition: width 0.3s ease;
229
+ }
230
+ .preview-frame-wrapper.mobile { width: 375px; }
231
+ .preview-frame-wrapper.tablet { width: 768px; }
232
+ iframe {
233
+ width: 100%;
234
+ height: 100%;
235
+ border: none;
236
+ }
237
+ pre.code-view {
238
+ font-family: 'JetBrains Mono', monospace;
239
+ font-size: 13px;
240
+ line-height: 1.6;
241
+ background: #090d16;
242
+ color: #93c5fd;
243
+ padding: 20px;
244
+ border-radius: 8px;
245
+ width: 100%;
246
+ height: 100%;
247
+ overflow: auto;
248
+ white-space: pre-wrap;
249
+ }
250
+ .empty-state {
251
+ flex: 1;
252
+ display: flex;
253
+ flex-direction: column;
254
+ align-items: center;
255
+ justify-content: center;
256
+ color: var(--text-muted);
257
+ text-align: center;
258
+ padding: 40px;
259
+ }
260
+ .empty-icon {
261
+ font-size: 48px;
262
+ margin-bottom: 16px;
263
+ opacity: 0.8;
264
+ }
265
+ .empty-title {
266
+ font-size: 18px;
267
+ font-weight: 600;
268
+ color: var(--text);
269
+ margin-bottom: 8px;
270
+ }
271
+ .empty-code {
272
+ margin-top: 20px;
273
+ background: rgba(255,255,255,0.04);
274
+ border: 1px solid var(--border);
275
+ padding: 12px 18px;
276
+ border-radius: 8px;
277
+ font-family: 'JetBrains Mono', monospace;
278
+ font-size: 12px;
279
+ color: #a5b4fc;
280
+ text-align: left;
281
+ }
282
+ </style>
283
+ </head>
284
+ <body>
285
+ <header>
286
+ <div class="logo-group">
287
+ <span class="logo-badge">Webspresso</span>
288
+ <span class="header-title">📬 Mail Preview & Dev Inbox</span>
289
+ <span class="header-count" id="headerCount">0 emails</span>
290
+ </div>
291
+ <div class="header-actions">
292
+ <button class="btn" onclick="fetchMessages()">↻ Refresh</button>
293
+ <button class="btn btn-danger" onclick="clearOutbox()">🗑️ Clear Outbox</button>
294
+ </div>
295
+ </header>
296
+
297
+ <div class="main-layout">
298
+ <div class="sidebar" id="sidebarList">
299
+ <!-- Populated via JS -->
300
+ </div>
301
+
302
+ <div class="content-area" id="viewerArea">
303
+ <!-- Populated via JS -->
304
+ </div>
305
+ </div>
306
+
307
+ <script>
308
+ let messages = ${safeJson};
309
+ let selectedId = messages.length > 0 ? messages[messages.length - 1].id : null;
310
+ let activeTab = 'html';
311
+ let activeViewport = 'desktop';
312
+
313
+ function renderSidebar() {
314
+ const sidebar = document.getElementById('sidebarList');
315
+ const countEl = document.getElementById('headerCount');
316
+ countEl.textContent = messages.length + ' email' + (messages.length === 1 ? '' : 's');
317
+
318
+ if (messages.length === 0) {
319
+ sidebar.innerHTML = '<div style="padding: 24px; text-align: center; color: var(--text-muted); font-size: 13px;">No emails captured yet</div>';
320
+ renderEmptyViewer();
321
+ return;
322
+ }
323
+
324
+ sidebar.innerHTML = messages.slice().reverse().map(msg => {
325
+ const isSelected = msg.id === selectedId;
326
+ const toDisplay = Array.isArray(msg.to) ? msg.to.join(', ') : (typeof msg.to === 'object' ? (msg.to.address || JSON.stringify(msg.to)) : String(msg.to || ''));
327
+ const dateStr = msg.sentAt ? new Date(msg.sentAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '';
328
+ const preview = msg.text || (msg.html ? msg.html.replace(/<[^>]+>/g, ' ').slice(0, 80) : '');
329
+
330
+ return \`
331
+ <div class="email-item \${isSelected ? 'active' : ''}" onclick="selectEmail('\${msg.id}')">
332
+ <div class="email-to">
333
+ <span>\${escapeHtml(toDisplay)}</span>
334
+ <span class="email-time">\${dateStr}</span>
335
+ </div>
336
+ <div class="email-subject">\${escapeHtml(msg.subject || '(No Subject)')}</div>
337
+ <div class="email-preview">\${escapeHtml(preview)}</div>
338
+ </div>
339
+ \`;
340
+ }).join('');
341
+
342
+ renderContent();
343
+ }
344
+
345
+ function renderEmptyViewer() {
346
+ const viewer = document.getElementById('viewerArea');
347
+ viewer.innerHTML = \`
348
+ <div class="empty-state">
349
+ <div class="empty-icon">✉️</div>
350
+ <div class="empty-title">Inbox is Empty</div>
351
+ <p>Send an email using <code>req.mailer.send(...)</code> or a <code>new Mailable()</code> class in your application to preview it here.</p>
352
+ <div class="empty-code">
353
+ // Example dispatch:<br>
354
+ await req.mailer.send({<br>
355
+ &nbsp;&nbsp;to: 'user@example.com',<br>
356
+ &nbsp;&nbsp;subject: 'Welcome!',<br>
357
+ &nbsp;&nbsp;html: '&lt;h1&gt;Welcome to Webspresso!&lt;/h1&gt;'<br>
358
+ });
359
+ </div>
360
+ </div>
361
+ \`;
362
+ }
363
+
364
+ function renderContent() {
365
+ const viewer = document.getElementById('viewerArea');
366
+ const msg = messages.find(m => m.id === selectedId);
367
+ if (!msg) {
368
+ if (messages.length > 0) {
369
+ selectedId = messages[messages.length - 1].id;
370
+ return renderContent();
371
+ }
372
+ return renderEmptyViewer();
373
+ }
374
+
375
+ const toDisplay = Array.isArray(msg.to) ? msg.to.join(', ') : (typeof msg.to === 'object' ? (msg.to.address || JSON.stringify(msg.to)) : String(msg.to || ''));
376
+ const fromDisplay = typeof msg.from === 'object' ? (msg.from.address || JSON.stringify(msg.from)) : String(msg.from || '');
377
+ const dateStr = msg.sentAt ? new Date(msg.sentAt).toLocaleString() : '';
378
+
379
+ viewer.innerHTML = \`
380
+ <div class="viewer-header">
381
+ <div class="vh-subject">\${escapeHtml(msg.subject || '(No Subject)')}</div>
382
+ <div class="vh-meta">
383
+ <div><strong>From:</strong> \${escapeHtml(fromDisplay)}</div>
384
+ <div><strong>To:</strong> \${escapeHtml(toDisplay)}</div>
385
+ \${msg.cc ? \`<div><strong>Cc:</strong> \${escapeHtml(String(msg.cc))}</div>\` : ''}
386
+ <div><strong>Date:</strong> \${dateStr}</div>
387
+ </div>
388
+ </div>
389
+
390
+ <div class="viewer-toolbar">
391
+ <div class="tab-group">
392
+ <button class="tab-btn \${activeTab === 'html' ? 'active' : ''}" onclick="setTab('html')">HTML View</button>
393
+ <button class="tab-btn \${activeTab === 'text' ? 'active' : ''}" onclick="setTab('text')">Plain Text</button>
394
+ <button class="tab-btn \${activeTab === 'raw' ? 'active' : ''}" onclick="setTab('raw')">Headers & Payload</button>
395
+ </div>
396
+ \${activeTab === 'html' ? \`
397
+ <div class="viewport-group">
398
+ <button class="vp-btn \${activeViewport === 'desktop' ? 'active' : ''}" onclick="setViewport('desktop')">🖥️ Desktop</button>
399
+ <button class="vp-btn \${activeViewport === 'tablet' ? 'active' : ''}" onclick="setViewport('tablet')">📱 Tablet</button>
400
+ <button class="vp-btn \${activeViewport === 'mobile' ? 'active' : ''}" onclick="setViewport('mobile')">📱 Mobile</button>
401
+ </div>
402
+ \` : '<div></div>'}
403
+ </div>
404
+
405
+ <div class="viewport-container">
406
+ \${activeTab === 'html' ? \`
407
+ <div class="preview-frame-wrapper \${activeViewport}">
408
+ <iframe id="previewFrame" src="${basePath}/api/messages/\${msg.id}/html"></iframe>
409
+ </div>
410
+ \` : activeTab === 'text' ? \`
411
+ <pre class="code-view">\${escapeHtml(msg.text || '(No plain text body)')}</pre>
412
+ \` : \`
413
+ <pre class="code-view">\${escapeHtml(JSON.stringify(msg, null, 2))}</pre>
414
+ \`}
415
+ </div>
416
+ \`;
417
+ }
418
+
419
+ function selectEmail(id) {
420
+ selectedId = id;
421
+ renderSidebar();
422
+ }
423
+
424
+ function setTab(tab) {
425
+ activeTab = tab;
426
+ renderContent();
427
+ }
428
+
429
+ function setViewport(vp) {
430
+ activeViewport = vp;
431
+ renderContent();
432
+ }
433
+
434
+ async function fetchMessages() {
435
+ try {
436
+ const res = await fetch('${basePath}/api/messages');
437
+ const data = await res.json();
438
+ messages = data.messages || [];
439
+ if (!messages.some(m => m.id === selectedId) && messages.length > 0) {
440
+ selectedId = messages[messages.length - 1].id;
441
+ }
442
+ renderSidebar();
443
+ } catch (err) {
444
+ console.error('Failed to refresh messages:', err);
445
+ }
446
+ }
447
+
448
+ async function clearOutbox() {
449
+ if (!confirm('Clear all captured emails from outbox?')) return;
450
+ try {
451
+ await fetch('${basePath}/api/clear', { method: 'POST' });
452
+ messages = [];
453
+ selectedId = null;
454
+ renderSidebar();
455
+ } catch (err) {
456
+ console.error('Failed to clear outbox:', err);
457
+ }
458
+ }
459
+
460
+ function escapeHtml(str) {
461
+ if (!str) return '';
462
+ return String(str)
463
+ .replace(/&/g, '&amp;')
464
+ .replace(/</g, '&lt;')
465
+ .replace(/>/g, '&gt;')
466
+ .replace(/"/g, '&quot;')
467
+ .replace(/'/g, '&#039;');
468
+ }
469
+
470
+ renderSidebar();
471
+ </script>
472
+ </body>
473
+ </html>`;
474
+ }
475
+
476
+ /**
477
+ * Create Dev Mail Preview HTTP handlers
478
+ * @param {Object} options
479
+ * @param {import('../../core/mail/mailer').Mailer} options.mailer
480
+ * @param {string} [options.basePath='/_webspresso/mail-preview']
481
+ */
482
+ function createMailPreviewHandlers({ mailer, basePath = '/_webspresso/mail-preview' }) {
483
+ return {
484
+ /**
485
+ * GET /_webspresso/mail-preview
486
+ * Serve HTML Dev Inbox SPA
487
+ */
488
+ indexHandler(req, res) {
489
+ const messages = mailer.getOutbox();
490
+ const html = renderPreviewHtml(messages, basePath);
491
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
492
+ res.send(html);
493
+ },
494
+
495
+ /**
496
+ * GET /_webspresso/mail-preview/api/messages
497
+ */
498
+ listMessagesHandler(req, res) {
499
+ const messages = mailer.getOutbox();
500
+ res.json({ success: true, count: messages.length, messages });
501
+ },
502
+
503
+ /**
504
+ * GET /_webspresso/mail-preview/api/messages/:id
505
+ */
506
+ getMessageHandler(req, res) {
507
+ const { id } = req.params;
508
+ const messages = mailer.getOutbox();
509
+ const msg = messages.find((m) => m.id === id || m.messageId === id);
510
+ if (!msg) {
511
+ return res.status(404).json({ error: 'Message not found' });
512
+ }
513
+ res.json({ success: true, message: msg });
514
+ },
515
+
516
+ /**
517
+ * GET /_webspresso/mail-preview/api/messages/:id/html
518
+ */
519
+ getMessageHtmlHandler(req, res) {
520
+ const { id } = req.params;
521
+ const messages = mailer.getOutbox();
522
+ const msg = messages.find((m) => m.id === id || m.messageId === id);
523
+ if (!msg) {
524
+ return res.status(404).send('<p>Message not found</p>');
525
+ }
526
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
527
+ res.send(msg.html || `<pre>${msg.text || 'No content'}</pre>`);
528
+ },
529
+
530
+ /**
531
+ * POST /_webspresso/mail-preview/api/clear
532
+ */
533
+ clearMessagesHandler(req, res) {
534
+ mailer.clearOutbox();
535
+ res.json({ success: true, message: 'Outbox cleared' });
536
+ },
537
+
538
+ /**
539
+ * POST /_webspresso/mail-preview/api/preview
540
+ */
541
+ async previewHandler(req, res) {
542
+ try {
543
+ const previewResult = await mailer.preview(req.body);
544
+ res.json({ success: true, preview: previewResult });
545
+ } catch (err) {
546
+ res.status(400).json({ error: err.message });
547
+ }
548
+ },
549
+ };
550
+ }
551
+
552
+ module.exports = {
553
+ renderPreviewHtml,
554
+ createMailPreviewHandlers,
555
+ };