Hi {{.FirstName}},
`, "Ann", `Hi Ann,
`}, + {"markup in a name", `Hi {{.FirstName}},
`, markupName, `Hi Tom & "Jerry" <b>'s,
`}, + {"markup in an attribute", `{{.job}} at {{.Company}}
`, "Ann", `Head <of> Sales at Acme
`}, + {"spaced custom field", `{{.job title}}
`, "Ann", `VP & CTO
`}, + {"href keeps a web link", `site`, "Ann", `site`}, + {"href refuses a script link", `x`, "Ann", `x`}, + {"href refuses a padded script link", `x`, "Ann", `x`}, + {"value in a query", `x`, "Ann Lee", `x`}, + {"unsubscribe link", `Unsubscribe {{.UnsubscribeLink}}`, "Ann", `Unsubscribe https://example.com/unsubscribe/tok_123`}, + {"signature stays markup", `{{.FirstName}}
{{.Sender.SignatureHTML}}`, "Ann", `Ann
Ana`}, + {"missing key", `[{{.nothing}}]
`, "Ann", `[]
`}, + {"conditional", `{{if eq .FirstName "Tom & \"Jerry\" 's"}}match
{{end}}`, markupName, `match
`}, + {"outlook comments", `{{.FirstName}}
`, "Ann", + `Ann
`}, + {"action inside a comment", `{{.FirstName}}
`, "Ann", + `Ann
`}, + {"no actions is untouched", `1 < 2 & a+b
`, "Ann", `1 < 2 & a+b
`}, + } + 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}}{{.FirstName}}
`, `{{if .Company}}Tom & "Jerry" <b>'s
`}, + {"attribute", `{{if .Company}}`, `{{if .Company}}
`}, + {"script link", `{{if .Company}}x`, `{{if .Company}}x`}, + {"web link", `{{if .Company}}x`, `{{if .Company}}x`}, + {"split link", `{{if .Company}}x`, `{{if .Company}}x`}, + {"event handler", `{{if .Company}}
`, `{{if .Company}}
`}, + {"inside a tag", `{{if .Company}}
`, `{{if .Company}}
`}, + {"script", `{{if .Company}}`, `{{if .Company}}`}, + {"after a comment", `{{if .Company}}
{{.FirstName}}
`, `{{if .Company}}Tom & "Jerry" <b>'s
`}, + } + 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(`{Hi|Hi} {{.FirstName}}
`, htmlContact("{a|a}"), TemplateContext{})) + if got != `Hi <i>a</i>
` { + t.Fatalf("got %q", got) + } +} + +func TestRenderHTMLTemplateThenTrackLinks(t *testing.T) { + body := RenderHTMLTemplateWith(`site x`, 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(`Opt out: {{.UnsubscribeLink}}
`, htmlContact("Ann"), TemplateContext{UnsubscribeLink: link}) + if got := linkifyUnsubscribeURL(body, link, "Unsubscribe"); got != `Opt out: Unsubscribe
` { + 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 := `[[ai:a1]]` + subject, bodyHTML, bodyPlain := substituteAIVariables("Re [[ai:a1]]", ""+span+"
", "Hi [[ai:a1]]", map[string]string{"a1": `<img src=x onerror=alert(1)> & co
` { + t.Fatalf("html: %q", bodyHTML) + } + if bodyPlain != `Hi