webspresso 0.0.80 → 0.0.81
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.
- package/README.md +91 -0
- package/bin/commands/doctor.js +83 -0
- package/bin/commands/favicon-generate.js +3 -1
- package/core/auth/hash.js +1 -1
- package/core/auth/index.js +30 -0
- package/core/auth/manager.js +190 -0
- package/core/auth/tokens.js +135 -0
- package/core/kernel/app.js +7 -1
- package/index.d.ts +32 -0
- package/index.js +2 -0
- package/package.json +5 -2
- package/plugins/admin-panel/field-renderers/file-upload.js +48 -2
- package/plugins/csrf/index.js +222 -0
- package/plugins/email/admin-component.js +226 -0
- package/plugins/email/api-handlers.js +139 -0
- package/plugins/email/auth-bridge.js +209 -0
- package/plugins/email/bundled-templates.js +68 -0
- package/plugins/email/fs-env.js +101 -0
- package/plugins/email/index.js +186 -0
- package/plugins/email/manifest.js +25 -0
- package/plugins/email/migration-template.js +40 -0
- package/plugins/email/purge.js +28 -0
- package/plugins/email/render.js +154 -0
- package/plugins/email/service.js +244 -0
- package/plugins/email/template-registry.js +134 -0
- package/plugins/email/templates/auth-email-verify.mjml +13 -0
- package/plugins/email/templates/auth-password-reset.mjml +13 -0
- package/plugins/email/templates/auth-welcome.mjml +11 -0
- package/plugins/email/transport.js +71 -0
- package/plugins/index.js +4 -0
- package/src/file-router.js +75 -0
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -0
package/index.js
CHANGED
|
@@ -63,6 +63,7 @@ const {
|
|
|
63
63
|
redirectPlugin,
|
|
64
64
|
rateLimitPlugin,
|
|
65
65
|
contentPlugin,
|
|
66
|
+
csrfPlugin,
|
|
66
67
|
} = require('./plugins');
|
|
67
68
|
|
|
68
69
|
const content = require('./core/content');
|
|
@@ -133,6 +134,7 @@ module.exports = {
|
|
|
133
134
|
redirectPlugin,
|
|
134
135
|
rateLimitPlugin,
|
|
135
136
|
contentPlugin,
|
|
137
|
+
csrfPlugin,
|
|
136
138
|
|
|
137
139
|
// Schema-driven CMS core (framework-agnostic)
|
|
138
140
|
content,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.81",
|
|
4
4
|
"description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -67,7 +67,9 @@
|
|
|
67
67
|
"helmet": "^7.2.0",
|
|
68
68
|
"inquirer": "^8.2.6",
|
|
69
69
|
"knex": "^3.1.0",
|
|
70
|
+
"mjml": "^5.4.0",
|
|
70
71
|
"multer": "^2.1.1",
|
|
72
|
+
"nodemailer": "^9.0.3",
|
|
71
73
|
"nunjucks": "^3.2.4",
|
|
72
74
|
"sanitize-html": "^2.17.3",
|
|
73
75
|
"sharp": "^0.33.5",
|
|
@@ -125,6 +127,7 @@
|
|
|
125
127
|
"overrides": {
|
|
126
128
|
"basic-ftp": "^5.3.0",
|
|
127
129
|
"tar": "^7.5.11",
|
|
128
|
-
"undici": "^6.24.0"
|
|
130
|
+
"undici": "^6.24.0",
|
|
131
|
+
"uuid": "^11.1.1"
|
|
129
132
|
}
|
|
130
133
|
}
|
|
@@ -223,6 +223,8 @@ module.exports = {
|
|
|
223
223
|
FilesUploadField: {
|
|
224
224
|
oninit: (vnode) => {
|
|
225
225
|
vnode.state.uploading = false;
|
|
226
|
+
vnode.state.draggedIndex = null;
|
|
227
|
+
vnode.state.dragOverIndex = null;
|
|
226
228
|
},
|
|
227
229
|
oncreate: (vnode) => {
|
|
228
230
|
const { name, meta = {} } = vnode.attrs;
|
|
@@ -298,19 +300,63 @@ module.exports = {
|
|
|
298
300
|
|
|
299
301
|
urls.length > 0
|
|
300
302
|
? m('.grid.grid-cols-2.sm:grid-cols-3.md:grid-cols-4.gap-4.mb-3', urls.map((url, idx) => {
|
|
301
|
-
|
|
303
|
+
const isDragOver = state.dragOverIndex === idx;
|
|
304
|
+
const baseClasses = '.relative.group.border.rounded-lg.p-2.transition-all';
|
|
305
|
+
const cursorClass = readonly ? '' : '.cursor-move';
|
|
306
|
+
const borderClasses = isDragOver ? '.border-blue-500.border-2.dark:border-blue-500' : '.border-gray-200.dark:border-slate-700';
|
|
307
|
+
const bgClasses = isDragOver ? '.bg-blue-50.dark:bg-slate-800' : '.bg-gray-50.dark:bg-slate-900';
|
|
308
|
+
|
|
309
|
+
return m(baseClasses + cursorClass + borderClasses + bgClasses, {
|
|
310
|
+
draggable: !readonly,
|
|
311
|
+
ondragstart: (e) => {
|
|
312
|
+
state.draggedIndex = idx;
|
|
313
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
314
|
+
e.dataTransfer.setData('text/plain', idx);
|
|
315
|
+
},
|
|
316
|
+
ondragover: (e) => {
|
|
317
|
+
e.preventDefault();
|
|
318
|
+
e.dataTransfer.dropEffect = 'move';
|
|
319
|
+
},
|
|
320
|
+
ondragenter: (e) => {
|
|
321
|
+
state.dragOverIndex = idx;
|
|
322
|
+
},
|
|
323
|
+
ondragleave: (e) => {
|
|
324
|
+
if (state.dragOverIndex === idx) {
|
|
325
|
+
state.dragOverIndex = null;
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
ondrop: (e) => {
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
const fromIdx = state.draggedIndex;
|
|
331
|
+
const toIdx = idx;
|
|
332
|
+
if (fromIdx !== null && fromIdx !== undefined && fromIdx !== toIdx) {
|
|
333
|
+
const newUrls = [...urls];
|
|
334
|
+
const [movedItem] = newUrls.splice(fromIdx, 1);
|
|
335
|
+
newUrls.splice(toIdx, 0, movedItem);
|
|
336
|
+
if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
|
|
337
|
+
}
|
|
338
|
+
state.draggedIndex = null;
|
|
339
|
+
state.dragOverIndex = null;
|
|
340
|
+
},
|
|
341
|
+
ondragend: (e) => {
|
|
342
|
+
state.draggedIndex = null;
|
|
343
|
+
state.dragOverIndex = null;
|
|
344
|
+
}
|
|
345
|
+
}, [
|
|
302
346
|
shouldShowImagePreview(url, accept)
|
|
303
|
-
? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer' },
|
|
347
|
+
? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer', draggable: false },
|
|
304
348
|
m('img.h-32.w-full.rounded.object-contain.bg-white.dark:bg-slate-950', {
|
|
305
349
|
src: url,
|
|
306
350
|
alt: `${label} ${idx + 1}`,
|
|
307
351
|
loading: 'lazy',
|
|
352
|
+
draggable: false,
|
|
308
353
|
})
|
|
309
354
|
)
|
|
310
355
|
: m('a.flex.items-center.justify-center.h-32.w-full.text-sm.text-indigo-600.dark:text-indigo-400.break-all.p-2', {
|
|
311
356
|
href: url,
|
|
312
357
|
target: '_blank',
|
|
313
358
|
rel: 'noopener noreferrer',
|
|
359
|
+
draggable: false,
|
|
314
360
|
}, url.substring(url.lastIndexOf('/') + 1) || url),
|
|
315
361
|
!readonly
|
|
316
362
|
? m('button.absolute.top-1.right-1.bg-red-500.text-white.rounded-full.p-1.shadow-md.opacity-0.group-hover:opacity-100.hover:bg-red-700.transition-opacity', {
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSRF protection plugin — session/cookie double-submit CSRF validation + Nunjucks helpers
|
|
3
|
+
* @module plugins/csrf
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const { AsyncLocalStorage } = require('async_hooks');
|
|
8
|
+
const cookieParser = require('cookie-parser');
|
|
9
|
+
const nunjucks = require('nunjucks');
|
|
10
|
+
|
|
11
|
+
// Storage to pass the active request to Nunjucks template helpers in a thread-safe manner
|
|
12
|
+
const csrfStorage = new AsyncLocalStorage();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compare two strings in constant time to prevent timing attacks
|
|
16
|
+
* @param {string} a
|
|
17
|
+
* @param {string} b
|
|
18
|
+
* @returns {boolean}
|
|
19
|
+
*/
|
|
20
|
+
function safeCompare(a, b) {
|
|
21
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
22
|
+
if (a.length !== b.length) return false;
|
|
23
|
+
try {
|
|
24
|
+
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if the request path matches any ignore patterns
|
|
32
|
+
* @param {import('express').Request} req
|
|
33
|
+
* @param {Array<string|RegExp|Function>} ignorePaths
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
function isIgnored(req, ignorePaths) {
|
|
37
|
+
if (!ignorePaths || !Array.isArray(ignorePaths)) return false;
|
|
38
|
+
const path = req.path || '';
|
|
39
|
+
|
|
40
|
+
for (const pattern of ignorePaths) {
|
|
41
|
+
if (typeof pattern === 'string') {
|
|
42
|
+
// Prefix matching
|
|
43
|
+
if (path.startsWith(pattern)) return true;
|
|
44
|
+
// Basic glob matching
|
|
45
|
+
if (pattern.includes('*')) {
|
|
46
|
+
const regexPattern = pattern
|
|
47
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
48
|
+
.replace(/\*\*/g, '.*')
|
|
49
|
+
.replace(/\*/g, '[^/]*');
|
|
50
|
+
if (new RegExp(`^${regexPattern}$`).test(path)) return true;
|
|
51
|
+
}
|
|
52
|
+
} else if (pattern instanceof RegExp) {
|
|
53
|
+
if (pattern.test(path)) return true;
|
|
54
|
+
} else if (typeof pattern === 'function') {
|
|
55
|
+
if (pattern(req)) return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Nunjucks Helper: Get raw CSRF token
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function csrfToken() {
|
|
66
|
+
const req = csrfStorage.getStore();
|
|
67
|
+
if (req && typeof req.csrfToken === 'function') {
|
|
68
|
+
return req.csrfToken();
|
|
69
|
+
}
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Nunjucks Helper: Get hidden input HTML element with CSRF token
|
|
75
|
+
* @returns {nunjucks.runtime.SafeString}
|
|
76
|
+
*/
|
|
77
|
+
function csrfInput() {
|
|
78
|
+
const token = csrfToken();
|
|
79
|
+
if (!token) return '';
|
|
80
|
+
return new nunjucks.runtime.SafeString(
|
|
81
|
+
`<input type="hidden" name="_csrf" value="${token}">`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* CSRF Plugin
|
|
87
|
+
* @param {Object} [options]
|
|
88
|
+
* @param {boolean|Object} [options.cookie=false] - Cookie options. If true or object, cookie-based storage is used.
|
|
89
|
+
* @param {string} [options.cookie.key='_csrf'] - Cookie name.
|
|
90
|
+
* @param {boolean} [options.cookie.signed=false] - Whether to sign the cookie.
|
|
91
|
+
* @param {string} [options.cookie.path='/'] - Cookie path.
|
|
92
|
+
* @param {boolean} [options.cookie.httpOnly=true] - Cookie httpOnly flag.
|
|
93
|
+
* @param {boolean} [options.cookie.secure] - Cookie secure flag.
|
|
94
|
+
* @param {'lax'|'strict'|'none'} [options.cookie.sameSite='lax'] - Cookie SameSite policy.
|
|
95
|
+
* @param {number} [options.cookie.maxAge] - Cookie maxAge.
|
|
96
|
+
* @param {string} [options.sessionKey='csrfToken'] - Session key name.
|
|
97
|
+
* @param {string[]} [options.methods=['POST', 'PUT', 'DELETE', 'PATCH']] - Methods to protect.
|
|
98
|
+
* @param {Array<string|RegExp|Function>} [options.ignorePaths=[]] - Routes to skip verification.
|
|
99
|
+
* @param {boolean} [options.global=false] - Mount CSRF middleware globally on all routes.
|
|
100
|
+
* @param {string} [options.errorMessage='Invalid or missing CSRF token'] - Error message on failure.
|
|
101
|
+
* @param {number} [options.errorStatus=403] - HTTP status code on failure.
|
|
102
|
+
*/
|
|
103
|
+
function csrfPlugin(options = {}) {
|
|
104
|
+
const {
|
|
105
|
+
cookie = false,
|
|
106
|
+
sessionKey = 'csrfToken',
|
|
107
|
+
methods = ['POST', 'PUT', 'DELETE', 'PATCH'],
|
|
108
|
+
ignorePaths = [],
|
|
109
|
+
global: applyGlobal = false,
|
|
110
|
+
errorMessage = 'Invalid or missing CSRF token',
|
|
111
|
+
errorStatus = 403,
|
|
112
|
+
} = options;
|
|
113
|
+
|
|
114
|
+
// Resolve cookie config
|
|
115
|
+
const cookieEnabled = !!cookie;
|
|
116
|
+
const cookieOpts = typeof cookie === 'object' ? cookie : {};
|
|
117
|
+
const cookieKey = cookieOpts.key || '_csrf';
|
|
118
|
+
const cookieOptions = {
|
|
119
|
+
httpOnly: cookieOpts.httpOnly !== undefined ? cookieOpts.httpOnly : true,
|
|
120
|
+
path: cookieOpts.path || '/',
|
|
121
|
+
secure: cookieOpts.secure !== undefined ? cookieOpts.secure : (process.env.NODE_ENV === 'production'),
|
|
122
|
+
sameSite: cookieOpts.sameSite || 'lax',
|
|
123
|
+
signed: !!cookieOpts.signed,
|
|
124
|
+
};
|
|
125
|
+
if (cookieOpts.maxAge !== undefined) {
|
|
126
|
+
cookieOptions.maxAge = cookieOpts.maxAge;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Internal middleware creator
|
|
131
|
+
* @param {Object} [overrideOpts]
|
|
132
|
+
*/
|
|
133
|
+
function createMiddleware(overrideOpts = {}) {
|
|
134
|
+
const activeMethods = overrideOpts.methods || methods;
|
|
135
|
+
const activeIgnorePaths = overrideOpts.ignorePaths || ignorePaths;
|
|
136
|
+
|
|
137
|
+
return (req, res, next) => {
|
|
138
|
+
// Determine if cookie or session storage should be used
|
|
139
|
+
const useCookie = cookieEnabled || !req.session;
|
|
140
|
+
|
|
141
|
+
// Initialize cookie parser dynamically if needed
|
|
142
|
+
if (useCookie && !req.cookies) {
|
|
143
|
+
const secret = cookieOptions.signed ? (cookieOpts.secret || process.env.SESSION_SECRET) : undefined;
|
|
144
|
+
cookieParser(secret)(req, res, () => {});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 1. Retrieve or generate secret token
|
|
148
|
+
let secret;
|
|
149
|
+
if (useCookie) {
|
|
150
|
+
secret = cookieOptions.signed ? req.signedCookies[cookieKey] : req.cookies[cookieKey];
|
|
151
|
+
if (!secret) {
|
|
152
|
+
secret = crypto.randomBytes(32).toString('hex');
|
|
153
|
+
res.cookie(cookieKey, secret, cookieOptions);
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
// Session based
|
|
157
|
+
if (!req.session[sessionKey]) {
|
|
158
|
+
req.session[sessionKey] = crypto.randomBytes(32).toString('hex');
|
|
159
|
+
}
|
|
160
|
+
secret = req.session[sessionKey];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Define standard req.csrfToken()
|
|
164
|
+
req.csrfToken = () => secret;
|
|
165
|
+
|
|
166
|
+
// 2. Perform CSRF verification for mutating HTTP methods
|
|
167
|
+
const method = req.method ? req.method.toUpperCase() : 'GET';
|
|
168
|
+
if (activeMethods.includes(method) && !isIgnored(req, activeIgnorePaths)) {
|
|
169
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
170
|
+
const query = req.query && typeof req.query === 'object' ? req.query : {};
|
|
171
|
+
const submitted = (
|
|
172
|
+
body._csrf ||
|
|
173
|
+
query._csrf ||
|
|
174
|
+
req.headers['x-csrf-token'] ||
|
|
175
|
+
req.headers['xsrf-token'] ||
|
|
176
|
+
req.headers['x-xsrf-token']
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
if (!submitted || !safeCompare(submitted, secret)) {
|
|
180
|
+
res.status(errorStatus);
|
|
181
|
+
if (req.accepts('html') && !req.path.startsWith('/api')) {
|
|
182
|
+
return res.send(`<h1>${errorStatus} Forbidden</h1><p>${errorMessage}</p>`);
|
|
183
|
+
} else {
|
|
184
|
+
return res.json({ error: errorMessage });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 3. Run downstream handlers inside AsyncLocalStorage context
|
|
190
|
+
csrfStorage.run(req, () => {
|
|
191
|
+
next();
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
name: 'csrf',
|
|
198
|
+
version: '1.0.0',
|
|
199
|
+
description: 'CSRF protection plugin for Webspresso with cookie/session storage and template helpers',
|
|
200
|
+
|
|
201
|
+
api: {
|
|
202
|
+
createMiddleware,
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
register(ctx) {
|
|
206
|
+
// Register named route middleware
|
|
207
|
+
ctx.middlewares.csrf = (routeOpts = {}) => createMiddleware(routeOpts);
|
|
208
|
+
|
|
209
|
+
// Register template helpers
|
|
210
|
+
ctx.addHelper('csrfToken', csrfToken);
|
|
211
|
+
ctx.addHelper('csrfInput', csrfInput);
|
|
212
|
+
|
|
213
|
+
// If global validation is enabled, mount the middleware globally on the Express app
|
|
214
|
+
if (applyGlobal) {
|
|
215
|
+
ctx.app.use(createMiddleware());
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = csrfPlugin;
|
|
222
|
+
module.exports.csrfPlugin = csrfPlugin;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email admin page — Mithril component source (string)
|
|
3
|
+
* @module plugins/email/admin-component
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {Object} [options={}]
|
|
8
|
+
* @param {string} [options.apiPrefix='/email']
|
|
9
|
+
*/
|
|
10
|
+
function generateEmailComponent(options = {}) {
|
|
11
|
+
const apiPrefix = options.apiPrefix || '/email';
|
|
12
|
+
|
|
13
|
+
return `
|
|
14
|
+
(function() {
|
|
15
|
+
var API = '${apiPrefix}';
|
|
16
|
+
|
|
17
|
+
function EmailPage() {
|
|
18
|
+
var tab = 'logs';
|
|
19
|
+
var logs = [];
|
|
20
|
+
var templates = [];
|
|
21
|
+
var loading = true;
|
|
22
|
+
var error = null;
|
|
23
|
+
var page = 1;
|
|
24
|
+
var perPage = 25;
|
|
25
|
+
var total = 0;
|
|
26
|
+
var statusFilter = '';
|
|
27
|
+
var testTo = '';
|
|
28
|
+
var testSubject = 'Test email';
|
|
29
|
+
var testTemplate = '';
|
|
30
|
+
var testData = '{}';
|
|
31
|
+
var previewHtml = '';
|
|
32
|
+
var verifyMsg = '';
|
|
33
|
+
var actionMsg = '';
|
|
34
|
+
|
|
35
|
+
function loadLogs() {
|
|
36
|
+
loading = true;
|
|
37
|
+
error = null;
|
|
38
|
+
var q = '?page=' + page + '&perPage=' + perPage;
|
|
39
|
+
if (statusFilter) q += '&status=' + encodeURIComponent(statusFilter);
|
|
40
|
+
api.get(API + '/logs' + q).then(function(res) {
|
|
41
|
+
logs = res.data || [];
|
|
42
|
+
total = (res.meta && res.meta.total) || 0;
|
|
43
|
+
loading = false;
|
|
44
|
+
}).catch(function(e) {
|
|
45
|
+
error = e.message || String(e);
|
|
46
|
+
loading = false;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function loadTemplates() {
|
|
51
|
+
api.get(API + '/templates').then(function(res) {
|
|
52
|
+
templates = res.data || [];
|
|
53
|
+
}).catch(function() {});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
oninit: function() {
|
|
58
|
+
loadTemplates();
|
|
59
|
+
loadLogs();
|
|
60
|
+
},
|
|
61
|
+
view: function() {
|
|
62
|
+
return m('div.p-6.space-y-4', [
|
|
63
|
+
m('div.flex.gap-2.border-b.pb-2', [
|
|
64
|
+
['logs', 'send', 'preview', 'verify'].map(function(t) {
|
|
65
|
+
return m('button.px-3.py-1.rounded.text-sm', {
|
|
66
|
+
class: tab === t ? 'bg-blue-600 text-white' : 'bg-gray-100',
|
|
67
|
+
onclick: function() { tab = t; actionMsg = ''; verifyMsg = ''; },
|
|
68
|
+
}, t === 'logs' ? 'Logs' : t === 'send' ? 'Test send' : t === 'preview' ? 'Preview' : 'Verify SMTP');
|
|
69
|
+
}),
|
|
70
|
+
]),
|
|
71
|
+
|
|
72
|
+
tab === 'logs' && (loading
|
|
73
|
+
? m('div.text-gray-500', 'Loading…')
|
|
74
|
+
: error
|
|
75
|
+
? m('div.text-red-600', error)
|
|
76
|
+
: m('div.space-y-3', [
|
|
77
|
+
m('div.flex.flex-wrap.gap-2.items-end', [
|
|
78
|
+
m('select.border.rounded.px-2.py-1', {
|
|
79
|
+
value: statusFilter,
|
|
80
|
+
onchange: function(e) { statusFilter = e.target.value; page = 1; loadLogs(); },
|
|
81
|
+
}, [
|
|
82
|
+
m('option', { value: '' }, 'All statuses'),
|
|
83
|
+
m('option', { value: 'sent' }, 'sent'),
|
|
84
|
+
m('option', { value: 'failed' }, 'failed'),
|
|
85
|
+
]),
|
|
86
|
+
m('button.border.px-3.py-1.rounded', { onclick: loadLogs }, 'Refresh'),
|
|
87
|
+
m('button.border.px-3.py-1.rounded.text-red-700', {
|
|
88
|
+
onclick: function() {
|
|
89
|
+
if (!confirm('Delete logs older than 90 days?')) return;
|
|
90
|
+
api.post(API + '/purge', { days: 90 }).then(function() { loadLogs(); });
|
|
91
|
+
},
|
|
92
|
+
}, 'Purge 90d'),
|
|
93
|
+
]),
|
|
94
|
+
m('div.text-sm.text-gray-500', 'Total: ' + total),
|
|
95
|
+
m('div.overflow-x-auto.border.rounded', [
|
|
96
|
+
m('table.min-w-full.text-sm', [
|
|
97
|
+
m('thead.bg-gray-50', m('tr', [
|
|
98
|
+
m('th.text-left.p-2', 'Time'),
|
|
99
|
+
m('th.text-left.p-2', 'To'),
|
|
100
|
+
m('th.text-left.p-2', 'Subject'),
|
|
101
|
+
m('th.text-left.p-2', 'Template'),
|
|
102
|
+
m('th.text-left.p-2', 'Status'),
|
|
103
|
+
m('th.text-left.p-2', 'Error'),
|
|
104
|
+
])),
|
|
105
|
+
m('tbody', logs.map(function(r) {
|
|
106
|
+
return m('tr.border-t', [
|
|
107
|
+
m('td.p-2.whitespace-nowrap', r.created_at || ''),
|
|
108
|
+
m('td.p-2', r.to || ''),
|
|
109
|
+
m('td.p-2', r.subject || ''),
|
|
110
|
+
m('td.p-2', r.template_id || '—'),
|
|
111
|
+
m('td.p-2', r.status),
|
|
112
|
+
m('td.p-2.text-red-600.max-w-xs.truncate', { title: r.error || '' }, r.error || ''),
|
|
113
|
+
]);
|
|
114
|
+
})),
|
|
115
|
+
]),
|
|
116
|
+
]),
|
|
117
|
+
])),
|
|
118
|
+
|
|
119
|
+
tab === 'send' && m('div.space-y-3.max-w-xl', [
|
|
120
|
+
m('div', [
|
|
121
|
+
m('label.block.text-xs.text-gray-500.mb-1', 'To'),
|
|
122
|
+
m('input.border.rounded.w-full.px-2.py-1', {
|
|
123
|
+
value: testTo,
|
|
124
|
+
oninput: function(e) { testTo = e.target.value; },
|
|
125
|
+
placeholder: 'user@example.com',
|
|
126
|
+
}),
|
|
127
|
+
]),
|
|
128
|
+
m('div', [
|
|
129
|
+
m('label.block.text-xs.text-gray-500.mb-1', 'Subject'),
|
|
130
|
+
m('input.border.rounded.w-full.px-2.py-1', {
|
|
131
|
+
value: testSubject,
|
|
132
|
+
oninput: function(e) { testSubject = e.target.value; },
|
|
133
|
+
}),
|
|
134
|
+
]),
|
|
135
|
+
m('div', [
|
|
136
|
+
m('label.block.text-xs.text-gray-500.mb-1', 'Template'),
|
|
137
|
+
m('select.border.rounded.w-full.px-2.py-1', {
|
|
138
|
+
value: testTemplate,
|
|
139
|
+
onchange: function(e) { testTemplate = e.target.value; },
|
|
140
|
+
}, [
|
|
141
|
+
m('option', { value: '' }, '(none — use data only if template empty)'),
|
|
142
|
+
].concat(templates.map(function(t) {
|
|
143
|
+
return m('option', { value: t.id }, t.id);
|
|
144
|
+
}))),
|
|
145
|
+
]),
|
|
146
|
+
m('div', [
|
|
147
|
+
m('label.block.text-xs.text-gray-500.mb-1', 'Data (JSON)'),
|
|
148
|
+
m('textarea.border.rounded.w-full.px-2.py-1.font-mono.text-xs', {
|
|
149
|
+
rows: 4,
|
|
150
|
+
value: testData,
|
|
151
|
+
oninput: function(e) { testData = e.target.value; },
|
|
152
|
+
}),
|
|
153
|
+
]),
|
|
154
|
+
m('button.bg-blue-600.text-white.px-4.py-2.rounded', {
|
|
155
|
+
onclick: function() {
|
|
156
|
+
actionMsg = 'Sending…';
|
|
157
|
+
api.post(API + '/send-test', {
|
|
158
|
+
to: testTo,
|
|
159
|
+
subject: testSubject,
|
|
160
|
+
template: testTemplate || undefined,
|
|
161
|
+
data: testData,
|
|
162
|
+
}).then(function() {
|
|
163
|
+
actionMsg = 'Sent.';
|
|
164
|
+
loadLogs();
|
|
165
|
+
}).catch(function(e) {
|
|
166
|
+
actionMsg = e.message || String(e);
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
}, 'Send test'),
|
|
170
|
+
actionMsg && m('div.text-sm', actionMsg),
|
|
171
|
+
]),
|
|
172
|
+
|
|
173
|
+
tab === 'preview' && m('div.space-y-3', [
|
|
174
|
+
m('div.max-w-xl.space-y-2', [
|
|
175
|
+
m('select.border.rounded.w-full.px-2.py-1', {
|
|
176
|
+
value: testTemplate,
|
|
177
|
+
onchange: function(e) { testTemplate = e.target.value; },
|
|
178
|
+
}, templates.map(function(t) {
|
|
179
|
+
return m('option', { value: t.id }, t.id);
|
|
180
|
+
})),
|
|
181
|
+
m('textarea.border.rounded.w-full.px-2.py-1.font-mono.text-xs', {
|
|
182
|
+
rows: 3,
|
|
183
|
+
value: testData,
|
|
184
|
+
oninput: function(e) { testData = e.target.value; },
|
|
185
|
+
}),
|
|
186
|
+
m('button.border.px-3.py-1.rounded', {
|
|
187
|
+
onclick: function() {
|
|
188
|
+
api.post(API + '/preview', { template: testTemplate, data: testData }).then(function(res) {
|
|
189
|
+
previewHtml = res.html || '';
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
}, 'Preview'),
|
|
193
|
+
]),
|
|
194
|
+
previewHtml && m('iframe.border.rounded.w-full', {
|
|
195
|
+
style: 'height: 420px',
|
|
196
|
+
srcdoc: previewHtml,
|
|
197
|
+
}),
|
|
198
|
+
]),
|
|
199
|
+
|
|
200
|
+
tab === 'verify' && m('div.space-y-3', [
|
|
201
|
+
m('button.bg-blue-600.text-white.px-4.py-2.rounded', {
|
|
202
|
+
onclick: function() {
|
|
203
|
+
verifyMsg = 'Checking…';
|
|
204
|
+
api.post(API + '/verify').then(function(res) {
|
|
205
|
+
verifyMsg = res.message || 'OK';
|
|
206
|
+
}).catch(function(e) {
|
|
207
|
+
verifyMsg = e.message || String(e);
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
}, 'Verify SMTP connection'),
|
|
211
|
+
verifyMsg && m('div.text-sm', verifyMsg),
|
|
212
|
+
]),
|
|
213
|
+
]);
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
window.__customPages = window.__customPages || {};
|
|
219
|
+
window.__customPages['email'] = EmailPage;
|
|
220
|
+
})();
|
|
221
|
+
`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = {
|
|
225
|
+
generateEmailComponent,
|
|
226
|
+
};
|