diff --git a/CLAUDE.md b/CLAUDE.md
index e7977bb..8ef85d7 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 (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
@@ -57,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/
@@ -141,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
@@ -252,18 +257,28 @@ default_similarity: 60 # Default similarity percentage (0-100)
go mod download
go mod tidy
-# Create configuration file from example
-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
-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
+# 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 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
@@ -271,11 +286,8 @@ go build -o mailboxzero main.go
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 main.go
+# Run in mock mode with the mock configuration - no Fastmail credentials needed
+go run . -config config-mock.yaml.example
```
**Mock Mode Features:**
@@ -305,7 +317,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,15 +350,16 @@ 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:**
+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
@@ -356,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
@@ -373,7 +386,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,11 +458,12 @@ 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
- **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 7bd6aca..c50113d 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -1,5 +1,9 @@
# Mailbox Zero Configuration Example
-# Copy this file to config.yaml and update with your settings
+# 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/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/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 f26c02a..869ba74 100644
--- a/main.go
+++ b/main.go
@@ -1,23 +1,67 @@
+// 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
+//
+// 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
+//
+// 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.
package main
import (
+ _ "embed"
+ "errors"
"flag"
+ "fmt"
+ "io/fs"
"log"
+ "os"
+ "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
+// can write it out with -example-config.
+//
+//go:embed config.yaml.example
+var exampleConfig []byte
+
func main() {
var configPath string
- flag.StringVar(&configPath, "config", "config.yaml", "Path to configuration file")
+ var showVersion, printExampleConfig bool
+ 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()
- cfg, err := config.Load(configPath)
+ if showVersion {
+ fmt.Println(version.String())
+ 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)
}
+ log.Printf("Using config file %s", configPath)
var jmapClient jmap.JMAPClient
@@ -42,8 +86,35 @@ func main() {
log.Fatalf("Failed to create server: %v", err)
}
- log.Printf("Starting Mailbox Zero...")
+ 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 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
+}
diff --git a/main_test.go b/main_test.go
new file mode 100644
index 0000000..8fd4f6b
--- /dev/null
+++ b/main_test.go
@@ -0,0 +1,94 @@
+package main
+
+import (
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/taskinen/mailboxzero/internal/config"
+)
+
+// 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 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) {
+ dir := filepath.Join(t.TempDir(), "mailboxzero")
+ path := filepath.Join(dir, "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)
+ }
+ // 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)
+ }
+ }
+}
+
+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)
+ }
+}
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}}