> **Note:** This is the full functional specification for Corky.

# Corky Functional Specification

> Language-independent specification for the corky email sync and collaboration tool.
> This document captures the exact behavior a port must reproduce.

## 1. Overview

Corky syncs email threads from IMAP providers into a flat directory of Markdown files,
supports AI-assisted drafting, manages mailbox sharing via git submodules or plain directories,
and pushes routing intelligence to Cloudflare.

## 2. Data Directory

### 2.1 Layout

```
{data_dir}/
  conversations/        # One .md file per thread
  drafts/               # Outgoing email drafts
  contacts/             # Per-contact context
    {name}/
      AGENTS.md
      CLAUDE.md -> AGENTS.md
  mailboxes/            # Named mailboxes (plain dirs or git submodules)
    {name}/
      conversations/
      drafts/
      contacts/
      AGENTS.md
      CLAUDE.md -> AGENTS.md
      README.md
      voice.md
      .gitignore
  manifest.toml         # Thread index (generated by sync)
  .sync-state.json      # IMAP sync state
```

### 2.2 Resolution Order

The data directory is resolved at runtime in this order:
1. `mail/` directory in current working directory (developer workflow)
2. `CORKY_DATA` environment variable
3. App config mailbox (see §2.4)
4. `~/Documents/mail` (hardcoded fallback)

### 2.3 Config Directory

Config always lives inside the data directory (`mail/`).

Config files: `.corky.toml`, `voice.md`, `credentials.json`

### 2.4 App Config

Location: `{platformdirs.user_config_dir("corky")}/config.toml`
- Linux: `~/.config/corky/config.toml`
- macOS: `~/Library/Application Support/corky/config.toml`
- Windows: `%APPDATA%/corky/config.toml`

Stores named mailboxes (data directory references) and a default. Used in resolution step 3.

Mailbox resolution (when no explicit name given):
1. `default_mailbox` set → use that mailbox
2. Exactly one mailbox → use it implicitly
3. Multiple mailboxes, no default → error with list
4. No mailboxes → return None (fall through to step 4)

## 3. File Formats

### 3.1 Conversation Markdown

```markdown
# {Subject}

**Labels**: {label1}, {label2}
**Accounts**: {account1}, {account2}
**Thread ID**: {thread_key}
**Last updated**: {RFC 2822 date}

---

## {Sender Name} <{email}> — {RFC 2822 date}

**To**: {recipient1}, {recipient2}
**CC**: {cc1}

{Body text}

---

## {Reply sender} — {date}

{Body text}
```

Per-message `**To**:` and `**CC**:` lines are emitted after the message header when non-empty. Old files without these lines parse correctly (fields default to empty).

Metadata regex: `^\*\*(.+?)\*\*:\s*(.+)$` (multiline)
Message header regex: `^## (.+?) — (.+)$` (multiline, em dash U+2014)

### 3.2 Draft Markdown

```markdown
# {Subject}

**To**: {recipient}
**CC**: {optional}
**Status**: draft
**Author**: {name}
**Account**: {optional — account name from .corky.toml}
**From**: {optional — email address, used to resolve account}
**In-Reply-To**: {optional — message ID}

---

{Draft body}
```

Required fields: `# Subject` heading, `**To**`, `---` separator
Recommended fields: `**Status**`, `**Author**`
Status values: `draft` → `review` → `approved` → `sent`
Valid send statuses (for draft push --send): `review`, `approved`

### 3.3 .corky.toml

```toml
[owner]
github_user = "username"
name = "Display Name"

[accounts.{name}]
provider = "gmail"          # gmail | protonmail-bridge | imap
user = "you@gmail.com"
password = ""               # Inline password (not recommended)
password_cmd = ""           # Shell command to retrieve password
labels = ["correspondence"]
imap_host = ""              # Auto-filled by provider preset
imap_port = 993
imap_starttls = false
smtp_host = ""
smtp_port = 465
drafts_folder = "Drafts"
sync_days = 3650            # How far back to sync
default = false             # Mark one account as default

[contacts.{name}]
emails = ["addr@example.com"]

[routing]
for-alex = ["mailboxes/alex"]
shared = ["mailboxes/alice", "mailboxes/bob"]

[mailboxes.alex]
auto_send = false

[watch]
poll_interval = 300         # Seconds between polls
notify = false              # Desktop notifications
```

