Release v0.1.0-preview.28: transactional account email
verify / verify (push) Successful in 4m29s

Reviewed source export adds verified TLS mail, encrypted outbox, mailbox verification and password reset protocols. Preserve public ancestry; omit local development history and operational queue. Consumer deployment and inbox delivery proof remain separate.
This commit is contained in:
2026-09-11 04:12:17 -04:00
parent ebcbbf06f5
commit 494b72fa3b
18 changed files with 3175 additions and 47 deletions
+10 -1
View File
@@ -2,11 +2,20 @@
# Changelog # Changelog
## Unreleased ## v0.1.0-preview.28 — 2026-09-11
- Add bounded single-recipient SMTP with verified TLS, a fixed sender and
secret-free diagnostics, plus an explicit encrypted SQLite transactional outbox.
Cover cancellation, retry/lease recovery, idempotency and payload expiry locally.
- Add optional verified-mailbox, both-mailbox address-change and password-reset
protocols with a separate explicit SQLite migration, atomic audits/mail intent,
session/credential/replay checks and preserved passkeys, ownership and history.
Consumer HTTP/UI integration is locally verified; deployment and real delivery
proof remain separate consumer checkpoints.
- Document and test optional CMS classification and relationships, including - Document and test optional CMS classification and relationships, including
unclassified publication and clearing associations with preserved history. unclassified publication and clearing associations with preserved history.
No runtime behavior or schema change. No runtime behavior or schema change.
- Keep the existing passkey algorithm regression tests in the public export.
## v0.1.0-preview.27 — 2026-09-10 ## v0.1.0-preview.27 — 2026-09-10
+2
View File
@@ -42,6 +42,8 @@ packages you import.
| Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) | | Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) |
| Atomic first-owner and organization setup | [`bootstrap`](bootstrap) | | Atomic first-owner and organization setup | [`bootstrap`](bootstrap) |
| Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) | | Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) |
| Optional mailbox verification, address changes and password reset | [`authmail`](authmail) + [`authsqlite`](authsqlite); explicit mail migration |
| Bounded transactional SMTP and encrypted queued delivery | [`mail`](mail) + [`mailsqlite`](mailsqlite) |
| Private SQLite persistence | [`authsqlite`](authsqlite) | | Private SQLite persistence | [`authsqlite`](authsqlite) |
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) | | Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
| Typed editorial categories and related-content discovery | [`cms`](cms) + [`cmssqlite`](cmssqlite); [integration guide](docs/CMS.md) | | Typed editorial categories and related-content discovery | [`cms`](cms) + [`cmssqlite`](cmssqlite); [integration guide](docs/CMS.md) |
-46
View File
@@ -1,46 +0,0 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Current work
September 10 consumer dogfood: reinforce that classification and relationships
are optional. Zero/nil/empty associations already work in `cms`/`cmssqlite`; new
regressions explicitly cover independent optional fields, publishing unclassified
content, and clearing classification without rewriting history. The reproduced
policy GET-modify-POST defect belongs to Gamertan's HTTP adapter, not the shared
package. Its fix stays in that consumer. No dependency upgrade or new package
release is needed for these documentation/test additions.
Verified: `go test -race ./cms ./cmssqlite` passes. Preserve unrelated auth work
in the separate `web-dev` checkout; it is not part of this slice.
## CMS classification and relationships
Implement `cms` and `cmssqlite` primitives for Gamertan's existing typed content:
named flat taxonomies, stable terms with rename history, immutable revision
associations, explicit publication and bidirectional related-content queries.
Keep authorization, audits, application schemas, templates and commerce policy
with their callers. Writes join caller-owned SQLite transactions.
Implemented: validated values, explicit schema, caller-owned transactions,
immutable associations, publication pointers, term aliases and public readers.
Verified: focused race tests for scope isolation, conflicts, draft/public
transitions, retirement, rename collisions, pagination and rollback. Gamertan's
consumer HTTP regressions now exercise real content/catalog/case-study routing,
draft isolation, restore, role/CSRF checks and unchanged catalog data.
Consolidated evidence: the full package race suite passed; association fuzzing
passed 698,275 executions. Dependency/module and license checks passed. Vet has
only the existing vendored COSE diagnostic documented by `scripts/verify.sh`.
The consumer's full Go suite and native trusted-TLS editor checks passed.
The consolidated `scripts/verify.sh` passed, including all 126 allowlisted
public-export paths, exported-package compilation, full tests/race, the existing
vet diagnostic exception, dependency/license checks and the starter build.
Published: `v0.1.0-preview.27`, commit `d991ad4`. The consumer now pins the
published module without a workspace replacement in its release checks; its
full Go/vet, focused race, package and trusted-TLS browser checks pass.
No remaining package implementation work or unrelated auth changes in this slice.
Status: implemented, verified, published and adopted. Consumer
deployment remains separately recorded in its repository. Release history stays
in CHANGELOG; publication is confirmed by the remote tag, not a checkbox here.
+83
View File
@@ -0,0 +1,83 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Account mail protocols
`authmail` provides optional verification, confirmed address changes and password
reset. It does not deliver mail directly or own the application router. Use
`authsqlite.Store.AccountMail` for an adapter sharing the identity database and
encrypted transactional outbox. Call `MigrateMail` explicitly and require its
independent schema version before enabling these routes. Base identity schema 11
and historical commerce records are unchanged; existing mailboxes start unverified.
## Operations
- Verification requires a current unrestricted account session and confirmation
at its existing canonical mailbox. Reading/inspecting a link does not verify it.
- Address change requires the current password and that session, then separate
confirmation at both current and proposed mailboxes. The current address stays
authoritative until both confirm. No passkey-approval boolean bypass exists.
A future passkey-only path needs a separately bound fresh-approval protocol.
- Password reset is available only through the current verified mailbox of an
active, fully registered account. Request results are generic for unknown,
unverified, inactive, malformed and account-throttled addresses. A reset creates
no login, removes no passkey/recovery code and bypasses no existing MFA policy.
Tokens use 32 random bytes and purpose-bound SHA-256 digests, expire in 15 minutes,
and work once. Requests bind the user ID, canonical address, profile revision and
current password digest; address/verification requests also bind the real acting
session. A replacement request invalidates the previous link for that purpose.
Already-in-flight older mail may still arrive; an invalidated token cannot act.
The SQLite adapter rechecks authority and identity under its writer lock, including
fresh time after waits/password hashing. A changed password, profile, address,
status or acting session rejects stale requests. Address uniqueness is checked
again at final confirmation. Old-address invitations are revoked, not moved;
new-address invitations still need normal token/authority checks to be accepted.
Existing memberships, ownership and purchase snapshots retain the immutable user.
Successful reset/address changes revoke sessions, pending ceremonies, enrollment
and recovery grants, and outstanding account-mail requests. Mailbox changes notify
both addresses; resets notify the current mailbox. Account changes, notifications
and secret-free audits commit atomically. Delivery capacity/audit failures roll
everything back, leaving valid tokens retryable until their original expiry.
Per-account requests are limited to one per minute and five per hour across these
purposes. At most one pending request per account/purpose remains. Applications
must also impose IP and password-hashing concurrency limits and handle anonymous
request responses without disclosing per-account operational failures.
## Required application boundaries
- Use a configured HTTPS origin and fixed route paths. Never build links from a
request Host header. `Composer` customizes reviewed plain-text copy, not security
state, sender, recipient, headers or editable executable templates.
- Render a deliberate confirmation/reset form; only POST consumes a token. Keep
strict same-origin/CSRF protection and private/no-store responses. Prefer
`TokenInFragment` so the browser transfers the code into the deliberate POST
without placing it in HTTP/proxy request targets. Native forms can require a
same-origin referrer policy to retain a usable Origin header; fragments are
never included in referrers. Script-only flows can use no-referrer. Do not
weaken origin validation to accept opaque/null origins, and never allow link
scanners or GET requests to change account state.
- Exclude tokens, query strings, message bodies, addresses, passwords and SMTP
credentials from logs/telemetry. Restrict token-bearing pages and avoid external
analytics/resources. Debug redaction does not make structured serialization safe.
- Return anonymous request responses consistently, independent of eligibility or
SMTP acceptance. Run SMTP through the bounded outbox worker, not in the request.
- After reset/change, clear the browser's old session and return to normal login.
Preserve the application's passkey/MFA checks; mailbox control is not an owner
recovery grant. Keep printed/owner-assisted recovery separate.
- Existing Stripe receipts/billing emails are financial snapshots, not canonical
login identifiers. Do not rewrite them as part of an account email change.
The design follows the applicable OWASP guidance on
[password reset](https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html)
and [registered-email changes](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#changing-a-users-registered-email-address).
This is not a claim of a completed application security audit.
Local tests cover both confirmation orders, replay/expiry, generic reset requests,
credential/session races, competing resets, address conflicts, stable ownership,
retained factors, invitation handling, rate limits, restart and transactional
migration/audit/outbox rollback. The account packages and mail/outbox suites pass;
focused races and vet pass. Consumer HTTP/UI integration, release/publication and
controlled real-mail proof remain pending in the repository queue.
+455
View File
@@ -0,0 +1,455 @@
// SPDX-License-Identifier: MPL-2.0
// Package authmail implements mailbox verification and password-reset protocols.
// Applications own HTTP CSRF/origin checks, IP/concurrency limits and templates.
// A confirmation must be a deliberate POST, never a mail-scanner-triggered GET.
package authmail
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
stdmail "net/mail"
"net/url"
"path"
"strings"
"time"
"unicode"
"gamertan.com/web/auth"
"gamertan.com/web/mail"
)
var (
ErrInvalid = errors.New("authmail: invalid request")
ErrUnavailable = errors.New("authmail: link or account unavailable")
ErrLimited = errors.New("authmail: please wait before requesting another message")
ErrAddressUnavailable = errors.New("authmail: address cannot be used")
)
type Purpose string
const (
Verify Purpose = "verify"
Change Purpose = "change"
Reset Purpose = "reset"
Lifetime = 15 * time.Minute
)
// Subject is a repository/service boundary, not a public response or log value.
// PasswordHash is needed for reauthentication and credential-race checks.
type Subject struct {
UserID, Email, PasswordHash string
Revision int64
Verified bool
}
func (Subject) String() string { return "authmail.Subject{identity:redacted}" }
func (subject Subject) GoString() string { return subject.String() }
type Request struct {
ID, UserID, Email, NewEmail string
Purpose Purpose
Revision int64
CredentialDigest, SessionDigest, NewDigest, OldDigest [32]byte
CreatedAt, ExpiresAt time.Time
}
func (Request) String() string { return "authmail.Request{identity:redacted}" }
func (request Request) GoString() string { return request.String() }
type Pending struct {
Request Request
Subject Subject
OldToken bool
}
func (Pending) String() string { return "authmail.Pending{identity:redacted}" }
func (pending Pending) GoString() string { return pending.String() }
// Repository must atomically recheck current account/session/credential identity,
// throttling, token expiry and replay; mail intent and audit join each mutation.
// Implementations must never grant authentication or remove MFA credentials.
type Repository interface {
OwnSubject(context.Context, [32]byte, time.Time) (Subject, error)
ResetSubject(context.Context, string, time.Time) (Subject, error)
Issue(context.Context, Request, []mail.Message, auth.AuditEvent) error
Pending(context.Context, [32]byte, time.Time) (Pending, error)
Complete(context.Context, [32]byte, string, string, []mail.Message, auth.AuditEvent) (bool, error)
}
// Config contains trusted deployment values, never a request Host header. The
// two route paths must render deliberate confirmation forms. Plain-text copy is
// centralized here; applications can provide a reviewed Composer to customize it.
type Config struct {
Origin, SiteName, ConfirmPath, ResetPath, SecurityPath string
// TokenInFragment keeps link tokens out of HTTP/proxy request targets. The
// application must transfer it locally into the deliberate confirmation POST.
TokenInFragment bool
Now func() time.Time
Random io.Reader
Compose Composer
}
type MessageContent struct{ Subject, Text string }
func (MessageContent) String() string { return "authmail.MessageContent{content:redacted}" }
func (content MessageContent) GoString() string { return content.String() }
type MailIntent struct {
Kind string
SiteName, ActionURL, SecurityURL string
ExpiresAt time.Time
}
func (MailIntent) String() string { return "authmail.MailIntent{links:redacted}" }
func (intent MailIntent) GoString() string { return intent.String() }
// Composer is trusted application code, not editable template code or arbitrary
// HTML. It does not choose recipients, sender, identifiers or security state.
type Composer func(MailIntent) (MessageContent, error)
type Service struct {
repository Repository
origin *url.URL
config Config
}
func New(repository Repository, config Config) (*Service, error) {
if repository == nil || !plainHeader(config.SiteName, 100) {
return nil, ErrInvalid
}
origin, err := url.Parse(config.Origin)
if err != nil || origin.Scheme != "https" || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || (origin.Path != "" && origin.Path != "/") || origin.Opaque != "" {
return nil, ErrInvalid
}
for _, route := range []string{config.ConfirmPath, config.ResetPath, config.SecurityPath} {
if !strings.HasPrefix(route, "/") || strings.HasPrefix(route, "//") || strings.ContainsAny(route, "?#\\%") || !plainHeader(route, 256) || strings.TrimSuffix(route, "/") != path.Clean(route) {
return nil, ErrInvalid
}
}
if config.Now == nil {
config.Now = time.Now
}
if config.Random == nil {
config.Random = rand.Reader
}
if config.Compose == nil {
config.Compose = DefaultComposer
}
return &Service{repository: repository, origin: origin, config: config}, nil
}
func plainHeader(value string, limit int) bool {
if strings.TrimSpace(value) == "" || len(value) > limit {
return false
}
for _, r := range value {
if unicode.IsControl(r) {
return false
}
}
return true
}
// NormalizeEmail preserves this identity store's case-insensitive mailbox rule.
// SMTPUTF8, display-name syntax and mailbox-provider alias rewriting are absent.
func NormalizeEmail(value string) (string, error) {
value = strings.ToLower(strings.TrimSpace(value))
if !plainHeader(value, 254) {
return "", ErrInvalid
}
for _, r := range value {
if r > 127 {
return "", ErrInvalid
}
}
address, err := stdmail.ParseAddress(value)
if err != nil || address.Name != "" || address.Address != value || !strings.Contains(value, "@") {
return "", ErrInvalid
}
return value, nil
}
func TokenDigest(token string) ([32]byte, error) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil || len(raw) != 32 || base64.RawURLEncoding.EncodeToString(raw) != token {
return [32]byte{}, ErrUnavailable
}
return sha256.Sum256([]byte("gwf.authmail.v1:" + token)), nil
}
func (service *Service) random(size int) (string, error) {
raw := make([]byte, size)
if _, err := io.ReadFull(service.config.Random, raw); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func (service *Service) link(route, token string) string {
link := *service.origin
link.Path = route
link.RawQuery = ""
if token != "" {
if service.config.TokenInFragment {
link.Fragment = url.Values{"token": {token}}.Encode()
} else {
link.RawQuery = url.Values{"token": {token}}.Encode()
}
}
return link.String()
}
func (service *Service) message(kind, recipient, token string, now time.Time) (mail.Message, error) {
route := service.config.ConfirmPath
if kind == "reset" {
route = service.config.ResetPath
}
intent := MailIntent{Kind: kind, SiteName: service.config.SiteName, SecurityURL: service.link(service.config.SecurityPath, ""), ExpiresAt: now.Add(Lifetime)}
if token != "" {
intent.ActionURL = service.link(route, token)
}
content, err := service.config.Compose(intent)
if err != nil {
return mail.Message{}, ErrInvalid
}
id, err := service.random(18)
if err != nil {
return mail.Message{}, err
}
message := mail.Message{ID: "mail_" + id, To: recipient, Subject: content.Subject, Text: content.Text, CreatedAt: now}
if message.Validate() != nil {
return mail.Message{}, ErrInvalid
}
return message, nil
}
func (service *Service) audit(action, userID string, now time.Time) (auth.AuditEvent, error) {
id, err := service.random(18)
if err != nil {
return auth.AuditEvent{}, err
}
actor := userID
if strings.HasPrefix(action, "reset") {
actor = ""
}
return auth.AuditEvent{ID: "mailaudit_" + id, ActorUserID: actor, Action: "auth.mail." + action, ResourceType: "user", ResourceID: userID, Summary: "Account mail operation", CreatedAt: now}, nil
}
// Status requires a current unrestricted session; it is not an address lookup.
func (service *Service) Status(ctx context.Context, session [32]byte) (bool, error) {
subject, err := service.repository.OwnSubject(ctx, session, service.config.Now().UTC())
return subject.Verified, err
}
func (service *Service) RequestVerification(ctx context.Context, session [32]byte) error {
now := service.config.Now().UTC().Truncate(time.Second)
subject, err := service.repository.OwnSubject(ctx, session, now)
if err != nil {
return err
}
if subject.Verified {
return nil
}
return service.issue(ctx, subject, session, Verify, "", now)
}
// RequestChange requires the current password and confirmations at BOTH old and
// new mailboxes. It does not change the canonical address immediately. Passkey-
// only reauthentication would be a separate, operation-bound protocol, not a bool.
func (service *Service) RequestChange(ctx context.Context, session [32]byte, currentPassword, newEmail string) error {
if len(currentPassword) > 1024 {
return ErrInvalid
}
now := service.config.Now().UTC().Truncate(time.Second)
subject, err := service.repository.OwnSubject(ctx, session, now)
if err != nil {
return err
}
if !auth.VerifyPassword(subject.PasswordHash, currentPassword) {
return auth.ErrInvalidCredentials
}
newEmail, err = NormalizeEmail(newEmail)
if err != nil {
return err
}
current, err := NormalizeEmail(subject.Email)
if err != nil || newEmail == current {
return ErrInvalid
}
return service.issue(ctx, subject, session, Change, newEmail, now)
}
// RequestReset has the same result for unknown, unverified, inactive, malformed
// and account-rate-limited addresses. Applications must likewise keep responses
// generic, rate-limit by IP and avoid response timing tied to actual SMTP work.
func (service *Service) RequestReset(ctx context.Context, email string) error {
email, err := NormalizeEmail(email)
if err != nil {
return nil
}
now := service.config.Now().UTC().Truncate(time.Second)
subject, err := service.repository.ResetSubject(ctx, email, now)
if errors.Is(err, ErrUnavailable) {
return nil
}
if err != nil {
return err
}
err = service.issue(ctx, subject, [32]byte{}, Reset, "", now)
if errors.Is(err, ErrLimited) || errors.Is(err, ErrUnavailable) {
return nil
}
return err
}
func (service *Service) issue(ctx context.Context, subject Subject, session [32]byte, purpose Purpose, newEmail string, now time.Time) error {
email, err := NormalizeEmail(subject.Email)
if err != nil {
return ErrUnavailable
}
id, err := service.random(18)
if err != nil {
return err
}
token, err := service.random(32)
if err != nil {
return err
}
digest, _ := TokenDigest(token)
request := Request{ID: "request_" + id, UserID: subject.UserID, Email: email, NewEmail: newEmail, Purpose: purpose, Revision: subject.Revision, CredentialDigest: sha256.Sum256([]byte(subject.PasswordHash)), SessionDigest: session, NewDigest: digest, CreatedAt: now, ExpiresAt: now.Add(Lifetime)}
kind, target := string(purpose), email
if purpose == Change {
kind, target = "change-new", newEmail
}
message, err := service.message(kind, target, token, now)
if err != nil {
return err
}
messages := []mail.Message{message}
if purpose == Change {
oldToken, err := service.random(32)
if err != nil {
return err
}
request.OldDigest, _ = TokenDigest(oldToken)
oldMessage, err := service.message("change-old", email, oldToken, now)
if err != nil {
return err
}
messages = append(messages, oldMessage)
}
audit, err := service.audit(string(purpose)+".request", subject.UserID, now)
if err != nil {
return err
}
return service.repository.Issue(ctx, request, messages, audit)
}
// Inspect is read-only and intentionally returns no account or address details.
func (service *Service) Inspect(ctx context.Context, token string) (Purpose, error) {
digest, err := TokenDigest(token)
if err != nil {
return "", err
}
pending, err := service.repository.Pending(ctx, digest, service.config.Now().UTC())
if err != nil {
return "", err
}
return pending.Request.Purpose, nil
}
// Confirm consumes a verification/change token. false means the other mailbox
// still needs confirmation. It never issues a login or changes a password.
func (service *Service) Confirm(ctx context.Context, token string) (bool, error) {
return service.complete(ctx, token, "", false)
}
// ResetPassword requires a reset token and retains passkeys/recovery codes. The
// application must send the user through its normal sign-in and MFA afterwards.
func (service *Service) ResetPassword(ctx context.Context, token, password string) error {
if err := auth.ValidatePassword(password); err != nil {
return err
}
_, err := service.complete(ctx, token, password, true)
return err
}
func (service *Service) complete(ctx context.Context, token, password string, reset bool) (bool, error) {
digest, err := TokenDigest(token)
if err != nil {
return false, err
}
now := service.config.Now().UTC().Truncate(time.Second)
pending, err := service.repository.Pending(ctx, digest, now)
if err != nil {
return false, err
}
if (pending.Request.Purpose == Reset) != reset {
return false, ErrUnavailable
}
var hash string
var notices []mail.Message
if reset {
if auth.VerifyPassword(pending.Subject.PasswordHash, password) {
return false, auth.ErrPasswordUnchanged
}
hash, err = auth.HashPasswordWithRandom(password, service.config.Random)
if err != nil {
return false, err
}
notice, err := service.message("password-changed", pending.Request.Email, "", now)
if err != nil {
return false, err
}
notices = append(notices, notice)
} else if pending.Request.Purpose == Change {
for _, recipient := range []string{pending.Request.Email, pending.Request.NewEmail} {
notice, err := service.message("email-changed", recipient, "", now)
if err != nil {
return false, err
}
notices = append(notices, notice)
}
}
audit, err := service.audit(string(pending.Request.Purpose)+".confirm", pending.Subject.UserID, now)
if err != nil {
return false, err
}
return service.repository.Complete(ctx, digest, pending.Request.ID, hash, notices, audit)
}
func DefaultComposer(intent MailIntent) (MessageContent, error) {
var title, text string
switch intent.Kind {
case "verify":
title, text = "Confirm your email", "Confirm that this is the email address you'd like to use for your account."
case "reset":
title, text = "Reset your password", "Someone requested a password reset for your account. If that was you, choose a new password using the link below."
case "change-new":
title, text = "Confirm your new email", "Confirm this address to continue your account email change. Your current mailbox also needs to approve the change."
case "change-old":
title, text = "Approve your email change", "Someone who confirmed your current password requested an account email change. Approve it only if you made this request; the new mailbox must confirm too."
case "password-changed":
title, text = "Your password was changed", "Your account password was reset. Existing sessions were signed out. Your passkeys and recovery codes were not removed."
case "email-changed":
title, text = "Your account email was changed", "Both mailboxes confirmed your account email change. Existing sessions were signed out. Your purchases and memberships still belong to the same account."
default:
return MessageContent{}, ErrInvalid
}
if intent.ActionURL != "" {
text += fmt.Sprintf("\n\n%s\n\nThis link expires at %s. Opening it alone does not change your account; the page asks you to confirm.", intent.ActionURL, intent.ExpiresAt.UTC().Format(time.RFC1123))
}
if intent.ActionURL != "" {
text += "\n\nIf you did not request this, do not confirm it."
} else {
text += "\n\nIf this was not you, please contact the site's support team promptly."
}
text += "\nVisit your account security page or contact support:\n" + intent.SecurityURL + "\n\n" + intent.SiteName + "\n"
return MessageContent{Subject: title + " — " + intent.SiteName, Text: text}, nil
}
+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: MPL-2.0
package authmail
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
"testing"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/mail"
)
type unavailableRepository struct{}
func (unavailableRepository) OwnSubject(context.Context, [32]byte, time.Time) (Subject, error) {
return Subject{}, ErrUnavailable
}
func (unavailableRepository) ResetSubject(context.Context, string, time.Time) (Subject, error) {
return Subject{}, ErrUnavailable
}
func (unavailableRepository) Issue(context.Context, Request, []mail.Message, auth.AuditEvent) error {
return ErrUnavailable
}
func (unavailableRepository) Pending(context.Context, [32]byte, time.Time) (Pending, error) {
return Pending{}, ErrUnavailable
}
func (unavailableRepository) Complete(context.Context, [32]byte, string, string, []mail.Message, auth.AuditEvent) (bool, error) {
return false, ErrUnavailable
}
func testConfig() Config {
return Config{Origin: "https://example.test", SiteName: "Example", ConfirmPath: "/account/email/confirm/", ResetPath: "/password/reset/", SecurityPath: "/account/security/"}
}
func TestTrustedOriginAndRouteValidation(t *testing.T) {
for _, origin := range []string{"http://example.test", "https://user:password@example.test", "https://example.test/path", "https://example.test/?query=1", "https://example.test/#fragment", "javascript:alert(1)", "//example.test", "https://"} {
config := testConfig()
config.Origin = origin
if _, err := New(unavailableRepository{}, config); !errors.Is(err, ErrInvalid) {
t.Fatalf("invalid origin accepted: %q", origin)
}
}
for _, route := range []string{"//evil.test/path", "/path?token=1", "/path#fragment", "/a/../b", "relative/path", "/bad\\path", "/bad\npath", "/%2f%2fevil.test"} {
config := testConfig()
config.ConfirmPath = route
if _, err := New(unavailableRepository{}, config); !errors.Is(err, ErrInvalid) {
t.Fatalf("invalid route accepted: %q", route)
}
}
if _, err := New(nil, testConfig()); !errors.Is(err, ErrInvalid) {
t.Fatal("nil repository")
}
service, err := New(unavailableRepository{}, testConfig())
if err != nil {
t.Fatal(err)
}
if got := service.link("/password/reset/", "a+/&b"); got != "https://example.test/password/reset/?token=a%2B%2F%26b" {
t.Fatalf("link encoding: %s", got)
}
fragmentConfig := testConfig()
fragmentConfig.TokenInFragment = true
fragmentService, err := New(unavailableRepository{}, fragmentConfig)
if err != nil {
t.Fatal(err)
}
if got := fragmentService.link("/password/reset/", "fixture-token"); got != "https://example.test/password/reset/#token=fixture-token" {
t.Fatal("fragment mode exposed token in request target")
}
config := testConfig()
config.SiteName = "Name\nInjected: header"
if _, err := New(unavailableRepository{}, config); !errors.Is(err, ErrInvalid) {
t.Fatal("site name injection")
}
}
func TestMailboxAndTokenValidation(t *testing.T) {
if got, err := NormalizeEmail(" READER@Example.Test "); err != nil || got != "reader@example.test" {
t.Fatalf("normalization %q %v", got, err)
}
for _, email := range []string{"Name <reader@example.test>", "reader@example.test\nBcc: other@example.test", "ü@example.test", "a,b@example.test", "missing-at", strings.Repeat("x", 255) + "@example.test"} {
if _, err := NormalizeEmail(email); err == nil {
t.Fatalf("invalid address accepted: %q", email)
}
}
token := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
first, err := TokenDigest(token)
if err != nil || first == ([32]byte{}) {
t.Fatal("valid token rejected")
}
for _, value := range []string{"", token + "=", token[:42], strings.Repeat("a", 200), " " + token} {
if _, err := TokenDigest(value); !errors.Is(err, ErrUnavailable) {
t.Fatal("malformed token accepted")
}
}
service, err := New(unavailableRepository{}, testConfig())
if err != nil {
t.Fatal(err)
}
for _, value := range []string{"not-an-email", "unknown@example.test"} {
if err := service.RequestReset(t.Context(), value); err != nil {
t.Fatal("reset enumeration", err)
}
}
if _, err = service.Inspect(t.Context(), token); !errors.Is(err, ErrUnavailable) {
t.Fatal(err)
}
if _, err = service.Confirm(t.Context(), token); !errors.Is(err, ErrUnavailable) {
t.Fatal(err)
}
}
func TestDefaultAndCustomCopyDoesNotControlEnvelope(t *testing.T) {
config := testConfig()
config.Compose = func(intent MailIntent) (MessageContent, error) {
return MessageContent{Subject: "Custom subject", Text: "Custom body: " + intent.ActionURL}, nil
}
service, err := New(unavailableRepository{}, config)
if err != nil {
t.Fatal(err)
}
message, err := service.message("verify", "recipient@example.test", "token", time.Now().UTC())
if err != nil {
t.Fatal(err)
}
if message.To != "recipient@example.test" || message.Subject != "Custom subject" || !strings.Contains(message.Text, "https://example.test/account/email/confirm/?token=token") {
t.Fatal("custom copy bypassed fixed envelope/origin")
}
config.Compose = func(MailIntent) (MessageContent, error) {
return MessageContent{Subject: "bad\nBcc: another@example.test", Text: "body"}, nil
}
service, err = New(unavailableRepository{}, config)
if err != nil {
t.Fatal(err)
}
if _, err = service.message("verify", "recipient@example.test", "token", time.Now().UTC()); !errors.Is(err, ErrInvalid) {
t.Fatal("custom header injection")
}
for _, kind := range []string{"verify", "reset", "change-new", "change-old", "password-changed", "email-changed"} {
content, err := DefaultComposer(MailIntent{Kind: kind, SiteName: "Example", SecurityURL: "https://example.test/security/", ActionURL: "https://example.test/confirm/", ExpiresAt: time.Now().UTC()})
if err != nil || content.Subject == "" || content.Text == "" {
t.Fatal("missing copy", kind)
}
}
if _, err = DefaultComposer(MailIntent{Kind: "unknown"}); !errors.Is(err, ErrInvalid) {
t.Fatal("unknown intent")
}
for _, value := range []any{Subject{Email: "private@example.test", PasswordHash: "secret-hash"}, Request{Email: "private@example.test"}, Pending{Subject: Subject{Email: "private@example.test"}}} {
if strings.Contains(fmt.Sprintf("%v %#v", value, value), "private@example.test") {
t.Fatal("sensitive debug output")
}
}
}
+415
View File
@@ -0,0 +1,415 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"crypto/sha256"
"database/sql"
"errors"
"strings"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authmail"
"gamertan.com/web/mail"
"gamertan.com/web/mailsqlite"
"modernc.org/sqlite"
)
// MailRepository is optional. Construction opens no new connection and performs
// no migration. The outbox shares this store's DB so account/audit/mail commits
// are atomic. Applications must check RequireMailSchema before enabling routes.
type MailRepository struct {
store *Store
queue *mailsqlite.Queue
now func() time.Time
}
func (store *Store) AccountMail(options mailsqlite.Options) (*MailRepository, *mailsqlite.Queue, error) {
if options.Now == nil {
options.Now = time.Now
}
queue, err := mailsqlite.New(store.db, options)
if err != nil {
return nil, nil, err
}
return &MailRepository{store: store, queue: queue, now: options.Now}, queue, nil
}
const MailSchemaVersion = 1
func (store *Store) RequireMailSchema(ctx context.Context) error {
var version int
if err := store.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM gwf_account_mail_migrations`).Scan(&version); err != nil {
return errors.New("authsqlite: explicit account mail migration required")
}
if version != MailSchemaVersion {
return errors.New("authsqlite: incompatible account mail schema")
}
return nil
}
// MigrateMail is an explicit operator migration, separate from schema 11. It
// creates no verified identities for existing users and touches no commerce data.
func (store *Store) MigrateMail(ctx context.Context) error {
if err := store.RequireCurrentSchema(ctx); err != nil {
return err
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS gwf_account_mail_migrations(version INTEGER PRIMARY KEY,applied_at INTEGER NOT NULL)`); err != nil {
return err
}
var version int
if err = tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM gwf_account_mail_migrations`).Scan(&version); err != nil {
return err
}
if version > MailSchemaVersion {
return errors.New("authsqlite: newer account mail schema")
}
if version == MailSchemaVersion {
return tx.Commit()
}
if err = mailsqlite.CreateSchema(ctx, tx); err != nil {
return err
}
for _, statement := range []string{
`CREATE TABLE gwf_verified_emails(user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE,email_normalized TEXT NOT NULL,verified_at INTEGER NOT NULL)`,
`CREATE TABLE gwf_account_mail_requests(id TEXT NOT NULL UNIQUE,user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE,purpose TEXT NOT NULL CHECK(purpose IN ('verify','change','reset')),email TEXT NOT NULL,new_email TEXT NOT NULL,profile_revision INTEGER NOT NULL,credential_digest BLOB NOT NULL CHECK(length(credential_digest)=32),session_digest BLOB NOT NULL CHECK(length(session_digest)=32),new_digest BLOB NOT NULL UNIQUE CHECK(length(new_digest)=32),old_digest BLOB UNIQUE,new_confirmed INTEGER NOT NULL DEFAULT 0 CHECK(new_confirmed IN (0,1)),old_confirmed INTEGER NOT NULL DEFAULT 0 CHECK(old_confirmed IN (0,1)),created_at INTEGER NOT NULL,expires_at INTEGER NOT NULL CHECK(expires_at>created_at),PRIMARY KEY(user_id,purpose),CHECK(old_digest IS NULL OR length(old_digest)=32))`,
`CREATE INDEX gwf_account_mail_requests_expiry ON gwf_account_mail_requests(expires_at)`,
`CREATE TABLE gwf_account_mail_limits(user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE,window_started INTEGER NOT NULL,request_count INTEGER NOT NULL,last_requested INTEGER NOT NULL)`,
} {
if _, err = tx.ExecContext(ctx, statement); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_mail_migrations(version,applied_at) VALUES(?,?)`, MailSchemaVersion, time.Now().UTC().Unix()); err != nil {
return err
}
return tx.Commit()
}
const mailSubjectSelect = `SELECT u.id,u.email,c.password_hash,u.profile_revision,EXISTS(SELECT 1 FROM gwf_verified_emails v WHERE v.user_id=u.id AND v.email_normalized=u.email_normalized)
FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.status='active' AND u.registration_pending=0`
func scanMailSubject(row interface{ Scan(...any) error }) (authmail.Subject, error) {
var subject authmail.Subject
err := row.Scan(&subject.UserID, &subject.Email, &subject.PasswordHash, &subject.Revision, &subject.Verified)
if errors.Is(err, sql.ErrNoRows) {
return subject, authmail.ErrUnavailable
}
return subject, err
}
func (repo *MailRepository) OwnSubject(ctx context.Context, session [32]byte, now time.Time) (authmail.Subject, error) {
if zeroDigest(session) || now.IsZero() {
return authmail.Subject{}, authmail.ErrUnavailable
}
return scanMailSubject(repo.store.db.QueryRowContext(ctx, mailSubjectSelect+` AND u.password_change_required=0 AND EXISTS(SELECT 1 FROM gwf_auth_sessions s WHERE s.user_id=u.id AND s.token_hash=? AND s.expires_at>?)`, session[:], now.Unix()))
}
func (repo *MailRepository) ResetSubject(ctx context.Context, email string, now time.Time) (authmail.Subject, error) {
value, err := authmail.NormalizeEmail(email)
if err != nil || value != email || now.IsZero() {
return authmail.Subject{}, authmail.ErrUnavailable
}
return scanMailSubject(repo.store.db.QueryRowContext(ctx, mailSubjectSelect+` AND u.email_normalized=? AND EXISTS(SELECT 1 FROM gwf_verified_emails v WHERE v.user_id=u.id AND v.email_normalized=u.email_normalized)`, email))
}
func mailAuditValid(audit auth.AuditEvent, request authmail.Request, suffix string) bool {
actor := request.UserID
if request.Purpose == authmail.Reset {
actor = ""
}
return validAuditEvent(audit) && audit.ActorUserID == actor && audit.Action == "auth.mail."+string(request.Purpose)+suffix && audit.ResourceType == "user" && audit.ResourceID == request.UserID
}
func mailRequestValid(request authmail.Request) bool {
email, err := authmail.NormalizeEmail(request.Email)
if err != nil || email != request.Email || !opaqueID(request.ID) || !opaqueID(request.UserID) || request.Revision < 1 || zeroDigest(request.NewDigest) || zeroDigest(request.CredentialDigest) || request.CreatedAt.IsZero() || request.ExpiresAt.Sub(request.CreatedAt) != authmail.Lifetime {
return false
}
if request.Purpose == authmail.Change {
value, err := authmail.NormalizeEmail(request.NewEmail)
return err == nil && value == request.NewEmail && value != email && !zeroDigest(request.OldDigest) && request.OldDigest != request.NewDigest && !zeroDigest(request.SessionDigest)
}
return request.NewEmail == "" && zeroDigest(request.OldDigest) && (request.Purpose == authmail.Verify && !zeroDigest(request.SessionDigest) || request.Purpose == authmail.Reset && zeroDigest(request.SessionDigest))
}
func mailSubjectMatches(subject authmail.Subject, request authmail.Request) bool {
return subject.UserID == request.UserID && normalize(subject.Email) == request.Email && subject.Revision == request.Revision && sha256.Sum256([]byte(subject.PasswordHash)) == request.CredentialDigest && (request.Purpose != authmail.Reset || subject.Verified)
}
func mailSessionCurrent(ctx context.Context, tx *sql.Tx, request authmail.Request, now time.Time) error {
if request.Purpose == authmail.Reset {
return nil
}
var valid int
err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.user_id=? AND s.token_hash=? AND s.expires_at>? AND u.password_change_required=0`, request.UserID, request.SessionDigest[:], now.Unix()).Scan(&valid)
if err != nil {
return err
}
if valid != 1 {
return authmail.ErrUnavailable
}
return nil
}
func (repo *MailRepository) Issue(ctx context.Context, request authmail.Request, messages []mail.Message, audit auth.AuditEvent) error {
if !mailRequestValid(request) || !mailAuditValid(audit, request, ".request") || !audit.CreatedAt.Equal(request.CreatedAt) {
return authmail.ErrInvalid
}
target := request.Email
if request.Purpose == authmail.Change {
target = request.NewEmail
}
if len(messages) != 1 && request.Purpose != authmail.Change || request.Purpose == authmail.Change && len(messages) != 2 {
return authmail.ErrInvalid
}
if messages[0].To != target || messages[0].Validate() != nil || !messages[0].CreatedAt.Equal(request.CreatedAt) {
return authmail.ErrInvalid
}
if len(messages) == 2 && (messages[1].To != request.Email || messages[1].Validate() != nil || messages[0].ID == messages[1].ID || !messages[1].CreatedAt.Equal(request.CreatedAt)) {
return authmail.ErrInvalid
}
tx, err := repo.store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Acquire the writer lock before all identity, uniqueness and rate checks.
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET updated_at=updated_at WHERE id=?`, request.UserID); err != nil {
return err
}
current := repo.now().UTC()
if !request.ExpiresAt.After(current) || request.CreatedAt.After(current.Add(time.Minute)) || request.CreatedAt.Before(current.Add(-time.Minute)) {
return authmail.ErrUnavailable
}
subject, err := scanMailSubject(tx.QueryRowContext(ctx, mailSubjectSelect+` AND u.id=?`, request.UserID))
if err != nil {
return err
}
if !mailSubjectMatches(subject, request) {
return authmail.ErrUnavailable
}
if err = mailSessionCurrent(ctx, tx, request, current); err != nil {
return err
}
if request.Purpose == authmail.Verify && subject.Verified {
return authmail.ErrUnavailable
}
if request.Purpose == authmail.Change {
var collision int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_users WHERE email_normalized=?`, request.NewEmail).Scan(&collision); err != nil {
return err
}
if collision != 0 {
return authmail.ErrAddressUnavailable
}
}
var start, last int64
var count int
err = tx.QueryRowContext(ctx, `SELECT window_started,request_count,last_requested FROM gwf_account_mail_limits WHERE user_id=?`, request.UserID).Scan(&start, &count, &last)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
now := current.Unix()
if last > now-60 || start > now-3600 && count >= 5 {
return authmail.ErrLimited
}
if start <= now-3600 {
start, count = now, 0
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_mail_limits(user_id,window_started,request_count,last_requested) VALUES(?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET window_started=excluded.window_started,request_count=excluded.request_count,last_requested=excluded.last_requested`, request.UserID, start, count+1, now); err != nil {
return err
}
var old []byte
if request.Purpose == authmail.Change {
old = request.OldDigest[:]
}
// Both token columns share one logical namespace. Detect even an entropy
// failure that collides with the other confirmation leg before inserting.
var tokenCollision int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_account_mail_requests WHERE new_digest IN (?,?) OR old_digest IN (?,?)`, request.NewDigest[:], old, request.NewDigest[:], old).Scan(&tokenCollision); err != nil {
return err
}
if tokenCollision != 0 {
return authmail.ErrInvalid
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_account_mail_requests(id,user_id,purpose,email,new_email,profile_revision,credential_digest,session_digest,new_digest,old_digest,created_at,expires_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(user_id,purpose) DO UPDATE SET id=excluded.id,email=excluded.email,new_email=excluded.new_email,profile_revision=excluded.profile_revision,credential_digest=excluded.credential_digest,session_digest=excluded.session_digest,new_digest=excluded.new_digest,old_digest=excluded.old_digest,new_confirmed=0,old_confirmed=0,created_at=excluded.created_at,expires_at=excluded.expires_at`, request.ID, request.UserID, request.Purpose, request.Email, request.NewEmail, request.Revision, request.CredentialDigest[:], request.SessionDigest[:], request.NewDigest[:], old, request.CreatedAt.Unix(), request.ExpiresAt.Unix()); err != nil {
return err
}
for _, message := range messages {
if err = repo.queue.EnqueueTx(ctx, tx, message, request.ExpiresAt); err != nil {
return err
}
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
type mailQuerier interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func pendingMail(ctx context.Context, query mailQuerier, digest [32]byte, now time.Time) (authmail.Pending, error) {
var pending authmail.Pending
if zeroDigest(digest) || now.IsZero() {
return pending, authmail.ErrUnavailable
}
request := &pending.Request
var credential, session, next, old []byte
var created, expires int64
err := query.QueryRowContext(ctx, `SELECT id,user_id,purpose,email,new_email,profile_revision,credential_digest,session_digest,new_digest,old_digest,created_at,expires_at FROM gwf_account_mail_requests WHERE expires_at>? AND ((new_digest=? AND new_confirmed=0) OR (old_digest=? AND old_confirmed=0))`, now.Unix(), digest[:], digest[:]).Scan(&request.ID, &request.UserID, &request.Purpose, &request.Email, &request.NewEmail, &request.Revision, &credential, &session, &next, &old, &created, &expires)
if errors.Is(err, sql.ErrNoRows) {
return pending, authmail.ErrUnavailable
}
if err != nil {
return pending, err
}
copy(request.CredentialDigest[:], credential)
copy(request.SessionDigest[:], session)
copy(request.NewDigest[:], next)
copy(request.OldDigest[:], old)
request.CreatedAt, request.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
if !mailRequestValid(*request) {
return pending, authmail.ErrUnavailable
}
pending.OldToken = digest == request.OldDigest
pending.Subject, err = scanMailSubject(query.QueryRowContext(ctx, mailSubjectSelect+` AND u.id=?`, request.UserID))
if err != nil {
return pending, err
}
if !mailSubjectMatches(pending.Subject, *request) {
return pending, authmail.ErrUnavailable
}
if request.Purpose != authmail.Reset {
var valid int
err = query.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.user_id=? AND s.token_hash=? AND s.expires_at>? AND u.password_change_required=0`, request.UserID, request.SessionDigest[:], now.Unix()).Scan(&valid)
if err != nil {
return pending, err
}
if valid != 1 {
return pending, authmail.ErrUnavailable
}
}
return pending, nil
}
func (repo *MailRepository) Pending(ctx context.Context, digest [32]byte, now time.Time) (authmail.Pending, error) {
return pendingMail(ctx, repo.store.db, digest, now)
}
func (repo *MailRepository) Complete(ctx context.Context, digest [32]byte, requestID, newHash string, notices []mail.Message, audit auth.AuditEvent) (bool, error) {
if zeroDigest(digest) || !opaqueID(requestID) || !validAuditEvent(audit) {
return false, authmail.ErrInvalid
}
tx, err := repo.store.db.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `UPDATE gwf_account_mail_requests SET expires_at=expires_at WHERE id=?`, requestID); err != nil {
return false, err
}
// Re-read time after acquiring the writer lock, not before password hashing
// or a database wait. A just-expired token must not complete a mutation.
audit.CreatedAt = repo.now().UTC().Truncate(time.Second)
pending, err := pendingMail(ctx, tx, digest, audit.CreatedAt)
if err != nil {
return false, err
}
request := pending.Request
if request.ID != requestID || !mailAuditValid(audit, request, ".confirm") {
return false, authmail.ErrUnavailable
}
if request.Purpose == authmail.Reset {
if !strings.HasPrefix(newHash, "$argon2id$") || len(newHash) > 1024 || newHash == pending.Subject.PasswordHash || len(notices) != 1 || notices[0].To != request.Email {
return false, authmail.ErrInvalid
}
} else if newHash != "" || request.Purpose == authmail.Verify && len(notices) != 0 || request.Purpose == authmail.Change && (len(notices) != 2 || notices[0].To != request.Email || notices[1].To != request.NewEmail) {
return false, authmail.ErrInvalid
}
for _, message := range notices {
if message.Validate() != nil {
return false, authmail.ErrInvalid
}
}
column := "new_confirmed"
if pending.OldToken {
column = "old_confirmed"
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_account_mail_requests SET `+column+`=1 WHERE id=?`, requestID); err != nil {
return false, err
}
if request.Purpose == authmail.Change {
var ready bool
if err = tx.QueryRowContext(ctx, `SELECT new_confirmed=1 AND old_confirmed=1 FROM gwf_account_mail_requests WHERE id=?`, requestID).Scan(&ready); err != nil {
return false, err
}
if !ready {
if err = appendAudit(ctx, tx, audit); err != nil {
return false, err
}
return false, tx.Commit()
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_users SET email=?,email_normalized=?,profile_revision=profile_revision+1,updated_at=MAX(updated_at,?) WHERE id=? AND profile_revision<9223372036854775807`, request.NewEmail, request.NewEmail, audit.CreatedAt.Unix(), request.UserID)
if err != nil {
var constraint *sqlite.Error
if errors.As(err, &constraint) && constraint.Code() == 2067 {
return false, authmail.ErrAddressUnavailable
}
return false, err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return false, authmail.ErrUnavailable
}
// Invitations issued to the previous identity do not migrate to another
// mailbox/account. Existing memberships remain bound to immutable user ID.
if _, err = tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=? WHERE email_normalized=? AND used_at IS NULL AND revoked_at IS NULL`, audit.CreatedAt.Unix(), request.Email); err != nil {
return false, err
}
}
if request.Purpose != authmail.Reset {
email := request.Email
if request.Purpose == authmail.Change {
email = request.NewEmail
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_verified_emails(user_id,email_normalized,verified_at) VALUES(?,?,?) ON CONFLICT(user_id) DO UPDATE SET email_normalized=excluded.email_normalized,verified_at=excluded.verified_at`, request.UserID, email, audit.CreatedAt.Unix()); err != nil {
return false, err
}
} else {
if _, err = tx.ExecContext(ctx, `UPDATE gwf_password_credentials SET password_hash=?,changed_at=? WHERE user_id=?`, newHash, audit.CreatedAt.Unix(), request.UserID); err != nil {
return false, err
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=MAX(updated_at,?) WHERE id=?`, audit.CreatedAt.Unix(), request.UserID); err != nil {
return false, err
}
}
if request.Purpose != authmail.Verify {
// Keep enrolled passkeys and recovery-code digests. Revoke only sessions,
// in-flight ceremonies, enrollment/recovery grants and pending mail links.
for _, table := range []string{"gwf_auth_sessions", "gwf_passkey_ceremonies", "gwf_passkey_enrollment_tokens", "gwf_recovery_grants", "gwf_assisted_recovery_grants", "gwf_account_mail_requests"} {
if _, err = tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE user_id=?`, request.UserID); err != nil {
return false, err
}
}
} else if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_account_mail_requests WHERE id=?`, requestID); err != nil {
return false, err
}
for _, message := range notices {
if err = repo.queue.EnqueueTx(ctx, tx, message, audit.CreatedAt.Add(24*time.Hour)); err != nil {
return false, err
}
}
if err = appendAudit(ctx, tx, audit); err != nil {
return false, err
}
return true, tx.Commit()
}
+592
View File
@@ -0,0 +1,592 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"net/url"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authmail"
"gamertan.com/web/mail"
"gamertan.com/web/mailsqlite"
)
const mailPassword = "correct horse battery staple"
var accountMailHash = sync.OnceValues(func() (string, error) { return auth.HashPassword(mailPassword) })
type mailClock struct{ unix atomic.Int64 }
func (clock *mailClock) now() time.Time { return time.Unix(clock.unix.Load(), 0).UTC() }
func (clock *mailClock) advance(duration time.Duration) {
clock.unix.Add(int64(duration / time.Second))
}
type mailFixture struct {
store *Store
repo *MailRepository
queue *mailsqlite.Queue
service *authmail.Service
clock *mailClock
user auth.User
session auth.Session
path string
}
func mailConfig(clock *mailClock) authmail.Config {
return authmail.Config{Origin: "https://accounts.example.test", SiteName: "Example Site", ConfirmPath: "/account/email/confirm/", ResetPath: "/reset-password/", SecurityPath: "/account/security/", Now: clock.now}
}
func accountMailFixture(t *testing.T, capacity int) mailFixture {
t.Helper()
clock := &mailClock{}
clock.unix.Store(time.Date(2026, 9, 11, 8, 0, 0, 0, time.UTC).Unix())
path := filepath.Join(t.TempDir(), "identity.sqlite")
store, err := Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
if err = store.RequireMailSchema(t.Context()); err == nil {
t.Fatal("mail schema silently installed")
}
if err = store.MigrateMail(t.Context()); err != nil {
t.Fatal(err)
}
if err = store.MigrateMail(t.Context()); err != nil {
t.Fatal(err)
}
if err = store.RequireMailSchema(t.Context()); err != nil {
t.Fatal(err)
}
user := auth.User{ID: "mail_user_fixture", Username: "mail.reader", Email: "reader@example.test", DisplayName: "Reader", Status: "active", CreatedAt: clock.now(), UpdatedAt: clock.now()}
hash, err := accountMailHash()
if err != nil {
t.Fatal(err)
}
if err = store.CreateUser(t.Context(), user, hash); err != nil {
t.Fatal(err)
}
session := auth.Session{UserID: user.ID, Digest: sha256.Sum256([]byte("acting-mail-session")), CreatedAt: clock.now(), ExpiresAt: clock.now().Add(24 * time.Hour), LastSeenAt: clock.now()}
if err = store.CreateSession(t.Context(), session); err != nil {
t.Fatal(err)
}
repo, queue, err := store.AccountMail(mailsqlite.Options{EncryptionKey: bytes.Repeat([]byte{31}, 32), MaxPending: capacity, Now: clock.now})
if err != nil {
t.Fatal(err)
}
service, err := authmail.New(repo, mailConfig(clock))
if err != nil {
t.Fatal(err)
}
return mailFixture{store, repo, queue, service, clock, user, session, path}
}
type collectMail struct{ messages []mail.Message }
func (collector *collectMail) Send(_ context.Context, message mail.Message) error {
collector.messages = append(collector.messages, message)
return nil
}
func (fixture mailFixture) drain(t *testing.T) []mail.Message {
t.Helper()
collector := &collectMail{}
for range 10 {
record, work, err := fixture.queue.ProcessOne(t.Context(), collector)
if err != nil {
t.Fatal(err)
}
if !work {
return collector.messages
}
if record.State != "accepted" {
t.Fatalf("outbox state=%s stage=%s", record.State, record.FailureStage)
}
}
t.Fatal("unbounded fixture queue")
return nil
}
func mailToken(t *testing.T, message mail.Message) string {
t.Helper()
for _, part := range strings.Fields(message.Text) {
parsed, err := url.Parse(part)
if err == nil && parsed.Scheme == "https" && parsed.Query().Get("token") != "" {
if parsed.Host != "accounts.example.test" {
t.Fatal("untrusted action origin")
}
return parsed.Query().Get("token")
}
}
t.Fatal("no action token in fixture mail")
return ""
}
func (fixture mailFixture) verify(t *testing.T) {
t.Helper()
if err := fixture.service.RequestVerification(t.Context(), fixture.session.Digest); err != nil {
t.Fatal(err)
}
messages := fixture.drain(t)
if len(messages) != 1 {
t.Fatalf("verification messages=%d", len(messages))
}
if complete, err := fixture.service.Confirm(t.Context(), mailToken(t, messages[0])); err != nil || !complete {
t.Fatalf("verify: %v %v", complete, err)
}
fixture.clock.advance(time.Minute)
}
func TestMailVerificationResetAndPreservedFactors(t *testing.T) {
f := accountMailFixture(t, 0)
if verified, err := f.service.Status(t.Context(), f.session.Digest); err != nil || verified {
t.Fatalf("legacy auto-verified: %v %v", verified, err)
}
for _, email := range []string{f.user.Email, "unknown@example.test", "not an address"} {
if err := f.service.RequestReset(t.Context(), email); err != nil {
t.Fatal(err)
}
}
if got := f.drain(t); len(got) != 0 {
t.Fatal("unverified/unknown address received reset")
}
if err := f.service.RequestVerification(t.Context(), f.session.Digest); err != nil {
t.Fatal(err)
}
messages := f.drain(t)
if len(messages) != 1 || messages[0].To != f.user.Email {
t.Fatal("verification recipient")
}
token := mailToken(t, messages[0])
for range 2 {
if purpose, err := f.service.Inspect(t.Context(), token); err != nil || purpose != authmail.Verify {
t.Fatalf("inspect: %s %v", purpose, err)
}
}
if verified, _ := f.service.Status(t.Context(), f.session.Digest); verified {
t.Fatal("read-only inspection mutated identity")
}
if err := f.service.ResetPassword(t.Context(), token, "a different safe password"); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("verify token used for reset: %v", err)
}
if completed, err := f.service.Confirm(t.Context(), token); err != nil || !completed {
t.Fatalf("confirm: %v %v", completed, err)
}
if _, err := f.service.Confirm(t.Context(), token); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("token replay: %v", err)
}
if verified, err := f.service.Status(t.Context(), f.session.Digest); err != nil || !verified {
t.Fatalf("not verified: %v %v", verified, err)
}
for _, statement := range []string{
`INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at) VALUES(X'010203','mail_user_fixture','Fixture',X'7B7D',1)`,
`INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at) VALUES('mail_user_fixture',X'1234',1)`,
`INSERT INTO gwf_recovery_grants(token_hash,user_id,created_at,expires_at) VALUES(X'1234','mail_user_fixture',1,9999999999)`,
`INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(X'1234','mail_user_fixture',1,9999999999)`,
} {
if _, err := f.store.db.Exec(statement); err != nil {
t.Fatal(err)
}
}
f.clock.advance(time.Minute)
if err := f.service.RequestReset(t.Context(), strings.ToUpper(f.user.Email)); err != nil {
t.Fatal(err)
}
if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil {
t.Fatal("rate limit disclosed account", err)
}
messages = f.drain(t)
if len(messages) != 1 {
t.Fatalf("reset requests=%d", len(messages))
}
token = mailToken(t, messages[0])
if _, err := f.service.Confirm(t.Context(), token); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("reset token confirmed: %v", err)
}
if err := f.service.ResetPassword(t.Context(), token, mailPassword); !errors.Is(err, auth.ErrPasswordUnchanged) {
t.Fatalf("password reuse: %v", err)
}
if err := f.service.ResetPassword(t.Context(), token, "a different safe password"); err != nil {
t.Fatal(err)
}
user, hash, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || user.ID != f.user.ID || user.Email != f.user.Email || !auth.VerifyPassword(hash, "a different safe password") || auth.VerifyPassword(hash, mailPassword) {
t.Fatal("incorrect reset result", err)
}
if _, _, err = f.store.PrincipalBySession(t.Context(), f.session.Digest, f.clock.now()); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("session survived: %v", err)
}
for table, want := range map[string]int{"gwf_passkey_credentials": 1, "gwf_recovery_codes": 1, "gwf_recovery_grants": 0, "gwf_passkey_enrollment_tokens": 0, "gwf_account_mail_requests": 0} {
var count int
if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE user_id=?`, f.user.ID).Scan(&count); err != nil || count != want {
t.Fatalf("%s count=%d want=%d err=%v", table, count, want, err)
}
}
if err = f.service.ResetPassword(t.Context(), token, "third different safe password"); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("reset replay: %v", err)
}
messages = f.drain(t)
if len(messages) != 1 || !strings.Contains(messages[0].Subject, "password was changed") || strings.Contains(messages[0].Text, "token=") {
t.Fatal("password-change notice missing or carries token")
}
}
func TestMailAddressChangeBothMailboxesAndStableOwnership(t *testing.T) {
for _, oldFirst := range []bool{false, true} {
t.Run(map[bool]string{false: "new-first", true: "old-first"}[oldFirst], func(t *testing.T) {
f := accountMailFixture(t, 0)
if err := f.service.RequestChange(t.Context(), f.session.Digest, "wrong password", "new@example.test"); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("unauthenticated change: %v", err)
}
if err := f.service.RequestChange(t.Context(), f.session.Digest, mailPassword, "NEW@example.test"); err != nil {
t.Fatal(err)
}
for _, statement := range []string{
`CREATE TABLE preserved_purchase(id TEXT PRIMARY KEY,user_id TEXT,old_email TEXT)`,
`INSERT INTO preserved_purchase VALUES('order_fixture','mail_user_fixture','reader@example.test')`,
`INSERT INTO gwf_organizations(id,slug,name,personal,created_at,updated_at) VALUES('family_fixture','family-fixture','Family',0,1,1)`,
`INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES('family_fixture','mail_user_fixture','active',1)`,
`INSERT INTO gwf_organization_invitations(token_hash,id,organization_id,email_normalized,invited_by_user_id,created_at,expires_at) VALUES(X'0101','old_invitation','family_fixture','reader@example.test','mail_user_fixture',1,9999999999)`,
`INSERT INTO gwf_organization_invitations(token_hash,id,organization_id,email_normalized,invited_by_user_id,created_at,expires_at) VALUES(X'0202','new_invitation','family_fixture','new@example.test','mail_user_fixture',1,9999999999)`,
} {
if _, err := f.store.db.Exec(statement); err != nil {
t.Fatal(err)
}
}
messages := f.drain(t)
if len(messages) != 2 {
t.Fatalf("change messages=%d", len(messages))
}
var oldToken, newToken string
for _, message := range messages {
if message.To == f.user.Email {
oldToken = mailToken(t, message)
} else if message.To == "new@example.test" {
newToken = mailToken(t, message)
} else {
t.Fatal("wrong mailbox")
}
}
first, second := newToken, oldToken
if oldFirst {
first, second = oldToken, newToken
}
if done, err := f.service.Confirm(t.Context(), first); err != nil || done {
t.Fatalf("first confirmation: %v %v", done, err)
}
user, _, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || user.Email != f.user.Email {
t.Fatal("address changed with one mailbox")
}
if got := f.drain(t); len(got) != 0 {
t.Fatal("premature change notice")
}
if _, err = f.service.Confirm(t.Context(), first); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("first-leg replay: %v", err)
}
if done, err := f.service.Confirm(t.Context(), second); err != nil || !done {
t.Fatalf("second confirmation: %v %v", done, err)
}
user, _, err = f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || user.Email != "new@example.test" || user.ID != f.user.ID {
t.Fatal("canonical identity changed incorrectly")
}
if _, _, err = f.store.CredentialByIdentifier(t.Context(), f.user.Email); !errors.Is(err, auth.ErrUserNotFound) {
t.Fatalf("old login address survived: %v", err)
}
if subject, err := f.repo.ResetSubject(t.Context(), "new@example.test", f.clock.now()); err != nil || !subject.Verified {
t.Fatal("new mailbox not verified", err)
}
var value string
if err = f.store.db.QueryRow(`SELECT user_id||':'||old_email FROM preserved_purchase`).Scan(&value); err != nil || value != f.user.ID+":"+f.user.Email {
t.Fatal("historical purchase changed")
}
var count int
if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, f.user.ID).Scan(&count); err != nil || count != 1 {
t.Fatal("membership changed")
}
if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_organization_invitations WHERE id='old_invitation' AND revoked_at IS NOT NULL`).Scan(&count); err != nil || count != 1 {
t.Fatal("old invitation retained")
}
if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_organization_invitations WHERE id='new_invitation' AND revoked_at IS NULL AND used_at IS NULL`).Scan(&count); err != nil || count != 1 {
t.Fatal("new invitation granted or rewritten")
}
if _, _, err = f.store.PrincipalBySession(t.Context(), f.session.Digest, f.clock.now()); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatal("change did not sign out sessions")
}
if got := f.drain(t); len(got) != 2 {
t.Fatalf("change notices=%d", len(got))
}
})
}
}
func TestMailTokenInvalidation(t *testing.T) {
for _, test := range []struct {
name, statement string
advance time.Duration
}{
{"expiry", "", authmail.Lifetime},
{"password-changed", `UPDATE gwf_password_credentials SET password_hash='new-hash'`, 0},
{"email-changed", `UPDATE gwf_users SET email='elsewhere@example.test',email_normalized='elsewhere@example.test'`, 0},
{"profile-revision", `UPDATE gwf_users SET profile_revision=profile_revision+1`, 0},
{"suspended", `UPDATE gwf_users SET status='suspended'`, 0},
{"pending", `UPDATE gwf_users SET registration_pending=1`, 0},
{"session-revoked", `DELETE FROM gwf_auth_sessions`, 0},
{"forced-password-change", `UPDATE gwf_users SET password_change_required=1`, 0},
} {
t.Run(test.name, func(t *testing.T) {
f := accountMailFixture(t, 0)
if err := f.service.RequestVerification(t.Context(), f.session.Digest); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
if test.statement != "" {
if _, err := f.store.db.Exec(test.statement); err != nil {
t.Fatal(err)
}
}
f.clock.advance(test.advance)
if _, err := f.service.Confirm(t.Context(), token); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("invalidated token accepted: %v", err)
}
var count int
if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_verified_emails`).Scan(&count); err != nil || count != 0 {
t.Fatal("invalidated verification wrote state")
}
})
}
}
func TestMailAuditAndOutboxRollback(t *testing.T) {
f := accountMailFixture(t, 1)
if _, err := f.store.db.Exec(`CREATE TRIGGER reject_mail_audit BEFORE INSERT ON gwf_audit_events WHEN NEW.action LIKE 'auth.mail.%' BEGIN SELECT RAISE(ABORT,'fixture audit failure'); END`); err != nil {
t.Fatal(err)
}
if err := f.service.RequestVerification(t.Context(), f.session.Digest); err == nil {
t.Fatal("audit failure ignored")
}
for _, table := range []string{"gwf_account_mail_requests", "gwf_account_mail_limits", "gwf_mail_outbox"} {
var count int
if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil || count != 0 {
t.Fatalf("%s not rolled back", table)
}
}
if _, err := f.store.db.Exec(`DROP TRIGGER reject_mail_audit`); err != nil {
t.Fatal(err)
}
f.verify(t)
if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
if err := f.queue.Enqueue(t.Context(), mail.Message{ID: "mail_capacity_fixture", To: "other@example.test", Subject: "Fixture", Text: "Unrelated message", CreatedAt: f.clock.now()}, f.clock.now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
if err := f.service.ResetPassword(t.Context(), token, "a different safe password"); !errors.Is(err, mailsqlite.ErrFull) {
t.Fatalf("outbox failure: %v", err)
}
_, hash, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || !auth.VerifyPassword(hash, mailPassword) {
t.Fatal("password changed without committed notification")
}
if _, err = f.service.Inspect(t.Context(), token); err != nil {
t.Fatal("token lost on rollback", err)
}
if _, _, err = f.store.PrincipalBySession(t.Context(), f.session.Digest, f.clock.now()); err != nil {
t.Fatal("session lost on rollback", err)
}
f.drain(t)
if err = f.service.ResetPassword(t.Context(), token, "a different safe password"); err != nil {
t.Fatal("retry after queue recovery", err)
}
}
func TestMailConcurrentConsumeAndRateLimit(t *testing.T) {
f := accountMailFixture(t, 0)
f.verify(t)
if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
var group sync.WaitGroup
var accepted, rejected atomic.Int32
for range 2 {
group.Go(func() {
err := f.service.ResetPassword(context.Background(), token, "a different safe password")
if err == nil {
accepted.Add(1)
} else if errors.Is(err, authmail.ErrUnavailable) {
rejected.Add(1)
} else {
t.Errorf("concurrent consume: %v", err)
}
})
}
group.Wait()
if accepted.Load() != 1 || rejected.Load() != 1 {
t.Fatalf("accepted=%d rejected=%d", accepted.Load(), rejected.Load())
}
g := accountMailFixture(t, 0)
var firstToken string
for request := range 5 {
if err := g.service.RequestVerification(t.Context(), g.session.Digest); err != nil {
t.Fatal(err)
}
messages := g.drain(t)
if request == 0 {
firstToken = mailToken(t, messages[0])
}
if err := g.service.RequestVerification(t.Context(), g.session.Digest); !errors.Is(err, authmail.ErrLimited) {
t.Fatalf("burst limit: %v", err)
}
g.clock.advance(time.Minute)
}
if err := g.service.RequestVerification(t.Context(), g.session.Digest); !errors.Is(err, authmail.ErrLimited) {
t.Fatalf("hour limit: %v", err)
}
if _, err := g.service.Inspect(t.Context(), firstToken); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatal("replaced link survived")
}
g.clock.advance(time.Hour)
if err := g.service.RequestVerification(t.Context(), g.session.Digest); err != nil {
t.Fatal("rate window did not recover", err)
}
}
func TestMailRestartAndAddressCollision(t *testing.T) {
f := accountMailFixture(t, 0)
if err := f.service.RequestChange(t.Context(), f.session.Digest, mailPassword, "new@example.test"); err != nil {
t.Fatal(err)
}
messages := f.drain(t)
first, second := mailToken(t, messages[0]), mailToken(t, messages[1])
if _, err := f.service.Confirm(t.Context(), first); err != nil {
t.Fatal(err)
}
other := f.user
other.ID = "another_mail_user"
other.Username = "another.reader"
other.Email = "new@example.test"
hash, _ := accountMailHash()
if err := f.store.CreateUser(t.Context(), other, hash); err != nil {
t.Fatal(err)
}
reopened, err := OpenWithOptions(f.path, OpenOptions{Migrate: false})
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if err = reopened.RequireMailSchema(t.Context()); err != nil {
t.Fatal(err)
}
repo, _, err := reopened.AccountMail(mailsqlite.Options{EncryptionKey: bytes.Repeat([]byte{31}, 32), Now: f.clock.now})
if err != nil {
t.Fatal(err)
}
service, err := authmail.New(repo, mailConfig(f.clock))
if err != nil {
t.Fatal(err)
}
if _, err = service.Confirm(t.Context(), second); !errors.Is(err, authmail.ErrAddressUnavailable) {
t.Fatalf("collision not rechecked: %v", err)
}
if _, err = service.Inspect(t.Context(), second); err != nil {
t.Fatal("collision consumed token", err)
}
user, _, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || user.Email != f.user.Email {
t.Fatal("collision overwrote canonical email")
}
var version int
if version, err = f.store.CurrentSchema(t.Context()); err != nil || version != 11 {
t.Fatalf("base schema changed: %d %v", version, err)
}
}
func TestMailCommitUsesFreshTimeAndCredentials(t *testing.T) {
for _, change := range []string{"expiry", "credential"} {
t.Run(change, func(t *testing.T) {
f := accountMailFixture(t, 0)
f.verify(t)
if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
config := mailConfig(f.clock)
config.Compose = func(intent authmail.MailIntent) (authmail.MessageContent, error) {
if intent.Kind == "password-changed" {
if change == "expiry" {
f.clock.advance(authmail.Lifetime)
} else {
if _, err := f.store.db.Exec(`UPDATE gwf_password_credentials SET password_hash='concurrent-credential'`); err != nil {
t.Fatal(err)
}
}
}
return authmail.DefaultComposer(intent)
}
service, err := authmail.New(f.repo, config)
if err != nil {
t.Fatal(err)
}
if err = service.ResetPassword(t.Context(), token, "a different safe password"); !errors.Is(err, authmail.ErrUnavailable) {
t.Fatalf("stale completion: %v", err)
}
_, hash, err := f.store.CredentialByUserID(t.Context(), f.user.ID)
if err != nil || auth.VerifyPassword(hash, "a different safe password") {
t.Fatal("stale completion changed password")
}
if got := f.drain(t); len(got) != 0 {
t.Fatal("uncommitted reset sent notice")
}
})
}
}
func TestMailMigrationRollbackAndMixedCaseLegacyAddress(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "rollback.sqlite"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
if _, err = store.db.Exec(`CREATE TABLE gwf_verified_emails(conflicting_schema TEXT)`); err != nil {
t.Fatal(err)
}
if err = store.MigrateMail(t.Context()); err == nil {
t.Fatal("schema conflict ignored")
}
var count int
if err = store.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE name IN ('gwf_mail_outbox','gwf_account_mail_migrations')`).Scan(&count); err != nil || count != 0 {
t.Fatal("partial mail migration remained")
}
if err = store.RequireCurrentSchema(t.Context()); err != nil {
t.Fatal("base schema damaged", err)
}
f := accountMailFixture(t, 0)
if _, err = f.store.db.Exec(`UPDATE gwf_users SET email='READER@Example.Test'`); err != nil {
t.Fatal(err)
}
f.verify(t)
if err = f.service.RequestReset(t.Context(), "Reader@Example.Test"); err != nil {
t.Fatal(err)
}
token := mailToken(t, f.drain(t)[0])
if err = f.service.ResetPassword(t.Context(), token, "a different safe password"); err != nil {
t.Fatal("legacy address reset", err)
}
if notices := f.drain(t); len(notices) != 1 || notices[0].To != "reader@example.test" {
t.Fatal("canonical notice recipient")
}
}
+6
View File
@@ -17,6 +17,12 @@ access <──────────────────── authsqlite
An ordinary `net/http` application composes whichever branches it needs. An ordinary `net/http` application composes whichever branches it needs.
Optional transactional email adds `authmail``auth`/`mail`, with `authsqlite`
implementing its storage protocol and using `mailsqlite` for atomic encrypted
outbox writes. `mail` is standard-library-only; no broker or hosted mail service
is required. Applications own recipient authority, trusted link origins, forms,
SMTP configuration and worker scheduling. See the [protocol guide](../authmail/README.md).
Packages never own application routes, templates, authorization policy, cache Packages never own application routes, templates, authorization policy, cache
policy, or deployment. Middleware communicates through typed request context. policy, or deployment. Middleware communicates through typed request context.
Storage and reporting surfaces are interfaces so an application can retain its Storage and reporting surfaces are interfaces so an application can retain its
+17
View File
@@ -59,6 +59,23 @@ configured reverse proxy, authorize application routes automatically, encrypt a
compromised host, or decide how long an operator may lawfully retain personal compromised host, or decide how long an operator may lawfully retain personal
request evidence. request evidence.
Optional account mail treats email links as bearer secrets, not sessions or MFA.
`authmail` binds random single-use digests to account identity, purpose, current
credential and expiry. Address changes additionally require current password,
session and both mailboxes. Reset requires an already verified current mailbox;
success creates no login and removes no enrolled factor. The SQLite adapter
atomically rechecks authority, revokes sessions/grants and queues notices with
the audit. Existing ownership and financial records are not reassigned.
SMTP authenticates only after verified TLS with no plaintext fallback. Encrypted
outbox payloads require an application-owned key, which may itself be wrapped in
SQLite only when its external wrapping key is kept separately. Compromise of
the sender, recipient mailbox or application host remains a threat. SMTP DATA
acceptance is not inbox delivery and crash retries are not exactly once. Forms,
CSRF/origin enforcement, fragment-to-POST handling, IP/concurrency limits and
non-enumerating responses remain explicit consumer responsibilities; see the
[integration boundaries](../authmail/README.md).
Applications must pass the authenticated user and requested resource hierarchy Applications must pass the authenticated user and requested resource hierarchy
to `access.Authorize`; possessing a platform-level `auth` role does not bypass to `access.Authorize`; possessing a platform-level `auth` role does not bypass
that decision. Team membership is resolved by the repository rather than that decision. Team membership is resolved by the repository rather than
+32
View File
@@ -0,0 +1,32 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Transactional mail transport
`mail.Message` is one bounded UTF-8 plain-text message with a stable ID/date and
one ASCII envelope recipient. `NewSMTP` binds a fixed, configured sender and
dedicated SMTP credentials. Use implicit TLS (normally465) or required STARTTLS
(normally587). Certificate chain and hostname verification are mandatory; no
plaintext fallback or caller-provided arbitrary headers/attachments exist.
The standard-library transport uses a bounded connection deadline and cancellation
across TLS and SMTP. Message IDs survive retries. Errors expose only a fixed
stage, numeric SMTP code and retry classification; raw server responses are not
propagated because they can contain addresses or credentials. Successful DATA
acceptance is success even if QUIT fails. A connection failure around acceptance
can still produce duplicate delivery on retry: SMTP is not exactly-once transport,
and acceptance is not proof of inbox placement.
Applications own authorized recipients, email templates, trusted HTTPS link
origins, rate limits, encrypted persistence and the worker. Never log message
content or serialize credentials into ordinary diagnostics. Debug string methods
redact sensitive content but are not a substitute for safe logging policy.
No account verification/reset protocol is implemented by this transport itself.
Verified locally with disposable SMTP servers: implicit/STARTTLS delivery,
certificate/hostname rejection, no-downgrade behavior, auth/recipient error
classification, cancellation after connection, MIME round-trip, injection/bounds
and acceptance followed by QUIT failure. `go test -race ./mail` and `go vet ./mail`
pass on Go1.26.6. No real credentials, message delivery, public package release or
consumer deployment are claimed. The [encrypted outbox](../mailsqlite/README.md)
adds transactional persistence; [authmail](../authmail/README.md) supplies optional
account protocols with consumer-owned forms and authorization boundaries.
+107
View File
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: MPL-2.0
// Package mail provides bounded, single-recipient transactional messages.
// Applications own authorization, templates, trusted link origins and consent.
package mail
import (
"bytes"
"context"
"errors"
"fmt"
"mime"
"mime/quotedprintable"
stdmail "net/mail"
"regexp"
"strings"
"time"
"unicode"
"unicode/utf8"
)
const MaxTextBytes = 64 << 10
var ErrInvalid = errors.New("mail: invalid message or configuration")
var messageIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{15,63}$`)
// Message has no caller-controlled From, arbitrary headers or recipient list.
// ID and CreatedAt stay stable across delivery retries. Treat Text as sensitive:
// never log it, and encrypt it when persisted (it may contain a recovery link).
type Message struct {
ID, To, Subject, Text string
CreatedAt time.Time
}
// String and GoString avoid accidental plaintext recovery-link logging during
// debugging. Structured serializers still require the same care as any secret.
func (Message) String() string { return "mail.Message{content:redacted}" }
func (message Message) GoString() string { return message.String() }
type Transport interface {
// Send returns nil after the SMTP server accepts DATA, not after inbox delivery.
// Implementations must honor context cancellation and deadlines. The outbox
// relies on this bound to finish transmission before its worker lease expires.
Send(context.Context, Message) error
}
func (message Message) Validate() error {
if !messageIDPattern.MatchString(message.ID) || !validMailbox(message.To) ||
!headerText(message.Subject, 256) || message.CreatedAt.Year() < 1970 || message.CreatedAt.Year() > 9999 ||
len(message.Text) == 0 || len(message.Text) > MaxTextBytes || !utf8.ValidString(message.Text) {
return ErrInvalid
}
for _, r := range message.Text {
if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' {
return ErrInvalid
}
}
return nil
}
func headerText(value string, maximum int) bool {
if strings.TrimSpace(value) == "" || len(value) > maximum || !utf8.ValidString(value) {
return false
}
for _, r := range value {
if unicode.IsControl(r) {
return false
}
}
return true
}
// This transport uses ASCII envelope addresses, not SMTPUTF8. Display names and
// subjects may be UTF-8; MIME encodes them. The application owns normalization.
func validMailbox(value string) bool {
if len(value) > 254 || !headerText(value, 254) {
return false
}
for _, r := range value {
if r > 127 {
return false
}
}
parsed, err := stdmail.ParseAddress(value)
return err == nil && parsed.Name == "" && parsed.Address == value && strings.Contains(value, "@")
}
func encode(message Message, from *stdmail.Address) ([]byte, error) {
if err := message.Validate(); err != nil {
return nil, err
}
var buffer bytes.Buffer
domain := from.Address[strings.LastIndexByte(from.Address, '@')+1:]
fmt.Fprintf(&buffer, "From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: <%s@%s>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n", from.String(), (&stdmail.Address{Address: message.To}).String(), mime.QEncoding.Encode("UTF-8", message.Subject), message.CreatedAt.UTC().Format(time.RFC1123Z), message.ID, domain)
body := strings.ReplaceAll(strings.ReplaceAll(message.Text, "\r\n", "\n"), "\r", "\n")
writer := quotedprintable.NewWriter(&buffer)
if _, err := writer.Write([]byte(body)); err != nil {
return nil, err
}
if err := writer.Close(); err != nil {
return nil, err
}
if !bytes.HasSuffix(buffer.Bytes(), []byte("\r\n")) {
buffer.WriteString("\r\n")
}
return buffer.Bytes(), nil
}
+354
View File
@@ -0,0 +1,354 @@
// SPDX-License-Identifier: MPL-2.0
package mail
import (
"bufio"
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"errors"
"fmt"
"io"
"math/big"
"mime"
"mime/quotedprintable"
"net"
stdmail "net/mail"
"net/textproto"
"strconv"
"strings"
"testing"
"time"
)
func sampleMessage() Message {
return Message{ID: "mail_fixture_1234567890", To: "reader@example.test", Subject: "Your account — a little care ♥", Text: "Hello!\nConfirm your email: https://example.test/verify/?token=disposable\n.One line with a dot.\n", CreatedAt: time.Date(2026, 9, 11, 5, 0, 0, 0, time.UTC)}
}
func TestMessageEncodingAndBounds(t *testing.T) {
from := &stdmail.Address{Name: "Cole ♥", Address: "support@example.test"}
message := sampleMessage()
if strings.Contains(fmt.Sprintf("%+v %#v", message, message), "disposable") {
t.Fatal("message debug output exposed a token")
}
encoded, err := encode(message, from)
if err != nil {
t.Fatal(err)
}
parsed, err := stdmail.ReadMessage(bytes.NewReader(encoded))
if err != nil {
t.Fatal(err)
}
subject, err := (&mime.WordDecoder{}).DecodeHeader(parsed.Header.Get("Subject"))
if err != nil || subject != message.Subject {
t.Fatal("subject did not round-trip")
}
if parsed.Header.Get("Message-ID") != "<"+message.ID+"@example.test>" {
t.Fatal("unstable message ID")
}
if _, err = parsed.Header.Date(); err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(quotedprintable.NewReader(parsed.Body))
if err != nil || strings.ReplaceAll(string(body), "\r\n", "\n") != message.Text {
t.Fatal("body did not round-trip")
}
for _, line := range strings.Split(string(encoded), "\r\n") {
if len(line) > 998 || strings.ContainsRune(line, '\n') {
t.Fatal("invalid MIME line")
}
}
for _, mutate := range []func(*Message){
func(m *Message) { m.ID = "bad\r\nID" }, func(m *Message) { m.To = "a@example.test,b@example.test" }, func(m *Message) { m.To = "Reader <reader@example.test>" },
func(m *Message) { m.To = "\"line\r\nbreak\"@example.test" }, func(m *Message) { m.To = "élève@example.test" }, func(m *Message) { m.Subject = "Subject\r\nBcc: other@example.test" },
func(m *Message) { m.Subject = strings.Repeat("a", 257) }, func(m *Message) { m.Text = strings.Repeat("x", MaxTextBytes+1) }, func(m *Message) { m.Text = "bad\x00body" },
func(m *Message) { m.Text = string([]byte{0xff}) }, func(m *Message) { m.CreatedAt = time.Time{} },
} {
bad := sampleMessage()
mutate(&bad)
if _, err := encode(bad, from); !errors.Is(err, ErrInvalid) {
t.Fatal("invalid message accepted")
}
}
}
type smtpFixtureOptions struct {
mode TLSMode
noSTARTTLS, rejectAuth, rejectRecipient, temporary, closeAfterAccept, stall bool
connected chan struct{}
}
type smtpCapture struct {
commands []string
authenticatedTLS bool
sender, recipient, body string
accepted bool
}
func smtpFixture(t *testing.T, options smtpFixtureOptions) (SMTPConfig, <-chan smtpCapture) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
now := time.Now()
template := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "localhost"}, DNSNames: []string{"localhost"}, NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
certificate, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AddCert(certificate)
tlsConfig := &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, MinVersion: tls.VersionTLS12}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { listener.Close() })
_, portText, _ := net.SplitHostPort(listener.Addr().String())
port, _ := strconv.Atoi(portText)
result := make(chan smtpCapture, 1)
go func() {
capture := smtpCapture{}
defer func() { result <- capture }()
connection, err := listener.Accept()
if err != nil {
return
}
defer connection.Close()
if options.connected != nil {
close(options.connected)
}
_ = connection.SetDeadline(time.Now().Add(5 * time.Second))
secure := options.mode == ImplicitTLS
if secure {
wire := tls.Server(connection, tlsConfig)
if wire.Handshake() != nil {
return
}
connection = wire
}
protocol := textproto.NewConn(connection)
if options.stall {
_, _ = protocol.ReadLine()
return
}
if protocol.PrintfLine("220 localhost test mail") != nil {
return
}
for {
line, err := protocol.ReadLine()
if err != nil {
return
}
verb, _, _ := strings.Cut(line, " ")
capture.commands = append(capture.commands, verb)
switch verb {
case "EHLO":
if !secure && !options.noSTARTTLS {
err = protocol.PrintfLine("250-localhost\r\n250 STARTTLS")
} else {
err = protocol.PrintfLine("250-localhost\r\n250 AUTH PLAIN")
}
case "STARTTLS":
if protocol.PrintfLine("220 Ready for TLS") != nil {
return
}
wire := tls.Server(connection, tlsConfig)
if wire.Handshake() != nil {
return
}
connection = wire
protocol = textproto.NewConn(connection)
secure = true
case "AUTH":
capture.authenticatedTLS = secure
if line != "AUTH PLAIN "+base64.StdEncoding.EncodeToString([]byte("\x00app-sender\x00fixture-password")) || options.rejectAuth {
err = protocol.PrintfLine("535 credential-detail-must-not-leak")
} else {
err = protocol.PrintfLine("235 Authenticated")
}
case "MAIL":
capture.sender = strings.TrimPrefix(line, "MAIL FROM:")
err = protocol.PrintfLine("250 Sender accepted")
case "RCPT":
capture.recipient = strings.TrimPrefix(line, "RCPT TO:")
if options.rejectRecipient {
code := 550
if options.temporary {
code = 450
}
err = protocol.PrintfLine("%d recipient-detail-must-not-leak", code)
} else {
err = protocol.PrintfLine("250 Recipient accepted")
}
case "DATA":
if protocol.PrintfLine("354 Send message") != nil {
return
}
body, readErr := io.ReadAll(io.LimitReader(protocol.DotReader(), MaxTextBytes*2))
if readErr != nil {
return
}
capture.body = string(body)
capture.accepted = true
err = protocol.PrintfLine("250 Accepted")
if options.closeAfterAccept {
return
}
case "QUIT":
_ = protocol.PrintfLine("221 Goodbye")
return
default:
err = protocol.PrintfLine("500 Unexpected command")
}
if err != nil {
return
}
}
}()
return SMTPConfig{Host: "localhost", Port: port, TLSMode: options.mode, Username: "app-sender", Password: "fixture-password", From: "Cole <support@example.test>", Timeout: 3 * time.Second, RootCAs: roots}, result
}
func takeCapture(t *testing.T, result <-chan smtpCapture) smtpCapture {
t.Helper()
select {
case capture := <-result:
return capture
case <-time.After(6 * time.Second):
t.Fatal("SMTP fixture did not finish")
return smtpCapture{}
}
}
func TestSMTPSecureDelivery(t *testing.T) {
for _, mode := range []TLSMode{ImplicitTLS, RequiredSTARTTLS} {
for _, closeAfterAccept := range []bool{false, true} {
t.Run(string(mode)+"/quit="+strconv.FormatBool(closeAfterAccept), func(t *testing.T) {
config, result := smtpFixture(t, smtpFixtureOptions{mode: mode, closeAfterAccept: closeAfterAccept})
transport, err := NewSMTP(config)
if err != nil {
t.Fatal(err)
}
if err = transport.Send(t.Context(), sampleMessage()); err != nil {
t.Fatal(err)
}
capture := takeCapture(t, result)
if !capture.authenticatedTLS || !capture.accepted || capture.sender != "<support@example.test>" || capture.recipient != "<reader@example.test>" {
t.Fatal("SMTP identity or TLS boundary failed")
}
message, err := stdmail.ReadMessage(bufio.NewReader(strings.NewReader(capture.body)))
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(quotedprintable.NewReader(message.Body))
if err != nil || strings.ReplaceAll(string(body), "\r\n", "\n") != sampleMessage().Text {
t.Fatal("SMTP body changed")
}
})
}
}
}
func TestSMTPRejectsDowngradeCertificatesAndProviderErrors(t *testing.T) {
for _, tc := range []struct {
name string
options smtpFixtureOptions
untrusted, wrongHost bool
stage string
code int
retry bool
}{
{name: "no STARTTLS", options: smtpFixtureOptions{mode: RequiredSTARTTLS, noSTARTTLS: true}, stage: "starttls-required"},
{name: "untrusted implicit TLS", options: smtpFixtureOptions{mode: ImplicitTLS}, untrusted: true, stage: "tls"},
{name: "untrusted STARTTLS", options: smtpFixtureOptions{mode: RequiredSTARTTLS}, untrusted: true, stage: "tls"},
{name: "wrong hostname", options: smtpFixtureOptions{mode: ImplicitTLS}, wrongHost: true, stage: "tls"},
{name: "bad credential", options: smtpFixtureOptions{mode: ImplicitTLS, rejectAuth: true}, stage: "authentication", code: 535},
{name: "bad recipient", options: smtpFixtureOptions{mode: ImplicitTLS, rejectRecipient: true}, stage: "recipient", code: 550},
{name: "temporary recipient", options: smtpFixtureOptions{mode: ImplicitTLS, rejectRecipient: true, temporary: true}, stage: "recipient", code: 450, retry: true},
} {
t.Run(tc.name, func(t *testing.T) {
config, result := smtpFixture(t, tc.options)
if tc.untrusted {
config.RootCAs = x509.NewCertPool()
}
if tc.wrongHost {
config.Host = "127.0.0.1"
}
transport, err := NewSMTP(config)
if err != nil {
t.Fatal(err)
}
err = transport.Send(t.Context(), sampleMessage())
var failure *Error
if !errors.As(err, &failure) || failure.Stage != tc.stage || failure.Code != tc.code || failure.Retryable != tc.retry {
t.Fatalf("unexpected safe failure: %v", err)
}
if strings.Contains(err.Error(), "must-not-leak") || strings.Contains(err.Error(), "fixture-password") || strings.Contains(err.Error(), "reader@example.test") {
t.Fatal("SMTP response leaked")
}
capture := takeCapture(t, result)
if capture.accepted {
t.Fatal("rejected delivery accepted DATA")
}
if tc.code == 0 && capture.authenticatedTLS {
t.Fatal("authenticated despite failed secure negotiation")
}
})
}
}
func TestSMTPCancellationAndInvalidInput(t *testing.T) {
connected := make(chan struct{})
config, result := smtpFixture(t, smtpFixtureOptions{mode: RequiredSTARTTLS, stall: true, connected: connected})
if strings.Contains(fmt.Sprintf("%+v %#v", config, config), "fixture-password") {
t.Fatal("configuration debug output exposed a credential")
}
transport, err := NewSMTP(config)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
completed := make(chan error, 1)
go func() { completed <- transport.Send(ctx, sampleMessage()) }()
select {
case <-connected:
case <-time.After(5 * time.Second):
t.Fatal("fixture connection missing")
}
cancel()
select {
case err = <-completed:
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation error=%v", err)
}
case <-time.After(time.Second):
t.Fatal("SMTP cancellation did not stop promptly")
}
if capture := takeCapture(t, result); capture.accepted {
t.Fatal("cancelled send accepted")
}
for _, mutate := range []func(*SMTPConfig){func(c *SMTPConfig) { c.TLSMode = "plaintext" }, func(c *SMTPConfig) { c.Host = "bad\nserver" }, func(c *SMTPConfig) { c.From = "a@example.test,b@example.test" }, func(c *SMTPConfig) { c.Password = "" }, func(c *SMTPConfig) { c.Port = 0 }, func(c *SMTPConfig) { c.Timeout = time.Hour }} {
bad := config
mutate(&bad)
if _, err := NewSMTP(bad); !errors.Is(err, ErrInvalid) {
t.Fatal("invalid SMTP config accepted")
}
}
bad := sampleMessage()
bad.To = "invalid"
if err := transport.Send(t.Context(), bad); !errors.Is(err, ErrInvalid) {
t.Fatal("invalid message reached transport")
}
}
+173
View File
@@ -0,0 +1,173 @@
// SPDX-License-Identifier: MPL-2.0
package mail
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
stdmail "net/mail"
"net/smtp"
"net/textproto"
"strconv"
"strings"
"time"
)
type TLSMode string
const (
ImplicitTLS TLSMode = "implicit"
RequiredSTARTTLS TLSMode = "starttls"
)
// SMTPConfig is trusted server configuration, never an HTTP payload. Keep its
// credentials in private operator-managed storage and do not log this value.
type SMTPConfig struct {
Host string
Port int
TLSMode TLSMode
Username, Password, From string
Timeout time.Duration
// RootCAs supplies an explicit trust pool (for example private PKI).
// Nil uses system roots. Hostname verification cannot be disabled.
RootCAs *x509.CertPool
}
func (SMTPConfig) String() string { return "mail.SMTPConfig{credentials:redacted}" }
func (config SMTPConfig) GoString() string { return config.String() }
type SMTP struct {
address, host, username, password string
from *stdmail.Address
tlsMode TLSMode
timeout time.Duration
rootCAs *x509.CertPool
}
func (*SMTP) String() string { return "mail.SMTP{credentials:redacted}" }
func (transport *SMTP) GoString() string { return transport.String() }
// Error contains only a fixed stage and numeric SMTP code, never the server's
// response (which can echo a mailbox, authentication token or message content).
type Error struct {
Stage string
Code int
Retryable bool
}
func (err *Error) Error() string {
return fmt.Sprintf("mail: SMTP %s failed (code %d)", err.Stage, err.Code)
}
func NewSMTP(config SMTPConfig) (*SMTP, error) {
if config.Timeout == 0 {
config.Timeout = 15 * time.Second
}
if !headerText(config.Host, 253) || strings.ContainsAny(config.Host, " \t\r\n/@[]\\") || config.Port < 1 || config.Port > 65535 ||
(config.TLSMode != ImplicitTLS && config.TLSMode != RequiredSTARTTLS) || config.Timeout < time.Second || config.Timeout > 30*time.Second ||
!headerText(config.Username, 254) || config.Password == "" || len(config.Password) > 4096 || strings.ContainsAny(config.Password, "\x00\r\n") || !headerText(config.From, 320) {
return nil, ErrInvalid
}
from, err := stdmail.ParseAddress(config.From)
if err != nil || !validMailbox(from.Address) || from.Name != "" && !headerText(from.Name, 128) {
return nil, ErrInvalid
}
var roots *x509.CertPool
if config.RootCAs != nil {
roots = config.RootCAs.Clone()
}
return &SMTP{address: net.JoinHostPort(config.Host, strconv.Itoa(config.Port)), host: config.Host, username: config.Username, password: config.Password, from: from, tlsMode: config.TLSMode, timeout: config.Timeout, rootCAs: roots}, nil
}
func failure(ctx context.Context, stage string, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
result := &Error{Stage: stage, Retryable: true}
var protocol *textproto.Error
if errors.As(err, &protocol) {
result.Code = protocol.Code
result.Retryable = protocol.Code >= 400 && protocol.Code < 500
}
var unknownAuthority x509.UnknownAuthorityError
var hostname x509.HostnameError
var invalidCertificate x509.CertificateInvalidError
if errors.As(err, &unknownAuthority) || errors.As(err, &hostname) || errors.As(err, &invalidCertificate) {
result.Retryable = false
}
return result
}
// Send never falls back to plaintext, even on localhost. Cancellation closes the
// connection and every protocol step shares one bounded deadline. SMTP can accept
// DATA just before a connection failure; retrying that uncertainty may duplicate
// mail. A failure of QUIT after DATA acceptance does not trigger another send.
func (transport *SMTP) Send(ctx context.Context, message Message) error {
if transport == nil {
return ErrInvalid
}
payload, err := encode(message, transport.from)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, transport.timeout)
defer cancel()
connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", transport.address)
if err != nil {
return failure(ctx, "connect", err)
}
defer connection.Close()
stop := context.AfterFunc(ctx, func() { _ = connection.Close() })
defer stop()
deadline, _ := ctx.Deadline()
if err = connection.SetDeadline(deadline); err != nil {
return failure(ctx, "connect", err)
}
tlsConfig := &tls.Config{ServerName: transport.host, MinVersion: tls.VersionTLS12, RootCAs: transport.rootCAs}
var wire net.Conn = connection
if transport.tlsMode == ImplicitTLS {
secure := tls.Client(connection, tlsConfig)
if err = secure.HandshakeContext(ctx); err != nil {
return failure(ctx, "tls", err)
}
wire = secure
}
client, err := smtp.NewClient(wire, transport.host)
if err != nil {
return failure(ctx, "greeting", err)
}
defer client.Close()
if transport.tlsMode == RequiredSTARTTLS {
if ok, _ := client.Extension("STARTTLS"); !ok {
return &Error{Stage: "starttls-required"}
}
if err = client.StartTLS(tlsConfig); err != nil {
return failure(ctx, "tls", err)
}
}
if err = client.Auth(smtp.PlainAuth("", transport.username, transport.password, transport.host)); err != nil {
return failure(ctx, "authentication", err)
}
if err = client.Mail(transport.from.Address); err != nil {
return failure(ctx, "sender", err)
}
if err = client.Rcpt(message.To); err != nil {
return failure(ctx, "recipient", err)
}
data, err := client.Data()
if err != nil {
return failure(ctx, "data", err)
}
if _, err = data.Write(payload); err != nil {
return failure(ctx, "data", err)
}
if err = data.Close(); err != nil {
return failure(ctx, "acceptance", err)
}
_ = client.Quit()
return nil
}
+47
View File
@@ -0,0 +1,47 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Encrypted transactional outbox
`mailsqlite.Queue` stores a single-recipient `mail.Message` as authenticated
AES-256-GCM ciphertext. Use a separate 32-byte application-managed secret, kept
out of source control and logs; back it up separately. It may be wrapped in SQLite
only if the wrapping key stays outside the database and is backed up separately.
Identity and expiry
are bound to the ciphertext. Key-derived HMACs support idempotency without storing
plaintext message hashes. Losing the key loses pending message contents.
Call `CreateSchema` inside the application's explicit, versioned migration.
`EnqueueTx` joins a caller-owned transaction, allowing account changes, audit and
mail intent to commit or roll back together. `Enqueue` is a convenience for a
standalone transaction. A message ID belongs to exactly one message/expiry, even
after its payload is cleared. Do not use the queue to authorize recipients.
`ProcessOne` commits a one-minute claim before calling the transport; it never
holds a database writer lock over SMTP. Transports must honor the supplied
deadline (at most 30 seconds). A stale worker cannot acknowledge a newer lease.
Retryable failures back off for 1, 2, 4 and 8 minutes, up to five attempts, only
while the message is valid. Application workers own scheduling and shutdown.
An incorrect key or corrupt payload never reaches SMTP. Such work retains its
ciphertext and retries decoding after five minutes without consuming a delivery
attempt. Restoring the correct key before expiry can recover pending messages.
This is not transparent key rotation: drain the old queue or provide an explicit
migration before changing keys.
Payloads expire within 24 hours and are cleared after terminal delivery results
or expiry. Run `Sweep` periodically even when sending is disabled; each call is
bounded to 100 records. Pending capacity defaults to 1,000 (maximum 10,000).
Safe metadata/deduplication tombstones remain; applications own any later bounded
retention policy and must not reuse purged IDs. Never expose `Recent` publicly.
SMTP acceptance is not inbox delivery. A crash or lost acknowledgement can cause
a retry after the remote server accepted DATA. Stable Message-ID helps diagnose
duplicates but cannot make SMTP exactly-once. Do not use this queue for payments
or another external operation requiring an exactly-once commitment.
Local Go/race/vet tests cover encryption and identity binding, domain rollback,
idempotency, concurrent capacity/claims, lock-free network waits, stale workers,
cancelled acknowledgements, retry bounds, safe diagnostics, expiry and wrong-key
recovery. No real SMTP credential, provider delivery or consumer deployment is
claimed here. [Account verification/reset protocols](../authmail/README.md) are a
separate optional layer, not behavior inferred by the queue.
+334
View File
@@ -0,0 +1,334 @@
// SPDX-License-Identifier: MPL-2.0
// Package mailsqlite provides an encrypted transactional mail outbox. Callers
// own schema-version journals, authorization, rate limits and connection opening.
package mailsqlite
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"time"
"gamertan.com/web/mail"
)
var (
ErrInvalid = errors.New("mailsqlite: invalid outbox operation")
ErrConflict = errors.New("mailsqlite: message identity or lease conflict")
ErrFull = errors.New("mailsqlite: pending outbox limit reached")
)
const maxAttempts = 5
const leaseDuration = time.Minute
type Options struct {
// EncryptionKey is a separate 32-byte application-managed secret. Back it up
// separately from SQLite. Losing/changing it makes pending messages unreadable.
EncryptionKey []byte
MaxPending int
Now func() time.Time
}
type Queue struct {
db *sql.DB
aead cipher.AEAD
digestKey []byte
maxPending int
now func() time.Time
}
func (*Queue) String() string { return "mailsqlite.Queue{keys:redacted}" }
func (queue *Queue) GoString() string { return queue.String() }
func derive(key []byte, label string) []byte {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(label))
return mac.Sum(nil)
}
func New(db *sql.DB, options Options) (*Queue, error) {
if db == nil || len(options.EncryptionKey) != 32 {
return nil, ErrInvalid
}
if options.MaxPending == 0 {
options.MaxPending = 1000
}
if options.MaxPending < 1 || options.MaxPending > 10000 {
return nil, ErrInvalid
}
if options.Now == nil {
options.Now = time.Now
}
block, err := aes.NewCipher(derive(options.EncryptionKey, "gwf.mail.encryption.v1"))
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &Queue{db: db, aead: aead, digestKey: derive(options.EncryptionKey, "gwf.mail.deduplication.v1"), maxPending: options.MaxPending, now: options.Now}, nil
}
// CreateSchema is called inside the application's explicit migration, never
// request startup. A caller-owned transaction also makes schema creation atomic.
func CreateSchema(ctx context.Context, tx *sql.Tx) error {
if tx == nil {
return ErrInvalid
}
for _, statement := range []string{
`CREATE TABLE IF NOT EXISTS gwf_mail_outbox (
id TEXT PRIMARY KEY,state TEXT NOT NULL CHECK(state IN ('queued','sending','accepted','failed','expired')),
payload BLOB,digest BLOB NOT NULL CHECK(length(digest)=32),created_at INTEGER NOT NULL,expires_at INTEGER NOT NULL,
next_attempt_at INTEGER NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,lease_hash BLOB,lease_until INTEGER NOT NULL DEFAULT 0,
last_stage TEXT NOT NULL DEFAULT '',smtp_code INTEGER NOT NULL DEFAULT 0,updated_at INTEGER NOT NULL,
CHECK(expires_at>created_at),CHECK(attempts>=0),CHECK(lease_hash IS NULL OR length(lease_hash)=32))`,
`CREATE INDEX IF NOT EXISTS gwf_mail_outbox_ready ON gwf_mail_outbox(state,next_attempt_at,created_at,id)`,
`CREATE INDEX IF NOT EXISTS gwf_mail_outbox_expiry ON gwf_mail_outbox(expires_at)`,
} {
if _, err := tx.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
type payload struct {
Version int
Message mail.Message
ExpiresAt time.Time
}
// EnqueueTx commits mail intent alongside the caller's domain change and audit.
// It never commits, sends SMTP or logs plaintext. Reusing an ID is idempotent only
// for the same message/expiry; a completed ID cannot send a second time.
func (queue *Queue) EnqueueTx(ctx context.Context, tx *sql.Tx, message mail.Message, expiresAt time.Time) error {
if tx == nil || message.Validate() != nil {
return ErrInvalid
}
now := queue.now().UTC()
expiresAt = expiresAt.UTC().Truncate(time.Second)
message.CreatedAt = message.CreatedAt.UTC().Truncate(time.Second)
if !expiresAt.After(now) || expiresAt.Sub(now) > 24*time.Hour || !expiresAt.After(message.CreatedAt) || message.CreatedAt.After(now.Add(time.Minute)) {
return ErrInvalid
}
raw, err := json.Marshal(payload{Version: 1, Message: message, ExpiresAt: expiresAt})
if err != nil {
return err
}
mac := hmac.New(sha256.New, queue.digestKey)
mac.Write(raw)
digest := mac.Sum(nil)
nonce := make([]byte, queue.aead.NonceSize())
if _, err = rand.Read(nonce); err != nil {
return err
}
sealed := append([]byte{1}, nonce...)
sealed = queue.aead.Seal(sealed, nonce, raw, []byte("gwf.mail.v1:"+message.ID))
// First statement acquires SQLite's writer lock, avoiding read/write upgrade
// races when multiple requests enqueue or compete for the bounded capacity.
var inserted string
err = tx.QueryRowContext(ctx, `INSERT INTO gwf_mail_outbox(id,state,payload,digest,created_at,expires_at,next_attempt_at,updated_at)
SELECT ?,'queued',?,?,?,?,?,? WHERE (SELECT COUNT(*) FROM gwf_mail_outbox WHERE payload IS NOT NULL)<?
ON CONFLICT(id) DO NOTHING RETURNING id`, message.ID, sealed, digest, message.CreatedAt.Unix(), expiresAt.Unix(), now.Unix(), now.Unix(), queue.maxPending).Scan(&inserted)
if err == nil {
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
var existing []byte
if err = tx.QueryRowContext(ctx, `SELECT digest FROM gwf_mail_outbox WHERE id=?`, message.ID).Scan(&existing); errors.Is(err, sql.ErrNoRows) {
return ErrFull
} else if err != nil {
return err
}
if !hmac.Equal(existing, digest) {
return ErrConflict
}
return nil
}
func (queue *Queue) Enqueue(ctx context.Context, message mail.Message, expiresAt time.Time) error {
tx, err := queue.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err = queue.EnqueueTx(ctx, tx, message, expiresAt); err != nil {
return err
}
return tx.Commit()
}
// Record is safe operational metadata; it contains no recipient/body/token.
// Expose it only through the application's current operator authority.
type Record struct {
ID, State, FailureStage string
SMTPCode, Attempts int
CreatedAt, ExpiresAt, NextAttemptAt time.Time
}
func (queue *Queue) Recent(ctx context.Context, limit int) ([]Record, error) {
if limit < 1 || limit > 100 {
return nil, ErrInvalid
}
rows, err := queue.db.QueryContext(ctx, `SELECT id,state,last_stage,smtp_code,attempts,created_at,expires_at,next_attempt_at FROM gwf_mail_outbox ORDER BY created_at DESC,id DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []Record
for rows.Next() {
var record Record
var created, expires, next int64
if err = rows.Scan(&record.ID, &record.State, &record.FailureStage, &record.SMTPCode, &record.Attempts, &created, &expires, &next); err != nil {
return nil, err
}
record.CreatedAt = time.Unix(created, 0).UTC()
record.ExpiresAt = time.Unix(expires, 0).UTC()
if next > 0 && record.State == "queued" {
record.NextAttemptAt = time.Unix(next, 0).UTC()
}
records = append(records, record)
}
return records, rows.Err()
}
// Sweep clears sensitive payloads for expired/exhausted work in batches of 100.
// A running, unexpired lease is left alone. Metadata/deduplication tombstones stay.
func (queue *Queue) Sweep(ctx context.Context) error {
now := queue.now().UTC().Unix()
_, err := queue.db.ExecContext(ctx, `UPDATE gwf_mail_outbox SET state=CASE WHEN expires_at<=? THEN 'expired' ELSE 'failed' END,
payload=NULL,lease_hash=NULL,lease_until=0,next_attempt_at=0,updated_at=?,last_stage=CASE WHEN expires_at<=? THEN 'expired' ELSE 'attempt-limit' END
WHERE id IN (SELECT id FROM gwf_mail_outbox WHERE payload IS NOT NULL AND (expires_at<=? OR attempts>=?) AND (state<>'sending' OR lease_until<=?) ORDER BY expires_at,id LIMIT 100)`, now, now, now, now, maxAttempts, now)
return err
}
func (queue *Queue) open(id string, sealed []byte, expires int64) (mail.Message, error) {
n := queue.aead.NonceSize()
if len(sealed) < 1+n+queue.aead.Overhead() || sealed[0] != 1 {
return mail.Message{}, ErrInvalid
}
raw, err := queue.aead.Open(nil, sealed[1:1+n], sealed[1+n:], []byte("gwf.mail.v1:"+id))
if err != nil {
return mail.Message{}, ErrInvalid
}
var body payload
if json.Unmarshal(raw, &body) != nil || body.Version != 1 || body.Message.ID != id || body.ExpiresAt.Unix() != expires || body.Message.Validate() != nil {
return mail.Message{}, ErrInvalid
}
return body.Message, nil
}
// ProcessOne takes one bounded lease, commits it, then contacts SMTP without a
// database write lock. It records safe status and clears terminal payloads. A
// crash/uncertain acceptance can duplicate delivery; stable IDs aid diagnosis.
// false means no ready work. A delivery failure is a Record, not a raw SMTP error.
func (queue *Queue) ProcessOne(ctx context.Context, transport mail.Transport) (Record, bool, error) {
if transport == nil {
return Record{}, false, ErrInvalid
}
if err := queue.Sweep(ctx); err != nil {
return Record{}, false, err
}
now := queue.now().UTC()
lease := make([]byte, 32)
if _, err := rand.Read(lease); err != nil {
return Record{}, false, err
}
var record Record
var sealed []byte
var created, expires int64
err := queue.db.QueryRowContext(ctx, `UPDATE gwf_mail_outbox SET state='sending',attempts=attempts+1,lease_hash=?,lease_until=?,updated_at=?
WHERE id=(SELECT id FROM gwf_mail_outbox WHERE payload IS NOT NULL AND expires_at>? AND next_attempt_at<=? AND attempts<?
AND (state='queued' OR (state='sending' AND lease_until<=?)) ORDER BY created_at,id LIMIT 1)
RETURNING id,payload,attempts,created_at,expires_at`, lease, now.Add(leaseDuration).Unix(), now.Unix(), now.Unix(), now.Unix(), maxAttempts, now.Unix()).Scan(&record.ID, &sealed, &record.Attempts, &created, &expires)
if errors.Is(err, sql.ErrNoRows) {
return Record{}, false, nil
}
if err != nil {
return Record{}, false, err
}
record.CreatedAt = time.Unix(created, 0).UTC()
record.ExpiresAt = time.Unix(expires, 0).UTC()
message, sendErr := queue.open(record.ID, sealed, expires)
payloadInvalid := sendErr != nil
if sendErr == nil {
// End transmission before the lease expires. Also stop when the message's
// own deadline expires; no stale verification link starts after expiry.
duration := min(30*time.Second, record.ExpiresAt.Sub(queue.now().UTC()))
if duration <= 0 {
sendErr = &mail.Error{Stage: "expired"}
} else {
sendContext, cancel := context.WithTimeout(ctx, duration)
sendErr = transport.Send(sendContext, message)
cancel()
}
}
finished := queue.now().UTC()
record.State = "accepted"
if payloadInvalid {
// A wrong key is an operational fault, not a delivery attempt. Preserve
// ciphertext until expiry so fixing configuration can recover pending
// messages. The same bounded delay also covers corrupt/invalid payloads;
// never pass them to SMTP or persist the decryption error.
record.Attempts--
record.State, record.FailureStage = "queued", "payload"
record.NextAttemptAt = finished.Add(5 * time.Minute)
if !record.ExpiresAt.After(finished) {
record.State = "expired"
}
} else if sendErr != nil {
stage, code, retry := "transport", 0, true
var failure *mail.Error
if errors.As(sendErr, &failure) {
stage, code, retry = failure.Stage, failure.Code, failure.Retryable
}
// Only bounded known stages may enter durable diagnostics, even for a
// caller-provided Transport. Never persist arbitrary error strings.
switch stage {
case "connect", "tls", "greeting", "starttls-required", "authentication", "sender", "recipient", "data", "acceptance", "payload", "expired":
default:
stage = "transport"
}
if code < 0 || code > 599 {
code = 0
}
record.FailureStage, record.SMTPCode = stage, code
record.State = "failed"
record.NextAttemptAt = finished.Add(time.Minute * time.Duration(1<<(record.Attempts-1)))
if !record.ExpiresAt.After(finished) || stage == "expired" {
record.State = "expired"
} else if retry && record.Attempts < maxAttempts && record.NextAttemptAt.Before(record.ExpiresAt) {
record.State = "queued"
}
}
var retain []byte
var nextAttempt int64
if record.State == "queued" {
retain = sealed
nextAttempt = record.NextAttemptAt.Unix()
} else {
record.NextAttemptAt = time.Time{}
}
result, err := queue.db.ExecContext(ctx, `UPDATE gwf_mail_outbox SET state=?,payload=?,last_stage=?,smtp_code=?,attempts=?,next_attempt_at=?,lease_hash=NULL,lease_until=0,updated_at=? WHERE id=? AND state='sending' AND lease_hash=? AND lease_until>?`, record.State, retain, record.FailureStage, record.SMTPCode, record.Attempts, nextAttempt, finished.Unix(), record.ID, lease, finished.Unix())
if err != nil {
return record, true, err
}
changed, err := result.RowsAffected()
if err != nil {
return record, true, err
}
if changed != 1 {
return record, true, ErrConflict
}
return record, true, nil
}
+378
View File
@@ -0,0 +1,378 @@
// SPDX-License-Identifier: MPL-2.0
package mailsqlite
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"gamertan.com/web/mail"
_ "modernc.org/sqlite"
)
type testClock struct{ seconds atomic.Int64 }
func (clock *testClock) now() time.Time { return time.Unix(clock.seconds.Load(), 0).UTC() }
func (clock *testClock) advance(duration time.Duration) {
clock.seconds.Add(int64(duration / time.Second))
}
type transportFunc func(context.Context, mail.Message) error
func (send transportFunc) Send(ctx context.Context, message mail.Message) error {
return send(ctx, message)
}
func fixture(t *testing.T, capacity int) (*Queue, *sql.DB, *testClock) {
t.Helper()
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "mail.sqlite"))+"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)")
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(8)
t.Cleanup(func() { db.Close() })
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
if err = CreateSchema(context.Background(), tx); err != nil {
t.Fatal(err)
}
if err = CreateSchema(context.Background(), tx); err != nil {
t.Fatal(err)
}
if err = tx.Commit(); err != nil {
t.Fatal(err)
}
clock := &testClock{}
clock.seconds.Store(time.Date(2026, 9, 11, 6, 0, 0, 0, time.UTC).Unix())
queue, err := New(db, Options{EncryptionKey: bytes.Repeat([]byte{17}, 32), MaxPending: capacity, Now: clock.now})
if err != nil {
t.Fatal(err)
}
return queue, db, clock
}
func messageAt(clock *testClock, number int) mail.Message {
return mail.Message{ID: fmt.Sprintf("mail_message_%016d", number), To: "recipient@example.test", Subject: "Confirm your email", Text: "Private recovery token: only-in-ciphertext-123", CreatedAt: clock.now()}
}
func enqueue(t *testing.T, queue *Queue, clock *testClock, number int) mail.Message {
t.Helper()
message := messageAt(clock, number)
if err := queue.Enqueue(context.Background(), message, clock.now().Add(time.Hour)); err != nil {
t.Fatal(err)
}
return message
}
func storedPayload(t *testing.T, db *sql.DB, id string) []byte {
t.Helper()
var sealed []byte
if err := db.QueryRow(`SELECT payload FROM gwf_mail_outbox WHERE id=?`, id).Scan(&sealed); err != nil {
t.Fatal(err)
}
return sealed
}
func TestTransactionalEncryptionAndDeduplication(t *testing.T) {
queue, db, clock := fixture(t, 1)
ctx := context.Background()
message := messageAt(clock, 1)
expires := clock.now().Add(time.Hour)
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
if _, err = tx.Exec(`CREATE TABLE domain_change(id TEXT)`); err != nil {
t.Fatal(err)
}
if _, err = tx.Exec(`INSERT INTO domain_change VALUES ('changed')`); err != nil {
t.Fatal(err)
}
if err = queue.EnqueueTx(ctx, tx, message, expires); err != nil {
t.Fatal(err)
}
if err = tx.Rollback(); err != nil {
t.Fatal(err)
}
var count int
if err = db.QueryRow(`SELECT COUNT(*) FROM gwf_mail_outbox`).Scan(&count); err != nil || count != 0 {
t.Fatalf("rollback count=%d err=%v", count, err)
}
if err = db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE name='domain_change'`).Scan(&count); err != nil || count != 0 {
t.Fatalf("domain rollback count=%d err=%v", count, err)
}
if err = queue.Enqueue(ctx, message, expires); err != nil {
t.Fatal(err)
}
sealed := storedPayload(t, db, message.ID)
if len(sealed) == 0 || bytes.Contains(sealed, []byte(message.To)) || bytes.Contains(sealed, []byte(message.Text)) {
t.Fatal("payload is not encrypted")
}
if err = queue.Enqueue(ctx, message, expires); err != nil {
t.Fatalf("idempotent at capacity: %v", err)
}
changed := message
changed.Text = "Different content"
if err = queue.Enqueue(ctx, changed, expires); !errors.Is(err, ErrConflict) {
t.Fatalf("conflicting content: %v", err)
}
if err = queue.Enqueue(ctx, message, expires.Add(time.Minute)); !errors.Is(err, ErrConflict) {
t.Fatalf("conflicting expiry: %v", err)
}
if err = queue.Enqueue(ctx, messageAt(clock, 2), expires); !errors.Is(err, ErrFull) {
t.Fatalf("capacity: %v", err)
}
var calls int
send := transportFunc(func(_ context.Context, got mail.Message) error {
calls++
if got.ID != message.ID || got.To != message.To || got.Text != message.Text {
t.Fatal("message changed")
}
return nil
})
record, work, err := queue.ProcessOne(ctx, send)
if err != nil || !work || record.State != "accepted" || record.Attempts != 1 || !record.NextAttemptAt.IsZero() {
t.Fatalf("accept: %+v work=%v err=%v", record, work, err)
}
if storedPayload(t, db, message.ID) != nil {
t.Fatal("accepted payload retained")
}
if err = queue.Enqueue(ctx, message, expires); err != nil {
t.Fatal(err)
}
if _, work, err = queue.ProcessOne(ctx, send); err != nil || work || calls != 1 {
t.Fatalf("completed ID sent again: calls=%d work=%v err=%v", calls, work, err)
}
records, err := queue.Recent(ctx, 10)
if err != nil || len(records) != 1 || records[0].State != "accepted" || !records[0].NextAttemptAt.IsZero() {
t.Fatalf("metadata: %+v %v", records, err)
}
if err = queue.Enqueue(ctx, messageAt(clock, 2), expires); err != nil {
t.Fatalf("released capacity: %v", err)
}
}
func TestConcurrentCapacity(t *testing.T) {
queue, _, clock := fixture(t, 1)
var group sync.WaitGroup
var wins, full atomic.Int32
for number := range 8 {
group.Go(func() {
err := queue.Enqueue(context.Background(), messageAt(clock, number), clock.now().Add(time.Hour))
if err == nil {
wins.Add(1)
} else if errors.Is(err, ErrFull) {
full.Add(1)
} else {
t.Errorf("enqueue: %v", err)
}
})
}
group.Wait()
if wins.Load() != 1 || full.Load() != 7 {
t.Fatalf("wins=%d full=%d", wins.Load(), full.Load())
}
}
func TestRetryBoundsAndSafeDiagnostics(t *testing.T) {
queue, db, clock := fixture(t, 0)
message := enqueue(t, queue, clock, 1)
send := transportFunc(func(context.Context, mail.Message) error {
return &mail.Error{Stage: "recipient", Code: 450, Retryable: true}
})
for attempt := 1; attempt <= maxAttempts; attempt++ {
record, work, err := queue.ProcessOne(context.Background(), send)
if err != nil || !work || record.Attempts != attempt || record.FailureStage != "recipient" || record.SMTPCode != 450 {
t.Fatalf("attempt %d: %+v %v %v", attempt, record, work, err)
}
if attempt == maxAttempts {
if record.State != "failed" || storedPayload(t, db, message.ID) != nil {
t.Fatal("exhausted payload not cleared")
}
break
}
delay := time.Minute * time.Duration(1<<(attempt-1))
if record.State != "queued" || !record.NextAttemptAt.Equal(clock.now().Add(delay)) {
t.Fatalf("retry schedule: %+v", record)
}
if _, work, err = queue.ProcessOne(context.Background(), send); err != nil || work {
t.Fatalf("early retry: %v %v", work, err)
}
clock.advance(delay)
}
enqueue(t, queue, clock, 2)
record, _, err := queue.ProcessOne(context.Background(), transportFunc(func(context.Context, mail.Message) error {
return &mail.Error{Stage: "secret-recipient@example.test", Code: 9999, Retryable: false}
}))
if err != nil || record.State != "failed" || record.FailureStage != "transport" || record.SMTPCode != 0 {
t.Fatalf("unsafe diagnostic: %+v %v", record, err)
}
enqueue(t, queue, clock, 3)
record, _, err = queue.ProcessOne(context.Background(), transportFunc(func(context.Context, mail.Message) error { return errors.New("contains private token and address") }))
if err != nil || record.State != "queued" || record.FailureStage != "transport" {
t.Fatalf("raw transport error: %+v %v", record, err)
}
}
func TestExpiryAndCiphertextRecovery(t *testing.T) {
queue, db, clock := fixture(t, 0)
message := enqueue(t, queue, clock, 1)
sealed := storedPayload(t, db, message.ID)
wrong, err := New(db, Options{EncryptionKey: bytes.Repeat([]byte{18}, 32), Now: clock.now})
if err != nil {
t.Fatal(err)
}
var calls int
send := transportFunc(func(context.Context, mail.Message) error { calls++; return nil })
record, work, err := wrong.ProcessOne(context.Background(), send)
if err != nil || !work || record.State != "queued" || record.FailureStage != "payload" || record.Attempts != 0 || calls != 0 {
t.Fatalf("wrong key: %+v %v %v calls=%d", record, work, err, calls)
}
if !bytes.Equal(sealed, storedPayload(t, db, message.ID)) {
t.Fatal("key error destroyed pending ciphertext")
}
clock.advance(5 * time.Minute)
record, work, err = queue.ProcessOne(context.Background(), send)
if err != nil || !work || record.State != "accepted" || record.Attempts != 1 || calls != 1 {
t.Fatalf("key recovery: %+v %v %v calls=%d", record, work, err, calls)
}
message = enqueue(t, queue, clock, 2)
sealed = storedPayload(t, db, message.ID)
sealed[len(sealed)-1] ^= 1
if _, err = db.Exec(`UPDATE gwf_mail_outbox SET payload=? WHERE id=?`, sealed, message.ID); err != nil {
t.Fatal(err)
}
record, _, err = queue.ProcessOne(context.Background(), send)
if err != nil || record.FailureStage != "payload" || calls != 1 {
t.Fatalf("tamper sent: %+v %v calls=%d", record, err, calls)
}
clock.advance(time.Hour)
if _, work, err = queue.ProcessOne(context.Background(), send); err != nil || work || calls != 1 {
t.Fatalf("expired sent: work=%v err=%v calls=%d", work, err, calls)
}
if storedPayload(t, db, message.ID) != nil {
t.Fatal("expired corrupt payload retained")
}
if _, err = queue.open(message.ID, sealed[:3], clock.now().Unix()); !errors.Is(err, ErrInvalid) {
t.Fatal("short ciphertext accepted")
}
valid := enqueue(t, queue, clock, 3)
if _, err = queue.open("another_message_identity", storedPayload(t, db, valid.ID), clock.now().Add(time.Hour).Unix()); !errors.Is(err, ErrInvalid) {
t.Fatal("AAD identity not bound")
}
if _, err = queue.open(valid.ID, storedPayload(t, db, valid.ID), clock.now().Add(2*time.Hour).Unix()); !errors.Is(err, ErrInvalid) {
t.Fatal("expiry not bound")
}
}
func TestLeaseIsolationAndLateAcknowledgement(t *testing.T) {
queue, db, clock := fixture(t, 0)
message := enqueue(t, queue, clock, 1)
entered, release := make(chan struct{}), make(chan struct{})
type result struct {
record Record
work bool
err error
}
done := make(chan result, 1)
go func() {
record, work, err := queue.ProcessOne(context.Background(), transportFunc(func(ctx context.Context, _ mail.Message) error {
close(entered)
select {
case <-release:
return nil
case <-ctx.Done():
return ctx.Err()
}
}))
done <- result{record, work, err}
}()
<-entered
if _, work, err := queue.ProcessOne(context.Background(), transportFunc(func(context.Context, mail.Message) error { t.Error("duplicate active lease"); return nil })); err != nil || work {
t.Fatalf("leased work claimed: %v %v", work, err)
}
// SMTP cannot hold the SQLite writer lock while waiting on the network.
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := db.ExecContext(ctx, `CREATE TABLE independent_domain_write(id TEXT)`); err != nil {
t.Fatal(err)
}
clock.advance(leaseDuration + time.Second)
record, work, err := queue.ProcessOne(context.Background(), transportFunc(func(context.Context, mail.Message) error { return nil }))
if err != nil || !work || record.State != "accepted" || record.Attempts != 2 {
t.Fatalf("takeover: %+v %v %v", record, work, err)
}
close(release)
previous := <-done
if !previous.work || !errors.Is(previous.err, ErrConflict) {
t.Fatalf("late acknowledgement: %+v", previous)
}
if storedPayload(t, db, message.ID) != nil {
t.Fatal("late worker restored payload")
}
}
func TestCancellationLeavesRecoverableLease(t *testing.T) {
queue, db, clock := fixture(t, 0)
message := enqueue(t, queue, clock, 1)
ctx, cancel := context.WithCancel(context.Background())
_, work, err := queue.ProcessOne(ctx, transportFunc(func(context.Context, mail.Message) error { cancel(); return nil }))
if !work || !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled acknowledgement: %v %v", work, err)
}
if storedPayload(t, db, message.ID) == nil {
t.Fatal("uncertain delivery destroyed recovery payload")
}
clock.advance(leaseDuration + time.Second)
record, work, err := queue.ProcessOne(context.Background(), transportFunc(func(context.Context, mail.Message) error { return nil }))
if err != nil || !work || record.State != "accepted" || record.Attempts != 2 {
t.Fatalf("recovery: %+v %v %v", record, work, err)
}
}
func TestValidation(t *testing.T) {
queue, db, clock := fixture(t, 0)
ctx := context.Background()
for _, options := range []Options{{}, {EncryptionKey: make([]byte, 31)}, {EncryptionKey: make([]byte, 32), MaxPending: -1}, {EncryptionKey: make([]byte, 32), MaxPending: 10001}} {
if _, err := New(db, options); !errors.Is(err, ErrInvalid) {
t.Fatalf("invalid options: %v", err)
}
}
if _, err := New(nil, Options{EncryptionKey: make([]byte, 32)}); !errors.Is(err, ErrInvalid) {
t.Fatal("nil DB accepted")
}
if err := CreateSchema(ctx, nil); !errors.Is(err, ErrInvalid) {
t.Fatal("nil migration accepted")
}
if err := queue.EnqueueTx(ctx, nil, messageAt(clock, 1), clock.now().Add(time.Hour)); !errors.Is(err, ErrInvalid) {
t.Fatal("nil tx accepted")
}
for _, expiry := range []time.Time{clock.now(), clock.now().Add(-time.Minute), clock.now().Add(25 * time.Hour)} {
if err := queue.Enqueue(ctx, messageAt(clock, 1), expiry); !errors.Is(err, ErrInvalid) {
t.Fatalf("invalid expiry: %v", err)
}
}
message := messageAt(clock, 1)
message.CreatedAt = clock.now().Add(2 * time.Minute)
if err := queue.Enqueue(ctx, message, clock.now().Add(time.Hour)); !errors.Is(err, ErrInvalid) {
t.Fatal("future message accepted")
}
for _, limit := range []int{0, 101} {
if _, err := queue.Recent(ctx, limit); !errors.Is(err, ErrInvalid) {
t.Fatal("invalid metadata limit")
}
}
if _, _, err := queue.ProcessOne(ctx, nil); !errors.Is(err, ErrInvalid) {
t.Fatal("nil transport accepted")
}
}
+13
View File
@@ -42,6 +42,11 @@ authhttp/authhttp.go
authhttp/authhttp_test.go authhttp/authhttp_test.go
authhttp/passkey.go authhttp/passkey.go
authhttp/passkey_test.go authhttp/passkey_test.go
authmail/authmail.go
authmail/authmail_test.go
authmail/README.md
authsqlite/mail.go
authsqlite/mail_test.go
authsqlite/store.go authsqlite/store.go
authsqlite/store_test.go authsqlite/store_test.go
authsqlite/directory.go authsqlite/directory.go
@@ -63,6 +68,7 @@ authsqlite/passkey.go
authsqlite/passkey_test.go authsqlite/passkey_test.go
authsqlite/recovery.go authsqlite/recovery.go
authwebauthn/fuzz_test.go authwebauthn/fuzz_test.go
authwebauthn/algorithm_test.go
authwebauthn/service.go authwebauthn/service.go
authwebauthn/service_test.go authwebauthn/service_test.go
authwebauthn/types.go authwebauthn/types.go
@@ -76,6 +82,13 @@ cmssqlite/associations.go
cmssqlite/store_test.go cmssqlite/store_test.go
cmssqlite/example_test.go cmssqlite/example_test.go
media/media.go media/media.go
mail/mail.go
mail/smtp.go
mail/mail_test.go
mail/README.md
mailsqlite/queue.go
mailsqlite/queue_test.go
mailsqlite/README.md
media/media_test.go media/media_test.go
medialocal/store.go medialocal/store.go
medialocal/store_test.go medialocal/store_test.go