# 0.2 — Phase 4: Data viewer (Implementation Plan)

> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Read-only browse of synced messages. SwiftUI `NavigationSplitView` with three columns: sidebar (accounts + top contacts + "Manage Accounts"), message list (middle, search-aware), message detail (right, with attachment thumbnails + Quick Look). Validates the data model and turns AccountPilot into a usable app.

**Architecture:** New CLI surface for messages: `accountpilot messages list [filters] --json`, `accountpilot messages get <id> --json`, `accountpilot attachments path <id> --json`. Existing `accountpilot search "query"` and `accountpilot people list` get `--json` modes. SwiftUI `MessagesStore` + `SearchStore` wrap these via `PythonRuntime`. Attachments open in Quick Look via `NSWorkspace.shared.open(url)` against the CAS file. `ContentView` now opens directly to `MessagesView` (the new browse UI); `AccountsView` (Phase 2) is reachable via the sidebar's "Manage Accounts" row.

**Tech Stack:** Click + sqlite (Python) · pytest (tests) · SwiftUI `NavigationSplitView` + `.searchable()` · macOS Quick Look via `NSWorkspace` · existing `messages_fts` FTS5 table with BM25 ordering · existing CAS at `<data_dir>/attachments/<hash[0:2]>/<hash[2:4]>/<hash>.bin`

---

## Prerequisites

- [ ] Phase 3 PR has merged to `main`. Verify: `git log --oneline main | head -3` shows the Phase 3 merge commit.
- [ ] On a Phase 4 branch: `git checkout -b 0.2-p4-data-viewer` (off main).
- [ ] `pytest -q` green from main.
- [ ] `dist/AccountPilot.app` from Phase 3 still builds end-to-end (`./app/scripts/build-app.sh` ends in `==> all verification passed`).
- [ ] Your local DB has at least a few hundred messages from Phase 3's sync — needed for the manual smoke test (Task 12) to feel realistic. If your DB is empty: open the Phase 3 .app, let sync run for a minute, confirm `accountpilot status --json` shows non-zero `synced_count`.

## File structure (created or modified)

```
src/accountpilot/core/cli/
├── messages_cmds.py        # CREATE: messages list / get + attachments path
├── search_cmd.py           # MODIFY: add --json
└── people_cmds.py          # MODIFY: add --json

src/accountpilot/cli.py     # MODIFY: register messages_group

tests/accountpilot/unit/cli/
├── test_messages_cmds.py   # CREATE
├── test_search_cmd_json.py # CREATE
└── test_people_cmds_json.py# CREATE

app/AccountPilot/
├── Models/
│   ├── Message.swift            # CREATE: list-row + detail Codable
│   └── Person.swift             # CREATE: contact sidebar Codable
├── Services/
│   ├── MessagesStore.swift      # CREATE: paginated list + cache
│   ├── SearchStore.swift        # CREATE: debounced FTS5 query
│   └── AttachmentService.swift  # CREATE: Quick Look opener
├── Views/
│   └── Browse/
│       ├── BrowseView.swift          # CREATE: NavigationSplitView root
│       ├── BrowseSidebar.swift       # CREATE: accounts + contacts + Manage
│       ├── MessageListView.swift     # CREATE: middle pane (with .searchable)
│       ├── MessageDetailView.swift   # CREATE: right pane with attachments
│       └── AttachmentRow.swift       # CREATE: thumbnail + Quick Look click
└── ContentView.swift              # MODIFY: route to BrowseView (not AccountsView) post-FDA

CHANGELOG.md                         # MODIFY: Phase 4 entries under [0.2.0]
```

---

## Task 1: `accountpilot search --json` — BM25-ordered envelope

**Goal:** Add `--json` to the existing `accountpilot search "query"` command. Returns FTS5 BM25-ranked results so the UI's search list reflects relevance, not just recency.

**Files:**
- Modify: `src/accountpilot/core/cli/search_cmd.py`
- Create: `tests/accountpilot/unit/cli/test_search_cmd_json.py`

JSON shape:
```json
{
  "ok": true,
  "data": {
    "query": "fazla",
    "results": [
      {
        "id": 42, "source": "gmail", "account_id": 2,
        "sent_at": "2026-04-15T08:23:00+00:00",
        "subject": "Q1 board update",
        "snippet": "…the fazla numbers we discussed…",
        "score": -0.23
      }
    ]
  },
  "error": null
}
```

`score` comes from FTS5's `bm25(messages_fts)`; lower is more relevant (FTS5 returns negative numbers, sort ascending by `bm25()`).

- [ ] **Step 1: Write the failing test** — `tests/accountpilot/unit/cli/test_search_cmd_json.py`:

```python
"""Tests for `accountpilot search --json`."""

from __future__ import annotations

import json
from pathlib import Path  # noqa: TC003 — used by click.Path

import pytest
from click.testing import CliRunner

from accountpilot.core.cli.search_cmd import search_cmd
from accountpilot.core.db.connection import open_db


@pytest.fixture
async def db_with_searchable_messages(tmp_path: Path) -> Path:
    db = tmp_path / "test.db"
    async with open_db(db) as conn:
        await conn.execute(
            "INSERT INTO people (id, name, surname, is_owner, created_at, updated_at) "
            "VALUES (1, 'Ada', 'Lovelace', 1, '2026-01-01T00:00:00+00:00', "
            "'2026-01-01T00:00:00+00:00')"
        )
        await conn.execute(
            "INSERT INTO accounts (id, owner_id, source, account_identifier, "
            "enabled, created_at, updated_at) "
            "VALUES (2, 1, 'gmail', 'ada@example.com', 1, "
            "'2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00')"
        )
        await conn.execute(
            "INSERT INTO messages (id, account_id, source, external_id, sent_at, "
            "body_text, direction, created_at) "
            "VALUES (10, 2, 'gmail', 'rfc-1', '2026-04-15T08:23:00+00:00', "
            "'the fazla quarterly numbers were strong', 'in', "
            "'2026-04-15T08:23:01+00:00')"
        )
        await conn.execute(
            "INSERT INTO email_details (message_id, subject, imap_uid, mailbox) "
            "VALUES (10, 'Q1 board update', 1234, 'INBOX')"
        )
        await conn.execute(
            "INSERT INTO messages (id, account_id, source, external_id, sent_at, "
            "body_text, direction, created_at) "
            "VALUES (11, 2, 'gmail', 'rfc-2', '2026-03-01T08:00:00+00:00', "
            "'unrelated text', 'in', '2026-03-01T08:00:01+00:00')"
        )
        await conn.execute(
            "INSERT INTO email_details (message_id, subject, imap_uid, mailbox) "
            "VALUES (11, 'Other', 1235, 'INBOX')"
        )
        await conn.commit()
    return db


def test_search_json_returns_only_matches(db_with_searchable_messages: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        search_cmd, ["fazla", "--json", "--db-path", str(db_with_searchable_messages)]
    )
    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload["ok"] is True
    results = payload["data"]["results"]
    assert len(results) == 1
    r = results[0]
    assert r["id"] == 10
    assert r["source"] == "gmail"
    assert r["account_id"] == 2
    assert r["subject"] == "Q1 board update"
    assert "fazla" in r["snippet"].lower()
    assert "score" in r
    assert payload["data"]["query"] == "fazla"


def test_search_json_no_matches_returns_empty_list(db_with_searchable_messages: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        search_cmd,
        ["xyznotfound", "--json", "--db-path", str(db_with_searchable_messages)],
    )
    assert result.exit_code == 0
    payload = json.loads(result.output)
    assert payload["ok"] is True
    assert payload["data"]["results"] == []


def test_search_json_respects_limit(db_with_searchable_messages: Path) -> None:
    runner = CliRunner()
    # "the" appears in both messages
    result = runner.invoke(
        search_cmd,
        ["the", "--json", "--limit", "1",
         "--db-path", str(db_with_searchable_messages)],
    )
    payload = json.loads(result.output)
    assert len(payload["data"]["results"]) == 1
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/accountpilot/unit/cli/test_search_cmd_json.py -v`
Expected: FAIL — `--json` not yet supported.

- [ ] **Step 3: Add `--json` mode + BM25 ordering to `search_cmd`**

Read `src/accountpilot/core/cli/search_cmd.py` first. Add `import json` + a local `_emit_envelope` helper at module level (mirror the Phase 2/3 pattern). Add `--json` flag:

```python
@click.option("--json", "json_out", is_flag=True)
```

In the function body, branch on `json_out`. The JSON branch uses BM25 ordering:

```python
if json_out:
    async with (
        open_db(db_path) as db,
        db.execute(
            """
            SELECT m.id, m.source, m.account_id, m.sent_at,
                   COALESCE(ed.subject, '') AS subject,
                   SUBSTR(m.body_text, 1, 160) AS snippet,
                   bm25(messages_fts) AS score
            FROM messages m
            JOIN messages_fts f ON f.rowid = m.id
            LEFT JOIN email_details ed ON ed.message_id = m.id
            WHERE messages_fts MATCH ?
            ORDER BY score
            LIMIT ?
            """,
            (query, limit),
        ) as cur,
    ):
        rows = await cur.fetchall()
    _emit_envelope(data={
        "query": query,
        "results": [
            {
                "id": r["id"],
                "source": r["source"],
                "account_id": r["account_id"],
                "sent_at": r["sent_at"],
                "subject": r["subject"],
                "snippet": r["snippet"],
                "score": r["score"],
            }
            for r in rows
        ],
    })
    return
```

Keep the existing human-readable branch unchanged below.

- [ ] **Step 4: Verify tests + linters**

