From dc0d6cb2d29e404e7146b5b8ec21108f04bdc729 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 28 Jul 2026 09:50:46 +0100 Subject: [PATCH] feat: Move mail system to shared --- mail/account.go | 37 ++++ mail/billing.go | 18 ++ mail/contact.go | 27 +++ mail/licence.go | 81 ++++++++ mail/monitor.go | 26 +++ mail/render.go | 121 ++++++++++++ mail/render_test.go | 161 +++++++++++++++ mail/sender.go | 241 +++++++++++++++++++++++ mail/templates/cancelled.html.tmpl | 6 + mail/templates/cancelled.txt.tmpl | 7 + mail/templates/contact.html.tmpl | 11 ++ mail/templates/contact.txt.tmpl | 13 ++ mail/templates/deletionwarning.html.tmpl | 7 + mail/templates/deletionwarning.txt.tmpl | 8 + mail/templates/expired.html.tmpl | 8 + mail/templates/expired.txt.tmpl | 9 + mail/templates/expiring.html.tmpl | 7 + mail/templates/expiring.txt.tmpl | 8 + mail/templates/instanceready.html.tmpl | 8 + mail/templates/instanceready.txt.tmpl | 9 + mail/templates/invite.html.tmpl | 7 + mail/templates/invite.txt.tmpl | 8 + mail/templates/layout.html.tmpl | 121 ++++++++++++ mail/templates/layout.txt.tmpl | 46 +++++ mail/templates/license.html.tmpl | 7 + mail/templates/license.txt.tmpl | 8 + mail/templates/linkreminder.html.tmpl | 7 + mail/templates/linkreminder.txt.tmpl | 8 + mail/templates/monitoralert.html.tmpl | 10 + mail/templates/monitoralert.txt.tmpl | 11 ++ mail/templates/pastdue.html.tmpl | 7 + mail/templates/pastdue.txt.tmpl | 8 + mail/templates/renewed.html.tmpl | 6 + mail/templates/renewed.txt.tmpl | 7 + mail/templates/verification.html.tmpl | 7 + mail/templates/verification.txt.tmpl | 8 + 36 files changed, 1089 insertions(+) create mode 100644 mail/account.go create mode 100644 mail/billing.go create mode 100644 mail/contact.go create mode 100644 mail/licence.go create mode 100644 mail/monitor.go create mode 100644 mail/render.go create mode 100644 mail/render_test.go create mode 100644 mail/sender.go create mode 100644 mail/templates/cancelled.html.tmpl create mode 100644 mail/templates/cancelled.txt.tmpl create mode 100644 mail/templates/contact.html.tmpl create mode 100644 mail/templates/contact.txt.tmpl create mode 100644 mail/templates/deletionwarning.html.tmpl create mode 100644 mail/templates/deletionwarning.txt.tmpl create mode 100644 mail/templates/expired.html.tmpl create mode 100644 mail/templates/expired.txt.tmpl create mode 100644 mail/templates/expiring.html.tmpl create mode 100644 mail/templates/expiring.txt.tmpl create mode 100644 mail/templates/instanceready.html.tmpl create mode 100644 mail/templates/instanceready.txt.tmpl create mode 100644 mail/templates/invite.html.tmpl create mode 100644 mail/templates/invite.txt.tmpl create mode 100644 mail/templates/layout.html.tmpl create mode 100644 mail/templates/layout.txt.tmpl create mode 100644 mail/templates/license.html.tmpl create mode 100644 mail/templates/license.txt.tmpl create mode 100644 mail/templates/linkreminder.html.tmpl create mode 100644 mail/templates/linkreminder.txt.tmpl create mode 100644 mail/templates/monitoralert.html.tmpl create mode 100644 mail/templates/monitoralert.txt.tmpl create mode 100644 mail/templates/pastdue.html.tmpl create mode 100644 mail/templates/pastdue.txt.tmpl create mode 100644 mail/templates/renewed.html.tmpl create mode 100644 mail/templates/renewed.txt.tmpl create mode 100644 mail/templates/verification.html.tmpl create mode 100644 mail/templates/verification.txt.tmpl diff --git a/mail/account.go b/mail/account.go new file mode 100644 index 0000000..394e1b6 --- /dev/null +++ b/mail/account.go @@ -0,0 +1,37 @@ +package mail + +import ( + "fmt" + "time" +) + +// VerifyWindow is how long a verification or invitation link stays usable. It +// is stated in the email, so it lives beside the message rather than in the +// caller. +const VerifyWindow = 24 * time.Hour + +// SendVerification asks someone to confirm the address they signed up with. +// The link is built from the Sender's PublicURL, because the address of the +// portal is a property of the deployment, not of the call site. +func (s Sender) SendVerification(to, token string) error { + return s.sendTemplate(to, "", "verification", struct { + Link string + TTLHours int + }{ + Link: fmt.Sprintf("%s/verify?token=%s", s.PublicURL, token), + TTLHours: int(VerifyWindow.Hours()), + }) +} + +// SendInvite asks someone to join an existing account and set their own +// password. It names the account, because an unexpected invitation from a +// service you have never used is otherwise indistinguishable from spam. +func (s Sender) SendInvite(to, accountName, token string) error { + return s.sendTemplate(to, "", "invite", struct { + AccountName string + Link string + }{ + AccountName: accountName, + Link: fmt.Sprintf("%s/accept-invite?token=%s", s.PublicURL, token), + }) +} diff --git a/mail/billing.go b/mail/billing.go new file mode 100644 index 0000000..e987c28 --- /dev/null +++ b/mail/billing.go @@ -0,0 +1,18 @@ +package mail + +// SendCancelled confirms a cancellation and states what stays true: the licence +// keeps working until it expires, then the instance degrades to read-only. +func (s Sender) SendCancelled(to, instanceName string) error { + return s.sendTemplate(to, "", "cancelled", struct { + InstanceName string + }{instanceName}) +} + +// SendPastDue notifies of a failed charge without alarming: the licence is +// untouched while Paddle retries the card. +func (s Sender) SendPastDue(to, instanceName string) error { + return s.sendTemplate(to, "", "pastdue", struct { + InstanceName string + PortalURL string + }{instanceName, s.PublicURL}) +} diff --git a/mail/contact.go b/mail/contact.go new file mode 100644 index 0000000..40db208 --- /dev/null +++ b/mail/contact.go @@ -0,0 +1,27 @@ +package mail + +import "time" + +// Enquiry is one submission of the public site's contact form. +type Enquiry struct { + Name string + Email string + Servers string + Topic string + Message string +} + +// SendEnquiry forwards a contact-form submission to the support address. +// +// Reply-To is the sender's address, not From: the message is sent by our own +// SMTP identity so it passes SPF, but hitting reply must reach the person who +// filled the form in. +func (s Sender) SendEnquiry(to string, e Enquiry) error { + return s.sendTemplate(to, e.Email, "contact", struct { + Enquiry + Received string + }{ + Enquiry: e, + Received: time.Now().UTC().Format(time.RFC1123), + }) +} diff --git a/mail/licence.go b/mail/licence.go new file mode 100644 index 0000000..3bbcf52 --- /dev/null +++ b/mail/licence.go @@ -0,0 +1,81 @@ +package mail + +import ( + "fmt" + "time" +) + +// SendLicense delivers the blob inline. It is signed public data, not a secret — +// it is useless on any instance other than the one it names. +func (s Sender) SendLicense(to, instanceName, blob string) error { + return s.sendTemplate(to, "", "license", struct { + InstanceName string + Blob string + }{instanceName, blob}) +} + +// SendInstanceReady tells a customer their cloud instance exists, where it is, +// and when its licence runs out. +// +// The expiry is stated here rather than only in a later reminder: a Free licence +// that quietly expires in a month is a surprise, and the first email is the one +// people keep. +func (s Sender) SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error { + return s.sendTemplate(to, "", "instanceready", struct { + InstanceName string + LoginURL string + Expires time.Time + }{instanceName, loginURL, expires}) +} + +// SendRenewed confirms a renewal and states the new date. +func (s Sender) SendRenewed(to, instanceName string, expires time.Time) error { + return s.sendTemplate(to, "", "renewed", struct { + InstanceName string + Expires time.Time + }{instanceName, expires}) +} + +// SendExpiring is the renew-now nudge, seven days out. +func (s Sender) SendExpiring(to, instanceName, portalURL string, expires time.Time) error { + return s.sendTemplate(to, "", "expiring", struct { + InstanceName string + PortalURL string + Expires time.Time + }{instanceName, portalURL, expires}) +} + +// SendExpired states plainly what has stopped and what happens next. +// +// It names the deletion date rather than a vague warning: the whole point of the +// sequence is that nobody loses an instance without having been told a date. A +// zero deleteOn means the reaper is disabled, and then no date is claimed. +func (s Sender) SendExpired(to, instanceName, portalURL string, deleteOn time.Time) error { + return s.sendTemplate(to, "", "expired", struct { + InstanceName string + PortalURL string + DeleteOn time.Time + }{instanceName, portalURL, deleteOn}) +} + +// SendDeletionWarning is the final countdown, sent at seven days and one day. +func (s Sender) SendDeletionWarning(to, instanceName, portalURL string, deleteOn time.Time, daysLeft int) error { + when := fmt.Sprintf("in %d days", daysLeft) + if daysLeft <= 1 { + when = "tomorrow" + } + return s.sendTemplate(to, "", "deletionwarning", struct { + InstanceName string + PortalURL string + When string + DeleteOn time.Time + }{instanceName, portalURL, when, deleteOn}) +} + +// SendLinkReminder chases a self-hosted customer who paid but never linked. +func (s Sender) SendLinkReminder(to, instanceName string) error { + return s.sendTemplate(to, "", "linkreminder", struct { + InstanceName string + PortalURL string + }{instanceName, s.PublicURL}) +} diff --git a/mail/monitor.go b/mail/monitor.go new file mode 100644 index 0000000..14b66fc --- /dev/null +++ b/mail/monitor.go @@ -0,0 +1,26 @@ +package mail + +import "time" + +// MonitorEvent is a monitor state transition, as the control plane's +// notification dispatcher sees it. It lives here rather than in server/ so that +// the email templates and the caller agree on the fields without server's model +// package leaking into shared. +type MonitorEvent struct { + MonitorName string + Type string + OldStatus string + NewStatus string + Message string + Time time.Time + + // Down drives the pill and the subject verb. The caller decides it, since + // only server/ knows which status strings mean down. + Down bool +} + +// SendMonitorAlert delivers one state-change notification to an SMTP +// notification channel's recipients, which may be a comma-separated list. +func (s Sender) SendMonitorAlert(to string, ev MonitorEvent) error { + return s.sendTemplate(to, "", "monitoralert", ev) +} diff --git a/mail/render.go b/mail/render.go new file mode 100644 index 0000000..00ccd29 --- /dev/null +++ b/mail/render.go @@ -0,0 +1,121 @@ +package mail + +import ( + "embed" + "fmt" + htmltmpl "html/template" + "io/fs" + "strings" + texttmpl "text/template" + "time" +) + +//go:embed templates +var files embed.FS + +// A message is rendered from three files: the shared layout, which owns every +// colour and every piece of chrome, and the message's own html/txt pair, which +// owns only its subject and its content. There is one template set per message +// rather than one big set, because each message defines "subject" and "body" +// under the same names and they would otherwise collide. +type set struct { + html *htmltmpl.Template + text *texttmpl.Template +} + +var sets = map[string]set{} + +func init() { + entries, err := fs.Glob(files, "templates/*.html.tmpl") + if err != nil { + panic("mail: glob templates: " + err.Error()) + } + for _, e := range entries { + name := strings.TrimSuffix(strings.TrimPrefix(e, "templates/"), ".html.tmpl") + if name == "layout" { + continue + } + h, err := htmltmpl.New("layout.html.tmpl").Funcs(htmltmpl.FuncMap(funcs)). + ParseFS(files, "templates/layout.html.tmpl", e) + if err != nil { + panic("mail: parse " + e + ": " + err.Error()) + } + t, err := texttmpl.New("layout.txt.tmpl").Funcs(texttmpl.FuncMap(funcs)). + ParseFS(files, "templates/layout.txt.tmpl", "templates/"+name+".txt.tmpl") + if err != nil { + panic("mail: parse " + name + ".txt.tmpl: " + err.Error()) + } + sets[name] = set{html: h, text: t} + } +} + +// render produces the subject and both bodies for one message. +// +// The subject comes from the text set, not the HTML one: html/template would +// escape an ampersand in an instance name into "&" and mail clients show +// subjects verbatim. +func render(name string, data any) (message, error) { + s, ok := sets[name] + if !ok { + return message{}, fmt.Errorf("no such template") + } + + var subject, text, html strings.Builder + if err := s.text.ExecuteTemplate(&subject, "subject", data); err != nil { + return message{}, err + } + if err := s.text.Execute(&text, data); err != nil { + return message{}, err + } + if err := s.html.Execute(&html, data); err != nil { + return message{}, err + } + + return message{ + Subject: strings.TrimSpace(subject.String()), + Text: normaliseText(text.String()), + HTML: html.String(), + }, nil +} + +// normaliseText gives the plain part CRLF line endings and collapses the blank +// runs that fall out of templating whitespace. +func normaliseText(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + for strings.Contains(s, "\n\n\n") { + s = strings.ReplaceAll(s, "\n\n\n", "\n\n") + } + s = strings.TrimSpace(s) + "\n" + return strings.ReplaceAll(s, "\n", "\r\n") +} + +// funcs are shared by both template flavours. They exist so that a message +// template never formats a date or builds a structure itself — two templates +// formatting the same date two ways is exactly the drift this package removes. +var funcs = map[string]any{ + // dict builds a map for the layout's helper templates, which take more + // than one argument. Go templates have no literal for this. + "dict": func(kv ...any) (map[string]any, error) { + if len(kv)%2 != 0 { + return nil, fmt.Errorf("dict: odd argument count") + } + m := make(map[string]any, len(kv)/2) + for i := 0; i < len(kv); i += 2 { + k, ok := kv[i].(string) + if !ok { + return nil, fmt.Errorf("dict: key %d is not a string", i) + } + m[k] = kv[i+1] + } + return m, nil + }, + "list": func(v ...any) []any { return v }, + + // date is the one long-date format used across every Vantage email. + "date": func(t time.Time) string { return t.Format("2 January 2006") }, + // shortDate drops the year, for subject lines where it is obvious. + "shortDate": func(t time.Time) string { return t.Format("2 January") }, + "stamp": func(t time.Time) string { return t.Format("2006-01-02 15:04:05 MST") }, + "hours": func(d time.Duration) int { return int(d.Hours()) }, + "upper": strings.ToUpper, +} diff --git a/mail/render_test.go b/mail/render_test.go new file mode 100644 index 0000000..a1da628 --- /dev/null +++ b/mail/render_test.go @@ -0,0 +1,161 @@ +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: "

