package app import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "net/smtp" "os" "strings" ) // Mailer sends transactional email. Kept deliberately small so backends are // swappable and the whole thing can be repointed or removed easily. type Mailer interface { Send(to []string, subject, htmlBody, textBody string) error } // NewMailerFromEnv picks a backend from MAIL_BACKEND (log|smtp|sendgrid), // defaulting to log so development needs no email infrastructure. func NewMailerFromEnv() Mailer { from := envOr("MAIL_FROM", "Spread ") switch strings.ToLower(os.Getenv("MAIL_BACKEND")) { case "smtp": return &SMTPMailer{ Host: os.Getenv("SMTP_HOST"), Port: envOr("SMTP_PORT", "587"), User: os.Getenv("SMTP_USER"), Pass: os.Getenv("SMTP_PASS"), From: from, } case "sendgrid": return &SendgridMailer{Key: os.Getenv("SENDGRID_API_KEY"), From: from} default: return LogMailer{} } } func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } // LogMailer writes messages to the server log — the default in development. type LogMailer struct{} func (LogMailer) Send(to []string, subject, htmlBody, textBody string) error { log.Printf("[mail:log] to=%s subject=%q\n%s", strings.Join(to, ", "), subject, textBody) return nil } // SMTPMailer sends via any SMTP relay using only the standard library. type SMTPMailer struct { Host, Port, User, Pass, From string } func (m *SMTPMailer) Send(to []string, subject, htmlBody, textBody string) error { const boundary = "spreadalt" var b strings.Builder fmt.Fprintf(&b, "From: %s\r\n", m.From) fmt.Fprintf(&b, "To: %s\r\n", strings.Join(to, ", ")) fmt.Fprintf(&b, "Subject: %s\r\n", subject) b.WriteString("MIME-Version: 1.0\r\n") fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=%s\r\n\r\n", boundary) fmt.Fprintf(&b, "--%s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n\r\n", boundary, textBody) fmt.Fprintf(&b, "--%s\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n\r\n", boundary, htmlBody) fmt.Fprintf(&b, "--%s--\r\n", boundary) var auth smtp.Auth if m.User != "" { auth = smtp.PlainAuth("", m.User, m.Pass, m.Host) } return smtp.SendMail(m.Host+":"+m.Port, auth, m.From, to, []byte(b.String())) } // SendgridMailer posts to SendGrid's v3 API over plain net/http (no SDK). type SendgridMailer struct { Key, From string } func (m *SendgridMailer) Send(to []string, subject, htmlBody, textBody string) error { recips := make([]map[string]string, len(to)) for i, addr := range to { recips[i] = map[string]string{"email": addr} } payload := map[string]any{ "personalizations": []any{map[string]any{"to": recips}}, "from": map[string]string{"email": m.From}, "subject": subject, "content": []any{ map[string]string{"type": "text/plain", "value": textBody}, map[string]string{"type": "text/html", "value": htmlBody}, }, } body, _ := json.Marshal(payload) req, err := http.NewRequest(http.MethodPost, "https://api.sendgrid.com/v3/mail/send", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+m.Key) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return fmt.Errorf("sendgrid: %s: %s", resp.Status, msg) } return nil }