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
9 changed files with 1249 additions and 5 deletions

View file

@ -0,0 +1,59 @@
// verae-mailtest sends ONE verification-code email using SMTP_* env settings,
// to check delivery of the installer registration mailer. Server-side tool;
// not shipped in the Windows/macOS client.
//
// SMTP_USER=veraetimestamp@gmail.com SMTP_PASS=... verae-mailtest --to veraetimestamp@gmail.com --send
//
// Without --send it only renders the message (--preview DIR writes .txt/.html).
// The password and the generated code are never printed.
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"time"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer"
)
func main() {
to := flag.String("to", "", "recipient (default: SMTP_USER, i.e. a self-send)")
name := flag.String("name", "George", "name used in the greeting")
send := flag.Bool("send", false, "actually send (otherwise preview only)")
preview := flag.String("preview", "", "write preview .txt/.html into this directory")
flag.Parse()
cfg, err := mailer.FromEnv()
if err != nil && *send {
fmt.Fprintln(os.Stderr, "config:", err)
os.Exit(2)
}
if *to == "" {
*to = cfg.User
}
code, err := mailer.NewCode()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
m := mailer.CodeEmail(*to, *name, code, 15, "the Verae installer (mailer test)")
if *preview != "" {
_ = os.MkdirAll(*preview, 0o755)
_ = os.WriteFile(filepath.Join(*preview, "code-email.txt"), []byte(m.Text), 0o644)
_ = os.WriteFile(filepath.Join(*preview, "code-email.html"), []byte(m.HTML), 0o644)
fmt.Println("preview written to", *preview, "(contains a random, unused test code)")
}
if !*send {
fmt.Println("not sending (pass --send).", cfg.String())
return
}
fmt.Printf("sending one test code email to %s via %s\n", mailer.MaskEmail(*to), cfg.String())
t0 := time.Now()
if err := (&mailer.SMTPSender{Cfg: cfg}).Send(m); err != nil {
fmt.Fprintln(os.Stderr, "send failed:", err)
os.Exit(1)
}
fmt.Printf("sent OK in %.1fs (subject \"Your Verae verification code: *** ***\"; code not printed)\n", time.Since(t0).Seconds())
}

View file

@ -1,6 +1,7 @@
# Verae installer registration API (proposed)
Status: **proposal + client implementation**. The client side is implemented in
Status: **proposal + client implementation + reference server pieces**
(`internal/installapi` handler, `internal/mailer` SMTP mailer; §7). The client side is implemented in
`internal/auth/portal_install.go` and driven by the Windows installer
(`platform/windows/installer-debug.nsi` → `verae-print installer-account`).
The server side does **not exist yet**. The portal backend source was not
@ -202,3 +203,33 @@ components:
| Verify email | `--op verify`; "Send a new code" → `--op resend` |
| Paste key | button opens customer.verae.ai; `validate-key` before Next |
| Skip | warning; the helper shows a key dialog on first print and holds the job until a key is saved |
## 7. Reference server implementation and email delivery
* `internal/installapi`: `Server.Handler()` serves `/install/capabilities|register|verify|resend|login`. Mount it under `/portal/api` in the portal backend. Account storage goes through the `Accounts` interface (`EmailExists`, `HashPassword`, `CreateAccount`, `Authenticate` → `ErrTOTPRequired`/`ErrTOTPInvalid`/`ErrInvalidCredentials`, `MintToken`), so it plugs into the existing portal DB and token code.
* At `/register` the password is hashed immediately. The plaintext is never kept in the pending record and is never logged.
* Codes are stored as salted SHA-256 and compared in constant time. They are single use, with a 15 min TTL and 5 attempts.
* An existing email gets a 202 plus an "you already have an account" email, so the endpoint reveals nothing.
* Logs contain masked emails only. Responses use `Cache-Control: no-store`.
* Pending sign-ups and rate-limit counters live in memory. Use a shared store (Redis/DB) when running more than one replica.
* `internal/mailer`: SMTP sender with mandatory STARTTLS (port 587) or implicit TLS (465), TLS ≥ 1.2, and AUTH PLAIN only after TLS. The SMTP password is scrubbed from every error and is never shown by `Config.String()`. Recipients and subjects are checked against header injection.
* Template: `CodeEmail` (multipart/alternative: plain text + responsive HTML, code shown as `123 456`, expiry, "ignore if this wasn't you") and `AccountExistsEmail`.
* Rate limits (`DefaultLimits`, sliding window): 60 s resend cooldown per address, 5 codes/h per address, 10/h per client IP, 20/min and 400/day globally. Gmail caps a normal account at about 500 recipients/day, so move to a transactional provider before real volume.
### Mailer configuration (environment only; never commit credentials)
| Variable | Value |
|---|---|
| `SMTP_HOST` | `smtp.gmail.com` (default) |
| `SMTP_PORT` | `587` (default; STARTTLS) |
| `SMTP_USER` | `veraetimestamp@gmail.com` |
| `SMTP_PASS` | Gmail **app password** for that account, from your secret store. Never put it in files, logs or the repo. |
| `SMTP_FROM` | default `Verae TimeStamp <veraetimestamp@gmail.com>` (Gmail rewrites a From that differs from the authenticated account unless it is a verified alias) |
Delivery check (sends exactly one email; the code is not printed):
```sh
SMTP_USER=veraetimestamp@gmail.com SMTP_PASS="$VERAE_GMAIL_APP_PASSWORD" \
go run ./cmd/verae-mailtest --to veraetimestamp@gmail.com --send
go run ./cmd/verae-mailtest --preview /tmp/mailprev # render only, no send
```

