diff --git a/.gitignore b/.gitignore
index 9ab0062..96fac67 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,3 +46,7 @@ configs/web.yaml
# Ansible DKIM keys (contain private keys)
ansible/dkim_keys/
+
+# Agent state
+.nemo/
+ansible/inventory/hosts.yml
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e0c23db..7946231 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,9 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+- builder: replaceTextVars now supports dotted placeholder paths (e.g. {{.User.Email}}) and optional whitespace inside braces
### Added
- Tests: additional `dnssync` plan cases — DKIM skipped on empty `DNSRecord`, default selector `mail`, and quoted/whitespace-equivalent values treated as noop
+- Unit test `TestStripDarkModeCSS` covering wrapper removal, custom dark-mode preservation, and no-op cases
+
+### Changed
+- Extract duplicated dark-mode strip regex from email builder preview views into shared `preview-utils.js` helper
+
+### Fixed
+- Email builder preview: `replaceHrefVars` now requires a whitespace boundary so it no longer rewrites `data-href` or `xlink:href` attributes
+- Email builder preview: defer initial render until DOMContentLoaded so `SendryPreview` helper is loaded before use (avoids race with layout scripts)
## [0.4.17] - 2026-04-17
diff --git a/internal/web/handlers/templates.go b/internal/web/handlers/templates.go
index 802ae45..c22d830 100644
--- a/internal/web/handlers/templates.go
+++ b/internal/web/handlers/templates.go
@@ -471,6 +471,8 @@ func (h *Handlers) TemplatePreview(w http.ResponseWriter, r *http.Request) {
}
}
+ html = stripDarkModeCSS(html)
+
data := map[string]any{
"Title": "Preview: " + t.Name,
"Active": "templates",
@@ -483,6 +485,17 @@ func (h *Handlers) TemplatePreview(w http.ResponseWriter, r *http.Request) {
h.render(w, "template_preview", data)
}
+var darkModeBlockRe = regexp.MustCompile(`(?s)@media\s*\(prefers-color-scheme:\s*dark\)\s*\{.*?\}\s*\}`)
+
+func stripDarkModeCSS(html string) string {
+ return darkModeBlockRe.ReplaceAllStringFunc(html, func(m string) string {
+ if strings.Contains(m, ".force-page-bg") {
+ return ""
+ }
+ return m
+ })
+}
+
// TemplateExportData represents template data for export/import
type TemplateExportData struct {
Name string `json:"name"`
diff --git a/internal/web/handlers/templates_test.go b/internal/web/handlers/templates_test.go
index 4aadf0c..e8a09e3 100644
--- a/internal/web/handlers/templates_test.go
+++ b/internal/web/handlers/templates_test.go
@@ -1,6 +1,9 @@
package handlers
-import "testing"
+import (
+ "strings"
+ "testing"
+)
func TestConvertToGoTemplate(t *testing.T) {
tests := []struct {
@@ -89,3 +92,61 @@ func TestConvertToGoTemplate(t *testing.T) {
})
}
}
+
+func TestStripDarkModeCSS(t *testing.T) {
+ wrapperBlock := `@media (prefers-color-scheme: dark) {
+ body, .force-page-bg {
+ background-color: #1C1C1E !important;
+ }
+ .email-container {
+ background-color: #2C2C2E !important;
+ }
+ }`
+
+ tests := []struct {
+ name string
+ input string
+ wantContain []string
+ wantMissing []string
+ }{
+ {
+ name: "removes wrapper dark-mode block",
+ input: "",
+ wantContain: []string{".a{color:red;}", ".b{color:blue;}"},
+ wantMissing: []string{"force-page-bg", "prefers-color-scheme"},
+ },
+ {
+ name: "preserves other dark-mode blocks without wrapper marker",
+ input: ``,
+ wantContain: []string{"prefers-color-scheme", ".custom-block"},
+ },
+ {
+ name: "no dark-mode block leaves html untouched",
+ input: "",
+ wantContain: []string{""},
+ },
+ {
+ name: "empty input",
+ input: "",
+ wantContain: []string{},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := stripDarkModeCSS(tt.input)
+ for _, s := range tt.wantContain {
+ if !strings.Contains(got, s) {
+ t.Errorf("expected result to contain %q, got %q", s, got)
+ }
+ }
+ for _, s := range tt.wantMissing {
+ if strings.Contains(got, s) {
+ t.Errorf("expected result to NOT contain %q, got %q", s, got)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/web/static/js/builder.js b/internal/web/static/js/builder.js
index a8bc06d..026c457 100644
--- a/internal/web/static/js/builder.js
+++ b/internal/web/static/js/builder.js
@@ -375,8 +375,18 @@
});
}
+ function replaceHrefVars(html) {
+ // Only match a standalone `href` attribute (preceded by whitespace inside
+ // a tag), not longer attribute names like `data-href` or `xlink:href`.
+ return html.replace(/(\s)href\s*=\s*(['"])\s*\{\{\s*\.\w+(?:\.\w+)*\s*\}\}\s*\2/gi, '$1href=$2#$2');
+ }
+
function replaceTextVars(html) {
- return html.replace(/\{\{\.(\w+)\}\}/g, function(match, varName) {
+ // Support nested/dotted paths like {{.User.Email}} and optional
+ // internal whitespace like {{ .Name }}. Tags are skipped to avoid
+ // wrapping placeholders that live inside attributes.
+ return html.replace(/<[^>]*>|\{\{\s*\.(\w+(?:\.\w+)*)\s*\}\}/g, function(match, varName) {
+ if (match[0] === '<') return match; // a tag — leave untouched
return '' + varName + '';
});
}
@@ -407,6 +417,7 @@
function previewHTML(html) {
html = applyTestValues(html);
html = replaceImageVars(html);
+ html = replaceHrefVars(html);
html = replaceTextVars(html);
return html;
}
@@ -432,39 +443,87 @@
}, true);
}
+ function bindEditable(el, original, item, previewEl, srcIndex) {
+ el.setAttribute('contenteditable', 'true');
+ el.setAttribute('spellcheck', 'false');
+ el.dataset.originalText = original;
+ if (srcIndex !== undefined && srcIndex >= 0) el.dataset.sourceIndex = String(srcIndex);
+ el.addEventListener('mousedown', function(e) { e.stopPropagation(); });
+ el.addEventListener('focus', function() {
+ var blockEl = el.closest('.builder-block');
+ if (blockEl) blockEl.draggable = false;
+ });
+ el.addEventListener('input', function() { scheduleInlineSave(item, previewEl); });
+ el.addEventListener('blur', function() {
+ var blockEl = el.closest('.builder-block');
+ if (blockEl) blockEl.draggable = true;
+ scheduleInlineSave(item, previewEl, true);
+ });
+ el.addEventListener('keydown', function(e) {
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); el.blur(); }
+ });
+ }
+
function enableInlineEdit(previewEl, item) {
+ var cursor = 0;
+ var locate = function(text) {
+ var at = item.html.indexOf(text, cursor);
+ if (at === -1) return -1;
+ cursor = at + text.length;
+ return at;
+ };
previewEl.querySelectorAll('td, th, span, p, h1, h2, h3, h4, h5, h6, li, a, b, strong, i, em').forEach(function(node) {
if (node.dataset && node.dataset.placeholder) return;
+ if (node.dataset && node.dataset.inlineText) return;
+ if (node.dataset && node.dataset.inlineDone) return;
+ // Skip nodes whose ancestor was already turned into a contenteditable
+ // region (e.g. an inside a
): making a descendant editable
+ // too would create overlapping edit zones and break source-index
+ // mapping during saveInlineEdit.
+ if (node.parentNode && node.parentNode.closest && node.parentNode.closest('[contenteditable="true"]')) return;
+ var mixed = false;
var blocksParent = false;
for (var c = 0; c < node.children.length; c++) {
var ch = node.children[c];
if (ch.tagName === 'BR') continue;
+ if (ch.tagName === 'IMG' || ch.tagName === 'A') { mixed = true; continue; }
if (ch.dataset && ch.dataset.placeholder) continue;
blocksParent = true;
break;
}
if (blocksParent) return;
+
+ if (mixed) {
+ Array.prototype.slice.call(node.childNodes).forEach(function(ch) {
+ if (ch.nodeType === 3) {
+ var raw = ch.nodeValue;
+ var trimmed = raw && raw.trim();
+ if (!trimmed) return;
+ var at = locate(trimmed);
+ if (at === -1) return;
+ var span = document.createElement('span');
+ span.dataset.inlineText = '1';
+ span.textContent = raw;
+ ch.parentNode.replaceChild(span, ch);
+ bindEditable(span, trimmed, item, previewEl, at);
+ } else if (ch.nodeType === 1 && ch.tagName === 'A') {
+ var aText = canonicalText(ch).trim();
+ if (!aText) return;
+ var aAt = locate(aText);
+ if (aAt === -1) return;
+ bindEditable(ch, aText, item, previewEl, aAt);
+ ch.dataset.inlineDone = '1';
+ }
+ });
+ return;
+ }
+
var canonical = canonicalText(node);
var trimmed = canonical.trim();
if (!trimmed) return;
- if (item.html.indexOf(trimmed) === -1) return;
- node.setAttribute('contenteditable', 'true');
- node.setAttribute('spellcheck', 'false');
- node.dataset.originalText = trimmed;
- node.addEventListener('mousedown', function(e) { e.stopPropagation(); });
- node.addEventListener('focus', function() {
- var blockEl = node.closest('.builder-block');
- if (blockEl) blockEl.draggable = false;
- });
- node.addEventListener('input', function() { scheduleInlineSave(item, previewEl); });
- node.addEventListener('blur', function() {
- var blockEl = node.closest('.builder-block');
- if (blockEl) blockEl.draggable = true;
- scheduleInlineSave(item, previewEl, true);
- });
- node.addEventListener('keydown', function(e) {
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); node.blur(); }
- });
+ var at = locate(trimmed);
+ if (at === -1) return;
+ bindEditable(node, trimmed, item, previewEl, at);
});
}
@@ -479,6 +538,8 @@
var newSourceHTML = item.html;
var changes = 0;
var diagnostics = [];
+
+ var edits = [];
previewEl.querySelectorAll('[contenteditable="true"]').forEach(function(node) {
var original = node.dataset.originalText;
var current = canonicalText(node).trim();
@@ -489,15 +550,41 @@
diagnostics.push('placeholder count mismatch in ' + JSON.stringify(original.slice(0, 30)));
return;
}
- var idx = newSourceHTML.indexOf(original);
+ var idx = -1;
+ if (node.dataset.sourceIndex !== undefined) {
+ var si = parseInt(node.dataset.sourceIndex, 10);
+ if (si >= 0 && item.html.substr(si, original.length) === original) idx = si;
+ }
+ if (idx === -1) idx = item.html.indexOf(original);
if (idx === -1) {
diagnostics.push('original text not found in source: ' + JSON.stringify(original.slice(0, 30)));
return;
}
- newSourceHTML = newSourceHTML.slice(0, idx) + current + newSourceHTML.slice(idx + original.length);
- node.dataset.originalText = current;
+ edits.push({ idx: idx, original: original, current: current, node: node });
+ });
+
+ edits.sort(function(a, b) { return a.idx - b.idx; });
+ var offset = 0;
+ edits.forEach(function(e) {
+ var pos = e.idx + offset;
+ newSourceHTML = newSourceHTML.slice(0, pos) + e.current + newSourceHTML.slice(pos + e.original.length);
+ offset += e.current.length - e.original.length;
+ e.node.dataset.originalText = e.current;
changes++;
});
+
+ if (changes > 0) {
+ var recur = 0;
+ previewEl.querySelectorAll('[contenteditable="true"]').forEach(function(node) {
+ if (node.dataset.sourceIndex === undefined) return;
+ var t = node.dataset.originalText;
+ if (t === undefined) return;
+ var at = newSourceHTML.indexOf(t, recur);
+ if (at === -1) return;
+ node.dataset.sourceIndex = String(at);
+ recur = at + t.length;
+ });
+ }
console.log('[inline-edit] save called', {
blockId: item.blockId,
sourceLen: item.html.length,
diff --git a/internal/web/static/js/preview-utils.js b/internal/web/static/js/preview-utils.js
new file mode 100644
index 0000000..5353854
--- /dev/null
+++ b/internal/web/static/js/preview-utils.js
@@ -0,0 +1,18 @@
+// Shared preview helpers for the email builder.
+// Strips only the wrapper's dark-mode block from preview HTML so previews
+// stay readable on a forced white background. Wrapper block is identified
+// by the .force-page-bg selector; block/template-specific dark-mode CSS
+// is preserved. Keep this in sync with internal/web/handlers/templates.go
+// (stripDarkModeCSS) — both must match the wrapper CSS in
+// internal/web/blocks/wrapper.html.
+(function(global) {
+ var darkBlockRe = /@media\s*\(prefers-color-scheme:\s*dark\)\s*\{[\s\S]*?\}\s*\}/g;
+ function stripWrapperDarkModeCSS(html) {
+ if (typeof html !== 'string' || html.indexOf('@media') === -1) return html;
+ return html.replace(darkBlockRe, function(m) {
+ return m.indexOf('.force-page-bg') !== -1 ? '' : m;
+ });
+ }
+ global.SendryPreview = global.SendryPreview || {};
+ global.SendryPreview.stripWrapperDarkModeCSS = stripWrapperDarkModeCSS;
+})(window);
diff --git a/internal/web/views/block_form.html b/internal/web/views/block_form.html
index 98f6f9f..75a1b76 100644
--- a/internal/web/views/block_form.html
+++ b/internal/web/views/block_form.html
@@ -321,6 +321,7 @@ Template Variables
if (!r.ok) return r.text().then(function(t) { throw new Error(t); });
return r.text();
}).then(function(html) {
+ html = SendryPreview.stripWrapperDarkModeCSS(html);
frame.srcdoc = html;
status.textContent = 'OK — block rendered with the JSON above.';
status.style.color = 'var(--text-muted)';
@@ -331,7 +332,11 @@ Template Variables
}
btn.addEventListener('click', render);
- render();
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', render);
+ } else {
+ render();
+ }
var shell = document.getElementById('livepreview-shell');
document.querySelectorAll('[data-livepreview-width]').forEach(function(b) {
b.addEventListener('click', function() {
diff --git a/internal/web/views/block_view.html b/internal/web/views/block_view.html
index 5318bb7..5c39a75 100644
--- a/internal/web/views/block_view.html
+++ b/internal/web/views/block_view.html
@@ -171,6 +171,7 @@ Appearance
if (!r.ok) return r.text().then(function(t) { throw new Error(t); });
return r.text();
}).then(function(html) {
+ html = SendryPreview.stripWrapperDarkModeCSS(html);
frame.srcdoc = html;
status.textContent = 'OK';
status.style.color = 'var(--text-muted)';
@@ -180,7 +181,11 @@ Appearance
});
}
- render();
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', render);
+ } else {
+ render();
+ }
// Appearance inputs: debounced auto-save to /blocks/{id}/appearance,
// re-render preview on success so radius/padding changes show up
diff --git a/internal/web/views/layout.html b/internal/web/views/layout.html
index 9071b41..c3b521a 100644
--- a/internal/web/views/layout.html
+++ b/internal/web/views/layout.html
@@ -63,6 +63,7 @@
+
|