Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,7 @@ configs/web.yaml

# Ansible DKIM keys (contain private keys)
ansible/dkim_keys/

# Agent state
.nemo/
ansible/inventory/hosts.yml
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions internal/web/handlers/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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") {
Comment thread
foxzi marked this conversation as resolved.
return ""
}
return m
})
}
Comment thread
foxzi marked this conversation as resolved.

// TemplateExportData represents template data for export/import
type TemplateExportData struct {
Name string `json:"name"`
Expand Down
63 changes: 62 additions & 1 deletion internal/web/handlers/templates_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package handlers

import "testing"
import (
"strings"
"testing"
)

func TestConvertToGoTemplate(t *testing.T) {
tests := []struct {
Expand Down Expand Up @@ -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: "<style>.a{color:red;}" + wrapperBlock + ".b{color:blue;}</style>",
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: `<style>@media (prefers-color-scheme: dark) {
.custom-block { color: #fff; }
}</style>`,
wantContain: []string{"prefers-color-scheme", ".custom-block"},
},
{
name: "no dark-mode block leaves html untouched",
input: "<style>.a{color:red;}</style>",
wantContain: []string{"<style>.a{color:red;}</style>"},
},
{
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)
}
}
})
}
}
131 changes: 109 additions & 22 deletions internal/web/static/js/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<span data-placeholder="' + varName + '" style="background:#E4E4E4;color:#959595;padding:1px 4px;border-radius:3px;font-size:12px;">' + varName + '</span>';
});
}
Expand Down Expand Up @@ -407,6 +417,7 @@
function previewHTML(html) {
html = applyTestValues(html);
html = replaceImageVars(html);
html = replaceHrefVars(html);
html = replaceTextVars(html);
return html;
}
Expand All @@ -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 <a> inside a <td>): 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;
Comment thread
foxzi marked this conversation as resolved.
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;
Comment thread
foxzi marked this conversation as resolved.
}

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);
});
}

Expand All @@ -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();
Expand All @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions internal/web/static/js/preview-utils.js
Original file line number Diff line number Diff line change
@@ -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);
7 changes: 6 additions & 1 deletion internal/web/views/block_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ <h3>Template Variables</h3>
if (!r.ok) return r.text().then(function(t) { throw new Error(t); });
return r.text();
}).then(function(html) {
html = SendryPreview.stripWrapperDarkModeCSS(html);
Comment thread
foxzi marked this conversation as resolved.
frame.srcdoc = html;
status.textContent = 'OK — block rendered with the JSON above.';
status.style.color = 'var(--text-muted)';
Expand All @@ -331,7 +332,11 @@ <h3>Template Variables</h3>
}

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() {
Expand Down
7 changes: 6 additions & 1 deletion internal/web/views/block_view.html
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ <h2 class="card-title">Appearance</h2>
if (!r.ok) return r.text().then(function(t) { throw new Error(t); });
return r.text();
}).then(function(html) {
html = SendryPreview.stripWrapperDarkModeCSS(html);
Comment thread
foxzi marked this conversation as resolved.
frame.srcdoc = html;
status.textContent = 'OK';
status.style.color = 'var(--text-muted)';
Expand All @@ -180,7 +181,11 @@ <h2 class="card-title">Appearance</h2>
});
}

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
Expand Down
1 change: 1 addition & 0 deletions internal/web/views/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@

<script src="/static/js/i18n.js"></script>
<script src="/static/js/app.js"></script>
<script src="/static/js/preview-utils.js"></script>
<script>
(function() {
function updateNavStats() {
Expand Down
Loading
Loading