rss email digests over ssh because you're a cool kid
herald.dunkirk.sh
go
rss
rss-reader
ssh
charm
1package email
2
3import (
4 "bytes"
5 "crypto/rand"
6 "crypto/rsa"
7 "crypto/tls"
8 "crypto/x509"
9 "encoding/hex"
10 "encoding/pem"
11 "fmt"
12 "mime"
13 "mime/quotedprintable"
14 "net"
15 "net/smtp"
16 "os"
17 "strings"
18 "time"
19
20 "github.com/emersion/go-msgauth/dkim"
21)
22
23type SMTPConfig struct {
24 Host string
25 Port int
26 User string
27 Pass string
28 From string
29 DKIMPrivateKey string
30 DKIMPrivateKeyFile string
31 DKIMSelector string
32 DKIMDomain string
33}
34
35type Mailer struct {
36 cfg SMTPConfig
37 unsubBaseURL string
38 dkimKey *rsa.PrivateKey
39}
40
41func NewMailer(cfg SMTPConfig, unsubBaseURL string) (*Mailer, error) {
42 m := &Mailer{
43 cfg: cfg,
44 unsubBaseURL: unsubBaseURL,
45 }
46
47 // Parse DKIM private key if provided
48 var keyData string
49 if cfg.DKIMPrivateKey != "" {
50 keyData = cfg.DKIMPrivateKey
51 } else if cfg.DKIMPrivateKeyFile != "" {
52 keyBytes, err := os.ReadFile(cfg.DKIMPrivateKeyFile)
53 if err != nil {
54 return nil, fmt.Errorf("failed to read DKIM private key file: %w", err)
55 }
56 keyData = string(keyBytes)
57 }
58
59 if keyData != "" && strings.Contains(keyData, "BEGIN") {
60 // Replace literal \n with actual newlines (for .env file compatibility)
61 keyData = strings.ReplaceAll(keyData, "\\n", "\n")
62
63 block, _ := pem.Decode([]byte(keyData))
64 if block == nil {
65 return nil, fmt.Errorf("failed to decode DKIM private key PEM")
66 }
67
68 key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
69 if err != nil {
70 // Try PKCS8 format
71 keyInterface, err := x509.ParsePKCS8PrivateKey(block.Bytes)
72 if err != nil {
73 return nil, fmt.Errorf("failed to parse DKIM private key: %w", err)
74 }
75 var ok bool
76 key, ok = keyInterface.(*rsa.PrivateKey)
77 if !ok {
78 return nil, fmt.Errorf("DKIM private key is not RSA")
79 }
80 }
81 m.dkimKey = key
82 }
83
84 return m, nil
85}
86
87// ValidateConfig tests SMTP connectivity and auth
88func (m *Mailer) ValidateConfig() error {
89 addr := net.JoinHostPort(m.cfg.Host, fmt.Sprintf("%d", m.cfg.Port))
90
91 var auth smtp.Auth
92 if m.cfg.User != "" && m.cfg.Pass != "" {
93 auth = smtp.PlainAuth("", m.cfg.User, m.cfg.Pass, m.cfg.Host)
94 }
95
96 // Port 465 uses implicit TLS
97 if m.cfg.Port == 465 {
98 tlsConfig := &tls.Config{
99 ServerName: m.cfg.Host,
100 MinVersion: tls.VersionTLS12,
101 }
102
103 conn, err := tls.Dial("tcp", addr, tlsConfig)
104 if err != nil {
105 return fmt.Errorf("TLS dial: %w", err)
106 }
107 defer func() { _ = conn.Close() }()
108
109 client, err := smtp.NewClient(conn, m.cfg.Host)
110 if err != nil {
111 return fmt.Errorf("SMTP client: %w", err)
112 }
113 defer func() { _ = client.Close() }()
114
115 if auth != nil {
116 if err = client.Auth(auth); err != nil {
117 return fmt.Errorf("auth: %w", err)
118 }
119 }
120
121 return client.Quit()
122 }
123
124 // Port 587 uses STARTTLS
125 conn, err := net.Dial("tcp", addr)
126 if err != nil {
127 return fmt.Errorf("dial: %w", err)
128 }
129 defer func() { _ = conn.Close() }()
130
131 client, err := smtp.NewClient(conn, m.cfg.Host)
132 if err != nil {
133 return fmt.Errorf("SMTP client: %w", err)
134 }
135 defer func() { _ = client.Close() }()
136
137 // Start TLS before auth
138 tlsConfig := &tls.Config{
139 ServerName: m.cfg.Host,
140 MinVersion: tls.VersionTLS12,
141 }
142 if err = client.StartTLS(tlsConfig); err != nil {
143 return fmt.Errorf("STARTTLS: %w", err)
144 }
145
146 if auth != nil {
147 if err = client.Auth(auth); err != nil {
148 return fmt.Errorf("auth: %w", err)
149 }
150 }
151
152 return client.Quit()
153}
154
155func (m *Mailer) Send(to, subject, htmlBody, textBody, unsubToken, dashboardURL, keepAliveURL string) error {
156 addr := net.JoinHostPort(m.cfg.Host, fmt.Sprintf("%d", m.cfg.Port))
157
158 boundary := "==herald-boundary-a1b2c3d4e5f6=="
159
160 // Add footer with keep-alive, unsubscribe, and dashboard links
161 var htmlFooter strings.Builder
162 var textFooter strings.Builder
163
164 if keepAliveURL != "" || unsubToken != "" || dashboardURL != "" {
165 htmlFooter.WriteString(`<hr><p style="font-size: 12px; color: #666;">`)
166 textFooter.WriteString("\n\n---\n")
167
168 if keepAliveURL != "" {
169 htmlFooter.WriteString(fmt.Sprintf(`<a href="%s">keep this digest active</a>`, keepAliveURL))
170 textFooter.WriteString(fmt.Sprintf("keep this digest active: %s\n", keepAliveURL))
171 }
172
173 if dashboardURL != "" {
174 if keepAliveURL != "" {
175 htmlFooter.WriteString(" • ")
176 }
177 htmlFooter.WriteString(fmt.Sprintf(`<a href="%s">profile</a>`, dashboardURL))
178 textFooter.WriteString(fmt.Sprintf("profile: %s\n", dashboardURL))
179 }
180
181 if unsubToken != "" {
182 unsubURL := m.unsubBaseURL + "/unsubscribe/" + unsubToken
183 if dashboardURL != "" || keepAliveURL != "" {
184 htmlFooter.WriteString(" • ")
185 }
186 htmlFooter.WriteString(fmt.Sprintf(`<a href="%s">unsubscribe</a>`, unsubURL))
187 textFooter.WriteString(fmt.Sprintf("unsubscribe: %s\n", unsubURL))
188 }
189
190 htmlFooter.WriteString("</p>")
191 htmlBody = htmlBody + htmlFooter.String()
192 textBody = textBody + textFooter.String()
193 }
194
195 headers := make(map[string]string)
196 headers["From"] = m.cfg.From
197 headers["To"] = to
198 headers["Subject"] = mime.QEncoding.Encode("utf-8", subject)
199 headers["MIME-Version"] = "1.0"
200 headers["Content-Type"] = fmt.Sprintf("multipart/alternative; boundary=%q", boundary)
201 headers["Date"] = time.Now().Format(time.RFC1123Z)
202 headers["Message-ID"] = fmt.Sprintf("<%d.%s@%s>", time.Now().Unix(), generateMessageIDToken(), m.cfg.Host)
203
204 // RFC 2369 list headers
205 headers["List-Id"] = fmt.Sprintf("<herald.%s>", m.cfg.Host)
206 headers["List-Archive"] = fmt.Sprintf("<%s>", dashboardURL)
207 headers["List-Post"] = "NO"
208
209 // RFC 8058 unsubscribe headers
210 if unsubToken != "" {
211 unsubURL := m.unsubBaseURL + "/unsubscribe/" + unsubToken
212 headers["List-Unsubscribe"] = fmt.Sprintf("<%s>", unsubURL)
213 headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
214 }
215
216 // Bulk mail headers for better deliverability
217 headers["Precedence"] = "bulk"
218 headers["X-Mailer"] = "Herald"
219
220 var msg strings.Builder
221 for k, v := range headers {
222 msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
223 }
224 msg.WriteString("\r\n")
225
226 msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
227 msg.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
228 msg.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
229 textQP := encodeQuotedPrintable(textBody)
230 msg.WriteString(textQP)
231 msg.WriteString("\r\n")
232
233 msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
234 msg.WriteString("Content-Type: text/html; charset=utf-8\r\n")
235 msg.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
236 htmlQP := encodeQuotedPrintable(htmlBody)
237 msg.WriteString(htmlQP)
238 msg.WriteString("\r\n")
239
240 msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
241
242 messageBytes := []byte(msg.String())
243
244 // Sign with DKIM if configured
245 if m.dkimKey != nil && m.cfg.DKIMDomain != "" && m.cfg.DKIMSelector != "" {
246 signed, err := m.signDKIM(messageBytes)
247 if err != nil {
248 return fmt.Errorf("DKIM signing: %w", err)
249 }
250 messageBytes = signed
251 }
252
253 var auth smtp.Auth
254 if m.cfg.User != "" && m.cfg.Pass != "" {
255 auth = smtp.PlainAuth("", m.cfg.User, m.cfg.Pass, m.cfg.Host)
256 }
257
258 if m.cfg.Port == 465 {
259 return m.sendWithTLS(addr, auth, to, messageBytes)
260 }
261
262 return m.sendWithSTARTTLS(addr, auth, to, messageBytes)
263}
264
265func generateMessageIDToken() string {
266 b := make([]byte, 8)
267 _, _ = rand.Read(b)
268 return hex.EncodeToString(b)
269}
270
271func encodeQuotedPrintable(s string) string {
272 var buf strings.Builder
273 w := quotedprintable.NewWriter(&buf)
274 _, _ = w.Write([]byte(s))
275 _ = w.Close()
276 return buf.String()
277}
278
279func (m *Mailer) sendWithTLS(addr string, auth smtp.Auth, to string, msg []byte) error {
280 tlsConfig := &tls.Config{
281 ServerName: m.cfg.Host,
282 MinVersion: tls.VersionTLS12,
283 }
284
285 dialer := &net.Dialer{Timeout: 30 * time.Second}
286 conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
287 if err != nil {
288 return fmt.Errorf("TLS dial: %w", err)
289 }
290 if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
291 _ = conn.Close()
292 return fmt.Errorf("set deadline: %w", err)
293 }
294 defer func() { _ = conn.Close() }()
295
296 client, err := smtp.NewClient(conn, m.cfg.Host)
297 if err != nil {
298 return fmt.Errorf("SMTP client: %w", err)
299 }
300 defer func() { _ = client.Close() }()
301
302 if auth != nil {
303 if err = client.Auth(auth); err != nil {
304 return fmt.Errorf("auth: %w", err)
305 }
306 }
307
308 if err = client.Mail(m.cfg.From); err != nil {
309 return fmt.Errorf("mail from: %w", err)
310 }
311
312 if err = client.Rcpt(to); err != nil {
313 return fmt.Errorf("rcpt to: %w", err)
314 }
315
316 w, err := client.Data()
317 if err != nil {
318 return fmt.Errorf("data: %w", err)
319 }
320
321 if _, err = w.Write(msg); err != nil {
322 return fmt.Errorf("write: %w", err)
323 }
324
325 if err = w.Close(); err != nil {
326 return fmt.Errorf("close data: %w", err)
327 }
328
329 return client.Quit()
330}
331
332func (m *Mailer) sendWithSTARTTLS(addr string, auth smtp.Auth, to string, msg []byte) error {
333 dialer := &net.Dialer{Timeout: 30 * time.Second}
334 conn, err := dialer.Dial("tcp", addr)
335 if err != nil {
336 return fmt.Errorf("dial: %w", err)
337 }
338 if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
339 _ = conn.Close()
340 return fmt.Errorf("set deadline: %w", err)
341 }
342 defer func() { _ = conn.Close() }()
343
344 client, err := smtp.NewClient(conn, m.cfg.Host)
345 if err != nil {
346 return fmt.Errorf("SMTP client: %w", err)
347 }
348 defer func() { _ = client.Close() }()
349
350 if err = client.StartTLS(&tls.Config{
351 ServerName: m.cfg.Host,
352 MinVersion: tls.VersionTLS12,
353 }); err != nil {
354 return fmt.Errorf("STARTTLS: %w", err)
355 }
356
357 if auth != nil {
358 if err = client.Auth(auth); err != nil {
359 return fmt.Errorf("auth: %w", err)
360 }
361 }
362
363 if err = client.Mail(m.cfg.From); err != nil {
364 return fmt.Errorf("mail from: %w", err)
365 }
366
367 if err = client.Rcpt(to); err != nil {
368 return fmt.Errorf("rcpt to: %w", err)
369 }
370
371 w, err := client.Data()
372 if err != nil {
373 return fmt.Errorf("data: %w", err)
374 }
375
376 if _, err = w.Write(msg); err != nil {
377 return fmt.Errorf("write: %w", err)
378 }
379
380 if err = w.Close(); err != nil {
381 return fmt.Errorf("close data: %w", err)
382 }
383
384 return client.Quit()
385}
386
387func (m *Mailer) signDKIM(message []byte) ([]byte, error) {
388 options := &dkim.SignOptions{
389 Domain: m.cfg.DKIMDomain,
390 Selector: m.cfg.DKIMSelector,
391 Signer: m.dkimKey,
392 HeaderCanonicalization: dkim.CanonicalizationRelaxed,
393 BodyCanonicalization: dkim.CanonicalizationRelaxed,
394 HeaderKeys: []string{
395 "From",
396 "To",
397 "Subject",
398 "List-Unsubscribe",
399 "List-Unsubscribe-Post",
400 },
401 Expiration: time.Now().Add(72 * time.Hour),
402 }
403
404 var b bytes.Buffer
405 if err := dkim.Sign(&b, bytes.NewReader(message), options); err != nil {
406 return nil, err
407 }
408
409 return b.Bytes(), nil
410}