```bash
pytest tests/accountpilot/unit/cli/test_search_cmd_json.py -v
pytest tests/accountpilot/unit/cli/ -q
ruff check src tests
mypy src/accountpilot
```

All clean.

- [ ] **Step 5: Commit**

```bash
git add src/accountpilot/core/cli/search_cmd.py tests/accountpilot/unit/cli/test_search_cmd_json.py
git commit -m "feat(cli): search --json with FTS5 BM25 ordering"
```

---

## Task 2: `accountpilot people list --json`

**Goal:** Add `--json` to the existing `people list`. The Swift sidebar uses this to populate the contacts list.

**Files:**
- Modify: `src/accountpilot/core/cli/people_cmds.py`
- Create: `tests/accountpilot/unit/cli/test_people_cmds_json.py`

JSON shape:
```json
{
  "ok": true,
  "data": {
    "people": [
      {
        "id": 1, "name": "Ada", "surname": "Lovelace",
        "is_owner": true,
        "identifiers": [
          {"kind": "email", "value": "ada@example.com"}
        ],
        "message_count": 142
      }
    ]
  },
  "error": null
}
```

`message_count` is a JOIN against `message_people` so the sidebar can sort by activity.

- [ ] **Step 1: Write the failing test** — `tests/accountpilot/unit/cli/test_people_cmds_json.py`:

```python
"""Tests for `accountpilot people list --json`."""

from __future__ import annotations

import json
from pathlib import Path  # noqa: TC003

import pytest
from click.testing import CliRunner

from accountpilot.core.cli.people_cmds import people_group
from accountpilot.core.db.connection import open_db


@pytest.fixture
async def db_with_people(tmp_path: Path) -> Path:
    db = tmp_path / "test.db"
    async with open_db(db) as conn:
        # Owner + 1 non-owner contact
        await conn.execute(
            "INSERT INTO people (id, name, surname, is_owner, created_at, updated_at) "
            "VALUES (1, 'Ada', 'Lovelace', 1, '2026-01-01T00:00:00+00:00', "
            "'2026-01-01T00:00:00+00:00')"
        )
        await conn.execute(
            "INSERT INTO people (id, name, surname, is_owner, created_at, updated_at) "
            "VALUES (2, 'Charles', 'Babbage', 0, '2026-01-01T00:00:00+00:00', "
            "'2026-01-01T00:00:00+00:00')"
        )
        await conn.execute(
            "INSERT INTO identifiers (id, person_id, kind, value, is_primary, created_at) "
            "VALUES (1, 1, 'email', 'ada@example.com', 1, '2026-01-01T00:00:00+00:00')"
        )
        await conn.execute(
            "INSERT INTO identifiers (id, person_id, kind, value, is_primary, created_at) "
            "VALUES (2, 2, 'email', 'charles@example.com', 1, '2026-01-01T00:00:00+00:00')"
        )
        # Account + 2 messages, link Charles to one
        await conn.execute(
            "INSERT INTO accounts (id, owner_id, source, account_identifier, "
            "enabled, created_at, updated_at) "
            "VALUES (1, 1, 'gmail', 'ada@example.com', 1, "
            "'2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00')"
        )
        await conn.execute(
            "INSERT INTO messages (id, account_id, source, external_id, sent_at, "
            "body_text, direction, created_at) "
            "VALUES (10, 1, 'gmail', 'a', '2026-04-01T00:00:00+00:00', "
            "'hi', 'in', '2026-04-01T00:00:01+00:00')"
        )
        await conn.execute(
            "INSERT INTO message_people (message_id, person_id, role) "
            "VALUES (10, 2, 'from')"
        )
        await conn.commit()
    return db


def test_people_list_json_envelope(db_with_people: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        people_group, ["list", "--json", "--db-path", str(db_with_people)]
    )
    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload["ok"] is True
    people = payload["data"]["people"]
    assert len(people) == 2
    by_id = {p["id"]: p for p in people}
    assert by_id[1]["name"] == "Ada"
    assert by_id[1]["is_owner"] is True
    assert by_id[1]["identifiers"] == [{"kind": "email", "value": "ada@example.com"}]
    assert by_id[1]["message_count"] == 0
    assert by_id[2]["name"] == "Charles"
    assert by_id[2]["is_owner"] is False
    assert by_id[2]["message_count"] == 1


def test_people_list_json_owners_only(db_with_people: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        people_group,
        ["list", "--json", "--owners", "--db-path", str(db_with_people)],
    )
    payload = json.loads(result.output)
    assert len(payload["data"]["people"]) == 1
    assert payload["data"]["people"][0]["is_owner"] is True
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/accountpilot/unit/cli/test_people_cmds_json.py -v`
Expected: FAIL — `--json` not yet supported.

- [ ] **Step 3: Implement `--json` mode**

Read `src/accountpilot/core/cli/people_cmds.py`. Add the `_emit_envelope` helper and `import json` at module top. Add `--json` flag to `people_list`. JSON branch:

```python
if json_out:
    async with (
        open_db(db_path) as db,
        db.execute(
            """
            SELECT p.id, p.name, p.surname, p.is_owner,
                   (SELECT COUNT(*) FROM message_people mp
                    WHERE mp.person_id = p.id) AS message_count
            FROM people p
            """ + ("WHERE p.is_owner=1 " if owners else "") + """
            ORDER BY message_count DESC, p.id
            """
        ) as cur,
    ):
        people_rows = await cur.fetchall()

        # Fetch identifiers per person separately to avoid GROUP_CONCAT JSON parsing.
        people_by_id: dict[int, dict[str, Any]] = {
            r["id"]: {
                "id": r["id"],
                "name": r["name"],
                "surname": r["surname"],
                "is_owner": bool(r["is_owner"]),
                "identifiers": [],
                "message_count": r["message_count"],
            }
            for r in people_rows
        }
        if people_by_id:
            placeholders = ",".join("?" * len(people_by_id))
            async with db.execute(
                f"SELECT person_id, kind, value FROM identifiers "
                f"WHERE person_id IN ({placeholders}) ORDER BY id",
                tuple(people_by_id.keys()),
            ) as cur2:
                async for ident in cur2:
                    people_by_id[ident["person_id"]]["identifiers"].append({
                        "kind": ident["kind"],
                        "value": ident["value"],
                    })

    # Preserve the message_count DESC ordering from the first query
    ordered = [people_by_id[r["id"]] for r in people_rows]
    _emit_envelope(data={"people": ordered})
    return
```

Keep the existing human-readable path unchanged.

- [ ] **Step 4: Verify**

```bash
pytest tests/accountpilot/unit/cli/test_people_cmds_json.py -v
pytest tests/accountpilot/unit/cli/ -q
ruff check src tests
mypy src/accountpilot
```

- [ ] **Step 5: Commit**

```bash
git add src/accountpilot/core/cli/people_cmds.py tests/accountpilot/unit/cli/test_people_cmds_json.py
git commit -m "feat(cli): people list --json with message counts"
```

---

## Task 3: `accountpilot messages list [filters] --json`

**Goal:** The big read endpoint. Returns paginated message rows for the middle pane.

**Files:**
- Create: `src/accountpilot/core/cli/messages_cmds.py`
- Create: `tests/accountpilot/unit/cli/test_messages_cmds.py`
- Modify: `src/accountpilot/cli.py` (register messages_group)

Filters:
- `--account ID` — restrict to one account
- `--contact-id ID` — restrict to messages where `message_people.person_id == ID` (any role)
- `--since YYYY-MM-DD` — only messages with `sent_at >= the date`
- `--limit N` (default 50, max 500)
- `--cursor ID` — paginate: rows with `id < cursor` (descending stable pagination by id)

JSON shape:
```json
{
  "ok": true,
  "data": {
    "messages": [
      {
        "id": 42, "source": "gmail", "account_id": 2,
        "sent_at": "2026-04-15T08:23:00+00:00",
        "subject": "Q1 board update",
        "snippet": "Hi team, the quarterly numbers were strong …",
        "thread_id": "gthread/abc",
        "from_name": "Charles Babbage",
        "from_identifier": "charles@example.com",
        "has_attachments": true
      }
    ],
    "next_cursor": 41
  },
  "error": null
}
```

`next_cursor` is the smallest `id` in the result minus … no wait, it's the LAST result's id; the caller passes it as `--cursor` to get the next page. `null` when fewer than `limit` rows returned (= last page).

- [ ] **Step 1: Write the failing tests** — `tests/accountpilot/unit/cli/test_messages_cmds.py`:

```python
"""Tests for `accountpilot messages list / get` and `attachments path`."""

from __future__ import annotations

import asyncio
import json
from pathlib import Path  # noqa: TC003

import pytest
from click.testing import CliRunner

from accountpilot.core.cli.messages_cmds import messages_group
from accountpilot.core.db.connection import open_db


def _seed_three_messages(db: Path) -> None:
    """Two gmail messages + one imessage, with from-attribution and an
    attachment on message id 12.
    """
    async def _run() -> None:
        async with open_db(db) as conn:
            await conn.execute(
                "INSERT INTO people (id, name, surname, is_owner, created_at, updated_at) "
                "VALUES (1, 'Ada', NULL, 1, '2026-01-01T00:00:00+00:00', "
                "'2026-01-01T00:00:00+00:00')"
            )
            await conn.execute(
                "INSERT INTO people (id, name, surname, is_owner, created_at, updated_at) "
                "VALUES (2, 'Charles', 'Babbage', 0, '2026-01-01T00:00:00+00:00', "
                "'2026-01-01T00:00:00+00:00')"
            )
            await conn.execute(
                "INSERT INTO identifiers (id, person_id, kind, value, is_primary, created_at) "
                "VALUES (1, 2, 'email', 'charles@example.com', 1, "
                "'2026-01-01T00:00:00+00:00')"
            )
            await conn.execute(
                "INSERT INTO accounts (id, owner_id, source, account_identifier, "
                "enabled, created_at, updated_at) "
                "VALUES (1, 1, 'gmail', 'ada@example.com', 1, "
                "'2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00')"
            )
            await conn.execute(
                "INSERT INTO accounts (id, owner_id, source, account_identifier, "
                "enabled, created_at, updated_at) "
                "VALUES (2, 1, 'imessage', '+15551234567', 1, "
                "'2026-01-01T00:00:00+00:00', '2026-01-01T00:00:00+00:00')"
            )
            for mid, acct, src, ext, sent, body in [
                (10, 1, 'gmail', 'g-1', '2026-03-01T00:00:00+00:00', 'oldest msg'),
                (11, 1, 'gmail', 'g-2', '2026-04-01T00:00:00+00:00', 'middle msg'),
                (12, 2, 'imessage', 'im-1', '2026-05-01T00:00:00+00:00', 'newest msg'),
            ]:
                await conn.execute(
                    "INSERT INTO messages (id, account_id, source, external_id, sent_at, "
                    "body_text, direction, created_at) "
                    "VALUES (?, ?, ?, ?, ?, ?, 'in', ?)",
                    (mid, acct, src, ext, sent, body, sent),
                )
                await conn.execute(
                    "INSERT INTO message_people (message_id, person_id, role) "
                    "VALUES (?, 2, 'from')",
                    (mid,),
                )
            await conn.execute(
                "INSERT INTO email_details (message_id, subject, imap_uid, mailbox) "
                "VALUES (10, 'Old subject', 1, 'INBOX')"
            )
            await conn.execute(
                "INSERT INTO email_details (message_id, subject, imap_uid, mailbox) "
                "VALUES (11, 'Middle subject', 2, 'INBOX')"
            )
            await conn.execute(
                "INSERT INTO attachments (message_id, filename, content_hash, "
                "mime_type, size_bytes, cas_path) "
                "VALUES (12, 'pic.jpg', 'abc123', 'image/jpeg', 4096, "
                "'ab/c1/abc123.bin')"
            )
            await conn.commit()
    asyncio.run(_run())


@pytest.fixture
def populated_db(tmp_path: Path) -> Path:
    db = tmp_path / "test.db"
    _seed_three_messages(db)
    return db


def test_messages_list_default(populated_db: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        messages_group, ["list", "--json", "--db-path", str(populated_db)]
    )
    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload["ok"] is True
    msgs = payload["data"]["messages"]
    assert len(msgs) == 3
    # Newest first (DESC sent_at)
    assert msgs[0]["id"] == 12
    assert msgs[0]["source"] == "imessage"
    assert msgs[0]["has_attachments"] is True
    assert msgs[0]["from_name"] == "Charles Babbage"
    assert msgs[0]["from_identifier"] == "charles@example.com"
    assert msgs[1]["id"] == 11
    assert msgs[1]["subject"] == "Middle subject"
    assert msgs[2]["id"] == 10
    assert msgs[2]["subject"] == "Old subject"
    assert msgs[2]["has_attachments"] is False
    assert payload["data"]["next_cursor"] is None  # 3 < default limit


def test_messages_list_filtered_by_account(populated_db: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        messages_group,
        ["list", "--json", "--account", "1", "--db-path", str(populated_db)],
    )
    payload = json.loads(result.output)
    assert {m["id"] for m in payload["data"]["messages"]} == {10, 11}


def test_messages_list_filtered_by_contact(populated_db: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        messages_group,
        ["list", "--json", "--contact-id", "2",
         "--db-path", str(populated_db)],
    )
    payload = json.loads(result.output)
    # Charles is the from-person on all three messages
    assert {m["id"] for m in payload["data"]["messages"]} == {10, 11, 12}


def test_messages_list_filtered_by_since(populated_db: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        messages_group,
        ["list", "--json", "--since", "2026-04-15",
         "--db-path", str(populated_db)],
    )
    payload = json.loads(result.output)
    assert {m["id"] for m in payload["data"]["messages"]} == {12}


def test_messages_list_pagination_via_cursor(populated_db: Path) -> None:
    runner = CliRunner()
    # Page 1: limit=2 → should return 12 + 11, with next_cursor=11
    result1 = runner.invoke(
        messages_group,
        ["list", "--json", "--limit", "2", "--db-path", str(populated_db)],
    )
    p1 = json.loads(result1.output)
    assert [m["id"] for m in p1["data"]["messages"]] == [12, 11]
    assert p1["data"]["next_cursor"] == 11

    # Page 2: cursor=11 → only msg id 10 (id < 11)
    result2 = runner.invoke(
        messages_group,
        ["list", "--json", "--cursor", "11",
         "--db-path", str(populated_db)],
    )
    p2 = json.loads(result2.output)
    assert [m["id"] for m in p2["data"]["messages"]] == [10]
    assert p2["data"]["next_cursor"] is None
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/accountpilot/unit/cli/test_messages_cmds.py -v`
Expected: FAIL — module does not exist.

- [ ] **Step 3: Implement `messages_cmds.py`**

`src/accountpilot/core/cli/messages_cmds.py`:

```python
# AccountPilot — unified per-machine account sync framework
# Copyright (C) 2026 Hasan Arda Eren <ardaeren13@gmail.com>
# Licensed under AGPL-3.0-or-later.

"""accountpilot messages — list / get + accountpilot attachments path."""

from __future__ import annotations

import asyncio
import json
from pathlib import Path
from typing import Any

import click

from accountpilot.core import paths
from accountpilot.core.db.connection import open_db


def _emit_envelope(*, data: Any | None = None, error: dict[str, str] | None = None) -> None:
    payload = {"ok": error is None, "data": data, "error": error}
    click.echo(json.dumps(payload))


def _db_option(f: Any) -> Any:
    return click.option(
        "--db-path",
        type=click.Path(path_type=Path),
        default=lambda: paths.data_dir() / "accountpilot.db",
        show_default="$ACCOUNTPILOT_DATA_DIR/accountpilot.db",
    )(f)


_LIST_BASE_SQL = """
SELECT
    m.id, m.source, m.account_id, m.sent_at, m.thread_id,
    COALESCE(ed.subject, '') AS subject,
    SUBSTR(m.body_text, 1, 200) AS snippet,
    fp.name AS from_name,
    fp.surname AS from_surname,
    fi.value AS from_identifier,
    EXISTS (SELECT 1 FROM attachments a WHERE a.message_id = m.id) AS has_attachments
FROM messages m
LEFT JOIN email_details ed ON ed.message_id = m.id
LEFT JOIN message_people mp_from ON mp_from.message_id = m.id AND mp_from.role = 'from'
LEFT JOIN people fp ON fp.id = mp_from.person_id
LEFT JOIN identifiers fi ON fi.person_id = fp.id AND fi.is_primary = 1
"""


@click.group("messages")
def messages_group() -> None:
    """Read messages stored in the local DB."""


@messages_group.command("list")
@click.option("--json", "json_out", is_flag=True)
@click.option("--account", "account_id", type=int, default=None)
@click.option("--contact-id", "contact_id", type=int, default=None,
              help="Filter to messages where this person appears (any role).")
@click.option("--since", "since_date", default=None,
              help="ISO date YYYY-MM-DD; sent_at >= this date.")
@click.option("--limit", type=int, default=50)
@click.option("--cursor", type=int, default=None,
              help="Pagination: return rows with id < cursor.")
@_db_option
def messages_list(
    json_out: bool,
    account_id: int | None,
    contact_id: int | None,
    since_date: str | None,
    limit: int,
    cursor: int | None,
    db_path: Path,
) -> None:
    """Paginated list of messages, newest first."""
    limit = max(1, min(limit, 500))

    where_clauses: list[str] = []
    params: list[Any] = []
    if account_id is not None:
        where_clauses.append("m.account_id = ?")
        params.append(account_id)
    if contact_id is not None:
        where_clauses.append(
            "EXISTS (SELECT 1 FROM message_people mp "
            "WHERE mp.message_id = m.id AND mp.person_id = ?)"
        )
        params.append(contact_id)
    if since_date is not None:
        # Compare lexicographically — works because sent_at is ISO 8601.
        where_clauses.append("m.sent_at >= ?")
        params.append(f"{since_date}T00:00:00+00:00")
    if cursor is not None:
        where_clauses.append("m.id < ?")
        params.append(cursor)

    sql = _LIST_BASE_SQL
    if where_clauses:
        sql += " WHERE " + " AND ".join(where_clauses)
    sql += " ORDER BY m.sent_at DESC, m.id DESC LIMIT ?"
    params.append(limit)

    async def _run() -> dict[str, Any]:
        async with open_db(db_path) as db, db.execute(sql, tuple(params)) as cur:
            rows = await cur.fetchall()
        messages = []
        for r in rows:
            from_name = None
            if r["from_name"] is not None:
                from_name = (
                    f"{r['from_name']} {r['from_surname']}".strip()
                    if r["from_surname"] else r["from_name"]
                )
            messages.append({
                "id": r["id"],
                "source": r["source"],
                "account_id": r["account_id"],
                "sent_at": r["sent_at"],
                "thread_id": r["thread_id"],
                "subject": r["subject"],
                "snippet": r["snippet"],
                "from_name": from_name,
                "from_identifier": r["from_identifier"],
                "has_attachments": bool(r["has_attachments"]),
            })
        next_cursor = messages[-1]["id"] if len(messages) == limit else None
        return {"messages": messages, "next_cursor": next_cursor}

    result = asyncio.run(_run())
    if json_out:
        _emit_envelope(data=result)
        return
    for m in result["messages"]:
        label = m["subject"] or m["snippet"][:60]
        click.echo(f"#{m['id']} [{m['source']}] {m['sent_at']}  {label}")
```

