client.go raw

   1  package smtp
   2  
   3  import (
   4  	"bytes"
   5  	"crypto/tls"
   6  	"fmt"
   7  	"net"
   8  	"strings"
   9  	"time"
  10  
  11  	"github.com/emersion/go-message"
  12  	"github.com/emersion/go-sasl"
  13  	gosmtp "github.com/emersion/go-smtp"
  14  	"next.orly.dev/pkg/lol/log"
  15  )
  16  
  17  // ClientConfig holds outbound SMTP client configuration.
  18  type ClientConfig struct {
  19  	// FromDomain is the bridge's email domain for the From address.
  20  	FromDomain string
  21  	// DKIMSigner signs outbound messages. Nil disables DKIM.
  22  	DKIMSigner *DKIMSigner
  23  
  24  	// RelayHost is the SMTP smarthost for outbound delivery.
  25  	// If empty, direct MX delivery is used.
  26  	RelayHost string
  27  	// RelayPort is the smarthost port (typically 587 for STARTTLS).
  28  	RelayPort int
  29  	// RelayUsername is the SMTP AUTH username.
  30  	RelayUsername string
  31  	// RelayPassword is the SMTP AUTH password.
  32  	RelayPassword string
  33  }
  34  
  35  // MXResolver looks up MX records for a domain. The default is net.LookupMX.
  36  type MXResolver func(domain string) ([]*net.MX, error)
  37  
  38  // SMTPDialer connects to an SMTP server. The default is gosmtp.Dial.
  39  type SMTPDialer func(addr string) (*gosmtp.Client, error)
  40  
  41  // Client sends outbound emails.
  42  type Client struct {
  43  	cfg      ClientConfig
  44  	resolver MXResolver
  45  	dialer   SMTPDialer
  46  }
  47  
  48  // NewClient creates an outbound SMTP client.
  49  func NewClient(cfg ClientConfig) *Client {
  50  	return &Client{
  51  		cfg:      cfg,
  52  		resolver: net.LookupMX,
  53  		dialer:   gosmtp.Dial,
  54  	}
  55  }
  56  
  57  // SetResolver replaces the MX resolver (for testing).
  58  func (c *Client) SetResolver(r MXResolver) { c.resolver = r }
  59  
  60  // SetDialer replaces the SMTP dialer (for testing).
  61  func (c *Client) SetDialer(d SMTPDialer) { c.dialer = d }
  62  
  63  // OutboundEmail represents an email to be sent.
  64  type OutboundEmail struct {
  65  	From    string   // e.g., "npub1abc@relay.example.com"
  66  	To      []string // recipient email addresses
  67  	Cc      []string
  68  	Bcc     []string
  69  	Subject string
  70  	Body    string
  71  }
  72  
  73  // Send delivers an outbound email. If a relay host is configured, delivery
  74  // goes through the authenticated smarthost. Otherwise, direct MX is used.
  75  func (c *Client) Send(email *OutboundEmail) error {
  76  	// Build the message
  77  	msg, err := c.buildMessage(email)
  78  	if err != nil {
  79  		return fmt.Errorf("build message: %w", err)
  80  	}
  81  
  82  	// Sign with DKIM if configured
  83  	var msgBytes []byte
  84  	if c.cfg.DKIMSigner != nil {
  85  		signed, err := c.cfg.DKIMSigner.Sign(msg)
  86  		if err != nil {
  87  			log.W.F("DKIM signing failed, sending unsigned: %v", err)
  88  			msgBytes = msg
  89  		} else {
  90  			msgBytes = signed
  91  		}
  92  	} else {
  93  		msgBytes = msg
  94  	}
  95  
  96  	// Collect all recipients
  97  	allRecipients := make([]string, 0, len(email.To)+len(email.Cc)+len(email.Bcc))
  98  	allRecipients = append(allRecipients, email.To...)
  99  	allRecipients = append(allRecipients, email.Cc...)
 100  	allRecipients = append(allRecipients, email.Bcc...)
 101  
 102  	// Use relay if configured, otherwise direct MX
 103  	if c.cfg.RelayHost != "" {
 104  		return c.deliverViaRelay(email.From, allRecipients, msgBytes)
 105  	}
 106  
 107  	// Group recipients by domain for MX delivery
 108  	byDomain := groupByDomain(allRecipients)
 109  
 110  	var lastErr error
 111  	for domain, addrs := range byDomain {
 112  		if err := c.deliverToMX(domain, email.From, addrs, msgBytes); err != nil {
 113  			log.E.F("failed to deliver to %s: %v", domain, err)
 114  			lastErr = err
 115  		}
 116  	}
 117  
 118  	return lastErr
 119  }
 120  
 121  func (c *Client) buildMessage(email *OutboundEmail) ([]byte, error) {
 122  	var buf bytes.Buffer
 123  
 124  	h := message.Header{}
 125  	h.Set("From", email.From)
 126  	h.Set("To", strings.Join(email.To, ", "))
 127  	if len(email.Cc) > 0 {
 128  		h.Set("Cc", strings.Join(email.Cc, ", "))
 129  	}
 130  	// BCC is NOT included in headers (that's the point of BCC)
 131  	h.Set("Subject", email.Subject)
 132  	h.Set("Date", time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700"))
 133  	h.Set("Message-Id", fmt.Sprintf("<%d.bridge@%s>", time.Now().UnixNano(), c.cfg.FromDomain))
 134  	h.Set("MIME-Version", "1.0")
 135  	h.SetContentType("text/plain", map[string]string{"charset": "utf-8"})
 136  
 137  	w, err := message.CreateWriter(&buf, h)
 138  	if err != nil {
 139  		return nil, fmt.Errorf("create writer: %w", err)
 140  	}
 141  	if _, err := w.Write([]byte(email.Body)); err != nil {
 142  		return nil, fmt.Errorf("write body: %w", err)
 143  	}
 144  	if err := w.Close(); err != nil {
 145  		return nil, fmt.Errorf("close writer: %w", err)
 146  	}
 147  
 148  	return buf.Bytes(), nil
 149  }
 150  
 151  // deliverToMX looks up the MX records for a domain and delivers the message.
 152  func (c *Client) deliverToMX(domain, from string, to []string, msg []byte) error {
 153  	mxRecords, err := c.resolver(domain)
 154  	if err != nil {
 155  		return fmt.Errorf("MX lookup for %s: %w", domain, err)
 156  	}
 157  
 158  	if len(mxRecords) == 0 {
 159  		// Fall back to A record
 160  		mxRecords = []*net.MX{{Host: domain, Pref: 0}}
 161  	}
 162  
 163  	var lastErr error
 164  	for _, mx := range mxRecords {
 165  		host := strings.TrimSuffix(mx.Host, ".")
 166  		addr := net.JoinHostPort(host, "25")
 167  
 168  		if err := c.deliverDirect(addr, from, to, msg); err != nil {
 169  			lastErr = err
 170  			log.D.F("MX %s failed: %v, trying next", host, err)
 171  			continue
 172  		}
 173  		return nil // success
 174  	}
 175  
 176  	return fmt.Errorf("all MX servers failed for %s: %w", domain, lastErr)
 177  }
 178  
 179  // deliverDirect sends the message to a specific SMTP server.
 180  func (c *Client) deliverDirect(addr, from string, to []string, msg []byte) error {
 181  	cl, err := c.dialer(addr)
 182  	if err != nil {
 183  		return fmt.Errorf("dial %s: %w", addr, err)
 184  	}
 185  	defer cl.Quit()
 186  
 187  	if err := cl.Hello(c.cfg.FromDomain); err != nil {
 188  		return fmt.Errorf("HELO: %w", err)
 189  	}
 190  
 191  	if err := cl.Mail(from, nil); err != nil {
 192  		return fmt.Errorf("MAIL FROM: %w", err)
 193  	}
 194  
 195  	for _, rcpt := range to {
 196  		if err := cl.Rcpt(rcpt, nil); err != nil {
 197  			return fmt.Errorf("RCPT TO %s: %w", rcpt, err)
 198  		}
 199  	}
 200  
 201  	w, err := cl.Data()
 202  	if err != nil {
 203  		return fmt.Errorf("DATA: %w", err)
 204  	}
 205  
 206  	if _, err := w.Write(msg); err != nil {
 207  		return fmt.Errorf("write data: %w", err)
 208  	}
 209  
 210  	if err := w.Close(); err != nil {
 211  		return fmt.Errorf("close data: %w", err)
 212  	}
 213  
 214  	log.D.F("SMTP delivered to %s via %s", to, addr)
 215  	return nil
 216  }
 217  
 218  // deliverViaRelay sends the message through an authenticated SMTP smarthost.
 219  func (c *Client) deliverViaRelay(from string, to []string, msg []byte) error {
 220  	port := c.cfg.RelayPort
 221  	if port == 0 {
 222  		port = 587
 223  	}
 224  	addr := net.JoinHostPort(c.cfg.RelayHost, fmt.Sprintf("%d", port))
 225  
 226  	cl, err := gosmtp.DialStartTLS(addr, &tls.Config{ServerName: c.cfg.RelayHost})
 227  	if err != nil {
 228  		return fmt.Errorf("dial relay %s: %w", addr, err)
 229  	}
 230  	defer cl.Quit()
 231  
 232  	if err := cl.Hello(c.cfg.FromDomain); err != nil {
 233  		return fmt.Errorf("HELO: %w", err)
 234  	}
 235  
 236  	auth := sasl.NewPlainClient("", c.cfg.RelayUsername, c.cfg.RelayPassword)
 237  	if err := cl.Auth(auth); err != nil {
 238  		return fmt.Errorf("AUTH: %w", err)
 239  	}
 240  
 241  	if err := cl.Mail(from, nil); err != nil {
 242  		return fmt.Errorf("MAIL FROM: %w", err)
 243  	}
 244  
 245  	for _, rcpt := range to {
 246  		if err := cl.Rcpt(rcpt, nil); err != nil {
 247  			return fmt.Errorf("RCPT TO %s: %w", rcpt, err)
 248  		}
 249  	}
 250  
 251  	w, err := cl.Data()
 252  	if err != nil {
 253  		return fmt.Errorf("DATA: %w", err)
 254  	}
 255  
 256  	if _, err := w.Write(msg); err != nil {
 257  		return fmt.Errorf("write data: %w", err)
 258  	}
 259  
 260  	if err := w.Close(); err != nil {
 261  		return fmt.Errorf("close data: %w", err)
 262  	}
 263  
 264  	log.D.F("SMTP delivered to %v via relay %s", to, c.cfg.RelayHost)
 265  	return nil
 266  }
 267  
 268  // groupByDomain groups email addresses by their domain part.
 269  func groupByDomain(addrs []string) map[string][]string {
 270  	result := make(map[string][]string)
 271  	for _, addr := range addrs {
 272  		parts := strings.SplitN(addr, "@", 2)
 273  		if len(parts) == 2 {
 274  			domain := strings.ToLower(parts[1])
 275  			result[domain] = append(result[domain], addr)
 276  		}
 277  	}
 278  	return result
 279  }
 280