carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

1// Package mail sends the one message CONTRA sends: a signup link.
2//
3// Stdlib net/smtp against the credentials the platform delivers
4// (CARLOS_SMTP_*). No dependency, because one message with one link does not
5// need a mail library, and because everything this package touches is an
6// address we have promised not to keep.
7//
8// # What it must not do
9//
10// The address is used to address one message and is never written down. There
11// is no send log, no bounce table, no "last emailed at" column — the blind
12// index is the only trace a signup leaves, and adding a second one here would
13// undo the reason the first one is blind.
14package mail
15
16import (
17 "context"
18 "crypto/tls"
19 "errors"
20 "fmt"
21 "net"
22 "net/smtp"
23 "os"
24 "strings"
25 "time"
26)
27
28// Sender delivers a message. An interface so tests take a fake and never open
29// a socket, and so a deployment with no email configured fails loudly at boot
30// rather than at the first signup.
31type Sender interface {
32 Send(ctx context.Context, to, subject, body string) error
33}
34
35var ErrNotConfigured = errors.New("mail: no SMTP configuration (CARLOS_SMTP_HOST and friends)")
36
37type smtpSender struct {
38 host, port, user, pass, from string
39}
40
41// FromEnv reads the platform's delivered configuration. It returns
42// ErrNotConfigured rather than a silent no-op sender: a signup flow whose
43// email quietly goes nowhere is worse than one that refuses to start.
44func FromEnv() (Sender, error) {
45 s := smtpSender{
46 host: os.Getenv("CARLOS_SMTP_HOST"),
47 port: os.Getenv("CARLOS_SMTP_PORT"),
48 user: os.Getenv("CARLOS_SMTP_USER"),
49 pass: os.Getenv("CARLOS_SMTP_PASS"),
50 from: os.Getenv("CARLOS_SMTP_FROM"),
51 }
52 if s.host == "" || s.from == "" {
53 return nil, ErrNotConfigured
54 }
55 if s.port == "" {
56 s.port = "587"
57 }
58 return s, nil
59}
60
61func (s smtpSender) Send(ctx context.Context, to, subject, body string) error {
62 // One header set, built here rather than assembled from user input: `to`
63 // is the only variable, and a newline in it would be header injection.
64 if strings.ContainsAny(to, "\r\n") || strings.ContainsAny(subject, "\r\n") {
65 return errors.New("mail: newline in a header")
66 }
67
68 msg := strings.Join([]string{
69 "From: " + s.from,
70 "To: " + to,
71 "Subject: " + subject,
72 "MIME-Version: 1.0",
73 "Content-Type: text/plain; charset=utf-8",
74 "", body,
75 }, "\r\n")
76
77 deadline, ok := ctx.Deadline()
78 if !ok {
79 deadline = time.Now().Add(20 * time.Second)
80 }
81 conn, err := (&net.Dialer{Deadline: deadline}).DialContext(
82 ctx, "tcp", net.JoinHostPort(s.host, s.port))
83 if err != nil {
84 return fmt.Errorf("mail: dial: %w", err)
85 }
86 defer conn.Close()
87
88 c, err := smtp.NewClient(conn, s.host)
89 if err != nil {
90 return fmt.Errorf("mail: smtp: %w", err)
91 }
92 defer c.Close()
93
94 // STARTTLS with the server name verified. The credentials are a bearer
95 // secret and the recipient is an address we have promised not to keep;
96 // neither goes over the wire in the clear.
97 if err := c.StartTLS(&tls.Config{ServerName: s.host, MinVersion: tls.VersionTLS12}); err != nil {
98 return fmt.Errorf("mail: starttls: %w", err)
99 }
100 if s.user != "" {
101 if err := c.Auth(smtp.PlainAuth("", s.user, s.pass, s.host)); err != nil {
102 return fmt.Errorf("mail: auth: %w", err)
103 }
104 }
105 if err := c.Mail(s.from); err != nil {
106 return fmt.Errorf("mail: from: %w", err)
107 }
108 if err := c.Rcpt(to); err != nil {
109 return fmt.Errorf("mail: rcpt: %w", err)
110 }
111 w, err := c.Data()
112 if err != nil {
113 return fmt.Errorf("mail: data: %w", err)
114 }
115 if _, err := w.Write([]byte(msg)); err != nil {
116 return fmt.Errorf("mail: write: %w", err)
117 }
118 if err := w.Close(); err != nil {
119 return fmt.Errorf("mail: close: %w", err)
120 }
121 return c.Quit()
122}
123