View file

@ -134,10 +134,7 @@ func ValidateXVeraeKey(key string) (string, string) {
func MaskKey(k string) string {
k = strings.TrimSpace(k)
if len(k) <= 12 {
if len(k) > 4 {
return k[:4] + "..."
}
return "..."
return "***" // too short to reveal any part safely
}
return k[:7] + "..." + k[len(k)-4:]
}

View file

@ -0,0 +1,382 @@
// Package installapi is a reference implementation of the installer
// registration API (docs/installer-registration-api.md), meant to be mounted
// by the portal backend under /portal/api. Account storage is behind the
// Accounts interface so the real portal database can be plugged in.
//
// Secrets: passwords are hashed by Accounts.HashPassword during /register and
// the plaintext is never retained or logged; codes are stored only as salted
// SHA-256 and compared in constant time; logs carry masked emails only.
package installapi
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"net"
"net/http"
"net/mail"
"strconv"
"strings"
"sync"
"time"
"unicode"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer"
)
var (
ErrInvalidCredentials = errors.New("invalid_credentials")
ErrTOTPRequired = errors.New("totp_required")
ErrTOTPInvalid = errors.New("totp_invalid")
)
// Accounts is implemented by the portal backend.
type Accounts interface {
EmailExists(email string) (bool, error)
HashPassword(password string) (string, error)
CreateAccount(name, email, passwordHash string) (userID string, err error)
Authenticate(email, password, totp string) (userID string, err error)
MintToken(userID, name string, client map[string]string) (token string, err error)
}
// Policy mirrors capabilities.passwordPolicy.
type Policy struct {
MinLength int `json:"minLength"`
RequireLetter bool `json:"requireLetter"`
RequireDigit bool `json:"requireDigit"`
RequireSymbol bool `json:"requireSymbol"`
DisallowEmail bool `json:"disallowEmail"`
}
var DefaultPolicy = Policy{MinLength: 10, RequireLetter: true, RequireDigit: true, DisallowEmail: true}
func (p Policy) check(pw, email string) string {
if len([]rune(pw)) < p.MinLength {
return "Password must be at least " + itoa(p.MinLength) + " characters."
}
var l, d, s bool
for _, r := range pw {
switch {
case unicode.IsLetter(r):
l = true
case unicode.IsDigit(r):
d = true
case !unicode.IsSpace(r):
s = true
}
}
switch {
case p.RequireLetter && !l:
return "Password must contain a letter."
case p.RequireDigit && !d:
return "Password must contain a number."
case p.RequireSymbol && !s:
return "Password must contain a symbol."
}
if p.DisallowEmail {
if i := strings.Index(email, "@"); i >= 3 && strings.Contains(strings.ToLower(pw), strings.ToLower(email[:i])) {
return "Password must not contain your email address."
}
}
return ""
}
func itoa(n int) string { return strconv.Itoa(n) }
type pending struct {
name, email, hash string
exists bool
salt []byte
codeHash [32]byte
expires time.Time
attempts int
}
// Server holds the API state.
type Server struct {
Accounts Accounts
Mail mailer.Sender
Limiter *mailer.Limiter
Policy Policy
CodeTTL time.Duration
MaxAttempt int
TrustProxy bool // honour X-Forwarded-For (only behind your own proxy)
Log func(format string, args ...any)
Now func() time.Time
mu sync.Mutex
pending map[string]*pending
loginRL *mailer.Limiter
}
func New(a Accounts, m mailer.Sender) *Server {
return &Server{Accounts: a, Mail: m, Limiter: mailer.NewLimiter(mailer.DefaultLimits), Policy: DefaultPolicy,
CodeTTL: 15 * time.Minute, MaxAttempt: 5, Log: func(string, ...any) {}, Now: time.Now,
pending: map[string]*pending{},
loginRL: mailer.NewLimiter(mailer.Limits{PerEmailHour: 20, PerIPHour: 60})}
}
// Handler serves /install/* (mount under /portal/api).
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/install/capabilities", s.capabilities)
mux.HandleFunc("/install/register", s.post(s.register))
mux.HandleFunc("/install/verify", s.post(s.verify))
mux.HandleFunc("/install/resend", s.post(s.resend))
mux.HandleFunc("/install/login", s.post(s.login))
return mux
}
type apiErr struct {
status int
body map[string]any
}
func fail(status int, code, msg string) *apiErr {
return &apiErr{status, map[string]any{"error": code, "message": msg}}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func (s *Server) post(fn func(r *http.Request, body map[string]any) (int, any, *apiErr)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, 405, map[string]any{"error": "method_not_allowed"})
return
}
var body map[string]any
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&body); err != nil {
writeJSON(w, 400, map[string]any{"error": "bad_json", "message": "Invalid request."})
return
}
st, out, e := fn(r, body)
if e != nil {
writeJSON(w, e.status, e.body)
return
}
writeJSON(w, st, out)
}
}
func str(b map[string]any, k string) string { v, _ := b[k].(string); return v }
func (s *Server) ip(r *http.Request) string {
if s.TrustProxy {
if f := r.Header.Get("X-Forwarded-For"); f != "" {
return strings.TrimSpace(strings.Split(f, ",")[0])
}
}
h, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return h
}
func limited(err error) *apiErr {
var le *mailer.LimitError
if errors.As(err, &le) {
e := fail(429, "rate_limited", "Too many attempts. Please wait and try again.")
e.body["retryAfterSeconds"] = int(le.RetryAfter.Seconds() + 0.999)
return e
}
return nil
}
func (s *Server) capabilities(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"installApi": true, "emailVerification": true, "passwordPolicy": s.Policy})
}
func newID() string {
b := make([]byte, 24)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func hashCode(salt []byte, code string) [32]byte {
return sha256.Sum256(append(append([]byte{}, salt...), []byte(code)...))
}
func normEmail(e string) (string, bool) {
e = strings.TrimSpace(e)
a, err := mail.ParseAddress(e)
if err != nil || a.Address != e || len(e) > 254 || strings.ContainsAny(e, "\r\n") {
return "", false
}
return strings.ToLower(e), true
}
// issueCode creates/refreshes a code for p and emails it.
func (s *Server) issueCode(p *pending, ip string) *apiErr {
if err := s.Limiter.Allow(p.email, ip); err != nil {
s.Log("install: code email to %s rate-limited: %v", mailer.MaskEmail(p.email), err)
return limited(err)
}
code, err := mailer.NewCode()
if err != nil {
return fail(500, "internal", "Please try again.")
}
p.salt = []byte(newID())
p.codeHash = hashCode(p.salt, code)
p.expires = s.Now().Add(s.CodeTTL)
p.attempts = 0
var m mailer.Message
if p.exists {
m = mailer.AccountExistsEmail(p.email)
} else {
m = mailer.CodeEmail(p.email, p.name, code, int(s.CodeTTL.Minutes()), "the Verae installer")
}
if err := s.Mail.Send(m); err != nil {
s.Log("install: send to %s failed: %v", mailer.MaskEmail(p.email), err)
return fail(502, "email_failed", "We could not send the email. Please try again later.")
}
s.Log("install: code email sent to %s (exists=%v)", mailer.MaskEmail(p.email), p.exists)
return nil
}
func (s *Server) register(r *http.Request, b map[string]any) (int, any, *apiErr) {
name := strings.TrimSpace(str(b, "name"))
email, ok := normEmail(str(b, "email"))
pw := str(b, "password")
if name == "" || len(name) > 200 {
return 0, nil, fail(400, "name_required", "Please enter your name.")
}
if !ok {
return 0, nil, fail(400, "invalid_email", "Please enter a valid email address.")
}
if msg := s.Policy.check(pw, email); msg != "" {
return 0, nil, fail(400, "weak_password", msg)
}
exists, err := s.Accounts.EmailExists(email)
if err != nil {
return 0, nil, fail(500, "internal", "Please try again.")
}
p := &pending{name: name, email: email, exists: exists}
if !exists {
if p.hash, err = s.Accounts.HashPassword(pw); err != nil {
return 0, nil, fail(500, "internal", "Please try again.")
}
}
pw = ""
if e := s.issueCode(p, s.ip(r)); e != nil {
return 0, nil, e
}
id := newID()
s.mu.Lock()
s.gc()
s.pending[id] = p
s.mu.Unlock()
return 202, map[string]any{"pendingId": id, "expiresInSeconds": int(s.CodeTTL.Seconds()), "resendAfterSeconds": int(s.Limiter.L.Cooldown.Seconds())}, nil
}
func (s *Server) gc() {
now := s.Now()
for k, p := range s.pending {
if now.After(p.expires.Add(time.Hour)) {
delete(s.pending, k)
}
}
}
func (s *Server) resend(r *http.Request, b map[string]any) (int, any, *apiErr) {
s.mu.Lock()
p := s.pending[str(b, "pendingId")]
s.mu.Unlock()
if p == nil {
return 0, nil, fail(410, "expired", "This sign-up has expired. Please start again.")
}
s.mu.Lock()
defer s.mu.Unlock()
if e := s.issueCode(p, s.ip(r)); e != nil {
return 0, nil, e
}
return 202, map[string]any{"resendAfterSeconds": int(s.Limiter.L.Cooldown.Seconds())}, nil
}
func (s *Server) verify(r *http.Request, b map[string]any) (int, any, *apiErr) {
id := str(b, "pendingId")
code := strings.ReplaceAll(strings.TrimSpace(str(b, "code")), " ", "")
s.mu.Lock()
p := s.pending[id]
if p == nil || s.Now().After(p.expires) {
s.mu.Unlock()
return 0, nil, fail(410, "expired", "This code has expired. Click 'Send a new code'.")
}
h := hashCode(p.salt, code)
if p.exists || subtle.ConstantTimeCompare(h[:], p.codeHash[:]) != 1 {
p.attempts++
left := s.MaxAttempt - p.attempts
if left <= 0 {
delete(s.pending, id)
s.mu.Unlock()
return 0, nil, fail(410, "too_many_attempts", "Too many wrong codes. Please start again.")
}
s.mu.Unlock()
e := fail(400, "invalid_code", "That code is not correct.")
e.body["attemptsLeft"] = left
return 0, nil, e
}
delete(s.pending, id) // single use
s.mu.Unlock()
uid, err := s.Accounts.CreateAccount(p.name, p.email, p.hash)
if err != nil {
return 0, nil, fail(500, "internal", "Could not create the account.")
}
tn := str(b, "tokenName")
if tn == "" {
tn = "Verae installer"
}
tok, err := s.Accounts.MintToken(uid, tn, nil)
if err != nil {
return 0, nil, fail(500, "internal", "Account created; sign in to get a key.")
}
s.Log("install: account created for %s", mailer.MaskEmail(p.email))
return 201, map[string]any{"token": tok, "name": tn}, nil
}
func (s *Server) login(r *http.Request, b map[string]any) (int, any, *apiErr) {
email, ok := normEmail(str(b, "email"))
if !ok {
return 0, nil, fail(401, "invalid_credentials", "Email or password is incorrect.")
}
if err := s.loginRL.Allow(email, s.ip(r)); err != nil {
return 0, nil, limited(err)
}
uid, err := s.Accounts.Authenticate(email, str(b, "password"), strings.TrimSpace(str(b, "totpCode")))
switch {
case errors.Is(err, ErrTOTPRequired):
return 0, nil, fail(401, "totp_required", "Enter the code from your authenticator app.")
case errors.Is(err, ErrTOTPInvalid):
return 0, nil, fail(401, "totp_invalid", "The authenticator code is not correct.")
case err != nil:
s.Log("install: login failed for %s", mailer.MaskEmail(email))
return 0, nil, fail(401, "invalid_credentials", "Email or password is incorrect.")
}
tn := str(b, "tokenName")
if tn == "" {
tn = "Verae installer"
}
var client map[string]string
if c, ok := b["client"].(map[string]any); ok {
client = map[string]string{}
for k, v := range c {
if sv, ok := v.(string); ok && len(sv) < 200 {
client[k] = sv
}
}
}
tok, err := s.Accounts.MintToken(uid, tn, client)
if err != nil {
return 0, nil, fail(500, "internal", "Please try again.")
}
s.Log("install: key issued for %s", mailer.MaskEmail(email))
return 201, map[string]any{"token": tok, "name": tn}, nil
}

View file

@ -0,0 +1,125 @@
package installapi
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"sync"
"testing"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer"
)
type fakeAccounts struct {
mu sync.Mutex
users map[string]string // email -> hash
}
func (f *fakeAccounts) EmailExists(e string) (bool, error) { _, ok := f.users[e]; return ok, nil }
func (f *fakeAccounts) HashPassword(p string) (string, error) {
return "h:" + strings.Repeat("*", len(p)), nil
}
func (f *fakeAccounts) CreateAccount(n, e, h string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.users[e] = h
return "u-" + e, nil
}
func (f *fakeAccounts) Authenticate(e, p, totp string) (string, error) {
if e == "twofa@x.com" && totp == "" {
return "", ErrTOTPRequired
}
if _, ok := f.users[e]; ok && p == "CorrectHorse9" {
return "u-" + e, nil
}
return "", ErrInvalidCredentials
}
func (f *fakeAccounts) MintToken(uid, n string, c map[string]string) (string, error) {
if uid == "" {
return "", errors.New("x")
}
return "xVerae-TEST" + strings.Repeat("a", 20), nil
}
type capture struct {
mu sync.Mutex
msgs []mailer.Message
}
func (c *capture) Send(m mailer.Message) error {
c.mu.Lock()
c.msgs = append(c.msgs, m)
c.mu.Unlock()
return nil
}
func call(t *testing.T, h http.Handler, path string, body any) (int, map[string]any) {
b, _ := json.Marshal(body)
r := httptest.NewRequest("POST", path, bytes.NewReader(b))
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out
}
var codeRe = regexp.MustCompile(`(\d{3}) (\d{3})`)
func TestRegisterVerifyLogin(t *testing.T) {
acc := &fakeAccounts{users: map[string]string{"old@x.com": "h"}}
mc := &capture{}
var logs bytes.Buffer
s := New(acc, mc)
s.Log = func(f string, a ...any) { logs.WriteString(strings.TrimSpace(sprintf(f, a...)) + "\n") }
h := s.Handler()
if st, out := call(t, h, "/install/register", map[string]any{"name": "G", "email": "new@x.com", "password": "short"}); st != 400 || out["error"] != "weak_password" {
t.Fatalf("weak: %d %v", st, out)
}
st, out := call(t, h, "/install/register", map[string]any{"name": "George", "email": "new@x.com", "password": "CorrectHorse9"})
if st != 202 {
t.Fatalf("register %d %v", st, out)
}
pid := out["pendingId"].(string)
m := codeRe.FindStringSubmatch(mc.msgs[0].Text)
code := m[1] + m[2]
if st, out := call(t, h, "/install/verify", map[string]any{"pendingId": pid, "code": "000000"}); st != 400 || out["attemptsLeft"].(float64) != 4 {
if code != "000000" {
t.Fatalf("bad code: %d %v", st, out)
}
}
st, out = call(t, h, "/install/verify", map[string]any{"pendingId": pid, "code": code, "tokenName": "PC"})
if st != 201 || !strings.HasPrefix(out["token"].(string), "xVerae-") {
t.Fatalf("verify %d %v", st, out)
}
if st, _ := call(t, h, "/install/verify", map[string]any{"pendingId": pid, "code": code}); st != 410 {
t.Fatal("code must be single use")
}
// existing email: 202 + "already have an account" email, verify never succeeds
st, out = call(t, h, "/install/register", map[string]any{"name": "X", "email": "old@x.com", "password": "CorrectHorse9"})
if st != 202 || !strings.Contains(mc.msgs[1].Subject, "already have") {
t.Fatalf("exists: %d %v", st, out)
}
// login
if st, out := call(t, h, "/install/login", map[string]any{"email": "new@x.com", "password": "CorrectHorse9"}); st != 201 || out["token"] == nil {
t.Fatalf("login %d %v", st, out)
}
if st, out := call(t, h, "/install/login", map[string]any{"email": "twofa@x.com", "password": "x"}); st != 401 || out["error"] != "totp_required" {
t.Fatalf("totp %d %v", st, out)
}
// resend cooldown → 429
st, out = call(t, h, "/install/register", map[string]any{"name": "R", "email": "r@x.com", "password": "CorrectHorse9"})
if st, out := call(t, h, "/install/resend", map[string]any{"pendingId": out["pendingId"]}); st != 429 || out["retryAfterSeconds"] == nil {
t.Fatalf("resend %d %v", st, out)
}
if strings.Contains(logs.String(), "CorrectHorse9") || strings.Contains(logs.String(), "new@x.com") || strings.Contains(logs.String(), code) {
t.Fatalf("secret/PII in logs:\n%s", logs.String())
}
}
func sprintf(f string, a ...any) string { return fmt.Sprintf(f, a...) }

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}
}