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:
+10
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user