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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MJML / HTML rendering with {{var}} interpolation
|
|
3
|
+
* @module plugins/email/render
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fsEnv = require('./fs-env');
|
|
7
|
+
|
|
8
|
+
let mjml2html;
|
|
9
|
+
try {
|
|
10
|
+
mjml2html = require('mjml');
|
|
11
|
+
} catch {
|
|
12
|
+
mjml2html = null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve dot-path on data object
|
|
17
|
+
* @param {Object} data
|
|
18
|
+
* @param {string} keyPath
|
|
19
|
+
* @returns {*}
|
|
20
|
+
*/
|
|
21
|
+
function getNestedValue(data, keyPath) {
|
|
22
|
+
if (!data || !keyPath) return '';
|
|
23
|
+
const parts = keyPath.split('.');
|
|
24
|
+
let cur = data;
|
|
25
|
+
for (const p of parts) {
|
|
26
|
+
if (cur == null || typeof cur !== 'object') return '';
|
|
27
|
+
cur = cur[p];
|
|
28
|
+
}
|
|
29
|
+
if (cur == null) return '';
|
|
30
|
+
return String(cur);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Replace {{key}} and {{a.b}} placeholders
|
|
35
|
+
* @param {string} source
|
|
36
|
+
* @param {Object} [data={}]
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function interpolate(source, data = {}) {
|
|
40
|
+
if (!source) return '';
|
|
41
|
+
return String(source).replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key) => getNestedValue(data, key));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read file if path exists (Node.js only)
|
|
46
|
+
* @param {string} filePath
|
|
47
|
+
* @returns {string|null}
|
|
48
|
+
*/
|
|
49
|
+
function readFileIfExists(filePath) {
|
|
50
|
+
return fsEnv.readFileUtf8(filePath);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Normalize template source to { mjml?, html?, text? }
|
|
55
|
+
* @param {string|Object} source
|
|
56
|
+
* @returns {{ mjml?: string, html?: string, text?: string }}
|
|
57
|
+
*/
|
|
58
|
+
function normalizeSource(source) {
|
|
59
|
+
if (typeof source === 'string') {
|
|
60
|
+
if (fsEnv.isInlineMarkup(source)) {
|
|
61
|
+
const trimmed = source.trim();
|
|
62
|
+
if (trimmed.startsWith('<mjml')) {
|
|
63
|
+
return { mjml: source };
|
|
64
|
+
}
|
|
65
|
+
return { html: source };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (fsEnv.looksLikeFilePath(source)) {
|
|
69
|
+
const content = fsEnv.readFileUtf8(source);
|
|
70
|
+
if (content == null) {
|
|
71
|
+
if (!fsEnv.isFilesystemAvailable()) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Template path "${source}" requires Node.js filesystem (unavailable on edge runtimes). ` +
|
|
74
|
+
'Use inline { mjml: "..." }, registerTemplate(id, { mjml }), or pass MJML/HTML strings directly.'
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`Template file not found: ${source}`);
|
|
78
|
+
}
|
|
79
|
+
if (source.endsWith('.mjml')) {
|
|
80
|
+
return { mjml: content };
|
|
81
|
+
}
|
|
82
|
+
return { html: content };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { html: source };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (source && typeof source === 'object') {
|
|
89
|
+
if (source.path) {
|
|
90
|
+
return normalizeSource(source.path);
|
|
91
|
+
}
|
|
92
|
+
if (source.mjml) {
|
|
93
|
+
return { mjml: source.mjml };
|
|
94
|
+
}
|
|
95
|
+
if (source.html) {
|
|
96
|
+
return { html: source.html };
|
|
97
|
+
}
|
|
98
|
+
if (source.text) {
|
|
99
|
+
return { text: source.text };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
throw new Error('Invalid template source');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Compile MJML to HTML
|
|
108
|
+
* @param {string} mjmlSource
|
|
109
|
+
* @returns {Promise<{ html: string, errors: Array }>}
|
|
110
|
+
*/
|
|
111
|
+
async function compileMjml(mjmlSource) {
|
|
112
|
+
if (!mjml2html) {
|
|
113
|
+
throw new Error('mjml is required for MJML templates. Install it with: npm install mjml');
|
|
114
|
+
}
|
|
115
|
+
const result = await mjml2html(mjmlSource, { validationLevel: 'soft' });
|
|
116
|
+
return {
|
|
117
|
+
html: result.html,
|
|
118
|
+
errors: result.errors || [],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Render template source with data
|
|
124
|
+
* @param {string|Object} source
|
|
125
|
+
* @param {Object} [data={}]
|
|
126
|
+
* @returns {Promise<{ html?: string, text?: string, errors?: Array }>}
|
|
127
|
+
*/
|
|
128
|
+
async function renderSource(source, data = {}) {
|
|
129
|
+
const normalized = normalizeSource(source);
|
|
130
|
+
|
|
131
|
+
if (normalized.mjml) {
|
|
132
|
+
const interpolated = interpolate(normalized.mjml, data);
|
|
133
|
+
const { html, errors } = await compileMjml(interpolated);
|
|
134
|
+
return { html, errors };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (normalized.html) {
|
|
138
|
+
return { html: interpolate(normalized.html, data) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (normalized.text) {
|
|
142
|
+
return { text: interpolate(normalized.text, data) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
interpolate,
|
|
150
|
+
normalizeSource,
|
|
151
|
+
compileMjml,
|
|
152
|
+
renderSource,
|
|
153
|
+
readFileIfExists,
|
|
154
|
+
};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email service — send, preview, verify, logging
|
|
3
|
+
* @module plugins/email/service
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { renderSource } = require('./render');
|
|
7
|
+
const { createTransport, resolveDefaultFrom } = require('./transport');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {Object} options
|
|
11
|
+
* @param {import('./template-registry').TemplateRegistry} options.registry
|
|
12
|
+
* @param {Object} [options.transportOptions]
|
|
13
|
+
* @param {Object} [options.defaults]
|
|
14
|
+
* @param {import('knex').Knex|null} [options.knex]
|
|
15
|
+
* @param {boolean} [options.logToDb=false]
|
|
16
|
+
* @param {string} [options.tableName='email_logs']
|
|
17
|
+
*/
|
|
18
|
+
function createEmailService(options) {
|
|
19
|
+
const registry = options.registry;
|
|
20
|
+
const defaults = options.defaults || {};
|
|
21
|
+
const knex = options.knex || null;
|
|
22
|
+
const logToDb = options.logToDb === true && !!knex;
|
|
23
|
+
const tableName = options.tableName || 'email_logs';
|
|
24
|
+
|
|
25
|
+
let transport;
|
|
26
|
+
try {
|
|
27
|
+
transport = createTransport(options.transportOptions || {});
|
|
28
|
+
} catch (err) {
|
|
29
|
+
transport = null;
|
|
30
|
+
console.warn('[email]', err.message);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {Object} row
|
|
35
|
+
*/
|
|
36
|
+
async function writeLog(row) {
|
|
37
|
+
if (!logToDb) return;
|
|
38
|
+
try {
|
|
39
|
+
await knex(tableName).insert({
|
|
40
|
+
to: row.to || null,
|
|
41
|
+
cc: row.cc || null,
|
|
42
|
+
bcc: row.bcc || null,
|
|
43
|
+
subject: row.subject || null,
|
|
44
|
+
template_id: row.template_id || null,
|
|
45
|
+
from: row.from || null,
|
|
46
|
+
status: row.status,
|
|
47
|
+
message_id: row.message_id || null,
|
|
48
|
+
error: row.error || null,
|
|
49
|
+
created_at: new Date(),
|
|
50
|
+
});
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.warn('[email] Failed to write email log:', err.message);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {Object} sendOptions
|
|
58
|
+
* @returns {Promise<Object>}
|
|
59
|
+
*/
|
|
60
|
+
async function send(sendOptions = {}) {
|
|
61
|
+
if (!transport) {
|
|
62
|
+
throw new Error('Email transport is not configured');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const {
|
|
66
|
+
to,
|
|
67
|
+
cc,
|
|
68
|
+
bcc,
|
|
69
|
+
subject,
|
|
70
|
+
from,
|
|
71
|
+
replyTo,
|
|
72
|
+
template,
|
|
73
|
+
mjml,
|
|
74
|
+
html,
|
|
75
|
+
text,
|
|
76
|
+
data = {},
|
|
77
|
+
attachments,
|
|
78
|
+
} = sendOptions;
|
|
79
|
+
|
|
80
|
+
if (!to) {
|
|
81
|
+
throw new Error('Email "to" is required');
|
|
82
|
+
}
|
|
83
|
+
if (!subject) {
|
|
84
|
+
throw new Error('Email "subject" is required');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let renderedHtml = html;
|
|
88
|
+
let renderedText = text;
|
|
89
|
+
let mjmlErrors = [];
|
|
90
|
+
let templateId = template || null;
|
|
91
|
+
|
|
92
|
+
if (template) {
|
|
93
|
+
const entry = registry.get(template);
|
|
94
|
+
if (!entry) {
|
|
95
|
+
throw new Error(`Unknown email template: ${template}`);
|
|
96
|
+
}
|
|
97
|
+
const rendered = await renderSource(entry.source, data);
|
|
98
|
+
renderedHtml = rendered.html || renderedHtml;
|
|
99
|
+
renderedText = rendered.text || renderedText;
|
|
100
|
+
mjmlErrors = rendered.errors || [];
|
|
101
|
+
} else if (mjml) {
|
|
102
|
+
const rendered = await renderSource({ mjml }, data);
|
|
103
|
+
renderedHtml = rendered.html;
|
|
104
|
+
mjmlErrors = rendered.errors || [];
|
|
105
|
+
} else if (html && data && Object.keys(data).length) {
|
|
106
|
+
const rendered = await renderSource({ html }, data);
|
|
107
|
+
renderedHtml = rendered.html;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const mailOptions = {
|
|
111
|
+
to,
|
|
112
|
+
cc,
|
|
113
|
+
bcc,
|
|
114
|
+
subject,
|
|
115
|
+
from: from || resolveDefaultFrom(defaults),
|
|
116
|
+
replyTo: replyTo || defaults.replyTo,
|
|
117
|
+
html: renderedHtml,
|
|
118
|
+
text: renderedText,
|
|
119
|
+
attachments,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const info = await transport.sendMail(mailOptions);
|
|
124
|
+
await writeLog({
|
|
125
|
+
to: Array.isArray(to) ? to.join(',') : String(to),
|
|
126
|
+
cc: cc ? (Array.isArray(cc) ? cc.join(',') : String(cc)) : null,
|
|
127
|
+
bcc: bcc ? (Array.isArray(bcc) ? bcc.join(',') : String(bcc)) : null,
|
|
128
|
+
subject,
|
|
129
|
+
template_id: templateId,
|
|
130
|
+
from: mailOptions.from,
|
|
131
|
+
status: 'sent',
|
|
132
|
+
message_id: info.messageId || null,
|
|
133
|
+
});
|
|
134
|
+
return { ok: true, messageId: info.messageId, mjmlErrors };
|
|
135
|
+
} catch (err) {
|
|
136
|
+
await writeLog({
|
|
137
|
+
to: Array.isArray(to) ? to.join(',') : String(to),
|
|
138
|
+
cc: cc ? (Array.isArray(cc) ? cc.join(',') : String(cc)) : null,
|
|
139
|
+
bcc: bcc ? (Array.isArray(bcc) ? bcc.join(',') : String(bcc)) : null,
|
|
140
|
+
subject,
|
|
141
|
+
template_id: templateId,
|
|
142
|
+
from: mailOptions.from,
|
|
143
|
+
status: 'failed',
|
|
144
|
+
error: err.message,
|
|
145
|
+
});
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {string} name
|
|
152
|
+
* @param {Object} options
|
|
153
|
+
*/
|
|
154
|
+
async function sendTemplate(name, options = {}) {
|
|
155
|
+
return send({ ...options, template: name });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {string|Object} nameOrOptions
|
|
160
|
+
* @param {Object} [data={}]
|
|
161
|
+
*/
|
|
162
|
+
async function preview(nameOrOptions, data = {}) {
|
|
163
|
+
if (typeof nameOrOptions === 'string') {
|
|
164
|
+
const entry = registry.get(nameOrOptions);
|
|
165
|
+
if (!entry) {
|
|
166
|
+
throw new Error(`Unknown email template: ${nameOrOptions}`);
|
|
167
|
+
}
|
|
168
|
+
return renderSource(entry.source, data);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (nameOrOptions.template) {
|
|
172
|
+
const entry = registry.get(nameOrOptions.template);
|
|
173
|
+
if (!entry) {
|
|
174
|
+
throw new Error(`Unknown email template: ${nameOrOptions.template}`);
|
|
175
|
+
}
|
|
176
|
+
return renderSource(entry.source, nameOrOptions.data || data);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (nameOrOptions.mjml) {
|
|
180
|
+
return renderSource({ mjml: nameOrOptions.mjml }, nameOrOptions.data || data);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (nameOrOptions.html) {
|
|
184
|
+
return renderSource({ html: nameOrOptions.html }, nameOrOptions.data || data);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
throw new Error('preview requires template id, mjml, or html');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function verifyConnection() {
|
|
191
|
+
if (!transport) {
|
|
192
|
+
throw new Error('Email transport is not configured');
|
|
193
|
+
}
|
|
194
|
+
return transport.verify();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @param {Object} [filters={}]
|
|
199
|
+
*/
|
|
200
|
+
async function queryLogs(filters = {}) {
|
|
201
|
+
if (!knex) return [];
|
|
202
|
+
|
|
203
|
+
const page = Math.max(1, parseInt(filters.page, 10) || 1);
|
|
204
|
+
const perPage = Math.min(100, Math.max(1, parseInt(filters.perPage, 10) || 25));
|
|
205
|
+
const offset = filters.offset != null ? Math.max(0, filters.offset) : (page - 1) * perPage;
|
|
206
|
+
const limit = filters.limit != null ? Math.min(500, filters.limit) : perPage;
|
|
207
|
+
|
|
208
|
+
let qb = knex(tableName).orderBy('created_at', 'desc');
|
|
209
|
+
|
|
210
|
+
if (filters.status) qb = qb.where('status', String(filters.status));
|
|
211
|
+
if (filters.template) qb = qb.where('template_id', String(filters.template));
|
|
212
|
+
if (filters.from) qb = qb.where('created_at', '>=', String(filters.from));
|
|
213
|
+
if (filters.to) qb = qb.where('created_at', '<=', String(filters.to));
|
|
214
|
+
|
|
215
|
+
const rows = await qb.offset(offset).limit(limit);
|
|
216
|
+
|
|
217
|
+
if (filters.includeTotal) {
|
|
218
|
+
let countQ = knex(tableName);
|
|
219
|
+
if (filters.status) countQ = countQ.where('status', String(filters.status));
|
|
220
|
+
if (filters.template) countQ = countQ.where('template_id', String(filters.template));
|
|
221
|
+
if (filters.from) countQ = countQ.where('created_at', '>=', String(filters.from));
|
|
222
|
+
if (filters.to) countQ = countQ.where('created_at', '<=', String(filters.to));
|
|
223
|
+
const countRow = await countQ.count('* as cnt').first();
|
|
224
|
+
const total = Number(countRow?.cnt ?? Object.values(countRow || {})[0] ?? 0);
|
|
225
|
+
return { data: rows, meta: { page, perPage: limit, total, offset } };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return rows;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
send,
|
|
233
|
+
sendTemplate,
|
|
234
|
+
preview,
|
|
235
|
+
verifyConnection,
|
|
236
|
+
queryLogs,
|
|
237
|
+
getTransport: () => transport,
|
|
238
|
+
isTransportReady: () => !!transport,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = {
|
|
243
|
+
createEmailService,
|
|
244
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email template registry — inline, config map, optional Node.js directory scan
|
|
3
|
+
* @module plugins/email/template-registry
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const fsEnv = require('./fs-env');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} TemplateEntry
|
|
11
|
+
* @property {string} id
|
|
12
|
+
* @property {'inline'|'path'} kind
|
|
13
|
+
* @property {string|Object} source
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
class TemplateRegistry {
|
|
17
|
+
constructor() {
|
|
18
|
+
/** @type {Map<string, TemplateEntry>} */
|
|
19
|
+
this._templates = new Map();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load *.mjml files from directory (Node.js only; no-op on edge)
|
|
24
|
+
* @param {string} dir
|
|
25
|
+
* @returns {number} Number of templates registered
|
|
26
|
+
*/
|
|
27
|
+
loadFromDir(dir) {
|
|
28
|
+
const files = fsEnv.listMjmlBasenamesInDir(dir);
|
|
29
|
+
if (files == null) {
|
|
30
|
+
console.warn(
|
|
31
|
+
'[email] templatesDir skipped — filesystem unavailable (edge/worker runtime). ' +
|
|
32
|
+
'Use templates: { id: { mjml: "..." } } or registerTemplate with inline sources.'
|
|
33
|
+
);
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const resolved = path.resolve(dir);
|
|
38
|
+
let count = 0;
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
const id = path.basename(file, '.mjml');
|
|
41
|
+
const filePath = path.join(resolved, file);
|
|
42
|
+
const content = fsEnv.readFileUtf8(filePath);
|
|
43
|
+
if (content != null) {
|
|
44
|
+
this.register(id, { mjml: content }, 'inline');
|
|
45
|
+
count += 1;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return count;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register templates from config map (paths are inlined when fs is available)
|
|
53
|
+
* @param {Record<string, string|Object>} templates
|
|
54
|
+
*/
|
|
55
|
+
loadFromMap(templates = {}) {
|
|
56
|
+
for (const [id, source] of Object.entries(templates)) {
|
|
57
|
+
if (typeof source === 'string' && fsEnv.looksLikeFilePath(source) && !fsEnv.isInlineMarkup(source)) {
|
|
58
|
+
const content = fsEnv.readFileUtf8(source);
|
|
59
|
+
if (content != null) {
|
|
60
|
+
this.register(
|
|
61
|
+
id,
|
|
62
|
+
source.endsWith('.mjml') ? { mjml: content } : { html: content },
|
|
63
|
+
'inline'
|
|
64
|
+
);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
this.register(id, source, 'path');
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.register(id, source, 'inline');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {string} id
|
|
77
|
+
* @param {string|Object} source
|
|
78
|
+
* @param {'inline'|'path'} [kind='inline']
|
|
79
|
+
*/
|
|
80
|
+
register(id, source, kind = 'inline') {
|
|
81
|
+
if (!id) throw new Error('Template id is required');
|
|
82
|
+
this._templates.set(id, { id, kind, source });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} id
|
|
87
|
+
* @returns {TemplateEntry|null}
|
|
88
|
+
*/
|
|
89
|
+
get(id) {
|
|
90
|
+
return this._templates.get(id) || null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @returns {Array<{ id: string, kind: string }>}
|
|
95
|
+
*/
|
|
96
|
+
list() {
|
|
97
|
+
return Array.from(this._templates.values()).map((t) => ({
|
|
98
|
+
id: t.id,
|
|
99
|
+
kind: t.kind,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Load templates embedded in build manifest (edge/worker safe)
|
|
105
|
+
* @param {Record<string, { mjml?: string, html?: string }>} emailTemplates
|
|
106
|
+
* @returns {number}
|
|
107
|
+
*/
|
|
108
|
+
loadFromManifest(emailTemplates = {}) {
|
|
109
|
+
let count = 0;
|
|
110
|
+
for (const [id, chunk] of Object.entries(emailTemplates)) {
|
|
111
|
+
if (!chunk) continue;
|
|
112
|
+
if (chunk.mjml) {
|
|
113
|
+
this.register(id, { mjml: chunk.mjml }, 'inline');
|
|
114
|
+
count += 1;
|
|
115
|
+
} else if (chunk.html) {
|
|
116
|
+
this.register(id, { html: chunk.html }, 'inline');
|
|
117
|
+
count += 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return count;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {string} id
|
|
125
|
+
* @returns {boolean}
|
|
126
|
+
*/
|
|
127
|
+
has(id) {
|
|
128
|
+
return this._templates.has(id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
TemplateRegistry,
|
|
134
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<mjml>
|
|
2
|
+
<mj-body background-color="#f4f4f5">
|
|
3
|
+
<mj-section background-color="#ffffff" padding="32px 24px">
|
|
4
|
+
<mj-column>
|
|
5
|
+
<mj-text font-size="20px" font-weight="600" color="#18181b">Verify your email</mj-text>
|
|
6
|
+
<mj-text color="#52525b">Hi {{name}},</mj-text>
|
|
7
|
+
<mj-text color="#52525b">Please confirm your email address by clicking the button below.</mj-text>
|
|
8
|
+
<mj-button background-color="#2563eb" href="{{verifyUrl}}">Verify email</mj-button>
|
|
9
|
+
<mj-text color="#71717a" font-size="12px">If you did not create an account, you can ignore this message.</mj-text>
|
|
10
|
+
</mj-column>
|
|
11
|
+
</mj-section>
|
|
12
|
+
</mj-body>
|
|
13
|
+
</mjml>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<mjml>
|
|
2
|
+
<mj-body background-color="#f4f4f5">
|
|
3
|
+
<mj-section background-color="#ffffff" padding="32px 24px">
|
|
4
|
+
<mj-column>
|
|
5
|
+
<mj-text font-size="20px" font-weight="600" color="#18181b">Reset your password</mj-text>
|
|
6
|
+
<mj-text color="#52525b">Hi {{name}},</mj-text>
|
|
7
|
+
<mj-text color="#52525b">We received a request to reset your password. Click the button below to choose a new one.</mj-text>
|
|
8
|
+
<mj-button background-color="#2563eb" href="{{resetUrl}}">Reset password</mj-button>
|
|
9
|
+
<mj-text color="#71717a" font-size="12px">If you did not request this, you can safely ignore this email.</mj-text>
|
|
10
|
+
</mj-column>
|
|
11
|
+
</mj-section>
|
|
12
|
+
</mj-body>
|
|
13
|
+
</mjml>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<mjml>
|
|
2
|
+
<mj-body background-color="#f4f4f5">
|
|
3
|
+
<mj-section background-color="#ffffff" padding="32px 24px">
|
|
4
|
+
<mj-column>
|
|
5
|
+
<mj-text font-size="20px" font-weight="600" color="#18181b">Welcome</mj-text>
|
|
6
|
+
<mj-text color="#52525b">Hi {{name}},</mj-text>
|
|
7
|
+
<mj-text color="#52525b">Thanks for joining us. Your account is ready.</mj-text>
|
|
8
|
+
</mj-column>
|
|
9
|
+
</mj-section>
|
|
10
|
+
</mj-body>
|
|
11
|
+
</mjml>
|