# 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}
{Body text}
---
## {Reply sender} — {date}
{Body text}
```
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 push-draft --send): `review`, `approved`
### 3.3 .corky.toml
```toml
[owner]
github_user = "username"
name = "Display Name"
[accounts.{name}]
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"]
labels = ["correspondence"] # For lookup, not sync routing
account = "personal" # Optional
[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 email from sender (`<email>` regex)
3. Match against `[contacts]` email→name mapping in `.corky.toml`
4. Write `manifest.toml` with thread metadata and matched contacts
## 5. Commands
### 5.1 init
```
corky init --user EMAIL [PATH] [--with-skill] [--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`
- `--with-skill`: install 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)
### 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 push-draft
```
corky push-draft FILE [--send]
```
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
```
corky contact-add NAME --email EMAIL [--email EMAIL2] [--label LABEL] [--account ACCT]
```
Creates `{data_dir}/contacts/{name}/` with `AGENTS.md` template and `CLAUDE.md` symlink.
Updates `.corky.toml`. If both `--label` and `--account` given, adds label to account config.
### 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 find-unanswered
```
corky find-unanswered [--from NAME]
```
Scans `conversations/` for threads where the last message sender doesn't match `--from` (default: "Brian"). Designed to run in a mailbox directory.
Sender regex: `^## (.+?) —` (multiline, em dash)
### 5.18 validate-draft
```
corky validate-draft FILE [FILE...]
```
Validates draft files. Checks: subject heading, required fields (To), recommended fields (Status, Author), valid status value, `---` separator, non-empty body.
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.
## 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()`
- 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)
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, workflow 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 validate-draft` checks format. See section 5.18.
### 8.3 Push / Send
`corky push-draft FILE`: IMAP APPEND to drafts folder.
`corky push-draft 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
| 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 `push-draft`:
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)