spread

https://git.tonybtw.com/spread.git git://git.tonybtw.com/spread.git
3,514 bytes raw
1
package app
2
3
import (
4
	"bytes"
5
	"encoding/json"
6
	"fmt"
7
	"io"
8
	"log"
9
	"net/http"
10
	"net/smtp"
11
	"os"
12
	"strings"
13
)
14
15
// Mailer sends transactional email. Kept deliberately small so backends are
16
// swappable and the whole thing can be repointed or removed easily.
17
type Mailer interface {
18
	Send(to []string, subject, htmlBody, textBody string) error
19
}
20
21
// NewMailerFromEnv picks a backend from MAIL_BACKEND (log|smtp|sendgrid),
22
// defaulting to log so development needs no email infrastructure.
23
func NewMailerFromEnv() Mailer {
24
	from := envOr("MAIL_FROM", "Spread <no-reply@spread.local>")
25
	switch strings.ToLower(os.Getenv("MAIL_BACKEND")) {
26
	case "smtp":
27
		return &SMTPMailer{
28
			Host: os.Getenv("SMTP_HOST"),
29
			Port: envOr("SMTP_PORT", "587"),
30
			User: os.Getenv("SMTP_USER"),
31
			Pass: os.Getenv("SMTP_PASS"),
32
			From: from,
33
		}
34
	case "sendgrid":
35
		return &SendgridMailer{Key: os.Getenv("SENDGRID_API_KEY"), From: from}
36
	default:
37
		return LogMailer{}
38
	}
39
}
40
41
func envOr(key, def string) string {
42
	if v := os.Getenv(key); v != "" {
43
		return v
44
	}
45
	return def
46
}
47
48
// LogMailer writes messages to the server log — the default in development.
49
type LogMailer struct{}
50
51
func (LogMailer) Send(to []string, subject, htmlBody, textBody string) error {
52
	log.Printf("[mail:log] to=%s subject=%q\n%s", strings.Join(to, ", "), subject, textBody)
53
	return nil
54
}
55
56
// SMTPMailer sends via any SMTP relay using only the standard library.
57
type SMTPMailer struct {
58
	Host, Port, User, Pass, From string
59
}
60
61
func (m *SMTPMailer) Send(to []string, subject, htmlBody, textBody string) error {
62
	const boundary = "spreadalt"
63
	var b strings.Builder
64
	fmt.Fprintf(&b, "From: %s\r\n", m.From)
65
	fmt.Fprintf(&b, "To: %s\r\n", strings.Join(to, ", "))
66
	fmt.Fprintf(&b, "Subject: %s\r\n", subject)
67
	b.WriteString("MIME-Version: 1.0\r\n")
68
	fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=%s\r\n\r\n", boundary)
69
	fmt.Fprintf(&b, "--%s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n\r\n", boundary, textBody)
70
	fmt.Fprintf(&b, "--%s\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n\r\n", boundary, htmlBody)
71
	fmt.Fprintf(&b, "--%s--\r\n", boundary)
72
73
	var auth smtp.Auth
74
	if m.User != "" {
75
		auth = smtp.PlainAuth("", m.User, m.Pass, m.Host)
76
	}
77
	return smtp.SendMail(m.Host+":"+m.Port, auth, m.From, to, []byte(b.String()))
78
}
79
80
// SendgridMailer posts to SendGrid's v3 API over plain net/http (no SDK).
81
type SendgridMailer struct {
82
	Key, From string
83
}
84
85
func (m *SendgridMailer) Send(to []string, subject, htmlBody, textBody string) error {
86
	recips := make([]map[string]string, len(to))
87
	for i, addr := range to {
88
		recips[i] = map[string]string{"email": addr}
89
	}
90
	payload := map[string]any{
91
		"personalizations": []any{map[string]any{"to": recips}},
92
		"from":             map[string]string{"email": m.From},
93
		"subject":          subject,
94
		"content": []any{
95
			map[string]string{"type": "text/plain", "value": textBody},
96
			map[string]string{"type": "text/html", "value": htmlBody},
97
		},
98
	}
99
	body, _ := json.Marshal(payload)
100
	req, err := http.NewRequest(http.MethodPost, "https://api.sendgrid.com/v3/mail/send", bytes.NewReader(body))
101
	if err != nil {
102
		return err
103
	}
104
	req.Header.Set("Authorization", "Bearer "+m.Key)
105
	req.Header.Set("Content-Type", "application/json")
106
	resp, err := http.DefaultClient.Do(req)
107
	if err != nil {
108
		return err
109
	}
110
	defer resp.Body.Close()
111
	if resp.StatusCode >= 300 {
112
		msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
113
		return fmt.Errorf("sendgrid: %s: %s", resp.Status, msg)
114
	}
115
	return nil
116
}