mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-31 00:12:37 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}} {
|
||||
|
||||
@@ -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, "<no value>", "")
|
||||
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()
|
||||
}
|
||||
@@ -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 & "Jerry" <b>'s,</p>`},
|
||||
{"markup in an attribute", `<img alt="{{.FirstName}}">`, markupName, `<img alt="Tom & "Jerry" <b>'s">`},
|
||||
{"custom field", `<p>{{.job}} at {{.Company}}</p>`, "Ann", `<p>Head <of> Sales at Acme</p>`},
|
||||
{"spaced custom field", `<p>{{.job title}}</p>`, "Ann", `<p>VP & CTO</p>`},
|
||||
{"href keeps a web link", `<a href="{{.website}}">site</a>`, "Ann", `<a href="https://acme.com/?a=1&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 & a+b</p><!-- note -->`, "Ann", `<p>1 < 2 & 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 & "Jerry" <b>'s</p>`},
|
||||
{"attribute", `{{if .Company}}<p title="{{.FirstName}}">`, `{{if .Company}}<p title="Tom & "Jerry" <b>'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&b=2'>x</a>`},
|
||||
{"split link", `{{if .Company}}<a href="{{.FirstName}}{{.evil}}">x</a>`, `{{if .Company}}<a href="Tom & "Jerry" <b>'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 & "Jerry" <b>'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 <i>a</i></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><img src=x onerror=alert(1)> & 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user