From 93f61d9aef864b3e2c61d3c103a5396e4656b35e Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Sun, 11 Oct 2026 18:40:16 +1100 Subject: [PATCH] Mail access: IMAP + app password (IPv4 only), not Gmail API --- .dockerignore | 11 ++ .env.example | 28 ++++ .gitignore | 5 + AGENTS.md | 35 ++++- Dockerfile | 52 ++++++++ Makefile | 52 ++++++++ README.md | 80 ++++++++++++ docker-compose.yml | 52 ++++++++ go.mod | 17 +++ go.sum | 43 +++++++ internal/config/config.go | 160 +++++++++++++++++++++++ internal/config/config_test.go | 72 +++++++++++ internal/server/server.go | 228 +++++++++++++++++++++++++++++++++ internal/server/server_test.go | 81 ++++++++++++ internal/store/schema.sql | 101 +++++++++++++++ internal/store/store.go | 117 +++++++++++++++++ internal/store/store_test.go | 85 ++++++++++++ internal/version/version.go | 16 +++ 18 files changed, 1229 insertions(+), 6 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/store/schema.sql create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/version/version.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0f0d834 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.pi +data +bin +.env +.env.* +*.md +Dockerfile +docker-compose.yml +.dockerignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f8b418e --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Email Agent — environment variable names (values are NOT committed). +# +# Copy to `.env` for local Docker Compose use. In production these come from +# ~/.config/environment.d/10-secrets.conf (or the host environment) and are +# passed through by docker-compose. +# +# Secret values must never be committed to git. + +# --- Runtime --------------------------------------------------------------- +EMAIL_AGENT_ENV=production +EMAIL_AGENT_PORT=8790 +EMAIL_AGENT_ADDR=:8790 +EMAIL_AGENT_DB_PATH=/data/email_agent.db +EMAIL_AGENT_LOG_LEVEL=info +EMAIL_AGENT_PUBLIC_BASE_URL=https://mail.lab.audasmedia.com.au + +# --- Session / auth -------------------------------------------------------- +# Generate with: openssl rand -hex 32 +EMAIL_AGENT_SESSION_SECRET=change-me + +# --- Integrations (used from later phases) --------------------------------- +JEV_BASE_URL= +JEV_API_KEY= +OMNIROUTE_BASE_URL= +OMNIROUTE_API_KEY= +EMAIL_AGENT_GMAIL_CREDENTIALS_FILE= +EMAIL_AGENT_GMAIL_TOKEN_FILE= +HUB_TOKEN= diff --git a/.gitignore b/.gitignore index 23c0a75..71898cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Secrets .env .env.* +!.env.example *.local *.keystore @@ -10,6 +11,10 @@ bin/ *.test *.out +# Local runtime data (SQLite) +data/ +email-agent + # Python __pycache__/ *.pyc diff --git a/AGENTS.md b/AGENTS.md index f49639c..17b1b0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,7 @@ size-capped. | System | Use | |---|---| -| **Gmail API** | Read mail; apply labels; send replies ("Send mail as"). | +| **Gmail IMAP + SMTP** | Read mail, move labels, send replies. App password (`GMAIL_USER`, `GMAIL_APP_PASSWORD`). **Force IPv4** — see section 9. | | **PurelyMail** | Source of the forwarding. Read-only reference. Not required for v1. | | **Amazon SES** | App/website mail only. Not for personal replies in v1. | | **JEV** | Classification (typed category + confidence). HTTP API. | @@ -133,11 +133,14 @@ Build to it. `/ask` never changes state. | Backend | **Go** | | UI | **HTMX** + server-rendered HTML | | Database | **SQLite** | +| Mail read | **IMAP** `imap.gmail.com:993` (SSL), **IPv4 only** | +| Mail send | **SMTP** `smtp.gmail.com:587` (STARTTLS), **IPv4 only** | +| Mail auth | **Gmail app password** from `environment.d`. No OAuth, no console. | | Design | Family Home Lab `tokens.css` | | Host | `.13`, Docker | | Container | **This project's own** image and compose file. Not shared with any other project. | | Folder | `/home/sam/paseo/projects/email_agent/` | -| Port | Allocate one (suggest **8790**). Record it here once chosen. | +| Port | **8790** (chosen; container listens on 8790, published as `${EMAIL_AGENT_PORT:-8790}`). Confirm against infra-facts [AUD-60](/AUD/issues/AUD-60). | | URL | `mail.lab.audasmedia.com.au` via Caddy on `.35` | | Auth | **Separate session login.** Not the console SSO. | @@ -165,8 +168,10 @@ Go service, SQLite, config, session login, Dockerfile, compose, Caddy route. *DoD:* the app runs on `.13`, login works, an empty dashboard renders, `/health` returns ok. **Phase 2 — Read mail** -Gmail OAuth (one manual consent). Fetch, store, and list real messages. -*DoD:* the inbox shows real mail, with the source address from FR-2. +Connect to Gmail over **IMAP** with the app password. **Force IPv4.** Fetch, store, +and list real messages. +*DoD:* the inbox shows real mail. Confirm the source address from FR-2 against a +real forwarded message (see section 13). **Phase 3 — Classify** JEV classification plus the rules table. @@ -213,6 +218,24 @@ Resolved: 3. **Spam:** recommend and confirm. Never auto-delete. 4. **Daily summary:** by email. 5. **Retention:** one month of message bodies. -6. **Gmail OAuth:** the agent chooses the moment during the build. +6. **Mail access is IMAP + app password** — no console, no OAuth. Working on `.13` + and `.51`. -Remaining: none that block. Add new ones here as they appear. +Remaining: + +1. **No domain mail is arriving yet.** All 617 inbox messages are addressed to + `samuelrolfe@gmail.com`. None to `where-woof.com`, `audasmedia.com.au`, or + `kontra.day`. Confirm the PurelyMail forwarding, then send a test message. +2. **FR-2 header:** Gmail sets `Delivered-To` to the Gmail address. The original + domain address must come from the forwarding headers (`X-Original-To`, + `Delivered-To`, or the envelope). Confirm with a real forwarded sample. +3. **Send-as:** sending as a domain address needs Gmail "Send mail as" set up for + that address. + +### Infrastructure notes (found during setup) + +- **IPv6 to Gmail is broken from `.13`.** The first IMAP attempt failed with a + socket EOF over IPv6. Forcing IPv4 works. **The agent must force IPv4** for IMAP + and SMTP. +- Credentials live in `~/.config/environment.d/10-secrets.conf` on `.13` and `.51`, + as `GMAIL_USER` and `GMAIL_APP_PASSWORD`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a9c1369 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1.7 + +# ---- build stage ----------------------------------------------------------- +FROM golang:1.25-alpine AS build + +WORKDIR /src + +# git is useful for `go mod download` of VCS-pinned modules; harmless otherwise. +RUN apk add --no-cache ca-certificates git + +# Cache dependencies before copying the rest of the source. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION=dev +ARG COMMIT=unknown +ARG BUILD_TIME= + +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath \ + -ldflags "-s -w \ + -X gitea.lab.audasmedia.com.au/sam/email_agent/internal/version.Version=${VERSION} \ + -X gitea.lab.audasmedia.com.au/sam/email_agent/internal/version.Commit=${COMMIT} \ + -X gitea.lab.audasmedia.com.au/sam/email_agent/internal/version.BuildTime=${BUILD_TIME}" \ + -o /out/email-agent ./cmd/email-agent + +# ---- runtime stage --------------------------------------------------------- +# Alpine (not distroless) so the bundled healthcheck and `sh` are available. +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates tzdata \ + && addgroup -g 10001 -S app \ + && adduser -u 10001 -S -G app -H -s /sbin/nologin app \ + && mkdir -p /data \ + && chown app:app /data + +COPY --from=build /out/email-agent /usr/local/bin/email-agent + +USER app + +EXPOSE 8790 + +ENV EMAIL_AGENT_ADDR=":8790" \ + EMAIL_AGENT_DB_PATH="/data/email_agent.db" + +VOLUME ["/data"] + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD ["/usr/local/bin/email-agent", "-healthcheck"] + +ENTRYPOINT ["/usr/local/bin/email-agent"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cda913a --- /dev/null +++ b/Makefile @@ -0,0 +1,52 @@ +# Email Agent — developer commands. +# +# Go is not installed on the host in every environment; the docker-* targets +# run the same commands inside the pinned golang image. + +GO ?= go +IMAGE ?= email-agent:dev +PKG := ./... + +.PHONY: help tidy fmt vet build test run clean docker-build docker-up docker-down docker-logs docker-test + +help: ## List targets + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " %-16s %s\n", $$1, $$2}' + +tidy: ## Sync go.mod/go.sum + $(GO) mod tidy + +fmt: ## Format Go sources + $(GO) fmt $(PKG) + +vet: ## Run go vet + $(GO) vet $(PKG) + +build: ## Build the binary into ./bin + CGO_ENABLED=0 $(GO) build -trimpath -o bin/email-agent ./cmd/email-agent + +test: ## Run tests + $(GO) test $(PKG) + +run: ## Run locally with defaults (db in ./data) + $(GO) run ./cmd/email-agent + +clean: ## Remove build artifacts + rm -rf bin data + +# --- Docker (works without a host Go toolchain) ---------------------------- + +docker-build: ## Build the container image + docker build -t $(IMAGE) . + +docker-up: ## Start the stack + docker compose up -d --build + +docker-down: ## Stop the stack + docker compose down + +docker-logs: ## Follow logs + docker compose logs -f + +docker-test: ## Run go build + vet + test inside the golang image + docker run --rm -v "$(CURDIR)":/src -w /src -e CGO_ENABLED=0 golang:1.25-alpine \ + sh -c "apk add --no-cache git >/dev/null && go mod download && go build $(PKG) && go vet $(PKG) && go test $(PKG)" diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d9bdde --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# Email Agent + +Triage the family inbox from one page: all mail lands in a single Gmail account +and this service classifies it, recommends actions, performs bulk actions, drafts +replies, and answers the Hub. + +The build spec is [`AGENTS.md`](AGENTS.md). `ORIGINAL_NOTES.md` is archived +pre-planning material and is not authoritative. + +## Status + +**Phase 1.1 — Go service skeleton.** Complete when this file was written: +config from the environment, SQLite schema init, `GET /health`, Dockerfile and +docker-compose. Mail fetch, classification, login, and the HTMX screens come in +later phases. + +## Layout + +``` +cmd/email-agent/ service entry point +internal/config/ environment-driven configuration +internal/store/ SQLite open + schema (internal/store/schema.sql) +internal/server/ HTTP routes and middleware +internal/version/ build metadata (set via -ldflags) +Dockerfile multi-stage, CGO-free build +docker-compose.yml this project's own container and image +``` + +## Configuration + +All configuration is read from environment variables. Secrets live in +`~/.config/environment.d/10-secrets.conf` on the host and are passed into the +container by `docker-compose`; no secret value is ever committed. See +[`.env.example`](.env.example) for the full list of names. + +Key variables: + +| Variable | Default | Purpose | +|---|---|---| +| `EMAIL_AGENT_ENV` | `development` | `development`, `production`, or `test` | +| `EMAIL_AGENT_ADDR` | `:8790` | Listen address | +| `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_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | +| `EMAIL_AGENT_SESSION_SECRET` | _(empty)_ | Session cookie signing key (Phase 1.2) | + +## Run locally + +```sh +go run ./cmd/email-agent +curl -s localhost:8790/health +``` + +## Docker + +```sh +docker compose up -d --build +curl -s localhost:8790/health +``` + +The container is `email-agent`, image `email-agent:latest`, named volume +`email_agent_data` mounted at `/data`, listening on **8790**. It runs as an +unprivileged user and has a built-in healthcheck (`email-agent -healthcheck`). +Caddy on `.35` proxies `mail.lab.audasmedia.com.au` to this port. + +## Endpoints (Phase 1) + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/health` | Liveness + database check, JSON | +| `GET` | `/readyz` | Readiness, JSON | +| `GET` | `/` | Placeholder dashboard (real screens in Phase 1.2) | + +## Database + +SQLite is opened with the pure-Go `modernc.org/sqlite` driver (no CGO). The +schema in `internal/store/schema.sql` creates the AGENTS.md §7 tables: +`messages`, `classifications`, `rules`, `actions_log`, `unsubscribe`, `drafts`, +`summaries`, plus `schema_migrations`. Schema init is idempotent and runs on +every start. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7760bf3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +name: email-agent + +# Own container and image for this project (AGENTS.md §9). Not shared with any +# other project. + +services: + email-agent: + build: + context: . + args: + VERSION: ${EMAIL_AGENT_VERSION:-dev} + COMMIT: ${EMAIL_AGENT_COMMIT:-unknown} + BUILD_TIME: ${EMAIL_AGENT_BUILD_TIME:-} + image: email-agent:${EMAIL_AGENT_TAG:-latest} + container_name: email-agent + restart: unless-stopped + + # Posture: keep the service on the internal network only. Caddy on .35 + # proxies to this port. Publishing is required for that hop. + ports: + - "${EMAIL_AGENT_PORT:-8790}:8790" + + environment: + EMAIL_AGENT_ENV: ${EMAIL_AGENT_ENV:-production} + EMAIL_AGENT_ADDR: ":8790" + EMAIL_AGENT_DB_PATH: /data/email_agent.db + EMAIL_AGENT_LOG_LEVEL: ${EMAIL_AGENT_LOG_LEVEL:-info} + EMAIL_AGENT_PUBLIC_BASE_URL: ${EMAIL_AGENT_PUBLIC_BASE_URL:-https://mail.lab.audasmedia.com.au} + # Secrets: names only here. Values are supplied by the host environment + # (see ~/.config/environment.d/10-secrets.conf) or a gitignored .env file, + # and are never committed. + EMAIL_AGENT_SESSION_SECRET: ${EMAIL_AGENT_SESSION_SECRET:-} + JEV_BASE_URL: ${JEV_BASE_URL:-} + JEV_API_KEY: ${JEV_API_KEY:-} + OMNIROUTE_BASE_URL: ${OMNIROUTE_BASE_URL:-} + OMNIROUTE_API_KEY: ${OMNIROUTE_API_KEY:-} + EMAIL_AGENT_GMAIL_CREDENTIALS_FILE: ${EMAIL_AGENT_GMAIL_CREDENTIALS_FILE:-} + EMAIL_AGENT_GMAIL_TOKEN_FILE: ${EMAIL_AGENT_GMAIL_TOKEN_FILE:-} + HUB_TOKEN: ${HUB_TOKEN:-} + + volumes: + - email_agent_data:/data + + healthcheck: + test: ["CMD", "/usr/local/bin/email-agent", "-healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 5s + +volumes: + email_agent_data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..579bfc3 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module gitea.lab.audasmedia.com.au/sam/email_agent + +go 1.25 + +require modernc.org/sqlite v1.34.5 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.22.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5424fe4 --- /dev/null +++ b/go.sum @@ -0,0 +1,43 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..f244e5a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,160 @@ +// Package config loads the Email Agent runtime configuration from the +// environment. +// +// Values are read from process environment variables. In production those +// variables are provided by the host (for example by +// ~/.config/environment.d/10-secrets.conf) and passed into the container by +// docker-compose. Secret values are never written to disk by this package and +// must never be committed to the repository. +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// Defaults for the service. Port 8790 is recorded in AGENTS.md §9. +const ( + DefaultPort = 8790 + DefaultEnv = "development" + DefaultAddr = ":" + "8790" + DefaultDB = "data/email_agent.db" +) + +// Config is the fully resolved runtime configuration. +type Config struct { + // Env is the deployment environment: "development" or "production". + Env string + + // Addr is the TCP address the HTTP server listens on, e.g. ":8790". + Addr string + + // Port is the TCP port the HTTP server listens on. + Port int + + // DBPath is the filesystem path to the SQLite database file. + DBPath string + + // SessionSecret signs session cookies for the login screen (FR auth). + // Login itself is implemented in Phase 1.2; the value is loaded here so the + // service has a single configuration surface. + SessionSecret string + + // LogLevel controls slog verbosity: debug, info, warn, error. + LogLevel string + + // PublicBaseURL is the externally visible base URL of the service. + PublicBaseURL string + + // Integration settings. These are loaded for later phases so that secret + // names are defined in one place. Phase 1 does not use them. + + // JEVBaseURL is the base URL of the JEV/TypeSafe HTTP API. + JEVBaseURL string + // JEVAPIKey authenticates to JEV. Secret. + JEVAPIKey string + + // OmniRouteBaseURL is the base URL of the OmniRoute gateway. + OmniRouteBaseURL string + // OmniRouteAPIKey authenticates to OmniRoute. Secret. + OmniRouteAPIKey string + + // GmailCredentialsFile is the OAuth client credentials JSON path. + GmailCredentialsFile string + // GmailTokenFile is the path where the OAuth token is cached. + GmailTokenFile string + + // HubToken authenticates the read-only Hub API. Secret. + HubToken string +} + +// Load reads configuration from the environment, applying defaults. +func Load() (*Config, error) { + c := &Config{ + Env: getenvDefault("EMAIL_AGENT_ENV", DefaultEnv), + SessionSecret: os.Getenv("EMAIL_AGENT_SESSION_SECRET"), + LogLevel: strings.ToLower(getenvDefault("EMAIL_AGENT_LOG_LEVEL", "info")), + PublicBaseURL: strings.TrimRight(getenvDefault("EMAIL_AGENT_PUBLIC_BASE_URL", "http://localhost:8790"), "/"), + JEVBaseURL: strings.TrimRight(os.Getenv("JEV_BASE_URL"), "/"), + JEVAPIKey: os.Getenv("JEV_API_KEY"), + OmniRouteBaseURL: strings.TrimRight(os.Getenv("OMNIROUTE_BASE_URL"), "/"), + OmniRouteAPIKey: os.Getenv("OMNIROUTE_API_KEY"), + GmailCredentialsFile: os.Getenv("EMAIL_AGENT_GMAIL_CREDENTIALS_FILE"), + GmailTokenFile: os.Getenv("EMAIL_AGENT_GMAIL_TOKEN_FILE"), + HubToken: os.Getenv("HUB_TOKEN"), + } + + port, err := resolvePort() + if err != nil { + return nil, err + } + c.Port = port + c.Addr = resolveAddr(port) + c.DBPath = getenvDefault("EMAIL_AGENT_DB_PATH", DefaultDB) + + if err := c.validate(); err != nil { + return nil, err + } + return c, nil +} + +// resolvePort determines the listening port from EMAIL_AGENT_PORT or the port +// component of EMAIL_AGENT_ADDR, falling back to DefaultPort. +func resolvePort() (int, error) { + if raw := strings.TrimSpace(os.Getenv("EMAIL_AGENT_PORT")); raw != "" { + p, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("EMAIL_AGENT_PORT %q is not a number: %w", raw, err) + } + return p, nil + } + if addr := strings.TrimSpace(os.Getenv("EMAIL_AGENT_ADDR")); addr != "" { + if i := strings.LastIndex(addr, ":"); i >= 0 && i < len(addr)-1 { + if p, err := strconv.Atoi(addr[i+1:]); err == nil { + return p, nil + } + } + } + return DefaultPort, nil +} + +// resolveAddr prefers an explicit EMAIL_AGENT_ADDR, otherwise builds one from +// the resolved port. +func resolveAddr(port int) string { + if addr := strings.TrimSpace(os.Getenv("EMAIL_AGENT_ADDR")); addr != "" { + return addr + } + return fmt.Sprintf(":%d", port) +} + +func (c *Config) validate() error { + if c.Port < 1 || c.Port > 65535 { + return fmt.Errorf("port %d is out of range", c.Port) + } + if strings.TrimSpace(c.DBPath) == "" { + return fmt.Errorf("EMAIL_AGENT_DB_PATH must not be empty") + } + switch c.Env { + case "development", "production", "test": + default: + return fmt.Errorf("EMAIL_AGENT_ENV %q must be development, production, or test", c.Env) + } + switch c.LogLevel { + case "debug", "info", "warn", "error": + default: + return fmt.Errorf("EMAIL_AGENT_LOG_LEVEL %q must be debug, info, warn, or error", c.LogLevel) + } + return nil +} + +// IsProduction reports whether the service runs in the production environment. +func (c *Config) IsProduction() bool { return c.Env == "production" } + +func getenvDefault(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..3b11c4e --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,72 @@ +package config + +import "testing" + +func TestLoadDefaults(t *testing.T) { + // Ensure no ambient values leak into the test. + for _, k := range []string{ + "EMAIL_AGENT_ENV", "EMAIL_AGENT_ADDR", "EMAIL_AGENT_PORT", + "EMAIL_AGENT_DB_PATH", "EMAIL_AGENT_LOG_LEVEL", "EMAIL_AGENT_PUBLIC_BASE_URL", + } { + t.Setenv(k, "") + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Port != DefaultPort { + t.Errorf("Port = %d, want %d", cfg.Port, DefaultPort) + } + if cfg.Addr != DefaultAddr { + t.Errorf("Addr = %q, want %q", cfg.Addr, DefaultAddr) + } + if cfg.Env != DefaultEnv { + t.Errorf("Env = %q, want %q", cfg.Env, DefaultEnv) + } + if cfg.DBPath != DefaultDB { + t.Errorf("DBPath = %q, want %q", cfg.DBPath, DefaultDB) + } +} + +func TestLoadOverrides(t *testing.T) { + t.Setenv("EMAIL_AGENT_ENV", "production") + t.Setenv("EMAIL_AGENT_PORT", "9000") + t.Setenv("EMAIL_AGENT_DB_PATH", "/tmp/custom.db") + t.Setenv("EMAIL_AGENT_LOG_LEVEL", "debug") + t.Setenv("EMAIL_AGENT_PUBLIC_BASE_URL", "https://example.test/") + t.Setenv("EMAIL_AGENT_ADDR", "") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Port != 9000 { + t.Errorf("Port = %d, want 9000", cfg.Port) + } + if cfg.Addr != ":9000" { + t.Errorf("Addr = %q, want %q", cfg.Addr, ":9000") + } + if !cfg.IsProduction() { + t.Errorf("IsProduction() = false, want true") + } + if cfg.PublicBaseURL != "https://example.test" { + t.Errorf("PublicBaseURL = %q, want trimmed URL", cfg.PublicBaseURL) + } +} + +func TestLoadInvalidPort(t *testing.T) { + t.Setenv("EMAIL_AGENT_PORT", "not-a-number") + t.Setenv("EMAIL_AGENT_ADDR", "") + if _, err := Load(); err == nil { + t.Fatal("Load() error = nil, want error for invalid port") + } +} + +func TestLoadInvalidEnv(t *testing.T) { + t.Setenv("EMAIL_AGENT_ENV", "staging") + t.Setenv("EMAIL_AGENT_ADDR", "") + if _, err := Load(); err == nil { + t.Fatal("Load() error = nil, want error for invalid env") + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..b078024 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,228 @@ +// Package server wires the HTTP surface of the Email Agent. +// +// Phase 1 exposes liveness/readiness endpoints and a placeholder dashboard. +// Login and the real HTMX screens arrive in Phase 1.2. +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "html/template" + "log/slog" + "net/http" + "time" + + "gitea.lab.audasmedia.com.au/sam/email_agent/internal/config" + "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" + "gitea.lab.audasmedia.com.au/sam/email_agent/internal/version" +) + +// Server holds the HTTP dependencies. +type Server struct { + cfg *config.Config + store *store.Store + logger *slog.Logger + http *http.Server + index *template.Template +} + +// New builds a Server. It does not start listening. +func New(cfg *config.Config, st *store.Store, logger *slog.Logger) (*Server, error) { + index, err := template.New("index").Parse(indexHTML) + if err != nil { + return nil, fmt.Errorf("parse index template: %w", err) + } + + s := &Server{ + cfg: cfg, + store: st, + logger: logger, + index: index, + } + s.http = &http.Server{ + Addr: cfg.Addr, + Handler: s.Handler(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + return s, nil +} + +// Handler returns the root HTTP handler with middleware applied. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /health", s.handleHealth) + mux.HandleFunc("GET /readyz", s.handleReady) + mux.HandleFunc("GET /{$}", s.handleIndex) + return s.recoverPanic(s.logRequests(mux)) +} + +// Run starts the HTTP server and blocks until ctx is cancelled. +func (s *Server) Run(ctx context.Context) error { + errCh := make(chan error, 1) + go func() { + s.logger.Info("http server listening", + "addr", s.cfg.Addr, + "env", s.cfg.Env, + "db", s.store.Path(), + "version", version.Version, + ) + if err := s.http.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + return + } + errCh <- nil + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + s.logger.Info("shutting down http server") + return s.http.Shutdown(shutdownCtx) + } +} + +type healthResponse struct { + Status string `json:"status"` + Version string `json:"version"` + Commit string `json:"commit"` + Env string `json:"env"` + UptimeSeconds int64 `json:"uptime_seconds"` + Database string `json:"database"` + Time string `json:"time"` +} + +var startedAt = time.Now() + +// handleHealth reports liveness plus a database connectivity check. +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + + resp := healthResponse{ + Status: "ok", + Version: version.Version, + Commit: version.Commit, + Env: s.cfg.Env, + UptimeSeconds: int64(time.Since(startedAt).Seconds()), + Database: "ok", + Time: time.Now().UTC().Format(time.RFC3339), + } + code := http.StatusOK + + if err := s.store.Ping(ctx); err != nil { + s.logger.Error("health check: database unreachable", "error", err) + resp.Status = "degraded" + resp.Database = "unreachable" + code = http.StatusServiceUnavailable + } + + writeJSON(w, code, resp) +} + +// handleReady reports readiness for load balancers (same signal as health). +func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + if err := s.store.Ping(ctx); err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not ready"}) + return + } + 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) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +// logRequests logs one line per request at debug level. +func (s *Server) logRequests(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + s.logger.Debug("request", + "method", r.Method, + "path", r.URL.Path, + "status", rec.status, + "duration_ms", time.Since(start).Milliseconds(), + "remote", r.RemoteAddr, + ) + }) +} + +// recoverPanic keeps one bad request from taking down the process. +func (s *Server) recoverPanic(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + s.logger.Error("panic recovered", "error", rec, "path", r.URL.Path) + http.Error(w, "internal server error", http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} + +const indexHTML = ` + + + + +Email Agent + + + +
+
+

