Vibecode Mimestream
track this build5 phases, 15 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Mimestream, build a native Mac Gmail client over standards-based APIs with local cache. The hard boundary is deep gmail api use, native macos polish, sync reliability, and support, plus mail infrastructure, integrations, and client polish.
You are building a lean indie version of Mimestream. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Mimestream · indie build A native client over an existing mailbox that syncs incrementally rather than refetching, stores messages locally so search and scrolling are instant, models labels honestly including a message in several at once, and sends through the account's normal service so deliverability stays someone else's problem. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 with AppKit | the point of building this is native responsiveness with a large local mailbox | | Sync | The provider's history API for incremental changes | refetching a mailbox is slow, rate limited and unnecessary when the provider offers a change feed | | Storage | SQLite with full text search | a hundred thousand messages is small for a database and impossible to keep in memory | | Auth | The account's own authorisation flow, tokens in the keychain | you never see a password and the user can revoke access from their account settings | | Sending | The account's normal sending service | deliverability, signing and reputation stay with the provider, which is where they belong | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac running macOS 13 or newer** · free if you own one - Why: The reason to build this is native performance, which exists nowhere else. - Get it: Apple menu, then About This Mac. - Verify: About This Mac reports macOS 13 or higher - [ ] **Xcode 16 with a Swift 6 toolchain** · free - Why: Builds the client and provides Instruments for the list and search work. - Get it: Mac App Store or developer.apple.com/xcode. - Verify: xcodebuild -version prints 16 or higher - [ ] **Provider API credentials for the authorisation flow** · free for personal use; verification may be required for wider distribution - Why: Without them the client cannot connect to a real mailbox, and a test mailbox does not have the label complexity that matters. - Get it: Create a project in the provider's developer console, enable the mail scopes you need, and configure the redirect. Some providers review applications requesting broad mail access, which takes time. - Verify: The flow completes and returns a token - [ ] **A real mailbox with tens of thousands of messages** · free - Why: Sync, search and list performance are all trivial on a small mailbox and decisive on a real one, and label complexity only appears in mail people actually use. - Get it: Your own account. If it is small, generate a large fixture as well, but test against the real one for label behaviour. - Verify: The account connects and reports its message count - [ ] **Apple Developer Program membership** (optional) · 99 US dollars per year - Why: Product phase only. Without it there is no notarization and a warning on other machines. - Get it: Enrol at developer.apple.com/programs. - Verify: A Developer ID Application certificate appears in Xcode settings ## Quick start ```sh xcrun notarytool submit Mail.zip --keychain-profile AC --wait ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Deep provider-specific feature support. - Mail hosting and deliverability. - Push notifications across platforms. - Support for other mail providers. - deep Gmail API use, native macOS polish, sync reliability, and support - mail hosting and deliverability - push on every platform - advanced team collaboration - provider-specific AI and search If one of those is essential to you, that is the reason to keep paying for Mimestream, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Mimestream The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a fast native mail client for an existing Gmail account, built around labels rather than folders to replace Mimestream. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Do not model Gmail as folders. A message has labels, several at once, and the inbox is one of them. Archiving removes a label rather than moving anything, and a message can be in three places because that is what labels mean. Clients that map labels onto folders behave wrongly in ways users notice immediately: archiving loses a message from a view where it should still appear, and a message shown twice looks like a bug. Model labels as they are and the rest of the client follows. ### Stack (fixed, do not substitute) - Language: Swift 6 with AppKit · the point of building this is native responsiveness with a large local mailbox - Sync: The provider's history API for incremental changes · refetching a mailbox is slow, rate limited and unnecessary when the provider offers a change feed - Storage: SQLite with full text search · a hundred thousand messages is small for a database and impossible to keep in memory - Auth: The account's own authorisation flow, tokens in the keychain · you never see a password and the user can revoke access from their account settings - Sending: The account's normal sending service · deliverability, signing and reputation stay with the provider, which is where they belong ### Data model (create this before Phase 1) - accounts(id, address, token_ref, history_id, last_synced_at) - messages(id, account_id, provider_id, thread_id, from_addr, to_json, subject, snippet, received_at, size, raw_key) - labels(id, account_id, provider_id, name, kind), message_labels(message_id, label_id) · many to many, which is the point - threads(id, account_id, provider_id, subject, last_message_at, message_count) - messages_fts · full text over subject and body - pending_actions(id, kind, message_id, label_id, created_at, attempts, last_error) · the outbox that makes offline actions safe - A local action is applied immediately and queued for the server. The queue is what makes the client feel instant without lying. ### Phase 1 · Authorising without touching a password Build: Implement the provider's authorisation flow with the narrowest scopes that do what you need. Store tokens in the keychain. Handle refresh, and handle revocation from the account side without losing local data. Trap: Requesting broad scopes because it is simpler means the consent screen asks for more than you need and users refuse, and it means a token leak is worse than it had to be. Request the narrowest scopes and add one only when a feature actually needs it. Done when: The account connects through its own flow and no password is ever entered in your application. Tokens are in the keychain and absent from the database, verified by inspection. A revoked authorisation produces a clear reconnect prompt rather than repeated failures. Token refresh happens without user action. Do not build yet: No message sync, labels or search. ### Phase 2 · Incremental sync Build: Do one bounded initial sync, then use the provider's change feed to pull only what changed since the last recorded point. Handle the case where the change point is too old and a full resynchronisation is required. Store the raw message so nothing needs refetching for display. Trap: A change cursor expires after a period, and a client that treats an expired cursor as an error stops syncing silently until someone notices missing mail. Detect the specific response and fall back to a full resynchronisation rather than failing. Done when: An initial sync of a large mailbox completes within a stated time and reports progress. Subsequent syncs transfer only changes, verified by comparing request counts. An expired change cursor triggers a full resynchronisation rather than an error. A message deleted on the server disappears locally. Do not build yet: No labels modelled properly yet. ### Phase 3 · Labels modelled as labels Build: Model the message to label relationship as many to many. Archiving removes the inbox label. A label view is a query. Show every label a message carries rather than picking one to display. Trap: Mapping labels to a single folder column forces a choice about which label a message lives in, and every subsequent behaviour is wrong: archiving from one view removes it from another, and a message in two labels appears in one. This is the modelling decision that cannot be corrected later without a rewrite. Done when: A message with three labels appears in all three views. Archiving removes it from the inbox and leaves it in the others. A message shows every label it carries. Removing the last label behaves as the provider defines rather than deleting locally. Do not build yet: No search or composing yet. ### Phase 4 · Instant search and scrolling Build: Full text search over locally stored messages, updated as they arrive. Paged thread lists rendering rows rather than loading everything. Generate a large fixture mailbox before optimising. Trap: Loading a whole thread list into memory works until a label has thirty thousand messages, and then the client stalls on opening it. Page the list and render rows, which is what a native list view is for and the reason to build this natively at all. Done when: Search over a hundred thousand messages returns in well under a hundred milliseconds. Opening a label with thirty thousand messages is instant and memory stays stable while scrolling. Search results include body text, not only subjects. A newly arrived message is searchable within a second. Do not build yet: No composing or offline actions yet. ### Phase 5 · Actions that work before the network does Build: Apply label changes locally at once and queue the server action. Drain the queue with retries. Reconcile when the server disagrees, preferring the server and telling the user rather than silently reverting. Trap: Applying locally and never reconciling means a failed action leaves the client showing a state the server does not have, and the message reappears days later with no explanation. Queue with retries, reconcile against the server on the next sync, and surface a conflict rather than flipping the message back silently. Done when: Archiving with networking disabled updates the view immediately and the action applies when connectivity returns. A permanently failing action is surfaced rather than retried forever. A server-side change that conflicts with a queued action is reconciled with a visible note. Quitting with queued actions preserves them. Do not build yet: No composing or distribution yet. ### Phase 6 · Composing, sending and shipping it Build: Compose and reply through the account's own sending service, preserving threading headers so replies thread correctly everywhere. Sign and notarize. State plainly what scopes were granted and what the application does with the mailbox. Trap: Dropping the reply headers breaks threading in every other client the recipient might use, and it looks like a new conversation to them. Preserve the message identifier chain on every reply, and verify by replying to yourself and checking the thread in a different client. Done when: A reply threads correctly when viewed in a different mail client. Sending happens through the account's service so the message appears in its sent items. A notarized build opens on a fresh Mac with no Gatekeeper warning. The README states which scopes are requested and why. Do not build yet: Nothing after this. ### Out of scope (and why) - Deep use of provider-specific features, which is where the commercial client's advantage lives and which is a long tail of behaviour. - Mail hosting and deliverability. Sending stays with the account's provider deliberately, and that is the right answer. - Push notification support on every platform, which needs infrastructure per platform. - Support for other mail providers, each of which has a different sync model and its own quirks. ### README must contain - That labels are modelled as labels and archiving removes one rather than moving a message - That sync is incremental with a documented full resynchronisation fallback - That tokens live in the keychain and no password is ever entered - That actions apply locally and queue, with conflicts reconciled visibly - Which authorisation scopes are requested and what each is used for ===== AGENTS.md ===== # Agent instructions · Mimestream indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Swift 6 with AppKit, The provider's history API for incremental changes, SQLite with full text search, The account's own authorisation flow, tokens in the keychain, The account's normal sending service. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Requesting broad scopes because it is simpler means the consent screen asks for more than you need and users refuse, and it means a token leak is worse than it had to be. Request the narrowest scopes and add one only when a feature actually needs it. - A change cursor expires after a period, and a client that treats an expired cursor as an error stops syncing silently until someone notices missing mail. Detect the specific response and fall back to a full resynchronisation rather than failing. - Mapping labels to a single folder column forces a choice about which label a message lives in, and every subsequent behaviour is wrong: archiving from one view removes it from another, and a message in two labels appears in one. This is the modelling decision that cannot be corrected later without a rewrite. - Loading a whole thread list into memory works until a label has thirty thousand messages, and then the client stalls on opening it. Page the list and render rows, which is what a native list view is for and the reason to build this natively at all. - Applying locally and never reconciling means a failed action leaves the client showing a state the server does not have, and the message reappears days later with no explanation. Queue with retries, reconcile against the server on the next sync, and surface a conflict rather than flipping the message back silently. ===== BUILD_PLAN.md ===== # Build plan · Mimestream A native client over an existing mailbox that syncs incrementally rather than refetching, stores messages locally so search and scrolling are instant, models labels honestly including a message in several at once, and sends through the account's normal service so deliverability stays someone else's problem. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Authorising without touching a password The account is connected through its own authorisation flow and the token is in the keychain. ### Steps 1. Implement the authorisation flow Narrowest scopes. Add scopes when a feature needs one, not in advance. Files: `Auth.swift` 2. Store tokens in the keychain Referenced from the database, never stored in it. Inspect the file to confirm. Files: `Keychain.swift` 3. Handle revocation gracefully A clear reconnect prompt, local data intact. Files: `Auth.swift` ### Done when - [ ] No password is entered in the application - [ ] Tokens are in the keychain and absent from the database - [ ] Revocation produces a reconnect prompt - [ ] Refresh happens without user action ### Watch out - Requesting broad scopes because it is simpler means the consent screen asks for more than you need and users refuse, and it means a token leak is worse than it had to be. Request the narrowest scopes and add one only when a feature actually needs it. ## Phase 2 · Incremental sync A mailbox with a hundred thousand messages syncs changes rather than refetching. ### Steps 1. Bound the initial sync Recent mail first so the client is usable in a minute, older mail backfilled after. Files: `Sync.swift` 2. Use the change feed Pull by history point. Store the point after each successful batch, not at the end. Files: `Sync.swift` 3. Handle an expired cursor Full resynchronisation, tested by corrupting the stored point deliberately. Files: `Sync.swift` ### Done when - [ ] Initial sync completes in a stated time with progress - [ ] Later syncs transfer only changes - [ ] An expired cursor triggers full resynchronisation - [ ] A server-side deletion disappears locally ### Watch out - A change cursor expires after a period, and a client that treats an expired cursor as an error stops syncing silently until someone notices missing mail. Detect the specific response and fall back to a full resynchronisation rather than failing. ## Phase 3 · Labels modelled as labels A message in three labels appears in all three, and archiving removes one rather than moving anything. ### Steps 1. Model many to many A join table. Not a folder column with a favourite label. Files: `Labels.swift` 2. Make archiving a label removal Inbox is a label. Archiving removes it and nothing else. Files: `Actions.swift` 3. Show all labels on a message Rather than choosing one. Users know their messages are in several places. Files: `MessageView.swift` ### Done when - [ ] A message with three labels appears in all three views - [ ] Archiving removes only the inbox label - [ ] Every label on a message is shown - [ ] Removing the last label matches provider behaviour ### Watch out - Mapping labels to a single folder column forces a choice about which label a message lives in, and every subsequent behaviour is wrong: archiving from one view removes it from another, and a message in two labels appears in one. This is the modelling decision that cannot be corrected later without a rewrite. ## Phase 4 · Instant search and scrolling Searching a hundred thousand messages is faster than the web client. ### Steps 1. Generate a large fixture mailbox A hundred thousand messages. Every performance claim depends on it existing first. Files: `Fixtures.swift` 2. Index on arrival Full text over subject and body, updated as messages sync. Files: `Search.swift` 3. Page the list Render rows, load a window. Never the whole label. Files: `ThreadList.swift` ### Done when - [ ] Search over a hundred thousand messages returns in under a hundred milliseconds - [ ] A thirty thousand message label opens instantly - [ ] Search covers body text - [ ] A new message is searchable within a second ### Watch out - Loading a whole thread list into memory works until a label has thirty thousand messages, and then the client stalls on opening it. Page the list and render rows, which is what a native list view is for and the reason to build this natively at all. ## Phase 5 · Actions that work before the network does Archiving feels instant and is never lost, even offline. ### Steps 1. Apply locally, queue remotely The view updates at once; the queue makes it true. Files: `Actions.swift` 2. Retry with a cap Then surface the failure. A silently dropped action is the worst outcome. Files: `Outbox.swift` 3. Reconcile visibly Server wins, user is told. Silent reversion looks like a bug and destroys trust. Files: `Reconcile.swift` ### Done when - [ ] An offline archive applies when connectivity returns - [ ] A failing action is surfaced rather than retried forever - [ ] A conflict is reconciled with a visible note - [ ] Queued actions survive a quit ### Watch out - Applying locally and never reconciling means a failed action leaves the client showing a state the server does not have, and the message reappears days later with no explanation. Queue with retries, reconcile against the server on the next sync, and surface a conflict rather than flipping the message back silently. ## Not in this build - Deep provider-specific feature support. - Mail hosting and deliverability. - Push notifications across platforms. - Support for other mail providers. ## After v1, if you want it - Scheduled send, which is local queuing you already have - Rules applied locally on arrival, since the index is already there - A second account, which mostly works if the model stayed account-scoped ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Local message cache. Contains your mail, so treat the file and its backups accordingly. DB_PATH=~/Library/Application Support/Mail/mail.db # Required. From the provider's developer console. OAUTH_CLIENT_ID=replace-with-client-id # Required. How much mail to fetch before the client becomes usable. Older mail backfills after. INITIAL_SYNC_DAYS=90 # Required. Cap on stored raw messages. Oldest are evicted and refetched on demand. MAX_RAW_CACHE_GB=10 # Required. Retries before a queued action is surfaced as failed. ACTION_MAX_ATTEMPTS=6
You are building a lean indie version of Mimestream. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Mimestream · indie build A native client over an existing mailbox that syncs incrementally rather than refetching, stores messages locally so search and scrolling are instant, models labels honestly including a message in several at once, and sends through the account's normal service so deliverability stays someone else's problem. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 with AppKit | the point of building this is native responsiveness with a large local mailbox | | Sync | The provider's history API for incremental changes | refetching a mailbox is slow, rate limited and unnecessary when the provider offers a change feed | | Storage | SQLite with full text search | a hundred thousand messages is small for a database and impossible to keep in memory | | Auth | The account's own authorisation flow, tokens in the keychain | you never see a password and the user can revoke access from their account settings | | Sending | The account's normal sending service | deliverability, signing and reputation stay with the provider, which is where they belong | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac running macOS 13 or newer** · free if you own one - Why: The reason to build this is native performance, which exists nowhere else. - Get it: Apple menu, then About This Mac. - Verify: About This Mac reports macOS 13 or higher - [ ] **Xcode 16 with a Swift 6 toolchain** · free - Why: Builds the client and provides Instruments for the list and search work. - Get it: Mac App Store or developer.apple.com/xcode. - Verify: xcodebuild -version prints 16 or higher - [ ] **Provider API credentials for the authorisation flow** · free for personal use; verification may be required for wider distribution - Why: Without them the client cannot connect to a real mailbox, and a test mailbox does not have the label complexity that matters. - Get it: Create a project in the provider's developer console, enable the mail scopes you need, and configure the redirect. Some providers review applications requesting broad mail access, which takes time. - Verify: The flow completes and returns a token - [ ] **A real mailbox with tens of thousands of messages** · free - Why: Sync, search and list performance are all trivial on a small mailbox and decisive on a real one, and label complexity only appears in mail people actually use. - Get it: Your own account. If it is small, generate a large fixture as well, but test against the real one for label behaviour. - Verify: The account connects and reports its message count - [ ] **Apple Developer Program membership** (optional) · 99 US dollars per year - Why: Product phase only. Without it there is no notarization and a warning on other machines. - Get it: Enrol at developer.apple.com/programs. - Verify: A Developer ID Application certificate appears in Xcode settings ## Quick start ```sh xcrun notarytool submit Mail.zip --keychain-profile AC --wait ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Deep provider-specific feature support. - Mail hosting and deliverability. - Push notifications across platforms. - Support for other mail providers. - deep Gmail API use, native macOS polish, sync reliability, and support - mail hosting and deliverability - push on every platform - advanced team collaboration - provider-specific AI and search If one of those is essential to you, that is the reason to keep paying for Mimestream, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Mimestream The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a fast native mail client for an existing Gmail account, built around labels rather than folders to replace Mimestream. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Do not model Gmail as folders. A message has labels, several at once, and the inbox is one of them. Archiving removes a label rather than moving anything, and a message can be in three places because that is what labels mean. Clients that map labels onto folders behave wrongly in ways users notice immediately: archiving loses a message from a view where it should still appear, and a message shown twice looks like a bug. Model labels as they are and the rest of the client follows. ### Stack (fixed, do not substitute) - Language: Swift 6 with AppKit · the point of building this is native responsiveness with a large local mailbox - Sync: The provider's history API for incremental changes · refetching a mailbox is slow, rate limited and unnecessary when the provider offers a change feed - Storage: SQLite with full text search · a hundred thousand messages is small for a database and impossible to keep in memory - Auth: The account's own authorisation flow, tokens in the keychain · you never see a password and the user can revoke access from their account settings - Sending: The account's normal sending service · deliverability, signing and reputation stay with the provider, which is where they belong ### Data model (create this before Phase 1) - accounts(id, address, token_ref, history_id, last_synced_at) - messages(id, account_id, provider_id, thread_id, from_addr, to_json, subject, snippet, received_at, size, raw_key) - labels(id, account_id, provider_id, name, kind), message_labels(message_id, label_id) · many to many, which is the point - threads(id, account_id, provider_id, subject, last_message_at, message_count) - messages_fts · full text over subject and body - pending_actions(id, kind, message_id, label_id, created_at, attempts, last_error) · the outbox that makes offline actions safe - A local action is applied immediately and queued for the server. The queue is what makes the client feel instant without lying. ### Phase 1 · Authorising without touching a password Build: Implement the provider's authorisation flow with the narrowest scopes that do what you need. Store tokens in the keychain. Handle refresh, and handle revocation from the account side without losing local data. Trap: Requesting broad scopes because it is simpler means the consent screen asks for more than you need and users refuse, and it means a token leak is worse than it had to be. Request the narrowest scopes and add one only when a feature actually needs it. Done when: The account connects through its own flow and no password is ever entered in your application. Tokens are in the keychain and absent from the database, verified by inspection. A revoked authorisation produces a clear reconnect prompt rather than repeated failures. Token refresh happens without user action. Do not build yet: No message sync, labels or search. ### Phase 2 · Incremental sync Build: Do one bounded initial sync, then use the provider's change feed to pull only what changed since the last recorded point. Handle the case where the change point is too old and a full resynchronisation is required. Store the raw message so nothing needs refetching for display. Trap: A change cursor expires after a period, and a client that treats an expired cursor as an error stops syncing silently until someone notices missing mail. Detect the specific response and fall back to a full resynchronisation rather than failing. Done when: An initial sync of a large mailbox completes within a stated time and reports progress. Subsequent syncs transfer only changes, verified by comparing request counts. An expired change cursor triggers a full resynchronisation rather than an error. A message deleted on the server disappears locally. Do not build yet: No labels modelled properly yet. ### Phase 3 · Labels modelled as labels Build: Model the message to label relationship as many to many. Archiving removes the inbox label. A label view is a query. Show every label a message carries rather than picking one to display. Trap: Mapping labels to a single folder column forces a choice about which label a message lives in, and every subsequent behaviour is wrong: archiving from one view removes it from another, and a message in two labels appears in one. This is the modelling decision that cannot be corrected later without a rewrite. Done when: A message with three labels appears in all three views. Archiving removes it from the inbox and leaves it in the others. A message shows every label it carries. Removing the last label behaves as the provider defines rather than deleting locally. Do not build yet: No search or composing yet. ### Phase 4 · Instant search and scrolling Build: Full text search over locally stored messages, updated as they arrive. Paged thread lists rendering rows rather than loading everything. Generate a large fixture mailbox before optimising. Trap: Loading a whole thread list into memory works until a label has thirty thousand messages, and then the client stalls on opening it. Page the list and render rows, which is what a native list view is for and the reason to build this natively at all. Done when: Search over a hundred thousand messages returns in well under a hundred milliseconds. Opening a label with thirty thousand messages is instant and memory stays stable while scrolling. Search results include body text, not only subjects. A newly arrived message is searchable within a second. Do not build yet: No composing or offline actions yet. ### Phase 5 · Actions that work before the network does Build: Apply label changes locally at once and queue the server action. Drain the queue with retries. Reconcile when the server disagrees, preferring the server and telling the user rather than silently reverting. Trap: Applying locally and never reconciling means a failed action leaves the client showing a state the server does not have, and the message reappears days later with no explanation. Queue with retries, reconcile against the server on the next sync, and surface a conflict rather than flipping the message back silently. Done when: Archiving with networking disabled updates the view immediately and the action applies when connectivity returns. A permanently failing action is surfaced rather than retried forever. A server-side change that conflicts with a queued action is reconciled with a visible note. Quitting with queued actions preserves them. Do not build yet: No composing or distribution yet. ### Phase 6 · Composing, sending and shipping it Build: Compose and reply through the account's own sending service, preserving threading headers so replies thread correctly everywhere. Sign and notarize. State plainly what scopes were granted and what the application does with the mailbox. Trap: Dropping the reply headers breaks threading in every other client the recipient might use, and it looks like a new conversation to them. Preserve the message identifier chain on every reply, and verify by replying to yourself and checking the thread in a different client. Done when: A reply threads correctly when viewed in a different mail client. Sending happens through the account's service so the message appears in its sent items. A notarized build opens on a fresh Mac with no Gatekeeper warning. The README states which scopes are requested and why. Do not build yet: Nothing after this. ### Out of scope (and why) - Deep use of provider-specific features, which is where the commercial client's advantage lives and which is a long tail of behaviour. - Mail hosting and deliverability. Sending stays with the account's provider deliberately, and that is the right answer. - Push notification support on every platform, which needs infrastructure per platform. - Support for other mail providers, each of which has a different sync model and its own quirks. ### README must contain - That labels are modelled as labels and archiving removes one rather than moving a message - That sync is incremental with a documented full resynchronisation fallback - That tokens live in the keychain and no password is ever entered - That actions apply locally and queue, with conflicts reconciled visibly - Which authorisation scopes are requested and what each is used for ===== AGENTS.md ===== # Agent instructions · Mimestream indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Swift 6 with AppKit, The provider's history API for incremental changes, SQLite with full text search, The account's own authorisation flow, tokens in the keychain, The account's normal sending service. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Requesting broad scopes because it is simpler means the consent screen asks for more than you need and users refuse, and it means a token leak is worse than it had to be. Request the narrowest scopes and add one only when a feature actually needs it. - A change cursor expires after a period, and a client that treats an expired cursor as an error stops syncing silently until someone notices missing mail. Detect the specific response and fall back to a full resynchronisation rather than failing. - Mapping labels to a single folder column forces a choice about which label a message lives in, and every subsequent behaviour is wrong: archiving from one view removes it from another, and a message in two labels appears in one. This is the modelling decision that cannot be corrected later without a rewrite. - Loading a whole thread list into memory works until a label has thirty thousand messages, and then the client stalls on opening it. Page the list and render rows, which is what a native list view is for and the reason to build this natively at all. - Applying locally and never reconciling means a failed action leaves the client showing a state the server does not have, and the message reappears days later with no explanation. Queue with retries, reconcile against the server on the next sync, and surface a conflict rather than flipping the message back silently. ===== BUILD_PLAN.md ===== # Build plan · Mimestream A native client over an existing mailbox that syncs incrementally rather than refetching, stores messages locally so search and scrolling are instant, models labels honestly including a message in several at once, and sends through the account's normal service so deliverability stays someone else's problem. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Authorising without touching a password The account is connected through its own authorisation flow and the token is in the keychain. ### Steps 1. Implement the authorisation flow Narrowest scopes. Add scopes when a feature needs one, not in advance. Files: `Auth.swift` 2. Store tokens in the keychain Referenced from the database, never stored in it. Inspect the file to confirm. Files: `Keychain.swift` 3. Handle revocation gracefully A clear reconnect prompt, local data intact. Files: `Auth.swift` ### Done when - [ ] No password is entered in the application - [ ] Tokens are in the keychain and absent from the database - [ ] Revocation produces a reconnect prompt - [ ] Refresh happens without user action ### Watch out - Requesting broad scopes because it is simpler means the consent screen asks for more than you need and users refuse, and it means a token leak is worse than it had to be. Request the narrowest scopes and add one only when a feature actually needs it. ## Phase 2 · Incremental sync A mailbox with a hundred thousand messages syncs changes rather than refetching. ### Steps 1. Bound the initial sync Recent mail first so the client is usable in a minute, older mail backfilled after. Files: `Sync.swift` 2. Use the change feed Pull by history point. Store the point after each successful batch, not at the end. Files: `Sync.swift` 3. Handle an expired cursor Full resynchronisation, tested by corrupting the stored point deliberately. Files: `Sync.swift` ### Done when - [ ] Initial sync completes in a stated time with progress - [ ] Later syncs transfer only changes - [ ] An expired cursor triggers full resynchronisation - [ ] A server-side deletion disappears locally ### Watch out - A change cursor expires after a period, and a client that treats an expired cursor as an error stops syncing silently until someone notices missing mail. Detect the specific response and fall back to a full resynchronisation rather than failing. ## Phase 3 · Labels modelled as labels A message in three labels appears in all three, and archiving removes one rather than moving anything. ### Steps 1. Model many to many A join table. Not a folder column with a favourite label. Files: `Labels.swift` 2. Make archiving a label removal Inbox is a label. Archiving removes it and nothing else. Files: `Actions.swift` 3. Show all labels on a message Rather than choosing one. Users know their messages are in several places. Files: `MessageView.swift` ### Done when - [ ] A message with three labels appears in all three views - [ ] Archiving removes only the inbox label - [ ] Every label on a message is shown - [ ] Removing the last label matches provider behaviour ### Watch out - Mapping labels to a single folder column forces a choice about which label a message lives in, and every subsequent behaviour is wrong: archiving from one view removes it from another, and a message in two labels appears in one. This is the modelling decision that cannot be corrected later without a rewrite. ## Phase 4 · Instant search and scrolling Searching a hundred thousand messages is faster than the web client. ### Steps 1. Generate a large fixture mailbox A hundred thousand messages. Every performance claim depends on it existing first. Files: `Fixtures.swift` 2. Index on arrival Full text over subject and body, updated as messages sync. Files: `Search.swift` 3. Page the list Render rows, load a window. Never the whole label. Files: `ThreadList.swift` ### Done when - [ ] Search over a hundred thousand messages returns in under a hundred milliseconds - [ ] A thirty thousand message label opens instantly - [ ] Search covers body text - [ ] A new message is searchable within a second ### Watch out - Loading a whole thread list into memory works until a label has thirty thousand messages, and then the client stalls on opening it. Page the list and render rows, which is what a native list view is for and the reason to build this natively at all. ## Phase 5 · Actions that work before the network does Archiving feels instant and is never lost, even offline. ### Steps 1. Apply locally, queue remotely The view updates at once; the queue makes it true. Files: `Actions.swift` 2. Retry with a cap Then surface the failure. A silently dropped action is the worst outcome. Files: `Outbox.swift` 3. Reconcile visibly Server wins, user is told. Silent reversion looks like a bug and destroys trust. Files: `Reconcile.swift` ### Done when - [ ] An offline archive applies when connectivity returns - [ ] A failing action is surfaced rather than retried forever - [ ] A conflict is reconciled with a visible note - [ ] Queued actions survive a quit ### Watch out - Applying locally and never reconciling means a failed action leaves the client showing a state the server does not have, and the message reappears days later with no explanation. Queue with retries, reconcile against the server on the next sync, and surface a conflict rather than flipping the message back silently. ## Not in this build - Deep provider-specific feature support. - Mail hosting and deliverability. - Push notifications across platforms. - Support for other mail providers. ## After v1, if you want it - Scheduled send, which is local queuing you already have - Rules applied locally on arrival, since the index is already there - A second account, which mostly works if the model stayed account-scoped ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Local message cache. Contains your mail, so treat the file and its backups accordingly. DB_PATH=~/Library/Application Support/Mail/mail.db # Required. From the provider's developer console. OAUTH_CLIENT_ID=replace-with-client-id # Required. How much mail to fetch before the client becomes usable. Older mail backfills after. INITIAL_SYNC_DAYS=90 # Required. Cap on stored raw messages. Oldest are evicted and refetched on demand. MAX_RAW_CACHE_GB=10 # Required. Retries before a queued action is surfaced as failed. ACTION_MAX_ATTEMPTS=6
You are building a production product version of Mimestream. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Mimestream · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Mimestream, build a native Mac Gmail client over standards-based APIs with local cache. The hard boundary is deep gmail api use, native macos polish, sync reliability, and support, plus mail infrastructure, integrations, and client polish. ## Product outcome A client that opens instantly on a large mailbox, models labels the way the provider actually does, never loses an action taken offline, and asks for the narrowest access that does the job. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - existing IMAP and SMTP mailbox - OAuth client credentials where required - desktop keychain access - local disk space ## Explicit non-goals for v1 - Deep provider-specific feature support. - Mail hosting and deliverability. - Push notifications across platforms. - Support for other mail providers. - deep Gmail API use, native macOS polish, sync reliability, and support - mail hosting and deliverability - push on every platform - advanced team collaboration - provider-specific AI and search ## Success criteria - A message with three labels is confirmed to appear in all three views - An expired cursor is confirmed to trigger full resynchronisation - Search latency on the large fixture has not regressed - An offline action is confirmed to apply on reconnection - A reply is confirmed to thread correctly in a different client ===== BRIEF.md ===== # Build brief · Mimestream The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a fast native mail client for an existing Gmail account, built around labels rather than folders to replace Mimestream. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Do not model Gmail as folders. A message has labels, several at once, and the inbox is one of them. Archiving removes a label rather than moving anything, and a message can be in three places because that is what labels mean. Clients that map labels onto folders behave wrongly in ways users notice immediately: archiving loses a message from a view where it should still appear, and a message shown twice looks like a bug. Model labels as they are and the rest of the client follows. ### Stack (fixed, do not substitute) - Language: Swift 6 with AppKit · the point of building this is native responsiveness with a large local mailbox - Sync: The provider's history API for incremental changes · refetching a mailbox is slow, rate limited and unnecessary when the provider offers a change feed - Storage: SQLite with full text search · a hundred thousand messages is small for a database and impossible to keep in memory - Auth: The account's own authorisation flow, tokens in the keychain · you never see a password and the user can revoke access from their account settings - Sending: The account's normal sending service · deliverability, signing and reputation stay with the provider, which is where they belong ### Data model (create this before Phase 1) - accounts(id, address, token_ref, history_id, last_synced_at) - messages(id, account_id, provider_id, thread_id, from_addr, to_json, subject, snippet, received_at, size, raw_key) - labels(id, account_id, provider_id, name, kind), message_labels(message_id, label_id) · many to many, which is the point - threads(id, account_id, provider_id, subject, last_message_at, message_count) - messages_fts · full text over subject and body - pending_actions(id, kind, message_id, label_id, created_at, attempts, last_error) · the outbox that makes offline actions safe - A local action is applied immediately and queued for the server. The queue is what makes the client feel instant without lying. ### Phase 1 · Authorising without touching a password Build: Implement the provider's authorisation flow with the narrowest scopes that do what you need. Store tokens in the keychain. Handle refresh, and handle revocation from the account side without losing local data. Trap: Requesting broad scopes because it is simpler means the consent screen asks for more than you need and users refuse, and it means a token leak is worse than it had to be. Request the narrowest scopes and add one only when a feature actually needs it. Done when: The account connects through its own flow and no password is ever entered in your application. Tokens are in the keychain and absent from the database, verified by inspection. A revoked authorisation produces a clear reconnect prompt rather than repeated failures. Token refresh happens without user action. Do not build yet: No message sync, labels or search. ### Phase 2 · Incremental sync Build: Do one bounded initial sync, then use the provider's change feed to pull only what changed since the last recorded point. Handle the case where the change point is too old and a full resynchronisation is required. Store the raw message so nothing needs refetching for display. Trap: A change cursor expires after a period, and a client that treats an expired cursor as an error stops syncing silently until someone notices missing mail. Detect the specific response and fall back to a full resynchronisation rather than failing. Done when: An initial sync of a large mailbox completes within a stated time and reports progress. Subsequent syncs transfer only changes, verified by comparing request counts. An expired change cursor triggers a full resynchronisation rather than an error. A message deleted on the server disappears locally. Do not build yet: No labels modelled properly yet. ### Phase 3 · Labels modelled as labels Build: Model the message to label relationship as many to many. Archiving removes the inbox label. A label view is a query. Show every label a message carries rather than picking one to display. Trap: Mapping labels to a single folder column forces a choice about which label a message lives in, and every subsequent behaviour is wrong: archiving from one view removes it from another, and a message in two labels appears in one. This is the modelling decision that cannot be corrected later without a rewrite. Done when: A message with three labels appears in all three views. Archiving removes it from the inbox and leaves it in the others. A message shows every label it carries. Removing the last label behaves as the provider defines rather than deleting locally. Do not build yet: No search or composing yet. ### Phase 4 · Instant search and scrolling Build: Full text search over locally stored messages, updated as they arrive. Paged thread lists rendering rows rather than loading everything. Generate a large fixture mailbox before optimising. Trap: Loading a whole thread list into memory works until a label has thirty thousand messages, and then the client stalls on opening it. Page the list and render rows, which is what a native list view is for and the reason to build this natively at all. Done when: Search over a hundred thousand messages returns in well under a hundred milliseconds. Opening a label with thirty thousand messages is instant and memory stays stable while scrolling. Search results include body text, not only subjects. A newly arrived message is searchable within a second. Do not build yet: No composing or offline actions yet. ### Phase 5 · Actions that work before the network does Build: Apply label changes locally at once and queue the server action. Drain the queue with retries. Reconcile when the server disagrees, preferring the server and telling the user rather than silently reverting. Trap: Applying locally and never reconciling means a failed action leaves the client showing a state the server does not have, and the message reappears days later with no explanation. Queue with retries, reconcile against the server on the next sync, and surface a conflict rather than flipping the message back silently. Done when: Archiving with networking disabled updates the view immediately and the action applies when connectivity returns. A permanently failing action is surfaced rather than retried forever. A server-side change that conflicts with a queued action is reconciled with a visible note. Quitting with queued actions preserves them. Do not build yet: No composing or distribution yet. ### Phase 6 · Composing, sending and shipping it Build: Compose and reply through the account's own sending service, preserving threading headers so replies thread correctly everywhere. Sign and notarize. State plainly what scopes were granted and what the application does with the mailbox. Trap: Dropping the reply headers breaks threading in every other client the recipient might use, and it looks like a new conversation to them. Preserve the message identifier chain on every reply, and verify by replying to yourself and checking the thread in a different client. Done when: A reply threads correctly when viewed in a different mail client. Sending happens through the account's service so the message appears in its sent items. A notarized build opens on a fresh Mac with no Gatekeeper warning. The README states which scopes are requested and why. Do not build yet: Nothing after this. ### Out of scope (and why) - Deep use of provider-specific features, which is where the commercial client's advantage lives and which is a long tail of behaviour. - Mail hosting and deliverability. Sending stays with the account's provider deliberately, and that is the right answer. - Push notification support on every platform, which needs infrastructure per platform. - Support for other mail providers, each of which has a different sync model and its own quirks. ### README must contain - That labels are modelled as labels and archiving removes one rather than moving a message - That sync is incremental with a documented full resynchronisation fallback - That tokens live in the keychain and no password is ever entered - That actions apply locally and queue, with conflicts reconciled visibly - Which authorisation scopes are requested and what each is used for ===== ARCHITECTURE.md ===== # Architecture · Mimestream ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 with AppKit | the point of building this is native responsiveness with a large local mailbox | | Sync | The provider's history API for incremental changes | refetching a mailbox is slow, rate limited and unnecessary when the provider offers a change feed | | Storage | SQLite with full text search | a hundred thousand messages is small for a database and impossible to keep in memory | | Auth | The account's own authorisation flow, tokens in the keychain | you never see a password and the user can revoke access from their account settings | | Sending | The account's normal sending service | deliverability, signing and reputation stay with the provider, which is where they belong | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Auth | Authorisation and token storage. | Provider specific. Narrow scopes and keychain storage survive any change. | | Sync | Incremental change pulling and the full fallback. | Any change feed. The expired-cursor fallback is the part that must not be forgotten. | | Labels | The many to many relationship. | Not swappable for folders. This is the modelling decision the whole client rests on. | | Search and lists | Local performance. | Paging and local indexing are the reason to build natively at all. | | Outbox | Local-first actions with reconciliation. | Any queue, provided conflicts surface rather than reverting silently. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DB_PATH` · required · Local message cache. Contains your mail, so treat the file and its backups accordingly. - `OAUTH_CLIENT_ID` · required · From the provider's developer console. - `INITIAL_SYNC_DAYS` · required · How much mail to fetch before the client becomes usable. Older mail backfills after. - `MAX_RAW_CACHE_GB` · required · Cap on stored raw messages. Oldest are evicted and refetched on demand. - `ACTION_MAX_ATTEMPTS` · required · Retries before a queued action is surfaced as failed. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Mimestream product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Swift 6 with AppKit, The provider's history API for incremental changes, SQLite with full text search, The account's own authorisation flow, tokens in the keychain, The account's normal sending service. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Requesting broad scopes because it is simpler means the consent screen asks for more than you need and users refuse, and it means a token leak is worse than it had to be. Request the narrowest scopes and add one only when a feature actually needs it. - A change cursor expires after a period, and a client that treats an expired cursor as an error stops syncing silently until someone notices missing mail. Detect the specific response and fall back to a full resynchronisation rather than failing. - Mapping labels to a single folder column forces a choice about which label a message lives in, and every subsequent behaviour is wrong: archiving from one view removes it from another, and a message in two labels appears in one. This is the modelling decision that cannot be corrected later without a rewrite. - Loading a whole thread list into memory works until a label has thirty thousand messages, and then the client stalls on opening it. Page the list and render rows, which is what a native list view is for and the reason to build this natively at all. - Applying locally and never reconciling means a failed action leaves the client showing a state the server does not have, and the message reappears days later with no explanation. Queue with retries, reconcile against the server on the next sync, and surface a conflict rather than flipping the message back silently. - Dropping the reply headers breaks threading in every other client the recipient might use, and it looks like a new conversation to them. Preserve the message identifier chain on every reply, and verify by replying to yourself and checking the thread in a different client. ===== MILESTONES.md ===== # Delivery milestones · Mimestream Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Authorising without touching a password The account is connected through its own authorisation flow and the token is in the keychain. ### Steps 1. Implement the authorisation flow Narrowest scopes. Add scopes when a feature needs one, not in advance. Files: `Auth.swift` 2. Store tokens in the keychain Referenced from the database, never stored in it. Inspect the file to confirm. Files: `Keychain.swift` 3. Handle revocation gracefully A clear reconnect prompt, local data intact. Files: `Auth.swift` ### Done when - [ ] No password is entered in the application - [ ] Tokens are in the keychain and absent from the database - [ ] Revocation produces a reconnect prompt - [ ] Refresh happens without user action ### Watch out - Requesting broad scopes because it is simpler means the consent screen asks for more than you need and users refuse, and it means a token leak is worse than it had to be. Request the narrowest scopes and add one only when a feature actually needs it. ## M2 · Incremental sync A mailbox with a hundred thousand messages syncs changes rather than refetching. ### Steps 1. Bound the initial sync Recent mail first so the client is usable in a minute, older mail backfilled after. Files: `Sync.swift` 2. Use the change feed Pull by history point. Store the point after each successful batch, not at the end. Files: `Sync.swift` 3. Handle an expired cursor Full resynchronisation, tested by corrupting the stored point deliberately. Files: `Sync.swift` ### Done when - [ ] Initial sync completes in a stated time with progress - [ ] Later syncs transfer only changes - [ ] An expired cursor triggers full resynchronisation - [ ] A server-side deletion disappears locally ### Watch out - A change cursor expires after a period, and a client that treats an expired cursor as an error stops syncing silently until someone notices missing mail. Detect the specific response and fall back to a full resynchronisation rather than failing. ## M3 · Labels modelled as labels A message in three labels appears in all three, and archiving removes one rather than moving anything. ### Steps 1. Model many to many A join table. Not a folder column with a favourite label. Files: `Labels.swift` 2. Make archiving a label removal Inbox is a label. Archiving removes it and nothing else. Files: `Actions.swift` 3. Show all labels on a message Rather than choosing one. Users know their messages are in several places. Files: `MessageView.swift` ### Done when - [ ] A message with three labels appears in all three views - [ ] Archiving removes only the inbox label - [ ] Every label on a message is shown - [ ] Removing the last label matches provider behaviour ### Watch out - Mapping labels to a single folder column forces a choice about which label a message lives in, and every subsequent behaviour is wrong: archiving from one view removes it from another, and a message in two labels appears in one. This is the modelling decision that cannot be corrected later without a rewrite. ## M4 · Instant search and scrolling Searching a hundred thousand messages is faster than the web client. ### Steps 1. Generate a large fixture mailbox A hundred thousand messages. Every performance claim depends on it existing first. Files: `Fixtures.swift` 2. Index on arrival Full text over subject and body, updated as messages sync. Files: `Search.swift` 3. Page the list Render rows, load a window. Never the whole label. Files: `ThreadList.swift` ### Done when - [ ] Search over a hundred thousand messages returns in under a hundred milliseconds - [ ] A thirty thousand message label opens instantly - [ ] Search covers body text - [ ] A new message is searchable within a second ### Watch out - Loading a whole thread list into memory works until a label has thirty thousand messages, and then the client stalls on opening it. Page the list and render rows, which is what a native list view is for and the reason to build this natively at all. ## M5 · Actions that work before the network does Archiving feels instant and is never lost, even offline. ### Steps 1. Apply locally, queue remotely The view updates at once; the queue makes it true. Files: `Actions.swift` 2. Retry with a cap Then surface the failure. A silently dropped action is the worst outcome. Files: `Outbox.swift` 3. Reconcile visibly Server wins, user is told. Silent reversion looks like a bug and destroys trust. Files: `Reconcile.swift` ### Done when - [ ] An offline archive applies when connectivity returns - [ ] A failing action is surfaced rather than retried forever - [ ] A conflict is reconciled with a visible note - [ ] Queued actions survive a quit ### Watch out - Applying locally and never reconciling means a failed action leaves the client showing a state the server does not have, and the message reappears days later with no explanation. Queue with retries, reconcile against the server on the next sync, and surface a conflict rather than flipping the message back silently. ## M6 · Composing, sending and shipping it (production only) You can reply correctly, and someone else can install it. ### Steps 1. Preserve threading headers The identifier chain on every reply. Verify in a different client rather than your own. Files: `Compose.swift` 2. Send through the account's service So deliverability and sent items both behave normally. Files: `Send.swift` 3. Sign, notarize and document scopes What was requested and what it is used for. Mail access deserves a plain statement. Files: `README.md` ```sh xcrun notarytool submit Mail.zip --keychain-profile AC --wait ``` ### Done when - [ ] A reply threads correctly in a different client - [ ] Sent messages appear in the account's sent items - [ ] spctl reports the notarized build accepted - [ ] The README states the scopes and their purpose ### Watch out - Dropping the reply headers breaks threading in every other client the recipient might use, and it looks like a new conversation to them. Preserve the message identifier chain on every reply, and verify by replying to yourself and checking the thread in a different client. ===== OPERATIONS.md ===== # Operations · Mimestream ## Backup The local cache is rebuildable from the server, so the thing worth protecting is the outbox and the authorisation, not the mail. That inverts the usual instinct and is worth stating. ## Restore Reauthorise and resynchronise. If a restore loses anything other than queued actions, something authoritative has leaked into the local cache. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Sync failure rate, outbox age and cache size. An outbox row older than a few minutes means actions are failing, which the user will otherwise discover when a message reappears. ## Incident checklist If mail stops arriving, check whether an expired cursor was treated as an error, since that failure is silent by design. If actions revert, the reconciliation is preferring the server correctly but not telling the user, which is a bug in the surfacing rather than the logic. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A message with three labels is confirmed to appear in all three views - [ ] An expired cursor is confirmed to trigger full resynchronisation - [ ] Search latency on the large fixture has not regressed - [ ] An offline action is confirmed to apply on reconnection - [ ] A reply is confirmed to thread correctly in a different client ## Launch constraint Do not market omitted Mimestream capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Local message cache. Contains your mail, so treat the file and its backups accordingly. DB_PATH=~/Library/Application Support/Mail/mail.db # Required. From the provider's developer console. OAUTH_CLIENT_ID=replace-with-client-id # Required. How much mail to fetch before the client becomes usable. Older mail backfills after. INITIAL_SYNC_DAYS=90 # Required. Cap on stored raw messages. Oldest are evicted and refetched on demand. MAX_RAW_CACHE_GB=10 # Required. Retries before a queued action is surfaced as failed. ACTION_MAX_ATTEMPTS=6
# Mimestream · indie build A native client over an existing mailbox that syncs incrementally rather than refetching, stores messages locally so search and scrolling are instant, models labels honestly including a message in several at once, and sends through the account's normal service so deliverability stays someone else's problem. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 with AppKit | the point of building this is native responsiveness with a large local mailbox | | Sync | The provider's history API for incremental changes | refetching a mailbox is slow, rate limited and unnecessary when the provider offers a change feed | | Storage | SQLite with full text search | a hundred thousand messages is small for a database and impossible to keep in memory | | Auth | The account's own authorisation flow, tokens in the keychain | you never see a password and the user can revoke access from their account settings | | Sending | The account's normal sending service | deliverability, signing and reputation stay with the provider, which is where they belong | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac running macOS 13 or newer** · free if you own one - Why: The reason to build this is native performance, which exists nowhere else. - Get it: Apple menu, then About This Mac. - Verify: About This Mac reports macOS 13 or higher - [ ] **Xcode 16 with a Swift 6 toolchain** · free - Why: Builds the client and provides Instruments for the list and search work. - Get it: Mac App Store or developer.apple.com/xcode. - Verify: xcodebuild -version prints 16 or higher - [ ] **Provider API credentials for the authorisation flow** · free for personal use; verification may be required for wider distribution - Why: Without them the client cannot connect to a real mailbox, and a test mailbox does not have the label complexity that matters. - Get it: Create a project in the provider's developer console, enable the mail scopes you need, and configure the redirect. Some providers review applications requesting broad mail access, which takes time. - Verify: The flow completes and returns a token - [ ] **A real mailbox with tens of thousands of messages** · free - Why: Sync, search and list performance are all trivial on a small mailbox and decisive on a real one, and label complexity only appears in mail people actually use. - Get it: Your own account. If it is small, generate a large fixture as well, but test against the real one for label behaviour. - Verify: The account connects and reports its message count - [ ] **Apple Developer Program membership** (optional) · 99 US dollars per year - Why: Product phase only. Without it there is no notarization and a warning on other machines. - Get it: Enrol at developer.apple.com/programs. - Verify: A Developer ID Application certificate appears in Xcode settings ## Quick start ```sh xcrun notarytool submit Mail.zip --keychain-profile AC --wait ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Deep provider-specific feature support. - Mail hosting and deliverability. - Push notifications across platforms. - Support for other mail providers. - deep Gmail API use, native macOS polish, sync reliability, and support - mail hosting and deliverability - push on every platform - advanced team collaboration - provider-specific AI and search If one of those is essential to you, that is the reason to keep paying for Mimestream, and the README should say so rather than pretend.
# Build brief · Mimestream The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a fast native mail client for an existing Gmail account, built around labels rather than folders to replace Mimestream. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Do not model Gmail as folders. A message has labels, several at once, and the inbox is one of them. Archiving removes a label rather than moving anything, and a message can be in three places because that is what labels mean. Clients that map labels onto folders behave wrongly in ways users notice immediately: archiving loses a message from a view where it should still appear, and a message shown twice looks like a bug. Model labels as they are and the rest of the client follows. ### Stack (fixed, do not substitute) - Language: Swift 6 with AppKit · the point of building this is native responsiveness with a large local mailbox - Sync: The provider's history API for incremental changes · refetching a mailbox is slow, rate limited and unnecessary when the provider offers a change feed - Storage: SQLite with full text search · a hundred thousand messages is small for a database and impossible to keep in memory - Auth: The account's own authorisation flow, tokens in the keychain · you never see a password and the user can revoke access from their account settings - Sending: The account's normal sending service · deliverability, signing and reputation stay with the provider, which is where they belong ### Data model (create this before Phase 1) - accounts(id, address, token_ref, history_id, last_synced_at) - messages(id, account_id, provider_id, thread_id, from_addr, to_json, subject, snippet, received_at, size, raw_key) - labels(id, account_id, provider_id, name, kind), message_labels(message_id, label_id) · many to many, which is the point - threads(id, account_id, provider_id, subject, last_message_at, message_count) - messages_fts · full text over subject and body - pending_actions(id, kind, message_id, label_id, created_at, attempts, last_error) · the outbox that makes offline actions safe - A local action is applied immediately and queued for the server. The queue is what makes the client feel instant without lying. ### Phase 1 · Authorising without touching a password Build: Implement the provider's authorisation flow with the narrowest scopes that do what you need. Store tokens in the keychain. Handle refresh, and handle revocation from the account side without losing local data. Trap: Requesting broad scopes because it is simpler means the consent screen asks for more than you need and users refuse, and it means a token leak is worse than it had to be. Request the narrowest scopes and add one only when a feature actually needs it. Done when: The account connects through its own flow and no password is ever entered in your application. Tokens are in the keychain and absent from the database, verified by inspection. A revoked authorisation produces a clear reconnect prompt rather than repeated failures. Token refresh happens without user action. Do not build yet: No message sync, labels or search. ### Phase 2 · Incremental sync Build: Do one bounded initial sync, then use the provider's change feed to pull only what changed since the last recorded point. Handle the case where the change point is too old and a full resynchronisation is required. Store the raw message so nothing needs refetching for display. Trap: A change cursor expires after a period, and a client that treats an expired cursor as an error stops syncing silently until someone notices missing mail. Detect the specific response and fall back to a full resynchronisation rather than failing. Done when: An initial sync of a large mailbox completes within a stated time and reports progress. Subsequent syncs transfer only changes, verified by comparing request counts. An expired change cursor triggers a full resynchronisation rather than an error. A message deleted on the server disappears locally. Do not build yet: No labels modelled properly yet. ### Phase 3 · Labels modelled as labels Build: Model the message to label relationship as many to many. Archiving removes the inbox label. A label view is a query. Show every label a message carries rather than picking one to display. Trap: Mapping labels to a single folder column forces a choice about which label a message lives in, and every subsequent behaviour is wrong: archiving from one view removes it from another, and a message in two labels appears in one. This is the modelling decision that cannot be corrected later without a rewrite. Done when: A message with three labels appears in all three views. Archiving removes it from the inbox and leaves it in the others. A message shows every label it carries. Removing the last label behaves as the provider defines rather than deleting locally. Do not build yet: No search or composing yet. ### Phase 4 · Instant search and scrolling Build: Full text search over locally stored messages, updated as they arrive. Paged thread lists rendering rows rather than loading everything. Generate a large fixture mailbox before optimising. Trap: Loading a whole thread list into memory works until a label has thirty thousand messages, and then the client stalls on opening it. Page the list and render rows, which is what a native list view is for and the reason to build this natively at all. Done when: Search over a hundred thousand messages returns in well under a hundred milliseconds. Opening a label with thirty thousand messages is instant and memory stays stable while scrolling. Search results include body text, not only subjects. A newly arrived message is searchable within a second. Do not build yet: No composing or offline actions yet. ### Phase 5 · Actions that work before the network does Build: Apply label changes locally at once and queue the server action. Drain the queue with retries. Reconcile when the server disagrees, preferring the server and telling the user rather than silently reverting. Trap: Applying locally and never reconciling means a failed action leaves the client showing a state the server does not have, and the message reappears days later with no explanation. Queue with retries, reconcile against the server on the next sync, and surface a conflict rather than flipping the message back silently. Done when: Archiving with networking disabled updates the view immediately and the action applies when connectivity returns. A permanently failing action is surfaced rather than retried forever. A server-side change that conflicts with a queued action is reconciled with a visible note. Quitting with queued actions preserves them. Do not build yet: No composing or distribution yet. ### Phase 6 · Composing, sending and shipping it Build: Compose and reply through the account's own sending service, preserving threading headers so replies thread correctly everywhere. Sign and notarize. State plainly what scopes were granted and what the application does with the mailbox. Trap: Dropping the reply headers breaks threading in every other client the recipient might use, and it looks like a new conversation to them. Preserve the message identifier chain on every reply, and verify by replying to yourself and checking the thread in a different client. Done when: A reply threads correctly when viewed in a different mail client. Sending happens through the account's service so the message appears in its sent items. A notarized build opens on a fresh Mac with no Gatekeeper warning. The README states which scopes are requested and why. Do not build yet: Nothing after this. ### Out of scope (and why) - Deep use of provider-specific features, which is where the commercial client's advantage lives and which is a long tail of behaviour. - Mail hosting and deliverability. Sending stays with the account's provider deliberately, and that is the right answer. - Push notification support on every platform, which needs infrastructure per platform. - Support for other mail providers, each of which has a different sync model and its own quirks. ### README must contain - That labels are modelled as labels and archiving removes one rather than moving a message - That sync is incremental with a documented full resynchronisation fallback - That tokens live in the keychain and no password is ever entered - That actions apply locally and queue, with conflicts reconciled visibly - Which authorisation scopes are requested and what each is used for
# Agent instructions · Mimestream indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Swift 6 with AppKit, The provider's history API for incremental changes, SQLite with full text search, The account's own authorisation flow, tokens in the keychain, The account's normal sending service. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Requesting broad scopes because it is simpler means the consent screen asks for more than you need and users refuse, and it means a token leak is worse than it had to be. Request the narrowest scopes and add one only when a feature actually needs it. - A change cursor expires after a period, and a client that treats an expired cursor as an error stops syncing silently until someone notices missing mail. Detect the specific response and fall back to a full resynchronisation rather than failing. - Mapping labels to a single folder column forces a choice about which label a message lives in, and every subsequent behaviour is wrong: archiving from one view removes it from another, and a message in two labels appears in one. This is the modelling decision that cannot be corrected later without a rewrite. - Loading a whole thread list into memory works until a label has thirty thousand messages, and then the client stalls on opening it. Page the list and render rows, which is what a native list view is for and the reason to build this natively at all. - Applying locally and never reconciling means a failed action leaves the client showing a state the server does not have, and the message reappears days later with no explanation. Queue with retries, reconcile against the server on the next sync, and surface a conflict rather than flipping the message back silently.
# Build plan · Mimestream A native client over an existing mailbox that syncs incrementally rather than refetching, stores messages locally so search and scrolling are instant, models labels honestly including a message in several at once, and sends through the account's normal service so deliverability stays someone else's problem. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Authorising without touching a password The account is connected through its own authorisation flow and the token is in the keychain. ### Steps 1. Implement the authorisation flow Narrowest scopes. Add scopes when a feature needs one, not in advance. Files: `Auth.swift` 2. Store tokens in the keychain Referenced from the database, never stored in it. Inspect the file to confirm. Files: `Keychain.swift` 3. Handle revocation gracefully A clear reconnect prompt, local data intact. Files: `Auth.swift` ### Done when - [ ] No password is entered in the application - [ ] Tokens are in the keychain and absent from the database - [ ] Revocation produces a reconnect prompt - [ ] Refresh happens without user action ### Watch out - Requesting broad scopes because it is simpler means the consent screen asks for more than you need and users refuse, and it means a token leak is worse than it had to be. Request the narrowest scopes and add one only when a feature actually needs it. ## Phase 2 · Incremental sync A mailbox with a hundred thousand messages syncs changes rather than refetching. ### Steps 1. Bound the initial sync Recent mail first so the client is usable in a minute, older mail backfilled after. Files: `Sync.swift` 2. Use the change feed Pull by history point. Store the point after each successful batch, not at the end. Files: `Sync.swift` 3. Handle an expired cursor Full resynchronisation, tested by corrupting the stored point deliberately. Files: `Sync.swift` ### Done when - [ ] Initial sync completes in a stated time with progress - [ ] Later syncs transfer only changes - [ ] An expired cursor triggers full resynchronisation - [ ] A server-side deletion disappears locally ### Watch out - A change cursor expires after a period, and a client that treats an expired cursor as an error stops syncing silently until someone notices missing mail. Detect the specific response and fall back to a full resynchronisation rather than failing. ## Phase 3 · Labels modelled as labels A message in three labels appears in all three, and archiving removes one rather than moving anything. ### Steps 1. Model many to many A join table. Not a folder column with a favourite label. Files: `Labels.swift` 2. Make archiving a label removal Inbox is a label. Archiving removes it and nothing else. Files: `Actions.swift` 3. Show all labels on a message Rather than choosing one. Users know their messages are in several places. Files: `MessageView.swift` ### Done when - [ ] A message with three labels appears in all three views - [ ] Archiving removes only the inbox label - [ ] Every label on a message is shown - [ ] Removing the last label matches provider behaviour ### Watch out - Mapping labels to a single folder column forces a choice about which label a message lives in, and every subsequent behaviour is wrong: archiving from one view removes it from another, and a message in two labels appears in one. This is the modelling decision that cannot be corrected later without a rewrite. ## Phase 4 · Instant search and scrolling Searching a hundred thousand messages is faster than the web client. ### Steps 1. Generate a large fixture mailbox A hundred thousand messages. Every performance claim depends on it existing first. Files: `Fixtures.swift` 2. Index on arrival Full text over subject and body, updated as messages sync. Files: `Search.swift` 3. Page the list Render rows, load a window. Never the whole label. Files: `ThreadList.swift` ### Done when - [ ] Search over a hundred thousand messages returns in under a hundred milliseconds - [ ] A thirty thousand message label opens instantly - [ ] Search covers body text - [ ] A new message is searchable within a second ### Watch out - Loading a whole thread list into memory works until a label has thirty thousand messages, and then the client stalls on opening it. Page the list and render rows, which is what a native list view is for and the reason to build this natively at all. ## Phase 5 · Actions that work before the network does Archiving feels instant and is never lost, even offline. ### Steps 1. Apply locally, queue remotely The view updates at once; the queue makes it true. Files: `Actions.swift` 2. Retry with a cap Then surface the failure. A silently dropped action is the worst outcome. Files: `Outbox.swift` 3. Reconcile visibly Server wins, user is told. Silent reversion looks like a bug and destroys trust. Files: `Reconcile.swift` ### Done when - [ ] An offline archive applies when connectivity returns - [ ] A failing action is surfaced rather than retried forever - [ ] A conflict is reconciled with a visible note - [ ] Queued actions survive a quit ### Watch out - Applying locally and never reconciling means a failed action leaves the client showing a state the server does not have, and the message reappears days later with no explanation. Queue with retries, reconcile against the server on the next sync, and surface a conflict rather than flipping the message back silently. ## Not in this build - Deep provider-specific feature support. - Mail hosting and deliverability. - Push notifications across platforms. - Support for other mail providers. ## After v1, if you want it - Scheduled send, which is local queuing you already have - Rules applied locally on arrival, since the index is already there - A second account, which mostly works if the model stayed account-scoped
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Local message cache. Contains your mail, so treat the file and its backups accordingly. DB_PATH=~/Library/Application Support/Mail/mail.db # Required. From the provider's developer console. OAUTH_CLIENT_ID=replace-with-client-id # Required. How much mail to fetch before the client becomes usable. Older mail backfills after. INITIAL_SYNC_DAYS=90 # Required. Cap on stored raw messages. Oldest are evicted and refetched on demand. MAX_RAW_CACHE_GB=10 # Required. Retries before a queued action is surfaced as failed. ACTION_MAX_ATTEMPTS=6
# Mimestream · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Mimestream, build a native Mac Gmail client over standards-based APIs with local cache. The hard boundary is deep gmail api use, native macos polish, sync reliability, and support, plus mail infrastructure, integrations, and client polish. ## Product outcome A client that opens instantly on a large mailbox, models labels the way the provider actually does, never loses an action taken offline, and asks for the narrowest access that does the job. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - existing IMAP and SMTP mailbox - OAuth client credentials where required - desktop keychain access - local disk space ## Explicit non-goals for v1 - Deep provider-specific feature support. - Mail hosting and deliverability. - Push notifications across platforms. - Support for other mail providers. - deep Gmail API use, native macOS polish, sync reliability, and support - mail hosting and deliverability - push on every platform - advanced team collaboration - provider-specific AI and search ## Success criteria - A message with three labels is confirmed to appear in all three views - An expired cursor is confirmed to trigger full resynchronisation - Search latency on the large fixture has not regressed - An offline action is confirmed to apply on reconnection - A reply is confirmed to thread correctly in a different client
# Build brief · Mimestream The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a fast native mail client for an existing Gmail account, built around labels rather than folders to replace Mimestream. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Do not model Gmail as folders. A message has labels, several at once, and the inbox is one of them. Archiving removes a label rather than moving anything, and a message can be in three places because that is what labels mean. Clients that map labels onto folders behave wrongly in ways users notice immediately: archiving loses a message from a view where it should still appear, and a message shown twice looks like a bug. Model labels as they are and the rest of the client follows. ### Stack (fixed, do not substitute) - Language: Swift 6 with AppKit · the point of building this is native responsiveness with a large local mailbox - Sync: The provider's history API for incremental changes · refetching a mailbox is slow, rate limited and unnecessary when the provider offers a change feed - Storage: SQLite with full text search · a hundred thousand messages is small for a database and impossible to keep in memory - Auth: The account's own authorisation flow, tokens in the keychain · you never see a password and the user can revoke access from their account settings - Sending: The account's normal sending service · deliverability, signing and reputation stay with the provider, which is where they belong ### Data model (create this before Phase 1) - accounts(id, address, token_ref, history_id, last_synced_at) - messages(id, account_id, provider_id, thread_id, from_addr, to_json, subject, snippet, received_at, size, raw_key) - labels(id, account_id, provider_id, name, kind), message_labels(message_id, label_id) · many to many, which is the point - threads(id, account_id, provider_id, subject, last_message_at, message_count) - messages_fts · full text over subject and body - pending_actions(id, kind, message_id, label_id, created_at, attempts, last_error) · the outbox that makes offline actions safe - A local action is applied immediately and queued for the server. The queue is what makes the client feel instant without lying. ### Phase 1 · Authorising without touching a password Build: Implement the provider's authorisation flow with the narrowest scopes that do what you need. Store tokens in the keychain. Handle refresh, and handle revocation from the account side without losing local data. Trap: Requesting broad scopes because it is simpler means the consent screen asks for more than you need and users refuse, and it means a token leak is worse than it had to be. Request the narrowest scopes and add one only when a feature actually needs it. Done when: The account connects through its own flow and no password is ever entered in your application. Tokens are in the keychain and absent from the database, verified by inspection. A revoked authorisation produces a clear reconnect prompt rather than repeated failures. Token refresh happens without user action. Do not build yet: No message sync, labels or search. ### Phase 2 · Incremental sync Build: Do one bounded initial sync, then use the provider's change feed to pull only what changed since the last recorded point. Handle the case where the change point is too old and a full resynchronisation is required. Store the raw message so nothing needs refetching for display. Trap: A change cursor expires after a period, and a client that treats an expired cursor as an error stops syncing silently until someone notices missing mail. Detect the specific response and fall back to a full resynchronisation rather than failing. Done when: An initial sync of a large mailbox completes within a stated time and reports progress. Subsequent syncs transfer only changes, verified by comparing request counts. An expired change cursor triggers a full resynchronisation rather than an error. A message deleted on the server disappears locally. Do not build yet: No labels modelled properly yet. ### Phase 3 · Labels modelled as labels Build: Model the message to label relationship as many to many. Archiving removes the inbox label. A label view is a query. Show every label a message carries rather than picking one to display. Trap: Mapping labels to a single folder column forces a choice about which label a message lives in, and every subsequent behaviour is wrong: archiving from one view removes it from another, and a message in two labels appears in one. This is the modelling decision that cannot be corrected later without a rewrite. Done when: A message with three labels appears in all three views. Archiving removes it from the inbox and leaves it in the others. A message shows every label it carries. Removing the last label behaves as the provider defines rather than deleting locally. Do not build yet: No search or composing yet. ### Phase 4 · Instant search and scrolling Build: Full text search over locally stored messages, updated as they arrive. Paged thread lists rendering rows rather than loading everything. Generate a large fixture mailbox before optimising. Trap: Loading a whole thread list into memory works until a label has thirty thousand messages, and then the client stalls on opening it. Page the list and render rows, which is what a native list view is for and the reason to build this natively at all. Done when: Search over a hundred thousand messages returns in well under a hundred milliseconds. Opening a label with thirty thousand messages is instant and memory stays stable while scrolling. Search results include body text, not only subjects. A newly arrived message is searchable within a second. Do not build yet: No composing or offline actions yet. ### Phase 5 · Actions that work before the network does Build: Apply label changes locally at once and queue the server action. Drain the queue with retries. Reconcile when the server disagrees, preferring the server and telling the user rather than silently reverting. Trap: Applying locally and never reconciling means a failed action leaves the client showing a state the server does not have, and the message reappears days later with no explanation. Queue with retries, reconcile against the server on the next sync, and surface a conflict rather than flipping the message back silently. Done when: Archiving with networking disabled updates the view immediately and the action applies when connectivity returns. A permanently failing action is surfaced rather than retried forever. A server-side change that conflicts with a queued action is reconciled with a visible note. Quitting with queued actions preserves them. Do not build yet: No composing or distribution yet. ### Phase 6 · Composing, sending and shipping it Build: Compose and reply through the account's own sending service, preserving threading headers so replies thread correctly everywhere. Sign and notarize. State plainly what scopes were granted and what the application does with the mailbox. Trap: Dropping the reply headers breaks threading in every other client the recipient might use, and it looks like a new conversation to them. Preserve the message identifier chain on every reply, and verify by replying to yourself and checking the thread in a different client. Done when: A reply threads correctly when viewed in a different mail client. Sending happens through the account's service so the message appears in its sent items. A notarized build opens on a fresh Mac with no Gatekeeper warning. The README states which scopes are requested and why. Do not build yet: Nothing after this. ### Out of scope (and why) - Deep use of provider-specific features, which is where the commercial client's advantage lives and which is a long tail of behaviour. - Mail hosting and deliverability. Sending stays with the account's provider deliberately, and that is the right answer. - Push notification support on every platform, which needs infrastructure per platform. - Support for other mail providers, each of which has a different sync model and its own quirks. ### README must contain - That labels are modelled as labels and archiving removes one rather than moving a message - That sync is incremental with a documented full resynchronisation fallback - That tokens live in the keychain and no password is ever entered - That actions apply locally and queue, with conflicts reconciled visibly - Which authorisation scopes are requested and what each is used for
# Architecture · Mimestream ## Stack | Part | Choice | Why | | --- | --- | --- | | Language | Swift 6 with AppKit | the point of building this is native responsiveness with a large local mailbox | | Sync | The provider's history API for incremental changes | refetching a mailbox is slow, rate limited and unnecessary when the provider offers a change feed | | Storage | SQLite with full text search | a hundred thousand messages is small for a database and impossible to keep in memory | | Auth | The account's own authorisation flow, tokens in the keychain | you never see a password and the user can revoke access from their account settings | | Sending | The account's normal sending service | deliverability, signing and reputation stay with the provider, which is where they belong | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Auth | Authorisation and token storage. | Provider specific. Narrow scopes and keychain storage survive any change. | | Sync | Incremental change pulling and the full fallback. | Any change feed. The expired-cursor fallback is the part that must not be forgotten. | | Labels | The many to many relationship. | Not swappable for folders. This is the modelling decision the whole client rests on. | | Search and lists | Local performance. | Paging and local indexing are the reason to build natively at all. | | Outbox | Local-first actions with reconciliation. | Any queue, provided conflicts surface rather than reverting silently. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DB_PATH` · required · Local message cache. Contains your mail, so treat the file and its backups accordingly. - `OAUTH_CLIENT_ID` · required · From the provider's developer console. - `INITIAL_SYNC_DAYS` · required · How much mail to fetch before the client becomes usable. Older mail backfills after. - `MAX_RAW_CACHE_GB` · required · Cap on stored raw messages. Oldest are evicted and refetched on demand. - `ACTION_MAX_ATTEMPTS` · required · Retries before a queued action is surfaced as failed. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Mimestream product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Swift 6 with AppKit, The provider's history API for incremental changes, SQLite with full text search, The account's own authorisation flow, tokens in the keychain, The account's normal sending service. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Requesting broad scopes because it is simpler means the consent screen asks for more than you need and users refuse, and it means a token leak is worse than it had to be. Request the narrowest scopes and add one only when a feature actually needs it. - A change cursor expires after a period, and a client that treats an expired cursor as an error stops syncing silently until someone notices missing mail. Detect the specific response and fall back to a full resynchronisation rather than failing. - Mapping labels to a single folder column forces a choice about which label a message lives in, and every subsequent behaviour is wrong: archiving from one view removes it from another, and a message in two labels appears in one. This is the modelling decision that cannot be corrected later without a rewrite. - Loading a whole thread list into memory works until a label has thirty thousand messages, and then the client stalls on opening it. Page the list and render rows, which is what a native list view is for and the reason to build this natively at all. - Applying locally and never reconciling means a failed action leaves the client showing a state the server does not have, and the message reappears days later with no explanation. Queue with retries, reconcile against the server on the next sync, and surface a conflict rather than flipping the message back silently. - Dropping the reply headers breaks threading in every other client the recipient might use, and it looks like a new conversation to them. Preserve the message identifier chain on every reply, and verify by replying to yourself and checking the thread in a different client.
# Delivery milestones · Mimestream Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Authorising without touching a password The account is connected through its own authorisation flow and the token is in the keychain. ### Steps 1. Implement the authorisation flow Narrowest scopes. Add scopes when a feature needs one, not in advance. Files: `Auth.swift` 2. Store tokens in the keychain Referenced from the database, never stored in it. Inspect the file to confirm. Files: `Keychain.swift` 3. Handle revocation gracefully A clear reconnect prompt, local data intact. Files: `Auth.swift` ### Done when - [ ] No password is entered in the application - [ ] Tokens are in the keychain and absent from the database - [ ] Revocation produces a reconnect prompt - [ ] Refresh happens without user action ### Watch out - Requesting broad scopes because it is simpler means the consent screen asks for more than you need and users refuse, and it means a token leak is worse than it had to be. Request the narrowest scopes and add one only when a feature actually needs it. ## M2 · Incremental sync A mailbox with a hundred thousand messages syncs changes rather than refetching. ### Steps 1. Bound the initial sync Recent mail first so the client is usable in a minute, older mail backfilled after. Files: `Sync.swift` 2. Use the change feed Pull by history point. Store the point after each successful batch, not at the end. Files: `Sync.swift` 3. Handle an expired cursor Full resynchronisation, tested by corrupting the stored point deliberately. Files: `Sync.swift` ### Done when - [ ] Initial sync completes in a stated time with progress - [ ] Later syncs transfer only changes - [ ] An expired cursor triggers full resynchronisation - [ ] A server-side deletion disappears locally ### Watch out - A change cursor expires after a period, and a client that treats an expired cursor as an error stops syncing silently until someone notices missing mail. Detect the specific response and fall back to a full resynchronisation rather than failing. ## M3 · Labels modelled as labels A message in three labels appears in all three, and archiving removes one rather than moving anything. ### Steps 1. Model many to many A join table. Not a folder column with a favourite label. Files: `Labels.swift` 2. Make archiving a label removal Inbox is a label. Archiving removes it and nothing else. Files: `Actions.swift` 3. Show all labels on a message Rather than choosing one. Users know their messages are in several places. Files: `MessageView.swift` ### Done when - [ ] A message with three labels appears in all three views - [ ] Archiving removes only the inbox label - [ ] Every label on a message is shown - [ ] Removing the last label matches provider behaviour ### Watch out - Mapping labels to a single folder column forces a choice about which label a message lives in, and every subsequent behaviour is wrong: archiving from one view removes it from another, and a message in two labels appears in one. This is the modelling decision that cannot be corrected later without a rewrite. ## M4 · Instant search and scrolling Searching a hundred thousand messages is faster than the web client. ### Steps 1. Generate a large fixture mailbox A hundred thousand messages. Every performance claim depends on it existing first. Files: `Fixtures.swift` 2. Index on arrival Full text over subject and body, updated as messages sync. Files: `Search.swift` 3. Page the list Render rows, load a window. Never the whole label. Files: `ThreadList.swift` ### Done when - [ ] Search over a hundred thousand messages returns in under a hundred milliseconds - [ ] A thirty thousand message label opens instantly - [ ] Search covers body text - [ ] A new message is searchable within a second ### Watch out - Loading a whole thread list into memory works until a label has thirty thousand messages, and then the client stalls on opening it. Page the list and render rows, which is what a native list view is for and the reason to build this natively at all. ## M5 · Actions that work before the network does Archiving feels instant and is never lost, even offline. ### Steps 1. Apply locally, queue remotely The view updates at once; the queue makes it true. Files: `Actions.swift` 2. Retry with a cap Then surface the failure. A silently dropped action is the worst outcome. Files: `Outbox.swift` 3. Reconcile visibly Server wins, user is told. Silent reversion looks like a bug and destroys trust. Files: `Reconcile.swift` ### Done when - [ ] An offline archive applies when connectivity returns - [ ] A failing action is surfaced rather than retried forever - [ ] A conflict is reconciled with a visible note - [ ] Queued actions survive a quit ### Watch out - Applying locally and never reconciling means a failed action leaves the client showing a state the server does not have, and the message reappears days later with no explanation. Queue with retries, reconcile against the server on the next sync, and surface a conflict rather than flipping the message back silently. ## M6 · Composing, sending and shipping it (production only) You can reply correctly, and someone else can install it. ### Steps 1. Preserve threading headers The identifier chain on every reply. Verify in a different client rather than your own. Files: `Compose.swift` 2. Send through the account's service So deliverability and sent items both behave normally. Files: `Send.swift` 3. Sign, notarize and document scopes What was requested and what it is used for. Mail access deserves a plain statement. Files: `README.md` ```sh xcrun notarytool submit Mail.zip --keychain-profile AC --wait ``` ### Done when - [ ] A reply threads correctly in a different client - [ ] Sent messages appear in the account's sent items - [ ] spctl reports the notarized build accepted - [ ] The README states the scopes and their purpose ### Watch out - Dropping the reply headers breaks threading in every other client the recipient might use, and it looks like a new conversation to them. Preserve the message identifier chain on every reply, and verify by replying to yourself and checking the thread in a different client.
# Operations · Mimestream ## Backup The local cache is rebuildable from the server, so the thing worth protecting is the outbox and the authorisation, not the mail. That inverts the usual instinct and is worth stating. ## Restore Reauthorise and resynchronise. If a restore loses anything other than queued actions, something authoritative has leaked into the local cache. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Sync failure rate, outbox age and cache size. An outbox row older than a few minutes means actions are failing, which the user will otherwise discover when a message reappears. ## Incident checklist If mail stops arriving, check whether an expired cursor was treated as an error, since that failure is silent by design. If actions revert, the reconciliation is preferring the server correctly but not telling the user, which is a bug in the surfacing rather than the logic. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A message with three labels is confirmed to appear in all three views - [ ] An expired cursor is confirmed to trigger full resynchronisation - [ ] Search latency on the large fixture has not regressed - [ ] An offline action is confirmed to apply on reconnection - [ ] A reply is confirmed to thread correctly in a different client ## Launch constraint Do not market omitted Mimestream capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Local message cache. Contains your mail, so treat the file and its backups accordingly. DB_PATH=~/Library/Application Support/Mail/mail.db # Required. From the provider's developer console. OAUTH_CLIENT_ID=replace-with-client-id # Required. How much mail to fetch before the client becomes usable. Older mail backfills after. INITIAL_SYNC_DAYS=90 # Required. Cap on stored raw messages. Oldest are evicted and refetched on demand. MAX_RAW_CACHE_GB=10 # Required. Retries before a queued action is surfaced as failed. ACTION_MAX_ATTEMPTS=6
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Mimestream because an email client can be rebuilt; operating a trusted mail service with excellent deliverability cannot. The recurring cost buys IMAP quirks, OAuth, MIME, threading, search, attachments, offline sync, notifications, secure storage, and provider changes, not just the visible interface.
xdeep Gmail API use, native macOS polish, sync reliability, and support
xmail hosting and deliverability
xpush on every platform
xadvanced team collaboration
xprovider-specific AI and search
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Mimestream pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| individual | $4.99 | $4.17 | All supported Google accounts; up to 5 devices; sharing with immediate family in the same household. |
| group | $4.99/user | $4.17/user | 1 seat per user; self-service purchase for up to 50 seats; contact sales above 50 seats. |
free tierno free tier; 14-day fully functional trial, no credit card
billingmonthly + annual; auto-renews; no one-time or lifetime purchase
hidden costsSales tax or VAT may apply; promotional prices apply only for the stated promotional term. Group plans with 10+ seats may temporarily exceed the purchased seat count for 30 days, after which enough seats must be bought; monthly payments are non-refundable.
verified 2026-08-12 · source ↗
Vibecode Mimestream
Kinda. The core of Mimestream is buildable in a weekend with the prompt on this page, but there are real gaps: deep Gmail API use, native macOS polish, sync reliability, and support, mail hosting and deliverability. Read the honest list above before committing.
How much does Mimestream cost?
Mimestream costs about $4.99/month (Individual, checked 2026-07-31), which is $59.88 per year.
What do I lose by replacing Mimestream?
Honestly: deep Gmail API use, native macOS polish, sync reliability, and support; mail hosting and deliverability; push on every platform; advanced team collaboration; provider-specific AI and search. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Mimestream?
Yes: Apple Mail (The native Mac client with a local cache and first-class Gmail support; it uses Apple's ideas about Gmail, not Mimestream's.) The prompt is for when you want it exactly your way.