feat: Move mail system to shared
Server Deploy / deploy (push) Successful in 2m46s

This commit is contained in:
2026-07-28 09:50:46 +01:00
parent 5e326335af
commit a232c74990
49 changed files with 1206 additions and 706 deletions
+37
View File
@@ -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),
})
}
+18
View File
@@ -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})
}
+27
View File
@@ -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),
})
}
+81
View File
@@ -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})
}
+26
View File
@@ -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)
}
+121
View File
@@ -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 "&amp;" 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,
}
+161
View File
@@ -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 &amp; 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)
}
}
}
+241
View File
@@ -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)
}
@@ -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}}
+7
View File
@@ -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}}
+11
View File
@@ -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}}
+13
View File
@@ -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}}
@@ -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}}
@@ -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}}
+8
View File
@@ -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}}
+9
View File
@@ -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}}
+7
View File
@@ -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}}
+8
View File
@@ -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}}
@@ -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}}
@@ -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}}
+7
View File
@@ -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}}
+8
View File
@@ -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}}
+121
View File
@@ -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" -}}
<p style="margin:0 0 16px;color:#9fb3ca;font-size:14px;line-height:1.6;">{{.}}</p>
{{- end -}}
{{- /* lead is the first paragraph: same size, brighter, sets the subject. */ -}}
{{- define "lead" -}}
<p style="margin:0 0 16px;color:#e4ecf6;font-size:15px;line-height:1.6;">{{.}}</p>
{{- 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" -}}
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:4px 0 16px;">
<tr>
<td style="border-radius:4px;background:#5b9be8;">
<a href="{{.url}}" style="display:inline-block;padding:10px 20px;color:#04101f;font-size:14px;font-weight:600;text-decoration:none;">{{.label}}</a>
</td>
</tr>
</table>
<p style="margin:0 0 20px;color:#71879f;font-size:12px;line-height:1.5;word-break:break-all;">
Or paste this into your browser:<br>
<a href="{{.url}}" style="color:#5b9be8;text-decoration:none;">{{.url}}</a>
</p>
{{- end -}}
{{- /* well shows machine output — a licence blob, an install ID. Mirrors
web/'s --well surface, the floor beneath the ground. */ -}}
{{- define "well" -}}
<pre style="margin:0 0 20px;padding:14px;background:#04101f;border:1px solid #1e3855;border-radius:4px;color:#9fb3ca;font-family:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all;">{{.}}</pre>
{{- end -}}
{{- /* note is a quoted callout, used for a free-text message we did not
write ourselves. */ -}}
{{- define "note" -}}
<p style="margin:0 0 20px;padding:12px 14px;background:#102842;border:1px solid #1e3855;border-radius:4px;color:#e4ecf6;font-size:13px;line-height:1.6;">{{.}}</p>
{{- end -}}
{{- /* rows takes a list of dict "k" "…" "v" "…". */ -}}
{{- define "rows" -}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 8px;border-top:1px solid #1e3855;">
{{- range .}}
<tr>
<td style="padding:9px 0;border-bottom:1px solid #172c44;color:#71879f;font-size:13px;width:130px;vertical-align:top;">{{.k}}</td>
<td style="padding:9px 0;border-bottom:1px solid #172c44;color:#e4ecf6;font-size:13px;font-weight:500;">{{.v}}</td>
</tr>
{{- end}}
</table>
{{- 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 -}}
<span style="display:inline-block;margin:0 0 12px;padding:4px 11px;border:1px solid {{$fg}};border-radius:9999px;color:{{$fg}};font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;">{{.label}}</span>
{{- end -}}
<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:#071628;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#071628;padding:32px 12px;">
<tr>
<td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0" style="max-width:520px;width:100%;background:#0d2138;border:1px solid #1e3855;border-radius:4px;overflow:hidden;font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<tr><td style="height:3px;background:#5b9be8;"></td></tr>
<tr>
<td style="padding:26px 28px 8px;">
<span style="font-size:16px;font-weight:700;letter-spacing:-.01em;color:#7fb2f0;">Vantage</span>
</td>
</tr>
<tr>
<td style="padding:10px 28px 26px;">
{{template "pill" .}}
<h1 style="margin:2px 0 14px;font-size:20px;font-weight:700;line-height:1.3;color:#e4ecf6;">{{template "title" .}}</h1>
{{template "body" .}}
</td>
</tr>
<tr>
<td style="padding:14px 28px;border-top:1px solid #1e3855;background:#04101f;">
<p style="margin:0;color:#71879f;font-size:12px;line-height:1.5;">Sent by Vantage · infrastructure control plane</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+46
View File
@@ -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
+7
View File
@@ -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}}
+8
View File
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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}}
+7
View File
@@ -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}}
+8
View File
@@ -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}}
+6
View File
@@ -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}}
+7
View File
@@ -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}}
@@ -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}}
@@ -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}}