installer API: reference handler (internal/installapi), SMTP mailer with STARTTLS, plain+HTML code template, rate limits; verae-mailtest; user-token.ps1 validates via Go; stricter key masking

This commit is contained in:
Grok Bot (for George) 2026-10-03 19:20:28 -04:00
commit 1317bea604
4 changed files with 1249 additions and 5 deletions

266
internal/mailer/mailer.go Normal file
View file

@ -0,0 +1,266 @@
// 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:]
}

View file

@ -0,0 +1,196 @@
package mailer
import (
"bufio"
"encoding/base64"
"net"
"strings"
"sync"
"testing"
"time"
)
// fakeSMTP: minimal server without STARTTLS, records AUTH and DATA.
type fakeSMTP struct {
ln net.Listener
mu sync.Mutex
auth string
data string
rcpt string
}
func startFake(t *testing.T) *fakeSMTP {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
f := &fakeSMTP{ln: ln}
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go f.serve(c)
}
}()
t.Cleanup(func() { ln.Close() })
return f
}
func (f *fakeSMTP) serve(c net.Conn) {
defer c.Close()
r := bufio.NewReader(c)
w := func(s string) { c.Write([]byte(s + "\r\n")) }
w("220 fake")
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
up := strings.ToUpper(line)
switch {
case strings.HasPrefix(up, "EHLO"):
w("250-fake")
w("250 AUTH PLAIN")
case strings.HasPrefix(up, "AUTH PLAIN"):
f.mu.Lock()
f.auth = strings.TrimSpace(line[len("AUTH PLAIN"):])
f.mu.Unlock()
w("235 ok")
case strings.HasPrefix(up, "MAIL FROM"):
w("250 ok")
case strings.HasPrefix(up, "RCPT TO"):
f.mu.Lock()
f.rcpt = line
f.mu.Unlock()
w("250 ok")
case up == "DATA":
w("354 go")
var b strings.Builder
for {
l, err := r.ReadString('\n')
if err != nil {
return
}
if l == ".\r\n" {
break
}
b.WriteString(l)
}
f.mu.Lock()
f.data = b.String()
f.mu.Unlock()
w("250 queued")
case up == "QUIT":
w("221 bye")
return
default:
w("250 ok")
}
}
}
func (f *fakeSMTP) port() int { return f.ln.Addr().(*net.TCPAddr).Port }
func TestSendViaFakeSMTP(t *testing.T) {
f := startFake(t)
s := &SMTPSender{Cfg: Config{Host: "127.0.0.1", Port: f.port(), User: "veraetimestamp@gmail.com", Pass: "s3cret-app-pw", From: "Verae TimeStamp <veraetimestamp@gmail.com>", AllowInsecureLocal: true, Timeout: 5 * time.Second}}
m := CodeEmail("george@example.com", "George", "123456", 15, "")
if err := s.Send(m); err != nil {
t.Fatal(err)
}
f.mu.Lock()
defer f.mu.Unlock()
dec, _ := base64.StdEncoding.DecodeString(f.auth)
if string(dec) != "\x00veraetimestamp@gmail.com\x00s3cret-app-pw" {
t.Fatalf("auth %q", dec)
}
for _, want := range []string{"multipart/alternative", "text/plain", "text/html", "Subject: Your Verae verification code: 123 456", "To: <george@example.com>"} {
if !strings.Contains(f.data, want) {
t.Errorf("message missing %q", want)
}
}
if !strings.Contains(f.rcpt, "george@example.com") {
t.Errorf("rcpt %q", f.rcpt)
}
}
func TestRefusesPlaintextWithoutOptIn(t *testing.T) {
f := startFake(t)
s := &SMTPSender{Cfg: Config{Host: "127.0.0.1", Port: f.port(), User: "u@x.com", Pass: "pw-should-not-leak", From: "u@x.com", Timeout: 5 * time.Second}}
err := s.Send(CodeEmail("a@b.com", "", "000111", 15, ""))
if err == nil || !strings.Contains(err.Error(), "STARTTLS") {
t.Fatalf("want STARTTLS refusal, got %v", err)
}
if strings.Contains(err.Error(), "pw-should-not-leak") {
t.Fatal("password leaked into error")
}
}
func TestConfigNeverPrintsPassword(t *testing.T) {
t.Setenv("SMTP_USER", "veraetimestamp@gmail.com")
t.Setenv("SMTP_PASS", "abcd efgh ijkl mnop")
t.Setenv("SMTP_HOST", "")
t.Setenv("SMTP_PORT", "")
t.Setenv("SMTP_FROM", "")
c, err := FromEnv()
if err != nil {
t.Fatal(err)
}
if c.Host != "smtp.gmail.com" || c.Port != 587 || c.From != "Verae TimeStamp <veraetimestamp@gmail.com>" {
t.Fatalf("defaults: %v", c)
}
if strings.Contains(c.String(), "abcd") {
t.Fatal("String() leaked password")
}
}
func TestRejectsHeaderInjection(t *testing.T) {
s := &SMTPSender{Cfg: Config{Host: "127.0.0.1", Port: 1, User: "u@x.com", Pass: "p", From: "u@x.com"}}
if err := s.Send(Message{To: "a@b.com\r\nBcc: evil@x.com", Subject: "x"}); err == nil {
t.Fatal("expected bad recipient")
}
}
func TestTemplateEscapesAndCode(t *testing.T) {
m := CodeEmail("a@b.com", "<script>", "987654", 15, "")
if strings.Contains(m.HTML, "<script>") || !strings.Contains(m.HTML, "987654") || !strings.Contains(m.Text, "987 654") {
t.Fatal("template")
}
c, err := NewCode()
if err != nil || len(c) != 6 {
t.Fatal(c, err)
}
}
func TestLimiter(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
l := NewLimiter(Limits{Cooldown: time.Minute, PerEmailHour: 3, PerIPHour: 4, GlobalMinute: 100, GlobalDay: 1000})
l.Now = func() time.Time { return now }
if err := l.Allow("a@x", "1.1.1.1"); err != nil {
t.Fatal(err)
}
if err := l.Allow("a@x", "1.1.1.1"); err == nil || !strings.Contains(err.Error(), "cooldown") {
t.Fatalf("want cooldown, got %v", err)
}
now = now.Add(61 * time.Second)
_ = l.Allow("a@x", "1.1.1.1")
now = now.Add(61 * time.Second)
_ = l.Allow("a@x", "1.1.1.1")
now = now.Add(61 * time.Second)
if err := l.Allow("a@x", "1.1.1.1"); err == nil || !strings.Contains(err.Error(), "too many codes") {
t.Fatalf("want per-email cap, got %v", err)
}
if err := l.Allow("b@x", "1.1.1.1"); err != nil {
t.Fatal(err)
}
if err := l.Allow("c@x", "1.1.1.1"); err == nil || !strings.Contains(err.Error(), "network") {
t.Fatalf("want per-IP cap, got %v", err)
}
now = now.Add(time.Hour)
if err := l.Allow("a@x", "1.1.1.1"); err != nil {
t.Fatalf("window should reset: %v", err)
}
}