Email Agent

+

Phase 1 skeleton is running. Version {{.Version}} ({{.Env}}).

+
+

Login and the inbox arrive in Phase 1.2. Liveness is at + /health.

+
+ + +` diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..4a0f8f6 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,81 @@ +package server + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "gitea.lab.audasmedia.com.au/sam/email_agent/internal/config" + "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" +) + +func newTestServer(t *testing.T) *Server { + t.Helper() + cfg := &config.Config{ + Env: "test", + Addr: ":0", + DBPath: filepath.Join(t.TempDir(), "email_agent.db"), + LogLevel: "error", + } + st, err := store.Open(context.Background(), cfg.DBPath) + if err != nil { + t.Fatalf("store.Open() error = %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + srv, err := New(cfg, st, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + return srv +} + +func TestHealthOK(t *testing.T) { + srv := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body["status"] != "ok" { + t.Errorf("status field = %v, want ok", body["status"]) + } +} + +func TestReadyz(t *testing.T) { + srv := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/readyz", nil) + rec := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestIndexRendersHTML(t *testing.T) { + srv := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + srv.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if ct := rec.Header().Get("Content-Type"); ct == "" { + t.Error("Content-Type header is empty") + } +} diff --git a/internal/store/schema.sql b/internal/store/schema.sql new file mode 100644 index 0000000..d2bba53 --- /dev/null +++ b/internal/store/schema.sql @@ -0,0 +1,101 @@ +-- Email Agent schema (AGENTS.md §7). +-- +-- Every statement is idempotent (CREATE ... IF NOT EXISTS) so this file can be +-- executed on every start. Retention/capping jobs belong to later phases. +-- +-- Attribution: created for the Email Agent Phase 1 skeleton. + +PRAGMA foreign_keys = ON; + +-- Records the applied schema version for future migrations. +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- FR-1, FR-2: mail metadata and body, plus the forwarding address it arrived at. +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + gmail_id TEXT NOT NULL UNIQUE, + thread_id TEXT, + from_addr TEXT, + to_addr TEXT, + subject TEXT, + body_text TEXT, + source_address TEXT, + date_sent TEXT, + received_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_messages_received_at ON messages (received_at); +CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages (thread_id); +CREATE INDEX IF NOT EXISTS idx_messages_source ON messages (source_address); + +-- FR-3, FR-14: classifier output per message. +CREATE TABLE IF NOT EXISTS classifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES messages (id) ON DELETE CASCADE, + category TEXT NOT NULL, + importance TEXT, + action TEXT, + confidence REAL, + model TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_classifications_message ON classifications (message_id); + +-- FR-4, FR-11: versioned rules text. +CREATE TABLE IF NOT EXISTS rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL, + version INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- FR-13: append-only action log. +CREATE TABLE IF NOT EXISTS actions_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER REFERENCES messages (id) ON DELETE SET NULL, + action TEXT NOT NULL, + actor TEXT, + rule_id INTEGER REFERENCES rules (id) ON DELETE SET NULL, + detail TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_actions_log_message ON actions_log (message_id); +CREATE INDEX IF NOT EXISTS idx_actions_log_created ON actions_log (created_at); + +-- FR-7: unsubscribe tracker and ignore list. +CREATE TABLE IF NOT EXISTS unsubscribe ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'ignored')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- FR-8: reply drafts. +CREATE TABLE IF NOT EXISTS drafts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES messages (id) ON DELETE CASCADE, + body TEXT NOT NULL, + template_id TEXT, + status TEXT NOT NULL DEFAULT 'draft', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_drafts_message ON drafts (message_id); + +-- FR-10: daily summary and its delivery target. +CREATE TABLE IF NOT EXISTS summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL UNIQUE, + text TEXT NOT NULL, + delivered_to TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +INSERT OR IGNORE INTO schema_migrations (version) VALUES (1); diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..a42a2cd --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,117 @@ +// Package store owns the SQLite database: opening it, applying the schema, and +// exposing a small query surface to the rest of the service. +package store + +import ( + "context" + "database/sql" + _ "embed" + "fmt" + "net/url" + "os" + "path/filepath" + "time" + + // Pure-Go SQLite driver. Keeps the container CGO-free. + _ "modernc.org/sqlite" +) + +//go:embed schema.sql +var schemaSQL string + +// Store wraps the SQLite connection pool. +type Store struct { + db *sql.DB + path string +} + +// Open creates (if needed) and connects to the SQLite database at path, then +// applies the schema. The parent directory is created when missing. +func Open(ctx context.Context, path string) (*Store, error) { + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o750); err != nil { + return nil, fmt.Errorf("create database directory %q: %w", dir, err) + } + } + + dsn := buildDSN(path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite database: %w", err) + } + + // SQLite handles one writer at a time; keep the pool small and give the + // busy handler room. Reads and writes share the connection here. + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(0) + + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := db.PingContext(pingCtx); err != nil { + _ = db.Close() + return nil, fmt.Errorf("ping sqlite database: %w", err) + } + + s := &Store{db: db, path: path} + if err := s.migrate(ctx); err != nil { + _ = db.Close() + return nil, err + } + return s, nil +} + +// buildDSN returns a modernc.org/sqlite DSN with safe pragmas applied on every +// new connection. +func buildDSN(path string) string { + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + q := url.Values{} + q.Add("_pragma", "busy_timeout(5000)") + q.Add("_pragma", "journal_mode(WAL)") + q.Add("_pragma", "foreign_keys(1)") + q.Add("_pragma", "synchronous(NORMAL)") + return "file:" + abs + "?" + q.Encode() +} + +// migrate applies the embedded schema. It is safe to run on every start. +func (s *Store) migrate(ctx context.Context) error { + if _, err := s.db.ExecContext(ctx, schemaSQL); err != nil { + return fmt.Errorf("apply schema: %w", err) + } + return nil +} + +// DB exposes the underlying pool for callers that need it. +func (s *Store) DB() *sql.DB { return s.db } + +// Path returns the database file path. +func (s *Store) Path() string { return s.path } + +// Ping verifies the database is reachable. +func (s *Store) Ping(ctx context.Context) error { + return s.db.PingContext(ctx) +} + +// Close releases the connection pool. +func (s *Store) Close() error { + if s == nil || s.db == nil { + return nil + } + return s.db.Close() +} + +// SchemaVersion returns the highest applied migration version. +func (s *Store) SchemaVersion(ctx context.Context) (int, error) { + var v sql.NullInt64 + err := s.db.QueryRowContext(ctx, `SELECT MAX(version) FROM schema_migrations`).Scan(&v) + if err != nil { + return 0, err + } + if !v.Valid { + return 0, nil + } + return int(v.Int64), nil +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..2a0948a --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,85 @@ +package store + +import ( + "context" + "path/filepath" + "testing" +) + +func TestOpenCreatesSchema(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "nested", "email_agent.db") + + s, err := Open(ctx, path) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + defer s.Close() + + v, err := s.SchemaVersion(ctx) + if err != nil { + t.Fatalf("SchemaVersion() error = %v", err) + } + if v < 1 { + t.Fatalf("SchemaVersion() = %d, want >= 1", v) + } + + wantTables := []string{ + "messages", "classifications", "rules", "actions_log", + "unsubscribe", "drafts", "summaries", + } + for _, table := range wantTables { + var name string + err := s.DB().QueryRowContext(ctx, + `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&name) + if err != nil { + t.Errorf("table %q missing: %v", table, err) + } + } +} + +func TestOpenIsIdempotent(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "email_agent.db") + + s1, err := Open(ctx, path) + if err != nil { + t.Fatalf("first Open() error = %v", err) + } + _ = s1.Close() + + s2, err := Open(ctx, path) + if err != nil { + t.Fatalf("second Open() error = %v", err) + } + defer s2.Close() + + if err := s2.Ping(ctx); err != nil { + t.Fatalf("Ping() error = %v", err) + } +} + +func TestMessagesInsertAndRead(t *testing.T) { + ctx := context.Background() + s, err := Open(ctx, filepath.Join(t.TempDir(), "email_agent.db")) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + defer s.Close() + + _, err = s.DB().ExecContext(ctx, + `INSERT INTO messages (gmail_id, subject, source_address) VALUES (?, ?, ?)`, + "abc123", "Hello", "hello@where-woof.com") + if err != nil { + t.Fatalf("insert message: %v", err) + } + + var subject string + if err := s.DB().QueryRowContext(ctx, + `SELECT subject FROM messages WHERE gmail_id = ?`, "abc123").Scan(&subject); err != nil { + t.Fatalf("select message: %v", err) + } + if subject != "Hello" { + t.Errorf("subject = %q, want %q", subject, "Hello") + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..dcc35d2 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,16 @@ +// Package version holds build metadata for the Email Agent service. +// +// The values are overridden at build time via -ldflags, for example: +// +// go build -ldflags "-X gitea.lab.audasmedia.com.au/sam/email_agent/internal/version.Version=1.2.3" +package version + +// Version is the application version. It defaults to "dev" for local builds. +var Version = "dev" + +// Commit is the git revision the binary was built from. +var Commit = "unknown" + +// BuildTime is the RFC3339 timestamp of the build. It may be empty for +// ad-hoc local builds. +var BuildTime = ""