From 59b1469d2f824d5f2a433770c420b117411be11b Mon Sep 17 00:00:00 2001 From: Timo Taskinen Date: Fri, 16 Oct 2026 20:25:23 +0300 Subject: [PATCH 1/2] Add -version and -example-config for installed binaries Users will install the binary with go install or download it from a release, and then have neither the source nor config.yaml.example. - -version prints the version Go records in the binary: the tag for a release or go install ...@vX.Y.Z, a pseudo-version for go build in a checkout, "(devel)" for go run. The startup log shows it too. - -example-config prints the built-in config.yaml.example, so a config file is one command away. - A missing config file now says how to create one. The e2e tests and the docs run the package (go run ., go build .) instead of main.go: a file list builds without the version stamp and would leave out any other file of package main. --- CLAUDE.md | 30 ++++++---- config.yaml.example | 3 +- e2e/playwright.config.js | 2 +- main.go | 80 ++++++++++++++++++++++++- main_test.go | 124 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 main_test.go diff --git a/CLAUDE.md b/CLAUDE.md index e7977bb..7bd3b22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,8 @@ Mailbox Zero is a Go-based web application that helps users clean up their Fastm ### Project Structure ``` mailboxzero/ -├── main.go # Application entry point +├── main.go # Application entry point: flags -config, -version (Go's build info) and -example-config (embeds config.yaml.example) +├── main_test.go # Version string, the embedded example config and the missing-config message ├── go.mod / go.sum # Go module dependencies (gorilla/mux, yaml.v3) ├── config.yaml.example # Configuration template ├── config-mock.yaml.example # Mock-mode configuration template @@ -252,18 +253,25 @@ default_similarity: 60 # Default similarity percentage (0-100) go mod download go mod tidy -# Create configuration file from example +# Create configuration file from example (an installed binary prints the same +# file with: mailboxzero -example-config > config.yaml) cp config.yaml.example config.yaml # Edit config.yaml with your Fastmail API token -# Run the application -go run main.go +# Run the application (always the package ".", not main.go, so every file of +# package main is included) +go run . # Run with custom config -go run main.go -config custom-config.yaml +go run . -config custom-config.yaml # Build for production (a single binary that includes the web page) -go build -o mailboxzero main.go +go build -o mailboxzero . + +# Show the version: Go records it in the binary, as the tag for a release or +# go install ...@vX.Y.Z, a pseudo-version (+dirty with uncommitted changes) +# for go build, and "(devel)" for go run +./mailboxzero -version ``` ### Mock Mode for Testing @@ -275,7 +283,7 @@ For development and testing purposes, you can run the application in mock mode: cp config-mock.yaml.example config.yaml # Run in mock mode - no Fastmail credentials needed -go run main.go +go run . ``` **Mock Mode Features:** @@ -305,7 +313,7 @@ mock_mode: true # Enable mock mode ### Testing Commands ```bash # Quick start with mock data (no Fastmail account required) -go run main.go -config config-mock.yaml.example +go run . -config config-mock.yaml.example # Run all tests go test ./... @@ -338,7 +346,7 @@ golangci-lint run ### Production Deployment 1. **Build Binary:** the page and static files are built in, so the binary runs from any directory ```bash - go build -o mailboxzero main.go + go build -o mailboxzero . ``` 2. **Create Production Config:** @@ -373,7 +381,7 @@ The server only binds to loopback, so a published container port (`-p 8080:8080` FROM golang:1.26-alpine AS builder WORKDIR /app COPY . . -RUN go build -o mailboxzero main.go +RUN go build -o mailboxzero . FROM alpine:latest RUN apk --no-cache add ca-certificates @@ -445,7 +453,7 @@ log.SetFlags(log.LstdFlags | log.Lshortfile) 6. **Comprehensive Testing:** Unit tests for every package under `internal/` (table-driven where it fits), plus Playwright end-to-end tests of the UI ### Test Coverage -The project includes unit tests for every package under `internal/` (`main` and `web` have none): +The project includes unit tests for every package under `internal/` and for the command itself in `main_test.go` (`web` has none): - **Config Package:** Configuration loading, validation, and error handling - **JMAP Package:** Data parsing, mock client functionality, helper functions, and the real Client's requests and responses via a fake JMAP server (`newFakeJMAPServer` in `fakeserver_test.go`), including authentication against its session resource and bearer-token check - **Similarity Package:** Fuzzy matching algorithms, Levenshtein distance, email similarity diff --git a/config.yaml.example b/config.yaml.example index 7bd6aca..5bef41c 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,5 +1,6 @@ # Mailbox Zero Configuration Example -# Copy this file to config.yaml and update with your settings +# Save this as config.yaml and update it with your settings. An installed +# binary writes it out with: mailboxzero -example-config > config.yaml server: port: 8080 diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js index b7650d2..cb9f93a 100644 --- a/e2e/playwright.config.js +++ b/e2e/playwright.config.js @@ -18,7 +18,7 @@ module.exports = defineConfig({ headless: true, }, webServer: { - command: 'go run main.go -config e2e/config-e2e.yaml', + command: 'go run . -config e2e/config-e2e.yaml', cwd: '..', url: `http://localhost:${port}`, reuseExistingServer: false, diff --git a/main.go b/main.go index f26c02a..1b37122 100644 --- a/main.go +++ b/main.go @@ -1,20 +1,60 @@ +// Mailbox Zero is a local web app that cleans up a Fastmail inbox: it finds +// groups of similar emails over JMAP and archives them. +// +// Install it with: +// +// go install github.com/taskinen/mailboxzero@latest +// +// Then create a configuration file with +// +// mailboxzero -example-config > config.yaml +// +// add a Fastmail API token to it, run mailboxzero and open +// http://localhost:8080. package main import ( + _ "embed" + "errors" "flag" + "fmt" + "io/fs" "log" + "os" + "runtime" + "runtime/debug" "github.com/taskinen/mailboxzero/internal/config" "github.com/taskinen/mailboxzero/internal/jmap" "github.com/taskinen/mailboxzero/internal/server" ) +// exampleConfig is config.yaml.example, built in so that an installed binary +// can write it out with -example-config. +// +//go:embed config.yaml.example +var exampleConfig []byte + func main() { var configPath string + var showVersion, printExampleConfig bool flag.StringVar(&configPath, "config", "config.yaml", "Path to configuration file") + flag.BoolVar(&showVersion, "version", false, "Print the version and exit") + flag.BoolVar(&printExampleConfig, "example-config", false, "Print an example configuration file and exit") flag.Parse() - cfg, err := config.Load(configPath) + if showVersion { + fmt.Println(versionString()) + return + } + if printExampleConfig { + if _, err := os.Stdout.Write(exampleConfig); err != nil { + log.Fatalf("Failed to write the example config: %v", err) + } + return + } + + cfg, err := loadConfig(configPath) if err != nil { log.Fatalf("Failed to load config: %v", err) } @@ -42,8 +82,44 @@ func main() { log.Fatalf("Failed to create server: %v", err) } - log.Printf("Starting Mailbox Zero...") + log.Printf("Starting %s", versionString()) if err := srv.Start(); err != nil { log.Fatalf("Server failed: %v", err) } } + +// loadConfig loads the configuration file, and says how to create one when +// it does not exist, as on the first run of an installed binary. +func loadConfig(path string) (*config.Config, error) { + cfg, err := config.Load(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("config file %s not found: create one with `mailboxzero -example-config > %s` "+ + "and add your Fastmail API token: %w", path, path, err) + } + return cfg, err +} + +// versionString describes the running binary, from the build information Go +// records in it. +func versionString() string { + info, ok := debug.ReadBuildInfo() + return versionFrom(info, ok) +} + +// versionFrom returns "mailboxzero (, /)". +// The version is the release tag for go install ...@vX.Y.Z and for release +// builds, a pseudo-version for a go build in a checkout between releases +// (with +dirty for uncommitted changes), and "(devel)" for go run. +func versionFrom(info *debug.BuildInfo, ok bool) string { + version := "(devel)" + goVersion := runtime.Version() + if ok { + if info.Main.Version != "" { + version = info.Main.Version + } + if info.GoVersion != "" { + goVersion = info.GoVersion + } + } + return fmt.Sprintf("mailboxzero %s (%s, %s/%s)", version, goVersion, runtime.GOOS, runtime.GOARCH) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..966ac50 --- /dev/null +++ b/main_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "strings" + "testing" + + "github.com/taskinen/mailboxzero/internal/config" +) + +func TestVersionFrom(t *testing.T) { + platform := runtime.GOOS + "/" + runtime.GOARCH + + tests := []struct { + name string + info *debug.BuildInfo + ok bool + want string + }{ + { + name: "release", + info: &debug.BuildInfo{GoVersion: "go1.26.9", Main: debug.Module{Version: "v1.0.0"}}, + ok: true, + want: "mailboxzero v1.0.0 (go1.26.9, " + platform + ")", + }, + { + name: "build from a checkout between releases", + info: &debug.BuildInfo{GoVersion: "go1.26.9", Main: debug.Module{Version: "v1.0.1-0.20261016120000-abcdef123456"}}, + ok: true, + want: "mailboxzero v1.0.1-0.20261016120000-abcdef123456 (go1.26.9, " + platform + ")", + }, + { + name: "build with uncommitted changes", + info: &debug.BuildInfo{GoVersion: "go1.26.9", Main: debug.Module{Version: "v1.0.0+dirty"}}, + ok: true, + want: "mailboxzero v1.0.0+dirty (go1.26.9, " + platform + ")", + }, + { + name: "go run", + info: &debug.BuildInfo{GoVersion: "go1.26.9", Main: debug.Module{Version: "(devel)"}}, + ok: true, + want: "mailboxzero (devel) (go1.26.9, " + platform + ")", + }, + { + name: "no module version", + info: &debug.BuildInfo{GoVersion: "go1.26.9"}, + ok: true, + want: "mailboxzero (devel) (go1.26.9, " + platform + ")", + }, + { + name: "no build info", + ok: false, + want: "mailboxzero (devel) (" + runtime.Version() + ", " + platform + ")", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := versionFrom(tt.info, tt.ok); got != tt.want { + t.Errorf("versionFrom() = %q, want %q", got, tt.want) + } + }) + } +} + +// The example that -example-config prints must become a working config once +// the user adds a token, and must start in dry run mode. +func TestExampleConfig(t *testing.T) { + example := string(exampleConfig) + if !strings.Contains(example, `api_token: ""`) { + t.Fatalf("example config has no empty api_token to fill in:\n%s", example) + } + + path := filepath.Join(t.TempDir(), "config.yaml") + filled := strings.Replace(example, `api_token: ""`, `api_token: "test-token"`, 1) + if err := os.WriteFile(path, []byte(filled), 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := config.Load(path) + if err != nil { + t.Fatalf("config.Load(example with a token) failed: %v", err) + } + if !cfg.DryRun { + t.Error("example config has dry_run off, want on") + } +} + +func TestLoadConfig_MissingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + + _, err := loadConfig(path) + if err == nil { + t.Fatal("loadConfig(missing file) succeeded, want an error") + } + if !errors.Is(err, fs.ErrNotExist) { + t.Errorf("error %q does not wrap fs.ErrNotExist", err) + } + for _, want := range []string{path, "mailboxzero -example-config > " + path} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err, want) + } + } +} + +func TestLoadConfig_InvalidFileIsNotMissing(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("server: [not yaml"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := loadConfig(path) + if err == nil { + t.Fatal("loadConfig(invalid YAML) succeeded, want an error") + } + if strings.Contains(err.Error(), "-example-config") { + t.Errorf("error %q suggests creating a file that exists", err) + } +} From 1dd095786632b6e84e7ba5fdd5fee8233d91ca1a Mon Sep 17 00:00:00 2001 From: Timo Taskinen Date: Fri, 16 Oct 2026 20:34:13 +0300 Subject: [PATCH 2/2] Show the version in the page and read the config from ~/.config - The page header shows which version is running: the release version (v1.0.0), otherwise the commit of the build (59b1469, -dirty with uncommitted changes), or "dev" for go run. Hovering shows the full description that -version prints. The version code moves to internal/version so the command and the server share it. - Without -config, the config file is now ~/.config/mailboxzero/config.yaml ($XDG_CONFIG_HOME/mailboxzero/ config.yaml when that is set) on every OS, instead of config.yaml in the current directory. An installed binary is run from anywhere, and an unrelated config.yaml in the current directory must not be picked up. The log names the file used, and a missing file's error says how to create the folder and the file. --- CLAUDE.md | 52 ++++++----- config.yaml.example | 7 +- e2e/tests/version.spec.js | 14 +++ internal/server/server.go | 5 + internal/server/server_test.go | 9 ++ internal/version/version.go | 90 ++++++++++++++++++ internal/version/version_test.go | 156 +++++++++++++++++++++++++++++++ main.go | 63 ++++++------- main_test.go | 88 ++++++----------- web/static/style.css | 9 ++ web/templates/index.html | 2 +- 11 files changed, 376 insertions(+), 119 deletions(-) create mode 100644 e2e/tests/version.spec.js create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 7bd3b22..8ef85d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,8 +23,8 @@ Mailbox Zero is a Go-based web application that helps users clean up their Fastm ### Project Structure ``` mailboxzero/ -├── main.go # Application entry point: flags -config, -version (Go's build info) and -example-config (embeds config.yaml.example) -├── main_test.go # Version string, the embedded example config and the missing-config message +├── main.go # Application entry point: flags -config (default ~/.config/mailboxzero/config.yaml, `defaultConfigPath`), -version and -example-config (embeds config.yaml.example) +├── main_test.go # Default config path, the embedded example config and the missing-config message ├── go.mod / go.sum # Go module dependencies (gorilla/mux, yaml.v3) ├── config.yaml.example # Configuration template ├── config-mock.yaml.example # Mock-mode configuration template @@ -58,11 +58,14 @@ mailboxzero/ │ ├── server/ │ │ ├── server.go # HTTP server and API handlers │ │ └── server_test.go -│ └── similarity/ -│ ├── similarity.go # Fuzzy matching algorithms -│ ├── candidates.go # Index of the email pairs that could reach the threshold -│ ├── candidates_test.go # Checks the fast grouping against comparing every pair -│ └── similarity_test.go +│ ├── similarity/ +│ │ ├── similarity.go # Fuzzy matching algorithms +│ │ ├── candidates.go # Index of the email pairs that could reach the threshold +│ │ ├── candidates_test.go # Checks the fast grouping against comparing every pair +│ │ └── similarity_test.go +│ └── version/ +│ ├── version.go # The running binary's version from Go's build info: String() for -version and the log, Short() for the page header +│ └── version_test.go └── web/ ├── web.go # Embeds templates/ and static/ into the binary ├── templates/ @@ -142,6 +145,7 @@ mailboxzero/ - Modal confirmation dialogs - Async API communication. Inbox and Find Similar requests are numbered (`inboxRequest`, `similarRequest` in `app.js`), and an answer to a request that a newer one replaced (or that Clear Results cancelled) is ignored, since answers can arrive out of order - Responsive design for mobile/desktop + - Version in the header (`.app-version`, from `version.Short()` via `PageData.Version`): the release version (`v1.0.0`), otherwise the commit of the build (`59b1469`, `-dirty` with uncommitted changes), or `dev` for `go run`; hovering shows `version.String()` ## Key Features Implemented @@ -253,17 +257,20 @@ default_similarity: 60 # Default similarity percentage (0-100) go mod download go mod tidy -# Create configuration file from example (an installed binary prints the same -# file with: mailboxzero -example-config > config.yaml) -cp config.yaml.example config.yaml -# Edit config.yaml with your Fastmail API token +# Create the configuration file where the app looks by default, +# ~/.config/mailboxzero/config.yaml ($XDG_CONFIG_HOME/mailboxzero/config.yaml +# when that is set), from the example (an installed binary prints the same +# file with -example-config), and add your Fastmail API token +mkdir -p ~/.config/mailboxzero +cp config.yaml.example ~/.config/mailboxzero/config.yaml # Run the application (always the package ".", not main.go, so every file of # package main is included) go run . -# Run with custom config -go run . -config custom-config.yaml +# Run with another config file, for example one in the repository +# (config.yaml is git-ignored) +go run . -config config.yaml # Build for production (a single binary that includes the web page) go build -o mailboxzero . @@ -279,11 +286,8 @@ go build -o mailboxzero . For development and testing purposes, you can run the application in mock mode: ```bash -# Copy the mock configuration -cp config-mock.yaml.example config.yaml - -# Run in mock mode - no Fastmail credentials needed -go run . +# Run in mock mode with the mock configuration - no Fastmail credentials needed +go run . -config config-mock.yaml.example ``` **Mock Mode Features:** @@ -349,12 +353,13 @@ golangci-lint run go build -o mailboxzero . ``` -2. **Create Production Config:** +2. **Create Production Config** in the default location: ```bash - cp config.yaml.example config.yaml + mkdir -p ~/.config/mailboxzero + ./mailboxzero -example-config > ~/.config/mailboxzero/config.yaml ``` - Then edit `config.yaml`: + Then edit it: ```yaml server: port: 8080 @@ -364,9 +369,9 @@ golangci-lint run dry_run: false # Enable real operations ``` -3. **Run Binary:** +3. **Run Binary** (`-config` points to a config file elsewhere): ```bash - ./mailboxzero -config production-config.yaml + ./mailboxzero ``` ### Remote Access @@ -458,6 +463,7 @@ The project includes unit tests for every package under `internal/` and for the - **JMAP Package:** Data parsing, mock client functionality, helper functions, and the real Client's requests and responses via a fake JMAP server (`newFakeJMAPServer` in `fakeserver_test.go`), including authentication against its session resource and bearer-token check - **Similarity Package:** Fuzzy matching algorithms, Levenshtein distance, email similarity - **Server Package:** HTTP handlers, API endpoints, and request/response handling +- **Version Package:** the version description and the header label for releases, pseudo-versions, uncommitted changes and `go run` - **End-to-End (`e2e/tests/`):** Playwright tests that drive the UI in Chrome against the server in mock mode (see Testing Commands) Tests follow Go best practices: diff --git a/config.yaml.example b/config.yaml.example index 5bef41c..c50113d 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,6 +1,9 @@ # Mailbox Zero Configuration Example -# Save this as config.yaml and update it with your settings. An installed -# binary writes it out with: mailboxzero -example-config > config.yaml +# Mailbox Zero reads ~/.config/mailboxzero/config.yaml (or +# $XDG_CONFIG_HOME/mailboxzero/config.yaml), or the file given with -config. +# Save this there and update it with your settings: +# mkdir -p ~/.config/mailboxzero +# mailboxzero -example-config > ~/.config/mailboxzero/config.yaml server: port: 8080 diff --git a/e2e/tests/version.spec.js b/e2e/tests/version.spec.js new file mode 100644 index 0000000..7c1115c --- /dev/null +++ b/e2e/tests/version.spec.js @@ -0,0 +1,14 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +// The header names the running version: the release (v1.2.3), the commit of +// a development build (abc1234, -dirty with uncommitted changes), or "dev" +// for go run, which records neither. Hovering shows the full description. +test('the header shows the version', async ({ page }) => { + await page.goto('/'); + + const label = page.locator('header h1 .app-version'); + await expect(label).toBeVisible(); + await expect(label).toHaveText(/^(v\d+\.\d+\.\d+\S*|[0-9a-f]{7}(-dirty)?|dev)$/); + await expect(label).toHaveAttribute('title', /^mailboxzero \S+ \(go\S+, \w+\/\w+\)$/); +}); diff --git a/internal/server/server.go b/internal/server/server.go index 89a7236..370f408 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -16,6 +16,7 @@ import ( "github.com/taskinen/mailboxzero/internal/config" "github.com/taskinen/mailboxzero/internal/jmap" "github.com/taskinen/mailboxzero/internal/similarity" + "github.com/taskinen/mailboxzero/internal/version" "github.com/taskinen/mailboxzero/web" "github.com/gorilla/mux" @@ -66,6 +67,8 @@ type Server struct { type PageData struct { DryRun bool DefaultSimilarity int + Version string // the release version or commit, shown in the header + VersionDetail string // the full version description, shown on hover } func New(cfg *config.Config, jmapClient jmap.JMAPClient) (*Server, error) { @@ -163,6 +166,8 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { data := PageData{ DryRun: s.config.DryRun, DefaultSimilarity: s.config.DefaultSimilarity, + Version: version.Short(), + VersionDetail: version.String(), } if err := s.templates.ExecuteTemplate(w, "index.html", data); err != nil { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index bc21aa0..5c37104 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -7,6 +7,8 @@ import ( "fmt" "github.com/taskinen/mailboxzero/internal/config" "github.com/taskinen/mailboxzero/internal/jmap" + "github.com/taskinen/mailboxzero/internal/version" + "html" "net/http" "net/http/httptest" "os" @@ -770,6 +772,13 @@ func TestHandleIndex_WithPageData(t *testing.T) { if !strings.Contains(body, `value="75"`) { t.Error("handleIndex() should render the default similarity") } + + // The header shows the version, with the full description on hover. + wantVersion := fmt.Sprintf(`%s`, + html.EscapeString(version.String()), html.EscapeString(version.Short())) + if !strings.Contains(body, wantVersion) { + t.Errorf("handleIndex() should render the version %s", wantVersion) + } } func TestHandleFindSimilar_ErrorConditions(t *testing.T) { diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..f7574cc --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,90 @@ +// Package version describes the running binary from the build information +// Go records in every binary: the module version, which is the release tag +// for a release and for go install ...@vX.Y.Z, and for a build in a Git +// checkout the commit it was built from. Nothing is set at build time. +package version + +import ( + "fmt" + "regexp" + "runtime" + "runtime/debug" + "strings" +) + +// String returns "mailboxzero (, /)", as +// printed by -version and logged at startup. +func String() string { + info, ok := debug.ReadBuildInfo() + return describe(info, ok) +} + +// Short returns the label the web page shows: the release version +// ("v1.0.0"), otherwise the commit the binary was built from ("59b1469", +// with "-dirty" for uncommitted changes), or "dev" when Go recorded neither, +// as for go run. +func Short() string { + info, ok := debug.ReadBuildInfo() + return short(info, ok) +} + +// describe is String for the given build information. The version is the +// module version Go recorded: the tag, a pseudo-version for a build between +// releases (with +dirty for uncommitted changes), or "(devel)". +func describe(info *debug.BuildInfo, ok bool) string { + version := "(devel)" + goVersion := runtime.Version() + if ok { + if info.Main.Version != "" { + version = info.Main.Version + } + if info.GoVersion != "" { + goVersion = info.GoVersion + } + } + return fmt.Sprintf("mailboxzero %s (%s, %s/%s)", version, goVersion, runtime.GOOS, runtime.GOARCH) +} + +// pseudoVersion matches the end of a Go pseudo-version, the version Go +// gives a commit that is not tagged, such as +// v0.0.0-20261016172523-59b1469d2f82 or v1.0.1-0.20261016172523-59b1469d2f82. +// Its last part is the start of the commit hash. +var pseudoVersion = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})(\+dirty)?$`) + +// short is Short for the given build information. +func short(info *debug.BuildInfo, ok bool) string { + if !ok { + return "dev" + } + version := info.Main.Version + if version != "" && version != "(devel)" && !pseudoVersion.MatchString(version) && !strings.HasSuffix(version, "+dirty") { + return version + } + + // go build in a checkout records the commit and whether files were + // changed. + var revision string + var modified bool + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + revision = setting.Value + case "vcs.modified": + modified = setting.Value == "true" + } + } + if revision != "" { + label := revision[:min(7, len(revision))] + if modified { + label += "-dirty" + } + return label + } + + // go install of a commit records no vcs settings, but its + // pseudo-version names the commit. + if match := pseudoVersion.FindStringSubmatch(version); match != nil { + return match[1][:7] + } + return "dev" +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..6edfeb2 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,156 @@ +package version + +import ( + "runtime" + "runtime/debug" + "testing" +) + +func checkout(revision, modified string) []debug.BuildSetting { + return []debug.BuildSetting{ + {Key: "vcs", Value: "git"}, + {Key: "vcs.revision", Value: revision}, + {Key: "vcs.modified", Value: modified}, + } +} + +const revision = "59b1469d2f82a1b2c3d4e5f60718293a4b5c6d7e" + +func TestDescribe(t *testing.T) { + platform := runtime.GOOS + "/" + runtime.GOARCH + + tests := []struct { + name string + info *debug.BuildInfo + ok bool + want string + }{ + { + name: "release", + info: &debug.BuildInfo{GoVersion: "go1.26.9", Main: debug.Module{Version: "v1.0.0"}}, + ok: true, + want: "mailboxzero v1.0.0 (go1.26.9, " + platform + ")", + }, + { + name: "build from a checkout between releases", + info: &debug.BuildInfo{GoVersion: "go1.26.9", Main: debug.Module{Version: "v1.0.1-0.20261016120000-abcdef123456"}}, + ok: true, + want: "mailboxzero v1.0.1-0.20261016120000-abcdef123456 (go1.26.9, " + platform + ")", + }, + { + name: "build with uncommitted changes", + info: &debug.BuildInfo{GoVersion: "go1.26.9", Main: debug.Module{Version: "v1.0.0+dirty"}}, + ok: true, + want: "mailboxzero v1.0.0+dirty (go1.26.9, " + platform + ")", + }, + { + name: "go run", + info: &debug.BuildInfo{GoVersion: "go1.26.9", Main: debug.Module{Version: "(devel)"}}, + ok: true, + want: "mailboxzero (devel) (go1.26.9, " + platform + ")", + }, + { + name: "no module version", + info: &debug.BuildInfo{GoVersion: "go1.26.9"}, + ok: true, + want: "mailboxzero (devel) (go1.26.9, " + platform + ")", + }, + { + name: "no build info", + ok: false, + want: "mailboxzero (devel) (" + runtime.Version() + ", " + platform + ")", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := describe(tt.info, tt.ok); got != tt.want { + t.Errorf("describe() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestShort(t *testing.T) { + tests := []struct { + name string + info *debug.BuildInfo + ok bool + want string + }{ + { + name: "release from go install", + info: &debug.BuildInfo{Main: debug.Module{Version: "v1.0.0"}}, + ok: true, + want: "v1.0.0", + }, + { + name: "release built at its tag", + info: &debug.BuildInfo{Main: debug.Module{Version: "v1.2.0"}, Settings: checkout(revision, "false")}, + ok: true, + want: "v1.2.0", + }, + { + name: "pre-release", + info: &debug.BuildInfo{Main: debug.Module{Version: "v1.1.0-rc.1"}}, + ok: true, + want: "v1.1.0-rc.1", + }, + { + name: "build from a checkout between releases", + info: &debug.BuildInfo{ + Main: debug.Module{Version: "v1.0.1-0.20261016172523-59b1469d2f82"}, + Settings: checkout(revision, "false"), + }, + ok: true, + want: "59b1469", + }, + { + name: "build with uncommitted changes", + info: &debug.BuildInfo{ + Main: debug.Module{Version: "v1.0.1-0.20261016172523-59b1469d2f82+dirty"}, + Settings: checkout(revision, "true"), + }, + ok: true, + want: "59b1469-dirty", + }, + { + name: "build at a tag with uncommitted changes", + info: &debug.BuildInfo{Main: debug.Module{Version: "v1.0.0+dirty"}, Settings: checkout(revision, "true")}, + ok: true, + want: "59b1469-dirty", + }, + { + // go install of a commit: a pseudo-version, but no vcs settings. + name: "go install of a commit", + info: &debug.BuildInfo{Main: debug.Module{Version: "v0.0.0-20261016172523-59b1469d2f82"}}, + ok: true, + want: "59b1469", + }, + { + name: "pseudo-version after a pre-release", + info: &debug.BuildInfo{Main: debug.Module{Version: "v1.1.0-rc.1.0.20261016172523-59b1469d2f82"}}, + ok: true, + want: "59b1469", + }, + { + name: "go run", + info: &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}, + ok: true, + want: "dev", + }, + { + name: "no build info", + ok: false, + want: "dev", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := short(tt.info, tt.ok); got != tt.want { + t.Errorf("short() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/main.go b/main.go index 1b37122..869ba74 100644 --- a/main.go +++ b/main.go @@ -5,9 +5,12 @@ // // go install github.com/taskinen/mailboxzero@latest // -// Then create a configuration file with +// It reads its configuration from ~/.config/mailboxzero/config.yaml +// ($XDG_CONFIG_HOME/mailboxzero/config.yaml when that is set), or from the +// file given with -config. Create the file with // -// mailboxzero -example-config > config.yaml +// mkdir -p ~/.config/mailboxzero +// mailboxzero -example-config > ~/.config/mailboxzero/config.yaml // // add a Fastmail API token to it, run mailboxzero and open // http://localhost:8080. @@ -21,12 +24,12 @@ import ( "io/fs" "log" "os" - "runtime" - "runtime/debug" + "path/filepath" "github.com/taskinen/mailboxzero/internal/config" "github.com/taskinen/mailboxzero/internal/jmap" "github.com/taskinen/mailboxzero/internal/server" + "github.com/taskinen/mailboxzero/internal/version" ) // exampleConfig is config.yaml.example, built in so that an installed binary @@ -38,13 +41,13 @@ var exampleConfig []byte func main() { var configPath string var showVersion, printExampleConfig bool - flag.StringVar(&configPath, "config", "config.yaml", "Path to configuration file") + flag.StringVar(&configPath, "config", defaultConfigPath(), "Path to configuration file") flag.BoolVar(&showVersion, "version", false, "Print the version and exit") flag.BoolVar(&printExampleConfig, "example-config", false, "Print an example configuration file and exit") flag.Parse() if showVersion { - fmt.Println(versionString()) + fmt.Println(version.String()) return } if printExampleConfig { @@ -58,6 +61,7 @@ func main() { if err != nil { log.Fatalf("Failed to load config: %v", err) } + log.Printf("Using config file %s", configPath) var jmapClient jmap.JMAPClient @@ -82,44 +86,35 @@ func main() { log.Fatalf("Failed to create server: %v", err) } - log.Printf("Starting %s", versionString()) + log.Printf("Starting %s", version.String()) if err := srv.Start(); err != nil { log.Fatalf("Server failed: %v", err) } } +// defaultConfigPath returns where the configuration file is read from when +// -config is not given: mailboxzero/config.yaml in $XDG_CONFIG_HOME, or in +// ~/.config when that is not set (on every OS, macOS and Windows included). +func defaultConfigPath() string { + dir := os.Getenv("XDG_CONFIG_HOME") + // The XDG Base Directory spec says to ignore a relative path. + if !filepath.IsAbs(dir) { + home, err := os.UserHomeDir() + if err != nil { + return "config.yaml" + } + dir = filepath.Join(home, ".config") + } + return filepath.Join(dir, "mailboxzero", "config.yaml") +} + // loadConfig loads the configuration file, and says how to create one when // it does not exist, as on the first run of an installed binary. func loadConfig(path string) (*config.Config, error) { cfg, err := config.Load(path) if errors.Is(err, fs.ErrNotExist) { - return nil, fmt.Errorf("config file %s not found: create one with `mailboxzero -example-config > %s` "+ - "and add your Fastmail API token: %w", path, path, err) + return nil, fmt.Errorf("config file %s not found: create it with `mkdir -p %s && mailboxzero -example-config > %s` "+ + "and add your Fastmail API token, or give another file with -config: %w", path, filepath.Dir(path), path, err) } return cfg, err } - -// versionString describes the running binary, from the build information Go -// records in it. -func versionString() string { - info, ok := debug.ReadBuildInfo() - return versionFrom(info, ok) -} - -// versionFrom returns "mailboxzero (, /)". -// The version is the release tag for go install ...@vX.Y.Z and for release -// builds, a pseudo-version for a go build in a checkout between releases -// (with +dirty for uncommitted changes), and "(devel)" for go run. -func versionFrom(info *debug.BuildInfo, ok bool) string { - version := "(devel)" - goVersion := runtime.Version() - if ok { - if info.Main.Version != "" { - version = info.Main.Version - } - if info.GoVersion != "" { - goVersion = info.GoVersion - } - } - return fmt.Sprintf("mailboxzero %s (%s, %s/%s)", version, goVersion, runtime.GOOS, runtime.GOARCH) -} diff --git a/main_test.go b/main_test.go index 966ac50..8fd4f6b 100644 --- a/main_test.go +++ b/main_test.go @@ -5,69 +5,12 @@ import ( "io/fs" "os" "path/filepath" - "runtime" - "runtime/debug" "strings" "testing" "github.com/taskinen/mailboxzero/internal/config" ) -func TestVersionFrom(t *testing.T) { - platform := runtime.GOOS + "/" + runtime.GOARCH - - tests := []struct { - name string - info *debug.BuildInfo - ok bool - want string - }{ - { - name: "release", - info: &debug.BuildInfo{GoVersion: "go1.26.9", Main: debug.Module{Version: "v1.0.0"}}, - ok: true, - want: "mailboxzero v1.0.0 (go1.26.9, " + platform + ")", - }, - { - name: "build from a checkout between releases", - info: &debug.BuildInfo{GoVersion: "go1.26.9", Main: debug.Module{Version: "v1.0.1-0.20261016120000-abcdef123456"}}, - ok: true, - want: "mailboxzero v1.0.1-0.20261016120000-abcdef123456 (go1.26.9, " + platform + ")", - }, - { - name: "build with uncommitted changes", - info: &debug.BuildInfo{GoVersion: "go1.26.9", Main: debug.Module{Version: "v1.0.0+dirty"}}, - ok: true, - want: "mailboxzero v1.0.0+dirty (go1.26.9, " + platform + ")", - }, - { - name: "go run", - info: &debug.BuildInfo{GoVersion: "go1.26.9", Main: debug.Module{Version: "(devel)"}}, - ok: true, - want: "mailboxzero (devel) (go1.26.9, " + platform + ")", - }, - { - name: "no module version", - info: &debug.BuildInfo{GoVersion: "go1.26.9"}, - ok: true, - want: "mailboxzero (devel) (go1.26.9, " + platform + ")", - }, - { - name: "no build info", - ok: false, - want: "mailboxzero (devel) (" + runtime.Version() + ", " + platform + ")", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := versionFrom(tt.info, tt.ok); got != tt.want { - t.Errorf("versionFrom() = %q, want %q", got, tt.want) - } - }) - } -} - // The example that -example-config prints must become a working config once // the user adds a token, and must start in dry run mode. func TestExampleConfig(t *testing.T) { @@ -91,8 +34,34 @@ func TestExampleConfig(t *testing.T) { } } +func TestDefaultConfigPath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + tests := []struct { + name string + xdgConfigHome string + want string + }{ + {name: "XDG_CONFIG_HOME not set", want: filepath.Join(home, ".config", "mailboxzero", "config.yaml")}, + {name: "XDG_CONFIG_HOME set", xdgConfigHome: "/xdg/config", want: filepath.Join("/xdg/config", "mailboxzero", "config.yaml")}, + // The XDG Base Directory spec says to ignore a relative path. + {name: "XDG_CONFIG_HOME relative", xdgConfigHome: "relative/config", want: filepath.Join(home, ".config", "mailboxzero", "config.yaml")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", tt.xdgConfigHome) + if got := defaultConfigPath(); got != tt.want { + t.Errorf("defaultConfigPath() = %q, want %q", got, tt.want) + } + }) + } +} + func TestLoadConfig_MissingFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "config.yaml") + dir := filepath.Join(t.TempDir(), "mailboxzero") + path := filepath.Join(dir, "config.yaml") _, err := loadConfig(path) if err == nil { @@ -101,7 +70,8 @@ func TestLoadConfig_MissingFile(t *testing.T) { if !errors.Is(err, fs.ErrNotExist) { t.Errorf("error %q does not wrap fs.ErrNotExist", err) } - for _, want := range []string{path, "mailboxzero -example-config > " + path} { + // The default folder may not exist yet either. + for _, want := range []string{path, "mkdir -p " + dir, "mailboxzero -example-config > " + path} { if !strings.Contains(err.Error(), want) { t.Errorf("error %q does not mention %q", err, want) } diff --git a/web/static/style.css b/web/static/style.css index 1b227a9..1b8fb5b 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -32,6 +32,15 @@ header h1 { font-size: 2em; } +/* The release version, or the commit of a development build. */ +header h1 .app-version { + margin-left: 6px; + font-size: 0.4em; + font-weight: normal; + color: #95a5a6; + vertical-align: middle; +} + .status-bar { display: flex; justify-content: space-between; diff --git a/web/templates/index.html b/web/templates/index.html index 8c3b452..dcb5029 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -9,7 +9,7 @@
-

Mailbox Zero

+

Mailbox Zero {{.Version}}

{{if .DryRun}}