From 767b6b85cbdc59612e2cdf251b7d7bf65a411d95 Mon Sep 17 00:00:00 2001
From: Rustem
Date: Mon, 6 Apr 2026 15:52:54 +0300
Subject: [PATCH 1/5] Fix relative image URLs in emails by converting to
absolute using public_url config
When templates contain images uploaded via the media manager, their src
attributes use relative paths (/uploads/...). Email clients cannot resolve
relative URLs, so images appear broken. This change applies makeAbsoluteURLs
at send time (both test emails and API sends) using server.public_url from config.
Co-Authored-By: Claude Sonnet 4.6
---
internal/web/handlers/handlers.go | 1 +
internal/web/handlers/templates.go | 2 ++
internal/web/router/router.go | 9 +++++++++
3 files changed, 12 insertions(+)
diff --git a/internal/web/handlers/handlers.go b/internal/web/handlers/handlers.go
index 9515e9d..1ffb75a 100644
--- a/internal/web/handlers/handlers.go
+++ b/internal/web/handlers/handlers.go
@@ -52,6 +52,7 @@ func New(cfg *config.Config, db *db.DB, logger *slog.Logger, v *views.Engine, oi
Settings: settings,
Sendry: sendryMgr,
MultiSend: &cfg.Sendry.MultiSend,
+ PublicURL: cfg.Server.PublicURL,
Logger: logger.With("component", "router"),
})
diff --git a/internal/web/handlers/templates.go b/internal/web/handlers/templates.go
index 100d4ab..276fd90 100644
--- a/internal/web/handlers/templates.go
+++ b/internal/web/handlers/templates.go
@@ -652,6 +652,8 @@ func (h *Handlers) TemplateTest(w http.ResponseWriter, r *http.Request) {
html := renderTemplateVars(t.HTML, globalVars)
text := renderTemplateVars(t.Text, globalVars)
+ html = makeAbsoluteURLs(html, h.cfg.Server.PublicURL)
+
// Send test email
req := &sendry.SendRequest{
From: from,
diff --git a/internal/web/router/router.go b/internal/web/router/router.go
index 768eb28..7dfa9a9 100644
--- a/internal/web/router/router.go
+++ b/internal/web/router/router.go
@@ -65,6 +65,7 @@ type EmailRouter struct {
settings *repository.SettingsRepository
sendry *sendry.Manager
cfg *config.MultiSendConfig
+ publicURL string
logger *slog.Logger
mu sync.Mutex
@@ -79,6 +80,7 @@ type RouterConfig struct {
Settings *repository.SettingsRepository
Sendry *sendry.Manager
MultiSend *config.MultiSendConfig
+ PublicURL string
Logger *slog.Logger
}
@@ -91,6 +93,7 @@ func NewEmailRouter(cfg RouterConfig) *EmailRouter {
settings: cfg.Settings,
sendry: cfg.Sendry,
cfg: cfg.MultiSend,
+ publicURL: cfg.PublicURL,
logger: cfg.Logger,
rrCounters: make(map[string]int),
}
@@ -294,6 +297,12 @@ func (r *EmailRouter) resolveTemplate(ctx context.Context, req *APISendRequest)
html := renderVars(tmpl.HTML, data)
text := renderVars(tmpl.Text, data)
+ if r.publicURL != "" {
+ base := strings.TrimRight(r.publicURL, "/")
+ html = strings.ReplaceAll(html, `src="/uploads/`, `src="`+base+`/uploads/`)
+ html = strings.ReplaceAll(html, `src="/static/`, `src="`+base+`/static/`)
+ }
+
return &sendry.SendRequest{
From: req.From,
To: req.To,
From 970f69a47e851ca0d2ab62a7c14facf2e8201a30 Mon Sep 17 00:00:00 2001
From: Rustem
Date: Mon, 6 Apr 2026 22:44:47 +0300
Subject: [PATCH 2/5] Fix template variable substitution for {{.VarName}}
syntax
- router.go: strip leading dot from variable names so {{.Var}} matches key "Var" in data map
- templates.go: replaceVar now handles both {{key}} and {{.key}} placeholders
- templates.go: TemplateTest now reads and applies "variables" JSON from form data
Co-Authored-By: Claude Sonnet 4.6
---
internal/web/handlers/templates.go | 25 +++++++++++++++++++++++--
internal/web/router/router.go | 3 ++-
2 files changed, 25 insertions(+), 3 deletions(-)
diff --git a/internal/web/handlers/templates.go b/internal/web/handlers/templates.go
index 276fd90..9a2da45 100644
--- a/internal/web/handlers/templates.go
+++ b/internal/web/handlers/templates.go
@@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
+ "fmt"
"net/http"
"strconv"
"time"
@@ -647,6 +648,20 @@ func (h *Handlers) TemplateTest(w http.ResponseWriter, r *http.Request) {
globalVars = make(map[string]string)
}
+ // Merge request variables (override global)
+ if varsJSON := r.FormValue("variables"); varsJSON != "" {
+ var reqVars map[string]any
+ if err := json.Unmarshal([]byte(varsJSON), &reqVars); err == nil {
+ for k, v := range reqVars {
+ if s, ok := v.(string); ok {
+ globalVars[k] = s
+ } else {
+ globalVars[k] = fmt.Sprintf("%v", v)
+ }
+ }
+ }
+ }
+
// Render template with variables
subject := renderTemplateVars(t.Subject, globalVars)
html := renderTemplateVars(t.HTML, globalVars)
@@ -687,9 +702,15 @@ func renderTemplateVars(template string, vars map[string]string) string {
return result
}
-// replaceVar replaces {{key}} with value in template
+// replaceVar replaces {{key}} and {{.key}} with value in template
func replaceVar(template, key, value string) string {
- placeholder := "{{" + key + "}}"
+ // Replace both {{key}} and {{.key}} (Go-template style)
+ template = replaceVarPlaceholder(template, "{{"+key+"}}", value)
+ template = replaceVarPlaceholder(template, "{{."+key+"}}", value)
+ return template
+}
+
+func replaceVarPlaceholder(template, placeholder, value string) string {
for i := 0; i < len(template); {
idx := indexString(template[i:], placeholder)
if idx == -1 {
diff --git a/internal/web/router/router.go b/internal/web/router/router.go
index 7dfa9a9..47452d0 100644
--- a/internal/web/router/router.go
+++ b/internal/web/router/router.go
@@ -462,8 +462,9 @@ var varPattern = regexp.MustCompile(`\{\{([^}]+)\}\}`)
// renderVars replaces {{variable}} placeholders with values
func renderVars(template string, data map[string]any) string {
return varPattern.ReplaceAllStringFunc(template, func(match string) string {
- // Extract variable name
+ // Extract variable name (strip leading dot for Go-template style {{.Var}})
varName := strings.TrimSpace(match[2 : len(match)-2])
+ varName = strings.TrimPrefix(varName, ".")
if val, ok := data[varName]; ok {
return fmt.Sprintf("%v", val)
From 06c1fb9788cb3b14630b1ad85034e65e7ca06a36 Mon Sep 17 00:00:00 2001
From: Rustem
Date: Mon, 6 Apr 2026 22:49:34 +0300
Subject: [PATCH 3/5] Revert "Fix template variable substitution for
{{.VarName}} syntax"
This reverts commit 970f69a47e851ca0d2ab62a7c14facf2e8201a30.
---
internal/web/handlers/templates.go | 25 ++-----------------------
internal/web/router/router.go | 3 +--
2 files changed, 3 insertions(+), 25 deletions(-)
diff --git a/internal/web/handlers/templates.go b/internal/web/handlers/templates.go
index 9a2da45..276fd90 100644
--- a/internal/web/handlers/templates.go
+++ b/internal/web/handlers/templates.go
@@ -2,7 +2,6 @@ package handlers
import (
"encoding/json"
- "fmt"
"net/http"
"strconv"
"time"
@@ -648,20 +647,6 @@ func (h *Handlers) TemplateTest(w http.ResponseWriter, r *http.Request) {
globalVars = make(map[string]string)
}
- // Merge request variables (override global)
- if varsJSON := r.FormValue("variables"); varsJSON != "" {
- var reqVars map[string]any
- if err := json.Unmarshal([]byte(varsJSON), &reqVars); err == nil {
- for k, v := range reqVars {
- if s, ok := v.(string); ok {
- globalVars[k] = s
- } else {
- globalVars[k] = fmt.Sprintf("%v", v)
- }
- }
- }
- }
-
// Render template with variables
subject := renderTemplateVars(t.Subject, globalVars)
html := renderTemplateVars(t.HTML, globalVars)
@@ -702,15 +687,9 @@ func renderTemplateVars(template string, vars map[string]string) string {
return result
}
-// replaceVar replaces {{key}} and {{.key}} with value in template
+// replaceVar replaces {{key}} with value in template
func replaceVar(template, key, value string) string {
- // Replace both {{key}} and {{.key}} (Go-template style)
- template = replaceVarPlaceholder(template, "{{"+key+"}}", value)
- template = replaceVarPlaceholder(template, "{{."+key+"}}", value)
- return template
-}
-
-func replaceVarPlaceholder(template, placeholder, value string) string {
+ placeholder := "{{" + key + "}}"
for i := 0; i < len(template); {
idx := indexString(template[i:], placeholder)
if idx == -1 {
diff --git a/internal/web/router/router.go b/internal/web/router/router.go
index 47452d0..7dfa9a9 100644
--- a/internal/web/router/router.go
+++ b/internal/web/router/router.go
@@ -462,9 +462,8 @@ var varPattern = regexp.MustCompile(`\{\{([^}]+)\}\}`)
// renderVars replaces {{variable}} placeholders with values
func renderVars(template string, data map[string]any) string {
return varPattern.ReplaceAllStringFunc(template, func(match string) string {
- // Extract variable name (strip leading dot for Go-template style {{.Var}})
+ // Extract variable name
varName := strings.TrimSpace(match[2 : len(match)-2])
- varName = strings.TrimPrefix(varName, ".")
if val, ok := data[varName]; ok {
return fmt.Sprintf("%v", val)
From d08de1dcb5fa537cbb9c9c0b88bbc57d9eed6dbf Mon Sep 17 00:00:00 2001
From: Rustem
Date: Tue, 7 Apr 2026 01:29:31 +0300
Subject: [PATCH 4/5] Use {{VarName}} syntax without dot for template variables
Switch from {{.VarName}} to {{VarName}} across block editor UI and
template test handler. Removes ambiguity between Go template dot syntax
and Sendry's custom variable substitution. Also enables test form to
pass variables JSON when sending test emails.
Co-Authored-By: Claude Sonnet 4.6
---
internal/web/handlers/templates.go | 15 +++++++++++++++
internal/web/views/block_form.html | 10 +++++-----
internal/web/views/block_view.html | 4 ++--
internal/web/views/settings_variables.html | 2 +-
4 files changed, 23 insertions(+), 8 deletions(-)
diff --git a/internal/web/handlers/templates.go b/internal/web/handlers/templates.go
index 276fd90..740ba5f 100644
--- a/internal/web/handlers/templates.go
+++ b/internal/web/handlers/templates.go
@@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
+ "fmt"
"net/http"
"strconv"
"time"
@@ -647,6 +648,20 @@ func (h *Handlers) TemplateTest(w http.ResponseWriter, r *http.Request) {
globalVars = make(map[string]string)
}
+ // Merge request variables (override global)
+ if varsJSON := r.FormValue("variables"); varsJSON != "" {
+ var reqVars map[string]any
+ if err := json.Unmarshal([]byte(varsJSON), &reqVars); err == nil {
+ for k, v := range reqVars {
+ if s, ok := v.(string); ok {
+ globalVars[k] = s
+ } else {
+ globalVars[k] = fmt.Sprintf("%v", v)
+ }
+ }
+ }
+ }
+
// Render template with variables
subject := renderTemplateVars(t.Subject, globalVars)
html := renderTemplateVars(t.HTML, globalVars)
diff --git a/internal/web/views/block_form.html b/internal/web/views/block_form.html
index f479068..770a283 100644
--- a/internal/web/views/block_form.html
+++ b/internal/web/views/block_form.html
@@ -185,7 +185,7 @@ Template Variables
- No variables found. Use {{"{{"}}.VarName{{"}}"}} syntax in your HTML.
+ No variables found. Use {{"{{"}}VarName{{"}}"}} syntax in your HTML.
@@ -205,12 +205,12 @@ Template Variables
// Replace variables that have test values
for (var key in testValues) {
if (testValues[key]) {
- var pattern = LP + '.' + key + '}}';
+ var pattern = LP + key + '}}';
html = html.split(pattern).join(testValues[key]);
}
}
// Remaining variables — show as grey badges
- html = html.replace(/\{\{\.(\w+)\}\}/g, function(match, name) {
+ html = html.replace(/\{\{(\w+)\}\}/g, function(match, name) {
return '' + name + '';
});
return html;
@@ -288,7 +288,7 @@ Template Variables
if (_pickerMode === 'replace') {
// Replace variable with the actual URL
- var varPattern = LP + '.' + _replaceVarName + '}}';
+ var varPattern = LP + _replaceVarName + '}}';
textarea.value = textarea.value.split(varPattern).join(file.url);
textarea.dispatchEvent(new Event('input'));
closeImagePicker();
@@ -324,7 +324,7 @@ Template Variables
// Find all template variables
var vars = {};
- var re = /\{\{\.(\w+)\}\}/g;
+ var re = /\{\{(\w+)\}\}/g;
var m;
while ((m = re.exec(html)) !== null) {
var full = m[0];
diff --git a/internal/web/views/block_view.html b/internal/web/views/block_view.html
index 8ad4696..b81301e 100644
--- a/internal/web/views/block_view.html
+++ b/internal/web/views/block_view.html
@@ -78,11 +78,11 @@ {{.Block.Name}}
var content = html;
for (var key in testValues) {
if (testValues[key]) {
- content = content.split('{{"{{"}}.' + key + '}}').join(testValues[key]);
+ content = content.split('{{"{{"}}' + key + '}}').join(testValues[key]);
}
}
// Remaining variables — show as grey badges
- var varRe = new RegExp('\\{\\{\\.(\\w+)\\}\\}', 'g');
+ var varRe = new RegExp('\\{\\{(\\w+)\\}\\}', 'g');
content = content.replace(varRe, function(m, name) {
return '' + name + '';
});
diff --git a/internal/web/views/settings_variables.html b/internal/web/views/settings_variables.html
index ec6f8aa..5a785e8 100644
--- a/internal/web/views/settings_variables.html
+++ b/internal/web/views/settings_variables.html
@@ -46,7 +46,7 @@ Variables
{{range .Variables}}
- {{`{{`}}.{{.Key}}{{`}}`}} |
+ {{`{{`}}{{.Key}}{{`}}`}} |
{{.Value}} |
{{.Description}} |
{{.UpdatedAt.Format "2006-01-02 15:04"}} |
From 3899bb8d8c3b2e99abba28789bf8ba82ed8068cb Mon Sep 17 00:00:00 2001
From: Rustem
Date: Tue, 7 Apr 2026 01:54:00 +0300
Subject: [PATCH 5/5] Disable dark mode in email wrapper to preserve brand
colors
Add color-scheme and supported-color-schemes meta tags to force
light-only rendering in Apple Mail and Gmail, preventing automatic
color inversion of brand colors on dark-themed devices.
Co-Authored-By: Claude Sonnet 4.6
---
internal/web/blocks/wrapper.html | 2 ++
1 file changed, 2 insertions(+)
diff --git a/internal/web/blocks/wrapper.html b/internal/web/blocks/wrapper.html
index 56adf63..d2fe98b 100644
--- a/internal/web/blocks/wrapper.html
+++ b/internal/web/blocks/wrapper.html
@@ -5,6 +5,8 @@
+
+
{{.Subject}}