(The `messages get` and `attachments path` commands land in Tasks 4 and 5 — separate files for the test split, but in the same module here.)

- [ ] **Step 4: Register the group**

In `src/accountpilot/cli.py`, add `from accountpilot.core.cli.messages_cmds import messages_group` and `cli.add_command(messages_group)` mirroring how Phase 2's `config_group` is registered.

- [ ] **Step 5: Verify**

```bash
pytest tests/accountpilot/unit/cli/test_messages_cmds.py -v
pytest tests/accountpilot/unit/cli/ -q
ruff check src tests
mypy src/accountpilot
python3 -m accountpilot.cli messages --help
```

`messages --help` should list `list` (and later `get`) subcommands.

- [ ] **Step 6: Commit**

```bash
git add src/accountpilot/core/cli/messages_cmds.py tests/accountpilot/unit/cli/test_messages_cmds.py src/accountpilot/cli.py
git commit -m "feat(cli): messages list --json with filters + cursor pagination"
```

---

## Task 4: `accountpilot messages get <id> --json`

**Goal:** Detail-pane endpoint. Returns the full message body + email-or-imessage specifics + attachments + recipients.

**Files:**
- Modify: `src/accountpilot/core/cli/messages_cmds.py`
- Modify: `tests/accountpilot/unit/cli/test_messages_cmds.py`

JSON shape:
```json
{
  "ok": true,
  "data": {
    "message": {
      "id": 12, "source": "imessage", "account_id": 2,
      "sent_at": "2026-05-01T00:00:00+00:00",
      "received_at": null,
      "thread_id": "im-thread-1",
      "body_text": "newest msg",
      "body_html": null,
      "direction": "in",
      "subject": null,                 // null for iMessage; populated for email
      "email": null,                   // or {imap_uid, mailbox, gmail_thread_id, in_reply_to, ...}
      "imessage": {                    // or null for email
        "chat_guid": "iMessage;-;+15551234567",
        "service": "iMessage",
        "is_from_me": false,
        "is_read": true
      },
      "people": [
        {"role": "from", "id": 2, "name": "Charles Babbage", "identifier": "charles@example.com"}
      ],
      "attachments": [
        {"id": 1, "filename": "pic.jpg", "content_hash": "abc123",
         "mime_type": "image/jpeg", "size_bytes": 4096}
      ]
    }
  },
  "error": null
}
```

Notice `cas_path` is intentionally NOT exposed here — that's what `accountpilot attachments path <id>` is for (Task 5). Keeping the CAS layout opaque to the UI lets us change it later.

- [ ] **Step 1: Write the failing tests** — append to `tests/accountpilot/unit/cli/test_messages_cmds.py`:

```python
def test_messages_get_imessage_with_attachment(populated_db: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        messages_group, ["get", "12", "--json", "--db-path", str(populated_db)]
    )
    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    m = payload["data"]["message"]
    assert m["id"] == 12
    assert m["source"] == "imessage"
    assert m["body_text"] == "newest msg"
    assert m["email"] is None
    # imessage_details fixture didn't insert this row in our seed; allow None
    # but assert subject is None for iMessage.
    assert m["subject"] is None
    assert any(p["role"] == "from" and p["name"] == "Charles Babbage" for p in m["people"])
    assert len(m["attachments"]) == 1
    a = m["attachments"][0]
    assert a["filename"] == "pic.jpg"
    assert a["mime_type"] == "image/jpeg"
    assert "cas_path" not in a   # opaque; fetched separately


def test_messages_get_email_with_subject(populated_db: Path) -> None:
    runner = CliRunner()
    result = runner.invoke(
        messages_group, ["get", "11", "--json", "--db-path", str(populated_db)]
    )
    payload = json.loads(result.output)
    m = payload["data"]["message"]
    assert m["source"] == "gmail"
    assert m["subject"] == "Middle subject"
    assert m["email"] is not None
    assert m["email"]["mailbox"] == "INBOX"
    assert m["email"]["imap_uid"] == 2
    assert m["imessage"] is None


def test_messages_get_unknown_id(tmp_path: Path) -> None:
    db = tmp_path / "fresh.db"
    asyncio.run((lambda: open_db(db).__aenter__())())  # init schema
    runner = CliRunner()
    result = runner.invoke(
        messages_group, ["get", "999", "--json", "--db-path", str(db)]
    )
    payload = json.loads(result.output)
    assert payload["ok"] is False
    assert payload["error"]["code"] == "MESSAGE_NOT_FOUND"
```

