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
+2 -1
View File
@@ -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,
+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() }
+36 -3
View File
@@ -61,7 +61,7 @@ vantage/
│ ├── grpc/ # gRPC server + generated pb
│ ├── models/ # MongoDB documents
│ ├── monitorsched/ # server-side monitor scheduler
│ ├── notify/ # smtp, http, templating, dispatch
│ ├── notify/ # channel dispatch: http, discord, slack, telegram, smtp
│ └── services/ # business logic + migrations
├── web/ # the application UI (authenticated)
│ ├── app/(app)/ # authed routes
@@ -77,7 +77,6 @@ vantage/
│ ├── cmd/main.go
│ └── internal/
│ ├── api/ # contact
│ ├── mail/ # SMTP
│ └── store/ # Mongo connect helper
├── admin/ # licensing authority: the only signer
│ ├── cmd/main.go # boot: two Mongo connections, reconciler, HTTP
@@ -88,7 +87,7 @@ vantage/
│ ├── inject/ # licence write path into the control plane
│ ├── cloudprov/ # instance write path: creates instances + owners
│ ├── licensing/ # Issue, LinkInstance, Relink
│ ├── mail/ # verification and licence delivery
│ ├── mail/ # admin's boot-time shared/mail Sender
│ └── models/ # accounts, instances, licences, plans
├── adminsite/ # staff + customer console (vantage-hq)
│ ├── app/(customer)/ # overview, instance, link, billing
@@ -96,6 +95,7 @@ vantage/
│ ├── components/ # AppBar, PageHeader, PageFrame, InstanceRecord
│ └── lib/ # api client, session guards, formatters
├── shared/ # imported by server, sitesvc and admin
│ ├── mail/ # the one email system: transport + tmpl templates
│ ├── license/ # payload, sign, verify, trusted keys, plans
│ ├── models/ # Instance, User, Settings
│ └── cmd/lkctl/ # issue and inspect licences by hand
@@ -191,6 +191,37 @@ person is later granted. `GET /auth/verify` therefore peeks before it consumes:
a token belonging to a passwordless row answers `{"needs_password":true}` and is
left unspent.
### Email
`shared/mail` is the only email system. It owns the SMTP conversation, the RFC
5322 envelope and the look of every message; `server`, `admin` and `sitesvc`
each import it and none of them builds a subject line, a MIME part or a colour.
Before this existed the transport was copied three times, and the copies had
already diverged once — the 465-implicit-TLS fix landed in one of them while
the others silently delivered nothing.
`Sender` is a value, not a singleton: `server/internal/notify` builds one per
notification channel from the channel document in Mongo, while `sitesvc` builds
one at boot and `admin` holds one in `admin/internal/mail.Default`, alongside
its other boot-time singletons. Callers only ever see typed methods —
`SendVerification`, `SendExpiring`, `SendMonitorAlert`, `SendEnquiry` and the
rest, grouped by owner into `account.go`, `licence.go`, `billing.go`,
`monitor.go` and `contact.go`.
Every message is `multipart/alternative`, so each one is two templates:
`templates/<name>.html.tmpl` and `.txt.tmpl`, embedded with `go:embed`. They
define `subject`, `title`, `pill` and `body`; `layout.html.tmpl` and
`layout.txt.tmpl` provide the chrome and the helper templates (`p`, `lead`,
`button`, `well`, `note`, `rows`, `chip`) that the bodies compose. One template
set is parsed per message rather than one big set, because every message
defines those same four names. **`subject` is defined in the txt file only** —
`html/template` would escape an ampersand in an instance name and mail clients
show subjects verbatim.
`shared/mail/render_test.go` renders all of them and fails if a template exists
that no case covers, which is the only thing standing between a mistyped field
and a boot-time panic — the templates are parsed in `init()`.
### Shared provisioning
`shared/provision` (`instance.go`, `slug.go`, `user.go`) holds the slug rules, reserved names and instance/user creation logic that both `server` and `admin/internal/cloudprov` need, so there is no longer a second copy to drift: `cloudprov.CreateInstance` calls straight into it to create a control-plane instance and its owner from a customer request.
@@ -526,6 +557,8 @@ All three apps are **one visual system**, anchored on the logo navy. What differ
`adminsite/app/globals.css` holds `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values. `web/app/globals.css` holds the same tokens too, but only the **dark** values, since it does not switch. **Change a token in all three files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules.
There is a **fourth** copy, and it is the one people forget: `shared/mail/templates/layout.html.tmpl` carries web/'s dark values as literal hex. Email clients support neither `var()` nor a reliable `prefers-color-scheme`, so the token indirection is simply not available there — an email is read before the recipient clicks through to the control plane, and the two should not look like different products. Every colour in the email system is in that one file, in the same way no component in the three web apps carries a hex.
Tailwind in all three maps `var(--…)` references only, so **no component in any of them may carry a hex value**. The names differ per app on purpose, because each app has its own subject: `site/` calls the semantic three `--up`/`--pend`/`--down` for monitor state, `adminsite/` aliases them to `valid`/`warn`/`expired` for licence state, and `web/` to `success`/`warning`/`danger`. Same colours, honest names on each side.
`web/` stores its tokens as **RGB channel triplets** with the hex in a trailing comment, and derives `--token: rgb(var(--token-rgb))` from them. That is not a style preference: the console leans on Tailwind's opacity modifiers (`bg-danger/10`, `border-accent/50`, `ring-accent/30`) in a way the other two do not, and `<alpha-value>` only compiles against channels. Keep the hex comments — they are what lets the three token blocks still be diffed by eye. `web/` also adds three tokens site/ has no use for: `--accent-hover` and `--down-hover` (site/ brightens with a CSS `filter`, which a Tailwind colour token cannot do) and `--well`, the floor beneath the ground for install one-liners, key blobs and run logs — surfaces showing machine output rather than interface.
+24 -76
View File
@@ -1,90 +1,38 @@
package notify
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
"time"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/shared/mail"
)
const smtpTimeout = 15 * time.Second
// dispatchSMTP delivers a state change over one channel's own SMTP settings.
//
// The transport, the envelope and the look of the message all live in
// shared/mail, which admin and sitesvc use too — a Vantage alert and a Vantage
// licence email should not look like they came from different products. This
// function only turns a channel document into a Sender.
func dispatchSMTP(ch models.NotificationChannel, ev Event) error {
host := ch.Config["host"]
port := ch.Config["port"]
from := ch.Config["from"]
to := ch.Config["to"]
if host == "" || port == "" || from == "" || to == "" {
sender := mail.Sender{
Host: ch.Config["host"],
Port: ch.Config["port"],
From: ch.Config["from"],
Username: ch.Config["username"],
Password: ch.Config["password"],
}
if !sender.Enabled() || sender.Port == "" || to == "" {
return fmt.Errorf("smtp: missing host/port/from/to")
}
addr := net.JoinHostPort(host, port)
conn, err := net.DialTimeout("tcp", addr, smtpTimeout)
if err != nil {
return fmt.Errorf("smtp: dial %s: %w", addr, err)
}
_ = conn.SetDeadline(time.Now().Add(smtpTimeout))
if port == "465" {
conn = tls.Client(conn, &tls.Config{ServerName: host})
}
c, err := smtp.NewClient(conn, host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp: client: %w", err)
}
defer c.Close()
if port != "465" {
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
return fmt.Errorf("smtp: starttls: %w", err)
}
}
}
if user := ch.Config["username"]; user != "" {
if err := c.Auth(smtp.PlainAuth("", user, ch.Config["password"], host)); err != nil {
return fmt.Errorf("smtp: auth: %w", err)
}
}
recipients := strings.Split(to, ",")
for i := range recipients {
recipients[i] = strings.TrimSpace(recipients[i])
}
if err := c.Mail(from); err != nil {
return fmt.Errorf("smtp: mail from: %w", err)
}
for _, rcpt := range recipients {
if rcpt == "" {
continue
}
if err := c.Rcpt(rcpt); err != nil {
return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)
}
}
msg, err := buildMIME(from, to, ev.title(), textEmail(ev), htmlEmail(ev))
if err != nil {
return fmt.Errorf("smtp: build message: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("smtp: data: %w", err)
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("smtp: write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp: close data: %w", err)
}
return c.Quit()
return sender.SendMonitorAlert(to, mail.MonitorEvent{
MonitorName: ev.MonitorName,
Type: ev.Type,
OldStatus: ev.OldStatus,
NewStatus: ev.NewStatus,
Message: ev.Message,
Time: ev.Time,
Down: ev.NewStatus == models.StatusDown,
})
}
-161
View File
@@ -1,161 +0,0 @@
package notify
import (
"fmt"
"html"
"mime/multipart"
"net/textproto"
"strings"
"github.com/mrhid6/vantage/server/internal/models"
)
const (
colBg = "#0f1117"
colSurface = "#1a1d27"
colSurface2 = "#232635"
colBorder = "#2e3147"
colText = "#e8eaf0"
colTextMuted = "#9095a8"
colAccent = "#6366f1"
colSuccess = "#22c55e"
colDanger = "#ef4444"
)
func statusColor(status string) string {
switch status {
case models.StatusUp:
return colSuccess
case models.StatusDown:
return colDanger
default:
return colTextMuted
}
}
func buildMIME(from, to, subject, text, htmlBody string) ([]byte, error) {
var buf strings.Builder
w := multipart.NewWriter(&buf)
var head strings.Builder
head.WriteString("From: " + from + "\r\n")
head.WriteString("To: " + to + "\r\n")
head.WriteString("Subject: " + subject + "\r\n")
head.WriteString("MIME-Version: 1.0\r\n")
head.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%s\r\n\r\n", w.Boundary()))
textPart, err := w.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/plain; charset=UTF-8"}})
if err != nil {
return nil, err
}
textPart.Write([]byte(text))
htmlPart, err := w.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/html; charset=UTF-8"}})
if err != nil {
return nil, err
}
htmlPart.Write([]byte(htmlBody))
if err := w.Close(); err != nil {
return nil, err
}
return []byte(head.String() + buf.String()), nil
}
func htmlEmail(ev Event) string {
accent := statusColor(ev.NewStatus)
label := "Recovered"
if ev.NewStatus == models.StatusDown {
label = "Down"
}
esc := html.EscapeString
row := func(k, v string) string {
if v == "" {
v = ""
}
return fmt.Sprintf(
`<tr>`+
`<td style="padding:8px 0;color:%s;font-size:13px;width:120px;">%s</td>`+
`<td style="padding:8px 0;color:%s;font-size:13px;font-weight:500;">%s</td>`+
`</tr>`,
colTextMuted, k, colText, esc(v))
}
message := ""
if ev.Message != "" {
message = fmt.Sprintf(
`<p style="margin:0 0 20px;padding:12px 14px;background:%s;border:1px solid %s;border-radius:8px;color:%s;font-size:13px;">%s</p>`,
colSurface2, colBorder, colText, esc(ev.Message))
}
transition := esc(ev.OldStatus) + " → " + esc(ev.NewStatus)
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:%s;">
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background:%s;padding:32px 0;">
<tr>
<td align="center">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="max-width:480px;background:%s;border:1px solid %s;border-radius:12px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<tr><td style="height:4px;background:%s;"></td></tr>
<tr>
<td style="padding:28px 28px 20px;">
<table role="presentation" cellpadding="0" cellspacing="0" style="margin-bottom:18px;">
<tr>
<td style="font-size:18px;font-weight:700;color:%s;">Vantage</td>
</tr>
</table>
<span style="display:inline-block;padding:4px 12px;border-radius:9999px;background:%s22;color:%s;font-size:12px;font-weight:600;letter-spacing:.02em;">%s</span>
<h1 style="margin:14px 0 6px;font-size:20px;font-weight:700;color:%s;">%s</h1>
<p style="margin:0 0 20px;color:%s;font-size:13px;">%s check</p>
%s
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="border-top:1px solid %s;">
%s
%s
%s
</table>
</td>
</tr>
<tr>
<td style="padding:16px 28px;border-top:1px solid %s;">
<p style="margin:0;color:%s;font-size:12px;">Sent by Vantage · self-hosted service monitoring</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`,
colBg,
colBg,
colSurface, colBorder,
accent,
colText,
accent, accent, label,
colText, esc(ev.MonitorName),
colTextMuted, esc(ev.Type),
message,
colBorder,
row("Status", transition),
row("Type", ev.Type),
row("Time", ev.Time.Format("2006-01-02 15:04:05 MST")),
colBorder,
colTextMuted,
)
}
func textEmail(ev Event) string {
return strings.Join([]string{
ev.title(),
"",
"Monitor: " + ev.MonitorName,
"Type: " + ev.Type,
"Status: " + ev.OldStatus + " -> " + ev.NewStatus,
"Message: " + ev.Message,
"Time: " + ev.Time.Format("2006-01-02 15:04:05 MST"),
"",
"Sent by Vantage · self-hosted service monitoring",
}, "\r\n")
}
+37
View File
@@ -0,0 +1,37 @@
package mail
import (
"fmt"
"time"
)
// VerifyWindow is how long a verification or invitation link stays usable. It
// is stated in the email, so it lives beside the message rather than in the
// caller.
const VerifyWindow = 24 * time.Hour
// SendVerification asks someone to confirm the address they signed up with.
// The link is built from the Sender's PublicURL, because the address of the
// portal is a property of the deployment, not of the call site.
func (s Sender) SendVerification(to, token string) error {
return s.sendTemplate(to, "", "verification", struct {
Link string
TTLHours int
}{
Link: fmt.Sprintf("%s/verify?token=%s", s.PublicURL, token),
TTLHours: int(VerifyWindow.Hours()),
})
}
// 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 (s Sender) SendInvite(to, accountName, token string) error {
return s.sendTemplate(to, "", "invite", struct {
AccountName string
Link string
}{
AccountName: accountName,
Link: fmt.Sprintf("%s/accept-invite?token=%s", s.PublicURL, token),
})
}
+18
View File
@@ -0,0 +1,18 @@
package mail
// SendCancelled confirms a cancellation and states what stays true: the licence
// keeps working until it expires, then the instance degrades to read-only.
func (s Sender) SendCancelled(to, instanceName string) error {
return s.sendTemplate(to, "", "cancelled", struct {
InstanceName string
}{instanceName})
}
// SendPastDue notifies of a failed charge without alarming: the licence is
// untouched while Paddle retries the card.
func (s Sender) SendPastDue(to, instanceName string) error {
return s.sendTemplate(to, "", "pastdue", struct {
InstanceName string
PortalURL string
}{instanceName, s.PublicURL})
}
+27
View File
@@ -0,0 +1,27 @@
package mail
import "time"
// Enquiry is one submission of the public site's contact form.
type Enquiry struct {
Name string
Email string
Servers string
Topic string
Message string
}
// SendEnquiry forwards a contact-form submission to the support address.
//
// Reply-To is the sender's address, not From: the message is sent by our own
// SMTP identity so it passes SPF, but hitting reply must reach the person who
// filled the form in.
func (s Sender) SendEnquiry(to string, e Enquiry) error {
return s.sendTemplate(to, e.Email, "contact", struct {
Enquiry
Received string
}{
Enquiry: e,
Received: time.Now().UTC().Format(time.RFC1123),
})
}
+81
View File
@@ -0,0 +1,81 @@
package mail
import (
"fmt"
"time"
)
// 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 (s Sender) SendLicense(to, instanceName, blob string) error {
return s.sendTemplate(to, "", "license", struct {
InstanceName string
Blob string
}{instanceName, blob})
}
// 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 (s Sender) SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error {
return s.sendTemplate(to, "", "instanceready", struct {
InstanceName string
LoginURL string
Expires time.Time
}{instanceName, loginURL, expires})
}
// SendRenewed confirms a renewal and states the new date.
func (s Sender) SendRenewed(to, instanceName string, expires time.Time) error {
return s.sendTemplate(to, "", "renewed", struct {
InstanceName string
Expires time.Time
}{instanceName, expires})
}
// SendExpiring is the renew-now nudge, seven days out.
func (s Sender) SendExpiring(to, instanceName, portalURL string, expires time.Time) error {
return s.sendTemplate(to, "", "expiring", struct {
InstanceName string
PortalURL string
Expires time.Time
}{instanceName, portalURL, expires})
}
// 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. A
// zero deleteOn means the reaper is disabled, and then no date is claimed.
func (s Sender) SendExpired(to, instanceName, portalURL string, deleteOn time.Time) error {
return s.sendTemplate(to, "", "expired", struct {
InstanceName string
PortalURL string
DeleteOn time.Time
}{instanceName, portalURL, deleteOn})
}
// SendDeletionWarning is the final countdown, sent at seven days and one day.
func (s Sender) SendDeletionWarning(to, instanceName, portalURL string, deleteOn time.Time, daysLeft int) error {
when := fmt.Sprintf("in %d days", daysLeft)
if daysLeft <= 1 {
when = "tomorrow"
}
return s.sendTemplate(to, "", "deletionwarning", struct {
InstanceName string
PortalURL string
When string
DeleteOn time.Time
}{instanceName, portalURL, when, deleteOn})
}
// SendLinkReminder chases a self-hosted customer who paid but never linked.
func (s Sender) SendLinkReminder(to, instanceName string) error {
return s.sendTemplate(to, "", "linkreminder", struct {
InstanceName string
PortalURL string
}{instanceName, s.PublicURL})
}
+26
View File
@@ -0,0 +1,26 @@
package mail
import "time"
// MonitorEvent is a monitor state transition, as the control plane's
// notification dispatcher sees it. It lives here rather than in server/ so that
// the email templates and the caller agree on the fields without server's model
// package leaking into shared.
type MonitorEvent struct {
MonitorName string
Type string
OldStatus string
NewStatus string
Message string
Time time.Time
// Down drives the pill and the subject verb. The caller decides it, since
// only server/ knows which status strings mean down.
Down bool
}
// SendMonitorAlert delivers one state-change notification to an SMTP
// notification channel's recipients, which may be a comma-separated list.
func (s Sender) SendMonitorAlert(to string, ev MonitorEvent) error {
return s.sendTemplate(to, "", "monitoralert", ev)
}
+121
View File
@@ -0,0 +1,121 @@
package mail
import (
"embed"
"fmt"
htmltmpl "html/template"
"io/fs"
"strings"
texttmpl "text/template"
"time"
)
//go:embed templates
var files embed.FS
// A message is rendered from three files: the shared layout, which owns every
// colour and every piece of chrome, and the message's own html/txt pair, which
// owns only its subject and its content. There is one template set per message
// rather than one big set, because each message defines "subject" and "body"
// under the same names and they would otherwise collide.
type set struct {
html *htmltmpl.Template
text *texttmpl.Template
}
var sets = map[string]set{}
func init() {
entries, err := fs.Glob(files, "templates/*.html.tmpl")
if err != nil {
panic("mail: glob templates: " + err.Error())
}
for _, e := range entries {
name := strings.TrimSuffix(strings.TrimPrefix(e, "templates/"), ".html.tmpl")
if name == "layout" {
continue
}
h, err := htmltmpl.New("layout.html.tmpl").Funcs(htmltmpl.FuncMap(funcs)).
ParseFS(files, "templates/layout.html.tmpl", e)
if err != nil {
panic("mail: parse " + e + ": " + err.Error())
}
t, err := texttmpl.New("layout.txt.tmpl").Funcs(texttmpl.FuncMap(funcs)).
ParseFS(files, "templates/layout.txt.tmpl", "templates/"+name+".txt.tmpl")
if err != nil {
panic("mail: parse " + name + ".txt.tmpl: " + err.Error())
}
sets[name] = set{html: h, text: t}
}
}
// render produces the subject and both bodies for one message.
//
// The subject comes from the text set, not the HTML one: html/template would
// escape an ampersand in an instance name into "&amp;" and mail clients show
// subjects verbatim.
func render(name string, data any) (message, error) {
s, ok := sets[name]
if !ok {
return message{}, fmt.Errorf("no such template")
}
var subject, text, html strings.Builder
if err := s.text.ExecuteTemplate(&subject, "subject", data); err != nil {
return message{}, err
}
if err := s.text.Execute(&text, data); err != nil {
return message{}, err
}
if err := s.html.Execute(&html, data); err != nil {
return message{}, err
}
return message{
Subject: strings.TrimSpace(subject.String()),
Text: normaliseText(text.String()),
HTML: html.String(),
}, nil
}
// normaliseText gives the plain part CRLF line endings and collapses the blank
// runs that fall out of templating whitespace.
func normaliseText(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
for strings.Contains(s, "\n\n\n") {
s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
}
s = strings.TrimSpace(s) + "\n"
return strings.ReplaceAll(s, "\n", "\r\n")
}
// funcs are shared by both template flavours. They exist so that a message
// template never formats a date or builds a structure itself — two templates
// formatting the same date two ways is exactly the drift this package removes.
var funcs = map[string]any{
// dict builds a map for the layout's helper templates, which take more
// than one argument. Go templates have no literal for this.
"dict": func(kv ...any) (map[string]any, error) {
if len(kv)%2 != 0 {
return nil, fmt.Errorf("dict: odd argument count")
}
m := make(map[string]any, len(kv)/2)
for i := 0; i < len(kv); i += 2 {
k, ok := kv[i].(string)
if !ok {
return nil, fmt.Errorf("dict: key %d is not a string", i)
}
m[k] = kv[i+1]
}
return m, nil
},
"list": func(v ...any) []any { return v },
// date is the one long-date format used across every Vantage email.
"date": func(t time.Time) string { return t.Format("2 January 2006") },
// shortDate drops the year, for subject lines where it is obvious.
"shortDate": func(t time.Time) string { return t.Format("2 January") },
"stamp": func(t time.Time) string { return t.Format("2006-01-02 15:04:05 MST") },
"hours": func(d time.Duration) int { return int(d.Hours()) },
"upper": strings.ToUpper,
}
+161
View File
@@ -0,0 +1,161 @@
package mail
import (
"strings"
"testing"
"time"
)
// Every template is parsed in init(), so a broken one panics the process at
// boot rather than at send time. This test renders each of them with realistic
// data, because parsing does not catch a field that does not exist on the data
// a Send* method actually passes.
func TestRenderAll(t *testing.T) {
expires := time.Date(2026, 8, 14, 9, 0, 0, 0, time.UTC)
cases := []struct {
name string
data any
subject string
wants []string
}{
{"verification", struct {
Link string
TTLHours int
}{"https://hq.example/verify?token=abc", 24},
"Verify your Vantage account",
[]string{"https://hq.example/verify?token=abc", "24 hours"}},
{"invite", struct{ AccountName, Link string }{
"Acme & Co", "https://hq.example/accept-invite?token=abc"},
"You have been invited to Acme & Co on Vantage",
[]string{"Acme &amp; Co", "accept-invite"}},
{"license", struct{ InstanceName, Blob string }{"acme", "BLOB-123"},
"Your Vantage licence key", []string{"BLOB-123"}},
{"instanceready", struct {
InstanceName, LoginURL string
Expires time.Time
}{"acme", "https://acme.vantage.example", expires},
"acme is ready", []string{"14 August 2026", "acme.vantage.example"}},
{"renewed", struct {
InstanceName string
Expires time.Time
}{"acme", expires}, "acme renewed", []string{"14 August 2026"}},
{"expiring", struct {
InstanceName, PortalURL string
Expires time.Time
}{"acme", "https://hq.example/billing", expires},
"acme expires on 14 August", []string{"hq.example/billing"}},
{"expired", struct {
InstanceName, PortalURL string
DeleteOn time.Time
}{"acme", "https://hq.example/billing", expires},
"acme is now read-only", []string{"read-only", "14 August 2026"}},
{"deletionwarning", struct {
InstanceName, PortalURL, When string
DeleteOn time.Time
}{"acme", "https://hq.example/billing", "tomorrow", expires},
"acme will be deleted tomorrow", []string{"cannot be undone"}},
{"linkreminder", struct{ InstanceName, PortalURL string }{
"acme", "https://hq.example"},
"Finish setting up acme", []string{"not linked yet"}},
{"cancelled", struct{ InstanceName string }{"acme"},
"Your Vantage subscription is cancelled", []string{"changes are disabled", "cancelled"}},
{"pastdue", struct{ InstanceName, PortalURL string }{"acme", "https://hq.example"},
"Payment failed for your Vantage subscription", []string{"retried"}},
{"monitoralert", MonitorEvent{
MonitorName: "api.example", Type: "http",
OldStatus: "up", NewStatus: "down",
Message: "connection refused", Time: expires, Down: true},
"[Vantage] api.example (http) is DOWN: connection refused",
[]string{"api.example", "connection refused", "2026-08-14"}},
{"contact", struct {
Enquiry
Received string
}{Enquiry{Name: "Ada", Email: "ada@example.com", Servers: "10",
Topic: "sales", Message: "hello"}, "now"},
"[Vantage] sales ada@example.com", []string{"ada@example.com", "hello"}},
}
if len(cases) != len(sets) {
t.Fatalf("%d templates on disk but %d covered", len(sets), len(cases))
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
m, err := render(c.name, c.data)
if err != nil {
t.Fatalf("render: %v", err)
}
if m.Subject != c.subject {
t.Errorf("subject = %q, want %q", m.Subject, c.subject)
}
if !strings.Contains(m.HTML, "Sent by Vantage") {
t.Error("html body is missing the layout footer")
}
if !strings.Contains(m.HTML, "#071628") {
t.Error("html body is not on the control plane ground")
}
if !strings.Contains(m.Text, "VANTAGE") {
t.Error("text body is missing the layout header")
}
if strings.Contains(m.Text, "<") && strings.Contains(m.Text, "style=") {
t.Error("markup leaked into the text part")
}
for _, want := range c.wants {
if !strings.Contains(m.HTML, want) && !strings.Contains(m.Text, want) {
t.Errorf("neither part contains %q", want)
}
}
})
}
}
// A header value carrying CRLF must not be able to start a new header.
func TestEnvelopeStripsHeaderInjection(t *testing.T) {
s := Sender{Host: "localhost", Port: "587", From: "vantage@example.com"}
b, err := s.envelope(message{
To: "someone@example.com\r\nBcc: attacker@example.com",
Subject: "hello",
Text: "body",
HTML: "<p>body</p>",
})
if err != nil {
t.Fatalf("envelope: %v", err)
}
if strings.Contains(string(b), "\r\nBcc:") {
t.Fatal("CRLF in a recipient produced an extra header")
}
}
// Both parts must be present: a client that refuses HTML should not get a
// blank message, and a text-only message scores worse with spam filters.
func TestEnvelopeIsMultipartAlternative(t *testing.T) {
s := Sender{Host: "localhost", Port: "587", From: "vantage@example.com"}
b, err := s.envelope(message{To: "a@example.com", Subject: "s", Text: "TEXTBODY", HTML: "<p>HTMLBODY</p>"})
if err != nil {
t.Fatalf("envelope: %v", err)
}
got := string(b)
for _, want := range []string{
"Content-Type: multipart/alternative; boundary=",
"Message-ID: <", "Date: ",
"text/plain; charset=utf-8", "text/html; charset=utf-8",
"TEXTBODY", "HTMLBODY",
} {
if !strings.Contains(got, want) {
t.Errorf("envelope is missing %q", want)
}
}
}
+241
View File
@@ -0,0 +1,241 @@
// Package mail is the one email system for every Vantage service.
//
// It owns three things that used to exist in three copies: the SMTP
// conversation (including the 465-implicit-TLS case that net/smtp gets wrong),
// the RFC 5322 envelope, and the rendered look of a Vantage email. Callers see
// only typed Send* methods — nobody outside this package builds a subject line,
// a MIME part or a colour.
package mail
import (
"crypto/rand"
"crypto/tls"
"encoding/hex"
"fmt"
"mime"
"mime/multipart"
"net"
"net/smtp"
"net/textproto"
"os"
"strings"
"time"
)
// 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 admin's signup rollback runs on that request's context.
const timeout = 15 * time.Second
// Sender is a configured SMTP destination. It is a value, not a singleton:
// server/internal/notify builds one per notification channel from data in
// Mongo, while admin and sitesvc build one at boot.
type Sender struct {
Host string
Port string
From string
Username string
Password string
// PublicURL is the browser origin used to build links in messages that
// carry one (verification, invitations). Empty is fine for senders that
// never send those, such as a monitor notification channel.
PublicURL string
}
// FromEnv reads the standard SMTP_* variables. Used by services configured
// straight from the environment; admin builds its Sender from its own config
// struct instead.
func FromEnv() Sender {
return Sender{
Host: os.Getenv("SMTP_HOST"),
Port: envOr("SMTP_PORT", "587"),
Username: os.Getenv("SMTP_USERNAME"),
Password: os.Getenv("SMTP_PASSWORD"),
From: os.Getenv("SMTP_FROM"),
}
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// Enabled reports whether this Sender can deliver anything at all. Callers
// check it to degrade politely rather than logging a failure per message.
func (s Sender) Enabled() bool { return s.Host != "" && s.From != "" }
// message is one rendered email, ready to be put on the wire.
type message struct {
To string // one address, or a comma-separated list
ReplyTo string
Subject string
HTML string
Text string
}
// sendTemplate renders name against data and delivers the result.
func (s Sender) sendTemplate(to, replyTo, name string, data any) error {
m, err := render(name, data)
if err != nil {
return fmt.Errorf("mail: render %s: %w", name, err)
}
m.To = to
m.ReplyTo = replyTo
return s.send(m)
}
// 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 — that bug silently stopped
// every admin email from being delivered once already.
func (s Sender) send(m message) error {
if !s.Enabled() {
return fmt.Errorf("smtp: not configured")
}
rcpts := recipients(m.To)
if len(rcpts) == 0 {
return fmt.Errorf("smtp: no recipient")
}
addr := net.JoinHostPort(s.Host, s.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 s.Port == "465" {
conn = tls.Client(conn, &tls.Config{ServerName: s.Host})
}
client, err := smtp.NewClient(conn, s.Host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp: client: %w", err)
}
defer client.Close()
if s.Port != "465" {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: s.Host}); err != nil {
return fmt.Errorf("smtp: starttls: %w", err)
}
}
}
if s.Username != "" {
if err := client.Auth(smtp.PlainAuth("", s.Username, s.Password, s.Host)); err != nil {
return fmt.Errorf("smtp: auth: %w", err)
}
}
if err := client.Mail(s.From); err != nil {
return fmt.Errorf("smtp: mail from: %w", err)
}
for _, rcpt := range rcpts {
if err := client.Rcpt(rcpt); err != nil {
return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)
}
}
body, err := s.envelope(m)
if err != nil {
return fmt.Errorf("smtp: build message: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp: data: %w", err)
}
if _, err := w.Write(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()
}
func recipients(to string) []string {
parts := strings.Split(to, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// envelope builds the RFC 5322 message as multipart/alternative.
//
// 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". A
// 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.
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 {
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 {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
var b strings.Builder
b.WriteString("From: " + sanitizeHeader(s.From) + "\r\n")
b.WriteString("To: " + sanitizeHeader(m.To) + "\r\n")
if m.ReplyTo != "" {
b.WriteString("Reply-To: " + sanitizeHeader(m.ReplyTo) + "\r\n")
}
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
b.WriteString("Message-ID: " + messageID(s.From) + "\r\n")
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", sanitizeHeader(m.Subject)) + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: multipart/alternative; boundary=" + w.Boundary() + "\r\n")
b.WriteString("\r\n")
b.WriteString(parts.String())
return []byte(b.String()), nil
}
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)
}
@@ -0,0 +1,6 @@
{{define "pill"}}{{template "chip" (dict "label" "Cancelled" "tone" "pend")}}{{end}}
{{define "title"}}Your subscription is cancelled{{end}}
{{define "body"}}
{{template "lead" (printf "Your subscription for %s is cancelled." .InstanceName)}}
{{template "p" "Your instance keeps working until the current licence expires. After that, monitors keep running but changes are disabled."}}
{{end}}
+7
View File
@@ -0,0 +1,7 @@
{{define "subject"}}Your Vantage subscription is cancelled{{end}}
{{define "pill"}}{{template "chip" (dict "label" "Cancelled" "tone" "pend")}}{{end}}
{{define "title"}}Your subscription is cancelled{{end}}
{{define "body"}}
{{template "lead" (printf "Your subscription for %s is cancelled." .InstanceName)}}
{{template "p" "Your instance keeps working until the current licence expires. After that, monitors keep running but changes are disabled."}}
{{end}}
+11
View File
@@ -0,0 +1,11 @@
{{define "title"}}New contact enquiry{{end}}
{{define "body"}}
{{template "lead" "Someone has used the contact form on the Vantage site."}}
{{template "rows" (list
(dict "k" "Name" "v" .Name)
(dict "k" "Email" "v" .Email)
(dict "k" "Servers" "v" .Servers)
(dict "k" "Topic" "v" .Topic)
(dict "k" "Received" "v" .Received))}}
{{template "note" .Message}}
{{end}}
+13
View File
@@ -0,0 +1,13 @@
{{define "subject"}}[Vantage] {{.Topic}} {{.Email}}{{end}}
{{define "title"}}New contact enquiry{{end}}
{{define "body"}}
{{template "lead" "Someone has used the contact form on the Vantage site."}}
{{template "rows" (list
(dict "k" "Name" "v" .Name)
(dict "k" "Email" "v" .Email)
(dict "k" "Servers" "v" .Servers)
(dict "k" "Topic" "v" .Topic)
(dict "k" "Received" "v" .Received))}}
Message:
{{template "note" .Message}}
{{end}}
@@ -0,0 +1,7 @@
{{define "pill"}}{{template "chip" (dict "label" "Deletion scheduled" "tone" "down")}}{{end}}
{{define "title"}}{{.InstanceName}} will be deleted {{.When}}{{end}}
{{define "body"}}
{{template "lead" (printf "%s and everything in it will be deleted %s, on %s." .InstanceName .When (date .DeleteOn))}}
{{template "p" "This cannot be undone. Renew it to keep it:"}}
{{template "button" (dict "label" "Keep this instance" "url" .PortalURL)}}
{{end}}
@@ -0,0 +1,8 @@
{{define "subject"}}{{.InstanceName}} will be deleted {{.When}}{{end}}
{{define "pill"}}{{template "chip" (dict "label" "Deletion scheduled" "tone" "down")}}{{end}}
{{define "title"}}{{.InstanceName}} will be deleted {{.When}}{{end}}
{{define "body"}}
{{template "lead" (printf "%s and everything in it will be deleted %s, on %s." .InstanceName .When (date .DeleteOn))}}
{{template "p" "This cannot be undone. Renew it to keep it:"}}
{{template "button" (dict "label" "Keep this instance" "url" .PortalURL)}}
{{end}}
+8
View File
@@ -0,0 +1,8 @@
{{define "pill"}}{{template "chip" (dict "label" "Read-only" "tone" "down")}}{{end}}
{{define "title"}}{{.InstanceName}} is now read-only{{end}}
{{define "body"}}
{{template "lead" (printf "%s's Free licence has expired." .InstanceName)}}
{{template "p" "Your servers and monitors keep running and your agents keep their keys, but changes are disabled."}}
{{template "button" (dict "label" "Renew now" "url" .PortalURL)}}
{{if not .DeleteOn.IsZero}}{{template "p" (printf "If it is not renewed, the instance and everything in it will be deleted on %s." (date .DeleteOn))}}{{end}}
{{end}}
+9
View File
@@ -0,0 +1,9 @@
{{define "subject"}}{{.InstanceName}} is now read-only{{end}}
{{define "pill"}}{{template "chip" (dict "label" "Read-only" "tone" "down")}}{{end}}
{{define "title"}}{{.InstanceName}} is now read-only{{end}}
{{define "body"}}
{{template "lead" (printf "%s's Free licence has expired." .InstanceName)}}
{{template "p" "Your servers and monitors keep running and your agents keep their keys, but changes are disabled."}}
{{template "button" (dict "label" "Renew now" "url" .PortalURL)}}
{{if not .DeleteOn.IsZero}}{{template "p" (printf "If it is not renewed, the instance and everything in it will be deleted on %s." (date .DeleteOn))}}{{end}}
{{end}}
+7
View File
@@ -0,0 +1,7 @@
{{define "pill"}}{{template "chip" (dict "label" "Expiring soon" "tone" "pend")}}{{end}}
{{define "title"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}}
{{define "body"}}
{{template "lead" (printf "%s's Free licence runs out on %s." .InstanceName (date .Expires))}}
{{template "button" (dict "label" "Renew in one click" "url" .PortalURL)}}
{{template "p" "If you do nothing, the instance keeps running but stops accepting changes."}}
{{end}}
+8
View File
@@ -0,0 +1,8 @@
{{define "subject"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}}
{{define "pill"}}{{template "chip" (dict "label" "Expiring soon" "tone" "pend")}}{{end}}
{{define "title"}}{{.InstanceName}} expires on {{shortDate .Expires}}{{end}}
{{define "body"}}
{{template "lead" (printf "%s's Free licence runs out on %s." .InstanceName (date .Expires))}}
{{template "button" (dict "label" "Renew in one click" "url" .PortalURL)}}
{{template "p" "If you do nothing, the instance keeps running but stops accepting changes."}}
{{end}}
@@ -0,0 +1,8 @@
{{define "pill"}}{{template "chip" (dict "label" "Ready" "tone" "up")}}{{end}}
{{define "title"}}{{.InstanceName}} is ready{{end}}
{{define "body"}}
{{template "lead" (printf "%s is provisioned and waiting for you." .InstanceName)}}
{{if .LoginURL}}{{template "button" (dict "label" "Sign in" "url" .LoginURL)}}{{end}}
{{template "p" (printf "Your Free licence runs until %s. We will email you before then so you can renew it in one click." (date .Expires))}}
{{template "p" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too."}}
{{end}}
@@ -0,0 +1,9 @@
{{define "subject"}}{{.InstanceName}} is ready{{end}}
{{define "pill"}}{{template "chip" (dict "label" "Ready" "tone" "up")}}{{end}}
{{define "title"}}{{.InstanceName}} is ready{{end}}
{{define "body"}}
{{template "lead" (printf "%s is provisioned and waiting for you." .InstanceName)}}
{{if .LoginURL}}{{template "button" (dict "label" "Sign in" "url" .LoginURL)}}{{end}}
{{template "p" (printf "Your Free licence runs until %s. We will email you before then so you can renew it in one click." (date .Expires))}}
{{template "p" "Sign in with the same email address and password you use for your Vantage account. Changing your Vantage HQ password changes it here too."}}
{{end}}
+7
View File
@@ -0,0 +1,7 @@
{{define "title"}}You have been invited to {{.AccountName}}{{end}}
{{define "body"}}
{{template "lead" (printf "You have been invited to join %s on Vantage." .AccountName)}}
{{template "p" "Set your own password and finish joining:"}}
{{template "button" (dict "label" "Set password and join" "url" .Link)}}
{{template "p" "This link expires in 24 hours. If you were not expecting this, ignore it — nothing happens until you open the link."}}
{{end}}
+8
View File
@@ -0,0 +1,8 @@
{{define "subject"}}You have been invited to {{.AccountName}} on Vantage{{end}}
{{define "title"}}You have been invited to {{.AccountName}}{{end}}
{{define "body"}}
{{template "lead" (printf "You have been invited to join %s on Vantage." .AccountName)}}
{{template "p" "Set your own password and finish joining:"}}
{{template "button" (dict "label" "Set password and join" "url" .Link)}}
{{template "p" "This link expires in 24 hours. If you were not expecting this, ignore it — nothing happens until you open the link."}}
{{end}}
+121
View File
@@ -0,0 +1,121 @@
{{- /*
The Vantage email shell.
Every colour in the email system lives in this file and nowhere else, in the
same way no component in web/, site/ or adminsite/ carries a hex. The values
are web/app/globals.css's tokens — an email is read before the recipient
clicks through to the control plane, so the two should not look like
different products. They are written as literal hex here because email
clients support neither var() nor a reliable prefers-color-scheme, so the
usual token indirection is not available: when you change a token in the
three globals.css files, change it here too.
--ground #071628 --rule #1e3855
--panel #0d2138 --accent #5b9be8
--panel-2 #102842 --up #4fb484
--ink #e4ecf6 --down #e2705a
--ink-2 #9fb3ca --pend #d6a63f
--ink-3 #71879f --well #04101f
Layout is tables and inline styles throughout, which is not a stylistic
choice — it is the only thing Outlook renders predictably.
A message file overrides "title", "pill" and "body"; the empty defaults below
exist so that a message needing no pill does not have to define one.
*/ -}}
{{- define "title"}}{{end -}}
{{- define "pill"}}{{end -}}
{{- define "body"}}{{end -}}
{{- /* p renders one paragraph of body copy. */ -}}
{{- define "p" -}}
<p style="margin:0 0 16px;color:#9fb3ca;font-size:14px;line-height:1.6;">{{.}}</p>
{{- end -}}
{{- /* lead is the first paragraph: same size, brighter, sets the subject. */ -}}
{{- define "lead" -}}
<p style="margin:0 0 16px;color:#e4ecf6;font-size:15px;line-height:1.6;">{{.}}</p>
{{- end -}}
{{- /* button takes dict "label" "…" "url" "…".
The bare URL is printed underneath on purpose: a plain-text-preferring
client, a stripped-styles inbox and a forwarded message all lose the
anchor, and a verification email whose link cannot be reached is a
support ticket. */ -}}
{{- define "button" -}}
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:4px 0 16px;">
<tr>
<td style="border-radius:4px;background:#5b9be8;">
<a href="{{.url}}" style="display:inline-block;padding:10px 20px;color:#04101f;font-size:14px;font-weight:600;text-decoration:none;">{{.label}}</a>
</td>
</tr>
</table>
<p style="margin:0 0 20px;color:#71879f;font-size:12px;line-height:1.5;word-break:break-all;">
Or paste this into your browser:<br>
<a href="{{.url}}" style="color:#5b9be8;text-decoration:none;">{{.url}}</a>
</p>
{{- end -}}
{{- /* well shows machine output — a licence blob, an install ID. Mirrors
web/'s --well surface, the floor beneath the ground. */ -}}
{{- define "well" -}}
<pre style="margin:0 0 20px;padding:14px;background:#04101f;border:1px solid #1e3855;border-radius:4px;color:#9fb3ca;font-family:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all;">{{.}}</pre>
{{- end -}}
{{- /* note is a quoted callout, used for a free-text message we did not
write ourselves. */ -}}
{{- define "note" -}}
<p style="margin:0 0 20px;padding:12px 14px;background:#102842;border:1px solid #1e3855;border-radius:4px;color:#e4ecf6;font-size:13px;line-height:1.6;">{{.}}</p>
{{- end -}}
{{- /* rows takes a list of dict "k" "…" "v" "…". */ -}}
{{- define "rows" -}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:4px 0 8px;border-top:1px solid #1e3855;">
{{- range .}}
<tr>
<td style="padding:9px 0;border-bottom:1px solid #172c44;color:#71879f;font-size:13px;width:130px;vertical-align:top;">{{.k}}</td>
<td style="padding:9px 0;border-bottom:1px solid #172c44;color:#e4ecf6;font-size:13px;font-weight:500;">{{.v}}</td>
</tr>
{{- end}}
</table>
{{- end -}}
{{- /* chip takes dict "label" "…" "tone" "up|down|pend|accent". Tone is
never the only signal: the label spells the state out. */ -}}
{{- define "chip" -}}
{{- $fg := "#5b9be8"}}{{if eq .tone "up"}}{{$fg = "#4fb484"}}{{else if eq .tone "down"}}{{$fg = "#e2705a"}}{{else if eq .tone "pend"}}{{$fg = "#d6a63f"}}{{end -}}
<span style="display:inline-block;margin:0 0 12px;padding:4px 11px;border:1px solid {{$fg}};border-radius:9999px;color:{{$fg}};font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;">{{.label}}</span>
{{- end -}}
<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:#071628;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#071628;padding:32px 12px;">
<tr>
<td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0" style="max-width:520px;width:100%;background:#0d2138;border:1px solid #1e3855;border-radius:4px;overflow:hidden;font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<tr><td style="height:3px;background:#5b9be8;"></td></tr>
<tr>
<td style="padding:26px 28px 8px;">
<span style="font-size:16px;font-weight:700;letter-spacing:-.01em;color:#7fb2f0;">Vantage</span>
</td>
</tr>
<tr>
<td style="padding:10px 28px 26px;">
{{template "pill" .}}
<h1 style="margin:2px 0 14px;font-size:20px;font-weight:700;line-height:1.3;color:#e4ecf6;">{{template "title" .}}</h1>
{{template "body" .}}
</td>
</tr>
<tr>
<td style="padding:14px 28px;border-top:1px solid #1e3855;background:#04101f;">
<p style="margin:0;color:#71879f;font-size:12px;line-height:1.5;">Sent by Vantage · infrastructure control plane</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
{{- /*
The plain-text counterpart of layout.html.tmpl.
It defines the same helper names — p, lead, button, well, note, rows, chip —
so a message's txt file reads as the same document as its html one, and a
helper added on one side is obvious by its absence on the other.
"subject" is defined here rather than in the HTML file: html/template would
escape an ampersand in an instance name, and mail clients show subject lines
verbatim.
*/ -}}
{{- define "subject"}}Vantage{{end -}}
{{- define "title"}}{{end -}}
{{- define "pill"}}{{end -}}
{{- define "body"}}{{end -}}
{{- define "p"}}{{.}}
{{end -}}
{{- define "lead"}}{{.}}
{{end -}}
{{- define "button"}}{{.label}}:
{{.url}}
{{end -}}
{{- define "well"}}{{.}}
{{end -}}
{{- define "note"}}{{.}}
{{end -}}
{{- define "rows"}}{{range .}}{{.k}}: {{.v}}
{{end}}
{{end -}}
{{- define "chip"}}[{{upper .label}}]
{{end -}}
VANTAGE
{{template "pill" .}}
{{template "title" .}}
{{template "body" .}}
--
Sent by Vantage · infrastructure control plane
+7
View File
@@ -0,0 +1,7 @@
{{define "title"}}Your licence key{{end}}
{{define "body"}}
{{template "lead" (printf "Your licence for %s is below." .InstanceName)}}
{{template "p" "Paste it into Settings → Licence on your Vantage install:"}}
{{template "well" .Blob}}
{{template "p" "The licence is signed public data, not a secret — it is useless on any instance other than the one it names."}}
{{end}}
+8
View File
@@ -0,0 +1,8 @@
{{define "subject"}}Your Vantage licence key{{end}}
{{define "title"}}Your licence key{{end}}
{{define "body"}}
{{template "lead" (printf "Your licence for %s is below." .InstanceName)}}
{{template "p" "Paste it into Settings > Licence on your Vantage install:"}}
{{template "well" .Blob}}
{{template "p" "The licence is signed public data, not a secret — it is useless on any instance other than the one it names."}}
{{end}}
@@ -0,0 +1,7 @@
{{define "pill"}}{{template "chip" (dict "label" "Action needed" "tone" "pend")}}{{end}}
{{define "title"}}Finish setting up {{.InstanceName}}{{end}}
{{define "body"}}
{{template "lead" (printf "Your subscription for %s is active, but the instance is not linked yet." .InstanceName)}}
{{template "p" "Paste your install's ID in the portal to receive your licence."}}
{{if .PortalURL}}{{template "button" (dict "label" "Link my install" "url" .PortalURL)}}{{end}}
{{end}}
@@ -0,0 +1,8 @@
{{define "subject"}}Finish setting up {{.InstanceName}}{{end}}
{{define "pill"}}{{template "chip" (dict "label" "Action needed" "tone" "pend")}}{{end}}
{{define "title"}}Finish setting up {{.InstanceName}}{{end}}
{{define "body"}}
{{template "lead" (printf "Your subscription for %s is active, but the instance is not linked yet." .InstanceName)}}
{{template "p" "Paste your install's ID in the portal to receive your licence."}}
{{if .PortalURL}}{{template "button" (dict "label" "Link my install" "url" .PortalURL)}}{{end}}
{{end}}
@@ -0,0 +1,10 @@
{{define "pill"}}{{if .Down}}{{template "chip" (dict "label" "Down" "tone" "down")}}{{else}}{{template "chip" (dict "label" "Recovered" "tone" "up")}}{{end}}{{end}}
{{define "title"}}{{.MonitorName}}{{end}}
{{define "body"}}
{{template "p" (printf "%s check" .Type)}}
{{if .Message}}{{template "note" .Message}}{{end}}
{{template "rows" (list
(dict "k" "Status" "v" (printf "%s → %s" .OldStatus .NewStatus))
(dict "k" "Type" "v" .Type)
(dict "k" "Time" "v" (stamp .Time)))}}
{{end}}
@@ -0,0 +1,11 @@
{{define "subject"}}[Vantage] {{.MonitorName}} ({{.Type}}) {{if .Down}}is DOWN{{else}}recovered{{end}}{{if .Message}}: {{.Message}}{{end}}{{end}}
{{define "pill"}}{{if .Down}}{{template "chip" (dict "label" "Down" "tone" "down")}}{{else}}{{template "chip" (dict "label" "Recovered" "tone" "up")}}{{end}}{{end}}
{{define "title"}}{{.MonitorName}}{{end}}
{{define "body"}}
{{template "p" (printf "%s check" .Type)}}
{{if .Message}}{{template "note" .Message}}{{end}}
{{template "rows" (list
(dict "k" "Status" "v" (printf "%s -> %s" .OldStatus .NewStatus))
(dict "k" "Type" "v" .Type)
(dict "k" "Time" "v" (stamp .Time)))}}
{{end}}
+7
View File
@@ -0,0 +1,7 @@
{{define "pill"}}{{template "chip" (dict "label" "Payment failed" "tone" "pend")}}{{end}}
{{define "title"}}Payment failed for {{.InstanceName}}{{end}}
{{define "body"}}
{{template "lead" (printf "A payment for %s failed." .InstanceName)}}
{{template "p" "Your instance is unaffected while the card is retried. Update your payment method from the billing portal."}}
{{if .PortalURL}}{{template "button" (dict "label" "Open billing portal" "url" .PortalURL)}}{{end}}
{{end}}
+8
View File
@@ -0,0 +1,8 @@
{{define "subject"}}Payment failed for your Vantage subscription{{end}}
{{define "pill"}}{{template "chip" (dict "label" "Payment failed" "tone" "pend")}}{{end}}
{{define "title"}}Payment failed for {{.InstanceName}}{{end}}
{{define "body"}}
{{template "lead" (printf "A payment for %s failed." .InstanceName)}}
{{template "p" "Your instance is unaffected while the card is retried. Update your payment method from the billing portal."}}
{{if .PortalURL}}{{template "button" (dict "label" "Open billing portal" "url" .PortalURL)}}{{end}}
{{end}}
+6
View File
@@ -0,0 +1,6 @@
{{define "pill"}}{{template "chip" (dict "label" "Renewed" "tone" "up")}}{{end}}
{{define "title"}}{{.InstanceName}} is renewed{{end}}
{{define "body"}}
{{template "lead" (printf "Your Free licence for %s now runs until %s." .InstanceName (date .Expires))}}
{{template "p" "Nothing else changes — your servers, agents and monitors carry on as they were."}}
{{end}}
+7
View File
@@ -0,0 +1,7 @@
{{define "subject"}}{{.InstanceName}} renewed{{end}}
{{define "pill"}}{{template "chip" (dict "label" "Renewed" "tone" "up")}}{{end}}
{{define "title"}}{{.InstanceName}} is renewed{{end}}
{{define "body"}}
{{template "lead" (printf "Your Free licence for %s now runs until %s." .InstanceName (date .Expires))}}
{{template "p" "Nothing else changes — your servers, agents and monitors carry on as they were."}}
{{end}}
@@ -0,0 +1,7 @@
{{define "title"}}Confirm your email address{{end}}
{{define "body"}}
{{template "lead" "Confirm this address to finish setting up your Vantage account."}}
{{template "button" (dict "label" "Confirm email address" "url" .Link)}}
{{template "p" (printf "The link works once and expires in %d hours." .TTLHours)}}
{{template "p" "If you did not request this, ignore this email — nothing happens until the link is opened."}}
{{end}}
@@ -0,0 +1,8 @@
{{define "subject"}}Verify your Vantage account{{end}}
{{define "title"}}Confirm your email address{{end}}
{{define "body"}}
{{template "lead" "Confirm this address to finish setting up your Vantage account."}}
{{template "button" (dict "label" "Confirm email address" "url" .Link)}}
{{template "p" (printf "The link works once and expires in %d hours." .TTLHours)}}
{{template "p" "If you did not request this, ignore this email — nothing happens until the link is opened."}}
{{end}}
+6 -5
View File
@@ -11,8 +11,8 @@ import (
"time"
"github.com/joho/godotenv"
"github.com/mrhid6/vantage/shared/mail"
"github.com/mrhid6/vantage/sitesvc/internal/api"
"github.com/mrhid6/vantage/sitesvc/internal/mail"
)
func main() {
@@ -21,9 +21,10 @@ func main() {
addr := ":" + getEnv("PORT", "8082")
mailCfg := mail.FromEnv()
if mailCfg.Enabled() {
log.Printf("smtp enabled (%s) contact form delivers to %s", mailCfg.Host, mailCfg.To)
sender := mail.FromEnv()
contactTo := getEnv("SMTP_TO", "support@hostxtra.co.uk")
if sender.Enabled() {
log.Printf("smtp enabled (%s) contact form delivers to %s", sender.Host, contactTo)
} else {
log.Println("warning: SMTP_HOST/SMTP_FROM not set the contact form will refuse submissions")
}
@@ -34,7 +35,7 @@ func main() {
srv := &http.Server{
Addr: addr,
Handler: api.New(mailCfg).Routes(),
Handler: api.New(sender, contactTo).Routes(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 20 * time.Second,
WriteTimeout: 30 * time.Second,
+16 -25
View File
@@ -11,7 +11,7 @@ import (
"strings"
"time"
"github.com/mrhid6/vantage/sitesvc/internal/mail"
"github.com/mrhid6/vantage/shared/mail"
)
const (
@@ -21,15 +21,18 @@ const (
)
type Server struct {
mail mail.Config
limiter *limiter
mail mail.Sender
contact string // where enquiries go; the sender itself has no default recipient
limiter *limiter
allowOrigin map[string]bool
trustProxy bool
}
func New(mailCfg mail.Config) *Server {
func New(sender mail.Sender, contactTo string) *Server {
return &Server{
mail: mailCfg,
mail: sender,
contact: contactTo,
limiter: newLimiter(perIPLimit, perIPWindow),
allowOrigin: parseOrigins(os.Getenv("SITE_ORIGIN")),
trustProxy: os.Getenv("TRUST_PROXY") == "true",
@@ -168,7 +171,7 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
return
}
if !s.mail.Enabled() {
if !s.mail.Enabled() || s.contact == "" {
log.Println("contact submission dropped: smtp is not configured")
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "The contact form is unavailable right now. Email support@hostxtra.co.uk directly.",
@@ -176,7 +179,13 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.mail.Send(subject(addr, fields), plainBody(addr, fields), addr); err != nil {
if err := s.mail.SendEnquiry(s.contact, mail.Enquiry{
Name: fields["name"],
Email: addr,
Servers: fields["servers"],
Topic: fields["topic"],
Message: fields["message"],
}); err != nil {
log.Printf("contact send: %v", err)
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": "We could not send that. Try again, or email support@hostxtra.co.uk directly.",
@@ -187,24 +196,6 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusAccepted, map[string]string{"status": "received"})
}
func subject(addr string, fields map[string]string) string {
return fmt.Sprintf("[Vantage] %s %s", fields["topic"], addr)
}
func plainBody(addr string, fields map[string]string) string {
var b strings.Builder
b.WriteString("New contact enquiry from the Vantage site.\n\n")
fmt.Fprintf(&b, "Name: %s\n", fields["name"])
fmt.Fprintf(&b, "Email: %s\n", addr)
fmt.Fprintf(&b, "Servers: %s\n", fields["servers"])
fmt.Fprintf(&b, "Topic: %s\n", fields["topic"])
fmt.Fprintf(&b, "Received: %s\n\n", time.Now().UTC().Format(time.RFC1123))
b.WriteString("Message:\n")
b.WriteString(fields["message"])
b.WriteString("\n")
return b.String()
}
func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
dec := json.NewDecoder(r.Body)
-174
View File
@@ -1,174 +0,0 @@
package mail
import (
"crypto/rand"
"crypto/tls"
"encoding/hex"
"fmt"
"mime"
"net"
"net/smtp"
"os"
"strings"
"time"
)
const timeout = 15 * time.Second
type Config struct {
Host string
Port string
Username string
Password string
From string
To string
}
func FromEnv() Config {
return Config{
Host: os.Getenv("SMTP_HOST"),
Port: envOr("SMTP_PORT", "587"),
Username: os.Getenv("SMTP_USERNAME"),
Password: os.Getenv("SMTP_PASSWORD"),
From: os.Getenv("SMTP_FROM"),
To: envOr("SMTP_TO", "support@hostxtra.co.uk"),
}
}
func (c Config) Enabled() bool {
return c.Host != "" && c.From != "" && c.To != ""
}
func (c Config) Send(subject, body, replyTo string) error {
return c.sendTo(c.To, subject, body, replyTo)
}
func (c Config) sendTo(to, subject, body, replyTo string) error {
if !c.Enabled() {
return fmt.Errorf("smtp: not configured")
}
if to == "" {
return fmt.Errorf("smtp: no recipient")
}
addr := net.JoinHostPort(c.Host, c.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 c.Port == "465" {
conn = tls.Client(conn, &tls.Config{ServerName: c.Host})
}
client, err := smtp.NewClient(conn, c.Host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp: client: %w", err)
}
defer client.Close()
if c.Port != "465" {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: c.Host}); err != nil {
return fmt.Errorf("smtp: starttls: %w", err)
}
}
}
if c.Username != "" {
if err := client.Auth(smtp.PlainAuth("", c.Username, c.Password, c.Host)); err != nil {
return fmt.Errorf("smtp: auth: %w", err)
}
}
if err := client.Mail(c.From); err != nil {
return fmt.Errorf("smtp: mail from: %w", err)
}
for _, rcpt := range recipients(to) {
if err := client.Rcpt(rcpt); err != nil {
return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)
}
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp: data: %w", err)
}
if _, err := w.Write(message(c.From, to, subject, body, replyTo)); 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()
}
func recipients(to string) []string {
parts := strings.Split(to, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func message(from, to, subject, body, replyTo string) []byte {
var b strings.Builder
b.WriteString("From: " + sanitizeHeader(from) + "\r\n")
b.WriteString("To: " + sanitizeHeader(to) + "\r\n")
if replyTo != "" {
b.WriteString("Reply-To: " + sanitizeHeader(replyTo) + "\r\n")
}
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
b.WriteString("Message-ID: " + messageID(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 (c Config) SendVerification(to, instanceName, link string, ttl time.Duration) error {
body := fmt.Sprintf(`Confirm your email to finish creating %s on Vantage.
Open this link:
%s
The link works once and expires in %d hours. Until you use it, no account
exists nothing has been created and the address is not registered.
If you did not request this, ignore this email and nothing will happen.
`, instanceName, link, int(ttl.Hours()))
return c.sendTo(to, "Confirm your Vantage organisation", body, "")
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}