162 lines
5.3 KiB
Go
162 lines
5.3 KiB
Go
package mail
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Every template is parsed in init(), so a broken one panics the process at
|
|
// boot rather than at send time. This test renders each of them with realistic
|
|
// data, because parsing does not catch a field that does not exist on the data
|
|
// a Send* method actually passes.
|
|
func TestRenderAll(t *testing.T) {
|
|
expires := time.Date(2026, 8, 14, 9, 0, 0, 0, time.UTC)
|
|
|
|
cases := []struct {
|
|
name string
|
|
data any
|
|
subject string
|
|
wants []string
|
|
}{
|
|
{"verification", struct {
|
|
Link string
|
|
TTLHours int
|
|
}{"https://hq.example/verify?token=abc", 24},
|
|
"Verify your Vantage account",
|
|
[]string{"https://hq.example/verify?token=abc", "24 hours"}},
|
|
|
|
{"invite", struct{ AccountName, Link string }{
|
|
"Acme & Co", "https://hq.example/accept-invite?token=abc"},
|
|
"You have been invited to Acme & Co on Vantage",
|
|
[]string{"Acme & Co", "accept-invite"}},
|
|
|
|
{"license", struct{ InstanceName, Blob string }{"acme", "BLOB-123"},
|
|
"Your Vantage licence key", []string{"BLOB-123"}},
|
|
|
|
{"instanceready", struct {
|
|
InstanceName, LoginURL string
|
|
Expires time.Time
|
|
}{"acme", "https://acme.vantage.example", expires},
|
|
"acme is ready", []string{"14 August 2026", "acme.vantage.example"}},
|
|
|
|
{"renewed", struct {
|
|
InstanceName string
|
|
Expires time.Time
|
|
}{"acme", expires}, "acme renewed", []string{"14 August 2026"}},
|
|
|
|
{"expiring", struct {
|
|
InstanceName, PortalURL string
|
|
Expires time.Time
|
|
}{"acme", "https://hq.example/billing", expires},
|
|
"acme expires on 14 August", []string{"hq.example/billing"}},
|
|
|
|
{"expired", struct {
|
|
InstanceName, PortalURL string
|
|
DeleteOn time.Time
|
|
}{"acme", "https://hq.example/billing", expires},
|
|
"acme is now read-only", []string{"read-only", "14 August 2026"}},
|
|
|
|
{"deletionwarning", struct {
|
|
InstanceName, PortalURL, When string
|
|
DeleteOn time.Time
|
|
}{"acme", "https://hq.example/billing", "tomorrow", expires},
|
|
"acme will be deleted tomorrow", []string{"cannot be undone"}},
|
|
|
|
{"linkreminder", struct{ InstanceName, PortalURL string }{
|
|
"acme", "https://hq.example"},
|
|
"Finish setting up acme", []string{"not linked yet"}},
|
|
|
|
{"cancelled", struct{ InstanceName string }{"acme"},
|
|
"Your Vantage subscription is cancelled", []string{"changes are disabled", "cancelled"}},
|
|
|
|
{"pastdue", struct{ InstanceName, PortalURL string }{"acme", "https://hq.example"},
|
|
"Payment failed for your Vantage subscription", []string{"retried"}},
|
|
|
|
{"monitoralert", MonitorEvent{
|
|
MonitorName: "api.example", Type: "http",
|
|
OldStatus: "up", NewStatus: "down",
|
|
Message: "connection refused", Time: expires, Down: true},
|
|
"[Vantage] api.example (http) is DOWN: connection refused",
|
|
[]string{"api.example", "connection refused", "2026-08-14"}},
|
|
|
|
{"contact", struct {
|
|
Enquiry
|
|
Received string
|
|
}{Enquiry{Name: "Ada", Email: "ada@example.com", Servers: "10",
|
|
Topic: "sales", Message: "hello"}, "now"},
|
|
"[Vantage] sales ada@example.com", []string{"ada@example.com", "hello"}},
|
|
}
|
|
|
|
if len(cases) != len(sets) {
|
|
t.Fatalf("%d templates on disk but %d covered", len(sets), len(cases))
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
m, err := render(c.name, c.data)
|
|
if err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
if m.Subject != c.subject {
|
|
t.Errorf("subject = %q, want %q", m.Subject, c.subject)
|
|
}
|
|
if !strings.Contains(m.HTML, "Sent by Vantage") {
|
|
t.Error("html body is missing the layout footer")
|
|
}
|
|
if !strings.Contains(m.HTML, "#071628") {
|
|
t.Error("html body is not on the control plane ground")
|
|
}
|
|
if !strings.Contains(m.Text, "VANTAGE") {
|
|
t.Error("text body is missing the layout header")
|
|
}
|
|
if strings.Contains(m.Text, "<") && strings.Contains(m.Text, "style=") {
|
|
t.Error("markup leaked into the text part")
|
|
}
|
|
for _, want := range c.wants {
|
|
if !strings.Contains(m.HTML, want) && !strings.Contains(m.Text, want) {
|
|
t.Errorf("neither part contains %q", want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A header value carrying CRLF must not be able to start a new header.
|
|
func TestEnvelopeStripsHeaderInjection(t *testing.T) {
|
|
s := Sender{Host: "localhost", Port: "587", From: "vantage@example.com"}
|
|
b, err := s.envelope(message{
|
|
To: "someone@example.com\r\nBcc: attacker@example.com",
|
|
Subject: "hello",
|
|
Text: "body",
|
|
HTML: "<p>body</p>",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("envelope: %v", err)
|
|
}
|
|
if strings.Contains(string(b), "\r\nBcc:") {
|
|
t.Fatal("CRLF in a recipient produced an extra header")
|
|
}
|
|
}
|
|
|
|
// Both parts must be present: a client that refuses HTML should not get a
|
|
// blank message, and a text-only message scores worse with spam filters.
|
|
func TestEnvelopeIsMultipartAlternative(t *testing.T) {
|
|
s := Sender{Host: "localhost", Port: "587", From: "vantage@example.com"}
|
|
b, err := s.envelope(message{To: "a@example.com", Subject: "s", Text: "TEXTBODY", HTML: "<p>HTMLBODY</p>"})
|
|
if err != nil {
|
|
t.Fatalf("envelope: %v", err)
|
|
}
|
|
got := string(b)
|
|
for _, want := range []string{
|
|
"Content-Type: multipart/alternative; boundary=",
|
|
"Message-ID: <", "Date: ",
|
|
"text/plain; charset=utf-8", "text/html; charset=utf-8",
|
|
"TEXTBODY", "HTMLBODY",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("envelope is missing %q", want)
|
|
}
|
|
}
|
|
}
|