|
|
|
@@ -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() }
|
|
|
|
|