fix: better integration, more tests (#1349)

Drew Smirnoff created

## What?

Add five new CI capabilities and harden the existing failure notifier.

**New workflows**
- `.github/workflows/security.yml` โ€” govulncheck, gosec (SARIF upload),
trivy fs+config scan, CodeQL with `security-extended` queries.
- `.github/workflows/benchmarks.yml` โ€” benchstat PR-vs-base comparison;
idempotent PR comment classifies result as regression / improvement /
neutral at ยฑ3% threshold; posts as the bot identity via
`HOMEBREW_GITHUB_TOKEN`.
- `.github/workflows/integration.yml` โ€” three jobs:
- **imap + smtp e2e**: spins up Greenmail as a service container, runs
real IMAPS/SMTPS tests against the `backend.Provider` interface.
- **cross-platform**: race-enabled `go test -race ./...` matrix across
linux/macos/windows.
  - **fuzz**: auto-discovers every `func Fuzz*` and runs each for 30s.

**New tests / configs**
- `.golangci.yml` โ€” version 2 config enabling 30+ linters (`errcheck`,
`govet`, `gosec`, `revive`, `gocritic`, `bodyclose`, `errorlint`, โ€ฆ)
with sensible per-path exclusions.
- `.github/workflows/ci.yml` โ€” lint job now also runs `golangci-lint`
with the integration build tag.
- `tui/snapshot_test.go` + `tui/testdata/golden/*.txt` โ€” ANSI-stripped
golden snapshots for `LogPanel` and `SearchOverlay` (empty / populated /
truncated / loading / error). Refresh with `go test ./tui -update`.
- `backend/search_bench_test.go` + `tui/render_bench_test.go` โ€”
benchmarks for query parsing, tokenizer, log panel render, search
overlay render, inbox construction.
- `tests/integration/` โ€” Go integration suite gated by `//go:build
integration`, plus a `docker-compose.yml` for running Greenmail locally.

**Failure notifier upgrades**
- Now fires on `master` pushes as well as PRs.
  - PR โ†’ `REQUEST_CHANGES` review (existing behavior).
- master โ†’ opens a labeled `ci-failure` issue and drops a commit comment
on the merge SHA; dedupes repeated failures into the same issue.
- master + success โ†’ auto-closes prior bot-authored `ci-failure` issues
with a link to the passing run.
- Downloads the workflow log archive, parses it with `adm-zip`, and
extracts:
  - failing Go packages (`FAIL\s+pkg/path`),
  - failing test names (`--- FAIL: TestName`),
  - build errors (`file.go:line:col: msg`).
- Per-job fix hints expanded: `make lint`, `go mod tidy`, `nix flake
check`, snap/flatpak validators, `goreleaser check`, `govulncheck`,
`docker compose -f tests/integration/docker-compose.yml up -d`, `go test
-fuzz=โ€ฆ`, etc.

## Why?

Makes it easier to detect issues with PRs

---------

Signed-off-by: drew <me@andrinoff.com>

Change summary

.gitattributes                                 |   3 
.github/workflows/benchmarks.yml               | 106 ++++++
.github/workflows/bot-bench-comment.yml        | 116 +++++++
.github/workflows/bot-check-ci.yml             | 314 ++++++++++++++++---
.github/workflows/ci.yml                       |   7 
.github/workflows/integration.yml              | 124 +++++++
.github/workflows/security.yml                 | 128 ++++++++
.golangci.yml                                  | 127 ++++++++
backend/search_bench_test.go                   |  27 +
tests/integration/docker-compose.yml           |  20 +
tests/integration/imap_test.go                 | 267 +++++++++++++++++
tui/render_bench_test.go                       |  62 +++
tui/snapshot_test.go                           | 128 ++++++++
tui/testdata/golden/log_panel_empty.txt        |   3 
tui/testdata/golden/log_panel_truncated.txt    |   3 
tui/testdata/golden/log_panel_with_entries.txt |   5 
tui/testdata/golden/search_overlay_empty.txt   |   7 
tui/testdata/golden/search_overlay_error.txt   |   9 
tui/testdata/golden/search_overlay_loading.txt |   9 
19 files changed, 1,405 insertions(+), 60 deletions(-)

Detailed changes

.gitattributes ๐Ÿ”—

@@ -5,3 +5,6 @@ clib/stb_image.h linguist-vendored
 *.json linguist-detectable=false
 *.html linguist-detectable=false
 public/** linguist-documentation
+
+# Golden snapshot files must be LF on all platforms.
+tui/testdata/golden/*.txt text eol=lf

.github/workflows/benchmarks.yml ๐Ÿ”—

@@ -0,0 +1,106 @@
+name: Benchmarks
+
+on:
+  pull_request:
+    branches: [master]
+  push:
+    branches: [master]
+
+permissions:
+  contents: read
+
+jobs:
+  benchmark:
+    runs-on: ubuntu-latest
+    timeout-minutes: 30
+    steps:
+      - name: Checkout PR
+        uses: actions/checkout@v6
+        with:
+          fetch-depth: 0
+
+      - name: Set up Go
+        uses: actions/setup-go@v6
+        with:
+          go-version: "1.26.3"
+
+      - name: Install system dependencies
+        run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev
+
+      - name: Install benchstat
+        run: go install golang.org/x/perf/cmd/benchstat@latest
+
+      - name: Resolve base ref
+        id: base
+        run: |
+          if [ "${{ github.event_name }}" = "pull_request" ]; then
+            echo "ref=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT"
+          else
+            echo "ref=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
+          fi
+
+      - name: Benchmark PR
+        run: |
+          go test -run=^$ -bench=. -benchmem -benchtime=3x -count=6 ./backend/ ./tui/ \
+            | tee new.txt
+
+      - name: Checkout base
+        run: git checkout ${{ steps.base.outputs.ref }}
+
+      - name: Benchmark base
+        run: |
+          go test -run=^$ -bench=. -benchmem -benchtime=3x -count=6 ./backend/ ./tui/ \
+            | tee old.txt || echo "base benchmarks failed" > old.txt
+
+      - name: Restore PR checkout
+        run: git checkout ${{ github.sha }}
+
+      - name: Compare with benchstat
+        run: |
+          set +e
+          benchstat old.txt new.txt | tee benchstat.txt
+
+      - name: Classify result
+        run: |
+          python3 - <<'PY' > verdict.txt
+          import re
+          worse, better = 0, 0
+          with open("benchstat.txt") as f:
+              for line in f:
+                  m = re.search(r"([-+]?\d+\.\d+)%", line)
+                  if not m:
+                      continue
+                  delta = float(m.group(1))
+                  if "ns/op" in line or "B/op" in line or "allocs/op" in line:
+                      if delta > 3:
+                          worse += 1
+                      elif delta < -3:
+                          better += 1
+          status = "neutral"
+          if worse > 0 and worse >= better:
+              status = "regression"
+          elif better > 0:
+              status = "improvement"
+          print(f"status={status}")
+          print(f"worse={worse}")
+          print(f"better={better}")
+          PY
+          cat verdict.txt
+
+      - name: Record PR metadata
+        if: github.event_name == 'pull_request'
+        run: |
+          echo "${{ github.event.pull_request.number }}" > pr-number.txt
+
+      - name: Upload artifacts
+        if: always()
+        uses: actions/upload-artifact@v4
+        with:
+          name: benchmarks
+          path: |
+            old.txt
+            new.txt
+            benchstat.txt
+            verdict.txt
+            pr-number.txt
+          if-no-files-found: ignore

.github/workflows/bot-bench-comment.yml ๐Ÿ”—

@@ -0,0 +1,116 @@
+name: Bot - Benchmark PR Comment
+
+on:
+  workflow_run:
+    workflows: ["Benchmarks"]
+    types: [completed]
+
+permissions:
+  contents: read
+  pull-requests: write
+  actions: read
+
+jobs:
+  comment:
+    if: github.event.workflow_run.event == 'pull_request'
+    runs-on: ubuntu-latest
+    steps:
+      - name: Download benchmarks artifact
+        uses: actions/github-script@v7
+        with:
+          script: |
+            const run_id = context.payload.workflow_run.id;
+            const { data: list } = await github.rest.actions.listWorkflowRunArtifacts({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              run_id,
+            });
+            const art = list.artifacts.find(a => a.name === 'benchmarks');
+            if (!art) {
+              core.setFailed('benchmarks artifact missing');
+              return;
+            }
+            const dl = await github.rest.actions.downloadArtifact({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              artifact_id: art.id,
+              archive_format: 'zip',
+            });
+            const fs = require('fs');
+            fs.writeFileSync('benchmarks.zip', Buffer.from(dl.data));
+
+      - name: Unzip
+        run: |
+          mkdir -p bench
+          unzip -o benchmarks.zip -d bench
+          ls -la bench
+
+      - name: Post comment
+        uses: actions/github-script@v7
+        with:
+          github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+          script: |
+            const fs = require('fs');
+            const read = (p, fallback = '') => {
+              try { return fs.readFileSync(p, 'utf8'); } catch (_) { return fallback; }
+            };
+
+            const prRaw = read('bench/pr-number.txt').trim();
+            const pr_number = parseInt(prRaw, 10);
+            if (!pr_number) {
+              core.info('no PR number recorded; skipping');
+              return;
+            }
+
+            const report = read('bench/benchstat.txt', '(empty)');
+            const verdict = read('bench/verdict.txt');
+            const parse = key => {
+              const m = verdict.match(new RegExp("^" + key + "=(.+)$", "m"));
+              return m ? m[1].trim() : '';
+            };
+            const status = parse('status') || 'neutral';
+            const worse = parse('worse') || '0';
+            const better = parse('better') || '0';
+
+            const headers = {
+              regression: "### Benchmark report โ€” regression detected",
+              improvement: "### Benchmark report โ€” improvement detected",
+              neutral: "### Benchmark report โ€” no significant change",
+            };
+            const body = [
+              headers[status] || headers.neutral,
+              `Metrics worse: **${worse}** ยท better: **${better}** (threshold: ยฑ3%).`,
+              "",
+              "<details><summary>benchstat output</summary>",
+              "",
+              "```",
+              report.trim() || "(empty)",
+              "```",
+              "",
+              "</details>",
+              "",
+              "<sub>auto-generated by benchmarks.yml</sub>",
+            ].join("\n");
+
+            const marker = "<sub>auto-generated by benchmarks.yml</sub>";
+            const { data: comments } = await github.rest.issues.listComments({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              issue_number: pr_number,
+            });
+            const existing = comments.find(c => c.body && c.body.includes(marker));
+            if (existing) {
+              await github.rest.issues.updateComment({
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                comment_id: existing.id,
+                body,
+              });
+            } else {
+              await github.rest.issues.createComment({
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                issue_number: pr_number,
+                body,
+              });
+            }

.github/workflows/bot-check-ci.yml ๐Ÿ”—

@@ -2,103 +2,297 @@ name: Bot - CI Failure Notifier
 
 on:
   workflow_run:
-    workflows: ["CI"]
+    workflows:
+      - "CI"
+      - "Security"
+      - "Benchmarks"
+      - "Integration"
     types: [completed]
 
+permissions:
+  contents: read
+  pull-requests: write
+  issues: write
+  actions: read
+
 jobs:
   notify:
     runs-on: ubuntu-latest
     steps:
+      - name: Install log-parser dep
+        run: npm install --no-save --prefix /tmp/botci adm-zip
+
       - name: Process CI Result
         uses: actions/github-script@v9
+        env:
+          BOT_NODE_MODULES: /tmp/botci/node_modules
         with:
-          github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
+          github-token: ${{ secrets.HOMEBREW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
           script: |
+            module.paths.unshift(process.env.BOT_NODE_MODULES);
             const run = context.payload.workflow_run;
             const owner = context.repo.owner;
             const repo = context.repo.repo;
+            const workflowName = run.name || "workflow";
+            const isMasterPush = run.event === 'push' && run.head_branch === 'master';
 
-            // 1. Robustly find the PR number (works for forks too)
-            let pr_number;
+            // Resolve PR
+            let pr_number = null;
             if (run.pull_requests && run.pull_requests.length > 0) {
               pr_number = run.pull_requests[0].number;
             } else {
-              // Fallback: Find PR associated with the commit SHA
-              const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
-                owner,
-                repo,
-                commit_sha: run.head_sha
-              });
-              if (prs.length > 0) {
-                pr_number = prs[0].number;
+              try {
+                const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
+                  owner, repo, commit_sha: run.head_sha
+                });
+                const open = prs.find(p => p.state === 'open');
+                if (open) pr_number = open.number;
+                else if (prs.length > 0) pr_number = prs[0].number;
+              } catch (e) {
+                console.log(`PR lookup failed: ${e.message}`);
               }
             }
 
-            if (!pr_number) {
-              console.log("No open PR found for this workflow run. Exiting.");
+            if (!pr_number && !isMasterPush) {
+              console.log(`No PR and not master push (event=${run.event} branch=${run.head_branch}). Exiting.`);
               return;
             }
 
-            // 2. Handle CI Failure -> Request Changes
-            if (run.conclusion === 'failure') {
-              const jobs = await github.rest.actions.listJobsForWorkflowRun({
-                owner, repo, run_id: run.id
-              });
-
-              const failedJobs = jobs.data.jobs.filter(j => j.conclusion === 'failure').map(j => j.name);
+            const fixHints = {
+              lint: "Run `make lint` locally.",
+              'mod-tidy': "Run `go mod tidy` and commit `go.mod`/`go.sum`.",
+              nix: "Run `nix flake check --no-build` locally.",
+              snap: "Validate `snapcraft.yaml` with `snapcraft list-plugins`.",
+              flatpak: "Validate the Flatpak manifest YAML structure.",
+              website: "Run `cd docs && npm ci && npx docusaurus build`.",
+              'lua-plugins': "Run `luac -p plugins/*.lua` to check Lua syntax.",
+              goreleaser: "Run `goreleaser check -f .goreleaser.yml`.",
+              build: "Run `go build ./...` and `go test ./...` locally.",
+              govulncheck: "Run `govulncheck ./...` and update vulnerable deps.",
+              gosec: "Inspect gosec SARIF in the Security tab; fix or annotate with `//nolint:gosec` (justified).",
+              trivy: "Run `trivy fs .` and address CRITICAL/HIGH findings.",
+              codeql: "Inspect CodeQL findings in the Security tab.",
+              benchmark: "Inspect benchstat report on the PR; look for >3% regressions.",
+              'imap + smtp e2e': "Run `docker compose -f tests/integration/docker-compose.yml up -d` then `go test -tags=integration ./tests/integration/...`.",
+              'cross-platform': "Reproduce with `go test -race ./...` on the failing OS.",
+              fuzz: "Reproduce with `go test -fuzz=<Name> -fuzztime=30s ./<pkg>`.",
+            };
 
-              let message = "The CI workflow failed. Please fix the following issues locally and push again:\n\n";
-              let foundSpecificInstruction = false;
+            function hintFor(jobName) {
+              const lower = jobName.toLowerCase();
+              for (const key of Object.keys(fixHints)) {
+                if (lower.includes(key)) return fixHints[key];
+              }
+              return null;
+            }
 
-              if (failedJobs.some(name => name.includes('lint'))) {
-                message += "- **Lint Failed**: Run `gofmt -w .` to format files and `go vet ./...` to check for vet errors.\n";
-                foundSpecificInstruction = true;
+            async function downloadLogs() {
+              try {
+                const res = await github.rest.actions.downloadWorkflowRunLogs({
+                  owner, repo, run_id: run.id
+                });
+                return Buffer.from(res.data);
+              } catch (e) {
+                console.log(`could not download logs: ${e.message}`);
+                return null;
               }
-              if (failedJobs.some(name => name.includes('mod-tidy'))) {
-                message += "- **Mod-tidy Failed**: Run `go mod tidy` locally and commit the resulting `go.mod` and `go.sum` changes.\n";
-                foundSpecificInstruction = true;
+            }
+
+            async function extractFailures(zipBuf) {
+              if (!zipBuf) return [];
+              let AdmZip;
+              try {
+                AdmZip = require('adm-zip');
+              } catch (_) {
+                console.log("adm-zip not available; skipping log parse");
+                return [];
               }
-              if (failedJobs.some(name => name.includes('nix'))) {
-                message += "- **Nix Failed**: Run `nix flake check --no-build` locally to find the issue with the flake.\n";
-                foundSpecificInstruction = true;
+              const zip = new AdmZip(zipBuf);
+              const failures = [];
+              const goFailRe = /^FAIL\s+(\S+)/;
+              const testFailRe = /^\s*---\s+FAIL:\s+(\S+)/;
+              const buildFailRe = /^(.+\.go):(\d+):(\d+):\s+(.+)$/;
+
+              for (const entry of zip.getEntries()) {
+                if (entry.isDirectory) continue;
+                if (!entry.entryName.endsWith('.txt')) continue;
+                const text = entry.getData().toString('utf8');
+                const pkgs = new Set();
+                const tests = new Set();
+                const buildErrs = [];
+                for (const line of text.split('\n')) {
+                  let m;
+                  if ((m = line.match(testFailRe))) tests.add(m[1]);
+                  else if ((m = line.match(goFailRe))) {
+                    if (m[1] !== '[build') pkgs.add(m[1]);
+                  } else if ((m = line.match(buildFailRe))) {
+                    if (buildErrs.length < 5) buildErrs.push(`${m[1]}:${m[2]}: ${m[4]}`);
+                  }
+                }
+                if (pkgs.size || tests.size || buildErrs.length) {
+                  failures.push({
+                    file: entry.entryName,
+                    packages: [...pkgs],
+                    tests: [...tests],
+                    buildErrors: buildErrs,
+                  });
+                }
               }
+              return failures;
+            }
 
-              if (!foundSpecificInstruction) {
-                message += "Please check the [CI logs](" + run.html_url + ") for more details on the failure.";
+            function renderFailuresSection(failures) {
+              if (!failures.length) return "";
+              const seenPkgs = new Set();
+              const seenTests = new Set();
+              const seenBuild = new Set();
+              for (const f of failures) {
+                f.packages.forEach(p => seenPkgs.add(p));
+                f.tests.forEach(t => seenTests.add(t));
+                f.buildErrors.forEach(b => seenBuild.add(b));
               }
+              const lines = ["\n#### Failure details\n"];
+              if (seenBuild.size) {
+                lines.push("**Build errors:**");
+                for (const b of [...seenBuild].slice(0, 8)) lines.push(`- \`${b}\``);
+                lines.push("");
+              }
+              if (seenPkgs.size) {
+                lines.push("**Failing packages:**");
+                for (const p of [...seenPkgs].slice(0, 20)) lines.push(`- \`${p}\``);
+                lines.push("");
+              }
+              if (seenTests.size) {
+                lines.push("**Failing tests:**");
+                for (const t of [...seenTests].slice(0, 30)) lines.push(`- \`${t}\``);
+                lines.push("");
+              }
+              return lines.join("\n");
+            }
 
-              // Submit an official "Request Changes" review
-              await github.rest.pulls.createReview({
-                owner,
-                repo,
-                pull_number: pr_number,
-                body: message,
-                event: 'REQUEST_CHANGES'
+            const marker = `<!-- bot-check-ci:${workflowName} -->`;
+
+            async function upsertPRComment(body) {
+              const { data: comments } = await github.rest.issues.listComments({
+                owner, repo, issue_number: pr_number, per_page: 100
               });
+              const existing = comments.find(c => c.body && c.body.includes(marker));
+              if (existing) {
+                await github.rest.issues.updateComment({
+                  owner, repo, comment_id: existing.id, body
+                });
+                console.log(`updated comment ${existing.id} on PR #${pr_number}`);
+              } else {
+                await github.rest.issues.createComment({
+                  owner, repo, issue_number: pr_number, body
+                });
+                console.log(`created comment on PR #${pr_number}`);
+              }
+            }
 
+            async function deletePRCommentIfExists() {
+              if (!pr_number) return;
+              const { data: comments } = await github.rest.issues.listComments({
+                owner, repo, issue_number: pr_number, per_page: 100
+              });
+              const existing = comments.find(c => c.body && c.body.includes(marker));
+              if (existing) {
+                try {
+                  await github.rest.issues.deleteComment({
+                    owner, repo, comment_id: existing.id
+                  });
+                  console.log(`deleted stale comment ${existing.id}`);
+                } catch (e) {
+                  // Fallback: edit instead of deleting if delete denied.
+                  await github.rest.issues.updateComment({
+                    owner, repo, comment_id: existing.id,
+                    body: `${marker}\n${workflowName} now passing on \`${run.head_sha.substring(0, 7)}\` ([run](${run.html_url})).`
+                  });
+                }
+              }
             }
-            // 3. Handle CI Success -> Dismiss Reviews
-            else if (run.conclusion === 'success') {
-              const { data: botUser } = await github.rest.users.getAuthenticated();
 
-              const { data: reviews } = await github.rest.pulls.listReviews({
-                owner, repo, pull_number: pr_number
+            // ----- failure -----
+            if (run.conclusion === 'failure' || run.conclusion === 'timed_out' || run.conclusion === 'startup_failure') {
+              const { data: jobsData } = await github.rest.actions.listJobsForWorkflowRun({
+                owner, repo, run_id: run.id
               });
+              const failedJobs = jobsData.jobs
+                .filter(j => j.conclusion === 'failure' || j.conclusion === 'timed_out')
+                .map(j => ({ name: j.name, url: j.html_url }));
+
+              const zipBuf = await downloadLogs();
+              const failures = await extractFailures(zipBuf);
 
-              // Find active blocking reviews left by the bot account
-              const botReviews = reviews.filter(r =>
-                r.user.login === botUser.login &&
-                r.state === 'CHANGES_REQUESTED'
-              );
-
-              // Dismiss them
-              for (const review of botReviews) {
-                await github.rest.pulls.dismissReview({
-                  owner,
-                  repo,
-                  pull_number: pr_number,
-                  review_id: review.id,
-                  message: 'The CI workflow has passed! Dismissing previous review.'
+              let lines = [];
+              lines.push(`### โŒ ${workflowName} failed`);
+              lines.push("");
+              lines.push(`Commit: \`${run.head_sha.substring(0, 7)}\` ยท [run logs](${run.html_url})`);
+              lines.push("");
+              if (failedJobs.length) {
+                lines.push("**Failed jobs:**");
+                for (const j of failedJobs) {
+                  const hint = hintFor(j.name);
+                  lines.push(`- [${j.name}](${j.url})${hint ? ` โ€” ${hint}` : ""}`);
+                }
+              }
+              lines.push(renderFailuresSection(failures));
+
+              const body = `${marker}\n` + lines.join("\n");
+
+              if (pr_number) {
+                await upsertPRComment(body);
+              } else if (isMasterPush) {
+                const title = `${workflowName} failed on master @ ${run.head_sha.substring(0, 7)}`;
+                const { data: existing } = await github.rest.issues.listForRepo({
+                  owner, repo, state: 'open', labels: 'ci-failure', per_page: 50
+                });
+                const dup = existing.find(i => i.title === title);
+                if (dup) {
+                  await github.rest.issues.createComment({
+                    owner, repo, issue_number: dup.number,
+                    body: `Re-run also failed: ${run.html_url}\n\n${lines.join("\n")}`
+                  });
+                } else {
+                  await github.rest.issues.create({
+                    owner, repo, title, body, labels: ['ci-failure']
+                  });
+                }
+                try {
+                  await github.rest.repos.createCommitComment({
+                    owner, repo, commit_sha: run.head_sha, body
+                  });
+                } catch (e) {
+                  console.log(`commit comment failed: ${e.message}`);
+                }
+              }
+              return;
+            }
+
+            // ----- success -----
+            if (run.conclusion === 'success') {
+              if (pr_number) {
+                await deletePRCommentIfExists();
+              }
+              if (isMasterPush) {
+                let botLogin = null;
+                try {
+                  const { data: botUser } = await github.rest.users.getAuthenticated();
+                  botLogin = botUser.login;
+                } catch (_) {}
+                const { data: openIssues } = await github.rest.issues.listForRepo({
+                  owner, repo, state: 'open', labels: 'ci-failure', per_page: 50
                 });
+                for (const issue of openIssues) {
+                  if (botLogin && issue.user && issue.user.login !== botLogin) continue;
+                  if (!issue.title.includes(workflowName)) continue;
+                  await github.rest.issues.createComment({
+                    owner, repo, issue_number: issue.number,
+                    body: `Subsequent master ${workflowName} run passed: ${run.html_url}. Closing.`
+                  });
+                  await github.rest.issues.update({
+                    owner, repo, issue_number: issue.number, state: 'closed'
+                  });
+                }
               }
             }

.github/workflows/ci.yml ๐Ÿ”—

@@ -58,6 +58,13 @@ jobs:
       - name: Vet
         run: go vet ./...
 
+      - name: golangci-lint
+        uses: golangci/golangci-lint-action@v7
+        with:
+          version: v2.12.2
+          args: --timeout=10m --build-tags=integration
+          only-new-issues: false
+
   mod-tidy:
     runs-on: ubuntu-latest
     steps:

.github/workflows/integration.yml ๐Ÿ”—

@@ -0,0 +1,124 @@
+name: Integration
+
+on:
+  push:
+    branches: [master]
+  pull_request:
+    branches: [master]
+
+jobs:
+  imap-smtp:
+    name: imap + smtp e2e
+    runs-on: ubuntu-latest
+    timeout-minutes: 25
+
+    services:
+      greenmail:
+        image: greenmail/standalone:2.1.3
+        ports:
+          - 3025:3025
+          - 3110:3110
+          - 3143:3143
+          - 3465:3465
+          - 3993:3993
+          - 3995:3995
+          - 8080:8080
+        env:
+          GREENMAIL_OPTS: "-Dgreenmail.setup.test.all -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.auth.disabled -Dgreenmail.verbose"
+        # No --health-cmd: greenmail/standalone image has no wget/curl.
+        # The runner step below polls /api/service/readiness externally instead.
+
+    steps:
+      - uses: actions/checkout@v6
+
+      - name: Set up Go
+        uses: actions/setup-go@v6
+        with:
+          go-version: "1.26.3"
+
+      - name: Install system dependencies
+        run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev
+
+      - name: Wait for greenmail
+        run: |
+          for i in $(seq 1 60); do
+            if curl -sf http://127.0.0.1:8080/api/configuration >/dev/null; then
+              echo "greenmail ready"
+              exit 0
+            fi
+            sleep 2
+          done
+          echo "greenmail not ready"
+          curl -v http://127.0.0.1:8080/api/configuration || true
+          exit 1
+
+      - name: Run integration tests
+        env:
+          MATCHA_TEST_IMAP_HOST: 127.0.0.1
+          MATCHA_TEST_IMAP_PORT: "3993"
+          MATCHA_TEST_SMTP_PORT: "3465"
+          MATCHA_TEST_API_PORT: "8080"
+        run: |
+          go test -v -tags=integration -timeout=10m -count=1 ./tests/integration/...
+
+  matrix:
+    name: cross-platform build matrix
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-latest, macos-latest, windows-latest]
+        go: ["1.26.3"]
+    steps:
+      - uses: actions/checkout@v6
+
+      - name: Set up Go
+        uses: actions/setup-go@v6
+        with:
+          go-version: ${{ matrix.go }}
+
+      - name: Install system deps (linux)
+        if: runner.os == 'Linux'
+        run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev
+
+      - name: Race-enabled unit tests
+        run: go test -race -timeout=10m ./...
+
+  fuzz:
+    name: short fuzz
+    runs-on: ubuntu-latest
+    timeout-minutes: 15
+    steps:
+      - uses: actions/checkout@v6
+
+      - name: Set up Go
+        uses: actions/setup-go@v6
+        with:
+          go-version: "1.26.3"
+
+      - name: Install system dependencies
+        run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev
+
+      - name: Discover fuzz targets
+        id: discover
+        run: |
+          set -e
+          targets=$(grep -RIl --include='*_test.go' -E '^func Fuzz' . || true)
+          echo "found targets:"
+          echo "$targets"
+          {
+            echo "targets<<EOF"
+            echo "$targets"
+            echo "EOF"
+          } >> "$GITHUB_OUTPUT"
+
+      - name: Run fuzz targets (30s each)
+        if: steps.discover.outputs.targets != ''
+        run: |
+          for file in ${{ steps.discover.outputs.targets }}; do
+            dir=$(dirname "$file")
+            for fn in $(grep -hoE '^func (Fuzz[A-Za-z0-9_]+)' "$file" | awk '{print $2}'); do
+              echo "=== $dir :: $fn ==="
+              go test -run=^$ -fuzz="^${fn}$" -fuzztime=30s "./$dir"
+            done
+          done

.github/workflows/security.yml ๐Ÿ”—

@@ -0,0 +1,128 @@
+name: Security
+
+on:
+  push:
+    branches: [master]
+  pull_request:
+    branches: [master]
+  schedule:
+    - cron: "0 6 * * 1"
+
+permissions:
+  contents: read
+  security-events: write
+  pull-requests: write
+
+jobs:
+  govulncheck:
+    name: govulncheck
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v6
+
+      - name: Set up Go
+        uses: actions/setup-go@v6
+        with:
+          go-version: "1.26.3"
+
+      - name: Install system dependencies
+        run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev
+
+      - name: Run govulncheck
+        run: |
+          go install golang.org/x/vuln/cmd/govulncheck@latest
+          govulncheck -show verbose ./...
+
+  gosec:
+    name: gosec
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v6
+
+      - name: Set up Go
+        uses: actions/setup-go@v6
+        with:
+          go-version: "1.26.3"
+
+      - name: Install system dependencies
+        run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev
+
+      - name: Run gosec
+        uses: securego/gosec@master
+        env:
+          GOTOOLCHAIN: auto
+        with:
+          args: "-no-fail -fmt=sarif -out=gosec.sarif -severity=medium -confidence=medium -exclude=G101,G115,G204,G304,G306,G401,G501 ./..."
+
+      - name: Upload SARIF
+        if: always() && hashFiles('gosec.sarif') != ''
+        uses: github/codeql-action/upload-sarif@v3
+        with:
+          sarif_file: gosec.sarif
+          category: gosec
+
+  trivy:
+    name: trivy
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v6
+
+      - name: Trivy filesystem scan
+        uses: aquasecurity/trivy-action@master
+        with:
+          scan-type: fs
+          scan-ref: .
+          format: sarif
+          output: trivy.sarif
+          severity: CRITICAL,HIGH,MEDIUM
+          ignore-unfixed: true
+          exit-code: "0"
+
+      - name: Upload SARIF
+        if: always()
+        uses: github/codeql-action/upload-sarif@v3
+        with:
+          sarif_file: trivy.sarif
+          category: trivy
+
+      - name: Trivy config scan
+        uses: aquasecurity/trivy-action@master
+        with:
+          scan-type: config
+          scan-ref: .
+          format: table
+          severity: CRITICAL,HIGH
+          exit-code: "1"
+          skip-dirs: docs/node_modules
+
+  codeql:
+    name: codeql
+    runs-on: ubuntu-latest
+    permissions:
+      security-events: write
+      actions: read
+      contents: read
+    steps:
+      - uses: actions/checkout@v6
+
+      - name: Set up Go
+        uses: actions/setup-go@v6
+        with:
+          go-version: "1.26.3"
+
+      - name: Install system dependencies
+        run: sudo apt-get update && sudo apt-get install -y libpcsclite-dev
+
+      - name: Initialize CodeQL
+        uses: github/codeql-action/init@v3
+        with:
+          languages: go
+          queries: security-extended
+
+      - name: Build
+        run: go build ./...
+
+      - name: Analyze
+        uses: github/codeql-action/analyze@v3
+        with:
+          category: codeql-go

.golangci.yml ๐Ÿ”—

@@ -0,0 +1,127 @@
+version: "2"
+
+run:
+  timeout: 10m
+  build-tags:
+    - integration
+
+linters:
+  default: none
+  enable:
+    - errcheck
+    - govet
+    - ineffassign
+    - staticcheck
+    - unused
+    - bodyclose
+    - copyloopvar
+    - dupword
+    - durationcheck
+    - errorlint
+    - exhaustive
+    - forbidigo
+    - gocheckcompilerdirectives
+    - gochecksumtype
+    - goconst
+    - gocritic
+    - gocyclo
+    - gosec
+    - misspell
+    - nilerr
+    - nilnil
+    - noctx
+    - nolintlint
+    - prealloc
+    - predeclared
+    - reassign
+    - revive
+    - rowserrcheck
+    - sqlclosecheck
+    - tagalign
+    - tparallel
+    - unconvert
+    - unparam
+    - usestdlibvars
+    - wastedassign
+    - whitespace
+
+  settings:
+    gocyclo:
+      min-complexity: 30
+    goconst:
+      min-len: 4
+      min-occurrences: 5
+    gosec:
+      excludes:
+        - G101
+        - G115
+        - G204
+        - G304
+        - G306
+        - G401
+        - G501
+    govet:
+      enable-all: true
+      disable:
+        - fieldalignment
+        - shadow
+    revive:
+      rules:
+        - name: blank-imports
+        - name: context-as-argument
+        - name: context-keys-type
+        - name: dot-imports
+        - name: error-return
+        - name: error-strings
+        - name: error-naming
+        - name: increment-decrement
+        - name: var-declaration
+        - name: package-comments
+          disabled: true
+        - name: range
+        - name: receiver-naming
+        - name: time-naming
+        - name: unexported-return
+        - name: indent-error-flow
+        - name: errorf
+        - name: empty-block
+        - name: superfluous-else
+        - name: unused-parameter
+          disabled: true
+        - name: unreachable-code
+        - name: redefines-builtin-id
+    forbidigo:
+      forbid:
+        - pattern: ^fmt\.Print.*$
+          msg: "use logger instead of fmt.Print*"
+
+  exclusions:
+    generated: lax
+    rules:
+      - path: _test\.go
+        linters:
+          - errcheck
+          - gosec
+          - goconst
+          - gocyclo
+          - forbidigo
+          - noctx
+          - unparam
+      - path: main\.go
+        linters:
+          - forbidigo
+      - path: cli/
+        linters:
+          - forbidigo
+      - text: "weak cryptographic primitive"
+        linters:
+          - gosec
+
+issues:
+  max-issues-per-linter: 0
+  max-same-issues: 0
+
+formatters:
+  enable:
+    - gofmt
+    - goimports

backend/search_bench_test.go ๐Ÿ”—

@@ -0,0 +1,27 @@
+package backend
+
+import "testing"
+
+func BenchmarkParseSearchQuery_Simple(b *testing.B) {
+	const q = "invoice"
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		_ = ParseSearchQuery(q)
+	}
+}
+
+func BenchmarkParseSearchQuery_Complex(b *testing.B) {
+	const q = `from:alice@example.com to:bob@example.com subject:"Q4 invoice" body:overdue since:2026-01-01 before:2026-05-01 larger:1024 misc terms`
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		_ = ParseSearchQuery(q)
+	}
+}
+
+func BenchmarkTokenizeSearchQuery(b *testing.B) {
+	const q = `from:alice "long quoted phrase here" subject:foo body:bar`
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		_ = tokenizeSearchQuery(q)
+	}
+}

tests/integration/docker-compose.yml ๐Ÿ”—

@@ -0,0 +1,20 @@
+services:
+  greenmail:
+    image: greenmail/standalone:2.1.3
+    environment:
+      GREENMAIL_OPTS: >-
+        -Dgreenmail.setup.test.all
+        -Dgreenmail.hostname=0.0.0.0
+        -Dgreenmail.auth.disabled
+        -Dgreenmail.verbose
+    ports:
+      - "3025:3025"   # SMTP
+      - "3110:3110"   # POP3
+      - "3143:3143"   # IMAP
+      - "3465:3465"   # SMTPS (implicit TLS)
+      - "3993:3993"   # IMAPS (implicit TLS)
+      - "3995:3995"   # POP3S
+      - "8080:8080"   # REST API
+    # greenmail/standalone image lacks wget/curl, so we can't run an
+    # in-container healthcheck. Wait externally:
+    #   until curl -sf http://127.0.0.1:8080/api/service/readiness; do sleep 1; done

tests/integration/imap_test.go ๐Ÿ”—

@@ -0,0 +1,267 @@
+//go:build integration
+
+package integration
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/smtp"
+	"net/textproto"
+	"os"
+	"strconv"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/floatpane/matcha/backend"
+	_ "github.com/floatpane/matcha/backend/imap"
+	"github.com/floatpane/matcha/config"
+)
+
+// testEnv resolves the integration test environment. Greenmail exposes the
+// following ports by default โ€” we read them from env to allow remapping:
+//
+//	MATCHA_TEST_IMAP_HOST       default: 127.0.0.1
+//	MATCHA_TEST_IMAP_PORT       default: 3993 (implicit TLS)
+//	MATCHA_TEST_SMTP_PORT       default: 3465 (implicit TLS, used by matcha sender)
+//	MATCHA_TEST_SMTP_PLAIN_PORT default: 3025 (plain SMTP, used by deliverViaSMTP)
+//	MATCHA_TEST_API_PORT        default: 8080 (Greenmail REST API)
+type testEnv struct {
+	host          string
+	imapPort      int
+	smtpPort      int
+	smtpPlainPort int
+	apiPort       int
+}
+
+func loadEnv(t *testing.T) testEnv {
+	t.Helper()
+	env := testEnv{
+		host:          getenv("MATCHA_TEST_IMAP_HOST", "127.0.0.1"),
+		imapPort:      getenvInt(t, "MATCHA_TEST_IMAP_PORT", 3993),
+		smtpPort:      getenvInt(t, "MATCHA_TEST_SMTP_PORT", 3465),
+		smtpPlainPort: getenvInt(t, "MATCHA_TEST_SMTP_PLAIN_PORT", 3025),
+		apiPort:       getenvInt(t, "MATCHA_TEST_API_PORT", 8080),
+	}
+	return env
+}
+
+func getenv(key, fallback string) string {
+	if v := os.Getenv(key); v != "" {
+		return v
+	}
+	return fallback
+}
+
+func getenvInt(t *testing.T, key string, fallback int) int {
+	t.Helper()
+	v := os.Getenv(key)
+	if v == "" {
+		return fallback
+	}
+	n, err := strconv.Atoi(v)
+	if err != nil {
+		t.Fatalf("invalid %s: %v", key, err)
+	}
+	return n
+}
+
+func waitForGreenmail(t *testing.T, env testEnv) {
+	t.Helper()
+	deadline := time.Now().Add(60 * time.Second)
+	url := fmt.Sprintf("http://%s:%d/api/configuration", env.host, env.apiPort)
+	for time.Now().Before(deadline) {
+		resp, err := http.Get(url) //nolint:gosec
+		if err == nil && resp.StatusCode == http.StatusOK {
+			resp.Body.Close()
+			return
+		}
+		if resp != nil {
+			resp.Body.Close()
+		}
+		time.Sleep(500 * time.Millisecond)
+	}
+	t.Fatalf("greenmail not ready after 60s at %s", url)
+}
+
+func resetGreenmail(t *testing.T, env testEnv) {
+	t.Helper()
+	url := fmt.Sprintf("http://%s:%d/api/mail/purge", env.host, env.apiPort)
+	req, _ := http.NewRequest(http.MethodPost, url, nil)
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		t.Fatalf("reset greenmail: %v", err)
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode >= 400 {
+		t.Fatalf("reset greenmail: status %d", resp.StatusCode)
+	}
+}
+
+// deliverViaSMTP injects a message into the IMAP store by speaking SMTP to
+// Greenmail directly. Greenmail's REST API only supports reading and purging;
+// SMTP is the only documented way to inject mail.
+func deliverViaSMTP(t *testing.T, env testEnv, from, to, subject, body string) {
+	t.Helper()
+	addr := fmt.Sprintf("%s:%d", env.host, env.smtpPlainPort)
+
+	hdr := textproto.MIMEHeader{}
+	hdr.Set("From", from)
+	hdr.Set("To", to)
+	hdr.Set("Subject", subject)
+	hdr.Set("Date", time.Now().UTC().Format(time.RFC1123Z))
+	hdr.Set("MIME-Version", "1.0")
+	hdr.Set("Content-Type", "text/plain; charset=UTF-8")
+
+	var msg strings.Builder
+	for k, vs := range hdr {
+		for _, v := range vs {
+			fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
+		}
+	}
+	msg.WriteString("\r\n")
+	msg.WriteString(body)
+
+	if err := smtp.SendMail(addr, nil, from, []string{to}, []byte(msg.String())); err != nil {
+		t.Fatalf("deliver via smtp: %v", err)
+	}
+	// Greenmail delivers asynchronously; wait briefly so the next IMAP read
+	// sees the message.
+	time.Sleep(300 * time.Millisecond)
+}
+
+func newTestAccount(env testEnv, user, pass string) *config.Account {
+	return &config.Account{
+		ID:              "test-account",
+		Name:            "Test User",
+		Email:           user,
+		Password:        pass,
+		ServiceProvider: "custom",
+		IMAPServer:      env.host,
+		IMAPPort:        env.imapPort,
+		SMTPServer:      env.host,
+		SMTPPort:        env.smtpPort,
+		Insecure:        true,
+		Protocol:        "imap",
+		SC:              &config.SessionCache{},
+	}
+}
+
+func TestIntegration_FetchInbox(t *testing.T) {
+	env := loadEnv(t)
+	waitForGreenmail(t, env)
+	resetGreenmail(t, env)
+
+	const user = "alice@example.com"
+	const pass = "secret"
+
+	deliverViaSMTP(t, env, "bob@example.com", user, "Hello Alice", "first message")
+	deliverViaSMTP(t, env, "carol@example.com", user, "Invoice Q4", "please pay")
+
+	acct := newTestAccount(env, user, pass)
+	provider, err := backend.New(acct)
+	if err != nil {
+		t.Fatalf("backend.New: %v", err)
+	}
+	defer provider.Close()
+
+	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+	defer cancel()
+
+	emails, err := provider.FetchEmails(ctx, "INBOX", 50, 0)
+	if err != nil {
+		t.Fatalf("FetchEmails: %v", err)
+	}
+	if len(emails) != 2 {
+		t.Fatalf("FetchEmails returned %d, want 2", len(emails))
+	}
+
+	subjects := map[string]bool{}
+	for _, e := range emails {
+		subjects[e.Subject] = true
+	}
+	for _, want := range []string{"Hello Alice", "Invoice Q4"} {
+		if !subjects[want] {
+			t.Errorf("missing subject %q in %v", want, subjects)
+		}
+	}
+}
+
+func TestIntegration_SearchSubject(t *testing.T) {
+	env := loadEnv(t)
+	waitForGreenmail(t, env)
+	resetGreenmail(t, env)
+
+	const user = "alice@example.com"
+	const pass = "secret"
+
+	deliverViaSMTP(t, env, "bob@example.com", user, "Invoice Q4", "")
+	deliverViaSMTP(t, env, "bob@example.com", user, "Random update", "")
+	deliverViaSMTP(t, env, "bob@example.com", user, "Invoice Q1", "")
+
+	acct := newTestAccount(env, user, pass)
+	provider, err := backend.New(acct)
+	if err != nil {
+		t.Fatalf("backend.New: %v", err)
+	}
+	defer provider.Close()
+
+	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+	defer cancel()
+
+	results, err := provider.Search(ctx, "INBOX", backend.SearchQuery{Subject: "Invoice"})
+	if err != nil {
+		t.Fatalf("Search: %v", err)
+	}
+	if len(results) != 2 {
+		t.Fatalf("Search returned %d, want 2", len(results))
+	}
+	for _, r := range results {
+		if !strings.Contains(r.Subject, "Invoice") {
+			t.Errorf("unexpected subject %q in search results", r.Subject)
+		}
+	}
+}
+
+func TestIntegration_MarkAsRead(t *testing.T) {
+	env := loadEnv(t)
+	waitForGreenmail(t, env)
+	resetGreenmail(t, env)
+
+	const user = "alice@example.com"
+	const pass = "secret"
+
+	deliverViaSMTP(t, env, "bob@example.com", user, "Toggle me", "")
+
+	acct := newTestAccount(env, user, pass)
+	provider, err := backend.New(acct)
+	if err != nil {
+		t.Fatalf("backend.New: %v", err)
+	}
+	defer provider.Close()
+
+	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+	defer cancel()
+
+	emails, err := provider.FetchEmails(ctx, "INBOX", 10, 0)
+	if err != nil || len(emails) != 1 {
+		t.Fatalf("FetchEmails: err=%v len=%d", err, len(emails))
+	}
+	uid := emails[0].UID
+	if emails[0].IsRead {
+		t.Fatal("email unexpectedly marked read before MarkAsRead")
+	}
+
+	if err := provider.MarkAsRead(ctx, "INBOX", uid); err != nil {
+		t.Fatalf("MarkAsRead: %v", err)
+	}
+
+	emails, err = provider.FetchEmails(ctx, "INBOX", 10, 0)
+	if err != nil {
+		t.Fatalf("re-fetch: %v", err)
+	}
+	if !emails[0].IsRead {
+		t.Error("email not marked read after MarkAsRead")
+	}
+}

tui/render_bench_test.go ๐Ÿ”—

@@ -0,0 +1,62 @@
+package tui
+
+import (
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/floatpane/matcha/config"
+	"github.com/floatpane/matcha/fetcher"
+)
+
+func BenchmarkLogPanelView(b *testing.B) {
+	logger := &snapshotLogger{}
+	for i := 0; i < 10; i++ {
+		logger.Write([]byte("benchmark log line " + strings.Repeat("x", 40) + "\n"))
+	}
+	panel := NewLogPanel(logger)
+	panel.SetSize(80, 12)
+
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		_ = panel.View()
+	}
+}
+
+func BenchmarkSearchOverlayView(b *testing.B) {
+	overlay := NewSearchOverlay(80, 24)
+	emails := make([]fetcher.Email, 10)
+	for i := range emails {
+		emails[i] = fetcher.Email{
+			UID:     uint32(i),
+			From:    "sender@example.com",
+			Subject: "Benchmark email subject",
+			Date:    time.Now(),
+		}
+	}
+	overlay.results = emails
+	overlay.done = true
+
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		_ = overlay.View()
+	}
+}
+
+func BenchmarkInboxConstruction(b *testing.B) {
+	accounts := []config.Account{{ID: "a", Email: "a@example.com"}}
+	emails := make([]fetcher.Email, 500)
+	for i := range emails {
+		emails[i] = fetcher.Email{
+			UID:       uint32(i),
+			From:      "bench@example.com",
+			Subject:   "Subject line " + strings.Repeat("y", 20),
+			Date:      time.Now().Add(-time.Duration(i) * time.Minute),
+			AccountID: "a",
+		}
+	}
+	b.ReportAllocs()
+	for i := 0; i < b.N; i++ {
+		_ = NewInbox(emails, accounts)
+	}
+}

tui/snapshot_test.go ๐Ÿ”—

@@ -0,0 +1,128 @@
+package tui
+
+import (
+	"flag"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/charmbracelet/x/ansi"
+	"github.com/floatpane/matcha/internal/logging"
+)
+
+var updateGolden = flag.Bool("update", false, "update golden snapshot files")
+
+// snapshotLogger is a deterministic in-memory logger for snapshot tests.
+type snapshotLogger struct {
+	entries []logging.Entry
+}
+
+func (l *snapshotLogger) Write(p []byte) (int, error) {
+	l.entries = append(l.entries, logging.Entry{Text: strings.TrimRight(string(p), "\n")})
+	return len(p), nil
+}
+func (l *snapshotLogger) MaxEntries() int { return logging.DefaultMaxEntries }
+func (l *snapshotLogger) Tail(n int) []logging.Entry {
+	if n <= 0 || len(l.entries) == 0 {
+		return nil
+	}
+	if n >= len(l.entries) {
+		out := make([]logging.Entry, len(l.entries))
+		copy(out, l.entries)
+		return out
+	}
+	out := make([]logging.Entry, n)
+	copy(out, l.entries[len(l.entries)-n:])
+	return out
+}
+func (l *snapshotLogger) Subscribe() <-chan logging.Entry { return nil }
+
+// assertGolden compares rendered output to a golden file in testdata/golden.
+// Re-run tests with `-update` to refresh the golden files.
+func assertGolden(t *testing.T, name, got string) {
+	t.Helper()
+
+	got = normalizeForGolden(got)
+
+	path := filepath.Join("testdata", "golden", name+".txt")
+
+	if *updateGolden {
+		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+			t.Fatalf("mkdir golden: %v", err)
+		}
+		if err := os.WriteFile(path, []byte(got+"\n"), 0o644); err != nil {
+			t.Fatalf("write golden: %v", err)
+		}
+		return
+	}
+
+	want, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatalf("read golden %q (run with -update to create): %v", path, err)
+	}
+	wantStr := normalizeForGolden(string(want))
+	if got != wantStr {
+		t.Fatalf("snapshot mismatch for %s\n--- got ---\n%s\n--- want ---\n%s\n--- diff ---\ngot bytes:  %q\nwant bytes: %q",
+			name, got, wantStr, got, wantStr)
+	}
+}
+
+func normalizeForGolden(s string) string {
+	s = ansi.Strip(s)
+	s = strings.ReplaceAll(s, "\r\n", "\n")
+	s = strings.ReplaceAll(s, "\r", "\n")
+	s = stripTrailingSpace(s)
+	return strings.TrimRight(s, " \n\t")
+}
+
+func stripTrailingSpace(s string) string {
+	lines := strings.Split(s, "\n")
+	for i, line := range lines {
+		lines[i] = strings.TrimRight(line, " \t")
+	}
+	return strings.Join(lines, "\n")
+}
+
+func TestSnapshot_LogPanel_Empty(t *testing.T) {
+	panel := NewLogPanel(&snapshotLogger{})
+	panel.SetSize(60, 6)
+	assertGolden(t, "log_panel_empty", panel.View())
+}
+
+func TestSnapshot_LogPanel_WithEntries(t *testing.T) {
+	logger := &snapshotLogger{}
+	logger.Write([]byte("started fetcher\n"))
+	logger.Write([]byte("connected to imap.example.com\n"))
+	logger.Write([]byte("fetched 12 new messages\n"))
+
+	panel := NewLogPanel(logger)
+	panel.SetSize(60, 6)
+	assertGolden(t, "log_panel_with_entries", panel.View())
+}
+
+func TestSnapshot_LogPanel_TruncatesLongLines(t *testing.T) {
+	logger := &snapshotLogger{}
+	logger.Write([]byte(strings.Repeat("verylongline ", 20) + "\n"))
+
+	panel := NewLogPanel(logger)
+	panel.SetSize(30, 4)
+	assertGolden(t, "log_panel_truncated", panel.View())
+}
+
+func TestSnapshot_SearchOverlay_Empty(t *testing.T) {
+	overlay := NewSearchOverlay(80, 24)
+	assertGolden(t, "search_overlay_empty", overlay.View())
+}
+
+func TestSnapshot_SearchOverlay_Loading(t *testing.T) {
+	overlay := NewSearchOverlay(80, 24)
+	overlay.loading = true
+	assertGolden(t, "search_overlay_loading", overlay.View())
+}
+
+func TestSnapshot_SearchOverlay_Error(t *testing.T) {
+	overlay := NewSearchOverlay(80, 24)
+	overlay.err = "connection refused"
+	assertGolden(t, "search_overlay_error", overlay.View())
+}

tui/testdata/golden/log_panel_empty.txt ๐Ÿ”—

@@ -0,0 +1,3 @@
+โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+[Logs]
+No logs yet

tui/testdata/golden/log_panel_truncated.txt ๐Ÿ”—

@@ -0,0 +1,3 @@
+โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+[Logs]
+verylongline verylongline verโ€ฆ

tui/testdata/golden/log_panel_with_entries.txt ๐Ÿ”—

@@ -0,0 +1,5 @@
+โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+[Logs]
+started fetcher
+connected to imap.example.com
+fetched 12 new messages

tui/testdata/golden/search_overlay_empty.txt ๐Ÿ”—

@@ -0,0 +1,7 @@
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚                                                                          โ”‚
+โ”‚  Search mail                                                             โ”‚
+โ”‚                                                                          โ”‚
+โ”‚  / f                                                                     โ”‚
+โ”‚                                                                          โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

tui/testdata/golden/search_overlay_error.txt ๐Ÿ”—

@@ -0,0 +1,9 @@
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚                                                                          โ”‚
+โ”‚  Search mail                                                             โ”‚
+โ”‚                                                                          โ”‚
+โ”‚  / f                                                                     โ”‚
+โ”‚                                                                          โ”‚
+โ”‚  connection refused                                                      โ”‚
+โ”‚                                                                          โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

tui/testdata/golden/search_overlay_loading.txt ๐Ÿ”—

@@ -0,0 +1,9 @@
+โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
+โ”‚                                                                          โ”‚
+โ”‚  Search mail                                                             โ”‚
+โ”‚                                                                          โ”‚
+โ”‚  / f                                                                     โ”‚
+โ”‚                                                                          โ”‚
+โ”‚  Searching...                                                            โ”‚
+โ”‚                                                                          โ”‚
+โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