Go · 4368 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package email is the transactional-email layer for the auth surface.
4 //
5 // Concrete implementations:
6 // - Stdout — writes a human-readable dump of every email to a writer.
7 // Used in tests and as the dev default when no SMTP is configured.
8 // - MailHog (any plain SMTP server) — used by `make dev-email` for
9 // local end-to-end testing.
10 // - Postmark — the prod implementation. Skeleton lives in postmark.go;
11 // full credentials wired by S35 deploy work.
12 //
13 // Templates live alongside the auth handlers under templates/email/ and
14 // have HTML + plaintext variants. Senders MUST send both.
15 package email
16
17 import (
18 "context"
19 "fmt"
20 "io"
21 "net/smtp"
22 "strings"
23 "sync"
24 "time"
25 )
26
27 // Message is the unit a Sender ships. Both HTML and Text are required —
28 // every transactional email shithub sends must work in plain-text clients.
29 type Message struct {
30 From string
31 To string
32 Subject string
33 HTML string
34 Text string
35 }
36
37 // Sender is the abstract interface every backend implements.
38 // Implementations MUST be safe for concurrent use.
39 type Sender interface {
40 Send(ctx context.Context, msg Message) error
41 }
42
43 // StdoutSender writes a dump of every send to w. Convenient for tests
44 // (capture w to assert) and as the dev default when no real backend is
45 // configured. Implements Sender.
46 type StdoutSender struct {
47 mu sync.Mutex
48 w io.Writer
49 }
50
51 // NewStdoutSender returns a sender that writes to w. Pass os.Stdout in dev.
52 func NewStdoutSender(w io.Writer) *StdoutSender { return &StdoutSender{w: w} }
53
54 // Send implements Sender.
55 func (s *StdoutSender) Send(_ context.Context, m Message) error {
56 s.mu.Lock()
57 defer s.mu.Unlock()
58 _, err := fmt.Fprintf(s.w,
59 "--- email (%s) ---\nFrom: %s\nTo: %s\nSubject: %s\n\n[text]\n%s\n\n[html]\n%s\n--- end ---\n",
60 time.Now().UTC().Format(time.RFC3339), m.From, m.To, m.Subject, m.Text, m.HTML)
61 return err
62 }
63
64 // SMTPSender ships messages through a plain SMTP server. Used for
65 // MailHog locally. SMTP credentials are optional (MailHog accepts none).
66 type SMTPSender struct {
67 Addr string // host:port
68 From string // default From when Message.From is empty
69 Username string // optional
70 Password string // optional
71 UseTLS bool // STARTTLS upgrade after EHLO
72 }
73
74 // Send implements Sender. Builds a multipart/alternative body so MUAs can
75 // pick the variant they prefer.
76 //
77 // The SMTP envelope sender is the bare address — `MAIL FROM:<...>` per
78 // RFC 5321 forbids nested angle brackets, so a configured `From` like
79 // `shithub <noreply@shithub.local>` only goes in the message header.
80 // The displayed-name form lives unchanged in the `From:` header inside
81 // the body.
82 func (s *SMTPSender) Send(_ context.Context, m Message) error {
83 if m.From == "" {
84 m.From = s.From
85 }
86 body := buildMultipart(m)
87
88 var auth smtp.Auth
89 if s.Username != "" {
90 host := s.Addr
91 if i := strings.IndexByte(host, ':'); i > 0 {
92 host = host[:i]
93 }
94 auth = smtp.PlainAuth("", s.Username, s.Password, host)
95 }
96 return smtp.SendMail(s.Addr, auth, envelopeAddress(m.From), []string{m.To}, body)
97 }
98
99 // envelopeAddress extracts the bare email address from a possibly-
100 // displayed-name form like `Name <addr@example>`. Returns the input
101 // unchanged when no angle brackets are present (already bare).
102 func envelopeAddress(from string) string {
103 if i := strings.IndexByte(from, '<'); i >= 0 {
104 if j := strings.IndexByte(from[i+1:], '>'); j >= 0 {
105 return from[i+1 : i+1+j]
106 }
107 }
108 return from
109 }
110
111 // boundary is a fixed multipart boundary. Per RFC 2046 the boundary need
112 // only be unique within a message; a constant is fine here because we
113 // always include the canonical Content-Type header that names it.
114 const boundary = "shithub-mime-boundary-x"
115
116 func buildMultipart(m Message) []byte {
117 var b strings.Builder
118 fmt.Fprintf(&b, "From: %s\r\n", m.From)
119 fmt.Fprintf(&b, "To: %s\r\n", m.To)
120 fmt.Fprintf(&b, "Subject: %s\r\n", m.Subject)
121 fmt.Fprintf(&b, "MIME-Version: 1.0\r\n")
122 fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=%q\r\n\r\n", boundary)
123
124 fmt.Fprintf(&b, "--%s\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s\r\n", boundary, m.Text)
125 fmt.Fprintf(&b, "--%s\r\nContent-Type: text/html; charset=utf-8\r\n\r\n%s\r\n", boundary, m.HTML)
126 fmt.Fprintf(&b, "--%s--\r\n", boundary)
127
128 return []byte(b.String())
129 }
130