View file

@ -0,0 +1,96 @@
package mailer
import (
"fmt"
"sync"
"time"
)
// Limits for verification emails (defaults match docs/installer-registration-api.md).
type Limits struct {
Cooldown time.Duration // min gap between emails to one address (resend)
PerEmailHour int // emails per address per hour
PerIPHour int // emails triggered per client IP per hour
GlobalMinute int // all emails per minute (protects the Gmail account: ~500/day cap)
GlobalDay int
}
// DefaultLimits keeps well under Gmail's sending limits.
var DefaultLimits = Limits{Cooldown: 60 * time.Second, PerEmailHour: 5, PerIPHour: 10, GlobalMinute: 20, GlobalDay: 400}
// Limiter is an in-memory sliding-window limiter (single instance; use a shared
// store such as Redis when running several API replicas).
type Limiter struct {
L Limits
Now func() time.Time
mu sync.Mutex
ev map[string][]time.Time
}
func NewLimiter(l Limits) *Limiter {
return &Limiter{L: l, Now: time.Now, ev: map[string][]time.Time{}}
}
// LimitError carries a retry hint.
type LimitError struct {
Reason string
RetryAfter time.Duration
}
func (e *LimitError) Error() string {
return fmt.Sprintf("rate limited (%s); retry in %ds", e.Reason, int(e.RetryAfter.Seconds()+0.999))
}
func (l *Limiter) prune(k string, win time.Duration, now time.Time) []time.Time {
v := l.ev[k]
i := 0
for i < len(v) && now.Sub(v[i]) >= win {
i++
}
v = v[i:]
l.ev[k] = v
return v
}
// Allow checks every limit for (email, ip) and records the event only if all pass.
func (l *Limiter) Allow(email, ip string) error {
l.mu.Lock()
defer l.mu.Unlock()
now := l.Now()
type rule struct {
key string
win time.Duration
max int
reason string
}
rules := []rule{
{"e:" + email, l.L.Cooldown, 1, "resend cooldown"},
{"e:" + email, time.Hour, l.L.PerEmailHour, "too many codes for this email"},
{"i:" + ip, time.Hour, l.L.PerIPHour, "too many requests from this network"},
{"g", time.Minute, l.L.GlobalMinute, "service busy"},
{"g", 24 * time.Hour, l.L.GlobalDay, "daily email limit reached"},
}
for _, r := range rules {
if r.max <= 0 || r.win <= 0 {
continue
}
v := l.prune(r.key, 24*time.Hour, now)
n := 0
var oldest time.Time
for _, t := range v {
if now.Sub(t) < r.win {
if n == 0 {
oldest = t
}
n++
}
}
if n >= r.max {
return &LimitError{Reason: r.reason, RetryAfter: r.win - now.Sub(oldest)}
}
}
for _, k := range []string{"e:" + email, "i:" + ip, "g"} {
l.ev[k] = append(l.ev[k], now)
}
return nil
}