Password resolution order:
1. `password` field (inline)
2. `password_cmd` (run shell command, strip trailing whitespace)
3. Error if neither set

Label scoping syntax: `account:label` (e.g. `"proton-dev:INBOX"`) binds a label to a specific account.

### 3.4 .sync-state.json

```json
{
  "accounts": {
    "{account_name}": {
      "labels": {
        "{label_name}": {
          "uidvalidity": 12345,
          "last_uid": 67890
        }
      }
    }
  }
}
```

### 3.5 manifest.toml

```toml
[threads.{slug}]
subject = "Thread Subject"
thread_id = "thread key"
labels = ["label1", "label2"]
accounts = ["account1"]
last_updated = "RFC 2822 date"
contacts = ["contact-name"]
```

Generated after each sync by scanning conversation files and matching sender emails against `[contacts]` in `.corky.toml`.

### 3.6 config.toml (App Config)

```toml
default_mailbox = "personal"

[mailboxes.personal]
path = "~/Documents/mail"

[mailboxes.work]
path = "~/work/mail"
```

Top-level fields:
- `default_mailbox`: name of the default mailbox (set automatically to the first mailbox added)

Mailbox fields:
- `path`: absolute or `~`-relative path to a mail data directory

## 4. Algorithms

### 4.1 Thread Slug Generation

```
fn slugify(text: &str) -> String:
    text = text.to_lowercase()
    text = regex_replace(r"[^a-z0-9]+", "-", text)
    text = text.trim_matches('-')
    text = text[..min(60, text.len())]
    if text.is_empty(): return "untitled"
    return text
```

Slug collisions: If `{slug}.md` exists, try `{slug}-2.md`, `{slug}-3.md`, etc.

### 4.2 Thread Key Derivation

```
fn thread_key_from_subject(subject: &str) -> String:
    regex_replace(r"^(re|fwd?):\s*", "", subject.to_lowercase().trim())
```

Strips one layer of `Re:` or `Fwd:` prefix (case-insensitive), then lowercases.

### 4.3 Message Deduplication

Messages are deduplicated by `(from, date)` tuple. If both match an existing message in the thread, the message is skipped but labels/accounts metadata is still updated.

### 4.4 Multi-Source Accumulation

When the same thread is fetched from multiple labels or accounts:
- Labels are appended (no duplicates)
- Accounts are appended (no duplicates)
- Messages are merged and deduplicated
- Messages are sorted by parsed date

### 4.5 Label Routing

Labels in the `[routing]` section of `.corky.toml` route to configured mailbox directories.
Fan-out: one label can route to multiple mailboxes (array of paths).
Plain labels (no routing entry) route to `{data_dir}/conversations/`.
Routing values are paths like `mailboxes/{name}`, resolved relative to data_dir, with `/conversations/` appended.

Account:label syntax (`"proton-dev:INBOX"`):
- Only matches when syncing the named account
- The IMAP folder used is the part after the colon

### 4.6 Manifest Generation

After sync, scan all `.md` files in `conversations/`:
1. Parse each file back into a Thread object
2. For each message, extract emails from `from`, `to`, and `cc` fields (`<email>` regex)
3. Match against `[contacts]` email→name mapping in `.corky.toml`
4. Write `manifest.toml` with thread metadata and matched contacts

A contact appears in the manifest if they sent, received, or were CC'd on any message in the thread.

## 5. Commands

### 5.1 init

```
corky init --user EMAIL [PATH] [--provider PROVIDER]
             [--password-cmd CMD] [--labels LABEL,...] [--github-user USER]
             [--name NAME] [--mailbox-name NAME] [--sync] [--force]
```

