Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91841174dd | ||
|
|
fbc9d4b9c4 | ||
|
|
50ab63ff13 | ||
|
|
5c87505820 |
@@ -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)
|
||||
}
|
||||
@@ -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, "<no value>") {
|
||||
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])
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-15
@@ -14,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"net/textproto"
|
||||
@@ -180,27 +181,21 @@ func recipients(to string) []string {
|
||||
// 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.
|
||||
//
|
||||
// Both parts are quoted-printable. They were raw UTF-8 with no
|
||||
// Content-Transfer-Encoding, which means 7bit, and every template carries
|
||||
// non-ASCII (the middot in the masthead at least). rspamd scored that
|
||||
// R_BAD_CTE_7BIT, the message went out without a DKIM signature, and Gmail
|
||||
// filed it as spam, while mail from the same mailbox via SOGo, sent
|
||||
// quoted-printable, was signed and delivered.
|
||||
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 {
|
||||
if err := writeQPPart(w, "text/plain; charset=utf-8", m.Text); 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 {
|
||||
if err := writeQPPart(w, "text/html; charset=utf-8", m.HTML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -224,6 +219,23 @@ func (s Sender) envelope(m message) ([]byte, error) {
|
||||
return []byte(b.String()), nil
|
||||
}
|
||||
|
||||
// writeQPPart adds one quoted-printable part, so the wire stays 7-bit and
|
||||
// lines stay under SMTP's 998-byte limit whatever the template produced.
|
||||
func writeQPPart(w *multipart.Writer, contentType, body string) error {
|
||||
part, err := w.CreatePart(textproto.MIMEHeader{
|
||||
"Content-Type": {contentType},
|
||||
"Content-Transfer-Encoding": {"quoted-printable"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
qp := quotedprintable.NewWriter(part)
|
||||
if _, err := qp.Write([]byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
return qp.Close()
|
||||
}
|
||||
|
||||
func messageID(from string) string {
|
||||
domain := "vantage.local"
|
||||
if at := strings.LastIndex(from, "@"); at >= 0 && at < len(from)-1 {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every part must declare quoted-printable and put only 7-bit bytes on the
|
||||
// wire. Raw UTF-8 under an implied 7bit encoding scored R_BAD_CTE_7BIT in
|
||||
// rspamd, went out without a DKIM signature, and landed in Gmail's spam.
|
||||
func TestEnvelopePartsAreQuotedPrintable(t *testing.T) {
|
||||
s := Sender{From: "Vantage <support@example.com>"}
|
||||
m := message{
|
||||
To: "a@example.com",
|
||||
Subject: "Smith & Sons · restored",
|
||||
Text: "VANTAGE · Account\nBilling has resumed.",
|
||||
HTML: "<p>VANTAGE · Account</p><p>" + strings.Repeat("x", 2000) + "</p>",
|
||||
}
|
||||
raw, err := s.envelope(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, c := range raw {
|
||||
if c > 0x7e && c != '\r' && c != '\n' {
|
||||
t.Fatalf("byte %d is 0x%x: the wire must be 7-bit", i, c)
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\r\n") {
|
||||
if len(line) > 998 {
|
||||
t.Fatalf("line of %d bytes exceeds SMTP's 998", len(line))
|
||||
}
|
||||
}
|
||||
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := multipart.NewReader(msg.Body, params["boundary"])
|
||||
want := []string{m.Text, m.HTML}
|
||||
for i := range want {
|
||||
p, err := r.NextRawPart()
|
||||
if err != nil {
|
||||
t.Fatalf("part %d: %v", i, err)
|
||||
}
|
||||
if cte := p.Header.Get("Content-Transfer-Encoding"); cte != "quoted-printable" {
|
||||
t.Fatalf("part %d Content-Transfer-Encoding = %q", i, cte)
|
||||
}
|
||||
// NextPart would decode for us; NextRawPart keeps the check honest by
|
||||
// decoding here, so a missing header cannot pass by accident.
|
||||
body, err := io.ReadAll(quotedprintable.NewReader(p))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Quoted-printable text mode writes line breaks as CRLF, which is the
|
||||
// canonical form on the wire.
|
||||
if strings.ReplaceAll(string(body), "\r\n", "\n") != want[i] {
|
||||
t.Fatalf("part %d decodes to %q", i, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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 on the period you already paid for, so there is no charge today."}}
|
||||
{{template "p" "You will need to sign in again."}}
|
||||
{{end}}
|
||||
@@ -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 on the period you already paid for, so there is no charge today."}}
|
||||
{{template "p" "You will need to sign in again."}}
|
||||
{{end}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -31,4 +31,12 @@ type Instance struct {
|
||||
LicenseBlob string `bson:"license_blob,omitempty" json:"-"`
|
||||
LicenseTier string `bson:"license_tier,omitempty" json:"license_tier,omitempty"`
|
||||
LicenseExpiry *time.Time `bson:"license_expiry,omitempty" json:"license_expiry,omitempty"`
|
||||
|
||||
// LockedAt and PurgeAfter are written by vantage-admin's cloudprov when the
|
||||
// account that owns this instance is under a dispute. LockedAt makes the
|
||||
// control plane refuse the instance everywhere; PurgeAfter, set only once a
|
||||
// dispute has failed, authorises the reaper to delete it. Both absent is the
|
||||
// normal state, and nothing but admin ever sets them.
|
||||
LockedAt *time.Time `bson:"locked_at,omitempty" json:"locked_at,omitempty"`
|
||||
PurgeAfter *time.Time `bson:"purge_after,omitempty" json:"purge_after,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestInstanceLockFieldsBSON(t *testing.T) {
|
||||
at := time.Date(2026, 9, 10, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
raw, err := bson.Marshal(Instance{InstanceID: "i1", LockedAt: &at, PurgeAfter: &at})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var set bson.M
|
||||
if err := bson.Unmarshal(raw, &set); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
for _, k := range []string{"locked_at", "purge_after"} {
|
||||
if _, ok := set[k]; !ok {
|
||||
t.Errorf("%s missing from %v", k, set)
|
||||
}
|
||||
}
|
||||
|
||||
raw, err = bson.Marshal(Instance{InstanceID: "i1"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var unset bson.M
|
||||
if err := bson.Unmarshal(raw, &unset); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
for _, k := range []string{"locked_at", "purge_after"} {
|
||||
if _, ok := unset[k]; ok {
|
||||
t.Errorf("%s written for an unlocked instance: %v", k, unset)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user