Files

307 lines
10 KiB
Go

// 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"
"mime/quotedprintable"
"net"
netmail "net/mail"
"net/smtp"
"net/textproto"
"os"
"sort"
"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
// SMTP send phases. A caller that retries per recipient needs to know which
// one failed: a recipient or data reply names one person's problem, while a
// connect or quit failure says nothing about the message itself and a data
// failure with no reply means the outcome of that one send is unknown.
const (
PhasePrepare = "prepare"
PhaseConnect = "connect"
PhaseRecipient = "recipient"
PhaseData = "data"
PhaseQuit = "quit"
)
// SendError names which phase of the SMTP conversation failed. Err is kept as
// the original wrapped error, so errors.As(err, &textprotoErr) still reaches
// a *textproto.Error through Unwrap when the server sent one.
type SendError struct {
Phase string
Err error
}
func (e *SendError) Error() string { return "smtp " + e.Phase + ": " + e.Err.Error() }
func (e *SendError) Unwrap() error { return e.Err }
// 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
// Headers are extra header lines for this one message, such as an
// announcement's List-Unsubscribe pair. Keys and values are CR/LF-stripped
// like every other header.
Headers map[string]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, s.PublicURL)
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 &SendError{Phase: PhasePrepare, Err: fmt.Errorf("smtp: not configured")}
}
rcpts := recipients(m.To)
if len(rcpts) == 0 {
return &SendError{Phase: PhasePrepare, Err: fmt.Errorf("smtp: no recipient")}
}
addr := net.JoinHostPort(s.Host, s.Port)
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return &SendError{Phase: PhaseConnect, Err: 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 &SendError{Phase: PhaseConnect, Err: 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 &SendError{Phase: PhaseConnect, Err: fmt.Errorf("smtp: starttls: %w", err)}
}
}
}
if s.Username != "" {
if err := client.Auth(smtp.PlainAuth("", s.Username, s.Password, s.Host)); err != nil {
return &SendError{Phase: PhaseConnect, Err: fmt.Errorf("smtp: auth: %w", err)}
}
}
if err := client.Mail(addrSpec(s.From)); err != nil {
return &SendError{Phase: PhaseConnect, Err: fmt.Errorf("smtp: mail from: %w", err)}
}
for _, rcpt := range rcpts {
if err := client.Rcpt(addrSpec(rcpt)); err != nil {
return &SendError{Phase: PhaseRecipient, Err: fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)}
}
}
body, err := s.envelope(m)
if err != nil {
return &SendError{Phase: PhasePrepare, Err: fmt.Errorf("smtp: build message: %w", err)}
}
w, err := client.Data()
if err != nil {
return &SendError{Phase: PhaseData, Err: fmt.Errorf("smtp: data: %w", err)}
}
if _, err := w.Write(body); err != nil {
return &SendError{Phase: PhaseData, Err: fmt.Errorf("smtp: write: %w", err)}
}
if err := w.Close(); err != nil {
return &SendError{Phase: PhaseData, Err: fmt.Errorf("smtp: close data: %w", err)}
}
if err := client.Quit(); err != nil {
return &SendError{Phase: PhaseQuit, Err: fmt.Errorf("smtp: quit: %w", err)}
}
return nil
}
// addrSpec is the bare address for the SMTP envelope. SMTP_FROM is usually
// "Vantage <support@example.com>", which belongs in the From header only:
// sent as MAIL FROM it became "<Vantage <support@example.com>>". Postfix
// salvaged the address, so delivery and Return-Path looked fine, but rspamd
// saw no envelope sender and mailcow skipped DKIM signing, so Gmail filed
// every Vantage email as spam. Anything that does not parse is passed through
// for the server to judge.
func addrSpec(v string) string {
if a, err := netmail.ParseAddress(v); err == nil {
return a.Address
}
return strings.TrimSpace(v)
}
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.
//
// Both parts are quoted-printable. They were raw UTF-8 with no
// Content-Transfer-Encoding, which means 7bit, and every template carries
// non-ASCII (the middot in the masthead at least), which rspamd scored as
// R_BAD_CTE_7BIT.
func (s Sender) envelope(m message) ([]byte, error) {
var parts strings.Builder
w := multipart.NewWriter(&parts)
if err := writeQPPart(w, "text/plain; charset=utf-8", m.Text); err != nil {
return nil, err
}
if err := writeQPPart(w, "text/html; charset=utf-8", 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")
keys := make([]string, 0, len(m.Headers))
for k := range m.Headers {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
b.WriteString(sanitizeHeader(k) + ": " + sanitizeHeader(m.Headers[k]) + "\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
}
// writeQPPart adds one quoted-printable part, so the wire stays 7-bit and
// lines stay under SMTP's 998-byte limit whatever the template produced.
func writeQPPart(w *multipart.Writer, contentType, body string) error {
part, err := w.CreatePart(textproto.MIMEHeader{
"Content-Type": {contentType},
"Content-Transfer-Encoding": {"quoted-printable"},
})
if err != nil {
return err
}
qp := quotedprintable.NewWriter(part)
if _, err := qp.Write([]byte(body)); err != nil {
return err
}
return qp.Close()
}
func messageID(from string) string {
domain := "vantage.local"
if at := strings.LastIndex(from, "@"); at >= 0 && at < len(from)-1 {
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)
}