This commit is contained in:
+2
-1
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/mrhid6/vantage/admin/internal/mail"
|
||||
"github.com/mrhid6/vantage/admin/internal/models"
|
||||
"github.com/mrhid6/vantage/admin/internal/paddle"
|
||||
sharedmail "github.com/mrhid6/vantage/shared/mail"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -40,7 +41,7 @@ func main() {
|
||||
log.Fatalf("paddle init: %v", err)
|
||||
}
|
||||
|
||||
mail.Init(mail.Config{
|
||||
mail.Init(sharedmail.Sender{
|
||||
Host: cfg.SMTPHost, Port: cfg.SMTPPort, From: cfg.SMTPFrom,
|
||||
Username: cfg.SMTPUsername, Password: cfg.SMTPPassword,
|
||||
PublicURL: cfg.PublicURL,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() }
|
||||
|
||||
Reference in New Issue
Block a user