(For `test_messages_get_unknown_id`: just delete the DB file dance — use `tmp_path / "fresh.db"`; since `open_db` auto-applies migrations on first connect, the test runner's invocation of `messages get` will open it and find no rows.)

Adjust to:

```python
def test_messages_get_unknown_id(tmp_path: Path) -> None:
    db = tmp_path / "fresh.db"
    runner = CliRunner()
    result = runner.invoke(
        messages_group, ["get", "999", "--json", "--db-path", str(db)]
    )
    payload = json.loads(result.output)
    assert payload["ok"] is False
    assert payload["error"]["code"] == "MESSAGE_NOT_FOUND"
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/accountpilot/unit/cli/test_messages_cmds.py -v -k get`
Expected: FAIL — `get` not yet implemented.

- [ ] **Step 3: Implement `messages get`**

Append to `src/accountpilot/core/cli/messages_cmds.py`:

```python
@messages_group.command("get")
@click.argument("message_id", type=int)
@click.option("--json", "json_out", is_flag=True)
@_db_option
def messages_get(message_id: int, json_out: bool, db_path: Path) -> None:
    """Fetch a single message + its attachments + recipients."""

    async def _run() -> dict[str, Any]:
        async with open_db(db_path) as db:
            async with db.execute(
                "SELECT id, account_id, source, sent_at, received_at, "
                "thread_id, body_text, body_html, direction "
                "FROM messages WHERE id = ?",
                (message_id,),
            ) as cur:
                m = await cur.fetchone()
            if m is None:
                return {
                    "ok": False,
                    "error": {
                        "code": "MESSAGE_NOT_FOUND",
                        "message": f"no message with id={message_id}",
                    },
                }

            msg: dict[str, Any] = {
                "id": m["id"],
                "source": m["source"],
                "account_id": m["account_id"],
                "sent_at": m["sent_at"],
                "received_at": m["received_at"],
                "thread_id": m["thread_id"],
                "body_text": m["body_text"],
                "body_html": m["body_html"],
                "direction": m["direction"],
                "subject": None,
                "email": None,
                "imessage": None,
                "people": [],
                "attachments": [],
            }

            async with db.execute(
                "SELECT subject, in_reply_to, references_json, imap_uid, "
                "mailbox, gmail_thread_id, labels_json "
                "FROM email_details WHERE message_id = ?",
                (message_id,),
            ) as cur:
                ed = await cur.fetchone()
            if ed is not None:
                msg["subject"] = ed["subject"]
                msg["email"] = {
                    "in_reply_to": ed["in_reply_to"],
                    "references_json": ed["references_json"],
                    "imap_uid": ed["imap_uid"],
                    "mailbox": ed["mailbox"],
                    "gmail_thread_id": ed["gmail_thread_id"],
                    "labels_json": ed["labels_json"],
                }

            async with db.execute(
                "SELECT chat_guid, service, is_from_me, is_read, date_read "
                "FROM imessage_details WHERE message_id = ?",
                (message_id,),
            ) as cur:
                im = await cur.fetchone()
            if im is not None:
                msg["imessage"] = {
                    "chat_guid": im["chat_guid"],
                    "service": im["service"],
                    "is_from_me": bool(im["is_from_me"]),
                    "is_read": bool(im["is_read"]),
                    "date_read": im["date_read"],
                }

            async with db.execute(
                """
                SELECT mp.role, p.id, p.name, p.surname,
                       (SELECT value FROM identifiers
                        WHERE person_id = p.id AND is_primary = 1
                        LIMIT 1) AS identifier
                FROM message_people mp
                JOIN people p ON p.id = mp.person_id
                WHERE mp.message_id = ?
                ORDER BY mp.role
                """,
                (message_id,),
            ) as cur:
                async for row in cur:
                    full_name = (
                        f"{row['name']} {row['surname']}".strip()
                        if row['surname'] else row['name']
                    )
                    msg["people"].append({
                        "role": row["role"],
                        "id": row["id"],
                        "name": full_name,
                        "identifier": row["identifier"],
                    })

            async with db.execute(
                "SELECT id, filename, content_hash, mime_type, size_bytes "
                "FROM attachments WHERE message_id = ? ORDER BY id",
                (message_id,),
            ) as cur:
                async for row in cur:
                    msg["attachments"].append({
                        "id": row["id"],
                        "filename": row["filename"],
                        "content_hash": row["content_hash"],
                        "mime_type": row["mime_type"],
                        "size_bytes": row["size_bytes"],
                    })

        return {"ok": True, "message": msg}

    result = asyncio.run(_run())
    if json_out:
        if result["ok"]:
            _emit_envelope(data={"message": result["message"]})
        else:
            _emit_envelope(error=result["error"])
        return
    if not result["ok"]:
        raise click.ClickException(result["error"]["message"])
    msg = result["message"]
    label = msg.get("subject") or (msg["body_text"][:80])
    click.echo(f"#{msg['id']} [{msg['source']}] {msg['sent_at']}  {label}")
    for a in msg["attachments"]:
        click.echo(f"  attachment: {a['filename']} ({a['size_bytes']} bytes)")
```

- [ ] **Step 4: Verify**

```bash
pytest tests/accountpilot/unit/cli/test_messages_cmds.py -v
ruff check src tests
mypy src/accountpilot
```

- [ ] **Step 5: Commit**

```bash
git add src/accountpilot/core/cli/messages_cmds.py tests/accountpilot/unit/cli/test_messages_cmds.py
git commit -m "feat(cli): messages get --json — full body + people + attachments"
```

---

## Task 5: `accountpilot attachments path <id> --json`

**Goal:** Resolve an attachment id to its absolute filesystem path in the CAS. The Swift `AttachmentService` calls this then opens the file in Quick Look. Separate command (not part of `messages get`) so the CAS layout stays opaque.

**Files:**
- Modify: `src/accountpilot/core/cli/messages_cmds.py` (add `attachments_group`)
- Modify: `src/accountpilot/cli.py` (register `attachments_group`)
- Modify: `tests/accountpilot/unit/cli/test_messages_cmds.py`

JSON shape:
```json
{"ok": true, "data": {"id": 1, "absolute_path": "/Users/.../attachments/ab/c1/abc123.bin",
                       "exists": true, "size_bytes": 4096}, "error": null}
```

`exists` flag because the CAS file might be missing (sync wrote the row but the CAS write failed, or a user manually deleted it). The Swift caller should check before opening.

- [ ] **Step 1: Write the failing tests** — append to `tests/accountpilot/unit/cli/test_messages_cmds.py`:

```python
def test_attachments_path_returns_resolved_path(populated_db: Path) -> None:
    """Returns the CAS path joined to the data dir's attachments root."""
    # Note: data_dir's `attachments/` subdirectory is the CAS root —
    # cas_path is stored as relative ("ab/c1/abc123.bin").
    from accountpilot.core.cli.messages_cmds import attachments_group
    runner = CliRunner()
    # populated_db is at <tmp>/test.db; attachments root will be
    # <tmp>/attachments (per the existing convention).
    cas_root = populated_db.parent / "attachments"
    cas_root.joinpath("ab/c1").mkdir(parents=True, exist_ok=True)
    blob = cas_root / "ab/c1/abc123.bin"
    blob.write_bytes(b"x" * 4096)

    result = runner.invoke(
        attachments_group,
        ["path", "1", "--json", "--db-path", str(populated_db)],
    )
    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload["ok"] is True
    assert payload["data"]["id"] == 1
    assert payload["data"]["absolute_path"] == str(blob.resolve())
    assert payload["data"]["exists"] is True
    assert payload["data"]["size_bytes"] == 4096


def test_attachments_path_unknown_id(populated_db: Path) -> None:
    from accountpilot.core.cli.messages_cmds import attachments_group
    runner = CliRunner()
    result = runner.invoke(
        attachments_group,
        ["path", "999", "--json", "--db-path", str(populated_db)],
    )
    payload = json.loads(result.output)
    assert payload["ok"] is False
    assert payload["error"]["code"] == "ATTACHMENT_NOT_FOUND"


def test_attachments_path_missing_blob_reports_exists_false(populated_db: Path) -> None:
    from accountpilot.core.cli.messages_cmds import attachments_group
    runner = CliRunner()
    # Don't create the blob on disk.
    result = runner.invoke(
        attachments_group,
        ["path", "1", "--json", "--db-path", str(populated_db)],
    )
    payload = json.loads(result.output)
    assert payload["ok"] is True
    assert payload["data"]["exists"] is False
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `pytest tests/accountpilot/unit/cli/test_messages_cmds.py -v -k attachments`
Expected: FAIL.

- [ ] **Step 3: Implement `attachments path`**

Append to `src/accountpilot/core/cli/messages_cmds.py`:

```python
@click.group("attachments")
def attachments_group() -> None:
    """Resolve attachment metadata + CAS paths."""


@attachments_group.command("path")
@click.argument("attachment_id", type=int)
@click.option("--json", "json_out", is_flag=True)
@_db_option
def attachments_path(attachment_id: int, json_out: bool, db_path: Path) -> None:
    """Resolve an attachment id to its absolute filesystem path.

    The CAS root is `<db_path.parent>/attachments/`. `cas_path` in the
    `attachments` table is stored relative to that root.
    """

    async def _run() -> dict[str, Any]:
        async with open_db(db_path) as db, db.execute(
            "SELECT id, cas_path, size_bytes FROM attachments WHERE id = ?",
            (attachment_id,),
        ) as cur:
            row = await cur.fetchone()
        if row is None:
            return {
                "ok": False,
                "error": {
                    "code": "ATTACHMENT_NOT_FOUND",
                    "message": f"no attachment with id={attachment_id}",
                },
            }
        absolute = (db_path.parent / "attachments" / row["cas_path"]).resolve()
        return {
            "ok": True,
            "data": {
                "id": row["id"],
                "absolute_path": str(absolute),
                "exists": absolute.exists(),
                "size_bytes": row["size_bytes"],
            },
        }

    result = asyncio.run(_run())
    if json_out:
        if result["ok"]:
            _emit_envelope(data=result["data"])
        else:
            _emit_envelope(error=result["error"])
        return
    if not result["ok"]:
        raise click.ClickException(result["error"]["message"])
    click.echo(result["data"]["absolute_path"])
```

In `src/accountpilot/cli.py`, ALSO register `attachments_group`:

```python
from accountpilot.core.cli.messages_cmds import attachments_group, messages_group
...
cli.add_command(attachments_group)
cli.add_command(messages_group)
```

- [ ] **Step 4: Verify**

```bash
pytest tests/accountpilot/unit/cli/test_messages_cmds.py -v
ruff check src tests
mypy src/accountpilot
python3 -m accountpilot.cli attachments --help
```

- [ ] **Step 5: Commit**

```bash
git add src/accountpilot/core/cli/messages_cmds.py tests/accountpilot/unit/cli/test_messages_cmds.py src/accountpilot/cli.py
git commit -m "feat(cli): attachments path <id> --json — CAS path resolver"
```

---

## Task 6: Swift `Message`, `MessageDetail`, `Person` Codable models

**Files:**
- Create: `app/AccountPilot/Models/Message.swift`
- Create: `app/AccountPilot/Models/Person.swift`

- [ ] **Step 1: Write `Message.swift`**

```swift
import Foundation

/// One row from `accountpilot messages list --json`'s data.messages[].
struct Message: Codable, Identifiable, Hashable {
    let id: Int
    let source: String
    let accountID: Int
    let sentAt: Date
    let threadID: String?
    let subject: String           // empty string for iMessage rows
    let snippet: String
    let fromName: String?
    let fromIdentifier: String?
    let hasAttachments: Bool

    enum CodingKeys: String, CodingKey {
        case id, source, subject, snippet
        case accountID = "account_id"
        case sentAt = "sent_at"
        case threadID = "thread_id"
        case fromName = "from_name"
        case fromIdentifier = "from_identifier"
        case hasAttachments = "has_attachments"
    }
}

struct MessagesListData: Decodable {
    let messages: [Message]
    let nextCursor: Int?

    enum CodingKeys: String, CodingKey {
        case messages
        case nextCursor = "next_cursor"
    }
}

/// One row from `accountpilot messages get --json`'s data.message.
struct MessageDetail: Codable, Identifiable, Hashable {
    let id: Int
    let source: String
    let accountID: Int
    let sentAt: Date
    let receivedAt: Date?
    let threadID: String?
    let bodyText: String
    let bodyHTML: String?
    let direction: String
    let subject: String?
    let email: EmailFields?
    let imessage: IMessageFields?
    let people: [PersonRef]
    let attachments: [Attachment]

    enum CodingKeys: String, CodingKey {
        case id, source, subject, email, imessage, people, attachments, direction
        case accountID = "account_id"
        case sentAt = "sent_at"
        case receivedAt = "received_at"
        case threadID = "thread_id"
        case bodyText = "body_text"
        case bodyHTML = "body_html"
    }

    struct EmailFields: Codable, Hashable {
        let imapUID: Int
        let mailbox: String
        let gmailThreadID: String?
        let inReplyTo: String?
        let labelsJSON: String?

        enum CodingKeys: String, CodingKey {
            case mailbox
            case imapUID = "imap_uid"
            case gmailThreadID = "gmail_thread_id"
            case inReplyTo = "in_reply_to"
            case labelsJSON = "labels_json"
        }
    }

    struct IMessageFields: Codable, Hashable {
        let chatGUID: String
        let service: String
        let isFromMe: Bool
        let isRead: Bool

        enum CodingKeys: String, CodingKey {
            case service
            case chatGUID = "chat_guid"
            case isFromMe = "is_from_me"
            case isRead = "is_read"
        }
    }

    struct PersonRef: Codable, Hashable {
        let role: String          // "from" / "to" / "cc" / "bcc"
        let id: Int
        let name: String
        let identifier: String?
    }

    struct Attachment: Codable, Identifiable, Hashable {
        let id: Int
        let filename: String
        let contentHash: String
        let mimeType: String?
        let sizeBytes: Int

        enum CodingKeys: String, CodingKey {
            case id, filename
            case contentHash = "content_hash"
            case mimeType = "mime_type"
            case sizeBytes = "size_bytes"
        }
    }
}

struct MessageGetData: Decodable {
    let message: MessageDetail
}
```

- [ ] **Step 2: Write `Person.swift`**

```swift
import Foundation

struct Person: Codable, Identifiable, Hashable {
    let id: Int
    let name: String
    let surname: String?
    let isOwner: Bool
    let identifiers: [PersonIdentifier]
    let messageCount: Int

    var displayName: String {
        if let s = surname, !s.isEmpty { return "\(name) \(s)" }
        return name
    }

    enum CodingKeys: String, CodingKey {
        case id, name, surname, identifiers
        case isOwner = "is_owner"
        case messageCount = "message_count"
    }
}

struct PersonIdentifier: Codable, Hashable {
    let kind: String
    let value: String
}

struct PeopleListData: Decodable {
    let people: [Person]
}
```

- [ ] **Step 3: Build**

```bash
cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -3 && cd ..
```

Expected: `** BUILD SUCCEEDED **`.

- [ ] **Step 4: Commit**

```bash
git add app/AccountPilot/Models/Message.swift app/AccountPilot/Models/Person.swift
git commit -m "feat(app): Message, MessageDetail, Person Codable models"
```

---

## Task 7: Swift `MessagesStore` + `SearchStore`

**Goal:** `@MainActor ObservableObject`s that wrap the CLI calls. `MessagesStore` paginates; `SearchStore` debounces FTS queries.

**Files:**
- Create: `app/AccountPilot/Services/MessagesStore.swift`
- Create: `app/AccountPilot/Services/SearchStore.swift`

- [ ] **Step 1: Write `MessagesStore.swift`**

```swift
import Foundation
import SwiftUI

/// Filter for the message list query.
struct MessageFilter: Equatable, Hashable {
    var accountID: Int?
    var contactID: Int?
    var since: Date?
}

@MainActor
final class MessagesStore: ObservableObject {
    @Published private(set) var messages: [Message] = []
    @Published private(set) var isLoading: Bool = false
    @Published private(set) var loadError: String?
    @Published var filter: MessageFilter = .init()

    private var nextCursor: Int?
    private var loadTask: Task<Void, Never>?

    /// Reset + load the first page for the current `filter`.
    func reload() {
        messages = []
        nextCursor = nil
        loadError = nil
        loadTask?.cancel()
        loadTask = Task { await self.loadPage(append: false) }
    }

    /// Load the next page if there is one.
    func loadMore() {
        guard nextCursor != nil, !isLoading else { return }
        loadTask?.cancel()
        loadTask = Task { await self.loadPage(append: true) }
    }

    private func loadPage(append: Bool) async {
        isLoading = true
        defer { isLoading = false }

        var args = ["-m", "accountpilot.cli", "messages", "list", "--json", "--limit", "50"]
        if let id = filter.accountID {
            args.append("--account"); args.append("\(id)")
        }
        if let id = filter.contactID {
            args.append("--contact-id"); args.append("\(id)")
        }
        if let since = filter.since {
            let f = ISO8601DateFormatter()
            f.formatOptions = [.withFullDate]
            args.append("--since"); args.append(f.string(from: since))
        }
        if append, let cursor = nextCursor {
            args.append("--cursor"); args.append("\(cursor)")
        }

        do {
            let stdout = try await PythonRuntime.shared.run(args)
            struct Env: Decodable { let ok: Bool; let data: MessagesListData?; let error: CLIEnvelope<EmptyData>.CLIError? }
            let env = try JSONDecoder.accountPilotCLI.decode(Env.self, from: Data(stdout.utf8))
            guard env.ok, let data = env.data else {
                loadError = env.error?.message ?? "unknown error"
                return
            }
            if append { messages.append(contentsOf: data.messages) }
            else { messages = data.messages }
            nextCursor = data.nextCursor
        } catch {
            loadError = "\(error)"
        }
    }
}
```

- [ ] **Step 2: Write `SearchStore.swift`**

```swift
import Foundation
import SwiftUI

@MainActor
final class SearchStore: ObservableObject {
    @Published var query: String = ""
    @Published private(set) var results: [SearchResult] = []
    @Published private(set) var isLoading: Bool = false

    private var debounceTask: Task<Void, Never>?

    /// Call when the search field text changes. Debounces 300ms then queries.
    func update(query newQuery: String) {
        query = newQuery
        debounceTask?.cancel()
        let trimmed = newQuery.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty else {
            results = []
            return
        }
        debounceTask = Task { [weak self] in
            try? await Task.sleep(for: .milliseconds(300))
            guard !Task.isCancelled else { return }
            await self?.fire(trimmed)
        }
    }

    private func fire(_ query: String) async {
        isLoading = true
        defer { isLoading = false }
        do {
            let stdout = try await PythonRuntime.shared.run(
                ["-m", "accountpilot.cli", "search", query, "--json", "--limit", "50"]
            )
            struct Env: Decodable {
                struct Data: Decodable { let query: String; let results: [SearchResult] }
                let ok: Bool; let data: Data?
            }
            let env = try JSONDecoder.accountPilotCLI.decode(Env.self, from: Data(stdout.utf8))
            results = env.data?.results ?? []
        } catch {
            results = []
        }
    }
}

struct SearchResult: Codable, Identifiable, Hashable {
    let id: Int
    let source: String
    let accountID: Int
    let sentAt: Date
    let subject: String
    let snippet: String
    let score: Double

    enum CodingKeys: String, CodingKey {
        case id, source, subject, snippet, score
        case accountID = "account_id"
        case sentAt = "sent_at"
    }
}
```

- [ ] **Step 3: Build**

```bash
cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -3 && cd ..
```

Expected: `** BUILD SUCCEEDED **`.

- [ ] **Step 4: Commit**

```bash
git add app/AccountPilot/Services/MessagesStore.swift app/AccountPilot/Services/SearchStore.swift
git commit -m "feat(app): MessagesStore + SearchStore — paginated list + debounced FTS5"
```

---

## Task 8: Swift `AttachmentService` — Quick Look opener

**Goal:** Resolve the attachment via `accountpilot attachments path <id> --json` and open in macOS Quick Look.

**Files:**
- Create: `app/AccountPilot/Services/AttachmentService.swift`

- [ ] **Step 1: Write the service**

```swift
import Foundation
import AppKit

enum AttachmentService {
    /// Resolves an attachment id via the CLI, then opens the file in
    /// the user's default app for that mime type. macOS Quick Look
    /// (cmd+space-after-Finder-select) requires the file to be in a
    /// Finder window — for direct preview, NSWorkspace.open() works
    /// for image/video/PDF/text via the registered handlers.
    static func openInQuickLook(attachmentID: Int) async throws {
        let stdout = try await PythonRuntime.shared.run(
            ["-m", "accountpilot.cli", "attachments", "path", "\(attachmentID)", "--json"]
        )
        struct Env: Decodable {
            struct Data: Decodable {
                let id: Int
                let absolutePath: String
                let exists: Bool
                let sizeBytes: Int
                enum CodingKeys: String, CodingKey {
                    case id, exists
                    case absolutePath = "absolute_path"
                    case sizeBytes = "size_bytes"
                }
            }
            let ok: Bool; let data: Data?
            struct Err: Decodable { let code: String; let message: String }
            let error: Err?
        }
        let env = try JSONDecoder().decode(Env.self, from: Data(stdout.utf8))
        guard env.ok, let data = env.data else {
            throw NSError(
                domain: "AttachmentService", code: 1,
                userInfo: [NSLocalizedDescriptionKey:
                    env.error?.message ?? "attachment lookup failed"]
            )
        }
        guard data.exists else {
            throw NSError(
                domain: "AttachmentService", code: 2,
                userInfo: [NSLocalizedDescriptionKey:
                    "attachment file missing on disk: \(data.absolutePath)"]
            )
        }
        let url = URL(fileURLWithPath: data.absolutePath)
        await MainActor.run {
            NSWorkspace.shared.open(url)
        }
    }
}
```

- [ ] **Step 2: Build**

```bash
cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -3 && cd ..
```

Expected: `** BUILD SUCCEEDED **`.

- [ ] **Step 3: Commit**

```bash
git add app/AccountPilot/Services/AttachmentService.swift
git commit -m "feat(app): AttachmentService — open attachment via NSWorkspace"
```

---

## Task 9: Swift `BrowseSidebar.swift` — accounts + contacts list

**Files:**
- Create: `app/AccountPilot/Views/Browse/BrowseSidebar.swift`

The sidebar has three sections:
1. **Library** — "All Messages" + per-account rows
2. **People** — top contacts (sorted by message_count DESC)
3. **Manage** — link to AccountsView (Phase 2)

Selection drives `MessagesStore.filter`.

- [ ] **Step 1: Write `BrowseSidebar.swift`**

```swift
import SwiftUI

/// Tag identifying which sidebar row is selected.
enum BrowseSelection: Hashable {
    case allMessages
    case account(id: Int)
    case contact(id: Int)
    case manageAccounts
}

struct BrowseSidebar: View {
    @ObservedObject var accountStore: AccountStore
    @State private var people: [Person] = []
    @State private var loadError: String?

    @Binding var selection: BrowseSelection?

    var body: some View {
        List(selection: $selection) {
            Section("Library") {
                Label("All Messages", systemImage: "tray.full")
                    .tag(BrowseSelection.allMessages)
                ForEach(accountStore.accounts) { acct in
                    Label {
                        VStack(alignment: .leading) {
                            Text(acct.identifier)
                            Text(acct.source).font(.caption).foregroundStyle(.secondary)
                        }
                    } icon: {
                        Image(systemName: iconForSource(acct.source))
                    }
                    .tag(BrowseSelection.account(id: acct.id))
                }
            }
            Section("People") {
                ForEach(people.prefix(20)) { p in
                    Label {
                        HStack {
                            Text(p.displayName)
                            Spacer()
                            Text("\(p.messageCount)")
                                .font(.caption.monospacedDigit())
                                .foregroundStyle(.secondary)
                        }
                    } icon: {
                        Image(systemName: "person.crop.circle")
                    }
                    .tag(BrowseSelection.contact(id: p.id))
                }
            }
            Section {
                Label("Manage Accounts…", systemImage: "gearshape")
                    .tag(BrowseSelection.manageAccounts)
            }
        }
        .listStyle(.sidebar)
        .task {
            await accountStore.refresh()
            await loadPeople()
        }
    }

    private func loadPeople() async {
        do {
            let stdout = try await PythonRuntime.shared.run(
                ["-m", "accountpilot.cli", "people", "list", "--json"]
            )
            struct Env: Decodable { let ok: Bool; let data: PeopleListData? }
            let env = try JSONDecoder().decode(Env.self, from: Data(stdout.utf8))
            // Filter out owners — sidebar shows other people only
            people = (env.data?.people ?? []).filter { !$0.isOwner }
        } catch {
            loadError = "\(error)"
        }
    }

    private func iconForSource(_ s: String) -> String {
        switch s {
        case "gmail", "outlook", "imap-generic": return "envelope"
        case "imessage": return "message"
        default: return "person.crop.circle"
        }
    }
}
```

- [ ] **Step 2: Build**

```bash
mkdir -p app/AccountPilot/Views/Browse
cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -3 && cd ..
```

Expected: `** BUILD SUCCEEDED **`.

- [ ] **Step 3: Commit**

```bash
git add app/AccountPilot/Views/Browse/BrowseSidebar.swift
git commit -m "feat(app): BrowseSidebar — accounts + people + Manage"
```

---

## Task 10: Swift `MessageListView.swift` + `MessageDetailView.swift`

**Goal:** The middle and right panes. The middle pane has `.searchable()` for the FTS5 query. The right pane shows the full message + attachments.

**Files:**
- Create: `app/AccountPilot/Views/Browse/MessageListView.swift`
- Create: `app/AccountPilot/Views/Browse/MessageDetailView.swift`
- Create: `app/AccountPilot/Views/Browse/AttachmentRow.swift`

- [ ] **Step 1: Write `MessageListView.swift`**

```swift
import SwiftUI

struct MessageListView: View {
    @StateObject private var store = MessagesStore()
    @StateObject private var search = SearchStore()
    @Binding var selection: Int?
    var filter: MessageFilter

    var body: some View {
        Group {
            if !search.query.isEmpty {
                searchResultsList
            } else if store.isLoading && store.messages.isEmpty {
                ProgressView("Loading messages…")
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            } else if let err = store.loadError {
                VStack(spacing: 8) {
                    Image(systemName: "exclamationmark.triangle")
                        .font(.system(size: 28))
                        .foregroundStyle(.orange)
                    Text("Couldn't load messages").font(.headline)
                    Text(err).font(.caption).foregroundStyle(.secondary)
                        .textSelection(.enabled)
                    Button("Retry") { store.reload() }
                }
                .padding()
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            } else if store.messages.isEmpty {
                Text("No messages")
                    .foregroundStyle(.secondary)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            } else {
                messagesList
            }
        }
        .searchable(text: Binding(
            get: { search.query },
            set: { search.update(query: $0) }
        ), prompt: "Search all messages")
        .onChange(of: filter) { newValue in
            store.filter = newValue
            store.reload()
        }
        .task {
            store.filter = filter
            store.reload()
        }
    }

    @ViewBuilder
    private var messagesList: some View {
        List(selection: $selection) {
            ForEach(store.messages) { msg in
                MessageRow(message: msg)
                    .tag(msg.id)
                    .onAppear {
                        if msg.id == store.messages.last?.id { store.loadMore() }
                    }
            }
            if store.isLoading && !store.messages.isEmpty {
                HStack { Spacer(); ProgressView().controlSize(.small); Spacer() }
                    .listRowSeparator(.hidden)
            }
        }
    }

    @ViewBuilder
    private var searchResultsList: some View {
        if search.results.isEmpty && !search.isLoading {
            ContentUnavailableView.search(text: search.query)
        } else {
            List(selection: $selection) {
                ForEach(search.results) { r in
                    SearchResultRow(result: r)
                        .tag(r.id)
                }
            }
        }
    }
}

private struct MessageRow: View {
    let message: Message
    var body: some View {
        HStack(spacing: 10) {
            Image(systemName: iconForSource(message.source))
                .frame(width: 22)
                .foregroundStyle(.secondary)
            VStack(alignment: .leading, spacing: 2) {
                HStack {
                    Text(message.fromName ?? message.fromIdentifier ?? "Unknown")
                        .font(.body.weight(.medium))
                    Spacer()
                    if message.hasAttachments {
                        Image(systemName: "paperclip")
                            .font(.caption2)
                            .foregroundStyle(.secondary)
                    }
                    Text(message.sentAt, style: .relative)
                        .font(.caption2.monospacedDigit())
                        .foregroundStyle(.secondary)
                }
                if !message.subject.isEmpty {
                    Text(message.subject).font(.callout).lineLimit(1)
                }
                Text(message.snippet)
                    .font(.caption)
                    .foregroundStyle(.secondary)
                    .lineLimit(2)
            }
        }
        .padding(.vertical, 2)
    }

    private func iconForSource(_ s: String) -> String {
        switch s {
        case "gmail", "outlook", "imap-generic": return "envelope"
        case "imessage": return "message"
        default: return "tray"
        }
    }
}

private struct SearchResultRow: View {
    let result: SearchResult
    var body: some View {
        VStack(alignment: .leading, spacing: 2) {
            HStack {
                Text(result.subject.isEmpty ? "(no subject)" : result.subject)
                    .font(.body.weight(.medium))
                    .lineLimit(1)
                Spacer()
                Text(result.sentAt, style: .relative)
                    .font(.caption2.monospacedDigit())
                    .foregroundStyle(.secondary)
            }
            Text(result.snippet)
                .font(.caption)
                .foregroundStyle(.secondary)
                .lineLimit(2)
        }
    }
}
```

- [ ] **Step 2: Write `MessageDetailView.swift`**

```swift
import SwiftUI

struct MessageDetailView: View {
    let messageID: Int
    @State private var detail: MessageDetail?
    @State private var loadError: String?

    var body: some View {
        Group {
            if let d = detail {
                content(d)
            } else if let err = loadError {
                Text(err).foregroundStyle(.red).padding()
            } else {
                ProgressView()
            }
        }
        .task(id: messageID) { await load() }
    }

    @ViewBuilder
    private func content(_ d: MessageDetail) -> some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 14) {
                if let subject = d.subject, !subject.isEmpty {
                    Text(subject).font(.title2.bold()).textSelection(.enabled)
                }
                HStack(spacing: 8) {
                    Text("From").foregroundStyle(.secondary)
                    Text(d.people.first(where: { $0.role == "from" })?.name ?? "—")
                        .fontWeight(.medium)
                }
                .font(.callout)
                Text(d.sentAt, format: .dateTime).font(.caption).foregroundStyle(.secondary)

                Divider()

                Text(d.bodyText)
                    .font(.body)
                    .textSelection(.enabled)
                    .frame(maxWidth: .infinity, alignment: .leading)

                if !d.attachments.isEmpty {
                    Divider()
                    Text("Attachments").font(.headline)
                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 220), spacing: 12)], spacing: 12) {
                        ForEach(d.attachments) { att in
                            AttachmentRow(attachment: att)
                        }
                    }
                }
            }
            .padding(24)
        }
    }

    private func load() async {
        loadError = nil
        do {
            let stdout = try await PythonRuntime.shared.run(
                ["-m", "accountpilot.cli", "messages", "get", "\(messageID)", "--json"]
            )
            struct Env: Decodable { let ok: Bool; let data: MessageGetData? }
            let env = try JSONDecoder.accountPilotCLI.decode(Env.self, from: Data(stdout.utf8))
            detail = env.data?.message
        } catch {
            loadError = "\(error)"
        }
    }
}
```

- [ ] **Step 3: Write `AttachmentRow.swift`**

```swift
import SwiftUI

