webspresso 0.0.80 → 0.0.82
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 +133 -15
- package/bin/commands/doctor.js +83 -0
- package/bin/commands/favicon-generate.js +3 -1
- package/bin/commands/skill.js +6 -4
- 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/app.js +43 -1
- package/plugins/admin-panel/core/admin-module.js +156 -16
- package/plugins/admin-panel/core/registry.js +3 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +48 -2
- package/plugins/admin-panel/index.js +17 -1
- 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/templates/skills/webspresso-usage/SKILL.md +2 -1
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin API handlers for email plugin
|
|
3
|
+
* @module plugins/email/api-handlers
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { purgeEmailLogs } = require('./purge');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {Object} options
|
|
10
|
+
* @param {ReturnType<import('./service').createEmailService>} options.emailService
|
|
11
|
+
* @param {import('./template-registry').TemplateRegistry} options.registry
|
|
12
|
+
* @param {import('knex').Knex|null} [options.knex]
|
|
13
|
+
* @param {string} [options.tableName='email_logs']
|
|
14
|
+
*/
|
|
15
|
+
function createEmailApiHandlers(options) {
|
|
16
|
+
const emailService = options.emailService;
|
|
17
|
+
const registry = options.registry;
|
|
18
|
+
const knex = options.knex || null;
|
|
19
|
+
const tableName = options.tableName || 'email_logs';
|
|
20
|
+
|
|
21
|
+
async function listLogsHandler(req, res) {
|
|
22
|
+
try {
|
|
23
|
+
const result = await emailService.queryLogs({
|
|
24
|
+
page: req.query.page,
|
|
25
|
+
perPage: req.query.perPage,
|
|
26
|
+
status: req.query.status,
|
|
27
|
+
template: req.query.template,
|
|
28
|
+
from: req.query.from,
|
|
29
|
+
to: req.query.to,
|
|
30
|
+
includeTotal: true,
|
|
31
|
+
});
|
|
32
|
+
res.json(result);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
res.status(500).json({ error: err.message });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function sendTestHandler(req, res) {
|
|
39
|
+
try {
|
|
40
|
+
const body = req.body || {};
|
|
41
|
+
const { to, subject, template, mjml, html, data } = body;
|
|
42
|
+
|
|
43
|
+
if (!to || !subject) {
|
|
44
|
+
return res.status(400).json({ error: 'to and subject are required' });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let parsedData = data;
|
|
48
|
+
if (typeof data === 'string') {
|
|
49
|
+
try {
|
|
50
|
+
parsedData = JSON.parse(data);
|
|
51
|
+
} catch {
|
|
52
|
+
parsedData = {};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const result = await emailService.send({
|
|
57
|
+
to,
|
|
58
|
+
subject,
|
|
59
|
+
template,
|
|
60
|
+
mjml,
|
|
61
|
+
html,
|
|
62
|
+
data: parsedData || {},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
res.json({ ok: true, ...result });
|
|
66
|
+
} catch (err) {
|
|
67
|
+
res.status(500).json({ error: err.message });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function previewHandler(req, res) {
|
|
72
|
+
try {
|
|
73
|
+
const body = req.body || {};
|
|
74
|
+
const { template, mjml, html, data } = body;
|
|
75
|
+
|
|
76
|
+
let parsedData = data;
|
|
77
|
+
if (typeof data === 'string') {
|
|
78
|
+
try {
|
|
79
|
+
parsedData = JSON.parse(data);
|
|
80
|
+
} catch {
|
|
81
|
+
parsedData = {};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const result = await emailService.preview(
|
|
86
|
+
template ? { template, data: parsedData || {} } : { mjml, html, data: parsedData || {} }
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
res.json(result);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
res.status(500).json({ error: err.message });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function verifyHandler(req, res) {
|
|
96
|
+
try {
|
|
97
|
+
await emailService.verifyConnection();
|
|
98
|
+
res.json({ ok: true, message: 'SMTP connection verified' });
|
|
99
|
+
} catch (err) {
|
|
100
|
+
res.status(500).json({ error: err.message });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function templatesHandler(req, res) {
|
|
105
|
+
res.json({ data: registry.list() });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function purgeHandler(req, res) {
|
|
109
|
+
if (!knex) {
|
|
110
|
+
return res.status(400).json({ error: 'Database logging is not enabled' });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const days = parseInt(req.body?.days || req.query?.days || '90', 10);
|
|
115
|
+
if (Number.isNaN(days) || days < 1) {
|
|
116
|
+
return res.status(400).json({ error: 'days must be a positive integer' });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const olderThan = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
120
|
+
const deleted = await purgeEmailLogs(knex, { tableName, olderThan });
|
|
121
|
+
res.json({ ok: true, deleted, olderThan: olderThan.toISOString() });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
res.status(500).json({ error: err.message });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
listLogsHandler,
|
|
129
|
+
sendTestHandler,
|
|
130
|
+
previewHandler,
|
|
131
|
+
verifyHandler,
|
|
132
|
+
templatesHandler,
|
|
133
|
+
purgeHandler,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = {
|
|
138
|
+
createEmailApiHandlers,
|
|
139
|
+
};
|