Phase 1.2: session login + empty HTMX dashboard

- Session login for the single user Sam: server-side session store with an
  HMAC-signed session cookie, POST /login, POST /logout, and double-submit
  CSRF on every form post. Unauthenticated requests to / and /partials/*
  redirect to /login?next=...
- Empty dashboard shell in the Family Home Lab design system: left nav
  (category and folder placeholders), centre list, right detail pane.
- Copy tokens.css and the self-hosted Inter fonts from the family-home-lab
  design system into web/static; vendor htmx 2.0.4. No cross-project imports
  at runtime.
- Dark mode via prefers-color-scheme plus a persisted toggle; phone-first
  layout with 44px touch targets, hairline borders, 8px grid, pill CTAs.
- Config: EMAIL_AGENT_PASSWORD for the login password, falling back to
  EMAIL_AGENT_SESSION_SECRET so one configured secret is enough.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-11 19:58:25 +11:00
parent 1dc755a740
commit 3c0030c9a3
21 changed files with 1423 additions and 80 deletions

View File

@@ -15,8 +15,11 @@ EMAIL_AGENT_LOG_LEVEL=info
EMAIL_AGENT_PUBLIC_BASE_URL=https://mail.lab.audasmedia.com.au EMAIL_AGENT_PUBLIC_BASE_URL=https://mail.lab.audasmedia.com.au
# --- Session / auth -------------------------------------------------------- # --- Session / auth --------------------------------------------------------
# Generate with: openssl rand -hex 32 # Session cookie signing key. Generate with: python3 -c 'import secrets;print(secrets.token_hex(32))'
EMAIL_AGENT_SESSION_SECRET=change-me EMAIL_AGENT_SESSION_SECRET=change-me
# Login password for the single user (Sam). If unset, the session secret above
# doubles as the password.
EMAIL_AGENT_PASSWORD=
# --- Integrations (used from later phases) --------------------------------- # --- Integrations (used from later phases) ---------------------------------
JEV_BASE_URL= JEV_BASE_URL=

View File

@@ -9,19 +9,22 @@ pre-planning material and is not authoritative.
## Status ## Status
**Phase 1.1 — Go service skeleton.** Complete when this file was written: **Phase 1.2 — Session login + empty dashboard.** The Go skeleton (Phase 1.1)
config from the environment, SQLite schema init, `GET /health`, Dockerfile and plus a single-user session login, a copied Family Home Lab `tokens.css`, and an
docker-compose. Mail fetch, classification, login, and the HTMX screens come in HTMX dashboard shell (left nav, centre list, right detail pane) with dark mode.
later phases. Mail fetch, classification, and the rest come in later phases.
## Layout ## Layout
``` ```
cmd/email-agent/ service entry point cmd/email-agent/ service entry point
internal/config/ environment-driven configuration internal/config/ environment-driven configuration
internal/session/ server-side session store + signed cookie values
internal/store/ SQLite open + schema (internal/store/schema.sql) internal/store/ SQLite open + schema (internal/store/schema.sql)
internal/server/ HTTP routes and middleware internal/server/ HTTP routes, auth middleware, HTMX handlers
internal/version/ build metadata (set via -ldflags) internal/version/ build metadata (set via -ldflags)
web/static/ embedded design tokens, Inter fonts, htmx, app.css
web/templates/ embedded server-rendered HTML
Dockerfile multi-stage, CGO-free build Dockerfile multi-stage, CGO-free build
docker-compose.yml this project's own container and image docker-compose.yml this project's own container and image
``` ```
@@ -42,7 +45,8 @@ Key variables:
| `EMAIL_AGENT_PORT` | `8790` | Listen port (used when `EMAIL_AGENT_ADDR` is unset) | | `EMAIL_AGENT_PORT` | `8790` | Listen port (used when `EMAIL_AGENT_ADDR` is unset) |
| `EMAIL_AGENT_DB_PATH` | `data/email_agent.db` | SQLite file | | `EMAIL_AGENT_DB_PATH` | `data/email_agent.db` | SQLite file |
| `EMAIL_AGENT_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | | `EMAIL_AGENT_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
| `EMAIL_AGENT_SESSION_SECRET` | _(empty)_ | Session cookie signing key (Phase 1.2) | | `EMAIL_AGENT_SESSION_SECRET` | _(empty)_ | Session cookie signing key. If unset, an ephemeral key is used (sessions reset on restart) |
| `EMAIL_AGENT_PASSWORD` | _(empty)_ | Login password for Sam. If unset, `EMAIL_AGENT_SESSION_SECRET` doubles as the password |
## Run locally ## Run locally
@@ -65,11 +69,20 @@ Caddy on `.35` proxies `mail.lab.audasmedia.com.au` to this port.
## Endpoints (Phase 1) ## Endpoints (Phase 1)
| Method | Path | Purpose | | Method | Path | Auth | Purpose |
|---|---|---| |---|---|---|---|
| `GET` | `/health` | Liveness + database check, JSON | | `GET` | `/health` | public | Liveness + database check, JSON |
| `GET` | `/readyz` | Readiness, JSON | | `GET` | `/readyz` | public | Readiness, JSON |
| `GET` | `/` | Placeholder dashboard (real screens in Phase 1.2) | | `GET` | `/login` | public | Sign-in form (deep-indigo band) |
| `POST` | `/login` | public | CSRF-checked password sign-in, sets the session cookie |
| `POST` | `/logout` | session | Destroys the session, returns to `/login` |
| `GET` | `/` | session | Empty dashboard shell |
| `GET` | `/partials/inbox` | session | HTMX fragment for the centre list |
| `GET` | `/static/…` | public | Embedded `tokens.css`, `app.css`, Inter fonts, `htmx.min.js` |
Unauthenticated requests to `/` and `/partials/…` redirect to `/login?next=…`.
Form posts use a double-submit CSRF token (`email_agent_csrf` cookie plus a
hidden `csrf` field).
## Database ## Database

View File

@@ -60,8 +60,8 @@ func run(cfg *config.Config, logger *slog.Logger) error {
"env", cfg.Env, "env", cfg.Env,
) )
if cfg.SessionSecret == "" { if cfg.SessionSecret == "" && cfg.Password == "" {
logger.Warn("EMAIL_AGENT_SESSION_SECRET is not set; set it before enabling login") logger.Warn("no EMAIL_AGENT_PASSWORD or EMAIL_AGENT_SESSION_SECRET set; login is disabled until one is configured")
} }
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)

View File

@@ -30,6 +30,7 @@ services:
# (see ~/.config/environment.d/10-secrets.conf) or a gitignored .env file, # (see ~/.config/environment.d/10-secrets.conf) or a gitignored .env file,
# and are never committed. # and are never committed.
EMAIL_AGENT_SESSION_SECRET: ${EMAIL_AGENT_SESSION_SECRET:-} EMAIL_AGENT_SESSION_SECRET: ${EMAIL_AGENT_SESSION_SECRET:-}
EMAIL_AGENT_PASSWORD: ${EMAIL_AGENT_PASSWORD:-}
JEV_BASE_URL: ${JEV_BASE_URL:-} JEV_BASE_URL: ${JEV_BASE_URL:-}
JEV_API_KEY: ${JEV_API_KEY:-} JEV_API_KEY: ${JEV_API_KEY:-}
OMNIROUTE_BASE_URL: ${OMNIROUTE_BASE_URL:-} OMNIROUTE_BASE_URL: ${OMNIROUTE_BASE_URL:-}

View File

@@ -42,6 +42,10 @@ type Config struct {
// service has a single configuration surface. // service has a single configuration surface.
SessionSecret string SessionSecret string
// Password is the single-user login password. When it is empty the service
// falls back to SessionSecret so one configured secret is enough. Secret.
Password string
// LogLevel controls slog verbosity: debug, info, warn, error. // LogLevel controls slog verbosity: debug, info, warn, error.
LogLevel string LogLevel string
@@ -75,6 +79,7 @@ func Load() (*Config, error) {
c := &Config{ c := &Config{
Env: getenvDefault("EMAIL_AGENT_ENV", DefaultEnv), Env: getenvDefault("EMAIL_AGENT_ENV", DefaultEnv),
SessionSecret: os.Getenv("EMAIL_AGENT_SESSION_SECRET"), SessionSecret: os.Getenv("EMAIL_AGENT_SESSION_SECRET"),
Password: os.Getenv("EMAIL_AGENT_PASSWORD"),
LogLevel: strings.ToLower(getenvDefault("EMAIL_AGENT_LOG_LEVEL", "info")), LogLevel: strings.ToLower(getenvDefault("EMAIL_AGENT_LOG_LEVEL", "info")),
PublicBaseURL: strings.TrimRight(getenvDefault("EMAIL_AGENT_PUBLIC_BASE_URL", "http://localhost:8790"), "/"), PublicBaseURL: strings.TrimRight(getenvDefault("EMAIL_AGENT_PUBLIC_BASE_URL", "http://localhost:8790"), "/"),
JEVBaseURL: strings.TrimRight(os.Getenv("JEV_BASE_URL"), "/"), JEVBaseURL: strings.TrimRight(os.Getenv("JEV_BASE_URL"), "/"),

284
internal/server/auth.go Normal file
View File

@@ -0,0 +1,284 @@
package server
import (
"context"
"crypto/subtle"
"net/http"
"net/url"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/session"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/version"
)
const (
// csrfCookieName holds the double-submit CSRF token. It is independent of
// the session so the login form (which has no session yet) is protected too.
csrfCookieName = "email_agent_csrf"
sessionTTL = session.DefaultTTL
)
// ctxKey is the private context key type for request-scoped values.
type ctxKey int
const sessionCtxKey ctxKey = iota
// requireAuth redirects unauthenticated requests to the login screen and
// otherwise puts the session on the request context.
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, ok := s.currentSession(r)
if !ok {
target := "/login?next=" + url.QueryEscape(r.URL.RequestURI())
http.Redirect(w, r, target, http.StatusSeeOther)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), sessionCtxKey, sess)))
}
}
func (s *Server) currentSession(r *http.Request) (session.Session, bool) {
c, err := r.Cookie(session.CookieName)
if err != nil || c.Value == "" {
return session.Session{}, false
}
return s.sessions.Lookup(c.Value)
}
func sessionFrom(r *http.Request) session.Session {
sess, _ := r.Context().Value(sessionCtxKey).(session.Session)
return sess
}
// loginSecret is the password the single user must supply. EMAIL_AGENT_PASSWORD
// wins; otherwise the session secret doubles as the password so a single
// configured secret is enough.
func (s *Server) loginSecret() string {
if s.cfg.Password != "" {
return s.cfg.Password
}
return s.cfg.SessionSecret
}
// csrfToken returns the current double-submit token, minting one when absent.
func (s *Server) csrfToken(w http.ResponseWriter, r *http.Request) string {
if c, err := r.Cookie(csrfCookieName); err == nil && c.Value != "" {
return c.Value
}
tok, err := session.RandomToken(32)
if err != nil {
// crypto/rand failing is fatal for security; surface it rather than
// issuing an unprotected form.
s.logger.Error("csrf token generation failed", "error", err)
return ""
}
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: tok,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.cfg.IsProduction(),
MaxAge: int(sessionTTL.Seconds()),
})
return tok
}
// csrfOK verifies the double-submit token from the form or the X-CSRF-Token
// header.
func (s *Server) csrfOK(r *http.Request) bool {
c, err := r.Cookie(csrfCookieName)
if err != nil || c.Value == "" {
return false
}
tok := r.FormValue("csrf")
if tok == "" {
tok = r.Header.Get("X-CSRF-Token")
}
return subtle.ConstantTimeCompare([]byte(tok), []byte(c.Value)) == 1
}
func (s *Server) setSessionCookie(w http.ResponseWriter, value string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: session.CookieName,
Value: value,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.cfg.IsProduction(),
Expires: expires,
MaxAge: int(sessionTTL.Seconds()),
})
}
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: session.CookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.cfg.IsProduction(),
MaxAge: -1,
})
}
type loginData struct {
PartOfDay string
Error string
Next string
CSRFToken string
Configured bool
}
func (s *Server) renderLogin(w http.ResponseWriter, r *http.Request, code int, errMsg, next string) {
// Mint the CSRF cookie before the status line, or Set-Cookie is dropped.
csrf := s.csrfToken(w, r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
data := loginData{
PartOfDay: partOfDay(time.Now()),
Error: errMsg,
Next: next,
CSRFToken: csrf,
Configured: s.loginSecret() != "",
}
if err := s.tmpl.ExecuteTemplate(w, "login.html", data); err != nil {
s.logger.Error("render login", "error", err)
}
}
// handleLoginGet renders the sign-in form.
func (s *Server) handleLoginGet(w http.ResponseWriter, r *http.Request) {
if _, ok := s.currentSession(r); ok {
http.Redirect(w, r, cleanNext(r.URL.Query().Get("next")), http.StatusSeeOther)
return
}
s.renderLogin(w, r, http.StatusOK, "", cleanNext(r.URL.Query().Get("next")))
}
// handleLoginPost validates the CSRF token and password, then starts a session.
// POST (not GET) means the password is never placed in the URL or browser
// history.
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderLogin(w, r, http.StatusBadRequest, "Could not read the sign-in form.", "/")
return
}
next := cleanNext(r.FormValue("next"))
if !s.csrfOK(r) {
s.renderLogin(w, r, http.StatusForbidden, "Your sign-in page expired. Please try again.", next)
return
}
secret := s.loginSecret()
if secret == "" {
s.renderLogin(w, r, http.StatusServiceUnavailable, "Login is not configured yet.", next)
return
}
if subtle.ConstantTimeCompare([]byte(r.FormValue("password")), []byte(secret)) != 1 {
s.logger.Warn("login failed", "remote", r.RemoteAddr)
s.renderLogin(w, r, http.StatusUnauthorized, "Incorrect password.", next)
return
}
sess, cookie, err := s.sessions.New("Sam")
if err != nil {
s.logger.Error("create session", "error", err)
http.Error(w, "could not start a session", http.StatusInternalServerError)
return
}
s.setSessionCookie(w, cookie, sess.ExpiresAt)
s.logger.Info("login ok", "user", sess.User, "remote", r.RemoteAddr)
http.Redirect(w, r, next, http.StatusSeeOther)
}
// handleLogout destroys the session and returns to the login screen.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if !s.csrfOK(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
if c, err := r.Cookie(session.CookieName); err == nil && c.Value != "" {
s.sessions.Destroy(c.Value)
}
s.clearSessionCookie(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
type navItem struct {
Name string
Color string
}
type dashboardData struct {
Greeting string
User string
CSRFToken string
Version string
Categories []navItem
Folders []navItem
}
// handleDashboard renders the empty inbox shell.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
sess := sessionFrom(r)
data := dashboardData{
Greeting: partOfDay(time.Now()),
User: sess.User,
CSRFToken: s.csrfToken(w, r),
Version: version.Version,
// Placeholders until FR-14 (editable categories) and FR-9 (folders)
// land. Colours are the design system's decorative sticker palette.
Categories: []navItem{
{Name: "Action", Color: "var(--primary)"},
{Name: "Important", Color: "var(--sticker-orange)"},
{Name: "Info", Color: "var(--sticker-sky)"},
{Name: "Spam", Color: "var(--sticker-red)"},
{Name: "Newsletter", Color: "var(--sticker-purple)"},
{Name: "Auto-reply", Color: "var(--sticker-teal)"},
},
Folders: []navItem{
{Name: "Replied-to", Color: "var(--sticker-green)"},
{Name: "Delete", Color: "var(--sticker-red)"},
{Name: "Saved", Color: "var(--sticker-teal)"},
},
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil {
s.logger.Error("render dashboard", "error", err)
}
}
// handleInboxPartial is the HTMX fragment for the centre list. Phase 1.2 has no
// mail yet, so it renders the empty state.
func (s *Server) handleInboxPartial(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, "inbox.html", nil); err != nil {
s.logger.Error("render inbox partial", "error", err)
}
}
// cleanNext only allows same-site, absolute-path redirects.
func cleanNext(next string) string {
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
return "/"
}
return next
}
// partOfDay returns the time-aware greeting prefix.
func partOfDay(t time.Time) string {
switch h := t.Hour(); {
case h < 12:
return "Good morning"
case h < 18:
return "Good afternoon"
default:
return "Good evening"
}
}

View File

@@ -1,45 +1,66 @@
// Package server wires the HTTP surface of the Email Agent. // Package server wires the HTTP surface of the Email Agent.
// //
// Phase 1 exposes liveness/readiness endpoints and a placeholder dashboard. // Phase 1.2 exposes a session login and an empty HTMX dashboard shell in the
// Login and the real HTMX screens arrive in Phase 1.2. // Family Home Lab design system. Mail fetch arrives in Phase 2.
package server package server
import ( import (
"context" "context"
"crypto/rand"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"html/template" "html/template"
"io/fs"
"log/slog" "log/slog"
"mime"
"net/http" "net/http"
"time" "time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/config" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/config"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/session"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/version" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/version"
"gitea.lab.audasmedia.com.au/sam/email_agent/web"
) )
func init() {
// Go's mime table has no woff2 entry; the self-hosted Inter fonts need it.
_ = mime.AddExtensionType(".woff2", "font/woff2")
}
// Server holds the HTTP dependencies. // Server holds the HTTP dependencies.
type Server struct { type Server struct {
cfg *config.Config cfg *config.Config
store *store.Store store *store.Store
logger *slog.Logger logger *slog.Logger
http *http.Server http *http.Server
index *template.Template tmpl *template.Template
sessions *session.Manager
} }
// New builds a Server. It does not start listening. // New builds a Server. It does not start listening.
func New(cfg *config.Config, st *store.Store, logger *slog.Logger) (*Server, error) { func New(cfg *config.Config, st *store.Store, logger *slog.Logger) (*Server, error) {
index, err := template.New("index").Parse(indexHTML) tmpl, err := template.ParseFS(web.TemplateFS, "templates/*.html")
if err != nil { if err != nil {
return nil, fmt.Errorf("parse index template: %w", err) return nil, fmt.Errorf("parse templates: %w", err)
}
key, err := sessionKey(cfg, logger)
if err != nil {
return nil, fmt.Errorf("session key: %w", err)
}
sessions, err := session.NewManager(key, session.DefaultTTL)
if err != nil {
return nil, fmt.Errorf("session manager: %w", err)
} }
s := &Server{ s := &Server{
cfg: cfg, cfg: cfg,
store: st, store: st,
logger: logger, logger: logger,
index: index, tmpl: tmpl,
sessions: sessions,
} }
s.http = &http.Server{ s.http = &http.Server{
Addr: cfg.Addr, Addr: cfg.Addr,
@@ -52,12 +73,43 @@ func New(cfg *config.Config, st *store.Store, logger *slog.Logger) (*Server, err
return s, nil return s, nil
} }
// sessionKey returns the cookie signing key. In production it comes from
// EMAIL_AGENT_SESSION_SECRET; with no secret configured the service still
// starts with an ephemeral key so it is usable, but sessions do not survive a
// restart and the operator sees a warning.
func sessionKey(cfg *config.Config, logger *slog.Logger) ([]byte, error) {
if cfg.SessionSecret != "" {
return []byte(cfg.SessionSecret), nil
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, err
}
logger.Warn("EMAIL_AGENT_SESSION_SECRET is not set; using an ephemeral session key (sessions reset on restart)")
return key, nil
}
// Handler returns the root HTTP handler with middleware applied. // Handler returns the root HTTP handler with middleware applied.
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
staticFS, err := fs.Sub(web.StaticFS, "static")
if err != nil {
// web.StaticFS is a compile-time constant; this cannot fail at runtime.
panic(fmt.Sprintf("static assets: %v", err))
}
static := http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth) mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /readyz", s.handleReady) mux.HandleFunc("GET /readyz", s.handleReady)
mux.HandleFunc("GET /{$}", s.handleIndex) mux.Handle("GET /static/", static)
mux.HandleFunc("GET /login", s.handleLoginGet)
mux.HandleFunc("POST /login", s.handleLoginPost)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard))
mux.HandleFunc("GET /partials/inbox", s.requireAuth(s.handleInboxPartial))
return s.recoverPanic(s.logRequests(mux)) return s.recoverPanic(s.logRequests(mux))
} }
@@ -138,19 +190,6 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
} }
// handleIndex renders a placeholder dashboard. The real login + inbox screens
// are Phase 1.2.
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data := struct {
Version string
Env string
}{Version: version.Version, Env: s.cfg.Env}
if err := s.index.Execute(w, data); err != nil {
s.logger.Error("render index", "error", err)
}
}
func writeJSON(w http.ResponseWriter, code int, v any) { func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code) w.WriteHeader(code)
@@ -195,34 +234,3 @@ func (r *statusRecorder) WriteHeader(code int) {
r.status = code r.status = code
r.ResponseWriter.WriteHeader(code) r.ResponseWriter.WriteHeader(code)
} }
const indexHTML = `<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Email Agent</title>
<style>
:root { color-scheme: light dark; }
body { font-family: Inter, -apple-system, system-ui, "Segoe UI", Helvetica, Arial, sans-serif;
background: #f6f5f4; color: #31302e; margin: 0; }
@media (prefers-color-scheme: dark) { body { background: #141518; color: #dcdee2; } }
main { max-width: 640px; margin: 12vh auto; padding: 0 24px; }
.band { background: #213183; color: #fff; border-radius: 12px; padding: 24px; }
h1 { font-size: 32px; line-height: 1.1; margin: 0 0 8px; }
p { font-size: 15px; line-height: 1.5; }
code { background: rgba(127,127,127,.15); padding: 2px 6px; border-radius: 4px; }
</style>
</head>
<body>
<main>
<div class="band">
<h1>Email Agent</h1>
<p>Phase 1 skeleton is running. Version <code>{{.Version}}</code> ({{.Env}}).</p>
</div>
<p>Login and the inbox arrive in Phase 1.2. Liveness is at
<a href="/health">/health</a>.</p>
</main>
</body>
</html>
`

View File

@@ -6,20 +6,28 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"path/filepath" "path/filepath"
"regexp"
"strings"
"testing" "testing"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/config" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/config"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/session"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
) )
const testPassword = "test-password"
func newTestServer(t *testing.T) *Server { func newTestServer(t *testing.T) *Server {
t.Helper() t.Helper()
cfg := &config.Config{ cfg := &config.Config{
Env: "test", Env: "test",
Addr: ":0", Addr: ":0",
DBPath: filepath.Join(t.TempDir(), "email_agent.db"), DBPath: filepath.Join(t.TempDir(), "email_agent.db"),
LogLevel: "error", LogLevel: "error",
SessionSecret: "test-session-secret",
Password: testPassword,
} }
st, err := store.Open(context.Background(), cfg.DBPath) st, err := store.Open(context.Background(), cfg.DBPath)
if err != nil { if err != nil {
@@ -65,9 +73,9 @@ func TestReadyz(t *testing.T) {
} }
} }
func TestIndexRendersHTML(t *testing.T) { func TestStaticTokensServed(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequest(http.MethodGet, "/static/tokens.css", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req) srv.Handler().ServeHTTP(rec, req)
@@ -75,7 +83,180 @@ func TestIndexRendersHTML(t *testing.T) {
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
} }
if ct := rec.Header().Get("Content-Type"); ct == "" { if !strings.Contains(rec.Body.String(), "--canvas-soft") {
t.Error("Content-Type header is empty") t.Error("tokens.css does not contain the design tokens")
} }
} }
func TestUnauthenticatedRedirectsToLogin(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusSeeOther)
}
if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, "/login") {
t.Errorf("Location = %q, want /login…", loc)
}
}
func TestLoginPageRenders(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/login", nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
body := rec.Body.String()
if !strings.Contains(body, "Sign in") || !strings.Contains(body, `name="csrf"`) {
t.Error("login page is missing the form or CSRF field")
}
if findCookie(t, rec.Result().Cookies(), csrfCookieName) == nil {
t.Error("login page did not set the CSRF cookie")
}
}
func TestLoginFlowAndDashboard(t *testing.T) {
srv := newTestServer(t)
h := srv.Handler()
// 1. Load the login page to obtain the CSRF token.
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
csrf := csrfFromBody(t, rec.Body.String())
csrfCookie := findCookie(t, rec.Result().Cookies(), csrfCookieName)
// 2. Sign in with the right password.
rec = httptest.NewRecorder()
h.ServeHTTP(rec, loginRequest(csrf, testPassword, csrfCookie))
if rec.Code != http.StatusSeeOther {
t.Fatalf("login status = %d, want %d (body: %s)", rec.Code, http.StatusSeeOther, rec.Body.String())
}
sessionCookie := findCookie(t, rec.Result().Cookies(), session.CookieName)
if sessionCookie == nil {
t.Fatal("login did not set the session cookie")
}
// 3. The dashboard renders with the session cookie.
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("dashboard status = %d, want %d", rec.Code, http.StatusOK)
}
body := rec.Body.String()
for _, want := range []string{"Good ", "Inbox", "Categories", "Folders", "Log out"} {
if !strings.Contains(body, want) {
t.Errorf("dashboard body missing %q", want)
}
}
}
func TestLoginWrongPassword(t *testing.T) {
srv := newTestServer(t)
h := srv.Handler()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
csrf := csrfFromBody(t, rec.Body.String())
csrfCookie := findCookie(t, rec.Result().Cookies(), csrfCookieName)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, loginRequest(csrf, "wrong-password", csrfCookie))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
if findCookie(t, rec.Result().Cookies(), session.CookieName) != nil {
t.Error("a session cookie was issued for a failed login")
}
}
func TestLoginRejectsMissingCSRF(t *testing.T) {
srv := newTestServer(t)
form := url.Values{"password": {testPassword}}
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
}
func TestLogoutDestroysSession(t *testing.T) {
srv := newTestServer(t)
h := srv.Handler()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
csrf := csrfFromBody(t, rec.Body.String())
csrfCookie := findCookie(t, rec.Result().Cookies(), csrfCookieName)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, loginRequest(csrf, testPassword, csrfCookie))
sessionCookie := findCookie(t, rec.Result().Cookies(), session.CookieName)
if sessionCookie == nil {
t.Fatal("login did not set the session cookie")
}
// Log out, carrying both the CSRF and session cookies.
form := url.Values{"csrf": {csrf}}
req := httptest.NewRequest(http.MethodPost, "/logout", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(csrfCookie)
req.AddCookie(sessionCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("logout status = %d, want %d", rec.Code, http.StatusSeeOther)
}
// The old session no longer reaches the dashboard.
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("dashboard after logout = %d, want %d", rec.Code, http.StatusSeeOther)
}
}
func loginRequest(csrf, password string, csrfCookie *http.Cookie) *http.Request {
form := url.Values{"password": {password}, "csrf": {csrf}}
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if csrfCookie != nil {
req.AddCookie(csrfCookie)
}
return req
}
var csrfFieldRE = regexp.MustCompile(`name="csrf" value="([^"]*)"`)
func csrfFromBody(t *testing.T, body string) string {
t.Helper()
m := csrfFieldRE.FindStringSubmatch(body)
if len(m) < 2 || m[1] == "" {
t.Fatalf("no CSRF token in body")
}
return m[1]
}
func findCookie(t *testing.T, cookies []*http.Cookie, name string) *http.Cookie {
t.Helper()
for _, c := range cookies {
if c.Name == name && c.Value != "" {
return c
}
}
return nil
}

196
internal/session/session.go Normal file
View File

@@ -0,0 +1,196 @@
// Package session implements the Email Agent's server-side session store and
// the signed cookie format used to reference a session.
//
// Sessions live in the process (there is one user and one container), so the
// cookie only ever carries an opaque, HMAC-signed identifier. Nothing about the
// user or the session state is stored client-side.
package session
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"strings"
"sync"
"time"
)
// CookieName is the name of the session cookie.
const CookieName = "email_agent_session"
// DefaultTTL is how long a session stays valid.
const DefaultTTL = 30 * 24 * time.Hour
// Session is a single logged-in session.
type Session struct {
ID string
User string
CreatedAt time.Time
ExpiresAt time.Time
}
// Store is an in-memory, concurrency-safe session store with lazy expiry.
type Store struct {
mu sync.Mutex
sessions map[string]Session
ttl time.Duration
now func() time.Time
}
// NewStore returns an empty store. A non-positive ttl falls back to DefaultTTL.
func NewStore(ttl time.Duration) *Store {
if ttl <= 0 {
ttl = DefaultTTL
}
return &Store{
sessions: make(map[string]Session),
ttl: ttl,
now: time.Now,
}
}
// Create makes a new session for user and returns it.
func (s *Store) Create(user string) (Session, error) {
id, err := RandomToken(32)
if err != nil {
return Session{}, err
}
now := s.now()
sess := Session{
ID: id,
User: user,
CreatedAt: now,
ExpiresAt: now.Add(s.ttl),
}
s.mu.Lock()
defer s.mu.Unlock()
s.gcLocked()
s.sessions[id] = sess
return sess, nil
}
// Get returns the session for id if it exists and has not expired.
func (s *Store) Get(id string) (Session, bool) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[id]
if !ok {
return Session{}, false
}
if s.now().After(sess.ExpiresAt) {
delete(s.sessions, id)
return Session{}, false
}
return sess, true
}
// Delete removes a session.
func (s *Store) Delete(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, id)
}
// Len returns the number of live (not-yet-swept) sessions.
func (s *Store) Len() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.sessions)
}
func (s *Store) gcLocked() {
now := s.now()
for id, sess := range s.sessions {
if now.After(sess.ExpiresAt) {
delete(s.sessions, id)
}
}
}
// Manager pairs a session store with the HMAC key that signs cookie values.
type Manager struct {
store *Store
key []byte
}
// NewManager builds a Manager. An empty key is replaced with a random key so
// the manager is always safe to use; the caller should normally supply one.
func NewManager(key []byte, ttl time.Duration) (*Manager, error) {
if len(key) == 0 {
k, err := randomBytes(32)
if err != nil {
return nil, err
}
key = k
}
return &Manager{store: NewStore(ttl), key: key}, nil
}
// New creates a session and returns it with the signed cookie value to set.
func (m *Manager) New(user string) (Session, string, error) {
sess, err := m.store.Create(user)
if err != nil {
return Session{}, "", err
}
return sess, m.sign(sess.ID), nil
}
// Lookup resolves a signed cookie value to a live session.
func (m *Manager) Lookup(cookieValue string) (Session, bool) {
id, ok := m.verify(cookieValue)
if !ok {
return Session{}, false
}
return m.store.Get(id)
}
// Destroy deletes the session referenced by a signed cookie value.
func (m *Manager) Destroy(cookieValue string) {
if id, ok := m.verify(cookieValue); ok {
m.store.Delete(id)
}
}
func (m *Manager) sign(id string) string {
mac := hmac.New(sha256.New, m.key)
mac.Write([]byte(id))
return id + "." + hex.EncodeToString(mac.Sum(nil))
}
func (m *Manager) verify(value string) (string, bool) {
i := strings.LastIndexByte(value, '.')
if i <= 0 || i == len(value)-1 {
return "", false
}
id, sigHex := value[:i], value[i+1:]
sig, err := hex.DecodeString(sigHex)
if err != nil {
return "", false
}
mac := hmac.New(sha256.New, m.key)
mac.Write([]byte(id))
if subtle.ConstantTimeCompare(sig, mac.Sum(nil)) != 1 {
return "", false
}
return id, true
}
// RandomToken returns a URL-safe random string carrying n bytes of entropy.
func RandomToken(n int) (string, error) {
b, err := randomBytes(n)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func randomBytes(n int) ([]byte, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return nil, err
}
return b, nil
}

View File

@@ -0,0 +1,78 @@
package session
import (
"testing"
"time"
)
func TestManagerRoundTrip(t *testing.T) {
m, err := NewManager([]byte("test-key"), time.Hour)
if err != nil {
t.Fatalf("NewManager() error = %v", err)
}
sess, cookie, err := m.New("Sam")
if err != nil {
t.Fatalf("New() error = %v", err)
}
if sess.User != "Sam" {
t.Errorf("User = %q, want Sam", sess.User)
}
got, ok := m.Lookup(cookie)
if !ok {
t.Fatal("Lookup() = not ok, want a live session")
}
if got.ID != sess.ID {
t.Errorf("Lookup ID = %q, want %q", got.ID, sess.ID)
}
m.Destroy(cookie)
if _, ok := m.Lookup(cookie); ok {
t.Error("Lookup() after Destroy() = ok, want expired")
}
}
func TestManagerRejectsTamperedCookie(t *testing.T) {
m, err := NewManager([]byte("key-a"), time.Hour)
if err != nil {
t.Fatalf("NewManager() error = %v", err)
}
_, cookie, err := m.New("Sam")
if err != nil {
t.Fatalf("New() error = %v", err)
}
other, err := NewManager([]byte("key-b"), time.Hour)
if err != nil {
t.Fatalf("NewManager() error = %v", err)
}
if _, ok := other.Lookup(cookie); ok {
t.Error("Lookup() with a different key = ok, want rejected")
}
for _, bad := range []string{"", "garbage", "abc.", ".abc", "a.b.c", cookie + "x"} {
if _, ok := m.Lookup(bad); ok {
t.Errorf("Lookup(%q) = ok, want rejected", bad)
}
}
}
func TestStoreExpiry(t *testing.T) {
st := NewStore(time.Minute)
base := time.Now()
st.now = func() time.Time { return base }
sess, err := st.Create("Sam")
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if _, ok := st.Get(sess.ID); !ok {
t.Fatal("Get() = not ok before expiry")
}
st.now = func() time.Time { return base.Add(2 * time.Minute) }
if _, ok := st.Get(sess.ID); ok {
t.Error("Get() = ok after expiry, want expired")
}
}

23
web/embed.go Normal file
View File

@@ -0,0 +1,23 @@
// Package web holds the server-rendered templates and static assets for the
// Email Agent UI.
//
// The assets are embedded in the binary so the container image needs no
// separate file tree and cannot drift from the code.
//
// tokens.css and the Inter woff2 files are copied verbatim from the Family Home
// Lab design system (AGENTS.md §6); they are compiled into this project rather
// than imported across projects.
package web
import "embed"
// StaticFS holds the /static tree: design tokens, self-hosted Inter fonts,
// htmx, and the application stylesheet.
//
//go:embed static
var StaticFS embed.FS
// TemplateFS holds the server-rendered HTML templates.
//
//go:embed templates
var TemplateFS embed.FS

293
web/static/app.css Normal file
View File

@@ -0,0 +1,293 @@
/* ============================================================
Email Agent — application styles
Built on Family Home Lab tokens.css (do not edit tokens here).
Base unit: 8px. Touch targets >= 44px. Pill CTAs, 4px inputs.
============================================================ */
*,
*::before,
*::after { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font: var(--type-body-md);
color: var(--ink-secondary);
background: var(--canvas-soft);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, p { margin: 0; }
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* ---------- primitives ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
min-height: 44px;
padding: 0 var(--space-lg);
border: 1px solid transparent;
border-radius: var(--rounded-full);
font: var(--type-button);
cursor: pointer;
transition: transform .08s ease, background-color .15s ease, box-shadow .15s ease;
white-space: nowrap;
}
.btn:active { transform: scale(.97); }
.btn-primary {
background: var(--primary);
color: var(--on-primary);
}
.btn-primary:hover { background: var(--primary-active); }
.btn-utility {
background: var(--surface);
color: var(--ink);
border-radius: var(--rounded-md);
min-height: 44px;
padding: 0 var(--space-md);
box-shadow: var(--shadow-level-1);
border: 1px solid var(--hairline);
}
.btn-utility:hover { background: var(--surface-2); }
.btn-block { width: 100%; }
.text-input {
width: 100%;
min-height: 44px;
padding: var(--space-xs) var(--space-sm);
background: var(--surface);
color: var(--ink);
font: var(--type-body-md);
border: 1px solid var(--hairline);
border-radius: var(--rounded-xs);
}
.text-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: var(--shadow-level-1);
}
.field { display: flex; flex-direction: column; gap: var(--space-xxs); }
.field > label { font: var(--type-caption); color: var(--ink-muted); }
.pill {
display: inline-flex;
align-items: center;
gap: var(--space-xxs);
padding: 4px 8px;
border-radius: var(--rounded-full);
font: var(--type-eyebrow);
background: var(--surface);
border: 1px solid var(--hairline);
color: var(--ink-muted);
}
.hairline { border: 1px solid var(--hairline); }
.muted { color: var(--ink-muted); }
.faint { color: var(--ink-faint); }
/* ---------- login ---------- */
.login-body {
background: var(--secondary);
}
.login-wrap {
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-xl);
padding: var(--space-lg);
}
.login-greeting {
font: var(--type-display-1);
color: #fff;
text-align: center;
letter-spacing: -1.5px;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--surface);
border-radius: var(--rounded-lg);
box-shadow: var(--shadow-level-2);
padding: var(--space-xxl) var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.auth-brand {
font: var(--type-eyebrow);
color: var(--ink-faint);
letter-spacing: .125px;
text-transform: uppercase;
}
.auth-card h1, .auth-card h2 {
font: var(--type-heading-1);
color: var(--ink);
}
.auth-card h2 { font: var(--type-heading-2); }
.auth-card p { font: var(--type-body-sm); color: var(--ink-muted); }
.auth-form { display: flex; flex-direction: column; gap: var(--space-md); }
.alert {
padding: var(--space-xs) var(--space-sm);
border-radius: var(--rounded-xs);
font: var(--type-body-sm);
border: 1px solid var(--hairline);
background: var(--surface-2);
color: var(--ink-secondary);
}
.alert-error { border-color: var(--sticker-red); color: var(--sticker-red); }
/* ---------- dashboard chrome ---------- */
.nav-bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-xs) var(--space-md);
min-height: 56px;
background: var(--surface);
border-bottom: 1px solid var(--hairline);
}
.nav-bar .wordmark { font: var(--type-title); color: var(--ink); }
.nav-bar .nav-actions { display: flex; align-items: center; gap: var(--space-xs); }
.page { max-width: var(--container-max); margin: 0 auto; padding: var(--space-lg) var(--space-md); }
.greeting { font: var(--type-heading-1); color: var(--ink); margin-bottom: var(--space-lg); }
.shell {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-md);
align-items: start;
}
@media (min-width: 760px) {
.shell { grid-template-columns: 220px minmax(0, 1fr); }
}
@media (min-width: 1080px) {
.shell { grid-template-columns: 220px minmax(0, 1fr) 340px; }
}
.panel {
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--rounded-lg);
overflow: hidden;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--hairline);
}
.panel-head h2 { font: var(--type-title); color: var(--ink); }
.panel-body { padding: var(--space-md); }
/* left nav: vertical list on desktop, horizontal scroller on phone */
.nav-groups { display: flex; flex-direction: column; gap: var(--space-md); padding: var(--space-md); }
@media (max-width: 759px) {
.nav-groups {
flex-direction: row;
overflow-x: auto;
gap: var(--space-md);
padding: var(--space-xs) var(--space-sm);
-webkit-overflow-scrolling: touch;
}
.nav-group { min-width: max-content; }
.nav-group-items { flex-direction: row; }
}
.nav-group-title {
font: var(--type-eyebrow);
color: var(--ink-faint);
text-transform: uppercase;
letter-spacing: .125px;
margin-bottom: var(--space-xs);
}
.nav-group-items { display: flex; flex-direction: column; gap: 2px; }
.nav-item {
display: flex;
align-items: center;
gap: var(--space-xs);
min-height: 44px;
padding: 0 var(--space-sm);
border-radius: var(--rounded-sm);
font: var(--type-body-sm);
color: var(--ink-secondary);
}
.nav-item:hover { background: var(--surface-2); text-decoration: none; }
.nav-item .dot {
width: 8px; height: 8px; border-radius: var(--rounded-full);
background: var(--ink-faint); flex: 0 0 auto;
}
.nav-item .count { margin-left: auto; font: var(--type-caption); color: var(--ink-faint); }
/* centre list */
.msg-list { display: flex; flex-direction: column; }
.msg-row {
display: flex;
align-items: center;
gap: var(--space-sm);
min-height: 64px;
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--hairline);
}
.msg-row:last-child { border-bottom: 0; }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
text-align: center;
padding: var(--space-xxl) var(--space-lg);
}
.empty-state .empty-title { font: var(--type-title); color: var(--ink); }
.empty-state .empty-copy { font: var(--type-body-sm); color: var(--ink-muted); max-width: 32ch; }
.detail-body { padding: var(--space-lg) var(--space-md); }
/* dark-mode toggle shows the right glyph */
.theme-toggle .icon-dark { display: none; }
[data-theme="dark"] .theme-toggle .icon-dark { display: inline; }
[data-theme="dark"] .theme-toggle .icon-light { display: none; }
/* subtle, non-decorative loading pulse (no spinners) */
.htmx-request .empty-state { opacity: .55; transition: opacity .15s ease; }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