- `PATH`: project directory (default: `.` — current directory)
- Creates `{path}/mail/{conversations,drafts,contacts}/` with `.gitkeep` files
- Generates `.corky.toml` at `{path}/mail/`
- Installs `voice.md` at `{path}/mail/` if not present
- If inside a git repo: adds `mail` to `.gitignore`
- Installs the email skill to `.claude/skills/email/`
- Registers the project dir as a named mailbox in app config
- `--force`: overwrite existing config; without it, exit 1 if `.corky.toml` exists
- `--sync`: set `CORKY_DATA` env, run sync
- `--provider`: `gmail` (default), `protonmail-bridge`, `imap`
- `--labels`: default `correspondence` (comma-separated)
- `--mailbox-name`: mailbox name to register (default: `"default"`)

### 5.1.1 install-skill

```
corky install-skill NAME
```

- Install an agent skill into the current directory
- Currently supported: `email` (installs `.claude/skills/email/SKILL.md` and `README.md`)
- Skips files that already exist (never overwrites)
- Works from any directory (mailbox repos ship the skill automatically via `mb add`/`mb reset`)

### 5.2 sync

```
corky sync                   # incremental IMAP sync (default)
corky sync full              # full IMAP resync (ignore saved state)
corky sync account NAME      # sync one account
corky sync routes            # apply routing to existing conversations
corky sync mailbox [NAME]    # push/pull shared mailboxes
```

Bare `corky sync` runs incremental IMAP sync for all configured accounts.
Subcommands:
- `full`: ignore saved state, re-fetch all messages within `sync_days`
- `account NAME`: sync only the named account
- `routes`: apply `[routing]` rules to existing `conversations/*.md` files,
  copying matching threads into mailbox `conversations/` directories
- `mailbox [NAME]`: git push/pull shared mailbox repos (alias for `mailbox sync`)

Exit code: 0 on success.

### 5.3 sync-auth

```
corky sync-auth
```

Gmail OAuth setup. Requires `credentials.json` from Google Cloud Console.
Runs a local server on port 3000 for the OAuth callback.
Outputs the refresh token for `.env`.

### 5.4 list-folders

```
corky list-folders [ACCOUNT]
```

Without argument: lists available account names.
With argument: connects to IMAP and lists all folders with flags.

### 5.5 draft push

```
corky draft push FILE [--send]
corky mailbox draft push FILE [--send]
```

Alias: `corky push-draft` (hidden, backwards-compatible).

Default: creates a draft via IMAP APPEND to the drafts folder.
`--send`: sends via SMTP. Requires Status to be `review` or `approved`.
After sending, updates Status field in the file to `sent`.

Account resolution for sending:
1. `**Account**` field → match by name in `.corky.toml`
2. `**From**` field → match by email address
3. Fall back to default account

### 5.6 add-label

```
corky add-label LABEL --account NAME
```

Text-level TOML edit to add a label to an account's `labels` array.
Preserves comments and formatting. Returns false if label already present.

### 5.7 contact-add (hidden alias)

```
corky contact-add NAME --email EMAIL [--email EMAIL2]
```

Hidden backward-compatible alias for `contact add`. The `--label` and `--account` flags are accepted but ignored.

### 5.8 watch

```
corky watch [--interval N]
```

IMAP polling daemon. Syncs all accounts, then pushes to shared mailboxes.
Desktop notifications on new messages if `notify = true` in `.corky.toml`.
Clean shutdown on SIGTERM/SIGINT.

### 5.9 audit-docs

```
corky audit-docs
```

Checks instruction files (AGENTS.md, README.md, SKILL.md) against codebase:
- Referenced paths exist on disk
- `uv run` scripts are registered
- Type conventions (msgspec, not dataclasses)
- Combined line budget (1000 lines max)
- Staleness (docs older than source)

### 5.10 help

```
corky help [FILTER]
corky --help
```

Shows command reference. Optional filter matches command names.

### 5.11 mailbox add

```
corky mailbox add NAME --label LABEL [--name NAME] [--github] [--pat] [--public] [--account ACCT] [--org ORG]
```

Alias: `corky mb add`

Without `--github`: creates a plain directory at `mailboxes/{name}/` with conversations/drafts/contacts subdirectories and template files (AGENTS.md, README.md, voice.md, .gitignore).
With `--github`: creates a private GitHub repo (`{org}/to-{name}`), initializes with template files, adds as git submodule at `mailboxes/{name}/`. Updates `.corky.toml`.

