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,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth ↔ email plugin bridge
|
|
3
|
+
* @module plugins/email/auth-bridge
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { createKnexAuthTokensAdapter } = require('../../core/auth/tokens');
|
|
7
|
+
const { registerBundledAuthTemplates } = require('./bundled-templates');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_AUTH_EMAILS = {
|
|
10
|
+
passwordReset: {
|
|
11
|
+
enabled: true,
|
|
12
|
+
template: 'auth-password-reset',
|
|
13
|
+
subject: 'Reset your password',
|
|
14
|
+
path: '/reset-password',
|
|
15
|
+
tokenTtlMs: 60 * 60 * 1000,
|
|
16
|
+
},
|
|
17
|
+
emailVerification: {
|
|
18
|
+
enabled: true,
|
|
19
|
+
template: 'auth-email-verify',
|
|
20
|
+
subject: 'Verify your email',
|
|
21
|
+
path: '/verify-email',
|
|
22
|
+
tokenTtlMs: 24 * 60 * 60 * 1000,
|
|
23
|
+
},
|
|
24
|
+
welcome: {
|
|
25
|
+
enabled: false,
|
|
26
|
+
template: 'auth-welcome',
|
|
27
|
+
subject: 'Welcome',
|
|
28
|
+
},
|
|
29
|
+
registerRoutes: true,
|
|
30
|
+
routePrefix: '/api/auth',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {Object} authManager
|
|
35
|
+
* @param {Object} emailService
|
|
36
|
+
* @param {import('./template-registry').TemplateRegistry} registry
|
|
37
|
+
* @param {Object} config
|
|
38
|
+
* @param {Object} db
|
|
39
|
+
*/
|
|
40
|
+
function wireAuthEmails(authManager, emailService, registry, config, db) {
|
|
41
|
+
if (!authManager || !emailService) return null;
|
|
42
|
+
|
|
43
|
+
const merged = {
|
|
44
|
+
...DEFAULT_AUTH_EMAILS,
|
|
45
|
+
...config,
|
|
46
|
+
passwordReset: { ...DEFAULT_AUTH_EMAILS.passwordReset, ...config.passwordReset },
|
|
47
|
+
emailVerification: { ...DEFAULT_AUTH_EMAILS.emailVerification, ...config.emailVerification },
|
|
48
|
+
welcome: { ...DEFAULT_AUTH_EMAILS.welcome, ...config.welcome },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
registerBundledAuthTemplates(registry);
|
|
52
|
+
|
|
53
|
+
if (!authManager.authTokens && db) {
|
|
54
|
+
authManager.authTokens = createKnexAuthTokensAdapter(db);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const baseUrl = (merged.baseUrl || process.env.BASE_URL || 'http://localhost:3000').replace(/\/$/, '');
|
|
58
|
+
const identifierField = authManager.identifierField || 'email';
|
|
59
|
+
|
|
60
|
+
function userDisplayName(user) {
|
|
61
|
+
return user.name || user[identifierField] || 'there';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildUrl(pathSegment, token) {
|
|
65
|
+
const sep = pathSegment.includes('?') ? '&' : '?';
|
|
66
|
+
return `${baseUrl}${pathSegment}${sep}token=${encodeURIComponent(token)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (merged.passwordReset?.enabled) {
|
|
70
|
+
authManager.notifications.passwordReset = async (user, { token, resetUrl }) => {
|
|
71
|
+
const url = resetUrl || buildUrl(merged.passwordReset.path, token);
|
|
72
|
+
await emailService.sendTemplate(merged.passwordReset.template, {
|
|
73
|
+
to: user[identifierField],
|
|
74
|
+
subject: merged.passwordReset.subject,
|
|
75
|
+
data: {
|
|
76
|
+
name: userDisplayName(user),
|
|
77
|
+
token,
|
|
78
|
+
resetUrl: url,
|
|
79
|
+
email: user[identifierField],
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (merged.emailVerification?.enabled) {
|
|
86
|
+
authManager.notifications.emailVerification = async (user, { token, verifyUrl }) => {
|
|
87
|
+
const url = verifyUrl || buildUrl(merged.emailVerification.path, token);
|
|
88
|
+
await emailService.sendTemplate(merged.emailVerification.template, {
|
|
89
|
+
to: user[identifierField],
|
|
90
|
+
subject: merged.emailVerification.subject,
|
|
91
|
+
data: {
|
|
92
|
+
name: userDisplayName(user),
|
|
93
|
+
token,
|
|
94
|
+
verifyUrl: url,
|
|
95
|
+
email: user[identifierField],
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (merged.welcome?.enabled) {
|
|
102
|
+
authManager.notifications.welcome = async (user) => {
|
|
103
|
+
await emailService.sendTemplate(merged.welcome.template, {
|
|
104
|
+
to: user[identifierField],
|
|
105
|
+
subject: merged.welcome.subject,
|
|
106
|
+
data: {
|
|
107
|
+
name: userDisplayName(user),
|
|
108
|
+
email: user[identifierField],
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return merged;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {Object} options
|
|
119
|
+
* @param {Object} options.authManager
|
|
120
|
+
* @param {Object} options.ctx - RoutesReadyContext
|
|
121
|
+
* @param {Object} options.authEmailsConfig
|
|
122
|
+
*/
|
|
123
|
+
function registerAuthEmailRoutes(options) {
|
|
124
|
+
const { authManager, ctx, authEmailsConfig } = options;
|
|
125
|
+
if (!authEmailsConfig?.registerRoutes) return;
|
|
126
|
+
|
|
127
|
+
const prefix = authEmailsConfig.routePrefix || '/api/auth';
|
|
128
|
+
const guest = ctx.middlewares?.guest;
|
|
129
|
+
const authMw = ctx.middlewares?.auth;
|
|
130
|
+
|
|
131
|
+
const base = (authEmailsConfig.baseUrl || process.env.BASE_URL || 'http://localhost:3000').replace(/\/$/, '');
|
|
132
|
+
|
|
133
|
+
async function forgotPasswordHandler(req, res) {
|
|
134
|
+
try {
|
|
135
|
+
const email = req.body?.email || req.body?.identifier;
|
|
136
|
+
if (email) {
|
|
137
|
+
await authManager.requestPasswordReset(String(email), {
|
|
138
|
+
tokenTtlMs: authEmailsConfig.passwordReset?.tokenTtlMs,
|
|
139
|
+
buildResetUrl: (token) => {
|
|
140
|
+
const pathSeg = authEmailsConfig.passwordReset?.path || '/reset-password';
|
|
141
|
+
return `${base}${pathSeg}?token=${encodeURIComponent(token)}`;
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
res.json({ ok: true });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
res.status(500).json({ error: err.message });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function resetPasswordHandler(req, res) {
|
|
152
|
+
try {
|
|
153
|
+
const { token, password } = req.body || {};
|
|
154
|
+
if (!token || !password) {
|
|
155
|
+
return res.status(400).json({ error: 'token and password are required' });
|
|
156
|
+
}
|
|
157
|
+
await authManager.completePasswordReset(String(token), String(password));
|
|
158
|
+
res.json({ ok: true });
|
|
159
|
+
} catch (err) {
|
|
160
|
+
res.status(err.status || 400).json({ error: err.message, code: err.code });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function verifyEmailHandler(req, res) {
|
|
165
|
+
try {
|
|
166
|
+
const { token } = req.body || {};
|
|
167
|
+
if (!token) {
|
|
168
|
+
return res.status(400).json({ error: 'token is required' });
|
|
169
|
+
}
|
|
170
|
+
const result = await authManager.verifyEmail(String(token));
|
|
171
|
+
res.json(result);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
res.status(err.status || 400).json({ error: err.message, code: err.code });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function resendVerificationHandler(req, res) {
|
|
178
|
+
try {
|
|
179
|
+
const userId = req.user?.id;
|
|
180
|
+
if (!userId) {
|
|
181
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
182
|
+
}
|
|
183
|
+
await authManager.requestEmailVerification(userId, {
|
|
184
|
+
tokenTtlMs: authEmailsConfig.emailVerification?.tokenTtlMs,
|
|
185
|
+
buildVerifyUrl: (token) => {
|
|
186
|
+
const pathSeg = authEmailsConfig.emailVerification?.path || '/verify-email';
|
|
187
|
+
return `${base}${pathSeg}?token=${encodeURIComponent(token)}`;
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
res.json({ ok: true });
|
|
191
|
+
} catch (err) {
|
|
192
|
+
res.status(500).json({ error: err.message });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const forgotMw = guest ? [guest, forgotPasswordHandler] : [forgotPasswordHandler];
|
|
197
|
+
const resetMw = guest ? [guest, resetPasswordHandler] : [resetPasswordHandler];
|
|
198
|
+
const resendMw = authMw ? [authMw, resendVerificationHandler] : [resendVerificationHandler];
|
|
199
|
+
|
|
200
|
+
ctx.addRoute('post', `${prefix}/forgot-password`, ...forgotMw);
|
|
201
|
+
ctx.addRoute('post', `${prefix}/reset-password`, ...resetMw);
|
|
202
|
+
ctx.addRoute('post', `${prefix}/verify-email`, verifyEmailHandler);
|
|
203
|
+
ctx.addRoute('post', `${prefix}/resend-verification`, ...resendMw);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
wireAuthEmails,
|
|
208
|
+
registerAuthEmailRoutes,
|
|
209
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in auth email templates (inline MJML — no filesystem reads)
|
|
3
|
+
* Source copies for editing: plugins/email/templates/*.mjml (sync to this file manually)
|
|
4
|
+
* @module plugins/email/bundled-templates
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** @type {Record<string, { mjml: string }>} */
|
|
8
|
+
const BUNDLED_AUTH_TEMPLATES = {
|
|
9
|
+
'auth-password-reset': {
|
|
10
|
+
mjml: `<mjml>
|
|
11
|
+
<mj-body background-color="#f4f4f5">
|
|
12
|
+
<mj-section background-color="#ffffff" padding="32px 24px">
|
|
13
|
+
<mj-column>
|
|
14
|
+
<mj-text font-size="20px" font-weight="600" color="#18181b">Reset your password</mj-text>
|
|
15
|
+
<mj-text color="#52525b">Hi {{name}},</mj-text>
|
|
16
|
+
<mj-text color="#52525b">We received a request to reset your password. Click the button below to choose a new one.</mj-text>
|
|
17
|
+
<mj-button background-color="#2563eb" href="{{resetUrl}}">Reset password</mj-button>
|
|
18
|
+
<mj-text color="#71717a" font-size="12px">If you did not request this, you can safely ignore this email.</mj-text>
|
|
19
|
+
</mj-column>
|
|
20
|
+
</mj-section>
|
|
21
|
+
</mj-body>
|
|
22
|
+
</mjml>`,
|
|
23
|
+
},
|
|
24
|
+
'auth-email-verify': {
|
|
25
|
+
mjml: `<mjml>
|
|
26
|
+
<mj-body background-color="#f4f4f5">
|
|
27
|
+
<mj-section background-color="#ffffff" padding="32px 24px">
|
|
28
|
+
<mj-column>
|
|
29
|
+
<mj-text font-size="20px" font-weight="600" color="#18181b">Verify your email</mj-text>
|
|
30
|
+
<mj-text color="#52525b">Hi {{name}},</mj-text>
|
|
31
|
+
<mj-text color="#52525b">Please confirm your email address by clicking the button below.</mj-text>
|
|
32
|
+
<mj-button background-color="#2563eb" href="{{verifyUrl}}">Verify email</mj-button>
|
|
33
|
+
<mj-text color="#71717a" font-size="12px">If you did not create an account, you can ignore this message.</mj-text>
|
|
34
|
+
</mj-column>
|
|
35
|
+
</mj-section>
|
|
36
|
+
</mj-body>
|
|
37
|
+
</mjml>`,
|
|
38
|
+
},
|
|
39
|
+
'auth-welcome': {
|
|
40
|
+
mjml: `<mjml>
|
|
41
|
+
<mj-body background-color="#f4f4f5">
|
|
42
|
+
<mj-section background-color="#ffffff" padding="32px 24px">
|
|
43
|
+
<mj-column>
|
|
44
|
+
<mj-text font-size="20px" font-weight="600" color="#18181b">Welcome</mj-text>
|
|
45
|
+
<mj-text color="#52525b">Hi {{name}},</mj-text>
|
|
46
|
+
<mj-text color="#52525b">Thanks for joining us. Your account is ready.</mj-text>
|
|
47
|
+
</mj-column>
|
|
48
|
+
</mj-section>
|
|
49
|
+
</mj-body>
|
|
50
|
+
</mjml>`,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {import('./template-registry').TemplateRegistry} registry
|
|
56
|
+
*/
|
|
57
|
+
function registerBundledAuthTemplates(registry) {
|
|
58
|
+
for (const [id, source] of Object.entries(BUNDLED_AUTH_TEMPLATES)) {
|
|
59
|
+
if (!registry.has(id)) {
|
|
60
|
+
registry.register(id, source, 'inline');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = {
|
|
66
|
+
BUNDLED_AUTH_TEMPLATES,
|
|
67
|
+
registerBundledAuthTemplates,
|
|
68
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional Node.js filesystem helpers — no top-level fs import (edge/worker safe)
|
|
3
|
+
* @module plugins/email/fs-env
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
let fsModule = null;
|
|
7
|
+
let fsChecked = false;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @returns {typeof import('fs')|null}
|
|
11
|
+
*/
|
|
12
|
+
function getFs() {
|
|
13
|
+
if (fsChecked) return fsModule;
|
|
14
|
+
fsChecked = true;
|
|
15
|
+
try {
|
|
16
|
+
fsModule = require('fs');
|
|
17
|
+
} catch {
|
|
18
|
+
fsModule = null;
|
|
19
|
+
}
|
|
20
|
+
return fsModule;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
function isFilesystemAvailable() {
|
|
27
|
+
return getFs() != null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} str
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
function isInlineMarkup(str) {
|
|
35
|
+
const trimmed = String(str).trim();
|
|
36
|
+
return trimmed.startsWith('<mjml')
|
|
37
|
+
|| trimmed.startsWith('<html')
|
|
38
|
+
|| trimmed.startsWith('<!DOCTYPE')
|
|
39
|
+
|| trimmed.startsWith('<!doctype');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} str
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
function looksLikeFilePath(str) {
|
|
47
|
+
if (isInlineMarkup(str)) return false;
|
|
48
|
+
const s = String(str);
|
|
49
|
+
return s.startsWith('./')
|
|
50
|
+
|| s.startsWith('../')
|
|
51
|
+
|| s.startsWith('/')
|
|
52
|
+
|| s.startsWith('~')
|
|
53
|
+
|| /\.(mjml|html|htm|txt)$/i.test(s);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} filePath
|
|
58
|
+
* @returns {string|null}
|
|
59
|
+
*/
|
|
60
|
+
function readFileUtf8(filePath) {
|
|
61
|
+
const fs = getFs();
|
|
62
|
+
if (!fs) return null;
|
|
63
|
+
|
|
64
|
+
const path = require('path');
|
|
65
|
+
const resolved = path.resolve(filePath);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
if (!fs.existsSync(resolved)) return null;
|
|
69
|
+
return fs.readFileSync(resolved, 'utf8');
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {string} dir
|
|
77
|
+
* @returns {string[]|null} null when fs unavailable
|
|
78
|
+
*/
|
|
79
|
+
function listMjmlBasenamesInDir(dir) {
|
|
80
|
+
const fs = getFs();
|
|
81
|
+
if (!fs) return null;
|
|
82
|
+
|
|
83
|
+
const path = require('path');
|
|
84
|
+
const resolved = path.resolve(dir);
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
if (!fs.existsSync(resolved)) return [];
|
|
88
|
+
return fs.readdirSync(resolved).filter((f) => f.endsWith('.mjml'));
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
getFs,
|
|
96
|
+
isFilesystemAvailable,
|
|
97
|
+
isInlineMarkup,
|
|
98
|
+
looksLikeFilePath,
|
|
99
|
+
readFileUtf8,
|
|
100
|
+
listMjmlBasenamesInDir,
|
|
101
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email plugin — MJML + Nodemailer
|
|
3
|
+
* @module plugins/email
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { TemplateRegistry } = require('./template-registry');
|
|
7
|
+
const { createEmailService } = require('./service');
|
|
8
|
+
const { createEmailApiHandlers } = require('./api-handlers');
|
|
9
|
+
const { generateEmailComponent } = require('./admin-component');
|
|
10
|
+
const { generateEmailLogsMigration } = require('./migration-template');
|
|
11
|
+
const { purgeEmailLogs } = require('./purge');
|
|
12
|
+
const { wireAuthEmails, registerAuthEmailRoutes } = require('./auth-bridge');
|
|
13
|
+
const { emailPluginOptionsFromManifest } = require('./manifest');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {Object} options
|
|
17
|
+
* @param {Object} [options.db] - Database instance (must expose .knex)
|
|
18
|
+
* @param {Object} [options.transport] - Nodemailer transport instance
|
|
19
|
+
* @param {Object} [options.smtp] - SMTP options
|
|
20
|
+
* @param {Object} [options.defaults] - Default from/replyTo
|
|
21
|
+
* @param {string} [options.templatesDir] - Directory of *.mjml templates
|
|
22
|
+
* @param {Object} [options.templates] - Template map
|
|
23
|
+
* @param {boolean} [options.logToDb] - Log sends to DB (default true when db present)
|
|
24
|
+
* @param {string} [options.tableName='email_logs']
|
|
25
|
+
* @param {boolean} [options.includeAdminPage=true]
|
|
26
|
+
* @param {string} [options.adminPath='/_admin']
|
|
27
|
+
* @param {string} [options.apiPrefix='/email']
|
|
28
|
+
* @param {Object} [options.auth] - AuthManager instance
|
|
29
|
+
* @param {Object} [options.authEmails] - Auth email bridge config
|
|
30
|
+
* @param {Object} [options.emailTemplates] - Precompiled templates from build manifest
|
|
31
|
+
* @param {Object} [options.manifest] - Full build manifest (uses emailTemplates field)
|
|
32
|
+
*/
|
|
33
|
+
function emailPlugin(options = {}) {
|
|
34
|
+
const {
|
|
35
|
+
db,
|
|
36
|
+
transport,
|
|
37
|
+
smtp,
|
|
38
|
+
defaults = {},
|
|
39
|
+
templatesDir,
|
|
40
|
+
templates = {},
|
|
41
|
+
tableName = 'email_logs',
|
|
42
|
+
includeAdminPage = true,
|
|
43
|
+
adminPath: adminPathOpt,
|
|
44
|
+
apiPrefix = '/email',
|
|
45
|
+
auth: authOption,
|
|
46
|
+
authEmails,
|
|
47
|
+
emailTemplates: emailTemplatesOpt,
|
|
48
|
+
manifest,
|
|
49
|
+
} = options;
|
|
50
|
+
|
|
51
|
+
const logToDb = options.logToDb !== undefined ? options.logToDb : !!db;
|
|
52
|
+
const knex = db ? (db.knex || db) : null;
|
|
53
|
+
|
|
54
|
+
if (logToDb && !db) {
|
|
55
|
+
console.warn('[email] logToDb is enabled but no db was passed — logging disabled');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const registry = new TemplateRegistry();
|
|
59
|
+
|
|
60
|
+
const manifestChunks = emailTemplatesOpt
|
|
61
|
+
|| manifest?.emailTemplates
|
|
62
|
+
|| null;
|
|
63
|
+
if (manifestChunks) {
|
|
64
|
+
registry.loadFromManifest(manifestChunks);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (templatesDir) registry.loadFromDir(templatesDir);
|
|
68
|
+
registry.loadFromMap(templates);
|
|
69
|
+
|
|
70
|
+
const emailService = createEmailService({
|
|
71
|
+
registry,
|
|
72
|
+
transportOptions: { transport, smtp },
|
|
73
|
+
defaults,
|
|
74
|
+
knex: logToDb ? knex : null,
|
|
75
|
+
logToDb: logToDb && !!knex,
|
|
76
|
+
tableName,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let wiredAuthEmailsConfig = null;
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
name: 'email',
|
|
83
|
+
version: '1.0.0',
|
|
84
|
+
description: 'MJML + Nodemailer email sending with optional admin UI and auth integration',
|
|
85
|
+
dependencies: includeAdminPage ? { 'admin-panel': '*' } : {},
|
|
86
|
+
|
|
87
|
+
api: {
|
|
88
|
+
send: (opts) => emailService.send(opts),
|
|
89
|
+
sendTemplate: (name, opts) => emailService.sendTemplate(name, opts),
|
|
90
|
+
preview: (nameOrOpts, data) => emailService.preview(nameOrOpts, data),
|
|
91
|
+
registerTemplate: (id, source) => registry.register(id, source),
|
|
92
|
+
listTemplates: () => registry.list(),
|
|
93
|
+
verifyConnection: () => emailService.verifyConnection(),
|
|
94
|
+
queryLogs: (filters) => emailService.queryLogs(filters),
|
|
95
|
+
purgeLogs: (opts = {}) => {
|
|
96
|
+
if (!knex) throw new Error('Database is required for purgeLogs');
|
|
97
|
+
return purgeEmailLogs(knex, {
|
|
98
|
+
tableName: opts.tableName || tableName,
|
|
99
|
+
olderThan: opts.olderThan,
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
getMigrationTemplate: (name) => generateEmailLogsMigration(name || tableName),
|
|
103
|
+
isTransportReady: () => emailService.isTransportReady(),
|
|
104
|
+
loadFromManifest: (chunks) => registry.loadFromManifest(chunks),
|
|
105
|
+
emailPluginOptionsFromManifest,
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
register(ctx) {
|
|
109
|
+
// noop — templates loaded at plugin init; register available for future hot-reload
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
onRoutesReady(ctx) {
|
|
113
|
+
const authManager = authOption || ctx.options?.auth || null;
|
|
114
|
+
|
|
115
|
+
if (authEmails?.enabled && authManager) {
|
|
116
|
+
wiredAuthEmailsConfig = wireAuthEmails(
|
|
117
|
+
authManager,
|
|
118
|
+
emailService,
|
|
119
|
+
registry,
|
|
120
|
+
authEmails,
|
|
121
|
+
db
|
|
122
|
+
);
|
|
123
|
+
registerAuthEmailRoutes({
|
|
124
|
+
authManager,
|
|
125
|
+
ctx,
|
|
126
|
+
authEmailsConfig: wiredAuthEmailsConfig,
|
|
127
|
+
});
|
|
128
|
+
} else if (authEmails?.enabled && !authManager) {
|
|
129
|
+
console.warn('[email] authEmails.enabled but no auth manager found — pass auth to createApp or emailPlugin');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!includeAdminPage) return;
|
|
133
|
+
|
|
134
|
+
const adminApi = ctx.usePlugin('admin-panel');
|
|
135
|
+
if (!adminApi) {
|
|
136
|
+
console.warn('[email] admin-panel plugin not found, skipping admin page registration');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const handlers = createEmailApiHandlers({
|
|
141
|
+
emailService,
|
|
142
|
+
registry,
|
|
143
|
+
knex: logToDb ? knex : null,
|
|
144
|
+
tableName,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
adminApi.registerModule({
|
|
148
|
+
id: 'email',
|
|
149
|
+
|
|
150
|
+
pages: [{
|
|
151
|
+
id: 'email',
|
|
152
|
+
title: 'Email',
|
|
153
|
+
path: '/email',
|
|
154
|
+
icon: 'mail',
|
|
155
|
+
component: generateEmailComponent({ apiPrefix }),
|
|
156
|
+
}],
|
|
157
|
+
|
|
158
|
+
menu: [{
|
|
159
|
+
id: 'email',
|
|
160
|
+
label: 'Email',
|
|
161
|
+
path: '/email',
|
|
162
|
+
icon: 'mail',
|
|
163
|
+
order: 40,
|
|
164
|
+
}],
|
|
165
|
+
|
|
166
|
+
api: {
|
|
167
|
+
prefix: apiPrefix,
|
|
168
|
+
routes: [
|
|
169
|
+
{ method: 'get', path: '/logs', handler: handlers.listLogsHandler },
|
|
170
|
+
{ method: 'get', path: '/templates', handler: handlers.templatesHandler },
|
|
171
|
+
{ method: 'post', path: '/send-test', handler: handlers.sendTestHandler },
|
|
172
|
+
{ method: 'post', path: '/preview', handler: handlers.previewHandler },
|
|
173
|
+
{ method: 'post', path: '/verify', handler: handlers.verifyHandler },
|
|
174
|
+
{ method: 'post', path: '/purge', handler: handlers.purgeHandler },
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = emailPlugin;
|
|
183
|
+
module.exports.emailPlugin = emailPlugin;
|
|
184
|
+
module.exports.generateEmailLogsMigration = generateEmailLogsMigration;
|
|
185
|
+
module.exports.purgeEmailLogs = purgeEmailLogs;
|
|
186
|
+
module.exports.emailPluginOptionsFromManifest = emailPluginOptionsFromManifest;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build manifest helpers for email plugin
|
|
3
|
+
* @module plugins/email/manifest
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {object} manifest
|
|
8
|
+
* @returns {{ templates: Record<string, { mjml?: string, html?: string }>, emailTemplates: Record<string, object> }}
|
|
9
|
+
*/
|
|
10
|
+
function emailPluginOptionsFromManifest(manifest) {
|
|
11
|
+
const chunks = manifest?.emailTemplates || {};
|
|
12
|
+
/** @type {Record<string, { mjml?: string, html?: string }>} */
|
|
13
|
+
const templates = {};
|
|
14
|
+
|
|
15
|
+
for (const [id, chunk] of Object.entries(chunks)) {
|
|
16
|
+
if (chunk?.mjml) templates[id] = { mjml: chunk.mjml };
|
|
17
|
+
else if (chunk?.html) templates[id] = { html: chunk.html };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return { templates, emailTemplates: chunks };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = {
|
|
24
|
+
emailPluginOptionsFromManifest,
|
|
25
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration template for email_logs table
|
|
3
|
+
* @module plugins/email/migration-template
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} [tableName='email_logs']
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
function generateEmailLogsMigration(tableName = 'email_logs') {
|
|
11
|
+
return `/**
|
|
12
|
+
* Migration: Create ${tableName} table
|
|
13
|
+
* Auto-generated by email plugin
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
exports.up = function(knex) {
|
|
17
|
+
return knex.schema.createTable('${tableName}', (table) => {
|
|
18
|
+
table.bigIncrements('id').primary();
|
|
19
|
+
table.string('to', 1000).nullable();
|
|
20
|
+
table.string('cc', 1000).nullable();
|
|
21
|
+
table.string('bcc', 1000).nullable();
|
|
22
|
+
table.string('subject', 500).nullable();
|
|
23
|
+
table.string('template_id', 255).nullable().index();
|
|
24
|
+
table.string('from', 255).nullable();
|
|
25
|
+
table.string('status', 16).notNullable().index();
|
|
26
|
+
table.string('message_id', 255).nullable();
|
|
27
|
+
table.text('error').nullable();
|
|
28
|
+
table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable().index();
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
exports.down = function(knex) {
|
|
33
|
+
return knex.schema.dropTableIfExists('${tableName}');
|
|
34
|
+
};
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
generateEmailLogsMigration,
|
|
40
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delete email log rows older than a cutoff
|
|
3
|
+
* @module plugins/email/purge
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {import('knex').Knex} knex
|
|
8
|
+
* @param {Object} options
|
|
9
|
+
* @param {string} [options.tableName='email_logs']
|
|
10
|
+
* @param {Date|string} options.olderThan
|
|
11
|
+
* @returns {Promise<number>}
|
|
12
|
+
*/
|
|
13
|
+
async function purgeEmailLogs(knex, options) {
|
|
14
|
+
const tableName = options.tableName || 'email_logs';
|
|
15
|
+
const olderThan = options.olderThan instanceof Date
|
|
16
|
+
? options.olderThan
|
|
17
|
+
: new Date(options.olderThan);
|
|
18
|
+
|
|
19
|
+
if (Number.isNaN(olderThan.getTime())) {
|
|
20
|
+
throw new Error('purgeEmailLogs: invalid olderThan date');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return knex(tableName).where('created_at', '<', olderThan).delete();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = {
|
|
27
|
+
purgeEmailLogs,
|
|
28
|
+
};
|