feat: render campaign, placement and preview HTML bodies through html/template so every contact, custom-field, AI and sender value is escaped for its context (text, attribute, URL scheme), keep the mailbox signature and Outlook conditional comments as written, escape the plain-substitution fallback with a context scanner under the 1 MB cap, and leave subject and plain text unescaped

This commit is contained in:
Matthew Meszaros
2026-10-23 16:07:48 +02:00
parent 1a77dbbd2b
commit 54e6910bb1
9 changed files with 494 additions and 22 deletions
+5 -1
View File
@@ -172,7 +172,11 @@ Warmbly guards against broken templates at three points:
- A campaign won't start if any step's template is malformed. You get an error naming the step, so recipients never receive raw `{{if ...}}` text.
- If a broken template ever reaches sending, it falls back to plain variable substitution rather than failing the send.
Templates also have limits, so one template cannot tie up the server. `{{range}}` only loops over a field of the data (`{{range .items}}`), never over a number or a function's result, and loops nest at most two deep. `{{template}}` and `{{block}}` are not supported. A template that breaks one of these rules is treated as malformed, and one whose output passes 1 MB falls back to plain substitution.
Templates also have limits, so one template cannot tie up the server. `{{range}}` only loops over a field of the data (`{{range .items}}`), never over a number or a function's result, and loops nest at most two deep. `{{template}}`, `{{block}}` and `call` are not supported, a template declares at most 64 variables, and a function takes at most 32 arguments. A template that breaks one of these rules is treated as malformed. A render that passes 1 MB of output or too many loop steps falls back to plain substitution, and that fallback stays within the same 1 MB.
## Values in the HTML body
In the HTML body every variable is escaped for the place it lands, so a contact whose name is `Tom & <b>Jerry</b>` reads exactly that way instead of turning bold. A variable inside a link (`<a href="{{.website}}">`) keeps web, `mailto:` and `tel:` addresses and is replaced with a harmless `#ZgotmplZ` for anything else, and a value placed in a link's query is URL-encoded. The subject and the plain-text part are sent as written. The mailbox signature (`{{.Sender.SignatureHTML}}`) is your own markup and is kept as is.
Always check Preview and send yourself a test email before launching.
+3 -2
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"html"
"regexp"
"strings"
"time"
@@ -451,7 +452,7 @@ func BuildAIVariablePrompt(vc generation.VoiceContext, contact models.Contact, i
}
// substituteAIVariables replaces resolved blocks in all three strings. In HTML
// the WHOLE span is swapped for the text (config never ships to the recipient);
// the WHOLE span is swapped for the escaped text (config never ships to the recipient);
// in plain text and subject the bare [[ai:ID]] token is swapped. Any leftover
// [[ai:ID]] with no resolved entry is replaced with "" (missingkey=zero).
func substituteAIVariables(subject, bodyHTML, bodyPlain string, resolved map[string]string) (string, string, string) {
@@ -460,7 +461,7 @@ func substituteAIVariables(subject, bodyHTML, bodyPlain string, resolved map[str
if len(m) < 2 {
return ""
}
return resolved[m[1]] // "" when unresolved
return html.EscapeString(resolved[m[1]]) // "" when unresolved
})
bodyPlain = replaceAIVarTokens(bodyPlain, resolved)
subject = replaceAIVarTokens(subject, resolved)
+2 -2
View File
@@ -630,7 +630,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) (result *errx
// {a|b|c} spintax per-recipient (only real |-groups; literal braces/CSS are
// left intact) so each send varies for deliverability.
subject := expandSpintax(RenderTemplateWith(rawSubject, *contact, extra))
bodyHTML := expandSpintax(RenderTemplateWith(rawBodyHTML, *contact, extra))
bodyHTML := expandSpintax(RenderHTMLTemplateWith(rawBodyHTML, *contact, extra))
bodyPlain := expandSpintax(RenderTemplateWith(rawBodyPlain, *contact, extra))
// A recorded conversation subject is already rendered: reuse it verbatim.
if threadParent != nil && threadParent.SubjectSent && threadSubject != "" {
@@ -658,7 +658,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) (result *errx
if threadSubject == "" {
subject = expandSpintax(RenderTemplateWith(selection.Subject, *contact, extra))
}
bodyHTML = expandSpintax(RenderTemplateWith(selection.BodyHTML, *contact, extra))
bodyHTML = expandSpintax(RenderHTMLTemplateWith(selection.BodyHTML, *contact, extra))
bodyPlain = expandSpintax(RenderTemplateWith(selection.BodyPlain, *contact, extra))
// A variant may carry HTML only; keep the plain-text alternative.
if bodyPlain == "" && bodyHTML != "" {
+1 -1
View File
@@ -276,7 +276,7 @@ func (s *tasksService) renderPlacementBase(ctx context.Context, test *models.Pla
}
extra := templateContext(account, unsubscribeURL)
subject := expandSpintax(RenderTemplateWith(rawSubject, contact, extra))
bodyHTML := expandSpintax(RenderTemplateWith(rawHTML, contact, extra))
bodyHTML := expandSpintax(RenderHTMLTemplateWith(rawHTML, contact, extra))
bodyPlain := expandSpintax(RenderTemplateWith(rawPlain, contact, extra))
if bodyPlain == "" && bodyHTML != "" {
bodyPlain = ExtractPlainTextFromHTML(bodyHTML)
+60 -8
View File
@@ -183,19 +183,71 @@ func RenderTemplateWith(tmpl string, contact models.Contact, context TemplateCon
return strings.ReplaceAll(out, "<no value>", "")
}
// Recover standalone actions with the same engine, leaving broken control syntax literal.
// RenderHTMLTemplateWith renders an HTML body: every value is escaped for the
// context it lands in (text, attribute, URL, CSS), so contact data can never
// add markup or a script: link. Template text, comments included, is kept.
func RenderHTMLTemplateWith(tmpl string, contact models.Contact, context TemplateContext) string {
if !strings.Contains(tmpl, "{{") {
return tmpl
}
data := buildTemplateData(contact, context)
if body := compiledHTMLTemplate(tmpl); body != nil {
if out, err := tmplfuncs.Execute(body.t, data); err == nil {
return body.restore(out)
}
}
return fallbackRenderHTML(tmpl, data)
}
// Recover standalone actions with the same engine, leaving broken control
// syntax literal; all of them share one MaxOutput budget.
func fallbackRenderTemplate(tmpl string, data map[string]any) string {
return templateAction.ReplaceAllStringFunc(tmpl, func(action string) string {
return fallbackRender(tmpl, data, nil)
}
// fallbackRenderHTML is fallbackRenderTemplate with each value escaped for
// where it sits in the document.
func fallbackRenderHTML(tmpl string, data map[string]any) string {
return fallbackRender(tmpl, data, &htmlScanner{})
}
func fallbackRender(tmpl string, data map[string]any, scan *htmlScanner) string {
budget := tmplfuncs.MaxOutput
var b strings.Builder
last := 0
for _, loc := range templateAction.FindAllStringIndex(tmpl, -1) {
literal, action := tmpl[last:loc[0]], tmpl[loc[0]:loc[1]]
b.WriteString(literal)
last = loc[1]
var at htmlPosition
if scan != nil {
scan.feed(literal)
at = scan.position()
scan.sawValue()
}
t := compiledTemplate(action)
if t == nil {
return action
b.WriteString(action)
continue
}
out, err := tmplfuncs.Execute(t, data)
out, err := tmplfuncs.ExecuteLimit(t, data, budget)
if err != nil {
return action
b.WriteString(action)
continue
}
return strings.ReplaceAll(out, "<no value>", "")
})
out = strings.ReplaceAll(out, "<no value>", "")
if scan != nil {
out = at.escape(out)
}
if len(out) > budget {
b.WriteString(action)
continue
}
budget -= len(out)
b.WriteString(out)
}
b.WriteString(tmpl[last:])
return b.String()
}
// TemplatePreview is the result of rendering a campaign template against one
@@ -233,7 +285,7 @@ func PreviewTemplates(subject, bodyHTML, bodyPlain string, contact models.Contac
func previewTemplatesWith(subject, bodyHTML, bodyPlain string, contact models.Contact, context TemplateContext) TemplatePreview {
p := TemplatePreview{
Subject: expandSpintax(RenderTemplateWith(subject, contact, context)),
BodyHTML: expandSpintax(RenderTemplateWith(bodyHTML, contact, context)),
BodyHTML: expandSpintax(RenderHTMLTemplateWith(bodyHTML, contact, context)),
BodyPlain: expandSpintax(RenderTemplateWith(bodyPlain, contact, context)),
}
for _, f := range []struct{ name, raw string }{{"subject", subject}, {"body", bodyHTML}, {"plain text", bodyPlain}} {
+269
View File
@@ -0,0 +1,269 @@
package tasks
import (
"html"
htmltemplate "html/template"
"regexp"
"strconv"
"strings"
"github.com/warmbly/warmbly/internal/pkg/tmplfuncs"
)
// htmlBody is a compiled HTML body plus the comments shielded from the escaper,
// which would otherwise strip them (Outlook's conditional comments among them).
type htmlBody struct {
t *htmltemplate.Template
comments []string
}
var htmlTmplCache tmplfuncs.TypedCache[*htmlBody]
var htmlComment = regexp.MustCompile(`(?s)<!--.*?-->`)
// Placeholders hold NUL, which the escaper never emits and the source is cleared of.
const (
shieldOpen = "\x00wo\x00"
shieldClose = "\x00wc\x00"
shieldBang = "\x00wb\x00"
)
var shieldToken = regexp.MustCompile("\x00w(o|c|b|[0-9]+)\x00")
// shieldComments swaps each comment for a placeholder; a comment holding an
// action keeps its body in place, so that action is escaped like any other.
func shieldComments(src string) (string, []string) {
var comments []string
out := htmlComment.ReplaceAllStringFunc(src, func(c string) string {
if strings.Contains(c, "{{") {
return shieldOpen + strings.ReplaceAll(c[4:len(c)-3], "<![", shieldBang) + shieldClose
}
comments = append(comments, c)
return "\x00w" + strconv.Itoa(len(comments)-1) + "\x00"
})
return out, comments
}
func (h *htmlBody) restore(out string) string {
out = strings.ReplaceAll(out, "&lt;no value&gt;", "")
if !strings.Contains(out, "\x00w") {
return out
}
return shieldToken.ReplaceAllStringFunc(out, func(tok string) string {
switch tok[2] {
case 'o':
return "<!--"
case 'c':
return "-->"
case 'b':
return "<!["
}
i, err := strconv.Atoi(tok[2 : len(tok)-1])
if err != nil || i >= len(h.comments) {
return ""
}
return h.comments[i]
})
}
// compiledHTMLTemplate returns the cached HTML body for tmpl, nil when it does not compile.
func compiledHTMLTemplate(tmpl string) *htmlBody {
if body, ok := htmlTmplCache.Load(tmpl); ok {
return body
}
src, comments := shieldComments(strings.ReplaceAll(tmpl, "\x00", ""))
t, err := tmplfuncs.CompileHTML("body", rewriteSpacedFieldRefs(src))
if err != nil {
htmlTmplCache.Store(tmpl, nil)
return nil
}
body := &htmlBody{t: t, comments: comments}
htmlTmplCache.Store(tmpl, body)
return body
}
type htmlState int
const (
inText htmlState = iota
inComment
inTag
inQuotedValue
inUnquotedValue
inRawText
)
// htmlScanner tracks where a fallback render is in the document, so each value
// it inserts is escaped for that place.
type htmlScanner struct {
state htmlState
quote byte
name strings.Builder
attr string
tag string
started bool
}
// htmlPosition is where one value lands.
type htmlPosition struct {
state htmlState
attr string
tag string
started bool
}
func (s *htmlScanner) position() htmlPosition {
return htmlPosition{state: s.state, attr: s.attr, tag: s.tag, started: s.started}
}
// sawValue records that a value was written into the current attribute.
func (s *htmlScanner) sawValue() {
if s.state == inQuotedValue {
s.started = true
}
}
func isLetter(c byte) bool { return c|0x20 >= 'a' && c|0x20 <= 'z' }
func (s *htmlScanner) feed(text string) {
for i := 0; i < len(text); i++ {
c := text[i]
switch s.state {
case inText:
switch {
case strings.HasPrefix(text[i:], "<!--"):
s.state, i = inComment, i+3
case c == '<' && i+1 < len(text) && (isLetter(text[i+1]) || text[i+1] == '/' && i+2 < len(text) && isLetter(text[i+2])):
j := i + 1
if text[j] == '/' {
j++
}
k := j
for k < len(text) && (isLetter(text[k]) || text[k] >= '0' && text[k] <= '9') {
k++
}
s.tag = strings.ToLower(text[j:k])
if text[i+1] == '/' {
s.tag = ""
}
s.state, s.attr, i = inTag, "", k-1
s.name.Reset()
}
case inComment:
if strings.HasPrefix(text[i:], "-->") {
s.state, i = inText, i+2
}
case inTag:
switch {
case c == '>':
s.state = inText
if s.tag == "script" || s.tag == "style" || s.tag == "textarea" || s.tag == "title" {
s.state = inRawText
}
case c == '=':
s.attr = strings.ToLower(strings.TrimSpace(s.name.String()))
s.name.Reset()
j := i + 1
for j < len(text) && (text[j] == ' ' || text[j] == '\t' || text[j] == '\n' || text[j] == '\r') {
j++
}
if j < len(text) && (text[j] == '"' || text[j] == '\'') {
s.state, s.quote, s.started, i = inQuotedValue, text[j], false, j
} else {
s.state, i = inUnquotedValue, j-1
}
case c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '/':
s.name.Reset()
default:
s.name.WriteByte(c)
}
case inQuotedValue:
if c == s.quote {
s.state, s.attr = inTag, ""
} else if c != ' ' && c != '\t' && c != '\n' && c != '\r' {
s.started = true
}
case inUnquotedValue:
switch c {
case ' ', '\t', '\n', '\r':
s.state, s.attr = inTag, ""
case '>':
s.state, s.attr = inTag, ""
i--
}
case inRawText:
if c == '<' && strings.HasPrefix(strings.ToLower(text[i:]), "</"+s.tag) {
s.state, s.tag, i = inTag, "", i+1+len(s.tag)
s.name.Reset()
}
}
}
}
var urlAttrs = map[string]bool{
"href": true, "src": true, "action": true, "formaction": true, "background": true,
"poster": true, "cite": true, "data": true, "codebase": true, "longdesc": true,
"usemap": true, "srcset": true, "xlink:href": true, "lowsrc": true, "dynsrc": true,
}
// escape writes v for its position: text, titles and plain attributes are
// HTML-escaped, a URL attribute refuses any scheme but http, https, mailto and
// tel, and a value inside a tag, a script, a style or an event handler is dropped.
func (p htmlPosition) escape(v string) string {
switch p.state {
case inText, inComment:
return html.EscapeString(v)
case inQuotedValue:
switch {
case strings.HasPrefix(p.attr, "on"), p.attr == "style", p.attr == "srcdoc":
return ""
case urlAttrs[p.attr] && !p.started:
return html.EscapeString(safeURL(v))
case urlAttrs[p.attr]:
return escapeURLPart(v)
}
return html.EscapeString(v)
case inRawText:
if p.tag == "title" || p.tag == "textarea" {
return html.EscapeString(v)
}
}
return ""
}
// safeURL keeps a URL whose scheme is http, https, mailto or tel, or that has
// none; anything else becomes the inert fragment html/template uses.
func safeURL(v string) string {
u := strings.TrimSpace(v)
end := strings.IndexAny(u, "/?#")
if end < 0 {
end = len(u)
}
colon := strings.IndexByte(u[:end], ':')
if colon < 0 {
return u
}
switch strings.ToLower(u[:colon]) {
case "http", "https", "mailto", "tel":
return u
}
return "#ZgotmplZ"
}
// escapeURLPart percent-encodes everything but unreserved characters, so a
// value placed after the start of a URL can add no scheme, query or markup.
func escapeURLPart(v string) string {
const hex = "0123456789ABCDEF"
var b strings.Builder
for i := 0; i < len(v); i++ {
c := v[i]
if isLetter(c) || c >= '0' && c <= '9' || c == '-' || c == '.' || c == '_' || c == '~' {
b.WriteByte(c)
continue
}
b.WriteByte('%')
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&15])
}
return b.String()
}
+140
View File
@@ -0,0 +1,140 @@
package tasks
import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/tmplfuncs"
)
const markupName = `Tom & "Jerry" <b>'s`
func htmlContact(name string) models.Contact {
return models.Contact{FirstName: name, Company: "Acme", CustomFields: map[string]string{
"website": "https://acme.com/?a=1&b=2",
"evil": "javascript:alert(1)",
"Evil2": " JaVaScRiPt:alert(1)",
"job": "Head <of> Sales",
"job title": "VP & CTO",
}}
}
func TestRenderHTMLTemplateEscapesValuesInContext(t *testing.T) {
sender := TemplateContext{
Sender: templateContext(&models.Email{SignatureHTML: "<b>Ana</b>"}, "").Sender,
UnsubscribeLink: "https://example.com/unsubscribe/tok_123",
}
cases := []struct {
name, tmpl, first, want string
}{
{"plain name", `<p>Hi {{.FirstName}},</p>`, "Ann", `<p>Hi Ann,</p>`},
{"markup in a name", `<p>Hi {{.FirstName}},</p>`, markupName, `<p>Hi Tom &amp; &#34;Jerry&#34; &lt;b&gt;&#39;s,</p>`},
{"markup in an attribute", `<img alt="{{.FirstName}}">`, markupName, `<img alt="Tom &amp; &#34;Jerry&#34; &lt;b&gt;&#39;s">`},
{"custom field", `<p>{{.job}} at {{.Company}}</p>`, "Ann", `<p>Head &lt;of&gt; Sales at Acme</p>`},
{"spaced custom field", `<p>{{.job title}}</p>`, "Ann", `<p>VP &amp; CTO</p>`},
{"href keeps a web link", `<a href="{{.website}}">site</a>`, "Ann", `<a href="https://acme.com/?a=1&amp;b=2">site</a>`},
{"href refuses a script link", `<a href="{{.evil}}">x</a>`, "Ann", `<a href="#ZgotmplZ">x</a>`},
{"href refuses a padded script link", `<a href="{{.Evil2}}">x</a>`, "Ann", `<a href="#ZgotmplZ">x</a>`},
{"value in a query", `<a href="https://acme.com/?n={{.FirstName}}">x</a>`, "Ann Lee", `<a href="https://acme.com/?n=Ann%20Lee">x</a>`},
{"unsubscribe link", `<a href="{{.UnsubscribeLink}}">Unsubscribe</a> {{.UnsubscribeLink}}`, "Ann", `<a href="https://example.com/unsubscribe/tok_123">Unsubscribe</a> https://example.com/unsubscribe/tok_123`},
{"signature stays markup", `<p>{{.FirstName}}</p>{{.Sender.SignatureHTML}}`, "Ann", `<p>Ann</p><b>Ana</b>`},
{"missing key", `<p>[{{.nothing}}]</p>`, "Ann", `<p>[]</p>`},
{"conditional", `{{if eq .FirstName "Tom & \"Jerry\" <b>'s"}}<p>match</p>{{end}}`, markupName, `<p>match</p>`},
{"outlook comments", `<!--[if mso]><table><tr><td><![endif]--><p>{{.FirstName}}</p><!--[if mso]></td></tr></table><![endif]-->`, "Ann",
`<!--[if mso]><table><tr><td><![endif]--><p>Ann</p><!--[if mso]></td></tr></table><![endif]-->`},
{"action inside a comment", `<!--[if mso]><v:roundrect href="{{.evil}}"><![endif]--><p>{{.FirstName}}</p>`, "Ann",
`<!--[if mso]><v:roundrect href="#ZgotmplZ"><![endif]--><p>Ann</p>`},
{"no actions is untouched", `<p>1 < 2 &amp; a+b</p><!-- note -->`, "Ann", `<p>1 < 2 &amp; a+b</p><!-- note -->`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := RenderHTMLTemplateWith(tc.tmpl, htmlContact(tc.first), sender); got != tc.want {
t.Fatalf("got %q\nwant %q", got, tc.want)
}
})
}
}
func TestRenderHTMLTemplateFallbackEscapes(t *testing.T) {
cases := []struct{ name, tmpl, want string }{
{"text", `{{if .Company}}<p>{{.FirstName}}</p>`, `{{if .Company}}<p>Tom &amp; &#34;Jerry&#34; &lt;b&gt;&#39;s</p>`},
{"attribute", `{{if .Company}}<p title="{{.FirstName}}">`, `{{if .Company}}<p title="Tom &amp; &#34;Jerry&#34; &lt;b&gt;&#39;s">`},
{"script link", `{{if .Company}}<a href="{{.evil}}">x</a>`, `{{if .Company}}<a href="#ZgotmplZ">x</a>`},
{"web link", `{{if .Company}}<a href='{{.website}}'>x</a>`, `{{if .Company}}<a href='https://acme.com/?a=1&amp;b=2'>x</a>`},
{"split link", `{{if .Company}}<a href="{{.FirstName}}{{.evil}}">x</a>`, `{{if .Company}}<a href="Tom &amp; &#34;Jerry&#34; &lt;b&gt;&#39;sjavascript%3Aalert%281%29">x</a>`},
{"event handler", `{{if .Company}}<p onclick="{{.FirstName}}">`, `{{if .Company}}<p onclick="">`},
{"inside a tag", `{{if .Company}}<p {{.FirstName}}>`, `{{if .Company}}<p >`},
{"script", `{{if .Company}}<script>var n = "{{.FirstName}}"</script>`, `{{if .Company}}<script>var n = ""</script>`},
{"after a comment", `{{if .Company}}<!-- <a href=" --><p>{{.FirstName}}</p>`, `{{if .Company}}<!-- <a href=" --><p>Tom &amp; &#34;Jerry&#34; &lt;b&gt;&#39;s</p>`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := RenderHTMLTemplateWith(tc.tmpl, htmlContact(markupName), TemplateContext{}); got != tc.want {
t.Fatalf("got %q\nwant %q", got, tc.want)
}
})
}
}
func TestRenderTemplatePlainPartIsNotEscaped(t *testing.T) {
if got := RenderTemplateWith(`Hi {{.FirstName}}, {{.website}}`, htmlContact(markupName), TemplateContext{}); got != `Hi `+markupName+`, https://acme.com/?a=1&b=2` {
t.Fatalf("plain text was altered: %q", got)
}
}
func TestRenderHTMLTemplateWithSpintax(t *testing.T) {
got := expandSpintax(RenderHTMLTemplateWith(`<p>{Hi|Hi} {{.FirstName}}</p>`, htmlContact("<i>{a|a}</i>"), TemplateContext{}))
if got != `<p>Hi &lt;i&gt;a&lt;/i&gt;</p>` {
t.Fatalf("got %q", got)
}
}
func TestRenderHTMLTemplateThenTrackLinks(t *testing.T) {
body := RenderHTMLTemplateWith(`<a href="{{.website}}">site</a> <a href="{{.evil}}">x</a>`, htmlContact("Ann"), TemplateContext{})
_, links := WrapLinksForTracking(body, uuid.New(), uuid.New(), "track.example.com")
if len(links) != 1 || links[0].Destination != "https://acme.com/?a=1&b=2" {
t.Fatalf("tracked %+v from %q", links, body)
}
}
func TestRenderHTMLTemplateThenLinkifyUnsubscribe(t *testing.T) {
link := "https://example.com/unsubscribe/tok_123"
body := RenderHTMLTemplateWith(`<p>Opt out: {{.UnsubscribeLink}}</p>`, htmlContact("Ann"), TemplateContext{UnsubscribeLink: link})
if got := linkifyUnsubscribeURL(body, link, "Unsubscribe"); got != `<p>Opt out: <a href="`+link+`">Unsubscribe</a></p>` {
t.Fatalf("got %q", got)
}
}
func TestRenderTemplateOutputCapHoldsOnFallback(t *testing.T) {
big := strings.Repeat("x", tmplfuncs.MaxOutput/4)
contact := models.Contact{CustomFields: map[string]string{"big": big}}
for _, tmpl := range []string{
strings.Repeat("{{.big}}", 10),
"{{if .big}}" + strings.Repeat("{{.big}}", 10),
} {
for name, render := range map[string]func(string, models.Contact, TemplateContext) string{
"text": RenderTemplateWith, "html": RenderHTMLTemplateWith,
} {
out := render(tmpl, contact, TemplateContext{})
if len(out) > tmplfuncs.MaxOutput+len(tmpl) {
t.Fatalf("%s render wrote %d bytes", name, len(out))
}
if !strings.Contains(out, big) {
t.Fatalf("%s render dropped every value", name)
}
}
}
}
func TestSubstituteAIVariablesEscapesHTMLOnly(t *testing.T) {
span := `<span data-ai-var="a1" data-ai-config="e30=">[[ai:a1]]</span>`
subject, bodyHTML, bodyPlain := substituteAIVariables("Re [[ai:a1]]", "<p>"+span+"</p>", "Hi [[ai:a1]]", map[string]string{"a1": `<img src=x onerror=alert(1)> & co`})
if bodyHTML != `<p>&lt;img src=x onerror=alert(1)&gt; &amp; co</p>` {
t.Fatalf("html: %q", bodyHTML)
}
if bodyPlain != `Hi <img src=x onerror=alert(1)> & co` || subject != `Re <img src=x onerror=alert(1)> & co` {
t.Fatalf("plain %q subject %q", bodyPlain, subject)
}
}
+10 -8
View File
@@ -1,6 +1,7 @@
package tasks
import (
htmltemplate "html/template"
"strings"
"time"
@@ -9,13 +10,14 @@ import (
// TemplateSender is an explicit allowlist: never embed the mailbox model or its methods.
type TemplateSender struct {
Name string
Email string
MailboxEmail string
SendAsEmail string
ReplyTo string
SignaturePlain string
SignatureHTML string
Name string
Email string
MailboxEmail string
SendAsEmail string
ReplyTo string
SignaturePlain string
// SignatureHTML is the mailbox's own markup, so an HTML body emits it as written.
SignatureHTML htmltemplate.HTML
SignatureSync bool
SignatureCode bool
Provider string
@@ -73,7 +75,7 @@ func templateContext(account *models.Email, unsubscribeURL string) TemplateConte
SendAsEmail: strings.TrimSpace(account.SendAsEmail),
ReplyTo: account.ReplyToHeader(),
SignaturePlain: account.SignaturePlain,
SignatureHTML: account.SignatureHTML,
SignatureHTML: htmltemplate.HTML(strings.ReplaceAll(account.SignatureHTML, "\x00", "")),
SignatureSync: account.SignatureSync,
SignatureCode: account.SignatureCode,
Provider: account.Provider,
+4
View File
@@ -1,6 +1,7 @@
package tasks
import (
htmltemplate "html/template"
"reflect"
"strings"
"testing"
@@ -174,6 +175,9 @@ func TestTemplateSender_MapsEveryAllowedMailboxField(t *testing.T) {
if name == "ReplyTo" {
want = account.ReplyToHeader()
}
if name == "SignatureHTML" {
want = htmltemplate.HTML(account.SignatureHTML)
}
if !reflect.DeepEqual(sender.Field(i).Interface(), want) {
t.Errorf("Sender.%s = %v, want %v", name, sender.Field(i).Interface(), want)
}