This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user