266 lines
7 KiB
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:]
|
|
}
|