Detailed changes
@@ -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
@@ -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
@@ -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,
+ });
+ }
@@ -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'
+ });
+ }
}
}
@@ -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:
@@ -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
@@ -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
@@ -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
@@ -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)
+ }
+}
@@ -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
@@ -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")
+ }
+}
@@ -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)
+ }
+}
@@ -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())
+}
@@ -0,0 +1,3 @@
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+[Logs]
+No logs yet
@@ -0,0 +1,3 @@
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+[Logs]
+verylongline verylongline verโฆ
@@ -0,0 +1,5 @@
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+[Logs]
+started fetcher
+connected to imap.example.com
+fetched 12 new messages
@@ -0,0 +1,7 @@
+โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ โ
+โ Search mail โ
+โ โ
+โ / f โ
+โ โ
+โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
@@ -0,0 +1,9 @@
+โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ โ
+โ Search mail โ
+โ โ
+โ / f โ
+โ โ
+โ connection refused โ
+โ โ
+โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
@@ -0,0 +1,9 @@
+โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ โ
+โ Search mail โ
+โ โ
+โ / f โ
+โ โ
+โ Searching... โ
+โ โ
+โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