struct AttachmentRow: View {
    let attachment: MessageDetail.Attachment

    var body: some View {
        HStack(spacing: 10) {
            Image(systemName: iconForMime(attachment.mimeType))
                .font(.system(size: 26))
                .foregroundStyle(.tint)
                .frame(width: 38)
            VStack(alignment: .leading, spacing: 2) {
                Text(attachment.filename).font(.callout).lineLimit(1)
                Text(humanSize(attachment.sizeBytes))
                    .font(.caption2)
                    .foregroundStyle(.secondary)
            }
            Spacer()
            Button("Open") {
                Task {
                    try? await AttachmentService.openInQuickLook(attachmentID: attachment.id)
                }
            }
            .controlSize(.small)
        }
        .padding(8)
        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8))
    }

    private func iconForMime(_ mime: String?) -> String {
        guard let m = mime else { return "doc" }
        if m.hasPrefix("image/") { return "photo" }
        if m.hasPrefix("video/") { return "play.rectangle" }
        if m.hasPrefix("audio/") { return "waveform" }
        if m == "application/pdf" { return "doc.richtext" }
        return "doc"
    }

    private func humanSize(_ bytes: Int) -> String {
        ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
    }
}
```

- [ ] **Step 4: Build**

```bash
cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -3 && cd ..
```

Expected: `** BUILD SUCCEEDED **`. If `.onChange(of:)` errors with "Use the new closure-based API" warnings on macOS 13: switch to the single-arg form `{ newValue in ... }` (same fix Phase 3 used).

- [ ] **Step 5: Commit**

```bash
git add app/AccountPilot/Views/Browse/MessageListView.swift app/AccountPilot/Views/Browse/MessageDetailView.swift app/AccountPilot/Views/Browse/AttachmentRow.swift
git commit -m "feat(app): MessageListView + MessageDetailView + AttachmentRow"
```

---

## Task 11: Swift `BrowseView.swift` orchestrator + wire into ContentView

**Goal:** `NavigationSplitView` with the three panes. Replace ContentView's body so the post-FDA route is `BrowseView` (not the bare `AccountsView`). The "Manage Accounts" sidebar row routes to AccountsView.

**Files:**
- Create: `app/AccountPilot/Views/Browse/BrowseView.swift`
- Modify: `app/AccountPilot/ContentView.swift`

- [ ] **Step 1: Write `BrowseView.swift`**

```swift
import SwiftUI

