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
+3 -3
View File
@@ -354,7 +354,7 @@ func createInstance(c *gin.Context) {
inject.Deliver(ctx, lic)
if mail.Enabled() {
if err := mail.SendInstanceReady(s.Email, inst.Name,
if err := mail.Default.SendInstanceReady(s.Email, inst.Name,
loginURLFor(inst.Slug), lic.ExpiresAt); err != nil {
log.Printf("createInstance: instance-ready email to %s: %v", s.Email, err)
}
@@ -431,7 +431,7 @@ func renewInstance(c *gin.Context) {
Target: inst.InstanceID, IP: c.ClientIP()})
if mail.Enabled() {
if err := mail.SendRenewed(s.Email, inst.Name, lic.ExpiresAt); err != nil {
if err := mail.Default.SendRenewed(s.Email, inst.Name, lic.ExpiresAt); err != nil {
log.Printf("renewInstance: renewed email to %s: %v", s.Email, err)
}
}
@@ -550,6 +550,6 @@ func deliver(c *gin.Context, inst *models.Instance, lic *models.License) {
}
s := auth.Current(c)
if s != nil && mail.Enabled() {
_ = mail.SendLicense(s.Email, inst.Name, lic.Blob)
_ = mail.Default.SendLicense(s.Email, inst.Name, lic.Blob)
}
}
+8 -3
View File
@@ -16,6 +16,7 @@ import (
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/mail"
"github.com/mrhid6/vantage/admin/internal/models"
sharedmail "github.com/mrhid6/vantage/shared/mail"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
@@ -26,7 +27,11 @@ const BcryptCost = 12
// VerifyWindow mirrors sitesvc's proven pattern: 32 random bytes, only the
// SHA-256 hash stored, 24-hour expiry.
const VerifyWindow = 24 * time.Hour
//
// It is shared/mail's constant rather than a second copy because the
// verification email states the number of hours: a window that disagreed with
// what the email promised would expire links early with no explanation.
const VerifyWindow = sharedmail.VerifyWindow
// CreateCustomerUser creates an unverified HQ login with a chosen password and
// emails the verification link. Used by signup and by staff.
@@ -58,7 +63,7 @@ func CreateCustomerUser(ctx context.Context, accountID, email, password, account
return err
}
if err := mail.SendVerification(u.Email, token); err != nil {
if err := mail.Default.SendVerification(u.Email, token); err != nil {
// Undo the insert. A row whose verification link was never delivered is
// worse than no row: it can never be signed in to, and it holds the
// unique index on email, so the customer cannot sign up again with the
@@ -109,7 +114,7 @@ func CreateInvitedUser(ctx context.Context, accountID, accountName, email, accou
return err
}
if err := mail.SendInvite(u.Email, accountName, token); err != nil {
if err := mail.Default.SendInvite(u.Email, accountName, token); err != nil {
// Same rollback rule, and the same detached context, as signup: a row
// whose link was never delivered can never be signed in to and holds
// the unique index on email against the person it was meant for.
+1 -1
View File
@@ -19,6 +19,6 @@ func deliver(ctx context.Context, inst *models.Instance, lic *models.License, to
return
}
if to != "" && mail.Enabled() {
_ = mail.SendLicense(to, inst.Name, lic.Blob)
_ = mail.Default.SendLicense(to, inst.Name, lic.Blob)
}
}
+2 -2
View File
@@ -176,7 +176,7 @@ func handleCanceled(ctx context.Context, ev Event) error {
return err
}
if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" {
_ = mail.SendCancelled(to, instanceNameFor(ctx, d.CustomData.InstanceID))
_ = mail.Default.SendCancelled(to, instanceNameFor(ctx, d.CustomData.InstanceID))
}
return nil
}
@@ -194,7 +194,7 @@ func handlePastDue(ctx context.Context, ev Event) error {
return err
}
if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" {
_ = mail.SendPastDue(to, instanceNameFor(ctx, d.CustomData.InstanceID))
_ = mail.Default.SendPastDue(to, instanceNameFor(ctx, d.CustomData.InstanceID))
}
return nil
}
+5 -5
View File
@@ -133,13 +133,13 @@ func sendNotice(ctx context.Context, inst models.Instance, lic models.License, k
switch key {
case noticeExpiring:
return mail.SendExpiring(to, inst.Name, portalURL, lic.ExpiresAt)
return mail.Default.SendExpiring(to, inst.Name, portalURL, lic.ExpiresAt)
case noticeExpired:
return mail.SendExpired(to, inst.Name, portalURL, deleteOn)
return mail.Default.SendExpired(to, inst.Name, portalURL, deleteOn)
case noticeDelete7:
return mail.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 7)
return mail.Default.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 7)
case noticeDelete1:
return mail.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 1)
return mail.Default.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 1)
}
return nil
}
@@ -223,7 +223,7 @@ func sweepAwaitingLink(ctx context.Context) {
if due == "" {
continue
}
if err := mail.SendLinkReminder(to, inst.Name); err != nil {
if err := mail.Default.SendLinkReminder(to, inst.Name); err != nil {
log.Printf("lifecycle: link reminder %s for %s: %v", due, inst.InstanceID, err)
continue
}
+14 -247
View File
@@ -1,252 +1,19 @@
// Package mail delivers verification links and licence files.
// Package mail holds admin's configured email sender.
//
// The transport, the templates and the look all live in shared/mail, which the
// control plane and sitesvc use too — this package exists only so that admin's
// mail configuration is a boot-time singleton like licensing's signing key,
// paddle's client and auth's Redis handle, rather than a value threaded through
// api, auth, billing and lifecycle.
package mail
import (
"crypto/rand"
"crypto/tls"
"encoding/hex"
"fmt"
"mime"
"net"
"net/smtp"
"strings"
"time"
)
import "github.com/mrhid6/vantage/shared/mail"
// 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 signup's rollback runs on that request's context.
const timeout = 15 * time.Second
// Default is admin's sender. Set once by main; read everywhere else.
var Default mail.Sender
type Config struct {
Host, Port, From, Username, Password string
PublicURL string
}
func Init(s mail.Sender) { Default = s }
var cfg Config
func Init(c Config) { cfg = c }
func Enabled() bool { return cfg.Host != "" && cfg.From != "" }
// 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 — this exact bug silently
// stopped every admin email from being delivered.
//
// This mirrors sitesvc/internal/mail, which solved the same problem first. The
// two are duplicated rather than shared; if you change the transport here,
// change it there too, or consolidate both into shared/.
func send(to, subject, body string) error {
if !Enabled() {
return fmt.Errorf("SMTP is not configured")
}
if strings.TrimSpace(to) == "" {
return fmt.Errorf("smtp: no recipient")
}
addr := net.JoinHostPort(cfg.Host, cfg.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 cfg.Port == "465" {
conn = tls.Client(conn, &tls.Config{ServerName: cfg.Host})
}
client, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp: client: %w", err)
}
defer client.Close()
if cfg.Port != "465" {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: cfg.Host}); err != nil {
return fmt.Errorf("smtp: starttls: %w", err)
}
}
}
if cfg.Username != "" {
if err := client.Auth(smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)); err != nil {
return fmt.Errorf("smtp: auth: %w", err)
}
}
if err := client.Mail(cfg.From); err != nil {
return fmt.Errorf("smtp: mail from: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("smtp: rcpt %s: %w", to, err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp: data: %w", err)
}
if _, err := w.Write(message(to, subject, 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()
}
// message builds the RFC 5322 envelope.
//
// 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".
// Header values are stripped of CR and LF so a crafted instance name cannot
// inject extra headers.
func message(to, subject, body string) []byte {
var b strings.Builder
b.WriteString("From: " + sanitizeHeader(cfg.From) + "\r\n")
b.WriteString("To: " + sanitizeHeader(to) + "\r\n")
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
b.WriteString("Message-ID: " + messageID(cfg.From) + "\r\n")
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", sanitizeHeader(subject)) + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("\r\n")
b.WriteString(body)
return []byte(b.String())
}
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)
}
func SendVerification(to, token string) error {
link := fmt.Sprintf("%s/verify?token=%s", cfg.PublicURL, token)
return send(to, "Verify your Vantage account",
"Confirm your email address to finish setting up your Vantage account:\n\n"+
link+"\n\nThis link expires in 24 hours.\n")
}
// 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 SendInvite(to, accountName, token string) error {
link := fmt.Sprintf("%s/accept-invite?token=%s", cfg.PublicURL, token)
return send(to, "You have been invited to "+sanitizeHeader(accountName)+" on Vantage",
fmt.Sprintf("You have been invited to join %s on Vantage.\n\n"+
"Set your password and finish joining:\n\n%s\n\n"+
"This link expires in 24 hours. If you were not expecting this, ignore it — "+
"nothing happens until you open the link.\n", accountName, link))
}
// 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 SendLicense(to, instanceName, blob string) error {
return send(to, "Your Vantage licence key",
fmt.Sprintf("Your licence for %s is below.\n\n"+
"Paste it into Settings → Licence on your Vantage install:\n\n%s\n",
instanceName, blob))
}
// SendCancelled confirms a cancellation and states what stays true: the licence
// keeps working until it expires, then the instance degrades to read-only.
func SendCancelled(to, instanceName string) error {
return send(to, "Your Vantage subscription is cancelled",
fmt.Sprintf("Your subscription for %s is cancelled.\n\n"+
"Your instance keeps working until the current licence expires. After "+
"that, monitors keep running but changes are disabled.\n", instanceName))
}
// SendPastDue notifies of a failed charge without alarming: the licence is
// untouched while Paddle retries the card.
func SendPastDue(to, instanceName string) error {
return send(to, "Payment failed for your Vantage subscription",
fmt.Sprintf("A payment for %s failed.\n\n"+
"Your instance is unaffected while the card is retried. Update your "+
"payment method from the billing portal.\n", instanceName))
}
// SendLinkReminder chases a self-hosted customer who paid but never linked.
func SendLinkReminder(to, instanceName string) error {
return send(to, "Finish setting up "+instanceName,
fmt.Sprintf("Your subscription for %s is active, but the instance is not "+
"linked yet.\n\nPaste your install's ID in the portal to receive your "+
"licence.\n", instanceName))
}
// 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 SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error {
body := fmt.Sprintf("%s is ready.\n\n", instanceName)
if loginURL != "" {
body += "Sign in here:\n\n" + loginURL + "\n\n"
}
body += fmt.Sprintf(
"Your Free licence runs until %s. We will email you before then so you can renew it in one click.\n\n"+
"Sign in with the same email address and password you use for your Vantage account. "+
"Changing your Vantage HQ password changes it here too.\n",
expires.Format("2 January 2006"))
return send(to, instanceName+" is ready", body)
}
// SendRenewed confirms a renewal and states the new date.
func SendRenewed(to, instanceName string, expires time.Time) error {
return send(to, instanceName+" renewed",
fmt.Sprintf("%s is renewed.\n\nYour Free licence now runs until %s.\n",
instanceName, expires.Format("2 January 2006")))
}
// SendExpiring is the renew-now nudge, seven days out.
func SendExpiring(to, instanceName, portalURL string, expires time.Time) error {
return send(to, instanceName+" expires on "+expires.Format("2 January"),
fmt.Sprintf("%s's Free licence runs out on %s.\n\n"+
"Renew it in one click:\n\n%s\n\n"+
"If you do nothing, the instance keeps running but stops accepting changes.\n",
instanceName, expires.Format("2 January 2006"), portalURL))
}
// 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.
func SendExpired(to, instanceName, portalURL string, deleteOn time.Time) error {
return send(to, instanceName+" is now read-only",
fmt.Sprintf("%s's Free licence has expired.\n\n"+
"Your servers and monitors keep running and your agents keep their keys, "+
"but changes are disabled.\n\n"+
"Renew it here:\n\n%s\n\n"+
"If it is not renewed, the instance and everything in it will be deleted on %s.\n",
instanceName, portalURL, deleteOn.Format("2 January 2006")))
}
// SendDeletionWarning is the final countdown, sent at seven days and one day.
func SendDeletionWarning(to, instanceName, portalURL string, deleteOn time.Time, daysLeft int) error {
when := fmt.Sprintf("in %d days", daysLeft)
if daysLeft <= 1 {
when = "tomorrow"
}
return send(to, instanceName+" will be deleted "+when,
fmt.Sprintf("%s and everything in it will be deleted %s, on %s.\n\n"+
"This cannot be undone. Renew it here to keep it:\n\n%s\n",
instanceName, when, deleteOn.Format("2 January 2006"), portalURL))
}
// Enabled reports whether SMTP is configured. Callers check it to skip a send
// politely rather than logging a failure per message.
func Enabled() bool { return Default.Enabled() }