domma-cms 0.15.0 → 0.17.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.
Files changed (65) hide show
  1. package/admin/css/dashboard.css +1 -0
  2. package/admin/dist/domma/domma-tools.css +3 -3
  3. package/admin/dist/domma/domma-tools.min.js +4 -4
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +1 -1
  7. package/admin/js/templates/dashboard/activity-feed.html +3 -0
  8. package/admin/js/templates/dashboard/health-detail.html +2 -0
  9. package/admin/js/templates/dashboard/journeys.html +17 -0
  10. package/admin/js/templates/dashboard/kpi-strip.html +34 -0
  11. package/admin/js/templates/dashboard/spike-feed.html +3 -0
  12. package/admin/js/templates/dashboard/top-pages.html +3 -0
  13. package/admin/js/templates/dashboard/traffic-chart.html +3 -0
  14. package/admin/js/templates/dashboard.html +22 -44
  15. package/admin/js/views/dashboard/lib/escape.js +1 -0
  16. package/admin/js/views/dashboard/widgets/activity-feed.js +1 -0
  17. package/admin/js/views/dashboard/widgets/health-detail.js +1 -0
  18. package/admin/js/views/dashboard/widgets/journeys.js +1 -0
  19. package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -0
  20. package/admin/js/views/dashboard/widgets/spike-feed.js +6 -0
  21. package/admin/js/views/dashboard/widgets/top-pages.js +1 -0
  22. package/admin/js/views/dashboard/widgets/traffic-chart.js +1 -0
  23. package/admin/js/views/dashboard.js +1 -1
  24. package/admin/js/views/form-editor.js +7 -7
  25. package/admin/js/views/index.js +1 -1
  26. package/config/plugins.json +3 -0
  27. package/package.json +2 -2
  28. package/plugins/analytics/admin/templates/analytics.html +52 -1
  29. package/plugins/analytics/admin/views/analytics.js +157 -32
  30. package/plugins/analytics/config.js +10 -2
  31. package/plugins/analytics/daily.json +5 -0
  32. package/plugins/analytics/journeys.json +10 -0
  33. package/plugins/analytics/lifetime.json +25 -0
  34. package/plugins/analytics/plugin.js +429 -25
  35. package/plugins/analytics/plugin.json +9 -5
  36. package/plugins/analytics/public/inject-body.html +49 -7
  37. package/plugins/blog/admin/templates/blog.html +25 -2
  38. package/plugins/blog/admin/views/blog.js +72 -56
  39. package/plugins/blog/admin/views/post-editor.js +98 -79
  40. package/plugins/blog/plugin.js +133 -0
  41. package/plugins/blog/plugin.json +3 -3
  42. package/plugins/blog/templates/post.html +2 -1
  43. package/plugins/invoice/admin/templates/editor.html +129 -0
  44. package/plugins/invoice/admin/templates/index.html +43 -0
  45. package/plugins/invoice/admin/templates/issuers.html +5 -0
  46. package/plugins/invoice/admin/templates/receivers.html +5 -0
  47. package/plugins/invoice/admin/views/editor.js +267 -0
  48. package/plugins/invoice/admin/views/index.js +155 -0
  49. package/plugins/invoice/admin/views/issuers.js +23 -0
  50. package/plugins/invoice/admin/views/party-view.js +148 -0
  51. package/plugins/invoice/admin/views/receivers.js +22 -0
  52. package/plugins/invoice/collections/invoice-issuers/schema.json +16 -0
  53. package/plugins/invoice/collections/invoice-receivers/schema.json +15 -0
  54. package/plugins/invoice/collections/invoices/schema.json +27 -0
  55. package/plugins/invoice/config.js +16 -0
  56. package/plugins/invoice/plugin.js +283 -0
  57. package/plugins/invoice/plugin.json +85 -0
  58. package/plugins/invoice/templates/invoice-print.html +213 -0
  59. package/server/routes/api/dashboard.js +239 -0
  60. package/server/server.js +2 -0
  61. package/server/services/email.js +60 -20
  62. package/server/services/health.js +282 -0
  63. package/server/services/markdown.js +24 -4
  64. package/server/services/plugins.js +37 -5
  65. package/server/services/renderer.js +9 -3
