domma-cms 0.2.0 → 0.3.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/README.md +2 -3
- package/admin/css/admin.css +1 -1200
- package/admin/js/api.js +1 -242
- package/admin/js/app.js +5 -279
- package/admin/js/config/sidebar-config.js +1 -115
- package/admin/js/lib/card.js +1 -63
- package/admin/js/lib/image-editor.js +1 -869
- package/admin/js/lib/markdown-toolbar.js +46 -421
- package/admin/js/templates/layouts.html +44 -7
- package/admin/js/templates/page-editor.html +9 -0
- package/admin/js/templates/settings.html +18 -1
- package/admin/js/templates/users.html +29 -4
- package/admin/js/views/collection-editor.js +3 -487
- package/admin/js/views/collection-entries.js +1 -484
- package/admin/js/views/collections.js +1 -153
- package/admin/js/views/dashboard.js +1 -56
- package/admin/js/views/documentation.js +1 -12
- package/admin/js/views/index.js +1 -39
- package/admin/js/views/layouts.js +9 -42
- package/admin/js/views/login.js +7 -251
- package/admin/js/views/media.js +1 -240
- package/admin/js/views/navigation.js +14 -212
- package/admin/js/views/page-editor.js +53 -661
- package/admin/js/views/pages.js +5 -72
- package/admin/js/views/plugins.js +13 -90
- package/admin/js/views/settings.js +1 -199
- package/admin/js/views/tutorials.js +1 -12
- package/admin/js/views/user-editor.js +1 -88
- package/admin/js/views/users.js +7 -76
- package/bin/cli.js +18 -9
- package/config/auth.json +1 -17
- package/config/navigation.json +15 -0
- package/config/site.json +5 -4
- package/package.json +1 -1
- package/plugins/domma-effects/public/celebrations/core/canvas.js +2 -104
- package/plugins/domma-effects/public/celebrations/core/particles.js +1 -144
- package/plugins/domma-effects/public/celebrations/core/physics.js +1 -166
- package/plugins/domma-effects/public/celebrations/index.js +1 -535
- package/plugins/domma-effects/public/celebrations/themes/christmas.js +1 -1805
- package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1 -1477
- package/plugins/domma-effects/public/celebrations/themes/halloween.js +1 -1837
- package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1 -1175
- package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1 -1258
- package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1 -1754
- package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1 -1290
- package/plugins/domma-effects/public/celebrations/themes/valentines.js +1 -1361
- package/plugins/example-analytics/stats.json +16 -12
- package/plugins/form-builder/admin/templates/form-editor.html +158 -130
- package/plugins/form-builder/admin/views/form-editor.js +3 -1
- package/plugins/form-builder/data/forms/contact-details.json +71 -35
- package/plugins/form-builder/data/forms/feedback.json +130 -0
- package/plugins/form-builder/data/submissions/feedback.json +1 -0
- package/plugins/form-builder/public/form-logic-engine.js +1 -568
- package/public/css/site.css +1 -302
- package/public/js/btt.js +1 -90
- package/public/js/cookie-consent.js +1 -61
- package/public/js/site.js +1 -204
- package/scripts/setup.js +12 -9
- package/server/middleware/auth.js +44 -21
- package/server/routes/api/auth.js +38 -8
- package/server/routes/api/collections.js +18 -5
- package/server/routes/api/layouts.js +18 -4
- package/server/routes/api/media.js +2 -3
- package/server/routes/api/navigation.js +2 -3
- package/server/routes/api/pages.js +3 -3
- package/server/routes/api/settings.js +2 -3
- package/server/routes/api/users.js +4 -6
- package/server/routes/public.js +3 -3
- package/server/server.js +8 -0
- package/server/services/markdown.js +102 -3
- package/server/services/userTypes.js +167 -0
- package/plugins/form-builder/email.js +0 -103
package/server/server.js
CHANGED
|
@@ -15,6 +15,7 @@ import {fileURLToPath} from 'url';
|
|
|
15
15
|
import {createRequire} from 'module';
|
|
16
16
|
import {config} from './config.js';
|
|
17
17
|
import {registerPlugins} from './services/plugins.js';
|
|
18
|
+
import { seed as seedUserTypes, load as loadUserTypes } from './services/userTypes.js';
|
|
18
19
|
|
|
19
20
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
21
|
const ROOT = path.resolve(__dirname, '..');
|
|
@@ -84,6 +85,13 @@ await fs.mkdir(usersDir, { recursive: true });
|
|
|
84
85
|
await fs.mkdir(collectionsDir, { recursive: true });
|
|
85
86
|
await fs.mkdir(pluginsDir, { recursive: true });
|
|
86
87
|
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// User Types — seed preset collection + load into cache
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
await seedUserTypes();
|
|
93
|
+
await loadUserTypes();
|
|
94
|
+
|
|
87
95
|
// Serve uploaded media files
|
|
88
96
|
await app.register(staticPlugin, {
|
|
89
97
|
root: mediaDir,
|
|
@@ -6,6 +6,7 @@ import matter from 'gray-matter';
|
|
|
6
6
|
import {marked} from 'marked';
|
|
7
7
|
import sanitizeHtml from 'sanitize-html';
|
|
8
8
|
import {applyTransforms, getSanitizeExtensions, getShortcodeProcessors} from './hooks.js';
|
|
9
|
+
import {getConfig} from '../config.js';
|
|
9
10
|
|
|
10
11
|
// Configure marked for safe output
|
|
11
12
|
marked.setOptions({
|
|
@@ -136,6 +137,7 @@ function processGridBlocks(markdown) {
|
|
|
136
137
|
if (attrs.cols) classes.push(`grid-cols-${attrs.cols}`);
|
|
137
138
|
if (attrs.gap) classes.push(`gap-${attrs.gap}`);
|
|
138
139
|
if (attrs.class) classes.push(attrs.class);
|
|
140
|
+
if (attrs.fullwidth === 'true') classes.push('grid-breakout');
|
|
139
141
|
const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
140
142
|
return `<div class="${classes.join(' ')}"${id}>${inner}</div>\n`;
|
|
141
143
|
}
|
|
@@ -466,6 +468,96 @@ function processTableBlocks(markdown) {
|
|
|
466
468
|
* @param {string} markdown
|
|
467
469
|
* @returns {string}
|
|
468
470
|
*/
|
|
471
|
+
/**
|
|
472
|
+
* Pre-process [spacer] self-closing shortcode.
|
|
473
|
+
*
|
|
474
|
+
* Syntax:
|
|
475
|
+
* [spacer /] - uses defaults from layoutOptions config
|
|
476
|
+
* [spacer size="24" /] - explicit pixel height
|
|
477
|
+
* [spacer class="my-gap" /] - extra CSS class
|
|
478
|
+
* [spacer size="16" class="mt-4" /] - both
|
|
479
|
+
*
|
|
480
|
+
* @param {string} markdown
|
|
481
|
+
* @returns {string}
|
|
482
|
+
*/
|
|
483
|
+
function processSpacerBlocks(markdown) {
|
|
484
|
+
const opts = getConfig('site')?.layoutOptions ?? {};
|
|
485
|
+
const defaultSize = opts.spacerSize ?? 8;
|
|
486
|
+
const defaultClass = opts.spacerClass ?? '';
|
|
487
|
+
return markdown.replace(
|
|
488
|
+
/\[spacer([^\]]*?)\/?\]/gi,
|
|
489
|
+
(_, attrStr) => {
|
|
490
|
+
const attrs = parseShortcodeAttrs(attrStr || '');
|
|
491
|
+
const size = parseInt(attrs.size, 10) || defaultSize;
|
|
492
|
+
const extra = attrs.class || defaultClass;
|
|
493
|
+
const classes = ['dm-spacer', extra].filter(Boolean).join(' ');
|
|
494
|
+
return `<div class="${classes}" style="height:${size}px" aria-hidden="true"></div>\n`;
|
|
495
|
+
}
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Pre-process [icon] self-closing shortcodes before running through marked.
|
|
501
|
+
*
|
|
502
|
+
* Syntax:
|
|
503
|
+
* [icon name="arrow-right" /]
|
|
504
|
+
* [icon name="star" size="24" color="#f59e0b" class="my-icon" /]
|
|
505
|
+
*
|
|
506
|
+
* Supported attributes:
|
|
507
|
+
* name - Domma icon name (required). Also accepted as src= for convenience.
|
|
508
|
+
* size - Width and height in px (default: 1em via CSS)
|
|
509
|
+
* color - CSS colour applied via style
|
|
510
|
+
* class - Extra CSS classes
|
|
511
|
+
*
|
|
512
|
+
* @param {string} markdown
|
|
513
|
+
* @returns {string}
|
|
514
|
+
*/
|
|
515
|
+
function processIconBlocks(markdown) {
|
|
516
|
+
return markdown.replace(
|
|
517
|
+
/\[icon([^\]]*?)\/\]/gi,
|
|
518
|
+
(_, attrStr) => {
|
|
519
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
520
|
+
const name = attrs.name || attrs.src || '';
|
|
521
|
+
if (!name) return '';
|
|
522
|
+
const classes = ['dm-icon', attrs.class].filter(Boolean).join(' ');
|
|
523
|
+
const sizeAttr = attrs.size ? ` data-icon-size="${parseInt(attrs.size, 10)}"` : '';
|
|
524
|
+
const colorAttr = attrs.color ? ` data-icon-colour="${escapeAttr(attrs.color)}"` : '';
|
|
525
|
+
return `<span data-icon="${escapeAttr(name)}" class="${escapeAttr(classes)}"${sizeAttr}${colorAttr}></span>`;
|
|
526
|
+
}
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Pre-process [form] self-closing shortcodes before running through marked.
|
|
532
|
+
*
|
|
533
|
+
* Syntax:
|
|
534
|
+
* [form name="contact" /]
|
|
535
|
+
* [form name="newsletter" class="my-form" /]
|
|
536
|
+
*
|
|
537
|
+
* Supported attributes:
|
|
538
|
+
* name - Form slug as configured in the form-builder (required). Also accepted as slug=.
|
|
539
|
+
* class - Extra CSS classes on the wrapper div
|
|
540
|
+
* id - Element id attribute
|
|
541
|
+
*
|
|
542
|
+
* The form-builder's injected client script scans for [data-form] and renders the form.
|
|
543
|
+
*
|
|
544
|
+
* @param {string} markdown
|
|
545
|
+
* @returns {string}
|
|
546
|
+
*/
|
|
547
|
+
function processFormBlocks(markdown) {
|
|
548
|
+
return markdown.replace(
|
|
549
|
+
/\[form([^\]]*?)\/\]/gi,
|
|
550
|
+
(_, attrStr) => {
|
|
551
|
+
const attrs = parseShortcodeAttrs(attrStr);
|
|
552
|
+
const name = attrs.name || attrs.slug || '';
|
|
553
|
+
if (!name) return '';
|
|
554
|
+
const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
555
|
+
const classAttr = attrs.class ? ` class="${escapeAttr(attrs.class)}"` : '';
|
|
556
|
+
return `<div data-form="${escapeAttr(name)}"${idAttr}${classAttr}></div>`;
|
|
557
|
+
}
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
469
561
|
function processHeroBlocks(markdown) {
|
|
470
562
|
return markdown.replace(
|
|
471
563
|
/\[hero([^\]]*)\]([\s\S]*?)\[\/hero\]/gi,
|
|
@@ -481,6 +573,7 @@ function processHeroBlocks(markdown) {
|
|
|
481
573
|
const fullwidth = attrs.fullwidth === 'true';
|
|
482
574
|
const cls = attrs.class || '';
|
|
483
575
|
const id = attrs.id || '';
|
|
576
|
+
const bg = attrs.bg || '';
|
|
484
577
|
|
|
485
578
|
const classes = ['hero'];
|
|
486
579
|
if (size) classes.push(`hero-${size}`);
|
|
@@ -491,7 +584,10 @@ function processHeroBlocks(markdown) {
|
|
|
491
584
|
if (fullwidth) classes.push('hero-breakout');
|
|
492
585
|
if (cls) classes.push(cls);
|
|
493
586
|
|
|
494
|
-
const
|
|
587
|
+
const styleParts = [];
|
|
588
|
+
if (image) styleParts.push(`background-image:url('${escapeAttr(image)}')`);
|
|
589
|
+
if (bg) styleParts.push(`background-color:${escapeAttr(bg)}`);
|
|
590
|
+
const style = styleParts.length ? ` style="${styleParts.join(';')}"` : '';
|
|
495
591
|
const idAttr = id ? ` id="${escapeAttr(id)}"` : '';
|
|
496
592
|
|
|
497
593
|
const processedBody = processCardBlocks(processGridBlocks(body.trim()));
|
|
@@ -590,7 +686,7 @@ export function parseMarkdown(raw) {
|
|
|
590
686
|
|
|
591
687
|
// Pipeline:
|
|
592
688
|
// beforeParse → dconfig → plugin shortcodes → tabs → accordion → carousel → countdown
|
|
593
|
-
// → hero → grid → card → slideover → marked → sanitize → afterParse
|
|
689
|
+
// → spacer → icon → form → hero → grid → card → slideover → marked → sanitize → afterParse
|
|
594
690
|
const preprocessed = applyTransforms('markdown:beforeParse', content);
|
|
595
691
|
const withDconfig = processDConfigBlocks(preprocessed);
|
|
596
692
|
const withPluginShortcodes = processPluginShortcodes(withDconfig);
|
|
@@ -598,7 +694,10 @@ export function parseMarkdown(raw) {
|
|
|
598
694
|
const withAccordion = processAccordionBlocks(withTabs);
|
|
599
695
|
const withCarousel = processCarouselBlocks(withAccordion);
|
|
600
696
|
const withCountdown = processCountdownBlocks(withCarousel);
|
|
601
|
-
const
|
|
697
|
+
const withSpacer = processSpacerBlocks(withCountdown);
|
|
698
|
+
const withIcon = processIconBlocks(withSpacer);
|
|
699
|
+
const withForm = processFormBlocks(withIcon);
|
|
700
|
+
const withHero = processHeroBlocks(withForm);
|
|
602
701
|
const withTable = processTableBlocks(withHero);
|
|
603
702
|
const withGrid = processGridBlocks(withTable);
|
|
604
703
|
const withCard = processCardBlocks(withGrid);
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User Types Service
|
|
3
|
+
* Preset Collection — seeds roles on first startup, caches them in memory.
|
|
4
|
+
* Auth middleware calls getRoleMap() / getPermissionsFor() at request time.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'fs/promises';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
9
|
+
import { config } from '../config.js';
|
|
10
|
+
|
|
11
|
+
const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
|
|
12
|
+
const SLUG = 'user-types';
|
|
13
|
+
const DIR = path.join(COLLECTIONS_DIR, SLUG);
|
|
14
|
+
const SCHEMA_PATH = path.join(DIR, 'schema.json');
|
|
15
|
+
const DATA_PATH = path.join(DIR, 'data.json');
|
|
16
|
+
|
|
17
|
+
const RESOURCES = ['pages', 'settings', 'navigation', 'layouts', 'media', 'users', 'plugins', 'collections'];
|
|
18
|
+
|
|
19
|
+
const PRESET_SCHEMA = {
|
|
20
|
+
slug: SLUG,
|
|
21
|
+
title: 'User Types',
|
|
22
|
+
description: 'CMS role definitions — managed by the system.',
|
|
23
|
+
preset: true,
|
|
24
|
+
fields: [
|
|
25
|
+
{ name: 'name', label: 'Name (slug)', type: 'text', required: true },
|
|
26
|
+
{ name: 'label', label: 'Label', type: 'text', required: true },
|
|
27
|
+
{ name: 'level', label: 'Level', type: 'number', required: true },
|
|
28
|
+
{ name: 'permissions', label: 'Permissions', type: 'multi-select', options: RESOURCES },
|
|
29
|
+
{ name: 'badgeClass', label: 'Badge Class', type: 'select',
|
|
30
|
+
options: ['badge-danger','badge-warning','badge-info','badge-secondary','badge-success','badge-primary'] }
|
|
31
|
+
],
|
|
32
|
+
api: {
|
|
33
|
+
create: { enabled: false, access: 'admin' },
|
|
34
|
+
read: { enabled: false, access: 'admin' },
|
|
35
|
+
update: { enabled: false, access: 'admin' },
|
|
36
|
+
delete: { enabled: false, access: 'admin' }
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const SEED_ENTRIES = [
|
|
41
|
+
{ name: 'admin', label: 'Admin', level: 0, permissions: RESOURCES, badgeClass: 'badge-danger' },
|
|
42
|
+
{ name: 'manager', label: 'Manager', level: 1, permissions: ['pages','settings','navigation','layouts','media','users','collections'], badgeClass: 'badge-warning' },
|
|
43
|
+
{ name: 'editor', label: 'Editor', level: 2, permissions: ['pages','media'], badgeClass: 'badge-info' },
|
|
44
|
+
{ name: 'subscriber', label: 'Subscriber', level: 3, permissions: [], badgeClass: 'badge-secondary' }
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// In-memory cache
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** @type {Map<string,{label:string,level:number,badgeClass:string}>} */
|
|
52
|
+
let roleMap = new Map();
|
|
53
|
+
|
|
54
|
+
/** @type {Map<string,string[]>} */
|
|
55
|
+
let permissionsMap = new Map();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build in-memory maps from an array of data entries.
|
|
59
|
+
*
|
|
60
|
+
* @param {object[]} entries
|
|
61
|
+
*/
|
|
62
|
+
function buildCache(entries) {
|
|
63
|
+
roleMap = new Map();
|
|
64
|
+
permissionsMap = new Map();
|
|
65
|
+
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const d = entry.data;
|
|
68
|
+
roleMap.set(d.name, { label: d.label, level: d.level, badgeClass: d.badgeClass || '' });
|
|
69
|
+
for (const resource of (d.permissions || [])) {
|
|
70
|
+
if (!permissionsMap.has(resource)) permissionsMap.set(resource, []);
|
|
71
|
+
permissionsMap.get(resource).push(d.name);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Public API
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Seed the preset collection on first startup (no-op if data.json already exists).
|
|
82
|
+
*
|
|
83
|
+
* @returns {Promise<void>}
|
|
84
|
+
*/
|
|
85
|
+
export async function seed() {
|
|
86
|
+
await fs.mkdir(DIR, { recursive: true });
|
|
87
|
+
|
|
88
|
+
// Always write schema (overwrite to keep in sync with code)
|
|
89
|
+
await fs.writeFile(SCHEMA_PATH, JSON.stringify(PRESET_SCHEMA, null, 2) + '\n', 'utf8');
|
|
90
|
+
|
|
91
|
+
// Only write data if it doesn't exist yet
|
|
92
|
+
try {
|
|
93
|
+
await fs.access(DATA_PATH);
|
|
94
|
+
} catch {
|
|
95
|
+
const entries = SEED_ENTRIES.map(data => ({
|
|
96
|
+
id: uuidv4(),
|
|
97
|
+
data,
|
|
98
|
+
createdAt: new Date().toISOString(),
|
|
99
|
+
updatedAt: new Date().toISOString()
|
|
100
|
+
}));
|
|
101
|
+
await fs.writeFile(DATA_PATH, JSON.stringify(entries, null, 2) + '\n', 'utf8');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Load the collection from disk into the in-memory cache.
|
|
107
|
+
*
|
|
108
|
+
* @returns {Promise<void>}
|
|
109
|
+
*/
|
|
110
|
+
export async function load() {
|
|
111
|
+
try {
|
|
112
|
+
const raw = await fs.readFile(DATA_PATH, 'utf8');
|
|
113
|
+
const entries = JSON.parse(raw);
|
|
114
|
+
buildCache(entries);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.warn('[userTypes] Failed to load user-types collection:', err.message);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Reload from disk — call after any CRUD on the user-types collection.
|
|
122
|
+
*
|
|
123
|
+
* @returns {Promise<void>}
|
|
124
|
+
*/
|
|
125
|
+
export async function invalidate() {
|
|
126
|
+
await load();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Return the full role map.
|
|
131
|
+
*
|
|
132
|
+
* @returns {Map<string,{label:string,level:number,badgeClass:string}>}
|
|
133
|
+
*/
|
|
134
|
+
export function getRoleMap() {
|
|
135
|
+
return roleMap;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Return the level for a named role, or Infinity if not found.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} roleName
|
|
142
|
+
* @returns {number}
|
|
143
|
+
*/
|
|
144
|
+
export function getRoleLevel(roleName) {
|
|
145
|
+
return roleMap.get(roleName)?.level ?? Infinity;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Return the role names allowed to access a resource, or [] if resource unknown.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} resource
|
|
152
|
+
* @returns {string[]}
|
|
153
|
+
*/
|
|
154
|
+
export function getPermissionsFor(resource) {
|
|
155
|
+
return permissionsMap.get(resource) ?? [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Return role names ordered from most to least privileged.
|
|
160
|
+
*
|
|
161
|
+
* @returns {string[]}
|
|
162
|
+
*/
|
|
163
|
+
export function getRoleHierarchy() {
|
|
164
|
+
return [...roleMap.entries()]
|
|
165
|
+
.sort((a, b) => a[1].level - b[1].level)
|
|
166
|
+
.map(([key]) => key);
|
|
167
|
+
}
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Form Builder Plugin — Email Helper
|
|
3
|
-
* Nodemailer transport factory and generic form email sending utility.
|
|
4
|
-
*/
|
|
5
|
-
import nodemailer from 'nodemailer';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Escape HTML special characters for safe use in email bodies.
|
|
9
|
-
*
|
|
10
|
-
* @param {string} str
|
|
11
|
-
* @returns {string}
|
|
12
|
-
*/
|
|
13
|
-
export function escapeHtml(str) {
|
|
14
|
-
return String(str)
|
|
15
|
-
.replace(/&/g, '&')
|
|
16
|
-
.replace(/</g, '<')
|
|
17
|
-
.replace(/>/g, '>')
|
|
18
|
-
.replace(/"/g, '"')
|
|
19
|
-
.replace(/'/g, ''');
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Create a nodemailer transport.
|
|
24
|
-
* Falls back to an Ethereal test account when no SMTP host is configured.
|
|
25
|
-
*
|
|
26
|
-
* @param {{ host: string, port: number, secure: boolean, user: string, pass: string }} smtp
|
|
27
|
-
* @returns {Promise<import('nodemailer').Transporter>}
|
|
28
|
-
* @throws {Error} If Ethereal test account creation fails.
|
|
29
|
-
*/
|
|
30
|
-
export async function createTransport(smtp) {
|
|
31
|
-
if (smtp?.host) {
|
|
32
|
-
return nodemailer.createTransport({
|
|
33
|
-
host: smtp.host,
|
|
34
|
-
port: smtp.port || 587,
|
|
35
|
-
secure: smtp.secure || false,
|
|
36
|
-
auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// No SMTP configured — use Ethereal for dev/demo
|
|
41
|
-
const testAccount = await nodemailer.createTestAccount();
|
|
42
|
-
console.log('[form-builder] No SMTP configured. Using Ethereal test account:', testAccount.user);
|
|
43
|
-
return nodemailer.createTransport({
|
|
44
|
-
host: 'smtp.ethereal.email',
|
|
45
|
-
port: 587,
|
|
46
|
-
secure: false,
|
|
47
|
-
auth: { user: testAccount.user, pass: testAccount.pass }
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Send an HTML + plain-text form submission notification email.
|
|
53
|
-
* Builds a generic table of field→value pairs from the submitted data.
|
|
54
|
-
*
|
|
55
|
-
* @param {import('nodemailer').Transporter} transport
|
|
56
|
-
* @param {{ from: string, fromName: string, to: string, subject: string, formTitle: string, fields: Array<{name: string, label: string}>, data: Record<string, unknown> }} opts
|
|
57
|
-
* @returns {Promise<void>}
|
|
58
|
-
* @throws {Error} If sending the email fails.
|
|
59
|
-
*/
|
|
60
|
-
export async function sendFormEmail(transport, { from, fromName, to, subject, formTitle, fields, data }) {
|
|
61
|
-
const rows = fields.map(field => {
|
|
62
|
-
const val = data[field.name] ?? '';
|
|
63
|
-
const safe = escapeHtml(String(val)).replace(/\n/g, '<br>');
|
|
64
|
-
const safeLabel = escapeHtml(field.label || field.name);
|
|
65
|
-
return `
|
|
66
|
-
<tr>
|
|
67
|
-
<td style="padding:8px 12px;font-weight:600;background:#f9f9f9;border:1px solid #eee;white-space:nowrap;vertical-align:top;">${safeLabel}</td>
|
|
68
|
-
<td style="padding:8px 12px;border:1px solid #eee;vertical-align:top;">${safe}</td>
|
|
69
|
-
</tr>`.trim();
|
|
70
|
-
}).join('\n');
|
|
71
|
-
|
|
72
|
-
const plainRows = fields.map(field => {
|
|
73
|
-
const val = data[field.name] ?? '';
|
|
74
|
-
return `${field.label || field.name}: ${val}`;
|
|
75
|
-
}).join('\n');
|
|
76
|
-
|
|
77
|
-
const html = `
|
|
78
|
-
<!DOCTYPE html>
|
|
79
|
-
<html>
|
|
80
|
-
<body style="font-family:sans-serif;max-width:640px;margin:0 auto;padding:20px;">
|
|
81
|
-
<h2 style="color:#333;margin-bottom:4px;">New Form Submission</h2>
|
|
82
|
-
<p style="color:#888;margin-top:0;font-size:.9rem;">${escapeHtml(formTitle)}</p>
|
|
83
|
-
<table style="width:100%;border-collapse:collapse;margin-top:16px;">
|
|
84
|
-
${rows}
|
|
85
|
-
</table>
|
|
86
|
-
</body>
|
|
87
|
-
</html>`.trim();
|
|
88
|
-
|
|
89
|
-
const text = `New form submission: ${formTitle}\n\n${plainRows}`;
|
|
90
|
-
|
|
91
|
-
const info = await transport.sendMail({
|
|
92
|
-
from: `"${fromName}" <${from}>`,
|
|
93
|
-
to,
|
|
94
|
-
subject,
|
|
95
|
-
text,
|
|
96
|
-
html
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
const previewUrl = nodemailer.getTestMessageUrl(info);
|
|
100
|
-
if (previewUrl) {
|
|
101
|
-
console.log('[form-builder] Email preview URL:', previewUrl);
|
|
102
|
-
}
|
|
103
|
-
}
|