View file

@ -0,0 +1,92 @@
package mailer
import (
"fmt"
"html"
"strings"
)
// CodeEmail renders the verification-code email (plain text + HTML).
func CodeEmail(to, name, code string, ttlMinutes int, client string) Message {
greet := "Hello"
if n := strings.TrimSpace(name); n != "" {
greet = "Hello " + n
}
if client == "" {
client = "the Verae installer"
}
spaced := code
if len(code) == 6 {
spaced = code[:3] + " " + code[3:]
}
text := fmt.Sprintf(`%s,
Your Verae verification code is:
%s
Enter it in %s to finish creating your Verae account.
The code expires in %d minutes and can be used once.
If you did not try to create a Verae account, you can ignore this email;
no account is created without this code.
Verae TimeStamp - https://customer.verae.ai/portal/
This is an automated message; replies are not monitored.
`, greet, spaced, client, ttlMinutes)
e := html.EscapeString
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Your Verae verification code</title></head>
<body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1f2933;">
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background:#f4f5f7;padding:32px 0;">
<tr><td align="center">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="max-width:480px;width:100%%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08);">
<tr><td style="background:#0b3d91;padding:20px 28px;color:#ffffff;font-size:20px;font-weight:600;letter-spacing:.3px;">Verae TimeStamp</td></tr>
<tr><td style="padding:28px;">
<p style="margin:0 0 16px;font-size:16px;">%s,</p>
<p style="margin:0 0 20px;font-size:15px;line-height:1.5;">Enter this code in %s to finish creating your Verae account:</p>
<div style="margin:0 0 20px;padding:18px 0;text-align:center;background:#eef3fb;border-radius:8px;font-family:Consolas,Menlo,monospace;font-size:34px;font-weight:700;letter-spacing:8px;color:#0b3d91;">%s</div>
<p style="margin:0 0 8px;font-size:14px;color:#52606d;">The code expires in <b>%d minutes</b> and can be used once.</p>
<p style="margin:0;font-size:14px;color:#52606d;">If you did not try to create a Verae account, ignore this email &mdash; no account is created without this code.</p>
</td></tr>
<tr><td style="padding:16px 28px;border-top:1px solid #e4e7eb;font-size:12px;color:#7b8794;">
<a href="https://customer.verae.ai/portal/" style="color:#0b3d91;text-decoration:none;">customer.verae.ai</a> &middot; Automated message, replies are not monitored.
</td></tr></table>
</td></tr></table>
</body></html>
`, e(greet), e(client), e(code), ttlMinutes)
return Message{
To: to,
Subject: "Your Verae verification code: " + spaced,
Text: text,
HTML: htmlBody,
}
}
// AccountExistsEmail is sent instead of a code when someone registers an
// address that already has an account (so /install/register reveals nothing).
func AccountExistsEmail(to string) Message {
text := `Hello,
Someone (hopefully you) tried to create a new Verae account with this email
address, but you already have one.
To use the Verae printer, choose "Sign in to my Verae account" in the installer.
Forgot your password? Reset it at https://customer.verae.ai/portal/
If this wasn't you, you can ignore this email.
Verae TimeStamp - automated message; replies are not monitored.
`
htmlBody := `<!DOCTYPE html><html><body style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1f2933;background:#f4f5f7;padding:32px;">
<div style="max-width:480px;margin:auto;background:#fff;border-radius:12px;padding:28px;">
<h2 style="margin-top:0;color:#0b3d91;">You already have a Verae account</h2>
<p>Someone (hopefully you) tried to create a new Verae account with this email address.</p>
<p>In the installer choose <b>Sign in to my Verae account</b>. Forgot your password? Reset it at
<a href="https://customer.verae.ai/portal/" style="color:#0b3d91;">customer.verae.ai</a>.</p>
<p style="color:#52606d;font-size:14px;">If this wasn't you, ignore this email.</p></div></body></html>`
return Message{To: to, Subject: "You already have a Verae account", Text: text, HTML: htmlBody}
}