From 5c33a0d30472f912f682ec65914b728787594c1c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 19 May 2026 11:24:13 +0800 Subject: [PATCH 1/2] Allow Go templates in campaign From header Mirror the Subject templating path so the campaign From header can be personalized per subscriber (e.g. `{{ .Subscriber.FirstName }} `). Compile FromEmail when it contains template syntax, execute it per message before send, and relax the From input validation to allow template syntax (compile errors still surface). --- cmd/campaigns.go | 24 +++++++++++++++++++++--- internal/manager/message.go | 14 ++++++++++++++ models/campaigns.go | 16 ++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/cmd/campaigns.go b/cmd/campaigns.go index c97e5703b..9944adfc3 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -663,9 +663,27 @@ func (a *App) sendTestMessage(sub models.Subscriber, camp *models.Campaign) erro func (a *App) validateCampaignFields(c campReq) (campReq, error) { if c.FromEmail == "" { c.FromEmail = a.cfg.FromEmail - } else if !reFromAddress.Match([]byte(c.FromEmail)) { - if _, err := a.importer.SanitizeEmail(c.FromEmail); err != nil { - return c, errors.New(a.i18n.T("campaigns.fieldInvalidFromEmail")) + } else { + fromEmail := c.FromEmail + + // If the From header contains a template, render it against the + // dummy subscriber so the resolved address can be validated. + if strings.Contains(fromEmail, "{{") { + testCamp := models.Campaign{FromEmail: fromEmail} + if err := testCamp.CompileTemplate(a.manager.TemplateFuncs(&testCamp)); err != nil { + return c, errors.New(a.i18n.Ts("campaigns.fieldInvalidFromEmail") + ": " + err.Error()) + } + msg, err := a.manager.NewCampaignMessage(&testCamp, dummySubscriber) + if err != nil { + return c, errors.New(a.i18n.Ts("campaigns.fieldInvalidFromEmail") + ": " + err.Error()) + } + fromEmail = msg.From() + } + + if !reFromAddress.Match([]byte(fromEmail)) { + if _, err := a.importer.SanitizeEmail(fromEmail); err != nil { + return c, errors.New(a.i18n.T("campaigns.fieldInvalidFromEmail")) + } } } diff --git a/internal/manager/message.go b/internal/manager/message.go index e6cd7b5bf..3d68dae9e 100644 --- a/internal/manager/message.go +++ b/internal/manager/message.go @@ -42,6 +42,15 @@ func (m *CampaignMessage) render() error { out.Reset() } + // Render the From header if it's a template. + if m.Campaign.FromEmailTpl != nil { + if err := m.Campaign.FromEmailTpl.ExecuteTemplate(&out, models.ContentTpl, m); err != nil { + return err + } + m.from = out.String() + out.Reset() + } + // Compile the main template. if err := m.Campaign.Tpl.ExecuteTemplate(&out, models.BaseTpl, m); err != nil { return err @@ -92,6 +101,11 @@ func (m *CampaignMessage) Subject() string { return m.subject } +// From returns the rendered From header. +func (m *CampaignMessage) From() string { + return m.from +} + // Body returns a copy of the message body. func (m *CampaignMessage) Body() []byte { out := make([]byte, len(m.body)) diff --git a/models/campaigns.go b/models/campaigns.go index 43e9b6a81..067f86d4f 100644 --- a/models/campaigns.go +++ b/models/campaigns.go @@ -65,6 +65,7 @@ type Campaign struct { ArchiveTemplateBody string `db:"archive_template_body" json:"-"` Tpl *template.Template `json:"-"` SubjectTpl *txttpl.Template `json:"-"` + FromEmailTpl *txttpl.Template `json:"-"` AltBodyTpl *template.Template `json:"-"` // HeaderTpls is holds optionally {{ templated }} campaign headers. @@ -154,6 +155,21 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error { c.SubjectTpl = subjTpl } + // If the From header has a template string, compile it. + if hasTplExpr(c.FromEmail) { + from := c.FromEmail + for _, r := range regTplFuncs { + from = r.regExp.ReplaceAllString(from, r.replace) + } + + var txtFuncs map[string]any = f + fromTpl, err := txttpl.New(ContentTpl).Funcs(txtFuncs).Parse(from) + if err != nil { + return fmt.Errorf("error compiling from: %v", err) + } + c.FromEmailTpl = fromTpl + } + // Compile the base template. body := c.TemplateBody From 4d8c24624103eafaadad2adbd6edad207dafb135 Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Fri, 19 Jun 2026 17:38:34 +0530 Subject: [PATCH 2/2] Centralize and simplify campaign `From` header templating logic. --- cmd/campaigns.go | 24 ++------- internal/manager/message.go | 5 -- models/campaigns.go | 98 +++++++++++++++++-------------------- models/messages.go | 4 +- models/templates.go | 4 +- 5 files changed, 51 insertions(+), 84 deletions(-) diff --git a/cmd/campaigns.go b/cmd/campaigns.go index 9944adfc3..d21d1caa5 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -663,27 +663,9 @@ func (a *App) sendTestMessage(sub models.Subscriber, camp *models.Campaign) erro func (a *App) validateCampaignFields(c campReq) (campReq, error) { if c.FromEmail == "" { c.FromEmail = a.cfg.FromEmail - } else { - fromEmail := c.FromEmail - - // If the From header contains a template, render it against the - // dummy subscriber so the resolved address can be validated. - if strings.Contains(fromEmail, "{{") { - testCamp := models.Campaign{FromEmail: fromEmail} - if err := testCamp.CompileTemplate(a.manager.TemplateFuncs(&testCamp)); err != nil { - return c, errors.New(a.i18n.Ts("campaigns.fieldInvalidFromEmail") + ": " + err.Error()) - } - msg, err := a.manager.NewCampaignMessage(&testCamp, dummySubscriber) - if err != nil { - return c, errors.New(a.i18n.Ts("campaigns.fieldInvalidFromEmail") + ": " + err.Error()) - } - fromEmail = msg.From() - } - - if !reFromAddress.Match([]byte(fromEmail)) { - if _, err := a.importer.SanitizeEmail(fromEmail); err != nil { - return c, errors.New(a.i18n.T("campaigns.fieldInvalidFromEmail")) - } + } else if !models.HasTplExpr(c.FromEmail) && !reFromAddress.Match([]byte(c.FromEmail)) { + if _, err := a.importer.SanitizeEmail(c.FromEmail); err != nil { + return c, errors.New(a.i18n.T("campaigns.fieldInvalidFromEmail")) } } diff --git a/internal/manager/message.go b/internal/manager/message.go index 3d68dae9e..c9ffda328 100644 --- a/internal/manager/message.go +++ b/internal/manager/message.go @@ -101,11 +101,6 @@ func (m *CampaignMessage) Subject() string { return m.subject } -// From returns the rendered From header. -func (m *CampaignMessage) From() string { - return m.from -} - // Body returns a copy of the message body. func (m *CampaignMessage) Body() []byte { out := make([]byte, len(m.body)) diff --git a/models/campaigns.go b/models/campaigns.go index 067f86d4f..d8384bd34 100644 --- a/models/campaigns.go +++ b/models/campaigns.go @@ -140,34 +140,15 @@ func (camps Campaigns) LoadStats(stmt *sqlx.Stmt) error { // CompileTemplate compiles a campaign body template into its base // template and sets the resultant template to Campaign.Tpl. func (c *Campaign) CompileTemplate(f template.FuncMap) error { - // If the subject line has a template string, compile it. - if hasTplExpr(c.Subject) { - subj := c.Subject - for _, r := range regTplFuncs { - subj = r.regExp.ReplaceAllString(subj, r.replace) - } - - var txtFuncs map[string]any = f - subjTpl, err := txttpl.New(ContentTpl).Funcs(txtFuncs).Parse(subj) - if err != nil { - return fmt.Errorf("error compiling subject: %v", err) - } - c.SubjectTpl = subjTpl + var err error + c.SubjectTpl, err = compileTxtTpl("subject", c.Subject, f) + if err != nil { + return err } - // If the From header has a template string, compile it. - if hasTplExpr(c.FromEmail) { - from := c.FromEmail - for _, r := range regTplFuncs { - from = r.regExp.ReplaceAllString(from, r.replace) - } - - var txtFuncs map[string]any = f - fromTpl, err := txttpl.New(ContentTpl).Funcs(txtFuncs).Parse(from) - if err != nil { - return fmt.Errorf("error compiling from: %v", err) - } - c.FromEmailTpl = fromTpl + c.FromEmailTpl, err = compileTxtTpl("from", c.FromEmail, f) + if err != nil { + return err } // Compile the base template. @@ -213,7 +194,7 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error { } c.Tpl = out - if hasTplExpr(c.AltBody.String) { + if HasTplExpr(c.AltBody.String) { b := c.AltBody.String for _, r := range regTplFuncs { b = r.regExp.ReplaceAllString(b, r.replace) @@ -225,42 +206,45 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error { c.AltBodyTpl = bTpl } - // Compile any header values that contain template expressions. - for _, set := range c.Headers { - for _, val := range set { - if hasTplExpr(val) { + for i, set := range c.Headers { + for hdr, val := range set { + if !HasTplExpr(val) { + continue + } + + tpl, err := compileTxtTpl(fmt.Sprintf("header %q", hdr), val, f) + if err != nil { + return err + } + + if c.HeaderTpls == nil { c.HeaderTpls = make([]map[string]*txttpl.Template, len(c.Headers)) - break } - } - if c.HeaderTpls != nil { - break - } - } - if c.HeaderTpls != nil { - var txtFuncs map[string]any = f - for i, set := range c.Headers { - c.HeaderTpls[i] = make(map[string]*txttpl.Template, len(set)) - for hdr, val := range set { - if !hasTplExpr(val) { - continue - } - tpl, err := txttpl.New(ContentTpl).Funcs(txtFuncs).Parse(val) - if err != nil { - return fmt.Errorf("error compiling header %q: %v", hdr, err) - } - c.HeaderTpls[i][hdr] = tpl + if c.HeaderTpls[i] == nil { + c.HeaderTpls[i] = make(map[string]*txttpl.Template) } + c.HeaderTpls[i][hdr] = tpl } } return nil } -// hasTplExpr checks whether a given string has a Go template expression with {{ and }}. -func hasTplExpr(s string) bool { - _, after, ok := strings.Cut(s, "{{") - return ok && strings.Contains(after, "}}") +func compileTxtTpl(label, val string, f template.FuncMap) (*txttpl.Template, error) { + if !HasTplExpr(val) { + return nil, nil + } + + for _, r := range regTplFuncs { + val = r.regExp.ReplaceAllString(val, r.replace) + } + + tpl, err := txttpl.New(ContentTpl).Funcs(f).Parse(val) + if err != nil { + return nil, fmt.Errorf("error compiling %s: %v", label, err) + } + + return tpl, nil } // ConvertContent converts a campaign's body from one format to another, @@ -286,3 +270,9 @@ func (c *Campaign) ConvertContent(from, to string) (string, error) { return out, nil } + +// HasTplExpr checks whether a given string has a Go template expression with {{ and }}. +func HasTplExpr(s string) bool { + _, after, ok := strings.Cut(s, "{{") + return ok && strings.Contains(after, "}}") +} diff --git a/models/messages.go b/models/messages.go index 7914dde53..9cb6c3ca1 100644 --- a/models/messages.go +++ b/models/messages.go @@ -86,7 +86,7 @@ func (m *TxMessage) Render(sub Subscriber, tpl *Template, funcs txttpl.FuncMap) b.Reset() // Render alt body if it has any templating strings. - if m.AltBody != "" && hasTplExpr(m.AltBody) { + if m.AltBody != "" && HasTplExpr(m.AltBody) { t, err := txttpl.New(BaseTpl).Funcs(funcs).Parse(m.AltBody) if err != nil { return fmt.Errorf("error compiling alt body: %v", err) @@ -104,7 +104,7 @@ func (m *TxMessage) Render(sub Subscriber, tpl *Template, funcs txttpl.FuncMap) subject = m.Subject ) if subject != "" { - if hasTplExpr(m.Subject) { + if HasTplExpr(m.Subject) { // If the subject has a template string, render that. s, err := txttpl.New(BaseTpl).Funcs(funcs).Parse(m.Subject) if err != nil { diff --git a/models/templates.go b/models/templates.go index 7d45f21eb..1605b06ac 100644 --- a/models/templates.go +++ b/models/templates.go @@ -44,10 +44,10 @@ func (t *Template) Compile(f template.FuncMap) error { t.Tpl = tpl // If the subject line has a template string, compile it. - if hasTplExpr(t.Subject) { + if HasTplExpr(t.Subject) { subj := t.Subject - subjTpl, err := txttpl.New(BaseTpl).Funcs(txttpl.FuncMap(f)).Parse(subj) + subjTpl, err := txttpl.New(BaseTpl).Funcs(f).Parse(subj) if err != nil { return fmt.Errorf("error compiling subject: %v", err) }