webspresso 0.0.79 → 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.
Files changed (39) hide show
  1. package/README.md +104 -6
  2. package/bin/commands/doctor.js +83 -0
  3. package/bin/commands/favicon-generate.js +3 -1
  4. package/core/auth/hash.js +1 -1
  5. package/core/auth/index.js +30 -0
  6. package/core/auth/manager.js +190 -0
  7. package/core/auth/tokens.js +135 -0
  8. package/core/kernel/app.js +7 -1
  9. package/core/orm/json-fields.js +1 -1
  10. package/core/orm/migrations/scaffold.js +5 -0
  11. package/core/orm/schema-helpers.js +18 -0
  12. package/core/orm/types.js +1 -1
  13. package/index.d.ts +32 -0
  14. package/index.js +2 -0
  15. package/package.json +5 -2
  16. package/plugins/admin-panel/client/parts/07-model-list.js +18 -0
  17. package/plugins/admin-panel/field-renderers/file-upload.js +269 -0
  18. package/plugins/admin-panel/field-renderers/index.js +1 -0
  19. package/plugins/csrf/index.js +222 -0
  20. package/plugins/email/admin-component.js +226 -0
  21. package/plugins/email/api-handlers.js +139 -0
  22. package/plugins/email/auth-bridge.js +209 -0
  23. package/plugins/email/bundled-templates.js +68 -0
  24. package/plugins/email/fs-env.js +101 -0
  25. package/plugins/email/index.js +186 -0
  26. package/plugins/email/manifest.js +25 -0
  27. package/plugins/email/migration-template.js +40 -0
  28. package/plugins/email/purge.js +28 -0
  29. package/plugins/email/render.js +154 -0
  30. package/plugins/email/service.js +244 -0
  31. package/plugins/email/template-registry.js +134 -0
  32. package/plugins/email/templates/auth-email-verify.mjml +13 -0
  33. package/plugins/email/templates/auth-password-reset.mjml +13 -0
  34. package/plugins/email/templates/auth-welcome.mjml +11 -0
  35. package/plugins/email/transport.js +71 -0
  36. package/plugins/index.js +4 -0
  37. package/plugins/upload/index.js +53 -30
  38. package/src/file-router.js +75 -0
  39. package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -0
@@ -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>
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Nodemailer transport resolution
3
+ * @module plugins/email/transport
4
+ */
5
+
6
+ let nodemailer;
7
+ try {
8
+ nodemailer = require('nodemailer');
9
+ } catch {
10
+ nodemailer = null;
11
+ }
12
+
13
+ /**
14
+ * Build SMTP config from env vars
15
+ * @returns {Object|null}
16
+ */
17
+ function smtpFromEnv() {
18
+ const host = process.env.SMTP_HOST;
19
+ if (!host) return null;
20
+
21
+ const port = parseInt(process.env.SMTP_PORT || '587', 10);
22
+ const secure = process.env.SMTP_SECURE === 'true' || port === 465;
23
+ const user = process.env.SMTP_USER;
24
+ const pass = process.env.SMTP_PASS;
25
+
26
+ const config = { host, port, secure };
27
+ if (user && pass) {
28
+ config.auth = { user, pass };
29
+ }
30
+ return config;
31
+ }
32
+
33
+ /**
34
+ * @param {Object} options
35
+ * @param {Object} [options.transport] - Pre-built nodemailer transport
36
+ * @param {Object} [options.smtp] - SMTP options
37
+ * @returns {Object} nodemailer transport
38
+ */
39
+ function createTransport(options = {}) {
40
+ if (!nodemailer) {
41
+ throw new Error('nodemailer is required. Install it with: npm install nodemailer');
42
+ }
43
+
44
+ if (options.transport) {
45
+ return options.transport;
46
+ }
47
+
48
+ const smtp = options.smtp || smtpFromEnv();
49
+ if (!smtp) {
50
+ throw new Error(
51
+ 'Email transport not configured. Pass transport/smtp options or set SMTP_HOST env variable.'
52
+ );
53
+ }
54
+
55
+ return nodemailer.createTransport(smtp);
56
+ }
57
+
58
+ /**
59
+ * Default from address
60
+ * @param {Object} [defaults={}]
61
+ * @returns {string|undefined}
62
+ */
63
+ function resolveDefaultFrom(defaults = {}) {
64
+ return defaults.from || process.env.MAIL_FROM || undefined;
65
+ }
66
+
67
+ module.exports = {
68
+ createTransport,
69
+ smtpFromEnv,
70
+ resolveDefaultFrom,
71
+ };
package/plugins/index.js CHANGED
@@ -21,7 +21,9 @@ const { uploadPlugin, createLocalFileProvider } = require('./upload');
21
21
  const { dataExchangePlugin } = require('./data-exchange');
22
22
  const { redirectPlugin } = require('./redirect');
23
23
  const { rateLimitPlugin } = require('./rate-limit');
24
+ const emailPlugin = require('./email');
24
25
  const contentPlugin = require('./content');
26
+ const csrfPlugin = require('./csrf');
25
27
 
26
28
  module.exports = {
27
29
  sitemapPlugin,
@@ -42,6 +44,8 @@ module.exports = {
42
44
  dataExchangePlugin,
43
45
  redirectPlugin,
44
46
  rateLimitPlugin,
47
+ emailPlugin,
45
48
  contentPlugin,
49
+ csrfPlugin,
46
50
  };
47
51