diff --git a/docs/docs/content/apis/campaigns.md b/docs/docs/content/apis/campaigns.md index 1dd2a4b55..a299c3c5f 100644 --- a/docs/docs/content/apis/campaigns.md +++ b/docs/docs/content/apis/campaigns.md @@ -304,7 +304,7 @@ Create a new campaign. | messenger | string | | 'email' or a custom messenger defined in settings. Defaults to 'email' if not provided. | | template_id | number | | Template ID to use. Defaults to default template if not provided. | | tags | string\[\] | | Tags to mark campaign. | -| headers | JSON | | Key-value pairs to send as SMTP headers. Example: \[{"x-custom-header": "value"}\]. | +| headers | JSON | | Key-value pairs to send as SMTP headers. Supports template expressions (e.g., `{{ .Subscriber.UUID }}`). Example: \[{"x-custom-header": "value"}, {"x-subscriber": "{{ .Subscriber.UUID }}"}\]. | ##### Example request diff --git a/docs/docs/content/templating.md b/docs/docs/content/templating.md index bea0b29be..a4a5d1c51 100644 --- a/docs/docs/content/templating.md +++ b/docs/docs/content/templating.md @@ -53,6 +53,18 @@ There are several template functions and expressions that can be used in campaig | `{{ OptinURL }}` | URL to the double-optin confirmation page. | | `{{ Safe "" }}` | Add any HTML code as it is. | +### Custom headers +Custom e-mail headers defined in campaign settings also support template expressions. This allows you to include dynamic, subscriber-specific values in headers. + +For example, you can set custom headers like: +```json +[ + {"X-Subscriber-ID": "{{ .Subscriber.UUID }}"}, + {"X-Subscriber-City": "{{ .Subscriber.Attribs.city }}"}, + {"X-Campaign-ID": "{{ .Campaign.UUID }}"} +] +``` + ### Sprig functions listmonk integrates the Sprig library that offers 100+ utility functions for working with strings, numbers, dates etc. that can be used in templating. Refer to the [Sprig documentation](https://masterminds.github.io/sprig/) for the full list of functions. diff --git a/i18n/en.json b/i18n/en.json index c6ed4aef7..e25d9bdf3 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -36,7 +36,7 @@ "campaigns.contentHelp": "Content here", "campaigns.continue": "Continue", "campaigns.copyOf": "Copy of {name}", - "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"value\"}]", + "campaigns.customHeadersHelp": "Array of custom headers to attach to outgoing messages. Supports template expressions (e.g., {{ .Subscriber.Attribs.city }}). eg: [{\"X-Custom\": \"value\"}, {\"X-Custom2\": \"{{ .Subscriber.UUID }}\"}]", "campaigns.dateAndTime": "Date and time", "campaigns.ended": "Ended", "campaigns.errorSendTest": "Error sending test: {error}", diff --git a/internal/manager/manager.go b/internal/manager/manager.go index 4fdf8eb8d..94931a3c4 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -106,6 +106,7 @@ type CampaignMessage struct { body []byte altBody []byte unsubURL string + headers []map[string]string pipe *pipe } @@ -483,9 +484,9 @@ func (m *Manager) worker() { h.Set("List-Unsubscribe", `<`+msg.unsubURL+`>`) } - // Attach any custom headers. - if len(msg.Campaign.Headers) > 0 { - for _, set := range msg.Campaign.Headers { + // Attach any custom headers (with template expressions already rendered). + if len(msg.headers) > 0 { + for _, set := range msg.headers { for hdr, val := range set { h.Add(hdr, val) } diff --git a/internal/manager/message.go b/internal/manager/message.go index 3a9d64c21..6c043c672 100644 --- a/internal/manager/message.go +++ b/internal/manager/message.go @@ -61,6 +61,29 @@ func (m *CampaignMessage) render() error { } } + // Render custom headers with template expressions. + if len(m.Campaign.Headers) > 0 { + m.headers = make([]map[string]string, len(m.Campaign.Headers)) + for i, set := range m.Campaign.Headers { + m.headers[i] = make(map[string]string, len(set)) + for hdr, val := range set { + // Check if there's a compiled template for this header. + if len(m.Campaign.HeaderTpls) > i && m.Campaign.HeaderTpls[i] != nil { + if tpl, ok := m.Campaign.HeaderTpls[i][hdr]; ok { + out.Reset() + if err := tpl.ExecuteTemplate(&out, models.ContentTpl, m); err != nil { + return fmt.Errorf("error rendering header '%s': %v", hdr, err) + } + m.headers[i][hdr] = out.String() + continue + } + } + // No template, use the raw value. + m.headers[i][hdr] = val + } + } + } + return nil } diff --git a/models/campaigns.go b/models/campaigns.go index 10af385f5..c29e668e9 100644 --- a/models/campaigns.go +++ b/models/campaigns.go @@ -60,11 +60,12 @@ type Campaign struct { ArchiveMeta json.RawMessage `db:"archive_meta" json:"archive_meta"` // TemplateBody is joined in from templates by the next-campaigns query. - TemplateBody string `db:"template_body" json:"-"` - ArchiveTemplateBody string `db:"archive_template_body" json:"-"` - Tpl *template.Template `json:"-"` - SubjectTpl *txttpl.Template `json:"-"` - AltBodyTpl *template.Template `json:"-"` + TemplateBody string `db:"template_body" json:"-"` + ArchiveTemplateBody string `db:"archive_template_body" json:"-"` + Tpl *template.Template `json:"-"` + SubjectTpl *txttpl.Template `json:"-"` + AltBodyTpl *template.Template `json:"-"` + HeaderTpls []map[string]*txttpl.Template `json:"-"` // List of media (attachment) IDs obtained from the next-campaign query // while sending a campaign. @@ -205,6 +206,26 @@ func (c *Campaign) CompileTemplate(f template.FuncMap) error { c.AltBodyTpl = bTpl } + // Compile header templates for headers that contain template expressions. + if len(c.Headers) > 0 { + var txtFuncs map[string]any = f + c.HeaderTpls = make([]map[string]*txttpl.Template, len(c.Headers)) + for i, set := range c.Headers { + for hdr, val := range set { + if strings.Contains(val, "{{") { + hdrTpl, err := txttpl.New(ContentTpl).Funcs(txtFuncs).Parse(val) + if err != nil { + return fmt.Errorf("error compiling header '%s': %v", hdr, err) + } + if c.HeaderTpls[i] == nil { + c.HeaderTpls[i] = make(map[string]*txttpl.Template) + } + c.HeaderTpls[i][hdr] = hdrTpl + } + } + } + } + return nil }