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

# {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: draftreviewapprovedsent 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:

  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

{
  "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/:

  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
  • NAMEmailboxes/{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
  • NAMEmailboxes/{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

Fieldgmailprotonmail-bridgeimap (generic)
imap_hostimap.gmail.com127.0.0.1(required)
imap_port9931143993
imap_starttlsfalsetruefalse
smtp_hostsmtp.gmail.com127.0.0.1(required)
smtp_port4651025465
drafts_folder[Gmail]/DraftsDraftsDrafts

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.