domma-cms 0.42.0 → 0.44.0
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/CLAUDE.md +36 -2
- package/admin/js/templates/collection-editor.html +1 -1
- package/admin/js/templates/effects.html +752 -752
- package/admin/js/templates/forms.html +17 -17
- package/admin/js/templates/my-profile.html +17 -17
- package/admin/js/templates/role-editor.html +70 -70
- package/admin/js/templates/roles.html +10 -10
- package/admin/js/views/action-editor.js +1 -1
- package/admin/js/views/block-editor.js +2 -2
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/form-editor.js +6 -6
- package/admin/js/views/navigation.js +16 -16
- package/admin/js/views/page-editor.js +41 -41
- package/admin/js/views/view-editor.js +1 -1
- package/bin/lib/config-merge.js +44 -44
- package/config/plugins.json +1 -1
- package/config/site.json +86 -86
- package/package.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/collection-export.mjs +3 -0
- package/public/js/collection-query.mjs +1 -0
- package/public/js/collection-sort.mjs +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +94 -0
- package/scripts/setup.js +23 -2
- package/server/middleware/auth.js +253 -253
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/navigation.js +42 -42
- package/server/services/collections.js +12 -11
- package/server/services/email.js +167 -167
- package/server/services/markdown.js +106 -33
- package/server/services/userProfiles.js +199 -199
- package/server/services/users.js +302 -302
- package/server/templates/page.html +1 -1
- package/config/navigation.json.bak +0 -31
|
@@ -1,42 +1,42 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Navigation API
|
|
3
|
-
* GET /api/navigation - get navigation config
|
|
4
|
-
* PUT /api/navigation - save navigation config
|
|
5
|
-
*/
|
|
6
|
-
import {getConfig, saveConfig} from '../../config.js';
|
|
7
|
-
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
8
|
-
import * as cache from '../../services/cache/index.js';
|
|
9
|
-
|
|
10
|
-
export async function navigationRoutes(fastify) {
|
|
11
|
-
const canRead = {preHandler: [authenticate, requirePermission('navigation', 'read')]};
|
|
12
|
-
const canUpdate = {preHandler: [authenticate, requirePermission('navigation', 'update')]};
|
|
13
|
-
|
|
14
|
-
fastify.get('/navigation', canRead, async () => {
|
|
15
|
-
return getConfig('navigation');
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
fastify.put('/navigation', canUpdate, async (request, reply) => {
|
|
19
|
-
const data = request.body;
|
|
20
|
-
if (!data || typeof data !== 'object') {
|
|
21
|
-
return reply.status(400).send({ error: 'Invalid navigation data' });
|
|
22
|
-
}
|
|
23
|
-
if (!Array.isArray(data.items) && !Array.isArray(data)) {
|
|
24
|
-
return reply.status(400).send({ error: 'Navigation must be an array of items' });
|
|
25
|
-
}
|
|
26
|
-
// Normalise child key: Domma navbar expects `items`, not `children`
|
|
27
|
-
if (Array.isArray(data.items)) {
|
|
28
|
-
data.items = data.items.map(item => {
|
|
29
|
-
const children = item.items || item.children;
|
|
30
|
-
if (children?.length) {
|
|
31
|
-
const { children: _c, ...rest } = item;
|
|
32
|
-
return { ...rest, items: children };
|
|
33
|
-
}
|
|
34
|
-
const { children: _c, ...rest } = item;
|
|
35
|
-
return rest;
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
saveConfig('navigation', data);
|
|
39
|
-
await cache.invalidateTags(['nav']);
|
|
40
|
-
return { success: true };
|
|
41
|
-
});
|
|
42
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Navigation API
|
|
3
|
+
* GET /api/navigation - get navigation config
|
|
4
|
+
* PUT /api/navigation - save navigation config
|
|
5
|
+
*/
|
|
6
|
+
import {getConfig, saveConfig} from '../../config.js';
|
|
7
|
+
import {authenticate, requirePermission} from '../../middleware/auth.js';
|
|
8
|
+
import * as cache from '../../services/cache/index.js';
|
|
9
|
+
|
|
10
|
+
export async function navigationRoutes(fastify) {
|
|
11
|
+
const canRead = {preHandler: [authenticate, requirePermission('navigation', 'read')]};
|
|
12
|
+
const canUpdate = {preHandler: [authenticate, requirePermission('navigation', 'update')]};
|
|
13
|
+
|
|
14
|
+
fastify.get('/navigation', canRead, async () => {
|
|
15
|
+
return getConfig('navigation');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
fastify.put('/navigation', canUpdate, async (request, reply) => {
|
|
19
|
+
const data = request.body;
|
|
20
|
+
if (!data || typeof data !== 'object') {
|
|
21
|
+
return reply.status(400).send({ error: 'Invalid navigation data' });
|
|
22
|
+
}
|
|
23
|
+
if (!Array.isArray(data.items) && !Array.isArray(data)) {
|
|
24
|
+
return reply.status(400).send({ error: 'Navigation must be an array of items' });
|
|
25
|
+
}
|
|
26
|
+
// Normalise child key: Domma navbar expects `items`, not `children`
|
|
27
|
+
if (Array.isArray(data.items)) {
|
|
28
|
+
data.items = data.items.map(item => {
|
|
29
|
+
const children = item.items || item.children;
|
|
30
|
+
if (children?.length) {
|
|
31
|
+
const { children: _c, ...rest } = item;
|
|
32
|
+
return { ...rest, items: children };
|
|
33
|
+
}
|
|
34
|
+
const { children: _c, ...rest } = item;
|
|
35
|
+
return rest;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
saveConfig('navigation', data);
|
|
39
|
+
await cache.invalidateTags(['nav']);
|
|
40
|
+
return { success: true };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -12,6 +12,7 @@ import {v4 as uuidv4} from 'uuid';
|
|
|
12
12
|
import {config} from '../config.js';
|
|
13
13
|
import {getAdapter, invalidate} from './adapterRegistry.js';
|
|
14
14
|
import * as cache from './cache/index.js';
|
|
15
|
+
import {toCsv} from '../../public/js/collection-export.mjs';
|
|
15
16
|
|
|
16
17
|
const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
|
|
17
18
|
|
|
@@ -480,17 +481,17 @@ export async function exportEntries(slug, format = 'json') {
|
|
|
480
481
|
const entries = await adapter.all(slug);
|
|
481
482
|
|
|
482
483
|
if (format === 'csv') {
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
});
|
|
493
|
-
return
|
|
484
|
+
const columns = [
|
|
485
|
+
{name: 'id'},
|
|
486
|
+
...schema.fields.map(f => ({name: f.name})),
|
|
487
|
+
{name: 'createdAt'}
|
|
488
|
+
];
|
|
489
|
+
const rows = entries.map(e => ({
|
|
490
|
+
id: e.id,
|
|
491
|
+
...Object.fromEntries(schema.fields.map(f => [f.name, e.data?.[f.name]])),
|
|
492
|
+
createdAt: e.meta?.createdAt || ''
|
|
493
|
+
}));
|
|
494
|
+
return toCsv(rows, columns);
|
|
494
495
|
}
|
|
495
496
|
|
|
496
497
|
return JSON.stringify(entries, null, 2);
|
package/server/services/email.js
CHANGED
|
@@ -1,167 +1,167 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Core Email Utility
|
|
3
|
-
* Nodemailer transport factory and generic form email sending utility.
|
|
4
|
-
*/
|
|
5
|
-
import nodemailer from 'nodemailer';
|
|
6
|
-
|
|
7
|
-
let lastSendResult = null;
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Return the most recent email send result, or null if no send has occurred.
|
|
11
|
-
*
|
|
12
|
-
* @returns {{ ok: boolean, at: string, info: string|null } | null}
|
|
13
|
-
*/
|
|
14
|
-
export function getLastSendResult() {
|
|
15
|
-
return lastSendResult;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Record the outcome of an email send for health reporting.
|
|
20
|
-
*
|
|
21
|
-
* @param {boolean} ok
|
|
22
|
-
* @param {string|null} info
|
|
23
|
-
* @returns {void}
|
|
24
|
-
*/
|
|
25
|
-
function recordSendResult(ok, info) {
|
|
26
|
-
lastSendResult = {
|
|
27
|
-
ok,
|
|
28
|
-
at: new Date().toISOString(),
|
|
29
|
-
info: info || null
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Escape HTML special characters for safe use in email bodies.
|
|
35
|
-
*
|
|
36
|
-
* @param {string} str
|
|
37
|
-
* @returns {string}
|
|
38
|
-
*/
|
|
39
|
-
export function escapeHtml(str) {
|
|
40
|
-
return String(str)
|
|
41
|
-
.replace(/&/g, '&')
|
|
42
|
-
.replace(/</g, '<')
|
|
43
|
-
.replace(/>/g, '>')
|
|
44
|
-
.replace(/"/g, '"')
|
|
45
|
-
.replace(/'/g, ''');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Create a nodemailer transport.
|
|
50
|
-
* Falls back to an Ethereal test account when no SMTP host is configured.
|
|
51
|
-
*
|
|
52
|
-
* @param {{ host: string, port: number, secure: boolean, user: string, pass: string }} smtp
|
|
53
|
-
* @returns {Promise<import('nodemailer').Transporter>}
|
|
54
|
-
* @throws {Error} If Ethereal test account creation fails.
|
|
55
|
-
*/
|
|
56
|
-
export async function createTransport(smtp) {
|
|
57
|
-
if (smtp?.host) {
|
|
58
|
-
return nodemailer.createTransport({
|
|
59
|
-
host: smtp.host,
|
|
60
|
-
port: smtp.port || 587,
|
|
61
|
-
secure: smtp.secure || false,
|
|
62
|
-
auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined,
|
|
63
|
-
tls: { rejectUnauthorized: false }
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// No SMTP configured — use Ethereal for dev/demo
|
|
68
|
-
const testAccount = await nodemailer.createTestAccount();
|
|
69
|
-
console.log('[email] No SMTP configured. Using Ethereal test account:', testAccount.user);
|
|
70
|
-
return nodemailer.createTransport({
|
|
71
|
-
host: 'smtp.ethereal.email',
|
|
72
|
-
port: 587,
|
|
73
|
-
secure: false,
|
|
74
|
-
auth: { user: testAccount.user, pass: testAccount.pass }
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Send a generic transactional email.
|
|
80
|
-
*
|
|
81
|
-
* @param {import('nodemailer').Transporter} transport
|
|
82
|
-
* @param {{ from: string, fromName: string, to: string, subject: string, html: string, text: string }} opts
|
|
83
|
-
* @returns {Promise<void>}
|
|
84
|
-
* @throws {Error} If sending the email fails.
|
|
85
|
-
*/
|
|
86
|
-
export async function sendEmail(transport, {from, fromName, to, subject, html, text}) {
|
|
87
|
-
try {
|
|
88
|
-
const info = await transport.sendMail({
|
|
89
|
-
from: `"${fromName}" <${from}>`,
|
|
90
|
-
to,
|
|
91
|
-
subject,
|
|
92
|
-
text,
|
|
93
|
-
html
|
|
94
|
-
});
|
|
95
|
-
recordSendResult(true, info.messageId);
|
|
96
|
-
|
|
97
|
-
const previewUrl = nodemailer.getTestMessageUrl(info);
|
|
98
|
-
if (previewUrl) {
|
|
99
|
-
console.log('[email] Preview URL:', previewUrl);
|
|
100
|
-
}
|
|
101
|
-
return info;
|
|
102
|
-
} catch (err) {
|
|
103
|
-
recordSendResult(false, err.message);
|
|
104
|
-
throw err;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Send an HTML + plain-text form submission notification email.
|
|
110
|
-
* Builds a generic table of field→value pairs from the submitted data.
|
|
111
|
-
*
|
|
112
|
-
* @param {import('nodemailer').Transporter} transport
|
|
113
|
-
* @param {{ from: string, fromName: string, to: string, subject: string, formTitle: string, fields: Array<{name: string, label: string}>, data: Record<string, unknown> }} opts
|
|
114
|
-
* @returns {Promise<void>}
|
|
115
|
-
* @throws {Error} If sending the email fails.
|
|
116
|
-
*/
|
|
117
|
-
export async function sendFormEmail(transport, { from, fromName, to, subject, formTitle, fields, data }) {
|
|
118
|
-
const rows = fields.map(field => {
|
|
119
|
-
const val = data[field.name] ?? '';
|
|
120
|
-
const safe = escapeHtml(String(val)).replace(/\n/g, '<br>');
|
|
121
|
-
const safeLabel = escapeHtml(field.label || field.name);
|
|
122
|
-
return `
|
|
123
|
-
<tr>
|
|
124
|
-
<td style="padding:8px 12px;font-weight:600;background:#f9f9f9;border:1px solid #eee;white-space:nowrap;vertical-align:top;">${safeLabel}</td>
|
|
125
|
-
<td style="padding:8px 12px;border:1px solid #eee;vertical-align:top;">${safe}</td>
|
|
126
|
-
</tr>`.trim();
|
|
127
|
-
}).join('\n');
|
|
128
|
-
|
|
129
|
-
const plainRows = fields.map(field => {
|
|
130
|
-
const val = data[field.name] ?? '';
|
|
131
|
-
return `${field.label || field.name}: ${val}`;
|
|
132
|
-
}).join('\n');
|
|
133
|
-
|
|
134
|
-
const html = `
|
|
135
|
-
<!DOCTYPE html>
|
|
136
|
-
<html>
|
|
137
|
-
<body style="font-family:sans-serif;max-width:640px;margin:0 auto;padding:20px;">
|
|
138
|
-
<h2 style="color:#333;margin-bottom:4px;">New Form Submission</h2>
|
|
139
|
-
<p style="color:#888;margin-top:0;font-size:.9rem;">${escapeHtml(formTitle)}</p>
|
|
140
|
-
<table style="width:100%;border-collapse:collapse;margin-top:16px;">
|
|
141
|
-
${rows}
|
|
142
|
-
</table>
|
|
143
|
-
</body>
|
|
144
|
-
</html>`.trim();
|
|
145
|
-
|
|
146
|
-
const text = `New form submission: ${formTitle}\n\n${plainRows}`;
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
const info = await transport.sendMail({
|
|
150
|
-
from: `"${fromName}" <${from}>`,
|
|
151
|
-
to,
|
|
152
|
-
subject,
|
|
153
|
-
text,
|
|
154
|
-
html
|
|
155
|
-
});
|
|
156
|
-
recordSendResult(true, info.messageId);
|
|
157
|
-
|
|
158
|
-
const previewUrl = nodemailer.getTestMessageUrl(info);
|
|
159
|
-
if (previewUrl) {
|
|
160
|
-
console.log('[email] Preview URL:', previewUrl);
|
|
161
|
-
}
|
|
162
|
-
return info;
|
|
163
|
-
} catch (err) {
|
|
164
|
-
recordSendResult(false, err.message);
|
|
165
|
-
throw err;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Core Email Utility
|
|
3
|
+
* Nodemailer transport factory and generic form email sending utility.
|
|
4
|
+
*/
|
|
5
|
+
import nodemailer from 'nodemailer';
|
|
6
|
+
|
|
7
|
+
let lastSendResult = null;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Return the most recent email send result, or null if no send has occurred.
|
|
11
|
+
*
|
|
12
|
+
* @returns {{ ok: boolean, at: string, info: string|null } | null}
|
|
13
|
+
*/
|
|
14
|
+
export function getLastSendResult() {
|
|
15
|
+
return lastSendResult;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Record the outcome of an email send for health reporting.
|
|
20
|
+
*
|
|
21
|
+
* @param {boolean} ok
|
|
22
|
+
* @param {string|null} info
|
|
23
|
+
* @returns {void}
|
|
24
|
+
*/
|
|
25
|
+
function recordSendResult(ok, info) {
|
|
26
|
+
lastSendResult = {
|
|
27
|
+
ok,
|
|
28
|
+
at: new Date().toISOString(),
|
|
29
|
+
info: info || null
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Escape HTML special characters for safe use in email bodies.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} str
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function escapeHtml(str) {
|
|
40
|
+
return String(str)
|
|
41
|
+
.replace(/&/g, '&')
|
|
42
|
+
.replace(/</g, '<')
|
|
43
|
+
.replace(/>/g, '>')
|
|
44
|
+
.replace(/"/g, '"')
|
|
45
|
+
.replace(/'/g, ''');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create a nodemailer transport.
|
|
50
|
+
* Falls back to an Ethereal test account when no SMTP host is configured.
|
|
51
|
+
*
|
|
52
|
+
* @param {{ host: string, port: number, secure: boolean, user: string, pass: string }} smtp
|
|
53
|
+
* @returns {Promise<import('nodemailer').Transporter>}
|
|
54
|
+
* @throws {Error} If Ethereal test account creation fails.
|
|
55
|
+
*/
|
|
56
|
+
export async function createTransport(smtp) {
|
|
57
|
+
if (smtp?.host) {
|
|
58
|
+
return nodemailer.createTransport({
|
|
59
|
+
host: smtp.host,
|
|
60
|
+
port: smtp.port || 587,
|
|
61
|
+
secure: smtp.secure || false,
|
|
62
|
+
auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined,
|
|
63
|
+
tls: { rejectUnauthorized: false }
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// No SMTP configured — use Ethereal for dev/demo
|
|
68
|
+
const testAccount = await nodemailer.createTestAccount();
|
|
69
|
+
console.log('[email] No SMTP configured. Using Ethereal test account:', testAccount.user);
|
|
70
|
+
return nodemailer.createTransport({
|
|
71
|
+
host: 'smtp.ethereal.email',
|
|
72
|
+
port: 587,
|
|
73
|
+
secure: false,
|
|
74
|
+
auth: { user: testAccount.user, pass: testAccount.pass }
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Send a generic transactional email.
|
|
80
|
+
*
|
|
81
|
+
* @param {import('nodemailer').Transporter} transport
|
|
82
|
+
* @param {{ from: string, fromName: string, to: string, subject: string, html: string, text: string }} opts
|
|
83
|
+
* @returns {Promise<void>}
|
|
84
|
+
* @throws {Error} If sending the email fails.
|
|
85
|
+
*/
|
|
86
|
+
export async function sendEmail(transport, {from, fromName, to, subject, html, text}) {
|
|
87
|
+
try {
|
|
88
|
+
const info = await transport.sendMail({
|
|
89
|
+
from: `"${fromName}" <${from}>`,
|
|
90
|
+
to,
|
|
91
|
+
subject,
|
|
92
|
+
text,
|
|
93
|
+
html
|
|
94
|
+
});
|
|
95
|
+
recordSendResult(true, info.messageId);
|
|
96
|
+
|
|
97
|
+
const previewUrl = nodemailer.getTestMessageUrl(info);
|
|
98
|
+
if (previewUrl) {
|
|
99
|
+
console.log('[email] Preview URL:', previewUrl);
|
|
100
|
+
}
|
|
101
|
+
return info;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
recordSendResult(false, err.message);
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Send an HTML + plain-text form submission notification email.
|
|
110
|
+
* Builds a generic table of field→value pairs from the submitted data.
|
|
111
|
+
*
|
|
112
|
+
* @param {import('nodemailer').Transporter} transport
|
|
113
|
+
* @param {{ from: string, fromName: string, to: string, subject: string, formTitle: string, fields: Array<{name: string, label: string}>, data: Record<string, unknown> }} opts
|
|
114
|
+
* @returns {Promise<void>}
|
|
115
|
+
* @throws {Error} If sending the email fails.
|
|
116
|
+
*/
|
|
117
|
+
export async function sendFormEmail(transport, { from, fromName, to, subject, formTitle, fields, data }) {
|
|
118
|
+
const rows = fields.map(field => {
|
|
119
|
+
const val = data[field.name] ?? '';
|
|
120
|
+
const safe = escapeHtml(String(val)).replace(/\n/g, '<br>');
|
|
121
|
+
const safeLabel = escapeHtml(field.label || field.name);
|
|
122
|
+
return `
|
|
123
|
+
<tr>
|
|
124
|
+
<td style="padding:8px 12px;font-weight:600;background:#f9f9f9;border:1px solid #eee;white-space:nowrap;vertical-align:top;">${safeLabel}</td>
|
|
125
|
+
<td style="padding:8px 12px;border:1px solid #eee;vertical-align:top;">${safe}</td>
|
|
126
|
+
</tr>`.trim();
|
|
127
|
+
}).join('\n');
|
|
128
|
+
|
|
129
|
+
const plainRows = fields.map(field => {
|
|
130
|
+
const val = data[field.name] ?? '';
|
|
131
|
+
return `${field.label || field.name}: ${val}`;
|
|
132
|
+
}).join('\n');
|
|
133
|
+
|
|
134
|
+
const html = `
|
|
135
|
+
<!DOCTYPE html>
|
|
136
|
+
<html>
|
|
137
|
+
<body style="font-family:sans-serif;max-width:640px;margin:0 auto;padding:20px;">
|
|
138
|
+
<h2 style="color:#333;margin-bottom:4px;">New Form Submission</h2>
|
|
139
|
+
<p style="color:#888;margin-top:0;font-size:.9rem;">${escapeHtml(formTitle)}</p>
|
|
140
|
+
<table style="width:100%;border-collapse:collapse;margin-top:16px;">
|
|
141
|
+
${rows}
|
|
142
|
+
</table>
|
|
143
|
+
</body>
|
|
144
|
+
</html>`.trim();
|
|
145
|
+
|
|
146
|
+
const text = `New form submission: ${formTitle}\n\n${plainRows}`;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const info = await transport.sendMail({
|
|
150
|
+
from: `"${fromName}" <${from}>`,
|
|
151
|
+
to,
|
|
152
|
+
subject,
|
|
153
|
+
text,
|
|
154
|
+
html
|
|
155
|
+
});
|
|
156
|
+
recordSendResult(true, info.messageId);
|
|
157
|
+
|
|
158
|
+
const previewUrl = nodemailer.getTestMessageUrl(info);
|
|
159
|
+
if (previewUrl) {
|
|
160
|
+
console.log('[email] Preview URL:', previewUrl);
|
|
161
|
+
}
|
|
162
|
+
return info;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
recordSendResult(false, err.message);
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|