domma-cms 0.22.6 → 0.24.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 +16 -5
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/crud-tutorial.js +1 -1
- package/admin/js/lib/project-context.js +1 -1
- package/admin/js/templates/api-tokens.html +13 -0
- package/admin/js/templates/effects.html +752 -752
- package/admin/js/templates/form-submissions.html +30 -30
- package/admin/js/templates/forms.html +17 -17
- package/admin/js/templates/my-profile.html +17 -17
- package/admin/js/templates/project-settings.html +1 -1
- package/admin/js/templates/role-editor.html +70 -70
- package/admin/js/templates/roles.html +10 -10
- package/admin/js/views/api-tokens.js +8 -0
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/projects.js +3 -3
- package/admin/js/views/roles.js +1 -1
- package/bin/lib/config-merge.js +44 -44
- package/bin/update.js +547 -547
- package/config/menus/admin-sidebar.json +7 -1
- package/package.json +3 -2
- package/server/middleware/auth.js +253 -253
- package/server/routes/api/api-tokens.js +83 -0
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/collections.js +113 -16
- package/server/routes/api/forms.js +765 -746
- package/server/routes/api/navigation.js +42 -42
- package/server/routes/api/projects.js +9 -2
- package/server/routes/api/settings.js +141 -141
- package/server/routes/public.js +202 -202
- package/server/server.js +10 -1
- package/server/services/apiTokens.js +259 -0
- package/server/services/email.js +167 -167
- package/server/services/forms.js +345 -255
- package/server/services/permissionRegistry.js +13 -0
- package/server/services/presetCollections.js +27 -1
- package/server/services/projects.js +115 -24
- package/server/services/roles.js +16 -0
- package/server/services/scaffolder.js +31 -1
- package/server/services/sidebar-migration.js +44 -0
- package/server/services/userProfiles.js +199 -199
- package/server/services/users.js +302 -302
- package/config/connections.json.bak +0 -9
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Tokens
|
|
3
|
+
* Project-scoped machine credentials for the external collections API.
|
|
4
|
+
*
|
|
5
|
+
* Tokens are entries in the file-based `api-tokens` preset collection.
|
|
6
|
+
* Only a SHA-256 hash is stored — the plaintext (`dcms_<64 hex>`) is returned
|
|
7
|
+
* exactly once, from createToken(). A token is accepted only on collection
|
|
8
|
+
* verbs configured with `api.<verb>.access === 'token'`, and only for
|
|
9
|
+
* collections whose resolved project matches the token's `project` binding.
|
|
10
|
+
*/
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from './collections.js';
|
|
13
|
+
import {canSeeArtefact, getProject} from './projects.js';
|
|
14
|
+
import {hooks} from './hooks.js';
|
|
15
|
+
|
|
16
|
+
export const API_TOKENS_COLLECTION_SLUG = 'api-tokens';
|
|
17
|
+
|
|
18
|
+
const TOKEN_RE = /^dcms_[a-f0-9]{64}$/;
|
|
19
|
+
const VERBS = ['create', 'read', 'update', 'delete'];
|
|
20
|
+
const LAST_USED_THROTTLE_MS = 60_000;
|
|
21
|
+
|
|
22
|
+
/** In-memory hash → entry cache; null = needs rebuild. */
|
|
23
|
+
let tokenCache = null;
|
|
24
|
+
|
|
25
|
+
function invalidateCache() {
|
|
26
|
+
tokenCache = null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Generic admin collection endpoints emit these for ALL collections — the
|
|
30
|
+
// service's own mutations invalidate directly, this catches edits made
|
|
31
|
+
// through the admin entries grid.
|
|
32
|
+
for (const ev of ['collection:entryCreated', 'collection:entryUpdated', 'collection:entryDeleted']) {
|
|
33
|
+
hooks.on(ev, (payload) => {
|
|
34
|
+
if (payload?.slug === API_TOKENS_COLLECTION_SLUG) invalidateCache();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* SHA-256 hex digest of a plaintext token.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} plaintext
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
function hashToken(plaintext) {
|
|
45
|
+
return crypto.createHash('sha256').update(plaintext).digest('hex');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Strip the entry down to what callers may see. Never includes tokenHash.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} entry - Raw collection entry { id, data, meta }
|
|
52
|
+
* @returns {object}
|
|
53
|
+
*/
|
|
54
|
+
function sanitise(entry) {
|
|
55
|
+
const {name, project, tokenHint, scopes, enabled, expiresAt, lastUsedAt, createdBy} = entry.data || {};
|
|
56
|
+
return {
|
|
57
|
+
id: entry.id,
|
|
58
|
+
name, project, tokenHint,
|
|
59
|
+
scopes: Array.isArray(scopes) ? scopes : [],
|
|
60
|
+
enabled: enabled !== false,
|
|
61
|
+
expiresAt: expiresAt || null,
|
|
62
|
+
lastUsedAt: lastUsedAt || null,
|
|
63
|
+
createdBy: createdBy || null,
|
|
64
|
+
meta: entry.meta
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validate a scopes declaration: array of { collection, verbs[] }.
|
|
70
|
+
* Empty array = the token covers every collection in its project.
|
|
71
|
+
*
|
|
72
|
+
* @param {*} scopes
|
|
73
|
+
* @returns {string|null} Error message, or null when valid
|
|
74
|
+
*/
|
|
75
|
+
function validateScopes(scopes) {
|
|
76
|
+
if (scopes == null) return null;
|
|
77
|
+
if (!Array.isArray(scopes)) return 'scopes must be an array';
|
|
78
|
+
for (const s of scopes) {
|
|
79
|
+
if (!s || typeof s !== 'object' || typeof s.collection !== 'string' || !s.collection.trim()) {
|
|
80
|
+
return 'each scope needs a collection slug';
|
|
81
|
+
}
|
|
82
|
+
if (s.verbs != null) {
|
|
83
|
+
if (!Array.isArray(s.verbs)) return 'scope verbs must be an array';
|
|
84
|
+
const bad = s.verbs.find(v => !VERBS.includes(v));
|
|
85
|
+
if (bad) return `unknown scope verb "${bad}"`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check whether a token's scopes permit an operation on a collection.
|
|
93
|
+
* Empty/missing scopes → everything in the token's project is allowed.
|
|
94
|
+
*
|
|
95
|
+
* @param {Array<{collection: string, verbs?: string[]}>} scopes
|
|
96
|
+
* @param {string} collectionSlug
|
|
97
|
+
* @param {string} verb - create | read | update | delete
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
export function scopeAllows(scopes, collectionSlug, verb) {
|
|
101
|
+
if (!Array.isArray(scopes) || scopes.length === 0) return true;
|
|
102
|
+
const scope = scopes.find(s => s.collection === collectionSlug);
|
|
103
|
+
if (!scope) return false;
|
|
104
|
+
return !Array.isArray(scope.verbs) || scope.verbs.length === 0 || scope.verbs.includes(verb);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Create a new token. The plaintext is returned ONCE here and never again.
|
|
109
|
+
*
|
|
110
|
+
* @param {{name: string, project: string, scopes?: object[], expiresAt?: string|null, createdBy?: string|null}} input
|
|
111
|
+
* @returns {Promise<{entry: object, plaintext: string}>} Sanitised entry + plaintext
|
|
112
|
+
* @throws {Error} On validation failure
|
|
113
|
+
*/
|
|
114
|
+
export async function createToken({name, project, scopes = [], expiresAt = null, createdBy = null}) {
|
|
115
|
+
if (!name || typeof name !== 'string' || !name.trim()) throw new Error('Token name is required');
|
|
116
|
+
if (!project || typeof project !== 'string') throw new Error('Token project is required');
|
|
117
|
+
if (!await getProject(project)) throw new Error(`Unknown project "${project}"`);
|
|
118
|
+
const scopeError = validateScopes(scopes);
|
|
119
|
+
if (scopeError) throw new Error(scopeError);
|
|
120
|
+
if (expiresAt != null && Number.isNaN(Date.parse(expiresAt))) throw new Error('expiresAt must be a valid date');
|
|
121
|
+
|
|
122
|
+
const plaintext = 'dcms_' + crypto.randomBytes(32).toString('hex');
|
|
123
|
+
const data = {
|
|
124
|
+
name: name.trim(),
|
|
125
|
+
project,
|
|
126
|
+
tokenHash: hashToken(plaintext),
|
|
127
|
+
tokenHint: plaintext.slice(-4),
|
|
128
|
+
scopes: scopes || [],
|
|
129
|
+
enabled: true,
|
|
130
|
+
expiresAt: expiresAt || null,
|
|
131
|
+
lastUsedAt: null,
|
|
132
|
+
createdBy
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const entry = await createEntry(API_TOKENS_COLLECTION_SLUG, data, {createdBy, source: 'admin'});
|
|
136
|
+
invalidateCache();
|
|
137
|
+
return {entry: sanitise(entry), plaintext};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Look up a token by name + project. Used for scaffolder idempotency.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} name
|
|
144
|
+
* @param {string} project
|
|
145
|
+
* @returns {Promise<object|null>} Sanitised entry or null
|
|
146
|
+
*/
|
|
147
|
+
export async function findTokenByName(name, project) {
|
|
148
|
+
const {entries} = await listEntries(API_TOKENS_COLLECTION_SLUG, {limit: 0});
|
|
149
|
+
const entry = entries.find(e => e.data?.name === name && e.data?.project === project);
|
|
150
|
+
return entry ? sanitise(entry) : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Validate a presented plaintext token.
|
|
155
|
+
* Returns the sanitised entry when the token exists, is enabled, and has not
|
|
156
|
+
* expired — otherwise null. Updates lastUsedAt at most once per minute
|
|
157
|
+
* (fire-and-forget; the cached copy is patched in place to avoid thrash).
|
|
158
|
+
*
|
|
159
|
+
* @param {string} plaintext
|
|
160
|
+
* @returns {Promise<object|null>}
|
|
161
|
+
*/
|
|
162
|
+
export async function validateToken(plaintext) {
|
|
163
|
+
if (typeof plaintext !== 'string' || !TOKEN_RE.test(plaintext)) return null;
|
|
164
|
+
const hash = hashToken(plaintext);
|
|
165
|
+
|
|
166
|
+
if (!tokenCache) {
|
|
167
|
+
const {entries} = await listEntries(API_TOKENS_COLLECTION_SLUG, {limit: 0});
|
|
168
|
+
tokenCache = new Map(entries.map(e => [e.data?.tokenHash, e]));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const entry = tokenCache.get(hash);
|
|
172
|
+
if (!entry) return null;
|
|
173
|
+
if (entry.data.enabled === false) return null;
|
|
174
|
+
if (entry.data.expiresAt && Date.parse(entry.data.expiresAt) < Date.now()) return null;
|
|
175
|
+
|
|
176
|
+
const lastUsed = entry.data.lastUsedAt ? Date.parse(entry.data.lastUsedAt) : 0;
|
|
177
|
+
if (Date.now() - lastUsed > LAST_USED_THROTTLE_MS) {
|
|
178
|
+
// updateEntry replaces data wholesale — pass the full object.
|
|
179
|
+
const data = {...entry.data, lastUsedAt: new Date().toISOString()};
|
|
180
|
+
entry.data = data; // keep the cached copy current without invalidating
|
|
181
|
+
updateEntry(API_TOKENS_COLLECTION_SLUG, entry.id, data).catch(() => {});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return sanitise(entry);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* List tokens the given admin user may see (project access scope applies).
|
|
189
|
+
*
|
|
190
|
+
* @param {object} user
|
|
191
|
+
* @returns {Promise<object[]>} Sanitised entries
|
|
192
|
+
*/
|
|
193
|
+
export async function listTokensSanitised(user) {
|
|
194
|
+
const {entries} = await listEntries(API_TOKENS_COLLECTION_SLUG, {limit: 0});
|
|
195
|
+
return entries
|
|
196
|
+
.filter(e => canSeeArtefact(user, {meta: {project: e.data?.project}}))
|
|
197
|
+
.map(sanitise);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Fetch a single token, sanitised. Caller is responsible for canSeeArtefact.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} id
|
|
204
|
+
* @returns {Promise<object|null>}
|
|
205
|
+
*/
|
|
206
|
+
export async function getTokenSanitised(id) {
|
|
207
|
+
const entry = await getEntry(API_TOKENS_COLLECTION_SLUG, id);
|
|
208
|
+
return entry ? sanitise(entry) : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Update mutable token fields. The project binding, hash, and hint are fixed
|
|
213
|
+
* for the token's lifetime — revoke and re-issue to rebind.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} id
|
|
216
|
+
* @param {{name?: string, enabled?: boolean, scopes?: object[], expiresAt?: string|null}} patch
|
|
217
|
+
* @returns {Promise<object|null>} Sanitised entry, or null when not found
|
|
218
|
+
* @throws {Error} On validation failure
|
|
219
|
+
*/
|
|
220
|
+
export async function updateToken(id, patch = {}) {
|
|
221
|
+
const entry = await getEntry(API_TOKENS_COLLECTION_SLUG, id);
|
|
222
|
+
if (!entry) return null;
|
|
223
|
+
|
|
224
|
+
const data = {...entry.data};
|
|
225
|
+
if (patch.name != null) {
|
|
226
|
+
if (typeof patch.name !== 'string' || !patch.name.trim()) throw new Error('Token name is required');
|
|
227
|
+
data.name = patch.name.trim();
|
|
228
|
+
}
|
|
229
|
+
if (patch.enabled != null) data.enabled = patch.enabled !== false;
|
|
230
|
+
if (patch.scopes != null) {
|
|
231
|
+
const scopeError = validateScopes(patch.scopes);
|
|
232
|
+
if (scopeError) throw new Error(scopeError);
|
|
233
|
+
data.scopes = patch.scopes;
|
|
234
|
+
}
|
|
235
|
+
if (patch.expiresAt !== undefined) {
|
|
236
|
+
if (patch.expiresAt != null && Number.isNaN(Date.parse(patch.expiresAt))) {
|
|
237
|
+
throw new Error('expiresAt must be a valid date');
|
|
238
|
+
}
|
|
239
|
+
data.expiresAt = patch.expiresAt || null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const updated = await updateEntry(API_TOKENS_COLLECTION_SLUG, id, data);
|
|
243
|
+
invalidateCache();
|
|
244
|
+
return updated ? sanitise(updated) : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Revoke (delete) a token. Takes effect immediately.
|
|
249
|
+
*
|
|
250
|
+
* @param {string} id
|
|
251
|
+
* @returns {Promise<boolean>}
|
|
252
|
+
*/
|
|
253
|
+
export async function revokeToken(id) {
|
|
254
|
+
const entry = await getEntry(API_TOKENS_COLLECTION_SLUG, id);
|
|
255
|
+
if (!entry) return false;
|
|
256
|
+
await deleteEntry(API_TOKENS_COLLECTION_SLUG, id);
|
|
257
|
+
invalidateCache();
|
|
258
|
+
return true;
|
|
259
|
+
}
|
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
|
+
}
|