// 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 ", which belongs in the From header only: // sent as MAIL FROM it became ">". 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) }