// Package mail sends the server's few transactional messages, today the // password-reset link. A Sender is an SMTP relay (production), a directory // of .eml files (development and tests), or nothing, in which case the // features that need mail are off. package mail import ( "bytes" "context" "crypto/rand" "crypto/tls" "encoding/hex" "errors" "fmt" "net" "net/mail" "net/smtp" "os" "path/filepath" "strings" "time" ) // Message is one plain-text email. type Message struct { To string Subject string Body string } // Sender delivers messages. type Sender interface { Send(ctx context.Context, m Message) error // Describe names the transport for logs, without secrets. Describe() string } // SMTP delivers through a relay. Username and Password are optional: a relay // inside the same network usually accepts mail from this host without them. // STARTTLS is used when the relay offers it; credentials are only ever sent // over TLS (or to localhost), which net/smtp enforces. type SMTP struct { Host string Port int Username string Password string From string // "Chess " or a bare address // Timeout bounds the whole exchange; 30 seconds when zero. Timeout time.Duration } // Describe names the relay. func (s *SMTP) Describe() string { auth := "no authentication" if s.Username != "" { auth = "as " + s.Username } return fmt.Sprintf("smtp %s:%d (%s), from %s", s.Host, s.Port, auth, s.From) } // Send delivers one message through the relay. func (s *SMTP) Send(ctx context.Context, m Message) error { from, err := mail.ParseAddress(s.From) if err != nil { return fmt.Errorf("mail from %q: %w", s.From, err) } to, err := mail.ParseAddress(m.To) if err != nil { return fmt.Errorf("mail to %q: %w", m.To, err) } timeout := s.Timeout if timeout <= 0 { timeout = 30 * time.Second } if deadline, ok := ctx.Deadline(); ok { if until := time.Until(deadline); until < timeout { timeout = until } } addr := net.JoinHostPort(s.Host, fmt.Sprint(s.Port)) conn, err := (&net.Dialer{Timeout: timeout}).DialContext(ctx, "tcp", addr) if err != nil { return fmt.Errorf("connect to %s: %w", addr, err) } defer conn.Close() if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { return err } client, err := smtp.NewClient(conn, s.Host) if err != nil { return fmt.Errorf("smtp handshake with %s: %w", addr, err) } defer client.Close() if ok, _ := client.Extension("STARTTLS"); ok { if err := client.StartTLS(&tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12}); err != nil { return fmt.Errorf("starttls with %s: %w", addr, err) } } if s.Username != "" { if err := client.Auth(smtp.PlainAuth("", s.Username, s.Password, s.Host)); err != nil { return fmt.Errorf("smtp authentication with %s: %w", addr, err) } } if err := client.Mail(from.Address); err != nil { return fmt.Errorf("smtp from: %w", err) } if err := client.Rcpt(to.Address); err != nil { return fmt.Errorf("smtp to: %w", err) } w, err := client.Data() if err != nil { return fmt.Errorf("smtp data: %w", err) } if _, err := w.Write(Format(s.From, m)); err != nil { return fmt.Errorf("smtp write: %w", err) } if err := w.Close(); err != nil { return fmt.Errorf("smtp send: %w", err) } return client.Quit() } // Dir writes each message to a file in a directory instead of sending it, // for development and tests. The file holds exactly what SMTP would carry. type Dir struct { Path string From string } // Describe names the directory. func (d *Dir) Describe() string { return fmt.Sprintf("directory %s, from %s", d.Path, d.From) } // Send writes the message as -.eml. func (d *Dir) Send(_ context.Context, m Message) error { if _, err := mail.ParseAddress(m.To); err != nil { return fmt.Errorf("mail to %q: %w", m.To, err) } if err := os.MkdirAll(d.Path, 0o700); err != nil { return err } var nonce [4]byte rand.Read(nonce[:]) name := fmt.Sprintf("%d-%s.eml", time.Now().UnixNano(), hex.EncodeToString(nonce[:])) return os.WriteFile(filepath.Join(d.Path, name), Format(d.From, m), 0o600) } // Format renders the message as RFC 5322 text with CRLF line ends: headers, // a blank line, the UTF-8 body. Header values are kept to one line. func Format(from string, m Message) []byte { var nonce [8]byte rand.Read(nonce[:]) domain := "localhost" if addr, err := mail.ParseAddress(from); err == nil { if at := strings.LastIndex(addr.Address, "@"); at >= 0 { domain = addr.Address[at+1:] } } var b bytes.Buffer header := func(name, value string) { value = strings.NewReplacer("\r", " ", "\n", " ").Replace(value) fmt.Fprintf(&b, "%s: %s\r\n", name, value) } header("From", from) header("To", m.To) header("Subject", m.Subject) header("Date", time.Now().UTC().Format(time.RFC1123Z)) header("Message-ID", fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), hex.EncodeToString(nonce[:]), domain)) header("MIME-Version", "1.0") header("Content-Type", "text/plain; charset=utf-8") header("Content-Transfer-Encoding", "8bit") header("Auto-Submitted", "auto-generated") b.WriteString("\r\n") body := strings.ReplaceAll(strings.ReplaceAll(m.Body, "\r\n", "\n"), "\n", "\r\n") b.WriteString(body) if !strings.HasSuffix(body, "\r\n") { b.WriteString("\r\n") } return b.Bytes() } // ErrNoSender is returned by Validate for an empty configuration. var ErrNoSender = errors.New("no mail transport configured") // Config is the server's mail configuration, from its flags. SMTPHost or Dir // selects the transport; both empty means no mail. type Config struct { SMTPHost string SMTPPort int SMTPUser string SMTPPasswordFile string Dir string From string } // Build returns the configured Sender, nil (with ErrNoSender) when nothing is // configured, or an error for an inconsistent configuration. func (c Config) Build() (Sender, error) { switch { case c.SMTPHost != "" && c.Dir != "": return nil, errors.New("-smtp-host and -mail-dir are exclusive") case c.SMTPHost == "" && c.Dir == "": return nil, ErrNoSender } if c.From == "" { return nil, errors.New("-mail-from is required with a mail transport") } if _, err := mail.ParseAddress(c.From); err != nil { return nil, fmt.Errorf("-mail-from %q: %w", c.From, err) } if c.Dir != "" { if err := os.MkdirAll(c.Dir, 0o700); err != nil { return nil, fmt.Errorf("-mail-dir: %w", err) } return &Dir{Path: c.Dir, From: c.From}, nil } port := c.SMTPPort if port <= 0 { port = 25 } s := &SMTP{Host: c.SMTPHost, Port: port, Username: c.SMTPUser, From: c.From} if c.SMTPPasswordFile != "" { secret, err := readSecretFile(c.SMTPPasswordFile) if err != nil { return nil, fmt.Errorf("-smtp-password-file: %w", err) } s.Password = secret } if s.Password != "" && s.Username == "" { return nil, errors.New("-smtp-password-file needs -smtp-user") } return s, nil } // readSecretFile reads a one-line secret from a file that only its owner may // read, as the JWT key file is. func readSecretFile(path string) (string, error) { info, err := os.Stat(path) if err != nil { return "", err } if !info.Mode().IsRegular() { return "", fmt.Errorf("%s is not a regular file", path) } if info.Mode().Perm()&0o077 != 0 { return "", fmt.Errorf("%s is readable by group or others (mode %04o); use mode 0600", path, info.Mode().Perm()) } data, err := os.ReadFile(path) if err != nil { return "", err } secret := strings.TrimRight(string(data), "\r\n") if secret == "" { return "", fmt.Errorf("%s is empty", path) } return secret, nil }