Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 42 additions & 28 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -252,30 +257,37 @@ 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

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:**
Expand Down Expand Up @@ -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 ./...
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion config.yaml.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion e2e/playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions e2e/tests/version.spec.js
Original file line number Diff line number Diff line change
@@ -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+\)$/);
});
5 changes: 5 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(`<span class="app-version" title="%s">%s</span>`,
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) {
Expand Down
90 changes: 90 additions & 0 deletions internal/version/version.go
Original file line number Diff line number Diff line change
@@ -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 <version> (<Go version>, <OS>/<arch>)", 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"
}
Loading