body

", + }) + 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: "

HTMLBODY

"}) + 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) + } + } +} diff --git a/mail/sender.go b/mail/sender.go new file mode 100644 index 0000000..5c7e991 --- /dev/null +++ b/mail/sender.go @@ -0,0 +1,241 @@ +// Package mail is the one email system for every Vantage service. +// +// It owns three things that used to exist in three copies: the SMTP +// conversation (including the 465-implicit-TLS case that net/smtp gets wrong), +// the RFC 5322 envelope, and the rendered look of a Vantage email. Callers see +// only typed Send* methods — nobody outside this package builds a subject line, +// a MIME part or a colour. +package mail + +import ( + "crypto/rand" + "crypto/tls" + "encoding/hex" + "fmt" + "mime" + "mime/multipart" + "net" + "net/smtp" + "net/textproto" + "os" + "strings" + "time" +) + +// timeout bounds the whole SMTP conversation. Without it a mail server that +// accepts the connection and then stalls holds an HTTP request open until the +// client gives up — and admin's signup rollback runs on that request's context. +const timeout = 15 * time.Second + +// Sender is a configured SMTP destination. It is a value, not a singleton: +// server/internal/notify builds one per notification channel from data in +// Mongo, while admin and sitesvc build one at boot. +type Sender struct { + Host string + Port string + From string + Username string + Password string + + // PublicURL is the browser origin used to build links in messages that + // carry one (verification, invitations). Empty is fine for senders that + // never send those, such as a monitor notification channel. + PublicURL string +} + +// FromEnv reads the standard SMTP_* variables. Used by services configured +// straight from the environment; admin builds its Sender from its own config +// struct instead. +func FromEnv() Sender { + return Sender{ + Host: os.Getenv("SMTP_HOST"), + Port: envOr("SMTP_PORT", "587"), + Username: os.Getenv("SMTP_USERNAME"), + Password: os.Getenv("SMTP_PASSWORD"), + From: os.Getenv("SMTP_FROM"), + } +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// Enabled reports whether this Sender can deliver anything at all. Callers +// check it to degrade politely rather than logging a failure per message. +func (s Sender) Enabled() bool { return s.Host != "" && s.From != "" } + +// message is one rendered email, ready to be put on the wire. +type message struct { + To string // one address, or a comma-separated list + ReplyTo string + Subject string + HTML string + Text string +} + +// sendTemplate renders name against data and delivers the result. +func (s Sender) sendTemplate(to, replyTo, name string, data any) error { + m, err := render(name, data) + if err != nil { + return fmt.Errorf("mail: render %s: %w", name, err) + } + m.To = to + m.ReplyTo = replyTo + return s.send(m) +} + +// send delivers one message. +// +// Port 465 is implicit TLS: the server expects a TLS handshake immediately, so +// the connection is wrapped BEFORE any SMTP is spoken. Every other port gets +// plaintext then STARTTLS if offered. net/smtp.SendMail only does the latter, +// which is why it fails against a 465 mail server — that bug silently stopped +// every admin email from being delivered once already. +func (s Sender) send(m message) error { + if !s.Enabled() { + return fmt.Errorf("smtp: not configured") + } + rcpts := recipients(m.To) + if len(rcpts) == 0 { + return fmt.Errorf("smtp: no recipient") + } + + addr := net.JoinHostPort(s.Host, s.Port) + conn, err := net.DialTimeout("tcp", addr, timeout) + if err != nil { + return fmt.Errorf("smtp: dial %s: %w", addr, err) + } + _ = conn.SetDeadline(time.Now().Add(timeout)) + + if s.Port == "465" { + conn = tls.Client(conn, &tls.Config{ServerName: s.Host}) + } + + client, err := smtp.NewClient(conn, s.Host) + if err != nil { + conn.Close() + return fmt.Errorf("smtp: client: %w", err) + } + defer client.Close() + + if s.Port != "465" { + if ok, _ := client.Extension("STARTTLS"); ok { + if err := client.StartTLS(&tls.Config{ServerName: s.Host}); err != nil { + return fmt.Errorf("smtp: starttls: %w", err) + } + } + } + + if s.Username != "" { + if err := client.Auth(smtp.PlainAuth("", s.Username, s.Password, s.Host)); err != nil { + return fmt.Errorf("smtp: auth: %w", err) + } + } + + if err := client.Mail(s.From); err != nil { + return fmt.Errorf("smtp: mail from: %w", err) + } + for _, rcpt := range rcpts { + if err := client.Rcpt(rcpt); err != nil { + return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err) + } + } + + body, err := s.envelope(m) + if err != nil { + return fmt.Errorf("smtp: build message: %w", err) + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("smtp: data: %w", err) + } + if _, err := w.Write(body); err != nil { + return fmt.Errorf("smtp: write: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("smtp: close data: %w", err) + } + return client.Quit() +} + +func recipients(to string) []string { + parts := strings.Split(to, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// envelope builds the RFC 5322 message as multipart/alternative. +// +// Date and Message-ID are not decoration: a message without them is scored as +// spam by most filters, which is its own way of "the email never arrived". A +// text part is sent beside every HTML one for the same reason, and because a +// client that refuses HTML should not receive a blank message. Header values +// are stripped of CR and LF so a crafted instance name cannot inject headers. +func (s Sender) envelope(m message) ([]byte, error) { + var parts strings.Builder + w := multipart.NewWriter(&parts) + + textPart, err := w.CreatePart(textproto.MIMEHeader{ + "Content-Type": {"text/plain; charset=utf-8"}, + }) + if err != nil { + return nil, err + } + if _, err := textPart.Write([]byte(m.Text)); err != nil { + return nil, err + } + + htmlPart, err := w.CreatePart(textproto.MIMEHeader{ + "Content-Type": {"text/html; charset=utf-8"}, + }) + if err != nil { + return nil, err + } + if _, err := htmlPart.Write([]byte(m.HTML)); err != nil { + return nil, err + } + + if err := w.Close(); err != nil { + return nil, err + } + + var b strings.Builder + b.WriteString("From: " + sanitizeHeader(s.From) + "\r\n") + b.WriteString("To: " + sanitizeHeader(m.To) + "\r\n") + if m.ReplyTo != "" { + b.WriteString("Reply-To: " + sanitizeHeader(m.ReplyTo) + "\r\n") + } + b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n") + b.WriteString("Message-ID: " + messageID(s.From) + "\r\n") + b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", sanitizeHeader(m.Subject)) + "\r\n") + b.WriteString("MIME-Version: 1.0\r\n") + b.WriteString("Content-Type: multipart/alternative; boundary=" + w.Boundary() + "\r\n") + b.WriteString("\r\n") + b.WriteString(parts.String()) + return []byte(b.String()), nil +} + +func messageID(from string) string { + domain := "vantage.local" + if at := strings.LastIndex(from, "@"); at >= 0 && at < len(from)-1 { + domain = strings.Trim(from[at+1:], "<> ") + } + var buf [16]byte + if _, err := rand.Read(buf[:]); err != nil { + return fmt.Sprintf("<%d@%s>", time.Now().UnixNano(), domain) + } + return fmt.Sprintf("<%s@%s>", hex.EncodeToString(buf[:]), domain) +} + +func sanitizeHeader(v string) string { + return strings.NewReplacer("\r", " ", "\n", " ").Replace(v) +} diff --git a/mail/templates/cancelled.html.tmpl b/mail/templates/cancelled.html.tmpl new file mode 100644 index 0000000..56b4d8d --- /dev/null +++ b/mail/templates/cancelled.html.tmpl @@ -0,0 +1,6 @@ +{{define "pill"}}{{template "chip" (dict "label" "Cancelled" "tone" "pend")}}{{end}} +{{define "title"}}Your subscription is cancelled{{end}} +{{define "body"}} +{{template "lead" (printf "Your subscription for %s is cancelled." .InstanceName)}} +{{template "p" "Your instance keeps working until the current licence expires. After that, monitors keep running but changes are disabled."}} +{{end}} diff --git a/mail/templates/cancelled.txt.tmpl b/mail/templates/cancelled.txt.tmpl new file mode 100644 index 0000000..95c387c --- /dev/null +++ b/mail/templates/cancelled.txt.tmpl @@ -0,0 +1,7 @@ +{{define "subject"}}Your Vantage subscription is cancelled{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Cancelled" "tone" "pend")}}{{end}} +{{define "title"}}Your subscription is cancelled{{end}} +{{define "body"}} +{{template "lead" (printf "Your subscription for %s is cancelled." .InstanceName)}} +{{template "p" "Your instance keeps working until the current licence expires. After that, monitors keep running but changes are disabled."}} +{{end}} diff --git a/mail/templates/contact.html.tmpl b/mail/templates/contact.html.tmpl new file mode 100644 index 0000000..165077d --- /dev/null +++ b/mail/templates/contact.html.tmpl @@ -0,0 +1,11 @@ +{{define "title"}}New contact enquiry{{end}} +{{define "body"}} +{{template "lead" "Someone has used the contact form on the Vantage site."}} +{{template "rows" (list + (dict "k" "Name" "v" .Name) + (dict "k" "Email" "v" .Email) + (dict "k" "Servers" "v" .Servers) + (dict "k" "Topic" "v" .Topic) + (dict "k" "Received" "v" .Received))}} +{{template "note" .Message}} +{{end}} diff --git a/mail/templates/contact.txt.tmpl b/mail/templates/contact.txt.tmpl new file mode 100644 index 0000000..c076c97 --- /dev/null +++ b/mail/templates/contact.txt.tmpl @@ -0,0 +1,13 @@ +{{define "subject"}}[Vantage] {{.Topic}} {{.Email}}{{end}} +{{define "title"}}New contact enquiry{{end}} +{{define "body"}} +{{template "lead" "Someone has used the contact form on the Vantage site."}} +{{template "rows" (list + (dict "k" "Name" "v" .Name) + (dict "k" "Email" "v" .Email) + (dict "k" "Servers" "v" .Servers) + (dict "k" "Topic" "v" .Topic) + (dict "k" "Received" "v" .Received))}} +Message: +{{template "note" .Message}} +{{end}} diff --git a/mail/templates/deletionwarning.html.tmpl b/mail/templates/deletionwarning.html.tmpl new file mode 100644 index 0000000..d1ebf3b --- /dev/null +++ b/mail/templates/deletionwarning.html.tmpl @@ -0,0 +1,7 @@ +{{define "pill"}}{{template "chip" (dict "label" "Deletion scheduled" "tone" "down")}}{{end}} +{{define "title"}}{{.InstanceName}} will be deleted {{.When}}{{end}} +{{define "body"}} +{{template "lead" (printf "%s and everything in it will be deleted %s, on %s." .InstanceName .When (date .DeleteOn))}} +{{template "p" "This cannot be undone. Renew it to keep it:"}} +{{template "button" (dict "label" "Keep this instance" "url" .PortalURL)}} +{{end}} diff --git a/mail/templates/deletionwarning.txt.tmpl b/mail/templates/deletionwarning.txt.tmpl new file mode 100644 index 0000000..72b8ece --- /dev/null +++ b/mail/templates/deletionwarning.txt.tmpl @@ -0,0 +1,8 @@ +{{define "subject"}}{{.InstanceName}} will be deleted {{.When}}{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Deletion scheduled" "tone" "down")}}{{end}} +{{define "title"}}{{.InstanceName}} will be deleted {{.When}}{{end}} +{{define "body"}} +{{template "lead" (printf "%s and everything in it will be deleted %s, on %s." .InstanceName .When (date .DeleteOn))}} +{{template "p" "This cannot be undone. Renew it to keep it:"}} +{{template "button" (dict "label" "Keep this instance" "url" .PortalURL)}} +{{end}} diff --git a/mail/templates/expired.html.tmpl b/mail/templates/expired.html.tmpl new file mode 100644 index 0000000..9ed09e3 --- /dev/null +++ b/mail/templates/expired.html.tmpl @@ -0,0 +1,8 @@ +{{define "pill"}}{{template "chip" (dict "label" "Read-only" "tone" "down")}}{{end}} +{{define "title"}}{{.InstanceName}} is now read-only{{end}} +{{define "body"}} +{{template "lead" (printf "%s's Free licence has expired." .InstanceName)}} +{{template "p" "Your servers and monitors keep running and your agents keep their keys, but changes are disabled."}} +{{template "button" (dict "label" "Renew now" "url" .PortalURL)}} +{{if not .DeleteOn.IsZero}}{{template "p" (printf "If it is not renewed, the instance and everything in it will be deleted on %s." (date .DeleteOn))}}{{end}} +{{end}} diff --git a/mail/templates/expired.txt.tmpl b/mail/templates/expired.txt.tmpl new file mode 100644 index 0000000..2eba94d --- /dev/null +++ b/mail/templates/expired.txt.tmpl @@ -0,0 +1,9 @@ +{{define "subject"}}{{.InstanceName}} is now read-only{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Read-only" "tone" "down")}}{{end}} +{{define "title"}}{{.InstanceName}} is now read-only{{end}} +{{define "body"}} +{{template "lead" (printf "%s's Free licence has expired." .InstanceName)}} +{{template "p" "Your servers and monitors keep running and your agents keep their keys, but changes are disabled."}} +{{template "button" (dict "label" "Renew now" "url" .PortalURL)}} +{{if not .DeleteOn.IsZero}}{{template "p" (printf "If it is not renewed, the instance and everything in it will be deleted on %s." (date .DeleteOn))}}{{end}} +{{end}} diff --git a/mail/templates/expiring.html.tmpl b/mail/templates/expiring.html.tmpl new file mode 100644 index 0000000..ec04b05 --- /dev/null +++ b/mail/templates/expiring.html.tmpl @@ -0,0 +1,7 @@ +{{define "pill"}}{{template "chip" (dict "label" "Expiring soon" "tone" "pend")}}{{end}} +{{define "title"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}} +{{define "body"}} +{{template "lead" (printf "%s's Free licence runs out on %s." .InstanceName (date .Expires))}} +{{template "button" (dict "label" "Renew in one click" "url" .PortalURL)}} +{{template "p" "If you do nothing, the instance keeps running but stops accepting changes."}} +{{end}} diff --git a/mail/templates/expiring.txt.tmpl b/mail/templates/expiring.txt.tmpl new file mode 100644 index 0000000..9bbedeb --- /dev/null +++ b/mail/templates/expiring.txt.tmpl @@ -0,0 +1,8 @@ +{{define "subject"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Expiring soon" "tone" "pend")}}{{end}} +{{define "title"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}} +{{define "body"}} +{{template "lead" (printf "%s's Free licence runs out on %s." .InstanceName (date .Expires))}} +{{template "button" (dict "label" "Renew in one click" "url" .PortalURL)}} +{{template "p" "If you do nothing, the instance keeps running but stops accepting changes."}} +{{end}} diff --git a/mail/templates/instanceready.html.tmpl b/mail/templates/instanceready.html.tmpl new file mode 100644 index 0000000..7e7fd21 --- /dev/null +++ b/mail/templates/instanceready.html.tmpl @@ -0,0 +1,8 @@ +{{define "pill"}}{{template "chip" (dict "label" "Ready" "tone" "up")}}{{end}} +{{define "title"}}{{.InstanceName}} is ready{{end}} +{{define "body"}} +{{template "lead" (printf "%s is provisioned and waiting for you." .InstanceName)}} +{{if .LoginURL}}{{template "button" (dict "label" "Sign in" "url" .LoginURL)}}{{end}} +{{template "p" (printf "Your Free licence runs until %s. We will email you before then so you can renew it in one click." (date .Expires))}} +{{template "p" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too."}} +{{end}} diff --git a/mail/templates/instanceready.txt.tmpl b/mail/templates/instanceready.txt.tmpl new file mode 100644 index 0000000..2ff3888 --- /dev/null +++ b/mail/templates/instanceready.txt.tmpl @@ -0,0 +1,9 @@ +{{define "subject"}}{{.InstanceName}} is ready{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Ready" "tone" "up")}}{{end}} +{{define "title"}}{{.InstanceName}} is ready{{end}} +{{define "body"}} +{{template "lead" (printf "%s is provisioned and waiting for you." .InstanceName)}} +{{if .LoginURL}}{{template "button" (dict "label" "Sign in" "url" .LoginURL)}}{{end}} +{{template "p" (printf "Your Free licence runs until %s. We will email you before then so you can renew it in one click." (date .Expires))}} +{{template "p" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too."}} +{{end}} diff --git a/mail/templates/invite.html.tmpl b/mail/templates/invite.html.tmpl new file mode 100644 index 0000000..5f292da --- /dev/null +++ b/mail/templates/invite.html.tmpl @@ -0,0 +1,7 @@ +{{define "title"}}You have been invited to {{.AccountName}}{{end}} +{{define "body"}} +{{template "lead" (printf "You have been invited to join %s on Vantage." .AccountName)}} +{{template "p" "Set your own password and finish joining:"}} +{{template "button" (dict "label" "Set password and join" "url" .Link)}} +{{template "p" "This link expires in 24 hours. If you were not expecting this, ignore it — nothing happens until you open the link."}} +{{end}} diff --git a/mail/templates/invite.txt.tmpl b/mail/templates/invite.txt.tmpl new file mode 100644 index 0000000..fa5a7a9 --- /dev/null +++ b/mail/templates/invite.txt.tmpl @@ -0,0 +1,8 @@ +{{define "subject"}}You have been invited to {{.AccountName}} on Vantage{{end}} +{{define "title"}}You have been invited to {{.AccountName}}{{end}} +{{define "body"}} +{{template "lead" (printf "You have been invited to join %s on Vantage." .AccountName)}} +{{template "p" "Set your own password and finish joining:"}} +{{template "button" (dict "label" "Set password and join" "url" .Link)}} +{{template "p" "This link expires in 24 hours. If you were not expecting this, ignore it — nothing happens until you open the link."}} +{{end}} diff --git a/mail/templates/layout.html.tmpl b/mail/templates/layout.html.tmpl new file mode 100644 index 0000000..84d1f83 --- /dev/null +++ b/mail/templates/layout.html.tmpl @@ -0,0 +1,121 @@ +{{- /* + The Vantage email shell. + + Every colour in the email system lives in this file and nowhere else, in the + same way no component in web/, site/ or adminsite/ carries a hex. The values + are web/app/globals.css's tokens — an email is read before the recipient + clicks through to the control plane, so the two should not look like + different products. They are written as literal hex here because email + clients support neither var() nor a reliable prefers-color-scheme, so the + usual token indirection is not available: when you change a token in the + three globals.css files, change it here too. + + --ground #071628 --rule #1e3855 + --panel #0d2138 --accent #5b9be8 + --panel-2 #102842 --up #4fb484 + --ink #e4ecf6 --down #e2705a + --ink-2 #9fb3ca --pend #d6a63f + --ink-3 #71879f --well #04101f + + Layout is tables and inline styles throughout, which is not a stylistic + choice — it is the only thing Outlook renders predictably. + + A message file overrides "title", "pill" and "body"; the empty defaults below + exist so that a message needing no pill does not have to define one. +*/ -}} +{{- define "title"}}{{end -}} +{{- define "pill"}}{{end -}} +{{- define "body"}}{{end -}} + +{{- /* p renders one paragraph of body copy. */ -}} +{{- define "p" -}} +

{{.}}

+{{- end -}} + +{{- /* lead is the first paragraph: same size, brighter, sets the subject. */ -}} +{{- define "lead" -}} +

{{.}}

+{{- end -}} + +{{- /* button takes dict "label" "…" "url" "…". + + The bare URL is printed underneath on purpose: a plain-text-preferring + client, a stripped-styles inbox and a forwarded message all lose the + anchor, and a verification email whose link cannot be reached is a + support ticket. */ -}} +{{- define "button" -}} + + + + +
+ {{.label}} +
+

+ Or paste this into your browser:
+ {{.url}} +

+{{- end -}} + +{{- /* well shows machine output — a licence blob, an install ID. Mirrors + web/'s --well surface, the floor beneath the ground. */ -}} +{{- define "well" -}} +
{{.}}
+{{- end -}} + +{{- /* note is a quoted callout, used for a free-text message we did not + write ourselves. */ -}} +{{- define "note" -}} +

{{.}}

+{{- end -}} + +{{- /* rows takes a list of dict "k" "…" "v" "…". */ -}} +{{- define "rows" -}} + + {{- range .}} + + + + + {{- end}} +
{{.k}}{{.v}}
+{{- end -}} + +{{- /* chip takes dict "label" "…" "tone" "up|down|pend|accent". Tone is + never the only signal: the label spells the state out. */ -}} +{{- define "chip" -}} +{{- $fg := "#5b9be8"}}{{if eq .tone "up"}}{{$fg = "#4fb484"}}{{else if eq .tone "down"}}{{$fg = "#e2705a"}}{{else if eq .tone "pend"}}{{$fg = "#d6a63f"}}{{end -}} +{{.label}} +{{- end -}} + + + + + + + + +
+ + + + + + + + + + + +
+ Vantage +
+ {{template "pill" .}} +

{{template "title" .}}

+ {{template "body" .}} +
+

Sent by Vantage · infrastructure control plane

+
+
+ + diff --git a/mail/templates/layout.txt.tmpl b/mail/templates/layout.txt.tmpl new file mode 100644 index 0000000..2d28593 --- /dev/null +++ b/mail/templates/layout.txt.tmpl @@ -0,0 +1,46 @@ +{{- /* + The plain-text counterpart of layout.html.tmpl. + + It defines the same helper names — p, lead, button, well, note, rows, chip — + so a message's txt file reads as the same document as its html one, and a + helper added on one side is obvious by its absence on the other. + + "subject" is defined here rather than in the HTML file: html/template would + escape an ampersand in an instance name, and mail clients show subject lines + verbatim. +*/ -}} +{{- define "subject"}}Vantage{{end -}} +{{- define "title"}}{{end -}} +{{- define "pill"}}{{end -}} +{{- define "body"}}{{end -}} + +{{- define "p"}}{{.}} + +{{end -}} +{{- define "lead"}}{{.}} + +{{end -}} +{{- define "button"}}{{.label}}: + +{{.url}} + +{{end -}} +{{- define "well"}}{{.}} + +{{end -}} +{{- define "note"}}{{.}} + +{{end -}} +{{- define "rows"}}{{range .}}{{.k}}: {{.v}} +{{end}} +{{end -}} +{{- define "chip"}}[{{upper .label}}] + +{{end -}} +VANTAGE +{{template "pill" .}} +{{template "title" .}} + +{{template "body" .}} +-- +Sent by Vantage · infrastructure control plane diff --git a/mail/templates/license.html.tmpl b/mail/templates/license.html.tmpl new file mode 100644 index 0000000..4af696a --- /dev/null +++ b/mail/templates/license.html.tmpl @@ -0,0 +1,7 @@ +{{define "title"}}Your licence key{{end}} +{{define "body"}} +{{template "lead" (printf "Your licence for %s is below." .InstanceName)}} +{{template "p" "Paste it into Settings → Licence on your Vantage install:"}} +{{template "well" .Blob}} +{{template "p" "The licence is signed public data, not a secret — it is useless on any instance other than the one it names."}} +{{end}} diff --git a/mail/templates/license.txt.tmpl b/mail/templates/license.txt.tmpl new file mode 100644 index 0000000..c52b5e6 --- /dev/null +++ b/mail/templates/license.txt.tmpl @@ -0,0 +1,8 @@ +{{define "subject"}}Your Vantage licence key{{end}} +{{define "title"}}Your licence key{{end}} +{{define "body"}} +{{template "lead" (printf "Your licence for %s is below." .InstanceName)}} +{{template "p" "Paste it into Settings > Licence on your Vantage install:"}} +{{template "well" .Blob}} +{{template "p" "The licence is signed public data, not a secret — it is useless on any instance other than the one it names."}} +{{end}} diff --git a/mail/templates/linkreminder.html.tmpl b/mail/templates/linkreminder.html.tmpl new file mode 100644 index 0000000..07460d8 --- /dev/null +++ b/mail/templates/linkreminder.html.tmpl @@ -0,0 +1,7 @@ +{{define "pill"}}{{template "chip" (dict "label" "Action needed" "tone" "pend")}}{{end}} +{{define "title"}}Finish setting up {{.InstanceName}}{{end}} +{{define "body"}} +{{template "lead" (printf "Your subscription for %s is active, but the instance is not linked yet." .InstanceName)}} +{{template "p" "Paste your install's ID in the portal to receive your licence."}} +{{if .PortalURL}}{{template "button" (dict "label" "Link my install" "url" .PortalURL)}}{{end}} +{{end}} diff --git a/mail/templates/linkreminder.txt.tmpl b/mail/templates/linkreminder.txt.tmpl new file mode 100644 index 0000000..ae1f7ae --- /dev/null +++ b/mail/templates/linkreminder.txt.tmpl @@ -0,0 +1,8 @@ +{{define "subject"}}Finish setting up {{.InstanceName}}{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Action needed" "tone" "pend")}}{{end}} +{{define "title"}}Finish setting up {{.InstanceName}}{{end}} +{{define "body"}} +{{template "lead" (printf "Your subscription for %s is active, but the instance is not linked yet." .InstanceName)}} +{{template "p" "Paste your install's ID in the portal to receive your licence."}} +{{if .PortalURL}}{{template "button" (dict "label" "Link my install" "url" .PortalURL)}}{{end}} +{{end}} diff --git a/mail/templates/monitoralert.html.tmpl b/mail/templates/monitoralert.html.tmpl new file mode 100644 index 0000000..84ee42f --- /dev/null +++ b/mail/templates/monitoralert.html.tmpl @@ -0,0 +1,10 @@ +{{define "pill"}}{{if .Down}}{{template "chip" (dict "label" "Down" "tone" "down")}}{{else}}{{template "chip" (dict "label" "Recovered" "tone" "up")}}{{end}}{{end}} +{{define "title"}}{{.MonitorName}}{{end}} +{{define "body"}} +{{template "p" (printf "%s check" .Type)}} +{{if .Message}}{{template "note" .Message}}{{end}} +{{template "rows" (list + (dict "k" "Status" "v" (printf "%s → %s" .OldStatus .NewStatus)) + (dict "k" "Type" "v" .Type) + (dict "k" "Time" "v" (stamp .Time)))}} +{{end}} diff --git a/mail/templates/monitoralert.txt.tmpl b/mail/templates/monitoralert.txt.tmpl new file mode 100644 index 0000000..34d480a --- /dev/null +++ b/mail/templates/monitoralert.txt.tmpl @@ -0,0 +1,11 @@ +{{define "subject"}}[Vantage] {{.MonitorName}} ({{.Type}}) {{if .Down}}is DOWN{{else}}recovered{{end}}{{if .Message}}: {{.Message}}{{end}}{{end}} +{{define "pill"}}{{if .Down}}{{template "chip" (dict "label" "Down" "tone" "down")}}{{else}}{{template "chip" (dict "label" "Recovered" "tone" "up")}}{{end}}{{end}} +{{define "title"}}{{.MonitorName}}{{end}} +{{define "body"}} +{{template "p" (printf "%s check" .Type)}} +{{if .Message}}{{template "note" .Message}}{{end}} +{{template "rows" (list + (dict "k" "Status" "v" (printf "%s -> %s" .OldStatus .NewStatus)) + (dict "k" "Type" "v" .Type) + (dict "k" "Time" "v" (stamp .Time)))}} +{{end}} diff --git a/mail/templates/pastdue.html.tmpl b/mail/templates/pastdue.html.tmpl new file mode 100644 index 0000000..611f485 --- /dev/null +++ b/mail/templates/pastdue.html.tmpl @@ -0,0 +1,7 @@ +{{define "pill"}}{{template "chip" (dict "label" "Payment failed" "tone" "pend")}}{{end}} +{{define "title"}}Payment failed for {{.InstanceName}}{{end}} +{{define "body"}} +{{template "lead" (printf "A payment for %s failed." .InstanceName)}} +{{template "p" "Your instance is unaffected while the card is retried. Update your payment method from the billing portal."}} +{{if .PortalURL}}{{template "button" (dict "label" "Open billing portal" "url" .PortalURL)}}{{end}} +{{end}} diff --git a/mail/templates/pastdue.txt.tmpl b/mail/templates/pastdue.txt.tmpl new file mode 100644 index 0000000..c3319b8 --- /dev/null +++ b/mail/templates/pastdue.txt.tmpl @@ -0,0 +1,8 @@ +{{define "subject"}}Payment failed for your Vantage subscription{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Payment failed" "tone" "pend")}}{{end}} +{{define "title"}}Payment failed for {{.InstanceName}}{{end}} +{{define "body"}} +{{template "lead" (printf "A payment for %s failed." .InstanceName)}} +{{template "p" "Your instance is unaffected while the card is retried. Update your payment method from the billing portal."}} +{{if .PortalURL}}{{template "button" (dict "label" "Open billing portal" "url" .PortalURL)}}{{end}} +{{end}} diff --git a/mail/templates/renewed.html.tmpl b/mail/templates/renewed.html.tmpl new file mode 100644 index 0000000..3f2e0a5 --- /dev/null +++ b/mail/templates/renewed.html.tmpl @@ -0,0 +1,6 @@ +{{define "pill"}}{{template "chip" (dict "label" "Renewed" "tone" "up")}}{{end}} +{{define "title"}}{{.InstanceName}} is renewed{{end}} +{{define "body"}} +{{template "lead" (printf "Your Free licence for %s now runs until %s." .InstanceName (date .Expires))}} +{{template "p" "Nothing else changes — your servers, agents and monitors carry on as they were."}} +{{end}} diff --git a/mail/templates/renewed.txt.tmpl b/mail/templates/renewed.txt.tmpl new file mode 100644 index 0000000..bddaad1 --- /dev/null +++ b/mail/templates/renewed.txt.tmpl @@ -0,0 +1,7 @@ +{{define "subject"}}{{.InstanceName}} renewed{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Renewed" "tone" "up")}}{{end}} +{{define "title"}}{{.InstanceName}} is renewed{{end}} +{{define "body"}} +{{template "lead" (printf "Your Free licence for %s now runs until %s." .InstanceName (date .Expires))}} +{{template "p" "Nothing else changes — your servers, agents and monitors carry on as they were."}} +{{end}} diff --git a/mail/templates/verification.html.tmpl b/mail/templates/verification.html.tmpl new file mode 100644 index 0000000..d22c4f2 --- /dev/null +++ b/mail/templates/verification.html.tmpl @@ -0,0 +1,7 @@ +{{define "title"}}Confirm your email address{{end}} +{{define "body"}} +{{template "lead" "Confirm this address to finish setting up your Vantage account."}} +{{template "button" (dict "label" "Confirm email address" "url" .Link)}} +{{template "p" (printf "The link works once and expires in %d hours." .TTLHours)}} +{{template "p" "If you did not request this, ignore this email — nothing happens until the link is opened."}} +{{end}} diff --git a/mail/templates/verification.txt.tmpl b/mail/templates/verification.txt.tmpl new file mode 100644 index 0000000..b71edf9 --- /dev/null +++ b/mail/templates/verification.txt.tmpl @@ -0,0 +1,8 @@ +{{define "subject"}}Verify your Vantage account{{end}} +{{define "title"}}Confirm your email address{{end}} +{{define "body"}} +{{template "lead" "Confirm this address to finish setting up your Vantage account."}} +{{template "button" (dict "label" "Confirm email address" "url" .Link)}} +{{template "p" (printf "The link works once and expires in %d hours." .TTLHours)}} +{{template "p" "If you did not request this, ignore this email — nothing happens until the link is opened."}} +{{end}}