110
web/static/tokens.css Normal file
View File

@@ -0,0 +1,110 @@
/* ============================================================
Family Home Lab — Design Tokens
Source: DESIGN.md (Notion design language)
Only structural/semantic tokens here. All values map 1:1.
============================================================ */
/* Inter (@fontsource) — self-hosted substitute for NotionInter */
@font-face { font-family: "Inter"; font-style: normal; font-weight: 400; font-display: swap; src: url("/static/fonts/inter-400.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 500; font-display: swap; src: url("/static/fonts/inter-500.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 600; font-display: swap; src: url("/static/fonts/inter-600.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 700; font-display: swap; src: url("/static/fonts/inter-700.woff2") format("woff2"); }
:root {
/* --- Surface --- */
--canvas-soft: #f6f5f4;
--surface: #ffffff;
--surface-2: #f2f2f1;
--hairline: #e6e6e6;
/* --- Text --- */
--ink: #000000;
--ink-secondary: #31302e;
--ink-muted: #615d59;
--ink-faint: #a39e98;
/* --- Structural accents --- */
--primary: #0075de; /* the ONLY action colour */
--primary-active: #005bab;
--on-primary: #ffffff;
--secondary: #213183; /* deep indigo — login band + greeting only */
/* --- Sticker palette (decorative only) --- */
--sticker-sky: #62aef0; /* chat */
--sticker-purple: #d6b6f6; /* image */
--sticker-pink: #ff64c8; /* video */
--sticker-orange: #dd5b00; /* audio */
--sticker-teal: #2a9d99; /* docs */
--sticker-green: #1aae39; /* status: online/saved */
--sticker-red: #d33a2b; /* status: failed / error (ON_OFF.md decision) */
--sticker-purple-deep: #391c57; /* illustration only */
--sticker-orange-deep: #793400; /* illustration only */
/* --- Typography (Inter, self-hosted via @font-face) --- */
--font-family: "Inter", -apple-system, system-ui, "Segoe UI",
Helvetica, Arial, sans-serif;
/* Font-family is baked into each token below: the CSS `font` shorthand
resets font-family to the UA default (serif) unless a family is written,
and it cannot carry letter-spacing — so weights/sizes ship inline and the
family is appended per token. */
--type-display-1: 700 48px/1.05 var(--font-family);
--type-heading-1: 700 32px/1.1 var(--font-family);
--type-heading-2: 700 22px/1.27 var(--font-family);
--type-title: 600 17px/1.4 var(--font-family);
--type-body-md: 400 15px/1.5 var(--font-family);
--type-body-sm: 400 14px/1.4 var(--font-family);
--type-button: 500 15px/1.4 var(--font-family);
--type-caption: 400 13px/1.43 var(--font-family);
--type-eyebrow: 600 12px/1.33 var(--font-family);
/* --- Layout (base unit 8px) --- */
--space-xxs: 4px;
--space-xs: 8px;
--space-sm: 12px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 28px;
--space-xxl: 32px;
--container-max: 1200px;
/* --- Elevation --- */
--shadow-level-1: rgba(0, 0, 0, 0.01) 0 0.175px 1px,
rgba(0, 0, 0, 0.02) 0 0.8px 3px,
rgba(0, 0, 0, 0.027) 0 2px 8px,
rgba(0, 0, 0, 0.04) 0 4px 18px;
--shadow-level-2: rgba(0, 0, 0, 0.0122) 0 0.175px 1px,
rgba(0, 0, 0, 0.024) 0 0.8px 3px,
rgba(0, 0, 0, 0.032) 0 2px 8px,
rgba(0, 0, 0, 0.047) 0 4px 18px,
rgba(0, 0, 0, 0.05) 0 23px 52px;
/* --- Shapes --- */
--rounded-xs: 4px;
--rounded-sm: 5px;
--rounded-md: 8px;
--rounded-lg: 12px;
--rounded-full: 9999px;
}
/* Dark mode — flip the semantic tokens only (components use var(--…)). */
[data-theme="dark"] {
--canvas-soft: #141518;
--surface: #1c1e22;
--surface-2: #24262c;
--hairline: #33363d;
--ink: #f5f6f7;
--ink-secondary: #dcdee2;
--ink-muted: #a9adb6;
--ink-faint: #727680;
--primary: #3d8be0;
--primary-active: #5d9ff0;
--on-primary: #ffffff;
--secondary: #10142a;
--shadow-level-1: rgba(0, 0, 0, 0.45) 0 1px 3px;
--shadow-level-2: rgba(0, 0, 0, 0.55) 0 5px 22px;
}
/* Numerals: enable OpenType lnum */
html { font-feature-settings: "lnum" 1; }

View File

@@ -0,0 +1,97 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Email Agent</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script>
(function () {
try {
var t = localStorage.getItem("ea-theme");
if (!t) t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
document.documentElement.setAttribute("data-theme", t);
} catch (e) {}
})();
function eaToggleTheme() {
var el = document.documentElement;
var next = el.getAttribute("data-theme") === "dark" ? "light" : "dark";
el.setAttribute("data-theme", next);
try { localStorage.setItem("ea-theme", next); } catch (e) {}
}
</script>
</head>
<body>
<header class="nav-bar">
<span class="wordmark">Email Agent</span>
<div class="nav-actions">
<button class="btn btn-utility theme-toggle" type="button" onclick="eaToggleTheme()" aria-label="Toggle dark mode">
<span class="icon-light" aria-hidden="true">&#9790;</span>
<span class="icon-dark" aria-hidden="true">&#9728;</span>
</button>
<form method="post" action="/logout">
<input type="hidden" name="csrf" value="{{.CSRFToken}}">
<button class="btn btn-utility" type="submit">Log out</button>
</form>
</div>
</header>
<main class="page">
<h1 class="greeting">{{.Greeting}}, Sam</h1>
<div class="shell">
<aside class="panel" aria-label="Categories and folders">
<div class="nav-groups">
<div class="nav-group">
<div class="nav-group-title">Categories</div>
<div class="nav-group-items">
{{range .Categories}}
<a class="nav-item" href="#">
<span class="dot" style="background: {{.Color}}"></span>
<span>{{.Name}}</span>
<span class="count">0</span>
</a>
{{end}}
</div>
</div>
<div class="nav-group">
<div class="nav-group-title">Folders</div>
<div class="nav-group-items">
{{range .Folders}}
<a class="nav-item" href="#">
<span class="dot" style="background: {{.Color}}"></span>
<span>{{.Name}}</span>
<span class="count">0</span>
</a>
{{end}}
</div>
</div>
</div>
</aside>
<section class="panel" aria-label="Message list">
<div class="panel-head">
<h2>Inbox</h2>
<button class="btn btn-utility" type="button"
hx-get="/partials/inbox" hx-target="#inbox-list" hx-swap="innerHTML">
Refresh
</button>
</div>
<div id="inbox-list" class="msg-list"
hx-get="/partials/inbox" hx-trigger="load" hx-swap="innerHTML"></div>
</section>
<aside class="panel detail-pane" aria-label="Message detail">
<div class="panel-head"><h2>Message</h2></div>
<div class="detail-body">
<p class="muted">Select a message to read it here.</p>
<p class="faint" style="margin-top: var(--space-xs);">Recommended action, reply draft, and history will appear in this pane.</p>
</div>
</aside>
</div>
</main>
<script src="/static/htmx.min.js" defer></script>
</body>
</html>

5
web/templates/inbox.html Normal file
View File

@@ -0,0 +1,5 @@
<div class="empty-state">
<div class="empty-title">No messages yet</div>
<p class="empty-copy">Mail fetch arrives in Phase 2. New messages will appear here, grouped by category, with a recommended action on each row.</p>
<span class="pill">IMAP scan not connected</span>
</div>

45
web/templates/login.html Normal file
View File

@@ -0,0 +1,45 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · Email Agent</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script>
(function () {
try {
var t = localStorage.getItem("ea-theme");
if (!t) t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
document.documentElement.setAttribute("data-theme", t);
} catch (e) {}
})();
</script>
</head>
<body class="login-body">
<main class="login-wrap">
<h1 class="login-greeting">{{.PartOfDay}}, Sam</h1>
<form class="auth-card" method="post" action="/login" autocomplete="on">
<div>
<p class="auth-brand">Email Agent</p>
<h2>Sign in</h2>
<p class="muted">The family inbox triage desk.</p>
</div>
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
{{if not .Configured}}<div class="alert" role="status">Login is not configured. Set <code>EMAIL_AGENT_PASSWORD</code> (or <code>EMAIL_AGENT_SESSION_SECRET</code>) on the host.</div>{{end}}
<input type="hidden" name="csrf" value="{{.CSRFToken}}">
<input type="hidden" name="next" value="{{.Next}}">
<div class="field">
<label for="password">Password</label>
<input class="text-input" type="password" id="password" name="password"
autocomplete="current-password" required {{if .Configured}}autofocus{{end}}>
</div>
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
</form>
</main>
</body>
</html>