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}}

{{if .DryRun}}