This commit is contained in:
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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: "<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user