rss email digests over ssh because you're a cool kid herald.dunkirk.sh
go rss rss-reader ssh charm
at main 410 lines 11 kB view raw
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}