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