struct BrowseView: View {
    @StateObject private var accountStore = AccountStore()
    @State private var sidebarSelection: BrowseSelection? = .allMessages
    @State private var messageSelection: Int?

    var body: some View {
        NavigationSplitView {
            BrowseSidebar(accountStore: accountStore, selection: $sidebarSelection)
                .frame(minWidth: 220)
        } content: {
            switch sidebarSelection {
            case .manageAccounts:
                AccountsView()
            case nil:
                Text("Select a source from the sidebar")
                    .foregroundStyle(.secondary)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            default:
                MessageListView(
                    selection: $messageSelection,
                    filter: filterFor(sidebarSelection)
                )
                .frame(minWidth: 320)
            }
        } detail: {
            if let id = messageSelection {
                MessageDetailView(messageID: id)
            } else {
                Text("Select a message")
                    .foregroundStyle(.secondary)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            }
        }
        .navigationTitle("AccountPilot")
        .onChange(of: sidebarSelection) { _ in
            messageSelection = nil  // clear detail pane on navigation
        }
    }

    private func filterFor(_ sel: BrowseSelection?) -> MessageFilter {
        switch sel {
        case .allMessages, nil, .manageAccounts:
            return MessageFilter()
        case .account(let id):
            return MessageFilter(accountID: id)
        case .contact(let id):
            return MessageFilter(contactID: id)
        }
    }
}
```

- [ ] **Step 2: Modify `ContentView.swift`** to route to BrowseView post-FDA:

Read the current file first. Replace the post-FDA branch:

```swift
// before (Phase 3)
} else {
    VStack(spacing: 0) {
        AccountsView()
        Divider()
        footer
    }
}
// after (Phase 4)
} else {
    VStack(spacing: 0) {
        BrowseView()
        Divider()
        footer
    }
}
```

The footer + migration banner stays.

- [ ] **Step 3: Build**

```bash
cd app && xcodegen generate && xcodebuild -project AccountPilot.xcodeproj -scheme AccountPilot -configuration Release -derivedDataPath build/ -skipMacroValidation build 2>&1 | tail -3 && cd ..
```

Expected: `** BUILD SUCCEEDED **`.

- [ ] **Step 4: Commit**

```bash
git add app/AccountPilot/Views/Browse/BrowseView.swift app/AccountPilot/ContentView.swift
git commit -m "feat(app): BrowseView NavigationSplitView + wire into ContentView"
```

---

## Task 12: CHANGELOG + end-to-end build + manual smoke + PR

**Files:**
- Modify: `CHANGELOG.md`

- [ ] **Step 1: Append Phase 4 entries under existing `[0.2.0]` `### Added`**

