PrintToVeraeTimeStamp/internal/mailer/mailer.go

266 lines
7 KiB
Go

// Package mailer sends Verae verification-code emails over SMTP (STARTTLS).
//
// Configuration comes only from the environment (never from files in the repo):
//
// SMTP_HOST default smtp.gmail.com
// SMTP_PORT default 587 (STARTTLS is required; 465 implicit TLS also works)
// SMTP_USER e.g. veraetimestamp@gmail.com
// SMTP_PASS Gmail app password (never logged; scrubbed from errors)
// SMTP_FROM default "Verae TimeStamp <SMTP_USER>"
//
// The password is held only in memory and is scrubbed from every error string.
package mailer
import (
"bytes"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"math/big"
"mime/quotedprintable"
"net"
"net/mail"
"net/smtp"
"os"
"strconv"
"strings"
"time"
)
// Config holds SMTP settings. Pass is unexported-by-convention: never print a Config.
type Config struct {
Host string
Port int
User string
Pass string
From string
// AllowInsecureLocal permits plaintext SMTP to 127.0.0.1/localhost (tests only).
AllowInsecureLocal bool
Timeout time.Duration
}
// String never reveals the password.
func (c Config) String() string {
p := "unset"
if c.Pass != "" {
p = "set"
}
return fmt.Sprintf("smtp://%s@%s:%d from=%q pass=%s", c.User, c.Host, c.Port, c.From, p)
}
// FromEnv reads SMTP_* variables.
func FromEnv() (Config, error) {
c := Config{
Host: envOr("SMTP_HOST", "smtp.gmail.com"),
User: strings.TrimSpace(os.Getenv("SMTP_USER")),
Pass: os.Getenv("SMTP_PASS"),
From: strings.TrimSpace(os.Getenv("SMTP_FROM")),
Timeout: 20 * time.Second,
}
port, err := strconv.Atoi(envOr("SMTP_PORT", "587"))
if err != nil || port <= 0 || port > 65535 {
return c, errors.New("SMTP_PORT is not a valid port")
}
c.Port = port
if c.User == "" {
return c, errors.New("SMTP_USER is not set")
}
if c.Pass == "" {
return c, errors.New("SMTP_PASS is not set")
}
if c.From == "" {
c.From = "Verae TimeStamp <" + c.User + ">"
}
if _, err := mail.ParseAddress(c.From); err != nil {
return c, fmt.Errorf("SMTP_FROM is not a valid address: %v", err)
}
return c, nil
}
func envOr(k, d string) string {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
return v
}
return d
}
// Message is a rendered email.
type Message struct {
To string
Subject string
Text string
HTML string
}
// Sender is what the install API needs (mockable).
type Sender interface {
Send(m Message) error
}
// SMTPSender implements Sender.
type SMTPSender struct{ Cfg Config }
func (s *SMTPSender) scrub(err error) error {
if err == nil {
return nil
}
msg := err.Error()
if s.Cfg.Pass != "" {
msg = strings.ReplaceAll(msg, s.Cfg.Pass, "[redacted]")
msg = strings.ReplaceAll(msg, base64.StdEncoding.EncodeToString([]byte("\x00"+s.Cfg.User+"\x00"+s.Cfg.Pass)), "[redacted]")
}
return errors.New(msg)
}
func isLocal(h string) bool { return h == "localhost" || h == "127.0.0.1" || h == "::1" }
// Send delivers m. STARTTLS (or implicit TLS on 465) is mandatory except for
// AllowInsecureLocal test servers.
func (s *SMTPSender) Send(m Message) error { return s.scrub(s.send(m)) }
func (s *SMTPSender) send(m Message) error {
c := s.Cfg
to, err := mail.ParseAddress(m.To)
if err != nil {
return fmt.Errorf("bad recipient: %v", err)
}
from, err := mail.ParseAddress(c.From)
if err != nil {
return fmt.Errorf("bad sender: %v", err)
}
if strings.ContainsAny(m.Subject, "\r\n") {
return errors.New("bad subject")
}
raw, err := Build(from, to, m, time.Now())
if err != nil {
return err
}
addr := net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
tlsCfg := &tls.Config{ServerName: c.Host, MinVersion: tls.VersionTLS12}
timeout := c.Timeout
if timeout == 0 {
timeout = 20 * time.Second
}
var conn net.Conn
if c.Port == 465 {
conn, err = tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", addr, tlsCfg)
} else {
conn, err = net.DialTimeout("tcp", addr, timeout)
}
if err != nil {
return fmt.Errorf("connect %s: %v", addr, err)
}
_ = conn.SetDeadline(time.Now().Add(2 * timeout))
cl, err := smtp.NewClient(conn, c.Host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp hello: %v", err)
}
defer cl.Close()
if err := cl.Hello("verae-mailer"); err != nil {
return fmt.Errorf("ehlo: %v", err)
}
if c.Port != 465 {
if ok, _ := cl.Extension("STARTTLS"); ok {
if err := cl.StartTLS(tlsCfg); err != nil {
return fmt.Errorf("starttls: %v", err)
}
} else if !(c.AllowInsecureLocal && isLocal(c.Host)) {
return errors.New("server does not offer STARTTLS; refusing to send credentials in clear text")
}
}
if ok, _ := cl.Extension("AUTH"); ok {
if err := cl.Auth(smtp.PlainAuth("", c.User, c.Pass, c.Host)); err != nil {
return fmt.Errorf("auth as %s failed: %v", c.User, err)
}
}
if err := cl.Mail(from.Address); err != nil {
return fmt.Errorf("mail from: %v", err)
}
if err := cl.Rcpt(to.Address); err != nil {
return fmt.Errorf("rcpt: %v", err)
}
w, err := cl.Data()
if err != nil {
return fmt.Errorf("data: %v", err)
}
if _, err := w.Write(raw); err != nil {
return fmt.Errorf("write: %v", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("data end: %v", err)
}
return cl.Quit()
}
// Build renders a multipart/alternative RFC 5322 message.
func Build(from, to *mail.Address, m Message, now time.Time) ([]byte, error) {
bnd, err := randHex(16)
if err != nil {
return nil, err
}
id, _ := randHex(12)
dom := "verae.local"
if i := strings.LastIndex(from.Address, "@"); i >= 0 {
dom = from.Address[i+1:]
}
var b bytes.Buffer
h := func(k, v string) { fmt.Fprintf(&b, "%s: %s\r\n", k, v) }
h("From", from.String())
h("To", to.String())
h("Subject", mimeHeader(m.Subject))
h("Date", now.Format(time.RFC1123Z))
h("Message-ID", "<"+id+"@"+dom+">")
h("MIME-Version", "1.0")
h("Auto-Submitted", "auto-generated")
h("Content-Type", `multipart/alternative; boundary="`+bnd+`"`)
b.WriteString("\r\n")
part := func(ct, body string) {
fmt.Fprintf(&b, "--%s\r\nContent-Type: %s; charset=UTF-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n", bnd, ct)
qp := quotedprintable.NewWriter(&b)
_, _ = qp.Write([]byte(strings.ReplaceAll(body, "\n", "\r\n")))
_ = qp.Close()
b.WriteString("\r\n")
}
part("text/plain", m.Text)
part("text/html", m.HTML)
fmt.Fprintf(&b, "--%s--\r\n", bnd)
return b.Bytes(), nil
}
func mimeHeader(s string) string {
for _, r := range s {
if r > 127 {
return "=?UTF-8?B?" + base64.StdEncoding.EncodeToString([]byte(s)) + "?="
}
}
return s
}
func randHex(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return fmt.Sprintf("%x", buf), nil
}
// NewCode returns a uniformly random 6-digit code (crypto/rand).
func NewCode() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}
// MaskEmail: george@example.com → g***@example.com (for logs).
func MaskEmail(e string) string {
i := strings.LastIndex(e, "@")
if i <= 0 {
return "***"
}
return e[:1] + "***" + e[i:]
}