@@ -0,0 +1,213 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Invoice {{number}}</title>
6
+ <style>
7
+ :root { color-scheme: light; }
8
+ * { box-sizing: border-box; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
11
+ color: #222;
12
+ background: #f3f3f3;
13
+ margin: 0;
14
+ padding: 24px;
15
+ font-size: 14px;
16
+ line-height: 1.5;
17
+ }
18
+ .invoice {
19
+ max-width: 800px;
20
+ margin: 0 auto;
21
+ background: #fff;
22
+ padding: 48px 56px;
23
+ box-shadow: 0 4px 20px rgba(0,0,0,.08);
24
+ }
25
+ .top {
26
+ display: flex;
27
+ justify-content: space-between;
28
+ align-items: flex-start;
29
+ gap: 24px;
30
+ border-bottom: 2px solid #e5e5e5;
31
+ padding-bottom: 24px;
32
+ margin-bottom: 32px;
33
+ }
34
+ .logo { max-height: 80px; max-width: 240px; display: block; margin-bottom: 12px; }
35
+ .meta { text-align: right; }
36
+ .meta h1 { margin: 0; font-size: 28px; letter-spacing: 1px; color: #000; }
37
+ .meta .number { font-size: 18px; color: #555; margin-top: 4px; }
38
+ .status {
39
+ display: inline-block;
40
+ padding: 4px 10px;
41
+ border-radius: 4px;
42
+ font-size: 11px;
43
+ font-weight: 600;
44
+ letter-spacing: .5px;
45
+ background: #eee;
46
+ color: #444;
47
+ margin-top: 8px;
48
+ }
49
+ .parties {
50
+ display: grid;
51
+ grid-template-columns: 1fr 1fr;
52
+ gap: 32px;
53
+ margin-bottom: 32px;
54
+ }
55
+ .party h3 {
56
+ margin: 0 0 8px;
57
+ font-size: 11px;
58
+ text-transform: uppercase;
59
+ letter-spacing: 1px;
60
+ color: #888;
61
+ }
62
+ .party .name { font-weight: 700; font-size: 15px; margin-bottom: 4px; }
63
+ .party .lines { color: #555; font-size: 13px; }
64
+ table.items {
65
+ width: 100%;
66
+ border-collapse: collapse;
67
+ margin-bottom: 24px;
68
+ }
69
+ table.items th {
70
+ text-align: left;
71
+ font-size: 11px;
72
+ text-transform: uppercase;
73
+ letter-spacing: 1px;
74
+ color: #888;
75
+ padding: 8px 10px;
76
+ border-bottom: 2px solid #e5e5e5;
77
+ }
78
+ table.items td {
79
+ padding: 10px;
80
+ border-bottom: 1px solid #f0f0f0;
81
+ }
82
+ table.items td.num, table.items th.num { text-align: right; }
83
+ .totals {
84
+ width: 320px;
85
+ margin-left: auto;
86
+ margin-bottom: 32px;
87
+ }
88
+ .totals table { width: 100%; border-collapse: collapse; }
89
+ .totals th {
90
+ text-align: left;
91
+ padding: 8px 10px;
92
+ font-weight: 500;
93
+ color: #555;
94
+ }
95
+ .totals td.num {
96
+ text-align: right;
97
+ padding: 8px 10px;
98
+ font-variant-numeric: tabular-nums;
99
+ }
100
+ .totals .grand th, .totals .grand td {
101
+ font-size: 17px;
102
+ font-weight: 700;
103
+ color: #000;
104
+ border-top: 2px solid #222;
105
+ padding-top: 12px;
106
+ }
107
+ .footer {
108
+ margin-top: 32px;
109
+ padding-top: 16px;
110
+ border-top: 1px solid #e5e5e5;
111
+ font-size: 12px;
112
+ color: #888;
113
+ }
114
+ .notes, .bank {
115
+ margin-top: 24px;
116
+ padding: 12px 16px;
117
+ background: #fafafa;
118
+ border-radius: 4px;
119
+ font-size: 13px;
120
+ }
121
+ .muted { color: #999; font-style: italic; text-align: center; }
122
+ .actions {
123
+ max-width: 800px;
124
+ margin: 0 auto 16px;
125
+ display: flex;
126
+ justify-content: flex-end;
127
+ gap: 8px;
128
+ }
129
+ .actions button {
130
+ background: #222;
131
+ color: #fff;
132
+ border: 0;
133
+ padding: 8px 16px;
134
+ font-size: 13px;
135
+ border-radius: 4px;
136
+ cursor: pointer;
137
+ }
138
+ @media print {
139
+ body { background: #fff; padding: 0; }
140
+ .invoice { box-shadow: none; padding: 24px; }
141
+ .actions { display: none; }
142
+ }
143
+ </style>
144
+ </head>
145
+ <body>
146
+
147
+ <div class="actions">
148
+ <button onclick="window.print()">Print / Save as PDF</button>
149
+ </div>
150
+
151
+ <div class="invoice">
152
+ <div class="top">
153
+ <div>
154
+ {{issuerLogo}}
155
+ <div class="party">
156
+ <div class="name">{{issuerName}}</div>
157
+ <div class="lines">{{issuerAddress}}</div>
158
+ <div class="lines">{{issuerEmail}}</div>
159
+ <div class="lines">{{issuerPhone}}</div>
160
+ {{issuerVat}}
161
+ </div>
162
+ </div>
163
+ <div class="meta">
164
+ <h1>INVOICE</h1>
165
+ <div class="number">{{number}}</div>
166
+ <div>{{issueDate}}</div>
167
+ {{dueDate}}
168
+ <div class="status">{{status}}</div>
169
+ </div>
170
+ </div>
171
+
172
+ <div class="parties">
173
+ <div class="party">
174
+ <h3>Billed To</h3>
175
+ <div class="name">{{receiverName}}</div>
176
+ <div class="lines">{{receiverAddress}}</div>
177
+ <div class="lines">{{receiverEmail}}</div>
178
+ {{receiverVat}}
179
+ </div>
180
+ </div>
181
+
182
+ <table class="items">
183
+ <thead>
184
+ <tr>
185
+ <th>Description</th>
186
+ <th class="num">Qty</th>
187
+ <th class="num">Unit</th>
188
+ <th class="num">Total</th>
189
+ </tr>
190
+ </thead>
191
+ <tbody>
192
+ {{items}}
193
+ </tbody>
194
+ </table>
195
+
196
+ <div class="totals">
197
+ <table>
198
+ <tr><th>Subtotal</th><td class="num">{{subtotal}}</td></tr>
199
+ {{vatRow}}
200
+ <tr class="grand"><th>Total</th><td class="num">{{total}}</td></tr>
201
+ </table>
202
+ </div>
203
+
204
+ {{notes}}
205
+ {{bankDetails}}
206
+
207
+ <div class="footer">{{footerNote}}</div>
208
+ </div>
209
+
210
+ {{autoPrint}}
211
+
212
+ </body>
213
+ </html>
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Dashboard API
3
+ * Aggregates traffic, journeys, spikes, health, and activity for the admin dashboard.
4
+ *
5
+ * GET /api/dashboard/summary full payload
6
+ * GET /api/dashboard/summary?lite=1 KPI strip + spikes + health.status only
7
+ */
8
+ import fs from 'fs/promises';
9
+ import path from 'path';
10
+ import {fileURLToPath} from 'url';
11
+ import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin} from '../../middleware/auth.js';
12
+ import {getHealth} from '../../services/health.js';
13
+ import {listVersions} from '../../services/versions.js';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
17
+
18
+ const ACTIVITY_LIMIT = 10;
19
+ const VERSIONS_PER_PAGE = 3;
20
+ const ENTRIES_PER_COLLECTION = 3;
21
+
22
+ /**
23
+ * @param {Date} [d]
24
+ * @returns {string}
25
+ */
26
+ function todayKey(d = new Date()) {
27
+ return d.toISOString().slice(0, 10);
28
+ }
29
+
30
+ /**
31
+ * @param {number} n
32
+ * @returns {string}
33
+ */
34
+ function isoDaysAgo(n) {
35
+ const d = new Date();
36
+ d.setUTCHours(0, 0, 0, 0);
37
+ d.setUTCDate(d.getUTCDate() - n);
38
+ return todayKey(d);
39
+ }
40
+
41
+ /**
42
+ * @param {object} daily
43
+ * @param {string} key
44
+ * @returns {number}
45
+ */
46
+ function sumDay(daily, key) {
47
+ return Object.values(daily[key] || {}).reduce((a, n) => a + n, 0);
48
+ }
49
+
50
+ /**
51
+ * @param {object} daily
52
+ * @param {string} fromKey
53
+ * @param {string} toKey
54
+ * @returns {number}
55
+ */
56
+ function sumRange(daily, fromKey, toKey) {
57
+ let total = 0;
58
+ for (const [day, urls] of Object.entries(daily)) {
59
+ if (day < fromKey || day > toKey) continue;
60
+ for (const n of Object.values(urls)) total += n;
61
+ }
62
+ return total;
63
+ }
64
+
65
+ /**
66
+ * Build the top-pages widget data, including a per-day sparkline and a
67
+ * percentage delta versus the previous comparable window.
68
+ *
69
+ * @param {object} daily
70
+ * @param {number} [days]
71
+ * @returns {Array<{url: string, hits: number, deltaPct: number|null, spark: number[]}>}
72
+ */
73
+ function buildTopPages(daily, days = 7) {
74
+ const totals = {};
75
+ const perDay = {};
76
+ for (let i = days - 1; i >= 0; i -= 1) {
77
+ const k = isoDaysAgo(i);
78
+ for (const [url, n] of Object.entries(daily[k] || {})) {
79
+ totals[url] = (totals[url] || 0) + n;
80
+ if (!perDay[url]) perDay[url] = new Array(days).fill(0);
81
+ perDay[url][days - 1 - i] = n;
82
+ }
83
+ }
84
+ const prevTotals = {};
85
+ for (let i = 2 * days - 1; i >= days; i -= 1) {
86
+ const k = isoDaysAgo(i);
87
+ for (const [url, n] of Object.entries(daily[k] || {})) {
88
+ prevTotals[url] = (prevTotals[url] || 0) + n;
89
+ }
90
+ }
91
+ return Object.entries(totals)
92
+ .map(([url, hits]) => {
93
+ const prev = prevTotals[url] || 0;
94
+ const deltaPct = prev === 0 ? null : +(((hits - prev) / prev) * 100).toFixed(1);
95
+ return { url, hits, deltaPct, spark: perDay[url] };
96
+ })
97
+ .sort((a, b) => b.hits - a.hits)
98
+ .slice(0, 5);
99
+ }
100
+
101
+ /**
102
+ * Recent activity feed — combines page version events with recent collection
103
+ * entries (form submissions). Returns at most 10 items, newest first.
104
+ *
105
+ * @returns {Promise<Array<object>>}
106
+ */
107
+ async function buildActivity() {
108
+ const items = [];
109
+
110
+ // Versions — read each page's _meta.json via the service so the schema
111
+ // stays in one place. listVersions returns newest-first.
112
+ const versionsDir = path.join(ROOT, 'content', 'versions');
113
+ try {
114
+ const dirs = await fs.readdir(versionsDir, { withFileTypes: true });
115
+ for (const d of dirs) {
116
+ if (!d.isDirectory()) continue;
117
+ const urlPath = '/' + (d.name === '_index' ? '' : d.name);
118
+ let versions = [];
119
+ try { versions = await listVersions(urlPath); }
120
+ catch { continue; }
121
+ for (const v of versions.slice(0, VERSIONS_PER_PAGE)) {
122
+ items.push({
123
+ type: v.type === 'manual' ? 'publish' : 'edit',
124
+ at: v.createdAt,
125
+ actor: v.author || 'unknown',
126
+ target: urlPath
127
+ });
128
+ }
129
+ }
130
+ } catch { /* no versions dir */ }
131
+
132
+ // Collection entries — each slug has a single data.json with an array of
133
+ // entries. Take the newest ENTRIES_PER_COLLECTION by meta.createdAt.
134
+ const collectionsDir = path.join(ROOT, 'content', 'collections');
135
+ try {
136
+ const slugs = await fs.readdir(collectionsDir, { withFileTypes: true });
137
+ for (const slug of slugs) {
138
+ if (!slug.isDirectory()) continue;
139
+ const dataFile = path.join(collectionsDir, slug.name, 'data.json');
140
+ let entries = [];
141
+ try { entries = JSON.parse(await fs.readFile(dataFile, 'utf8')); }
142
+ catch { continue; }
143
+ if (!Array.isArray(entries)) continue;
144
+
145
+ const sorted = entries
146
+ .filter(e => e && e.meta && e.meta.createdAt)
147
+ .sort((a, b) => (b.meta.createdAt || '').localeCompare(a.meta.createdAt || ''))
148
+ .slice(0, ENTRIES_PER_COLLECTION);
149
+
150
+ for (const e of sorted) {
151
+ items.push({
152
+ type: 'form',
153
+ at: e.meta.createdAt,
154
+ form: slug.name
155
+ });
156
+ }
157
+ }
158
+ } catch { /* no collections dir */ }
159
+
160
+ return items
161
+ .filter(i => i.at)
162
+ .sort((a, b) => (b.at || '').localeCompare(a.at || ''))
163
+ .slice(0, ACTIVITY_LIMIT);
164
+ }
165
+
166
+ /**
167
+ * Register the dashboard routes.
168
+ *
169
+ * Auth middlewares are accepted as options so tests can supply no-ops without
170
+ * needing to register `@fastify/jwt` on the test instance. In production the
171
+ * defaults are the real `authenticate` / `requireAdmin` from middleware/auth.js.
172
+ *
173
+ * @param {import('fastify').FastifyInstance} fastify
174
+ * @param {{authenticate?: Function, requireAdmin?: Function}} [opts]
175
+ * @returns {Promise<void>}
176
+ */
177
+ export async function dashboardRoutes(fastify, opts = {}) {
178
+ const authenticate = opts.authenticate || defaultAuthenticate;
179
+ const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
180
+ const guard = { preHandler: [authenticate, requireAdmin] };
181
+
182
+ fastify.get('/dashboard/summary', guard, async (request) => {
183
+ const lite = request.query.lite === '1' || request.query.lite === 'true';
184
+ const warnings = [];
185
+ const safe = async (label, fn) => {
186
+ try { return await fn(); }
187
+ catch (e) {
188
+ // Strip absolute paths from error messages so we don't disclose deployment layout.
189
+ const msg = String(e.message || e).replace(/(\/|[A-Za-z]:\\)[^\s'"]+/g, '<path>');
190
+ warnings.push(`${label}: ${msg}`);
191
+ return null;
192
+ }
193
+ };
194
+
195
+ const analytics = fastify.analytics;
196
+ const daily = analytics ? (await safe('analytics.daily', () => analytics.getDaily())) || {} : {};
197
+
198
+ const today = todayKey();
199
+ const yesterday = isoDaysAgo(1);
200
+ const todayHits = sumDay(daily, today);
201
+ const yesterdayHits = sumDay(daily, yesterday);
202
+ const deltaPct = yesterdayHits === 0
203
+ ? null
204
+ : +(((todayHits - yesterdayHits) / yesterdayHits) * 100).toFixed(1);
205
+ const traffic = {
206
+ today: todayHits,
207
+ yesterday: yesterdayHits,
208
+ deltaPct,
209
+ weekToDate: sumRange(daily, isoDaysAgo(6), today),
210
+ previousWeek: sumRange(daily, isoDaysAgo(13), isoDaysAgo(7))
211
+ };
212
+
213
+ const realtime = analytics
214
+ ? (await safe('analytics.realtime', () => analytics.getRealtime())) || { activeSessions: 0 }
215
+ : { activeSessions: 0 };
216
+ const spikes = analytics
217
+ ? ((await safe('analytics.spikes', () => analytics.getSpikes())) || { items: [] }).items
218
+ : [];
219
+ const health = await safe('health', () => getHealth()) || { status: 'ok', checks: [] };
220
+
221
+ if (lite) {
222
+ return {
223
+ traffic: { today: traffic.today, yesterday: traffic.yesterday, deltaPct: traffic.deltaPct },
224
+ realtime,
225
+ spikes,
226
+ health: { status: health.status },
227
+ warnings
228
+ };
229
+ }
230
+
231
+ const topPages = buildTopPages(daily);
232
+ const journeys = analytics
233
+ ? (await safe('analytics.journeys', () => analytics.getJourneys({ range: '7d' }))) || null
234
+ : null;
235
+ const activity = (await safe('activity', () => buildActivity())) || [];
236
+
237
+ return { traffic, topPages, journeys, spikes, realtime, health, activity, warnings };
238
+ });
239
+ }
package/server/server.js CHANGED
@@ -249,6 +249,7 @@ const {componentsRoutes} = await import('./routes/api/components.js');
249
249
  const {versionsRoutes} = await import('./routes/api/versions.js');
250
250
  const {effectsRoutes} = await import('./routes/api/effects.js');
251
251
  const {notificationsRoutes} = await import('./routes/api/notifications.js');
252
+ const {dashboardRoutes} = await import('./routes/api/dashboard.js');
252
253
 
253
254
  await app.register(pagesRoutes, { prefix: '/api' });
254
255
  await app.register(settingsRoutes, { prefix: '/api' });
@@ -267,6 +268,7 @@ await app.register(componentsRoutes, {prefix: '/api'});
267
268
  await app.register(versionsRoutes, {prefix: '/api'});
268
269
  await app.register(effectsRoutes, {prefix: '/api'});
269
270
  await app.register(notificationsRoutes, {prefix: '/api'});
271
+ await app.register(dashboardRoutes, {prefix: '/api'});
270
272
 
271
273
  // ---------------------------------------------------------------------------
272
274
  // CMS Plugins (server-side Fastify plugins from plugins/ directory)
@@ -4,6 +4,32 @@
4
4
  */
5
5
  import nodemailer from 'nodemailer';
6
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
+
7
33
  /**
8
34
  * Escape HTML special characters for safe use in email bodies.
9
35
  *
@@ -58,17 +84,24 @@ export async function createTransport(smtp) {
58
84
  * @throws {Error} If sending the email fails.
59
85
  */
60
86
  export async function sendEmail(transport, {from, fromName, to, subject, html, text}) {
61
- const info = await transport.sendMail({
62
- from: `"${fromName}" <${from}>`,
63
- to,
64
- subject,
65
- text,
66
- html
67
- });
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);
68
96
 
69
- const previewUrl = nodemailer.getTestMessageUrl(info);
70
- if (previewUrl) {
71
- console.log('[email] Preview URL:', previewUrl);
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;
72
105
  }
73
106
  }
74
107
 
@@ -112,16 +145,23 @@ export async function sendFormEmail(transport, { from, fromName, to, subject, fo
112
145
 
113
146
  const text = `New form submission: ${formTitle}\n\n${plainRows}`;
114
147
 
115
- const info = await transport.sendMail({
116
- from: `"${fromName}" <${from}>`,
117
- to,
118
- subject,
119
- text,
120
- html
121
- });
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);
122
157
 
123
- const previewUrl = nodemailer.getTestMessageUrl(info);
124
- if (previewUrl) {
125
- console.log('[email] Preview URL:', previewUrl);
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;
126
166
  }
127
167
  }