From 50ab63ff139c817e48c0637579c24f2ef9097dc5 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Thu, 10 Sep 2026 13:21:04 +0000 Subject: [PATCH] feat: account dispute emails --- mail/dispute.go | 62 +++++++++++++++++++ mail/dispute_test.go | 70 ++++++++++++++++++++++ mail/render.go | 8 ++- mail/render_test.go | 4 ++ mail/templates/accountlocked.html.tmpl | 9 +++ mail/templates/accountlocked.txt.tmpl | 10 ++++ mail/templates/accountrestored.html.tmpl | 7 +++ mail/templates/accountrestored.txt.tmpl | 8 +++ mail/templates/accountterminated.html.tmpl | 9 +++ mail/templates/accountterminated.txt.tmpl | 10 ++++ mail/templates/disputereminder.html.tmpl | 7 +++ mail/templates/disputereminder.txt.tmpl | 8 +++ 12 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 mail/dispute.go create mode 100644 mail/dispute_test.go create mode 100644 mail/templates/accountlocked.html.tmpl create mode 100644 mail/templates/accountlocked.txt.tmpl create mode 100644 mail/templates/accountrestored.html.tmpl create mode 100644 mail/templates/accountrestored.txt.tmpl create mode 100644 mail/templates/accountterminated.html.tmpl create mode 100644 mail/templates/accountterminated.txt.tmpl create mode 100644 mail/templates/disputereminder.html.tmpl create mode 100644 mail/templates/disputereminder.txt.tmpl diff --git a/mail/dispute.go b/mail/dispute.go new file mode 100644 index 0000000..07a02db --- /dev/null +++ b/mail/dispute.go @@ -0,0 +1,62 @@ +package mail + +import "time" + +// DisputeNotice is everything the four account-dispute emails say. +// +// vantage-admin's dispute package builds it. It never carries the staff note: +// the customer is told the reason category, not what staff wrote about them. +type DisputeNotice struct { + AccountName string + DisputeID string + Reason string + CloudNames []string + SelfHosted []SelfHostedNotice + DisputeEmail string + DisputeBy time.Time +} + +// SelfHostedNotice names a self-hosted install and when its licence ends, +// because that is the one thing a dispute cannot change for it. +type SelfHostedNotice struct { + Name string + LicenceExpires time.Time +} + +// InstanceRows lists the affected instances in the shape the layout's "rows" +// helper takes. A method rather than template logic, because a Go template +// has no way to build a list of maps. +func (n DisputeNotice) InstanceRows() []map[string]any { + rows := make([]map[string]any, 0, len(n.CloudNames)+len(n.SelfHosted)) + for _, name := range n.CloudNames { + rows = append(rows, map[string]any{"k": "Cloud", "v": name}) + } + for _, s := range n.SelfHosted { + rows = append(rows, map[string]any{ + "k": "Self-hosted", + "v": s.Name + ", licence ends " + s.LicenceExpires.Format("2 January 2006"), + }) + } + return rows +} + +// SendAccountLocked tells the account a dispute has locked it, that nothing is +// deleted, and how to dispute. Replies go to the dispute address. +func (s Sender) SendAccountLocked(to string, n DisputeNotice) error { + return s.sendTemplate(to, n.DisputeEmail, "accountlocked", n) +} + +// SendDisputeReminder repeats the dispute route shortly before the hold ends. +func (s Sender) SendDisputeReminder(to string, n DisputeNotice) error { + return s.sendTemplate(to, n.DisputeEmail, "disputereminder", n) +} + +// SendAccountTerminated states the outcome of a failed dispute. +func (s Sender) SendAccountTerminated(to string, n DisputeNotice) error { + return s.sendTemplate(to, n.DisputeEmail, "accountterminated", n) +} + +// SendAccountRestored states the outcome of an upheld dispute. +func (s Sender) SendAccountRestored(to string, n DisputeNotice) error { + return s.sendTemplate(to, "", "accountrestored", n) +} diff --git a/mail/dispute_test.go b/mail/dispute_test.go new file mode 100644 index 0000000..1329960 --- /dev/null +++ b/mail/dispute_test.go @@ -0,0 +1,70 @@ +package mail + +import ( + "strings" + "testing" + "time" +) + +func sampleNotice() DisputeNotice { + return DisputeNotice{ + AccountName: "Smith & Sons", + DisputeID: "dsp_123", + Reason: "Breach of the terms of service", + CloudNames: []string{"smith-prod"}, + SelfHosted: []SelfHostedNotice{{Name: "smith-dc", LicenceExpires: time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)}}, + DisputeEmail: "disputes@vantage.example", + DisputeBy: time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC), + } +} + +var disputeTemplates = []string{"accountlocked", "disputereminder", "accountterminated", "accountrestored"} + +func TestDisputeTemplatesRender(t *testing.T) { + for _, name := range disputeTemplates { + t.Run(name, func(t *testing.T) { + m, err := render(name, sampleNotice()) + if err != nil { + t.Fatalf("render: %v", err) + } + if !strings.Contains(m.Subject, "Smith & Sons") { + t.Errorf("subject %q does not name the account verbatim", m.Subject) + } + for label, body := range map[string]string{"text": m.Text, "html": m.HTML} { + if strings.Contains(body, "") { + t.Errorf("%s body has an unfilled field", label) + } + if strings.Contains(body, "—") { + t.Errorf("%s body contains an em dash", label) + } + } + }) + } +} + +func TestDisputeRouteIsStated(t *testing.T) { + for _, name := range []string{"accountlocked", "disputereminder"} { + m, err := render(name, sampleNotice()) + if err != nil { + t.Fatalf("render %s: %v", name, err) + } + for _, want := range []string{"disputes@vantage.example", "dsp_123", "17 September 2026"} { + if !strings.Contains(m.Text, want) { + t.Errorf("%s text is missing %q", name, want) + } + } + } +} + +func TestInstanceRows(t *testing.T) { + rows := sampleNotice().InstanceRows() + if len(rows) != 2 { + t.Fatalf("got %d rows, want 2", len(rows)) + } + if rows[0]["v"] != "smith-prod" { + t.Errorf("cloud row = %v", rows[0]) + } + if v, _ := rows[1]["v"].(string); !strings.Contains(v, "licence ends 2 November 2026") { + t.Errorf("self-hosted row = %v", rows[1]) + } +} diff --git a/mail/render.go b/mail/render.go index 0f16488..b8a491b 100644 --- a/mail/render.go +++ b/mail/render.go @@ -69,17 +69,21 @@ func perRender(hq, tone string) map[string]any { // 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, hq string) (message, error) { +func render(name string, data any, hq ...string) (message, error) { s, ok := sets[name] if !ok { return message{}, fmt.Errorf("no such template") } + var hqURL string + if len(hq) > 0 { + hqURL = hq[0] + } var tone strings.Builder if err := s.text.ExecuteTemplate(&tone, "tone", data); err != nil { return message{}, err } - per := perRender(hq, strings.TrimSpace(tone.String())) + per := perRender(hqURL, strings.TrimSpace(tone.String())) ht, err := s.html.Clone() if err != nil { diff --git a/mail/render_test.go b/mail/render_test.go index 62aa717..cfc169d 100644 --- a/mail/render_test.go +++ b/mail/render_test.go @@ -47,6 +47,10 @@ func cases() map[string]any { {CVEID: "CVE-2025-48112", Severity: "low", PackageName: "less", ServerName: "worker-03"}, }, More: 9, DBAge: "2 days"}, + "accountlocked": sampleNotice(), + "disputereminder": sampleNotice(), + "accountterminated": sampleNotice(), + "accountrestored": sampleNotice(), } } diff --git a/mail/templates/accountlocked.html.tmpl b/mail/templates/accountlocked.html.tmpl new file mode 100644 index 0000000..8ed76da --- /dev/null +++ b/mail/templates/accountlocked.html.tmpl @@ -0,0 +1,9 @@ +{{define "pill"}}{{template "chip" (dict "label" "Account locked" "tone" "pend")}}{{end}} +{{define "title"}}Your account is locked{{end}} +{{define "body"}} +{{template "lead" (printf "We have locked the Vantage account %s. Reason: %s." .AccountName .Reason)}} +{{template "p" "Nothing has been deleted. While the account is locked, sign-in is paused, cloud instances are offline and billing is paused."}} +{{if .InstanceRows}}{{template "rows" .InstanceRows}}{{end}} +{{template "p" (printf "If you believe this is a mistake, reply to %s before %s, quoting dispute reference %s." .DisputeEmail (date .DisputeBy) .DisputeID)}} +{{template "p" "If the dispute is not upheld, the account will be terminated and its cloud instances deleted."}} +{{end}} diff --git a/mail/templates/accountlocked.txt.tmpl b/mail/templates/accountlocked.txt.tmpl new file mode 100644 index 0000000..f910957 --- /dev/null +++ b/mail/templates/accountlocked.txt.tmpl @@ -0,0 +1,10 @@ +{{define "subject"}}Your Vantage account {{.AccountName}} is locked{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Account locked" "tone" "pend")}}{{end}} +{{define "title"}}Your account is locked{{end}} +{{define "body"}} +{{template "lead" (printf "We have locked the Vantage account %s. Reason: %s." .AccountName .Reason)}} +{{template "p" "Nothing has been deleted. While the account is locked, sign-in is paused, cloud instances are offline and billing is paused."}} +{{if .InstanceRows}}{{template "rows" .InstanceRows}}{{end}} +{{template "p" (printf "If you believe this is a mistake, reply to %s before %s, quoting dispute reference %s." .DisputeEmail (date .DisputeBy) .DisputeID)}} +{{template "p" "If the dispute is not upheld, the account will be terminated and its cloud instances deleted."}} +{{end}} diff --git a/mail/templates/accountrestored.html.tmpl b/mail/templates/accountrestored.html.tmpl new file mode 100644 index 0000000..9973ce6 --- /dev/null +++ b/mail/templates/accountrestored.html.tmpl @@ -0,0 +1,7 @@ +{{define "pill"}}{{template "chip" (dict "label" "Restored" "tone" "up")}}{{end}} +{{define "title"}}Your account is restored{{end}} +{{define "body"}} +{{template "lead" (printf "The dispute for %s is resolved and your account is restored." .AccountName)}} +{{template "p" "Sign-in works again and cloud instances are back online. Billing has resumed and starts a new period from today."}} +{{template "p" "You will need to sign in again."}} +{{end}} diff --git a/mail/templates/accountrestored.txt.tmpl b/mail/templates/accountrestored.txt.tmpl new file mode 100644 index 0000000..e70557b --- /dev/null +++ b/mail/templates/accountrestored.txt.tmpl @@ -0,0 +1,8 @@ +{{define "subject"}}Your Vantage account {{.AccountName}} is restored{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Restored" "tone" "up")}}{{end}} +{{define "title"}}Your account is restored{{end}} +{{define "body"}} +{{template "lead" (printf "The dispute for %s is resolved and your account is restored." .AccountName)}} +{{template "p" "Sign-in works again and cloud instances are back online. Billing has resumed and starts a new period from today."}} +{{template "p" "You will need to sign in again."}} +{{end}} diff --git a/mail/templates/accountterminated.html.tmpl b/mail/templates/accountterminated.html.tmpl new file mode 100644 index 0000000..33f19df --- /dev/null +++ b/mail/templates/accountterminated.html.tmpl @@ -0,0 +1,9 @@ +{{define "pill"}}{{template "chip" (dict "label" "Terminated" "tone" "down")}}{{end}} +{{define "title"}}Your account is terminated{{end}} +{{define "body"}} +{{template "lead" (printf "The dispute for %s was not upheld, and the account is terminated. Reason: %s." .AccountName .Reason)}} +{{template "p" "Every subscription is cancelled and no refund is due. Sign-in is removed and cloud instances are being deleted. This cannot be undone."}} +{{if .InstanceRows}}{{template "rows" .InstanceRows}}{{end}} +{{if .SelfHosted}}{{template "p" "Self-hosted installs keep running until the licence date shown above. No new licence will be issued."}}{{end}} +{{template "p" (printf "Questions: %s, quoting dispute reference %s." .DisputeEmail .DisputeID)}} +{{end}} diff --git a/mail/templates/accountterminated.txt.tmpl b/mail/templates/accountterminated.txt.tmpl new file mode 100644 index 0000000..795b322 --- /dev/null +++ b/mail/templates/accountterminated.txt.tmpl @@ -0,0 +1,10 @@ +{{define "subject"}}Your Vantage account {{.AccountName}} is terminated{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Terminated" "tone" "down")}}{{end}} +{{define "title"}}Your account is terminated{{end}} +{{define "body"}} +{{template "lead" (printf "The dispute for %s was not upheld, and the account is terminated. Reason: %s." .AccountName .Reason)}} +{{template "p" "Every subscription is cancelled and no refund is due. Sign-in is removed and cloud instances are being deleted. This cannot be undone."}} +{{if .InstanceRows}}{{template "rows" .InstanceRows}}{{end}} +{{if .SelfHosted}}{{template "p" "Self-hosted installs keep running until the licence date shown above. No new licence will be issued."}}{{end}} +{{template "p" (printf "Questions: %s, quoting dispute reference %s." .DisputeEmail .DisputeID)}} +{{end}} diff --git a/mail/templates/disputereminder.html.tmpl b/mail/templates/disputereminder.html.tmpl new file mode 100644 index 0000000..93af448 --- /dev/null +++ b/mail/templates/disputereminder.html.tmpl @@ -0,0 +1,7 @@ +{{define "pill"}}{{template "chip" (dict "label" "Dispute deadline" "tone" "pend")}}{{end}} +{{define "title"}}Your account is still locked{{end}} +{{define "body"}} +{{template "lead" (printf "The Vantage account %s is still locked. Reason: %s." .AccountName .Reason)}} +{{template "p" (printf "To dispute this, reply to %s before %s, quoting dispute reference %s." .DisputeEmail (date .DisputeBy) .DisputeID)}} +{{template "p" "After that date the dispute is decided. If it is not upheld, the account is terminated and its cloud instances deleted."}} +{{end}} diff --git a/mail/templates/disputereminder.txt.tmpl b/mail/templates/disputereminder.txt.tmpl new file mode 100644 index 0000000..24d33de --- /dev/null +++ b/mail/templates/disputereminder.txt.tmpl @@ -0,0 +1,8 @@ +{{define "subject"}}{{.AccountName}}: dispute deadline {{shortDate .DisputeBy}}{{end}} +{{define "pill"}}{{template "chip" (dict "label" "Dispute deadline" "tone" "pend")}}{{end}} +{{define "title"}}Your account is still locked{{end}} +{{define "body"}} +{{template "lead" (printf "The Vantage account %s is still locked. Reason: %s." .AccountName .Reason)}} +{{template "p" (printf "To dispute this, reply to %s before %s, quoting dispute reference %s." .DisputeEmail (date .DisputeBy) .DisputeID)}} +{{template "p" "After that date the dispute is decided. If it is not upheld, the account is terminated and its cloud instances deleted."}} +{{end}}