```markdown
- **Data viewer.** New SwiftUI three-pane browse experience: sidebar
  with accounts + people, message list with FTS5-ranked
  `.searchable()` query bar, message detail with attachment thumbnails
  and one-click "Open" via NSWorkspace. Replaces the Phase 2/3
  bare-AccountsView main window; AccountsView is now reachable via
  the sidebar's "Manage Accounts…" row.
- **CLI: `accountpilot messages list [--account ID] [--contact-id ID] [--since YYYY-MM-DD] [--limit N] [--cursor ID] --json`,
  `accountpilot messages get <id> --json`,
  `accountpilot attachments path <id> --json`,
  `accountpilot search "query" --json`,
  `accountpilot people list --json`.** All return the standard JSON
  envelope. `messages list` paginates by `id` cursor. `search`
  returns FTS5 BM25-ranked results.
```

- [ ] **Step 2: End-to-end build**

```bash
rm -rf dist/AccountPilot.app
./app/scripts/build-app.sh > /tmp/p4-build.log 2>&1; echo "exit=$?"
tail -20 /tmp/p4-build.log
```

Expected ending: `==> all verification passed`.

- [ ] **Step 3: Manual smoke test**

```bash
osascript -e 'tell application "AccountPilot" to quit' 2>/dev/null
rm -rf /Applications/AccountPilot.app
cp -R dist/AccountPilot.app /Applications/AccountPilot.app
open /Applications/AccountPilot.app
```

In the running app:
1. The window opens to the new BrowseView. Sidebar shows accounts + (some) people + "Manage Accounts" at the bottom.
2. Click "All Messages" → message list populates within ~200ms (typical: <5000 rows; if your DB is huge, pagination handles it).
3. Click a message → detail pane shows full body. If it has attachments, thumbnails appear; clicking "Open" launches Preview/Quick Look on the file.
4. Type "fazla" (or any term you know exists) into the search field → results re-rank by BM25 (most relevant first, not most recent).
5. Click a person in the sidebar → list filters to only their messages.
6. Click "Manage Accounts…" → AccountsView (Phase 2) opens in the middle pane; you can still add/remove accounts.

Verify CLI parity:
```bash
/Applications/AccountPilot.app/Contents/Resources/bin/accountpilot messages list --json --limit 5 | python3 -m json.tool | head -30
/Applications/AccountPilot.app/Contents/Resources/bin/accountpilot search "test" --json | python3 -m json.tool | head -20
```

Both should return well-formed envelopes matching the in-app UI's data.

- [ ] **Step 4: Commit changelog (if you haven't already in step 1)**

```bash
git add CHANGELOG.md
git commit -m "docs(changelog): Phase 4 — data viewer + messages CLI"
```

- [ ] **Step 5: Push + PR**

```bash
git push -u origin 0.2-p4-data-viewer
gh pr create --title "0.2 Phase 4: Data viewer (browse + search + attachments)" --body "$(cat <<'EOF'
Implements Phase 4 of the 0.2 macOS .app pivot per
\`docs/plans/0.2-p4-data-viewer.md\`.

## What's in here

### Python CLI
- \`accountpilot messages list [--account ID] [--contact-id ID] [--since YYYY-MM-DD] [--limit N] [--cursor ID] --json\` — paginated newest-first list with from-attribution + has_attachments flag. Cursor is the smallest id in the page; pass it back for the next page.
- \`accountpilot messages get <id> --json\` — full body + email/imessage discriminated fields + people refs (role + identifier) + attachments metadata (no cas_path)
- \`accountpilot attachments path <id> --json\` — resolves the CAS file's absolute path + exists flag
- \`accountpilot search "query" --json\` — FTS5 BM25-ranked
- \`accountpilot people list --json\` — message_count per person (sorted DESC)

### Swift app
- \`Models/Message.swift\` (list row + MessageDetail with discriminated email/imessage), \`Models/Person.swift\`
- \`Services/MessagesStore.swift\` (paginated + filter-driven), \`Services/SearchStore.swift\` (300ms debounce), \`Services/AttachmentService.swift\` (NSWorkspace.open)
- \`Views/Browse/\`: BrowseView (NavigationSplitView) + BrowseSidebar + MessageListView (.searchable) + MessageDetailView + AttachmentRow
- \`ContentView.swift\` — post-FDA routes to BrowseView; AccountsView is now the "Manage Accounts" detail

## Verification

- \`./app/scripts/build-app.sh\` ends in \`==> all verification passed\` (15 checks, unchanged from Phase 3)
- pytest green (~30 new test cases across messages_cmds, search_cmd_json, people_cmds_json)
- ruff + mypy clean
- Manual: copied to /Applications, browse view opens, sidebar populates, search ranks by relevance, attachments open in Preview

## Phase 5 next

Sparkle auto-update + DMG distribution. Branch \`0.2-p5-sparkle-dmg\` after merge.
EOF
)"
```

---

## Phase 4 done when

- [ ] All 11 implementation task commits land + verification + PR (~13 commits total)
- [ ] `pytest tests/accountpilot/unit/cli/ -q` green; full pytest suite green
- [ ] `ruff check src tests` clean; `mypy src/accountpilot` clean
- [ ] `./app/scripts/build-app.sh` ends in `==> all verification passed`
- [ ] Manual: drag to /Applications, BrowseView shows sidebar + list + detail. Search updates. Attachment "Open" launches Preview/Quick Look.
- [ ] PR open against `main`, CI green, merged

## Phase 5 starts after

- Phase 4 PR merged
- New branch `0.2-p5-sparkle-dmg`
- Write `docs/plans/0.2-p5-sparkle-dmg.md` via `superpowers:writing-plans`