`--github`: use a git submodule instead of a plain directory
`--pat`: PAT-based access (prints instructions instead of GitHub collaborator invite)
`--public`: public repo visibility
`--org`: override GitHub org (default: owner's github_user)

### 5.12 mailbox sync

```
corky mailbox sync [NAME]
```

Alias: `corky mb sync`

For each mailbox (or one named): git pull --rebase, copy voice.md if newer, sync GitHub Actions workflow, stage+commit+push local changes, update submodule ref in parent. Skips git ops for plain (non-submodule) directories.

### 5.13 mailbox status

```
corky mailbox status
```

Alias: `corky mb status`

Shows incoming/outgoing commit counts for each mailbox submodule.

### 5.14 mailbox remove

```
corky mailbox remove NAME [--delete-repo]
```

Alias: `corky mb remove`

For plain directories: `rm -rf mailboxes/{name}/`.
For submodules: `git submodule deinit -f`, `git rm`, clean up `.git/modules/{path}`.
Removes from `.corky.toml`.
`--delete-repo`: interactively confirms, then deletes GitHub repo via `gh`.

### 5.15 mailbox rename

```
corky mailbox rename OLD NEW [--rename-repo]
```

Alias: `corky mb rename`

Moves `mailboxes/{old}` to `mailboxes/{new}`. Uses `git mv` for submodules, `mv` for plain dirs.
Updates `.corky.toml`.
`--rename-repo`: also rename the GitHub repo via `gh repo rename`.

### 5.16 mailbox reset

```
corky mailbox reset [NAME] [--no-sync]
```

Alias: `corky mb reset`

Pull latest, regenerate all template files (AGENTS.md, README.md, CLAUDE.md symlink, .gitignore, voice.md, notify.yml) at `mailboxes/{name}/`, commit, push.
`--no-sync`: regenerate files without pull/push.

### 5.17 unanswered

```
corky unanswered [SCOPE] [--from NAME]
corky mailbox unanswered [SCOPE] [--from NAME]
```

Alias: `corky find-unanswered` (hidden, backwards-compatible).

Scans conversations for threads where the last message sender doesn't match `--from`.

Scope argument:
- Omitted → scan root `conversations/` + all `mailboxes/*/conversations/`
- `.` → root `conversations/` only
- `NAME` → `mailboxes/{name}/conversations/` only

`--from` resolution: CLI flag > `[owner] name` in `.corky.toml` > error.

Output is grouped by scope when scanning multiple directories.

Sender regex: `^## (.+?) —` (multiline, em dash)

### 5.18 draft validate

```
corky draft validate [FILE|SCOPE...]
corky mailbox draft validate [FILE|SCOPE...]
```

Alias: `corky validate-draft` (hidden, backwards-compatible).

Validates draft files. Checks: subject heading, required fields (To), recommended fields (Status, Author), valid status value, `---` separator, non-empty body.

Scope argument (when no files given):
- Omitted → scan root `drafts/` + all `mailboxes/*/drafts/`
- `.` → root `drafts/` only
- `NAME` → `mailboxes/{name}/drafts/` only

Exit code: 0 if all valid, 1 if any errors.

### 5.19 mailbox list

```
corky mailbox list
```

Lists all registered mailboxes with paths. Marks the default mailbox. If no mailboxes configured, prints setup instructions.

### 5.20 Global `--mailbox` Flag

```
corky --mailbox NAME <subcommand> [args...]
```

Available on all commands. Resolves the named mailbox via app config and sets `CORKY_DATA` before dispatching to the subcommand.

### 5.21 draft new

```
corky draft new SUBJECT --to EMAIL [--cc EMAIL] [--account NAME] [--from EMAIL]
                [--in-reply-to MSG-ID] [--mailbox NAME]
corky mailbox draft new SUBJECT --to EMAIL [...]
```

Scaffolds a new draft file with pre-filled metadata.

Output: creates `drafts/YYYY-MM-DD-{slug}.md` and prints the path.

- `--mailbox NAME`: create in `mailboxes/{name}/drafts/` instead of root `drafts/`
- `--cc`: CC recipient
- `--account`: sending account name from `.corky.toml`
- `--from`: sending email address
- `--in-reply-to`: message ID for threading
- Author resolved from `[owner] name` in `.corky.toml`
- Slug collisions handled with `-2`, `-3` suffix (same as sync)

### 5.22 contact add

```
corky contact add NAME --email EMAIL [--email EMAIL2]
corky contact add --from SLUG [NAME]
```

Creates `{data_dir}/contacts/{name}/` with `AGENTS.md` template and `CLAUDE.md` symlink.
Updates `.corky.toml` with the contact's email addresses.

Manual mode (`--email`): requires `NAME` positional. Creates contact with default AGENTS.md.

From-conversation mode (`--from`):
1. Find `conversations/{slug}.md` or `mailboxes/*/conversations/{slug}.md`
2. Parse thread, extract non-owner participants from `from`, `to`, `cc` fields
3. Filter owner emails via `accounts.*.user` in `.corky.toml`
4. Single participant: auto-derive name from display name (slugified)
5. Multiple participants: require positional `NAME` to select one
6. Generate enriched AGENTS.md with pre-filled Topics, Formality, Tone, and Research sections

`--from` and `--email` conflict (clap `conflicts_with`).

### 5.23 contact info

```
corky contact info NAME
```

Aggregates and displays contact information:
1. Contact config from `.corky.toml` (emails)
2. `contacts/{name}/AGENTS.md` content
3. Matching threads from `manifest.toml` (root) and `mailboxes/*/manifest.toml`
4. Summary: thread count, last activity date

Threads are matched where the `contacts` array in manifest contains `NAME`.

## 6. Sync Algorithm

### 6.1 State

Per-account, per-label state: `(uidvalidity: u32, last_uid: u32)`

### 6.2 Incremental Sync

For each account, for each label:
1. `SELECT` the IMAP folder
2. Check `UIDVALIDITY` — if changed from stored value, do full sync
3. If incremental: `SEARCH UID {last_uid+1}:*`, filter out `<= last_uid`
4. If full: `SEARCH SINCE {today - sync_days}`
5. For each UID: `FETCH RFC822`, parse email, merge to thread file
6. Update `(uidvalidity, last_uid)` in state

### 6.3 Message Parsing

From RFC822:
- Subject: `email.header.decode_header()` (handles encoded words)
- From: `email.header.decode_header()`
- To: `email.header.decode_header()` (comma-separated recipients)
- CC: `email.header.decode_header()` (comma-separated recipients)
- Date: raw header string
- Body: walk multipart for `text/plain` without `Content-Disposition`, or get payload for non-multipart
- Thread key: `thread_key_from_subject(subject)`

### 6.4 Merge

For each message:
1. Find existing thread file by scanning `**Thread ID**` metadata in all `.md` files
2. If found, parse back into Thread object
3. Check dedup: `(from, date)` tuple
4. If new: append message, sort by date, update `last_date`
5. Accumulate labels and accounts
6. Write markdown, set file mtime to last message date

### 6.5 Orphan Cleanup

On `--full` sync: track all files written/updated. After sync, delete any `.md` files in `conversations/` not in the touched set.

### 6.6 State Persistence

State is saved only after all accounts complete successfully. If sync crashes mid-way, state is not saved — next run re-fetches.

## 7. Mailbox Lifecycle

### 7.1 Add

Without `--github` (plain directory):
1. Create `mailboxes/{name}/` with conversations/drafts/contacts subdirectories
2. Write template files (AGENTS.md, CLAUDE.md symlink, README.md, voice.md, .gitignore, `.claude/skills/email/`)
3. Update `.corky.toml`

With `--github` (submodule):
1. Create GitHub repo (`gh repo create`)
2. Add collaborator (`gh api repos/.../collaborators/...`) or print PAT instructions
3. Clone to temp dir, write template files, commit, push
4. Add as git submodule at `mailboxes/{name}/`
5. Update `.corky.toml`

### 7.2 Sync

1. `git pull --rebase` in submodule (skipped for plain directories)
2. Copy `voice.md` if root copy is newer
3. Sync workflow template if newer
4. Stage, commit, push local changes (skipped for plain directories)
5. Update submodule ref in parent (`git add {submodule_path}`) (skipped for plain directories)

### 7.3 Status

For each mailbox submodule:
1. `git fetch`
2. `git rev-list --count HEAD..@{u}` (incoming)
3. `git rev-list --count @{u}..HEAD` (outgoing)

### 7.4 Remove

For plain directories: `rm -rf mailboxes/{name}/`.
For submodules:
1. `git submodule deinit -f`
2. `git rm -f`
3. Clean up `.git/modules/{path}`

Then:
4. Remove from `.corky.toml`
5. Optionally delete GitHub repo (interactive confirmation)

### 7.5 Rename

1. Move `mailboxes/{old}` to `mailboxes/{new}` (`git mv` for submodules, `mv` for plain dirs)
2. Optionally `gh repo rename`
3. Update `.corky.toml` entry

### 7.6 Reset

1. `git pull --rebase` (submodules only)
2. Regenerate: AGENTS.md, CLAUDE.md (symlink), README.md, .gitignore, voice.md, `.claude/skills/email/` at `mailboxes/{name}/`
3. Stage, commit, push (submodules only)
4. Update submodule ref in parent (submodules only)

## 8. Draft Lifecycle

### 8.1 Create

Manual: create file in `{data_dir}/drafts/` or `{data_dir}/mailboxes/{name}/drafts/`.
Filename convention: `YYYY-MM-DD-{slug}.md`.

### 8.2 Validate

`corky draft validate` checks format. See section 5.18.

### 8.3 Push / Send

`corky draft push FILE`: IMAP APPEND to drafts folder.
`corky draft push FILE --send`: SMTP send, update Status to `sent`.

Account resolution: Account field → From field → default account.

## 9. Watch Daemon

### 9.1 Poll Loop

```
while not shutdown:
    for each account:
        sync_account(full=false)
    save_state()
    count_new = compare uid snapshots before/after
    if count_new > 0:
        sync_mailboxes()
        notify(count_new)
    wait(interval) or shutdown
```

### 9.2 Signals

SIGTERM, SIGINT → clean shutdown (finish current poll, then exit).

### 9.3 Notifications

- macOS: `osascript -e 'display notification ...'`
- Linux: `notify-send`
- Silently degrades if tool not installed.

### 9.4 Config

`[watch]` section in `.corky.toml`:
- `poll_interval`: seconds (default 300)
- `notify`: bool (default false)

CLI `--interval` overrides config.

## 10. Provider Presets

| Field | `gmail` | `protonmail-bridge` | `imap` (generic) |
|---|---|---|---|
| imap_host | imap.gmail.com | 127.0.0.1 | (required) |
| imap_port | 993 | 1143 | 993 |
| imap_starttls | false | true | false |
| smtp_host | smtp.gmail.com | 127.0.0.1 | (required) |
| smtp_port | 465 | 1025 | 465 |
| drafts_folder | [Gmail]/Drafts | Drafts | Drafts |

Preset values are defaults — any field explicitly set on the account wins.

## 11. Account Resolution

### 11.1 Password

1. `password` field (inline string)
2. `password_cmd` (shell command, capture stdout, strip trailing whitespace)
3. Raise error if neither set

### 11.2 Sending Account

For `draft push`:
1. `**Account**` metadata field → lookup by name in `.corky.toml`
2. `**From**` metadata field → lookup by email address (case-insensitive)
3. Default account (first with `default = true`, or first in file)
4. Credential bubbling (see §11.3)

### 11.3 Credential Bubbling

When a draft lives inside a `mailboxes/` subtree, the child mailbox may not have its own IMAP/SMTP credentials. Corky resolves credentials bottom-up:

1. Check the leaf mailbox's `.corky.toml` for matching account credentials
2. Walk parent directories upward, checking each `.corky.toml` for an account whose `user` matches the `**From**` address
3. First match wins
4. If no credentials found at any level, bail with error

This enables child mailboxes to draft replies that the parent's account sends.

