# Drafts, Mailbox Management & Permission Levels — Implementation Plan

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

**Goal:** Add draft message creation (#18), non-destructive mailbox management (#19), and per-source permission levels to ts4k.

**Architecture:** Each source config gains a `level` field (`readonly`, `modify`, `draft`, `send`) that gates which operations are allowed and which OAuth scopes are requested. Management methods (archive, label, mark read/unread) require `modify`; draft creation requires `draft`. The `send` level is defined in the enum but intentionally not implemented — it exists to document the full permission model and reserve the scope mapping. All operations are non-destructive (reversible). The no-send invariant is enforced at code level — there is no send method anywhere.

**Tech Stack:** Python 3.12+, Google API (`gmail.modify` scope), Microsoft Graph (`Mail.ReadWrite` scope), existing test patterns (pytest, mock).

---

## Chunk 1: Permission Levels Foundation

### Task 1: Create `src/ts4k/core/levels.py`

**Files:**
- Create: `src/ts4k/core/levels.py`
- Test: `tests/test_levels.py`

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

```python
# tests/test_levels.py
"""Tests for permission level system."""

import pytest

from ts4k.core.levels import AccessLevel, check_level, parse_level, scopes_for


class TestAccessLevel:
    def test_ordering(self):
        assert AccessLevel.READONLY < AccessLevel.MODIFY < AccessLevel.DRAFT < AccessLevel.SEND

    def test_values(self):
        assert AccessLevel.READONLY == 0
        assert AccessLevel.MODIFY == 1
        assert AccessLevel.DRAFT == 2
        assert AccessLevel.SEND == 3


class TestParseLevel:
    def test_none_defaults_to_readonly(self):
        assert parse_level(None) == AccessLevel.READONLY

    def test_parses_lowercase(self):
        assert parse_level("readonly") == AccessLevel.READONLY
        assert parse_level("modify") == AccessLevel.MODIFY
        assert parse_level("draft") == AccessLevel.DRAFT

    def test_parses_uppercase(self):
        assert parse_level("MODIFY") == AccessLevel.MODIFY

    def test_parses_mixed_case(self):
        assert parse_level("Draft") == AccessLevel.DRAFT

    def test_parses_send(self):
        assert parse_level("send") == AccessLevel.SEND

    def test_invalid_raises(self):
        with pytest.raises(KeyError):
            parse_level("admin")

    def test_empty_string_raises(self):
        with pytest.raises(KeyError):
            parse_level("")


class TestCheckLevel:
    def test_allows_equal(self):
        check_level(AccessLevel.MODIFY, AccessLevel.MODIFY, "archive")

    def test_allows_higher(self):
        check_level(AccessLevel.DRAFT, AccessLevel.MODIFY, "archive")

    def test_blocks_lower(self):
        with pytest.raises(PermissionError, match="archive"):
            check_level(AccessLevel.READONLY, AccessLevel.MODIFY, "archive")

    def test_error_message_includes_upgrade_hint(self):
        with pytest.raises(PermissionError, match="level=modify"):
            check_level(AccessLevel.READONLY, AccessLevel.MODIFY, "archive")

    def test_send_always_blocked(self):
        """Send level is defined but intentionally not implemented."""
        with pytest.raises(NotImplementedError, match="intentionally not implemented"):
            check_level(AccessLevel.SEND, AccessLevel.SEND, "send_message")


class TestScopesFor:
    def test_gmail_readonly(self):
        scopes = scopes_for("gmail", AccessLevel.READONLY)
        assert "gmail.readonly" in scopes[0]

    def test_gmail_modify(self):
        scopes = scopes_for("gmail", AccessLevel.MODIFY)
        assert "gmail.modify" in scopes[0]

    def test_gmail_draft(self):
        scopes = scopes_for("gmail", AccessLevel.DRAFT)
        assert "gmail.modify" in scopes[0]

    def test_o365_readonly(self):
        scopes = scopes_for("o365", AccessLevel.READONLY)
        assert any("Mail.Read" in s for s in scopes)
        assert not any("ReadWrite" in s for s in scopes)

    def test_o365_modify(self):
        scopes = scopes_for("o365", AccessLevel.MODIFY)
        assert any("Mail.ReadWrite" in s for s in scopes)

    def test_o365_draft(self):
        scopes = scopes_for("o365", AccessLevel.DRAFT)
        assert any("Mail.ReadWrite" in s for s in scopes)

    def test_whatsapp_returns_empty(self):
        assert scopes_for("whatsapp", AccessLevel.MODIFY) == []

    def test_unknown_provider_returns_empty(self):
        assert scopes_for("telegram", AccessLevel.READONLY) == []
```

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

Run: `uv run pytest tests/test_levels.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'ts4k.core.levels'`

- [ ] **Step 3: Implement levels module**

```python
# src/ts4k/core/levels.py
"""Permission levels for ts4k source connections.

Each source has an access level that gates which operations are allowed
and determines which OAuth scopes are requested at auth time.

Levels are cumulative — each level includes all capabilities of lower levels:
  readonly (default) — read messages, list, search
  modify             — readonly + archive, label, mark read/unread, trash
  draft              — modify + create draft messages
  send               — draft + send messages (INTENTIONALLY NOT IMPLEMENTED)

The send level is defined to complete the permission model and reserve
the OAuth scope mapping, but ts4k does not implement any send capability.
This is by design — ts4k is the data layer, not the action layer.
"""

from __future__ import annotations

from enum import IntEnum


class AccessLevel(IntEnum):
    """Permission tier for a source connection."""
    READONLY = 0
    MODIFY = 1
    DRAFT = 2
    SEND = 3  # Defined but NOT IMPLEMENTED. ts4k never sends messages.


def parse_level(value: str | None) -> AccessLevel:
    """Parse a level string to an AccessLevel. None defaults to READONLY."""
    if value is None:
        return AccessLevel.READONLY
    return AccessLevel[value.upper()]


def check_level(current: AccessLevel, required: AccessLevel, operation: str) -> None:
    """Raise PermissionError if current level is below required."""
    if required >= AccessLevel.SEND:
        raise NotImplementedError(
            f"Operation '{operation}' requires level 'send', which is "
            "intentionally not implemented. ts4k never sends messages."
        )
    if current < required:
        raise PermissionError(
            f"Operation '{operation}' requires level '{required.name.lower()}', "
            f"but source is configured as '{current.name.lower()}'. "
            f"Update with: ts4k src add <prefix> <provider> level={required.name.lower()}"
        )


# -- OAuth scope maps per provider per level --------------------------------

_GMAIL_SCOPES: dict[AccessLevel, list[str]] = {
    AccessLevel.READONLY: ["https://www.googleapis.com/auth/gmail.readonly"],
    AccessLevel.MODIFY: ["https://www.googleapis.com/auth/gmail.modify"],
    AccessLevel.DRAFT: ["https://www.googleapis.com/auth/gmail.modify"],
    # gmail.modify already includes send — no additional scope needed.
    # The code-level gating is the real protection for Gmail.
    AccessLevel.SEND: ["https://www.googleapis.com/auth/gmail.modify"],
}

_O365_SCOPES: dict[AccessLevel, list[str]] = {
    AccessLevel.READONLY: [
        "https://graph.microsoft.com/Mail.Read",
        "https://graph.microsoft.com/Mail.Read.Shared",
        "https://graph.microsoft.com/User.Read",
    ],
    AccessLevel.MODIFY: [
        "https://graph.microsoft.com/Mail.ReadWrite",
        "https://graph.microsoft.com/Mail.Read.Shared",
        "https://graph.microsoft.com/User.Read",
    ],
    AccessLevel.DRAFT: [
        "https://graph.microsoft.com/Mail.ReadWrite",
        "https://graph.microsoft.com/Mail.Read.Shared",
        "https://graph.microsoft.com/User.Read",
    ],
    # Mail.Send is a separate scope — O365 has true scope-level send gating.
    AccessLevel.SEND: [
        "https://graph.microsoft.com/Mail.ReadWrite",
        "https://graph.microsoft.com/Mail.Send",
        "https://graph.microsoft.com/Mail.Read.Shared",
        "https://graph.microsoft.com/User.Read",
    ],
}


def scopes_for(provider: str, level: AccessLevel) -> list[str]:
    """Return the OAuth scopes required for a provider at a given level."""
    provider = provider.lower()
    if provider == "gmail":
        return list(_GMAIL_SCOPES.get(level, []))
    if provider == "o365":
        return list(_O365_SCOPES.get(level, []))
    return []
```

- [ ] **Step 4: Run tests**

Run: `uv run pytest tests/test_levels.py -v`
Expected: All pass

- [ ] **Step 5: Commit**

```bash
git add src/ts4k/core/levels.py tests/test_levels.py
git commit -m "feat: add permission level system (readonly/modify/draft)"
```

### Task 2: Wire Levels into Adapter Configs and Factory

**Files:**
- Modify: `src/ts4k/adapters/base.py`
- Modify: `src/ts4k/adapters/gmail.py:39-48` (GmailAdapterConfig)
- Modify: `src/ts4k/adapters/o365.py:63-73` (O365AdapterConfig)
- Modify: `src/ts4k/adapters/gmail.py:263-274` (connect method)
- Modify: `src/ts4k/adapters/o365.py:228-243` (connect method)
- Modify: `src/ts4k/commands.py:69-116` (_make_adapter)
- Test: `tests/test_levels.py` (add integration tests)

- [ ] **Step 1: Add `_access_level` to BaseAdapter**

In `src/ts4k/adapters/base.py`, add after the class docstring (line 22):
```python
from ts4k.core.levels import AccessLevel

    # ... existing abstract methods ...

    @property
    def access_level(self) -> AccessLevel:
        """Permission level for this source connection.

        Defaults to READONLY. Concrete adapters set this via config.
        """
        return getattr(self, "_access_level", AccessLevel.READONLY)
```

- [ ] **Step 2: Add `level` field to adapter config dataclasses**

In `src/ts4k/adapters/gmail.py`, add to `GmailAdapterConfig`:
```python
@dataclass
class GmailAdapterConfig:
    user_email: str
    config_dir: Path | None = None
    level: str | None = None
    """Access level: readonly (default), modify, draft."""
```

In `GmailAdapter.__init__`, store the parsed level:
```python
def __init__(self, config: GmailAdapterConfig, prefix: str = "g") -> None:
    self._config = config
    self._prefix = prefix
    self._service = None
    from ts4k.core.levels import parse_level
    self._access_level = parse_level(config.level)
```

In `GmailAdapter.connect()`, pass level-based scopes:
```python
async def connect(self) -> None:
    if self._service is not None:
        return
    from ts4k.auth.google import build_gmail_service
    from ts4k.core.levels import scopes_for
    scopes = scopes_for("gmail", self._access_level)
    self._service = await asyncio.to_thread(
        build_gmail_service,
        self._config.user_email,
        config_dir=self._config.config_dir,
        scopes=scopes,
    )
    logger.info("GmailAdapter connected for %s (level=%s)",
                self._config.user_email, self._access_level.name.lower())
```

Same pattern for `O365AdapterConfig` and `O365Adapter`:
```python
@dataclass
class O365AdapterConfig:
    client_id: str = ""
    tenant_id: str = "common"
    mailbox: str | None = None
    config_dir: Path | None = None
    level: str | None = None
```

```python
def __init__(self, config: O365AdapterConfig, prefix: str = "o") -> None:
    self._config = config
    self._prefix = prefix
    self._client: httpx.AsyncClient | None = None
    from ts4k.core.levels import parse_level
    self._access_level = parse_level(config.level)
```

In `O365Adapter.connect()`:
```python
async def connect(self) -> None:
    if self._client is not None:
        return
    from ts4k.auth.microsoft import build_graph_client
    from ts4k.core.levels import scopes_for
    scopes = scopes_for("o365", self._access_level)
    self._client = await asyncio.to_thread(
        build_graph_client,
        self._config.client_id,
        tenant_id=self._config.tenant_id,
        config_dir=self._config.config_dir,
        username=self._config.mailbox,
        scopes=scopes,
    )
```

- [ ] **Step 3: Update `_make_adapter` in commands.py to pass level**

In `src/ts4k/commands.py`, `_make_adapter` function — pass `cfg.get("level")` to each adapter config:

```python
# Gmail branch (around line 80-86):
return GmailAdapter(
    GmailAdapterConfig(
        user_email=email,
        config_dir=Path(config_dir) if config_dir else None,
        level=cfg.get("level"),
    ),
    prefix=prefix,
)

# O365 branch (around line 105-113):
return O365Adapter(
    O365AdapterConfig(
        client_id=client_id,
        tenant_id=cfg.get("tenant_id", "common"),
        mailbox=cfg.get("mailbox"),
        config_dir=Path(cfg["config_dir"]) if cfg.get("config_dir") else None,
        level=cfg.get("level"),
    ),
    prefix=prefix,
)
```

- [ ] **Step 4: Add a test for level passthrough**

Add to `tests/test_levels.py`:
```python
class TestAdapterLevelPassthrough:
    def test_gmail_adapter_default_readonly(self):
        from ts4k.adapters.gmail import GmailAdapter, GmailAdapterConfig
        adapter = GmailAdapter(GmailAdapterConfig(user_email="a@b.com"))
        assert adapter.access_level == AccessLevel.READONLY

    def test_gmail_adapter_modify(self):
        from ts4k.adapters.gmail import GmailAdapter, GmailAdapterConfig
        adapter = GmailAdapter(GmailAdapterConfig(user_email="a@b.com", level="modify"))
        assert adapter.access_level == AccessLevel.MODIFY

    def test_o365_adapter_draft(self):
        from ts4k.adapters.o365 import O365Adapter, O365AdapterConfig
        adapter = O365Adapter(O365AdapterConfig(client_id="x", level="draft"))
        assert adapter.access_level == AccessLevel.DRAFT
```

- [ ] **Step 5: Run all tests**

Run: `uv run pytest tests/test_levels.py tests/test_gmail_adapter.py tests/test_o365_adapter.py -v`
Expected: All pass

- [ ] **Step 6: Commit**

```bash
git add src/ts4k/core/levels.py src/ts4k/adapters/base.py src/ts4k/adapters/gmail.py \
       src/ts4k/adapters/o365.py src/ts4k/commands.py tests/test_levels.py
git commit -m "feat: wire permission levels into adapter configs and OAuth scopes"
```

### Task 3: Display Level in `src list` and Accept in `src add`

**Files:**
- Modify: `src/ts4k/cli.py:265-347` (_cmd_sources)

- [ ] **Step 1: Update `src list` to show level**

In `_cmd_sources`, in the `action == "list"` branch (around line 334), add level display:
```python
for prefix, cfg in sorted(all_cfg.items()):
    provider = cfg.get("provider", "?")
    detail = cfg.get("email") or cfg.get("mailbox") or cfg.get("mcp_cwd") or ""
    level = cfg.get("level", "readonly")
    print(f"  {prefix}: {provider} ({detail}) [{level}]")
```

No new tests needed — this is display-only. Manual verification:
```bash
uv run ts4k src list
```

- [ ] **Step 2: Commit**

```bash
git add src/ts4k/cli.py
git commit -m "feat: show access level in src list output"
```

---

## Chunk 2: Gmail Mailbox Management

### Task 4: Add Management Methods to BaseAdapter

**Files:**
- Modify: `src/ts4k/adapters/base.py`

- [ ] **Step 1: Add optional management methods to BaseAdapter**

Add after `mailbox_stats` (line 117) in `base.py`:
```python
    # -- Management methods (optional, require level >= MODIFY) ---------------
    # ts4k NEVER sends messages. There is no send method. This is by design.

    async def archive_message(self, msg_id: str) -> dict:
        """Archive a message (remove from inbox, keep in mailbox)."""
        raise NotImplementedError(f"{type(self).__name__} does not support archive")

    async def unarchive_message(self, msg_id: str) -> dict:
        """Unarchive a message (return to inbox)."""
        raise NotImplementedError(f"{type(self).__name__} does not support unarchive")

    async def label_message(self, msg_id: str, label: str) -> dict:
        """Add a label/category to a message."""
        raise NotImplementedError(f"{type(self).__name__} does not support labeling")

    async def unlabel_message(self, msg_id: str, label: str) -> dict:
        """Remove a label/category from a message."""
        raise NotImplementedError(f"{type(self).__name__} does not support unlabeling")

    async def mark_read(self, msg_id: str) -> dict:
        """Mark a message as read."""
        raise NotImplementedError(f"{type(self).__name__} does not support mark_read")

    async def mark_unread(self, msg_id: str) -> dict:
        """Mark a message as unread."""
        raise NotImplementedError(f"{type(self).__name__} does not support mark_unread")

    async def trash_message(self, msg_id: str) -> dict:
        """Move a message to trash (recoverable)."""
        raise NotImplementedError(f"{type(self).__name__} does not support trash")

    async def list_labels(self) -> list[dict]:
        """List available labels/categories/folders."""
        raise NotImplementedError(f"{type(self).__name__} does not support list_labels")

    async def create_label(self, name: str) -> dict:
        """Create a new label/category/folder."""
        raise NotImplementedError(f"{type(self).__name__} does not support create_label")

    # -- Draft methods (optional, require level >= DRAFT) ---------------------

    async def create_draft(
        self,
        to: str,
        subject: str,
        body: str,
        reply_to_message_id: str | None = None,
    ) -> dict:
        """Create a draft message. Does NOT send.

        When reply_to_message_id is provided, the draft threads as a reply
        with proper headers and blockquoted original.

        Returns: {"id": "<prefixed draft ID>", "status": "draft_created"}
        """
        raise NotImplementedError(f"{type(self).__name__} does not support create_draft")
```

- [ ] **Step 2: Commit**

```bash
git add src/ts4k/adapters/base.py
git commit -m "feat: add management and draft method stubs to BaseAdapter"
```

### Task 5: Gmail Management Implementation

**Files:**
- Modify: `src/ts4k/adapters/gmail.py`
- Test: `tests/test_gmail_manage.py`

- [ ] **Step 1: Write tests for Gmail management methods**

```python
# tests/test_gmail_manage.py
"""Tests for Gmail mailbox management methods."""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from ts4k.adapters.gmail import GmailAdapter, GmailAdapterConfig
from ts4k.core.levels import AccessLevel


@pytest.fixture
def gmail_modify():
    """GmailAdapter at modify level with mocked service."""
    adapter = GmailAdapter(
        GmailAdapterConfig(user_email="test@gmail.com", level="modify"),
        prefix="g",
    )
    adapter._service = MagicMock()
    return adapter


@pytest.fixture
def gmail_readonly():
    """GmailAdapter at readonly level with mocked service."""
    adapter = GmailAdapter(
        GmailAdapterConfig(user_email="test@gmail.com"),
        prefix="g",
    )
    adapter._service = MagicMock()
    return adapter


class TestLevelGating:
    @pytest.mark.asyncio
    async def test_archive_blocked_at_readonly(self, gmail_readonly):
        with pytest.raises(PermissionError, match="level='modify'"):
            await gmail_readonly.archive_message("g:abc123")

    @pytest.mark.asyncio
    async def test_archive_allowed_at_modify(self, gmail_modify):
        gmail_modify._service.users().messages().modify().execute.return_value = {
            "id": "abc123", "labelIds": ["CATEGORY_PERSONAL"]
        }
        result = await gmail_modify.archive_message("g:abc123")
        assert result["status"] == "archived"


class TestArchive:
    @pytest.mark.asyncio
    async def test_archive_removes_inbox_label(self, gmail_modify):
        svc = gmail_modify._service
        svc.users().messages().modify().execute.return_value = {
            "id": "abc123", "labelIds": ["CATEGORY_PERSONAL"]
        }
        result = await gmail_modify.archive_message("g:abc123")
        svc.users().messages().modify.assert_called_with(
            userId="me", id="abc123",
            body={"removeLabelIds": ["INBOX"]}
        )
        assert result["status"] == "archived"
        assert result["id"] == "g:abc123"

    @pytest.mark.asyncio
    async def test_unarchive_adds_inbox_label(self, gmail_modify):
        svc = gmail_modify._service
        svc.users().messages().modify().execute.return_value = {
            "id": "abc123", "labelIds": ["INBOX"]
        }
        result = await gmail_modify.unarchive_message("g:abc123")
        svc.users().messages().modify.assert_called_with(
            userId="me", id="abc123",
            body={"addLabelIds": ["INBOX"]}
        )
        assert result["status"] == "unarchived"


class TestLabel:
    @pytest.mark.asyncio
    async def test_label_creates_if_missing(self, gmail_modify):
        svc = gmail_modify._service
        # labels.list returns no match
        svc.users().labels().list().execute.return_value = {
            "labels": [{"id": "INBOX", "name": "INBOX"}]
        }
        # labels.create returns new label
        svc.users().labels().create().execute.return_value = {
            "id": "Label_99", "name": "llm-garbage"
        }
        # modify succeeds
        svc.users().messages().modify().execute.return_value = {
            "id": "abc123", "labelIds": ["Label_99"]
        }
        result = await gmail_modify.label_message("g:abc123", "llm-garbage")
        assert result["status"] == "labeled"
        assert result["label"] == "llm-garbage"

    @pytest.mark.asyncio
    async def test_unlabel(self, gmail_modify):
        svc = gmail_modify._service
        svc.users().labels().list().execute.return_value = {
            "labels": [{"id": "Label_99", "name": "llm-garbage"}]
        }
        svc.users().messages().modify().execute.return_value = {
            "id": "abc123", "labelIds": []
        }
        result = await gmail_modify.unlabel_message("g:abc123", "llm-garbage")
        assert result["status"] == "unlabeled"


class TestMarkRead:
    @pytest.mark.asyncio
    async def test_mark_read(self, gmail_modify):
        svc = gmail_modify._service
        svc.users().messages().modify().execute.return_value = {
            "id": "abc123", "labelIds": []
        }
        result = await gmail_modify.mark_read("g:abc123")
        svc.users().messages().modify.assert_called_with(
            userId="me", id="abc123",
            body={"removeLabelIds": ["UNREAD"]}
        )
        assert result["status"] == "marked_read"

    @pytest.mark.asyncio
    async def test_mark_unread(self, gmail_modify):
        svc = gmail_modify._service
        svc.users().messages().modify().execute.return_value = {
            "id": "abc123", "labelIds": ["UNREAD"]
        }
        result = await gmail_modify.mark_unread("g:abc123")
        svc.users().messages().modify.assert_called_with(
            userId="me", id="abc123",
            body={"addLabelIds": ["UNREAD"]}
        )
        assert result["status"] == "marked_unread"


class TestTrash:
    @pytest.mark.asyncio
    async def test_trash(self, gmail_modify):
        svc = gmail_modify._service
        svc.users().messages().trash().execute.return_value = {
            "id": "abc123", "labelIds": ["TRASH"]
        }
        result = await gmail_modify.trash_message("g:abc123")
        assert result["status"] == "trashed"


class TestListLabels:
    @pytest.mark.asyncio
    async def test_list_labels(self, gmail_modify):
        svc = gmail_modify._service
        svc.users().labels().list().execute.return_value = {
            "labels": [
                {"id": "INBOX", "name": "INBOX", "type": "system"},
                {"id": "Label_1", "name": "invoices", "type": "user"},
            ]
        }
        result = await gmail_modify.list_labels()
        assert len(result) == 2
        assert result[1]["name"] == "invoices"
```

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

Run: `uv run pytest tests/test_gmail_manage.py -v`
Expected: FAIL — methods not implemented

- [ ] **Step 3: Implement Gmail management methods**

Add to `GmailAdapter` class in `src/ts4k/adapters/gmail.py`, after `mailbox_stats`:

```python
    # -- Management methods (require level >= MODIFY) -----------------------

    def _check_modify(self, operation: str) -> None:
        from ts4k.core.levels import AccessLevel, check_level
        check_level(self._access_level, AccessLevel.MODIFY, operation)

    async def archive_message(self, msg_id: str) -> dict:
        self._check_modify("archive")
        service = self._require_service()
        raw_id = self._strip_prefix(msg_id)
        await asyncio.to_thread(
            lambda: service.users().messages().modify(
                userId="me", id=raw_id,
                body={"removeLabelIds": ["INBOX"]}
            ).execute()
        )
        return {"id": f"{self._prefix}:{raw_id}", "status": "archived"}

    async def unarchive_message(self, msg_id: str) -> dict:
        self._check_modify("unarchive")
        service = self._require_service()
        raw_id = self._strip_prefix(msg_id)
        await asyncio.to_thread(
            lambda: service.users().messages().modify(
                userId="me", id=raw_id,
                body={"addLabelIds": ["INBOX"]}
            ).execute()
        )
        return {"id": f"{self._prefix}:{raw_id}", "status": "unarchived"}

    async def _resolve_label_id(self, label_name: str, create: bool = True) -> str:
        """Find a label ID by name, optionally creating it if missing."""
        service = self._require_service()
        result = await asyncio.to_thread(
            lambda: service.users().labels().list(userId="me").execute()
        )
        for lbl in result.get("labels", []):
            if lbl.get("name", "").lower() == label_name.lower():
                return lbl["id"]
        if not create:
            raise ValueError(f"Label {label_name!r} not found")
        new_label = await asyncio.to_thread(
            lambda: service.users().labels().create(
                userId="me",
                body={"name": label_name, "labelListVisibility": "labelShow",
                      "messageListVisibility": "show"}
            ).execute()
        )
        return new_label["id"]

    async def label_message(self, msg_id: str, label: str) -> dict:
        self._check_modify("label")
        service = self._require_service()
        raw_id = self._strip_prefix(msg_id)
        label_id = await self._resolve_label_id(label, create=True)
        await asyncio.to_thread(
            lambda: service.users().messages().modify(
                userId="me", id=raw_id,
                body={"addLabelIds": [label_id]}
            ).execute()
        )
        return {"id": f"{self._prefix}:{raw_id}", "status": "labeled", "label": label}

    async def unlabel_message(self, msg_id: str, label: str) -> dict:
        self._check_modify("unlabel")
        service = self._require_service()
        raw_id = self._strip_prefix(msg_id)
        label_id = await self._resolve_label_id(label, create=False)
        await asyncio.to_thread(
            lambda: service.users().messages().modify(
                userId="me", id=raw_id,
                body={"removeLabelIds": [label_id]}
            ).execute()
        )
        return {"id": f"{self._prefix}:{raw_id}", "status": "unlabeled", "label": label}

    async def mark_read(self, msg_id: str) -> dict:
        self._check_modify("mark_read")
        service = self._require_service()
        raw_id = self._strip_prefix(msg_id)
        await asyncio.to_thread(
            lambda: service.users().messages().modify(
                userId="me", id=raw_id,
                body={"removeLabelIds": ["UNREAD"]}
            ).execute()
        )
        return {"id": f"{self._prefix}:{raw_id}", "status": "marked_read"}

    async def mark_unread(self, msg_id: str) -> dict:
        self._check_modify("mark_unread")
        service = self._require_service()
        raw_id = self._strip_prefix(msg_id)
        await asyncio.to_thread(
            lambda: service.users().messages().modify(
                userId="me", id=raw_id,
                body={"addLabelIds": ["UNREAD"]}
            ).execute()
        )
        return {"id": f"{self._prefix}:{raw_id}", "status": "marked_unread"}

    async def trash_message(self, msg_id: str) -> dict:
        self._check_modify("trash")
        service = self._require_service()
        raw_id = self._strip_prefix(msg_id)
        await asyncio.to_thread(
            lambda: service.users().messages().trash(
                userId="me", id=raw_id,
            ).execute()
        )
        return {"id": f"{self._prefix}:{raw_id}", "status": "trashed"}

    async def list_labels(self) -> list[dict]:
        self._check_modify("list_labels")
        service = self._require_service()
        result = await asyncio.to_thread(
            lambda: service.users().labels().list(userId="me").execute()
        )
        return [
            {"id": lbl["id"], "name": lbl.get("name", ""), "type": lbl.get("type", "")}
            for lbl in result.get("labels", [])
        ]

    async def create_label(self, name: str) -> dict:
        self._check_modify("create_label")
        label_id = await self._resolve_label_id(name, create=True)
        return {"id": label_id, "name": name, "status": "created"}
```

- [ ] **Step 4: Run tests**

Run: `uv run pytest tests/test_gmail_manage.py -v`
Expected: All pass

- [ ] **Step 5: Run full test suite**

Run: `uv run pytest -x -v`
Expected: All pass (no regressions)

- [ ] **Step 6: Commit**

```bash
git add src/ts4k/adapters/gmail.py tests/test_gmail_manage.py
git commit -m "feat: implement Gmail mailbox management (archive, label, mark read/unread, trash)"
```

---

## Chunk 3: O365 Mailbox Management

### Task 6: O365 Management Implementation

**Files:**
- Modify: `src/ts4k/adapters/o365.py`
- Test: `tests/test_o365_manage.py`

- [ ] **Step 1: Write tests for O365 management methods**

```python
# tests/test_o365_manage.py
"""Tests for O365 mailbox management methods."""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock

import httpx
import pytest

from ts4k.adapters.o365 import O365Adapter, O365AdapterConfig
from ts4k.core.levels import AccessLevel


def _mock_response(json_data: dict, status_code: int = 200) -> httpx.Response:
    return httpx.Response(status_code, json=json_data, request=httpx.Request("POST", "http://test"))


@pytest.fixture
def o365_modify():
    adapter = O365Adapter(
        O365AdapterConfig(client_id="test-id", level="modify"), prefix="o"
    )
    adapter._client = AsyncMock(spec=httpx.AsyncClient)
    return adapter


@pytest.fixture
def o365_readonly():
    adapter = O365Adapter(
        O365AdapterConfig(client_id="test-id"), prefix="o"
    )
    adapter._client = AsyncMock(spec=httpx.AsyncClient)
    return adapter


class TestLevelGating:
    @pytest.mark.asyncio
    async def test_archive_blocked_at_readonly(self, o365_readonly):
        with pytest.raises(PermissionError, match="level='modify'"):
            await o365_readonly.archive_message("o:AAMk123")


class TestArchive:
    @pytest.mark.asyncio
    async def test_archive_moves_to_archive_folder(self, o365_modify):
        o365_modify._client.post = AsyncMock(
            return_value=_mock_response({"id": "AAMk123"})
        )
        result = await o365_modify.archive_message("o:AAMk123")
        o365_modify._client.post.assert_called_once()
        call_args = o365_modify._client.post.call_args
        assert "/messages/AAMk123/move" in call_args[0][0]
        assert result["status"] == "archived"

    @pytest.mark.asyncio
    async def test_unarchive_moves_to_inbox(self, o365_modify):
        o365_modify._client.post = AsyncMock(
            return_value=_mock_response({"id": "AAMk123"})
        )
        result = await o365_modify.unarchive_message("o:AAMk123")
        assert result["status"] == "unarchived"


class TestMarkRead:
    @pytest.mark.asyncio
    async def test_mark_read(self, o365_modify):
        o365_modify._client.patch = AsyncMock(
            return_value=_mock_response({"id": "AAMk123", "isRead": True})
        )
        result = await o365_modify.mark_read("o:AAMk123")
        call_args = o365_modify._client.patch.call_args
        assert call_args[1]["json"] == {"isRead": True}
        assert result["status"] == "marked_read"

    @pytest.mark.asyncio
    async def test_mark_unread(self, o365_modify):
        o365_modify._client.patch = AsyncMock(
            return_value=_mock_response({"id": "AAMk123", "isRead": False})
        )
        result = await o365_modify.mark_unread("o:AAMk123")
        call_args = o365_modify._client.patch.call_args
        assert call_args[1]["json"] == {"isRead": False}
        assert result["status"] == "marked_unread"


class TestCategorize:
    @pytest.mark.asyncio
    async def test_categorize(self, o365_modify):
        o365_modify._client.patch = AsyncMock(
            return_value=_mock_response({"id": "AAMk123", "categories": ["llm-garbage"]})
        )
        result = await o365_modify.label_message("o:AAMk123", "llm-garbage")
        call_args = o365_modify._client.patch.call_args
        assert "llm-garbage" in call_args[1]["json"]["categories"]
        assert result["status"] == "labeled"


class TestMoveToFolder:
    @pytest.mark.asyncio
    async def test_move_to_folder(self, o365_modify):
        # First call: list folders (find or create)
        o365_modify._client.get = AsyncMock(
            return_value=_mock_response({
                "value": [{"id": "folder123", "displayName": "llm-garbage"}]
            })
        )
        o365_modify._client.post = AsyncMock(
            return_value=_mock_response({"id": "AAMk123"})
        )
        result = await o365_modify.move_to_folder("o:AAMk123", "llm-garbage")
        assert result["status"] == "moved"
```

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

Run: `uv run pytest tests/test_o365_manage.py -v`
Expected: FAIL

- [ ] **Step 3: Implement O365 management methods**

Add to `O365Adapter` class in `src/ts4k/adapters/o365.py`, after `mailbox_stats`:

```python
    # -- Management methods (require level >= MODIFY) -----------------------

    def _check_modify(self, operation: str) -> None:
        from ts4k.core.levels import AccessLevel, check_level
        check_level(self._access_level, AccessLevel.MODIFY, operation)

    async def _post(self, path: str, json: dict | None = None) -> dict:
        """Issue a POST request to Graph API."""
        client = self._require_client()
        resp = await client.post(path, json=json or {})
        resp.raise_for_status()
        return resp.json()

    async def _patch(self, path: str, json: dict) -> dict:
        """Issue a PATCH request to Graph API."""
        client = self._require_client()
        resp = await client.patch(path, json=json)
        resp.raise_for_status()
        return resp.json()

    async def archive_message(self, msg_id: str) -> dict:
        self._check_modify("archive")
        raw_id = _strip_prefix(msg_id, self.source_prefix)
        await self._post(
            f"{self._base_url()}/messages/{raw_id}/move",
            json={"destinationId": "archive"},
        )
        return {"id": f"{self._prefix}:{raw_id}", "status": "archived"}

    async def unarchive_message(self, msg_id: str) -> dict:
        self._check_modify("unarchive")
        raw_id = _strip_prefix(msg_id, self.source_prefix)
        await self._post(
            f"{self._base_url()}/messages/{raw_id}/move",
            json={"destinationId": "inbox"},
        )
        return {"id": f"{self._prefix}:{raw_id}", "status": "unarchived"}

    async def mark_read(self, msg_id: str) -> dict:
        self._check_modify("mark_read")
        raw_id = _strip_prefix(msg_id, self.source_prefix)
        await self._patch(
            f"{self._base_url()}/messages/{raw_id}",
            json={"isRead": True},
        )
        return {"id": f"{self._prefix}:{raw_id}", "status": "marked_read"}

    async def mark_unread(self, msg_id: str) -> dict:
        self._check_modify("mark_unread")
        raw_id = _strip_prefix(msg_id, self.source_prefix)
        await self._patch(
            f"{self._base_url()}/messages/{raw_id}",
            json={"isRead": False},
        )
        return {"id": f"{self._prefix}:{raw_id}", "status": "marked_unread"}

    async def label_message(self, msg_id: str, label: str) -> dict:
        """Add a category to a message (O365 equivalent of Gmail labels)."""
        self._check_modify("categorize")
        raw_id = _strip_prefix(msg_id, self.source_prefix)
        # Get current categories, then add
        msg = await self._get(
            f"{self._base_url()}/messages/{raw_id}",
            {"$select": "categories"},
        )
        categories = list(msg.get("categories", []))
        if label not in categories:
            categories.append(label)
        await self._patch(
            f"{self._base_url()}/messages/{raw_id}",
            json={"categories": categories},
        )
        return {"id": f"{self._prefix}:{raw_id}", "status": "labeled", "label": label}

    async def unlabel_message(self, msg_id: str, label: str) -> dict:
        """Remove a category from a message."""
        self._check_modify("uncategorize")
        raw_id = _strip_prefix(msg_id, self.source_prefix)
        msg = await self._get(
            f"{self._base_url()}/messages/{raw_id}",
            {"$select": "categories"},
        )
        categories = [c for c in msg.get("categories", []) if c != label]
        await self._patch(
            f"{self._base_url()}/messages/{raw_id}",
            json={"categories": categories},
        )
        return {"id": f"{self._prefix}:{raw_id}", "status": "unlabeled", "label": label}

    async def _resolve_folder_id(self, folder_name: str, create: bool = True) -> str:
        """Find a folder ID by name, optionally creating it."""
        data = await self._get(
            f"{self._base_url()}/mailFolders",
            {"$filter": f"displayName eq '{folder_name}'"},
        )
        folders = data.get("value", [])
        if folders:
            return folders[0]["id"]
        if not create:
            raise ValueError(f"Folder {folder_name!r} not found")
        result = await self._post(
            f"{self._base_url()}/mailFolders",
            json={"displayName": folder_name},
        )
        return result["id"]

    async def move_to_folder(self, msg_id: str, folder_name: str) -> dict:
        """Move a message to a named folder (creating it if needed)."""
        self._check_modify("move")
        raw_id = _strip_prefix(msg_id, self.source_prefix)
        folder_id = await self._resolve_folder_id(folder_name, create=True)
        await self._post(
            f"{self._base_url()}/messages/{raw_id}/move",
            json={"destinationId": folder_id},
        )
        return {"id": f"{self._prefix}:{raw_id}", "status": "moved", "folder": folder_name}

    async def trash_message(self, msg_id: str) -> dict:
        self._check_modify("trash")
        raw_id = _strip_prefix(msg_id, self.source_prefix)
        await self._post(
            f"{self._base_url()}/messages/{raw_id}/move",
            json={"destinationId": "deleteditems"},
        )
        return {"id": f"{self._prefix}:{raw_id}", "status": "trashed"}
```

- [ ] **Step 4: Run tests**

Run: `uv run pytest tests/test_o365_manage.py -v`
Expected: All pass

- [ ] **Step 5: Run full test suite**

Run: `uv run pytest -x -v`
Expected: All pass

- [ ] **Step 6: Commit**

```bash
git add src/ts4k/adapters/o365.py tests/test_o365_manage.py
git commit -m "feat: implement O365 mailbox management (archive, categorize, move, mark read/unread, trash)"
```

---

## Chunk 4: Gmail Draft Creation

### Task 7: Gmail Draft with Reply Threading

**Files:**
- Modify: `src/ts4k/adapters/gmail.py`
- Test: `tests/test_gmail_draft.py`

- [ ] **Step 1: Write tests for Gmail draft creation**

```python
# tests/test_gmail_draft.py
"""Tests for Gmail draft creation with reply threading."""

from __future__ import annotations

import base64
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from ts4k.adapters.gmail import GmailAdapter, GmailAdapterConfig
from ts4k.core.levels import AccessLevel


def _b64(text: str) -> str:
    return base64.urlsafe_b64encode(text.encode()).decode()


@pytest.fixture
def gmail_draft():
    adapter = GmailAdapter(
        GmailAdapterConfig(user_email="test@gmail.com", level="draft"),
        prefix="g",
    )
    adapter._service = MagicMock()
    return adapter


@pytest.fixture
def gmail_modify():
    adapter = GmailAdapter(
        GmailAdapterConfig(user_email="test@gmail.com", level="modify"),
        prefix="g",
    )
    adapter._service = MagicMock()
    return adapter


class TestLevelGating:
    @pytest.mark.asyncio
    async def test_draft_blocked_at_modify(self, gmail_modify):
        with pytest.raises(PermissionError, match="level='draft'"):
            await gmail_modify.create_draft(
                to="alice@example.com", subject="Hi", body="Hello"
            )

    @pytest.mark.asyncio
    async def test_draft_allowed_at_draft(self, gmail_draft):
        svc = gmail_draft._service
        svc.users().drafts().create().execute.return_value = {
            "id": "draft_123",
            "message": {"id": "msg_456", "threadId": "thread_789"},
        }
        result = await gmail_draft.create_draft(
            to="alice@example.com", subject="Hi", body="Hello"
        )
        assert result["status"] == "draft_created"


class TestNewDraft:
    @pytest.mark.asyncio
    async def test_creates_new_draft(self, gmail_draft):
        svc = gmail_draft._service
        svc.users().drafts().create().execute.return_value = {
            "id": "draft_123",
            "message": {"id": "msg_456", "threadId": "thread_789"},
        }
        result = await gmail_draft.create_draft(
            to="alice@example.com",
            subject="Meeting tomorrow",
            body="Let's meet at 3pm.",
        )
        assert result["id"] == "g:draft_123"
        assert result["status"] == "draft_created"
        # Verify the draft was created with correct structure
        call_args = svc.users().drafts().create.call_args
        assert call_args[1]["userId"] == "me"


class TestReplyDraft:
    @pytest.mark.asyncio
    async def test_reply_includes_threading_headers(self, gmail_draft):
        svc = gmail_draft._service
        # Mock reading the original message
        svc.users().messages().get().execute.return_value = {
            "id": "orig_123",
            "threadId": "thread_789",
            "payload": {
                "headers": [
                    {"name": "Subject", "value": "Meeting tomorrow"},
                    {"name": "From", "value": "Alice <alice@example.com>"},
                    {"name": "Message-ID", "value": "<orig@mail.gmail.com>"},
                    {"name": "To", "value": "test@gmail.com"},
                    {"name": "Date", "value": "Mon, 10 Mar 2026 09:00:00 +0000"},
                ],
                "mimeType": "text/plain",
                "body": {"data": _b64("Let's meet at 3pm.")},
            },
            "internalDate": "1741597200000",
        }
        # Mock draft creation
        svc.users().drafts().create().execute.return_value = {
            "id": "draft_456",
            "message": {"id": "msg_789", "threadId": "thread_789"},
        }
        result = await gmail_draft.create_draft(
            to="alice@example.com",
            subject="Re: Meeting tomorrow",
            body="Sure, see you then!",
            reply_to_message_id="g:orig_123",
        )
        assert result["status"] == "draft_created"
        assert result["id"] == "g:draft_456"

    @pytest.mark.asyncio
    async def test_reply_blockquotes_original(self, gmail_draft):
        svc = gmail_draft._service
        svc.users().messages().get().execute.return_value = {
            "id": "orig_123",
            "threadId": "thread_789",
            "payload": {
                "headers": [
                    {"name": "Subject", "value": "Budget review"},
                    {"name": "From", "value": "Alice <alice@example.com>"},
                    {"name": "Message-ID", "value": "<orig@mail.gmail.com>"},
                    {"name": "Date", "value": "Mon, 10 Mar 2026 09:00:00 +0000"},
                ],
                "mimeType": "text/plain",
                "body": {"data": _b64("Please review the budget.")},
            },
            "internalDate": "1741597200000",
        }
        svc.users().drafts().create().execute.return_value = {
            "id": "draft_456",
            "message": {"id": "msg_789", "threadId": "thread_789"},
        }
        await gmail_draft.create_draft(
            to="alice@example.com",
            subject="Re: Budget review",
            body="Looks good to me.",
            reply_to_message_id="g:orig_123",
        )
        # Extract the raw message from the draft create call
        call_args = svc.users().drafts().create.call_args
        raw_msg = base64.urlsafe_b64decode(
            call_args[1]["body"]["message"]["raw"]
        ).decode()
        assert "Looks good to me." in raw_msg
        assert "Please review the budget." in raw_msg
        assert "wrote:" in raw_msg.lower() or ">" in raw_msg
```

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

Run: `uv run pytest tests/test_gmail_draft.py -v`
Expected: FAIL

- [ ] **Step 3: Implement Gmail draft creation**

Add to `GmailAdapter` class, after management methods:

```python
    # -- Draft methods (require level >= DRAFT) -----------------------------

    def _check_draft(self, operation: str) -> None:
        from ts4k.core.levels import AccessLevel, check_level
        check_level(self._access_level, AccessLevel.DRAFT, operation)

    async def create_draft(
        self,
        to: str,
        subject: str,
        body: str,
        reply_to_message_id: str | None = None,
    ) -> dict:
        """Create a Gmail draft. Does NOT send.

        When reply_to_message_id is provided, fetches the original message
        to set threading headers and blockquote the original body.
        """
        import email.mime.text
        from email.mime.text import MIMEText

        self._check_draft("create_draft")
        service = self._require_service()

        thread_id = None
        in_reply_to = None
        references = None
        quoted_body = body

        if reply_to_message_id:
            raw_orig_id = self._strip_prefix(reply_to_message_id)
            orig = await asyncio.to_thread(
                lambda: service.users().messages().get(
                    userId="me", id=raw_orig_id, format="full",
                ).execute()
            )
            orig_headers = orig.get("payload", {}).get("headers", [])
            thread_id = orig.get("threadId")
            in_reply_to = _get_header(orig_headers, "Message-ID")
            references = _get_header(orig_headers, "References")
            if references and in_reply_to:
                references = f"{references} {in_reply_to}"
            elif in_reply_to:
                references = in_reply_to

            # Auto-add Re: if not present
            if not subject.lower().startswith("re:"):
                orig_subject = _get_header(orig_headers, "Subject")
                subject = f"Re: {orig_subject}" if orig_subject else subject

            # Build blockquote
            orig_from = _get_header(orig_headers, "From")
            orig_date = _get_header(orig_headers, "Date")
            orig_body = _decode_body(orig.get("payload", {}))
            # Strip HTML if needed
            if "<" in orig_body and ">" in orig_body:
                from ts4k.core.normalize import _strip_html
                orig_body = _strip_html(orig_body)
            quoted_lines = "\n".join(f"> {line}" for line in orig_body.strip().split("\n"))
            quoted_body = (
                f"{body}\n\n"
                f"On {orig_date}, {orig_from} wrote:\n"
                f"{quoted_lines}"
            )

        msg = MIMEText(quoted_body)
        msg["to"] = to
        msg["subject"] = subject
        msg["from"] = self._config.user_email
        if in_reply_to:
            msg["In-Reply-To"] = in_reply_to
        if references:
            msg["References"] = references

        raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()
        draft_body: dict = {"message": {"raw": raw}}
        if thread_id:
            draft_body["message"]["threadId"] = thread_id

        result = await asyncio.to_thread(
            lambda: service.users().drafts().create(
                userId="me", body=draft_body,
            ).execute()
        )
        draft_id = result.get("id", "")
        return {"id": f"{self._prefix}:{draft_id}", "status": "draft_created"}
```

- [ ] **Step 4: Run tests**

Run: `uv run pytest tests/test_gmail_draft.py -v`
Expected: All pass

- [ ] **Step 5: Commit**

```bash
git add src/ts4k/adapters/gmail.py tests/test_gmail_draft.py
git commit -m "feat: implement Gmail draft creation with reply threading and blockquoting"
```

---

## Chunk 5: O365 Draft Creation

### Task 8: O365 Draft with Reply Threading

**Files:**
- Modify: `src/ts4k/adapters/o365.py`
- Test: `tests/test_o365_draft.py`

- [ ] **Step 1: Write tests for O365 draft creation**

```python
# tests/test_o365_draft.py
"""Tests for O365 draft creation with reply threading."""

from __future__ import annotations

from unittest.mock import AsyncMock

import httpx
import pytest

from ts4k.adapters.o365 import O365Adapter, O365AdapterConfig


def _mock_response(json_data: dict, status_code: int = 200) -> httpx.Response:
    return httpx.Response(status_code, json=json_data, request=httpx.Request("POST", "http://test"))


@pytest.fixture
def o365_draft():
    adapter = O365Adapter(
        O365AdapterConfig(client_id="test-id", level="draft"), prefix="o"
    )
    adapter._client = AsyncMock(spec=httpx.AsyncClient)
    return adapter


class TestNewDraft:
    @pytest.mark.asyncio
    async def test_creates_draft_message(self, o365_draft):
        o365_draft._client.post = AsyncMock(
            return_value=_mock_response({
                "id": "AAMkDraft123",
                "subject": "Hello",
            })
        )
        result = await o365_draft.create_draft(
            to="alice@example.com",
            subject="Hello",
            body="Hi Alice!",
        )
        assert result["id"] == "o:AAMkDraft123"
        assert result["status"] == "draft_created"
        call_args = o365_draft._client.post.call_args
        assert "/messages" in call_args[0][0]
        payload = call_args[1]["json"]
        assert payload["subject"] == "Hello"
        assert payload["body"]["content"] == "Hi Alice!"
        assert payload["toRecipients"][0]["emailAddress"]["address"] == "alice@example.com"

    @pytest.mark.asyncio
    async def test_draft_level_gating(self):
        adapter = O365Adapter(
            O365AdapterConfig(client_id="test-id", level="modify"), prefix="o"
        )
        adapter._client = AsyncMock(spec=httpx.AsyncClient)
        with pytest.raises(PermissionError, match="level='draft'"):
            await adapter.create_draft(to="a@b.com", subject="X", body="Y")


class TestReplyDraft:
    @pytest.mark.asyncio
    async def test_reply_includes_conversation_id(self, o365_draft):
        # Mock GET for original message
        o365_draft._client.get = AsyncMock(
            return_value=_mock_response({
                "id": "AAMkOrig",
                "conversationId": "convABC",
                "subject": "Budget review",
                "from": {
                    "emailAddress": {"name": "Alice", "address": "alice@example.com"}
                },
                "receivedDateTime": "2026-03-10T09:00:00Z",
                "body": {"contentType": "text", "content": "Please review the budget."},
                "internetMessageId": "<orig@outlook.com>",
            })
        )
        # Mock POST for draft creation
        o365_draft._client.post = AsyncMock(
            return_value=_mock_response({
                "id": "AAMkDraft456",
                "conversationId": "convABC",
            })
        )
        result = await o365_draft.create_draft(
            to="alice@example.com",
            subject="Re: Budget review",
            body="Looks good.",
            reply_to_message_id="o:AAMkOrig",
        )
        assert result["status"] == "draft_created"
        call_args = o365_draft._client.post.call_args
        payload = call_args[1]["json"]
        assert payload["conversationId"] == "convABC"
        assert "Please review the budget." in payload["body"]["content"]
```

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

Run: `uv run pytest tests/test_o365_draft.py -v`
Expected: FAIL

- [ ] **Step 3: Implement O365 draft creation**

Add to `O365Adapter` class, after management methods:

```python
    # -- Draft methods (require level >= DRAFT) -----------------------------

    def _check_draft(self, operation: str) -> None:
        from ts4k.core.levels import AccessLevel, check_level
        check_level(self._access_level, AccessLevel.DRAFT, operation)

    async def create_draft(
        self,
        to: str,
        subject: str,
        body: str,
        reply_to_message_id: str | None = None,
    ) -> dict:
        """Create a draft in the O365 Drafts folder. Does NOT send.

        Uses POST /me/messages which creates in Drafts by default.
        When reply_to_message_id is provided, sets conversationId for
        threading and blockquotes the original message body.
        """
        self._check_draft("create_draft")

        draft_body: dict = {
            "subject": subject,
            "body": {"contentType": "text", "content": body},
            "toRecipients": [
                {"emailAddress": {"address": to}}
            ],
        }

        if reply_to_message_id:
            raw_id = _strip_prefix(reply_to_message_id, self.source_prefix)
            orig = await self._get(
                f"{self._base_url()}/messages/{raw_id}",
                {"$select": "conversationId,subject,from,receivedDateTime,"
                            "body,internetMessageId"},
            )

            conv_id = orig.get("conversationId")
            if conv_id:
                draft_body["conversationId"] = conv_id

            # Auto-add Re: if not present
            if not subject.lower().startswith("re:"):
                orig_subject = orig.get("subject", "")
                draft_body["subject"] = f"Re: {orig_subject}" if orig_subject else subject

            # Build blockquote
            orig_from = _format_from(orig)
            orig_date = orig.get("receivedDateTime", "")
            orig_body_obj = orig.get("body", {})
            orig_body = orig_body_obj.get("content", "") if isinstance(orig_body_obj, dict) else ""
            if not orig_body:
                orig_body = orig.get("bodyPreview", "")

            # Strip HTML if body is HTML
            content_type = orig_body_obj.get("contentType", "text") if isinstance(orig_body_obj, dict) else "text"
            if content_type.lower() == "html" and orig_body:
                from ts4k.core.normalize import _strip_html
                orig_body = _strip_html(orig_body)

            quoted_lines = "\n".join(f"> {line}" for line in orig_body.strip().split("\n"))
            full_body = (
                f"{body}\n\n"
                f"On {orig_date}, {orig_from} wrote:\n"
                f"{quoted_lines}"
            )
            draft_body["body"]["content"] = full_body

            # Set In-Reply-To header for proper threading
            internet_msg_id = orig.get("internetMessageId")
            if internet_msg_id:
                draft_body["internetMessageHeaders"] = [
                    {"name": "In-Reply-To", "value": internet_msg_id},
                    {"name": "References", "value": internet_msg_id},
                ]

        result = await self._post(
            f"{self._base_url()}/messages",
            json=draft_body,
        )
        draft_id = result.get("id", "")
        return {"id": f"{self._prefix}:{draft_id}", "status": "draft_created"}
```

- [ ] **Step 4: Run tests**

Run: `uv run pytest tests/test_o365_draft.py -v`
Expected: All pass

- [ ] **Step 5: Commit**

```bash
git add src/ts4k/adapters/o365.py tests/test_o365_draft.py
git commit -m "feat: implement O365 draft creation with reply threading and blockquoting"
```

---

## Chunk 6: Commands, CLI, and MCP Tools

### Task 9: Add `manage` and `draft` Commands

**Files:**
- Modify: `src/ts4k/commands.py`
- Test: `tests/test_manage_commands.py`

- [ ] **Step 1: Write tests for manage/draft command functions**

```python
# tests/test_manage_commands.py
"""Tests for manage and draft command functions."""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from ts4k.commands import manage_message, create_draft


class TestManageMessage:
    @pytest.mark.asyncio
    async def test_archive(self, monkeypatch, tmp_path):
        monkeypatch.setenv("TS4K_CONFIG_DIR", str(tmp_path))
        from ts4k.state import sources
        monkeypatch.setattr(sources, "_CONFIG_DIR", tmp_path)
        monkeypatch.setattr(sources, "_SOURCES_FILE", tmp_path / "sources.json")
        sources.add("g", provider="gmail", email="a@b.com", level="modify")

        mock_adapter = AsyncMock()
        mock_adapter.archive_message.return_value = {"id": "g:abc", "status": "archived"}
        mock_adapter.source_prefix = "g"

        with patch("ts4k.commands._make_adapter", return_value=mock_adapter):
            result = await manage_message(action="archive", msg_id="g:abc")
        assert "archived" in result

    @pytest.mark.asyncio
    async def test_dry_run(self, monkeypatch, tmp_path):
        monkeypatch.setenv("TS4K_CONFIG_DIR", str(tmp_path))
        from ts4k.state import sources
        monkeypatch.setattr(sources, "_CONFIG_DIR", tmp_path)
        monkeypatch.setattr(sources, "_SOURCES_FILE", tmp_path / "sources.json")
        sources.add("g", provider="gmail", email="a@b.com", level="modify")

        result = await manage_message(action="archive", msg_id="g:abc", dry_run=True)
        assert "dry-run" in result.lower() or "would" in result.lower()

    @pytest.mark.asyncio
    async def test_batch_ids(self, monkeypatch, tmp_path):
        monkeypatch.setenv("TS4K_CONFIG_DIR", str(tmp_path))
        from ts4k.state import sources
        monkeypatch.setattr(sources, "_CONFIG_DIR", tmp_path)
        monkeypatch.setattr(sources, "_SOURCES_FILE", tmp_path / "sources.json")
        sources.add("g", provider="gmail", email="a@b.com", level="modify")

        mock_adapter = AsyncMock()
        mock_adapter.archive_message.return_value = {"id": "g:x", "status": "archived"}
        mock_adapter.source_prefix = "g"

        with patch("ts4k.commands._make_adapter", return_value=mock_adapter):
            result = await manage_message(
                action="archive", msg_id="g:abc,g:def,g:ghi"
            )
        assert mock_adapter.archive_message.call_count == 3


class TestCreateDraft:
    @pytest.mark.asyncio
    async def test_create_draft(self, monkeypatch, tmp_path):
        monkeypatch.setenv("TS4K_CONFIG_DIR", str(tmp_path))
        from ts4k.state import sources
        monkeypatch.setattr(sources, "_CONFIG_DIR", tmp_path)
        monkeypatch.setattr(sources, "_SOURCES_FILE", tmp_path / "sources.json")
        sources.add("g", provider="gmail", email="a@b.com", level="draft")

        mock_adapter = AsyncMock()
        mock_adapter.create_draft.return_value = {"id": "g:draft_1", "status": "draft_created"}
        mock_adapter.source_prefix = "g"

        with patch("ts4k.commands._make_adapter", return_value=mock_adapter):
            result = await create_draft(
                source="g", to="alice@x.com", subject="Hi", body="Hello"
            )
        assert "draft_created" in result
```

- [ ] **Step 2: Implement command functions**

Add to `src/ts4k/commands.py`, before the internal helpers at the bottom:

```python
# ---------------------------------------------------------------------------
# Manage commands (#19)
# ---------------------------------------------------------------------------

async def manage_message(
    action: str,
    msg_id: str,
    label: str | None = None,
    folder: str | None = None,
    dry_run: bool = False,
    ref_table: RefTable | None = None,
) -> str:
    """Execute a management action on one or more messages.

    msg_id can be comma-separated for batch operations.
    Actions: archive, unarchive, label, unlabel, read, unread, trash, list-labels.
    """
    # Resolve refs to full IDs
    ids = [_resolve_ref(mid.strip(), ref_table) for mid in msg_id.split(",")]

    if action == "list-labels":
        prefix = ids[0].split(":")[0] if ids else None
        if not prefix:
            return "Error: provide a source-prefixed ID (e.g. g:123)"
        cfg = sources.get(prefix)
        if not cfg:
            return f"Error: unknown source {prefix!r}"
        adapter = _make_adapter(prefix, cfg)
        if not adapter:
            return f"Error: failed to create adapter for {prefix!r}"
        async with adapter:
            labels = await adapter.list_labels()
        lines = [f"{lbl['name']} ({lbl.get('type', '')})" for lbl in labels]
        return "\n".join(lines) if lines else "No labels found."

    results = []
    for mid in ids:
        prefix = mid.split(":")[0] if ":" in mid else None
        if not prefix:
            results.append(f"{mid}: error — missing source prefix")
            continue

        if dry_run:
            results.append(f"{mid}: would {action}" + (f" label={label}" if label else "") + (f" folder={folder}" if folder else ""))
            continue

        cfg = sources.get(prefix)
        if not cfg:
            results.append(f"{mid}: error — unknown source {prefix!r}")
            continue

        adapter = _make_adapter(prefix, cfg)
        if not adapter:
            results.append(f"{mid}: error — failed to create adapter")
            continue

        try:
            async with adapter:
                if action == "archive":
                    r = await adapter.archive_message(mid)
                elif action == "unarchive":
                    r = await adapter.unarchive_message(mid)
                elif action == "label":
                    if not label:
                        results.append(f"{mid}: error — --label required")
                        continue
                    r = await adapter.label_message(mid, label)
                elif action == "unlabel":
                    if not label:
                        results.append(f"{mid}: error — --label required")
                        continue
                    r = await adapter.unlabel_message(mid, label)
                elif action == "read":
                    r = await adapter.mark_read(mid)
                elif action == "unread":
                    r = await adapter.mark_unread(mid)
                elif action == "trash":
                    r = await adapter.trash_message(mid)
                elif action == "move":
                    if not folder:
                        results.append(f"{mid}: error — --folder required")
                        continue
                    r = await adapter.move_to_folder(mid, folder)
                else:
                    results.append(f"{mid}: error — unknown action {action!r}")
                    continue
                results.append(f"{mid}: {r.get('status', 'ok')}")
        except PermissionError as e:
            results.append(f"{mid}: {e}")
        except Exception as e:
            results.append(f"{mid}: error — {e}")

    return "\n".join(results)


def _resolve_ref(mid: str, ref_table: RefTable | None) -> str:
    """Resolve a short ref or full ID. Returns the full ID."""
    if ref_table and mid.isdigit():
        resolved = ref_table.resolve(int(mid))
        if resolved:
            return resolved
    return mid


# ---------------------------------------------------------------------------
# Draft commands (#18)
# ---------------------------------------------------------------------------

async def create_draft(
    source: str,
    to: str,
    subject: str,
    body: str,
    reply_to: str | None = None,
    ref_table: RefTable | None = None,
) -> str:
    """Create a draft message on the specified source."""
    cfg = sources.get(source)
    if not cfg:
        # Try provider name
        by_prov = sources.by_provider(source)
        if by_prov:
            source = next(iter(by_prov))
            cfg = by_prov[source]
        else:
            return f"Error: unknown source {source!r}"

    adapter = _make_adapter(source, cfg)
    if not adapter:
        return f"Error: failed to create adapter for {source!r}"

    reply_id = None
    if reply_to:
        reply_id = _resolve_ref(reply_to, ref_table)

    try:
        async with adapter:
            result = await adapter.create_draft(
                to=to, subject=subject, body=body,
                reply_to_message_id=reply_id,
            )
        return f"Draft created: {result['id']} ({result['status']})"
    except PermissionError as e:
        return f"Error: {e}"
    except Exception as e:
        return f"Error creating draft: {e}"
```

- [ ] **Step 3: Run tests**

Run: `uv run pytest tests/test_manage_commands.py -v`
Expected: All pass

- [ ] **Step 4: Commit**

```bash
git add src/ts4k/commands.py tests/test_manage_commands.py
git commit -m "feat: add manage and draft command functions with batch and dry-run support"
```

### Task 10: Add CLI Commands

**Files:**
- Modify: `src/ts4k/cli.py`

- [ ] **Step 1: Add manage subcommand to CLI parser**

In `_build_parser()`, add after the `cache` subparser (around line 1003):

```python
    # --- manage / m ---
    mg = subparsers.add_parser(
        "manage", aliases=["m"],
        help="Manage mailbox (archive, label, mark read/unread, trash)",
        description="Non-destructive mailbox management. All actions are reversible. Requires source level >= modify.",
        epilog=(
            "actions:\n"
            "  archive      Remove from inbox (keep in mailbox)\n"
            "  unarchive    Return to inbox\n"
            "  label        Add a label/category (--label required)\n"
            "  unlabel      Remove a label/category (--label required)\n"
            "  read         Mark as read\n"
            "  unread       Mark as unread\n"
            "  trash        Move to trash (recoverable)\n"
            "  move         Move to folder (--folder required, O365 only)\n"
            "  list-labels  List available labels/categories\n"
            "\n"
            "examples:\n"
            "  ts4k m archive g:abc123              # archive one message\n"
            "  ts4k m archive g:abc,g:def,g:ghi     # batch archive\n"
            "  ts4k m label g:abc --label llm-garbage\n"
            "  ts4k m read g:abc,g:def              # mark batch as read\n"
            "  ts4k m list-labels g:any              # list labels for source g\n"
            "  ts4k m archive g:abc --dry-run        # preview without acting"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    mg.add_argument("action", choices=[
        "archive", "unarchive", "label", "unlabel",
        "read", "unread", "trash", "move", "list-labels",
    ])
    mg.add_argument("id", help="Message ID(s), comma-separated for batch")
    mg.add_argument("--label", "-l", help="Label/category name (for label/unlabel)")
    mg.add_argument("--folder", help="Folder name (for move, O365)")
    mg.add_argument("--dry-run", action="store_true", help="Preview actions without executing")
    mg.set_defaults(func=_cmd_manage)

    # --- draft / d ---
    dr = subparsers.add_parser(
        "draft", aliases=["d"],
        help="Create draft messages (never sends)",
        description="Create draft messages in your mailbox. Requires source level >= draft. ts4k NEVER sends messages.",
        epilog=(
            "examples:\n"
            '  ts4k d create -s g --to alice@x.com --subject "Hi" --body "Hello"\n'
            '  ts4k d create -s g --reply-to g:abc123 --body "Sounds good!"\n'
            '  ts4k d create -s o --to bob@co.com --subject "FYI" --body "See attached"'
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    dr_sub = dr.add_subparsers(dest="action")
    dr_create = dr_sub.add_parser("create", help="Create a new draft")
    dr_create.add_argument("--source", "-s", required=True, help="Source prefix (e.g. g, o)")
    dr_create.add_argument("--to", required=True, help="Recipient email address")
    dr_create.add_argument("--subject", default="", help="Subject line")
    dr_create.add_argument("--body", required=True, help="Message body text")
    dr_create.add_argument("--reply-to", help="Message ID to reply to (threads the draft)")
    dr.set_defaults(func=_cmd_draft)
```

- [ ] **Step 2: Add handler functions**

```python
async def _cmd_manage_async(args: argparse.Namespace) -> None:
    ref_table = _load_refs()
    result = await commands.manage_message(
        action=args.action,
        msg_id=args.id,
        label=getattr(args, "label", None),
        folder=getattr(args, "folder", None),
        dry_run=getattr(args, "dry_run", False),
        ref_table=ref_table,
    )
    print(result)

def _cmd_manage(args: argparse.Namespace) -> None:
    asyncio.run(_cmd_manage_async(args))

async def _cmd_draft_async(args: argparse.Namespace) -> None:
    action = getattr(args, "action", None)
    if action != "create":
        print("Usage: ts4k draft create --source S --to ADDR --subject SUBJ --body TEXT")
        return
    ref_table = _load_refs()
    result = await commands.create_draft(
        source=args.source,
        to=args.to,
        subject=args.subject,
        body=args.body,
        reply_to=getattr(args, "reply_to", None),
        ref_table=ref_table,
    )
    print(result)

def _cmd_draft(args: argparse.Namespace) -> None:
    asyncio.run(_cmd_draft_async(args))
```

- [ ] **Step 3: Commit**

```bash
git add src/ts4k/cli.py
git commit -m "feat: add manage and draft CLI commands"
```

### Task 11: Add MCP Tools

**Files:**
- Modify: `src/ts4k/server.py`

- [ ] **Step 1: Add manage and draft tools to MCP server**

Add after the `overview` tool in `server.py`:

```python
@mcp.tool()
async def manage(
    action: str,
    id: str,
    label: str | None = None,
    folder: str | None = None,
    dry_run: bool = False,
) -> str:
    """Manage mailbox: archive, label, mark read/unread, trash.

    All actions are non-destructive and reversible. Requires source
    level >= modify. Comma-separate IDs for batch operations.

    Args:
        action: archive, unarchive, label, unlabel, read, unread, trash, move, list-labels.
        id: Message ID(s), comma-separated for batch. Use short refs from listings.
        label: Label/category name (required for label/unlabel).
        folder: Folder name (required for move, O365 only).
        dry_run: Preview actions without executing (default false).
    """
    return await commands.manage_message(
        action=action,
        msg_id=id,
        label=label,
        folder=folder,
        dry_run=dry_run,
        ref_table=_refs,
    )


@mcp.tool()
async def draft(
    source: str,
    to: str,
    subject: str,
    body: str,
    reply_to: str | None = None,
) -> str:
    """Create a draft message. NEVER sends. Requires source level >= draft.

    When reply_to is provided, the draft threads as a reply with proper
    headers and blockquoted original message — indistinguishable from
    a manually composed reply.

    Args:
        source: Source prefix (e.g. "g", "o").
        to: Recipient email address.
        subject: Subject line (auto-prefixed with "Re:" for replies).
        body: Message body text.
        reply_to: Message ID or short ref to reply to (optional).
    """
    return await commands.create_draft(
        source=source,
        to=to,
        subject=subject,
        body=body,
        reply_to=reply_to,
        ref_table=_refs,
    )
```

- [ ] **Step 2: Run full test suite**

Run: `uv run pytest -x -v`
Expected: All pass

- [ ] **Step 3: Commit**

```bash
git add src/ts4k/server.py
git commit -m "feat: add manage and draft MCP tools"
```

### Task 12: Update Help and Skill Reference

**Files:**
- Modify: `src/ts4k/commands.py` (help text sections)

- [ ] **Step 1: Update help/skill reference to include manage and draft commands**

Find the help text sections in commands.py and add entries for `manage` and `draft`.
Also add a note about access levels in the source management section.

- [ ] **Step 2: Run full test suite one final time**

Run: `uv run pytest -x -v`
Expected: All pass

- [ ] **Step 3: Commit**

```bash
git add src/ts4k/commands.py
git commit -m "docs: add manage and draft to help and skill reference"
```

---

## Safety Verification (after all chunks complete)

- [ ] **Grep for send-related API calls to verify no-send invariant**

```bash
uv run python -c "
import subprocess, sys
patterns = ['messages.send', 'sendMail', '/send', 'def.*send_message', 'def.*send(']
found = False
for p in patterns:
    r = subprocess.run(['grep', '-rn', p, 'src/ts4k/'], capture_output=True, text=True)
    if r.stdout.strip():
        print(f'FOUND: {p}')
        print(r.stdout)
        found = True
if not found:
    print('PASS: No send-related API calls found.')
"
```

Expected: `PASS: No send-related API calls found.`
