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:
mail/directory in current working directory (developer workflow)CORKY_DATAenvironment variable- App config mailbox (see §2.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):
default_mailboxset → use that mailbox- Exactly one mailbox → use it implicitly
- Multiple mailboxes, no default → error with list
- No mailboxes → return None (fall through to step 4)
3. File Formats
3.1 Conversation 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
# {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
[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:
passwordfield (inline)password_cmd(run shell command, strip trailing whitespace)- 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
{
"accounts": {
"{account_name}": {
"labels": {
"{label_name}": {
"uidvalidity": 12345,
"last_uid": 67890
}
}
}
}
}
3.5 manifest.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)
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/:
- Parse each file back into a Thread object
- For each message, extract emails from
from,to, andccfields (<email>regex) - Match against
[contacts]email→name mapping in.corky.toml - Write
manifest.tomlwith 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.gitkeepfiles - Generates
.corky.tomlat{path}/mail/ - Installs
voice.mdat{path}/mail/if not present - If inside a git repo: adds
mailto.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.tomlexists--sync: setCORKY_DATAenv, run sync--provider:gmail(default),protonmail-bridge,imap--labels: defaultcorrespondence(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.mdandREADME.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 withinsync_daysaccount NAME: sync only the named accountroutes: apply[routing]rules to existingconversations/*.mdfiles, copying matching threads into mailboxconversations/directoriesmailbox [NAME]: git push/pull shared mailbox repos (alias formailbox 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:
**Account**field → match by name in.corky.toml**From**field → match by email address- 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 runscripts 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/+ allmailboxes/*/conversations/ .→ rootconversations/onlyNAME→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/+ allmailboxes/*/drafts/ .→ rootdrafts/onlyNAME→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 inmailboxes/{name}/drafts/instead of rootdrafts/--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] namein.corky.toml - Slug collisions handled with
-2,-3suffix (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):
- Find
conversations/{slug}.mdormailboxes/*/conversations/{slug}.md - Parse thread, extract non-owner participants from
from,to,ccfields - Filter owner emails via
accounts.*.userin.corky.toml - Single participant: auto-derive name from display name (slugified)
- Multiple participants: require positional
NAMEto select one - 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:
- Contact config from
.corky.toml(emails) contacts/{name}/AGENTS.mdcontent- Matching threads from
manifest.toml(root) andmailboxes/*/manifest.toml - 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:
SELECTthe IMAP folder- Check
UIDVALIDITY— if changed from stored value, do full sync - If incremental:
SEARCH UID {last_uid+1}:*, filter out<= last_uid - If full:
SEARCH SINCE {today - sync_days} - For each UID:
FETCH RFC822, parse email, merge to thread file - 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/plainwithoutContent-Disposition, or get payload for non-multipart - Thread key:
thread_key_from_subject(subject)
6.4 Merge
For each message:
- Find existing thread file by scanning
**Thread ID**metadata in all.mdfiles - If found, parse back into Thread object
- Check dedup:
(from, date)tuple - If new: append message, sort by date, update
last_date - Accumulate labels and accounts
- 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):
- Create
mailboxes/{name}/with conversations/drafts/contacts subdirectories - Write template files (AGENTS.md, CLAUDE.md symlink, README.md, voice.md, .gitignore,
.claude/skills/email/) - Update
.corky.toml
With --github (submodule):
- Create GitHub repo (
gh repo create) - Add collaborator (
gh api repos/.../collaborators/...) or print PAT instructions - Clone to temp dir, write template files, commit, push
- Add as git submodule at
mailboxes/{name}/ - Update
.corky.toml
7.2 Sync
git pull --rebasein submodule (skipped for plain directories)- Copy
voice.mdif root copy is newer - Sync workflow template if newer
- Stage, commit, push local changes (skipped for plain directories)
- Update submodule ref in parent (
git add {submodule_path}) (skipped for plain directories)
7.3 Status
For each mailbox submodule:
git fetchgit rev-list --count HEAD..@{u}(incoming)git rev-list --count @{u}..HEAD(outgoing)
7.4 Remove
For plain directories: rm -rf mailboxes/{name}/.
For submodules:
git submodule deinit -fgit rm -f- Clean up
.git/modules/{path}
Then:
4. Remove from .corky.toml
5. Optionally delete GitHub repo (interactive confirmation)
7.5 Rename
- Move
mailboxes/{old}tomailboxes/{new}(git mvfor submodules,mvfor plain dirs) - Optionally
gh repo rename - Update
.corky.tomlentry
7.6 Reset
git pull --rebase(submodules only)- Regenerate: AGENTS.md, CLAUDE.md (symlink), README.md, .gitignore, voice.md,
.claude/skills/email/atmailboxes/{name}/ - Stage, commit, push (submodules only)
- 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
passwordfield (inline string)password_cmd(shell command, capture stdout, strip trailing whitespace)- Raise error if neither set
11.2 Sending Account
For draft push:
**Account**metadata field → lookup by name in.corky.toml**From**metadata field → lookup by email address (case-insensitive)- Default account (first with
default = true, or first in file) - 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:
- Check the leaf mailbox's
.corky.tomlfor matching account credentials - Walk parent directories upward, checking each
.corky.tomlfor an account whoseusermatches the**From**address - First match wins
- If no credentials found at any level, bail with error
This enables child mailboxes to draft replies that the parent's account sends.