Vibecode Mailbutler
track this build4 phases, 12 steps, beginner friendly0%The visible email productivity add-on loop is buildable, but a credible replacement needs more than the first screen. Mailbutler earns its keep through deliverability, security, integration, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Mailbutler. 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 ===== # Mailbutler · indie build A local companion for one mailbox whose hard problems are keeping annotations attached to the right message when a server changes its identifiers, cancelling a follow-up only when a real reply arrives, and reporting tracking honestly enough to be worth having. Estimated effort: **weekend**. 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 | | --- | --- | --- | | Access | IMAP and SMTP against one mailbox, with an app password or OAuth | one mailbox you own, not a shared inbox product with a permission model | | Identity | Message-Id as the durable key, with folder, UID and UIDVALIDITY as a mapping | a UID means nothing outside its UIDVALIDITY, and servers do change it | | Storage | SQLite holding your annotations and an index of the mailbox, not a copy of it | a full local copy is a second mailbox to secure and a second one to lose | | Scheduled send | A local scheduler holding the composed message on disk until its time | a scheduled send that needs a server is a different product with different risks | | Secrets | The platform keychain, never the database and never a file in the repository | a mailbox password in a plain file is the entire mailbox | | Tracking | Off by default, reported as a remote content fetch and never as a read | the honest version of this feature is less impressive and considerably more useful | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Rust toolchain and the Tauri 2 prerequisites** · free - Why: A desktop process that holds a mailbox credential and a local database, rather than a server that holds your mail. - Get it: rustup.rs, then the Tauri 2 prerequisites page for your platform. - Verify: cargo --version prints and a blank Tauri 2 application launches - [ ] **Node.js 22 or newer** · free - Why: Builds the interface and runs its tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A mailbox you own, with an app password or OAuth client** · free with most providers - Why: This build touches exactly one mailbox, and using an app password rather than your account password means the credential can be revoked on its own. - Get it: Create an app password in your provider's security settings, or register an OAuth client with mail scopes. - Verify: An IMAP session lists folders and an SMTP session authenticates - [ ] **A local IMAP server you can break on purpose** · free - Why: Forcing a UIDVALIDITY change is the only way to test the remap, and you cannot do it on a real mailbox. - Get it: Any small IMAP server you can run locally and reset, seeded with a few thousand messages. - Verify: UIDVALIDITY can be changed on demand and the client notices - [ ] **A realistic mailbox export, including the awkward threads** · free - Why: Threading, auto-replies and forwarded chains only go wrong on real mail, and a generated corpus has none of those shapes. - Get it: Export your own mail, or assemble a few thousand messages including auto-responders and a thread with a reused subject. - Verify: The corpus contains at least one auto-reply and one unrelated message sharing a subject - [ ] **Your missed window, idle lock and tracking stance** · free - Why: All three are asserted in tests, and the tracking stance in particular is a promise the interface has to keep. - Get it: Write down how late a scheduled send may still go, how long until the application locks, and exactly what wording the tracking panel uses. - Verify: All three appear in configuration and are named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Shared inboxes and team assignment. - Two-way CRM synchronisation. - A hosted tracking service. - Mail merge at volume. - team routing and shared inbox rules - abuse response and security operations - calendar-provider edge cases and timezone correctness - provider-grade deliverability - push reliability across devices If one of those is essential to you, that is the reason to keep paying for Mailbutler, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Mailbutler 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 an email companion that adds notes, follow-ups and scheduled send to one mailbox you own to replace Mailbutler. 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 Two rules. The first is that this tool does not change your mail. It reads a mailbox over IMAP, sends only what you explicitly compose, and never deletes, moves or rewrites a message. Notes, tasks and follow-ups live in a local database keyed on the message identifier, not inside the messages, so every other client you own keeps working exactly as it did. The second is about tracking, and it is the one everybody skips. Apple's Mail Privacy Protection downloads remote content in the background by default, regardless of whether the recipient engaged with the message, and routes it through two relays so that the address you see is not theirs. A tracking pixel firing therefore tells you that a machine fetched an image, and nothing more. Report exactly that. Never render a sentence like 'read at half past two in Berlin' from a pixel load, never use one to decide somebody is ignoring you, and keep the whole feature off by default with a plain statement of what it can and cannot tell you. ### Stack (fixed, do not substitute) - Access: IMAP and SMTP against one mailbox, with an app password or OAuth · one mailbox you own, not a shared inbox product with a permission model - Identity: Message-Id as the durable key, with folder, UID and UIDVALIDITY as a mapping · a UID means nothing outside its UIDVALIDITY, and servers do change it - Storage: SQLite holding your annotations and an index of the mailbox, not a copy of it · a full local copy is a second mailbox to secure and a second one to lose - Scheduled send: A local scheduler holding the composed message on disk until its time · a scheduled send that needs a server is a different product with different risks - Secrets: The platform keychain, never the database and never a file in the repository · a mailbox password in a plain file is the entire mailbox - Tracking: Off by default, reported as a remote content fetch and never as a read · the honest version of this feature is less impressive and considerably more useful ### Data model (create this before Phase 1) - accounts(id, host, port, username, secret_ref, uidvalidity, last_seen_uid, last_sync_at, state) · secret_ref names a keychain entry and never holds a value - messages(message_id, account_id, folder, uid, uidvalidity, subject, from_addr, to_json, date, snippet, thread_key) · an index, with bodies fetched on demand - notes(message_id, body_md, created_at, updated_at) · yours, local, and never written into the message - tasks(id, message_id, title, due_at, done_at) - followups(id, thread_key, message_id, due_at, cancel_on_reply, state, cancelled_by_message_id) · the cancelling message is recorded, so a wrong cancellation is explainable - scheduled_sends(id, mime_path, to_json, send_at, state, attempts, sent_message_id) · the composed message sits on disk, so a restart loses nothing - remote_fetches(message_id, at, user_agent, network_class, verdict) · verdict is fetched, and no value in this column means read - orphans(annotation_kind, annotation_id, lost_message_id, at) · annotations whose message could not be remapped, shown rather than reattached by guesswork ### Phase 1 · Reading a mailbox without breaking it Build: Connect over TLS, select folders read-only where the server supports it, and fetch incrementally by UID within the current UIDVALIDITY. Store Message-Id as the durable key for every annotation. Detect a UIDVALIDITY change and treat it as a full remap. Issue no delete, move or expunge command anywhere in the codebase. Trap: Caching messages by UID and resuming after the server has changed UIDVALIDITY points every stored annotation at a different message, and on a large mailbox nobody notices until a private note about a difficult client appears attached to a stranger's email. Key annotations on Message-Id, treat a UIDVALIDITY change as a remap of the whole folder, and show an annotation whose remap failed as orphaned rather than attaching it to a best guess. Done when: A forced UIDVALIDITY change triggers a remap and no annotation lands on a different message, tested against a local test server. No delete, move or expunge command exists in the codebase, verified by search and by review. Reconnecting after a dropped connection resumes from the last seen UID rather than refetching the folder. An annotation that cannot be remapped appears in the orphans list and is attached to nothing. Do not build yet: No notes, follow-ups, sending or tracking. ### Phase 2 · Notes and tasks that live beside the mail Build: Notes in Markdown per message and tasks with due dates, all stored locally. Full text search across notes and indexed headers. Export as one file per annotated message plus a manifest that maps them back to message identifiers, and an import that restores them into a fresh database. Trap: Storing a note by appending to the message body, or by uploading an edited copy back to the folder, rewrites the message, changes its identifier, invalidates its signature and leaves two copies in the mailbox, and the person discovers it when their phone shows the same email twice. Keep annotations in the local database and never upload a modified copy of an existing message. Done when: Adding a note leaves the message bytes on the server byte-identical, verified by fetching and comparing before and after. Notes and tasks export to files and reimport into a fresh database with their message links intact. Search over notes and indexed headers returns within a stated time on a mailbox of fifty thousand messages. No code path uploads a modified copy of an existing message, verified by review. Do not build yet: No follow-ups, scheduled send or tracking yet. ### Phase 3 · Follow-ups that cancel themselves Build: Build the thread key from References and In-Reply-To, falling back to a normalised subject only when both headers are absent. Set a follow-up on a sent message with a due date. Cancel it when a message arrives in the same thread from somebody other than you, recording which message cancelled it. Ignore automatic replies that identify themselves as such. Trap: Matching a thread on the subject line alone means an unrelated message headed 'Re: invoice' from a different person quietly cancels your follow-up, and a holiday auto-responder cancels it just as effectively, so the follow-up you were relying on simply never appears. Thread on References and In-Reply-To, fall back to the subject only when both are missing, and skip messages that declare themselves automatic. Done when: A reply threaded by References cancels the follow-up and the cancelling message is recorded, tested. An unrelated message with the same subject does not cancel it, tested with a crafted pair. An automatic reply that identifies itself does not cancel a follow-up. A message you sent yourself into the thread does not cancel your own follow-up. Do not build yet: No scheduled send or tracking yet. ### Phase 4 · Scheduled send that sends once, or not at all Build: Compose to a MIME file on disk with its send time recorded beside it. The scheduler claims the row in its own transaction before opening an SMTP connection, and records the resulting identifier afterwards. Decide a missed window policy in configuration: send if within a stated number of minutes, otherwise hold and ask. Trap: A laptop asleep at the send time either wakes at eleven and sends a good-morning message at an hour that reads oddly, or never sends it and nobody notices until the recipient does not reply. Decide the missed window explicitly, show held messages on the list with the reason, and test the behaviour by suspending the machine across a scheduled time. Done when: A machine suspended across a scheduled time applies the documented missed window policy, tested by suspending it. Claiming the row before connecting means a crash between the server accepting and the database recording leaves a row to reconcile rather than a second send, tested with a counting fake server. A composed message survives a restart because it is on disk. A sent row records its identifier and is never claimed again. Do not build yet: No tracking, keychain handling or export yet. ### Phase 5 · Telling the truth about tracking, and holding the credential properly Build: Keep tracking off after installation and require an explicit action to enable it. When enabled, add a disclosure line to the message. Report a pixel load as remote content fetched, with the caveat about privacy proxies shown beside the number and no location at all. Store the mailbox secret in the platform keychain, lock the application on idle, and provide one command that deletes every local file this tool created. Trap: Recording a pixel fetch as a read and deriving a city from the connecting address produces confident statements that are frequently false, because privacy proxies fetch every image on receipt and relay it from somewhere else entirely, and acting on those statements damages real relationships with people who did nothing. Report a fetch as a fetch, drop location entirely, and put the caveat next to the number rather than in the documentation. Done when: Tracking is off after a fresh install and enabling it requires an explicit action, tested. With tracking on, every message carries the disclosure line, asserted in the suite. No template renders a location or the word read for a remote content fetch, verified by searching the templates in the suite. The mailbox secret is in the platform keychain and appears in no file and no log, verified by search. Do not build yet: Nothing after this. ### Out of scope (and why) - Shared inboxes, assignment and team rules, which need a permission model and turn a personal tool into a service holding other people's mail. - Two-way CRM synchronisation, which is a maintained integration per system and is most of what the paid product charges for. - A hosted tracking service, which would make you the custodian of other people's recipients' behaviour. - Mail merge at volume through a personal mailbox, which is bulk marketing wearing a disguise and is how a personal address gets suspended. ### README must contain - That the tool never deletes, moves or rewrites a message, and how that is proved in the test suite - That annotations are keyed on the message identifier, and what happens to one whose message cannot be remapped - That a follow-up cancels on a real threaded reply and not on a matching subject or an auto-responder - What a tracking pixel actually reports, that privacy proxies fetch images on receipt, and why no location is shown - That the mailbox secret lives in the platform keychain and how to remove every local file the tool created ===== AGENTS.md ===== # Agent instructions · Mailbutler indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: IMAP and SMTP against one mailbox, with an app password or OAuth, Message-Id as the durable key, with folder, UID and UIDVALIDITY as a mapping, SQLite holding your annotations and an index of the mailbox, not a copy of it, A local scheduler holding the composed message on disk until its time, The platform keychain, never the database and never a file in the repository, Off by default, reported as a remote content fetch and never as a read. 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 - Caching messages by UID and resuming after the server has changed UIDVALIDITY points every stored annotation at a different message, and on a large mailbox nobody notices until a private note about a difficult client appears attached to a stranger's email. Key annotations on Message-Id, treat a UIDVALIDITY change as a remap of the whole folder, and show an annotation whose remap failed as orphaned rather than attaching it to a best guess. - Storing a note by appending to the message body, or by uploading an edited copy back to the folder, rewrites the message, changes its identifier, invalidates its signature and leaves two copies in the mailbox, and the person discovers it when their phone shows the same email twice. Keep annotations in the local database and never upload a modified copy of an existing message. - Matching a thread on the subject line alone means an unrelated message headed 'Re: invoice' from a different person quietly cancels your follow-up, and a holiday auto-responder cancels it just as effectively, so the follow-up you were relying on simply never appears. Thread on References and In-Reply-To, fall back to the subject only when both are missing, and skip messages that declare themselves automatic. - A laptop asleep at the send time either wakes at eleven and sends a good-morning message at an hour that reads oddly, or never sends it and nobody notices until the recipient does not reply. Decide the missed window explicitly, show held messages on the list with the reason, and test the behaviour by suspending the machine across a scheduled time. ===== BUILD_PLAN.md ===== # Build plan · Mailbutler A local companion for one mailbox whose hard problems are keeping annotations attached to the right message when a server changes its identifiers, cancelling a follow-up only when a real reply arrives, and reporting tracking honestly enough to be worth having. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Reading a mailbox without breaking it The index survives a server changing its identifiers, and nothing is ever deleted. ### Steps 1. Key annotations on Message-Id It is the only identifier that survives the server's housekeeping. Files: `src/mail/identity.ts`, `tests/uidvalidity.test.ts` 2. Select read-only where the server allows it The strongest guarantee is the one the protocol enforces. Files: `src/mail/imap.ts` 3. Show orphans instead of guessing A note on the wrong email is worse than a note with no email. Files: `src/ui/Orphans.tsx` ### Done when - [ ] A UIDVALIDITY change remaps without misattaching - [ ] No delete, move or expunge exists in the code - [ ] Reconnect resumes from the last seen UID - [ ] Unremappable annotations show as orphans ### Watch out - Caching messages by UID and resuming after the server has changed UIDVALIDITY points every stored annotation at a different message, and on a large mailbox nobody notices until a private note about a difficult client appears attached to a stranger's email. Key annotations on Message-Id, treat a UIDVALIDITY change as a remap of the whole folder, and show an annotation whose remap failed as orphaned rather than attaching it to a best guess. ## Phase 2 · Notes and tasks that live beside the mail Annotations are local, exportable, and invisible to every other client. ### Steps 1. Store annotations locally, keyed on the identifier The mailbox stays exactly as the other clients left it. Files: `src/store/notes.ts` 2. Compare message bytes before and after The test that proves the first rule rather than asserting it. Files: `tests/untouched.test.ts` 3. Export with a manifest that maps back Annotations without their message links are a pile of text. Files: `src/export/annotations.ts` ### Done when - [ ] Message bytes are unchanged after annotating - [ ] Export and reimport preserve message links - [ ] Search meets its budget on a large mailbox - [ ] Nothing uploads a modified message ### Watch out - Storing a note by appending to the message body, or by uploading an edited copy back to the folder, rewrites the message, changes its identifier, invalidates its signature and leaves two copies in the mailbox, and the person discovers it when their phone shows the same email twice. Keep annotations in the local database and never upload a modified copy of an existing message. ## Phase 3 · Follow-ups that cancel themselves A follow-up disappears the moment the person actually replies, and not before. ### Steps 1. Thread on References and In-Reply-To Subjects are a last resort, not a strategy. Files: `src/mail/threading.ts`, `tests/threading.test.ts` 2. Record what cancelled a follow-up So a wrong cancellation can be explained rather than argued about. Files: `src/followups/cancel.ts` 3. Skip self-declared automatic replies A holiday responder is not a reply. Files: `src/mail/autoreply.ts` ### Done when - [ ] A real reply cancels and is recorded - [ ] A same-subject stranger does not cancel - [ ] Automatic replies do not cancel - [ ] Your own message does not cancel ### Watch out - Matching a thread on the subject line alone means an unrelated message headed 'Re: invoice' from a different person quietly cancels your follow-up, and a holiday auto-responder cancels it just as effectively, so the follow-up you were relying on simply never appears. Thread on References and In-Reply-To, fall back to the subject only when both are missing, and skip messages that declare themselves automatic. ## Phase 4 · Scheduled send that sends once, or not at all A message scheduled for the morning either goes at the right time or explains why it did not. ### Steps 1. Claim the row before connecting The lock happens before the network, or it does not help. Files: `src/schedule/send.ts`, `tests/once.test.ts` 2. Keep the composed message on disk A restart should cost nothing but a moment. Files: `src/compose/spool.ts` 3. Make the missed window a decision Held with a reason beats sent at a strange hour. Files: `src/schedule/policy.ts` ### Done when - [ ] A suspend across the time applies the policy - [ ] A crash mid-send leaves one message, not two - [ ] Composed messages survive a restart - [ ] Sent rows record an identifier and are final ### Watch out - A laptop asleep at the send time either wakes at eleven and sends a good-morning message at an hour that reads oddly, or never sends it and nobody notices until the recipient does not reply. Decide the missed window explicitly, show held messages on the list with the reason, and test the behaviour by suspending the machine across a scheduled time. ## Not in this build - Shared inboxes and team assignment. - Two-way CRM synchronisation. - A hosted tracking service. - Mail merge at volume. ## After v1, if you want it - Snoozing a message locally, which needs nothing the follow-up machinery does not already have - Link click recording with the same honest wording, once the tracking panel has proved it refuses to overclaim - A second mailbox, which mostly means proving the identifier mapping holds per account ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The local database and the composed message spool. Removed entirely by the delete command. DATA_DIR=~/.local/share/mailcompanion # Required. One mailbox, over TLS. IMAP_HOST=imap.example.com # Required. The account. Its secret is in the keychain, not here. IMAP_USERNAME=you@example.com # Required. Names the keychain entry holding the mailbox secret. KEYCHAIN_SERVICE=mailcompanion # Required. Used only when you send something you composed. SMTP_HOST=smtp.example.com # Required. A scheduled send later than this is held and asked about rather than sent. MISSED_WINDOW_MINUTES=30 # Required. Locks the application. An unlocked mailbox on a shared desk is the exposure. IDLE_LOCK_MINUTES=15 # Required. False after installation. Enabling it adds a disclosure line to every message. TRACKING_ENABLED=false
You are building a lean indie version of Mailbutler. 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 ===== # Mailbutler · indie build A local companion for one mailbox whose hard problems are keeping annotations attached to the right message when a server changes its identifiers, cancelling a follow-up only when a real reply arrives, and reporting tracking honestly enough to be worth having. Estimated effort: **weekend**. 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 | | --- | --- | --- | | Access | IMAP and SMTP against one mailbox, with an app password or OAuth | one mailbox you own, not a shared inbox product with a permission model | | Identity | Message-Id as the durable key, with folder, UID and UIDVALIDITY as a mapping | a UID means nothing outside its UIDVALIDITY, and servers do change it | | Storage | SQLite holding your annotations and an index of the mailbox, not a copy of it | a full local copy is a second mailbox to secure and a second one to lose | | Scheduled send | A local scheduler holding the composed message on disk until its time | a scheduled send that needs a server is a different product with different risks | | Secrets | The platform keychain, never the database and never a file in the repository | a mailbox password in a plain file is the entire mailbox | | Tracking | Off by default, reported as a remote content fetch and never as a read | the honest version of this feature is less impressive and considerably more useful | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Rust toolchain and the Tauri 2 prerequisites** · free - Why: A desktop process that holds a mailbox credential and a local database, rather than a server that holds your mail. - Get it: rustup.rs, then the Tauri 2 prerequisites page for your platform. - Verify: cargo --version prints and a blank Tauri 2 application launches - [ ] **Node.js 22 or newer** · free - Why: Builds the interface and runs its tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A mailbox you own, with an app password or OAuth client** · free with most providers - Why: This build touches exactly one mailbox, and using an app password rather than your account password means the credential can be revoked on its own. - Get it: Create an app password in your provider's security settings, or register an OAuth client with mail scopes. - Verify: An IMAP session lists folders and an SMTP session authenticates - [ ] **A local IMAP server you can break on purpose** · free - Why: Forcing a UIDVALIDITY change is the only way to test the remap, and you cannot do it on a real mailbox. - Get it: Any small IMAP server you can run locally and reset, seeded with a few thousand messages. - Verify: UIDVALIDITY can be changed on demand and the client notices - [ ] **A realistic mailbox export, including the awkward threads** · free - Why: Threading, auto-replies and forwarded chains only go wrong on real mail, and a generated corpus has none of those shapes. - Get it: Export your own mail, or assemble a few thousand messages including auto-responders and a thread with a reused subject. - Verify: The corpus contains at least one auto-reply and one unrelated message sharing a subject - [ ] **Your missed window, idle lock and tracking stance** · free - Why: All three are asserted in tests, and the tracking stance in particular is a promise the interface has to keep. - Get it: Write down how late a scheduled send may still go, how long until the application locks, and exactly what wording the tracking panel uses. - Verify: All three appear in configuration and are named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Shared inboxes and team assignment. - Two-way CRM synchronisation. - A hosted tracking service. - Mail merge at volume. - team routing and shared inbox rules - abuse response and security operations - calendar-provider edge cases and timezone correctness - provider-grade deliverability - push reliability across devices If one of those is essential to you, that is the reason to keep paying for Mailbutler, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Mailbutler 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 an email companion that adds notes, follow-ups and scheduled send to one mailbox you own to replace Mailbutler. 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 Two rules. The first is that this tool does not change your mail. It reads a mailbox over IMAP, sends only what you explicitly compose, and never deletes, moves or rewrites a message. Notes, tasks and follow-ups live in a local database keyed on the message identifier, not inside the messages, so every other client you own keeps working exactly as it did. The second is about tracking, and it is the one everybody skips. Apple's Mail Privacy Protection downloads remote content in the background by default, regardless of whether the recipient engaged with the message, and routes it through two relays so that the address you see is not theirs. A tracking pixel firing therefore tells you that a machine fetched an image, and nothing more. Report exactly that. Never render a sentence like 'read at half past two in Berlin' from a pixel load, never use one to decide somebody is ignoring you, and keep the whole feature off by default with a plain statement of what it can and cannot tell you. ### Stack (fixed, do not substitute) - Access: IMAP and SMTP against one mailbox, with an app password or OAuth · one mailbox you own, not a shared inbox product with a permission model - Identity: Message-Id as the durable key, with folder, UID and UIDVALIDITY as a mapping · a UID means nothing outside its UIDVALIDITY, and servers do change it - Storage: SQLite holding your annotations and an index of the mailbox, not a copy of it · a full local copy is a second mailbox to secure and a second one to lose - Scheduled send: A local scheduler holding the composed message on disk until its time · a scheduled send that needs a server is a different product with different risks - Secrets: The platform keychain, never the database and never a file in the repository · a mailbox password in a plain file is the entire mailbox - Tracking: Off by default, reported as a remote content fetch and never as a read · the honest version of this feature is less impressive and considerably more useful ### Data model (create this before Phase 1) - accounts(id, host, port, username, secret_ref, uidvalidity, last_seen_uid, last_sync_at, state) · secret_ref names a keychain entry and never holds a value - messages(message_id, account_id, folder, uid, uidvalidity, subject, from_addr, to_json, date, snippet, thread_key) · an index, with bodies fetched on demand - notes(message_id, body_md, created_at, updated_at) · yours, local, and never written into the message - tasks(id, message_id, title, due_at, done_at) - followups(id, thread_key, message_id, due_at, cancel_on_reply, state, cancelled_by_message_id) · the cancelling message is recorded, so a wrong cancellation is explainable - scheduled_sends(id, mime_path, to_json, send_at, state, attempts, sent_message_id) · the composed message sits on disk, so a restart loses nothing - remote_fetches(message_id, at, user_agent, network_class, verdict) · verdict is fetched, and no value in this column means read - orphans(annotation_kind, annotation_id, lost_message_id, at) · annotations whose message could not be remapped, shown rather than reattached by guesswork ### Phase 1 · Reading a mailbox without breaking it Build: Connect over TLS, select folders read-only where the server supports it, and fetch incrementally by UID within the current UIDVALIDITY. Store Message-Id as the durable key for every annotation. Detect a UIDVALIDITY change and treat it as a full remap. Issue no delete, move or expunge command anywhere in the codebase. Trap: Caching messages by UID and resuming after the server has changed UIDVALIDITY points every stored annotation at a different message, and on a large mailbox nobody notices until a private note about a difficult client appears attached to a stranger's email. Key annotations on Message-Id, treat a UIDVALIDITY change as a remap of the whole folder, and show an annotation whose remap failed as orphaned rather than attaching it to a best guess. Done when: A forced UIDVALIDITY change triggers a remap and no annotation lands on a different message, tested against a local test server. No delete, move or expunge command exists in the codebase, verified by search and by review. Reconnecting after a dropped connection resumes from the last seen UID rather than refetching the folder. An annotation that cannot be remapped appears in the orphans list and is attached to nothing. Do not build yet: No notes, follow-ups, sending or tracking. ### Phase 2 · Notes and tasks that live beside the mail Build: Notes in Markdown per message and tasks with due dates, all stored locally. Full text search across notes and indexed headers. Export as one file per annotated message plus a manifest that maps them back to message identifiers, and an import that restores them into a fresh database. Trap: Storing a note by appending to the message body, or by uploading an edited copy back to the folder, rewrites the message, changes its identifier, invalidates its signature and leaves two copies in the mailbox, and the person discovers it when their phone shows the same email twice. Keep annotations in the local database and never upload a modified copy of an existing message. Done when: Adding a note leaves the message bytes on the server byte-identical, verified by fetching and comparing before and after. Notes and tasks export to files and reimport into a fresh database with their message links intact. Search over notes and indexed headers returns within a stated time on a mailbox of fifty thousand messages. No code path uploads a modified copy of an existing message, verified by review. Do not build yet: No follow-ups, scheduled send or tracking yet. ### Phase 3 · Follow-ups that cancel themselves Build: Build the thread key from References and In-Reply-To, falling back to a normalised subject only when both headers are absent. Set a follow-up on a sent message with a due date. Cancel it when a message arrives in the same thread from somebody other than you, recording which message cancelled it. Ignore automatic replies that identify themselves as such. Trap: Matching a thread on the subject line alone means an unrelated message headed 'Re: invoice' from a different person quietly cancels your follow-up, and a holiday auto-responder cancels it just as effectively, so the follow-up you were relying on simply never appears. Thread on References and In-Reply-To, fall back to the subject only when both are missing, and skip messages that declare themselves automatic. Done when: A reply threaded by References cancels the follow-up and the cancelling message is recorded, tested. An unrelated message with the same subject does not cancel it, tested with a crafted pair. An automatic reply that identifies itself does not cancel a follow-up. A message you sent yourself into the thread does not cancel your own follow-up. Do not build yet: No scheduled send or tracking yet. ### Phase 4 · Scheduled send that sends once, or not at all Build: Compose to a MIME file on disk with its send time recorded beside it. The scheduler claims the row in its own transaction before opening an SMTP connection, and records the resulting identifier afterwards. Decide a missed window policy in configuration: send if within a stated number of minutes, otherwise hold and ask. Trap: A laptop asleep at the send time either wakes at eleven and sends a good-morning message at an hour that reads oddly, or never sends it and nobody notices until the recipient does not reply. Decide the missed window explicitly, show held messages on the list with the reason, and test the behaviour by suspending the machine across a scheduled time. Done when: A machine suspended across a scheduled time applies the documented missed window policy, tested by suspending it. Claiming the row before connecting means a crash between the server accepting and the database recording leaves a row to reconcile rather than a second send, tested with a counting fake server. A composed message survives a restart because it is on disk. A sent row records its identifier and is never claimed again. Do not build yet: No tracking, keychain handling or export yet. ### Phase 5 · Telling the truth about tracking, and holding the credential properly Build: Keep tracking off after installation and require an explicit action to enable it. When enabled, add a disclosure line to the message. Report a pixel load as remote content fetched, with the caveat about privacy proxies shown beside the number and no location at all. Store the mailbox secret in the platform keychain, lock the application on idle, and provide one command that deletes every local file this tool created. Trap: Recording a pixel fetch as a read and deriving a city from the connecting address produces confident statements that are frequently false, because privacy proxies fetch every image on receipt and relay it from somewhere else entirely, and acting on those statements damages real relationships with people who did nothing. Report a fetch as a fetch, drop location entirely, and put the caveat next to the number rather than in the documentation. Done when: Tracking is off after a fresh install and enabling it requires an explicit action, tested. With tracking on, every message carries the disclosure line, asserted in the suite. No template renders a location or the word read for a remote content fetch, verified by searching the templates in the suite. The mailbox secret is in the platform keychain and appears in no file and no log, verified by search. Do not build yet: Nothing after this. ### Out of scope (and why) - Shared inboxes, assignment and team rules, which need a permission model and turn a personal tool into a service holding other people's mail. - Two-way CRM synchronisation, which is a maintained integration per system and is most of what the paid product charges for. - A hosted tracking service, which would make you the custodian of other people's recipients' behaviour. - Mail merge at volume through a personal mailbox, which is bulk marketing wearing a disguise and is how a personal address gets suspended. ### README must contain - That the tool never deletes, moves or rewrites a message, and how that is proved in the test suite - That annotations are keyed on the message identifier, and what happens to one whose message cannot be remapped - That a follow-up cancels on a real threaded reply and not on a matching subject or an auto-responder - What a tracking pixel actually reports, that privacy proxies fetch images on receipt, and why no location is shown - That the mailbox secret lives in the platform keychain and how to remove every local file the tool created ===== AGENTS.md ===== # Agent instructions · Mailbutler indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: IMAP and SMTP against one mailbox, with an app password or OAuth, Message-Id as the durable key, with folder, UID and UIDVALIDITY as a mapping, SQLite holding your annotations and an index of the mailbox, not a copy of it, A local scheduler holding the composed message on disk until its time, The platform keychain, never the database and never a file in the repository, Off by default, reported as a remote content fetch and never as a read. 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 - Caching messages by UID and resuming after the server has changed UIDVALIDITY points every stored annotation at a different message, and on a large mailbox nobody notices until a private note about a difficult client appears attached to a stranger's email. Key annotations on Message-Id, treat a UIDVALIDITY change as a remap of the whole folder, and show an annotation whose remap failed as orphaned rather than attaching it to a best guess. - Storing a note by appending to the message body, or by uploading an edited copy back to the folder, rewrites the message, changes its identifier, invalidates its signature and leaves two copies in the mailbox, and the person discovers it when their phone shows the same email twice. Keep annotations in the local database and never upload a modified copy of an existing message. - Matching a thread on the subject line alone means an unrelated message headed 'Re: invoice' from a different person quietly cancels your follow-up, and a holiday auto-responder cancels it just as effectively, so the follow-up you were relying on simply never appears. Thread on References and In-Reply-To, fall back to the subject only when both are missing, and skip messages that declare themselves automatic. - A laptop asleep at the send time either wakes at eleven and sends a good-morning message at an hour that reads oddly, or never sends it and nobody notices until the recipient does not reply. Decide the missed window explicitly, show held messages on the list with the reason, and test the behaviour by suspending the machine across a scheduled time. ===== BUILD_PLAN.md ===== # Build plan · Mailbutler A local companion for one mailbox whose hard problems are keeping annotations attached to the right message when a server changes its identifiers, cancelling a follow-up only when a real reply arrives, and reporting tracking honestly enough to be worth having. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Reading a mailbox without breaking it The index survives a server changing its identifiers, and nothing is ever deleted. ### Steps 1. Key annotations on Message-Id It is the only identifier that survives the server's housekeeping. Files: `src/mail/identity.ts`, `tests/uidvalidity.test.ts` 2. Select read-only where the server allows it The strongest guarantee is the one the protocol enforces. Files: `src/mail/imap.ts` 3. Show orphans instead of guessing A note on the wrong email is worse than a note with no email. Files: `src/ui/Orphans.tsx` ### Done when - [ ] A UIDVALIDITY change remaps without misattaching - [ ] No delete, move or expunge exists in the code - [ ] Reconnect resumes from the last seen UID - [ ] Unremappable annotations show as orphans ### Watch out - Caching messages by UID and resuming after the server has changed UIDVALIDITY points every stored annotation at a different message, and on a large mailbox nobody notices until a private note about a difficult client appears attached to a stranger's email. Key annotations on Message-Id, treat a UIDVALIDITY change as a remap of the whole folder, and show an annotation whose remap failed as orphaned rather than attaching it to a best guess. ## Phase 2 · Notes and tasks that live beside the mail Annotations are local, exportable, and invisible to every other client. ### Steps 1. Store annotations locally, keyed on the identifier The mailbox stays exactly as the other clients left it. Files: `src/store/notes.ts` 2. Compare message bytes before and after The test that proves the first rule rather than asserting it. Files: `tests/untouched.test.ts` 3. Export with a manifest that maps back Annotations without their message links are a pile of text. Files: `src/export/annotations.ts` ### Done when - [ ] Message bytes are unchanged after annotating - [ ] Export and reimport preserve message links - [ ] Search meets its budget on a large mailbox - [ ] Nothing uploads a modified message ### Watch out - Storing a note by appending to the message body, or by uploading an edited copy back to the folder, rewrites the message, changes its identifier, invalidates its signature and leaves two copies in the mailbox, and the person discovers it when their phone shows the same email twice. Keep annotations in the local database and never upload a modified copy of an existing message. ## Phase 3 · Follow-ups that cancel themselves A follow-up disappears the moment the person actually replies, and not before. ### Steps 1. Thread on References and In-Reply-To Subjects are a last resort, not a strategy. Files: `src/mail/threading.ts`, `tests/threading.test.ts` 2. Record what cancelled a follow-up So a wrong cancellation can be explained rather than argued about. Files: `src/followups/cancel.ts` 3. Skip self-declared automatic replies A holiday responder is not a reply. Files: `src/mail/autoreply.ts` ### Done when - [ ] A real reply cancels and is recorded - [ ] A same-subject stranger does not cancel - [ ] Automatic replies do not cancel - [ ] Your own message does not cancel ### Watch out - Matching a thread on the subject line alone means an unrelated message headed 'Re: invoice' from a different person quietly cancels your follow-up, and a holiday auto-responder cancels it just as effectively, so the follow-up you were relying on simply never appears. Thread on References and In-Reply-To, fall back to the subject only when both are missing, and skip messages that declare themselves automatic. ## Phase 4 · Scheduled send that sends once, or not at all A message scheduled for the morning either goes at the right time or explains why it did not. ### Steps 1. Claim the row before connecting The lock happens before the network, or it does not help. Files: `src/schedule/send.ts`, `tests/once.test.ts` 2. Keep the composed message on disk A restart should cost nothing but a moment. Files: `src/compose/spool.ts` 3. Make the missed window a decision Held with a reason beats sent at a strange hour. Files: `src/schedule/policy.ts` ### Done when - [ ] A suspend across the time applies the policy - [ ] A crash mid-send leaves one message, not two - [ ] Composed messages survive a restart - [ ] Sent rows record an identifier and are final ### Watch out - A laptop asleep at the send time either wakes at eleven and sends a good-morning message at an hour that reads oddly, or never sends it and nobody notices until the recipient does not reply. Decide the missed window explicitly, show held messages on the list with the reason, and test the behaviour by suspending the machine across a scheduled time. ## Not in this build - Shared inboxes and team assignment. - Two-way CRM synchronisation. - A hosted tracking service. - Mail merge at volume. ## After v1, if you want it - Snoozing a message locally, which needs nothing the follow-up machinery does not already have - Link click recording with the same honest wording, once the tracking panel has proved it refuses to overclaim - A second mailbox, which mostly means proving the identifier mapping holds per account ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The local database and the composed message spool. Removed entirely by the delete command. DATA_DIR=~/.local/share/mailcompanion # Required. One mailbox, over TLS. IMAP_HOST=imap.example.com # Required. The account. Its secret is in the keychain, not here. IMAP_USERNAME=you@example.com # Required. Names the keychain entry holding the mailbox secret. KEYCHAIN_SERVICE=mailcompanion # Required. Used only when you send something you composed. SMTP_HOST=smtp.example.com # Required. A scheduled send later than this is held and asked about rather than sent. MISSED_WINDOW_MINUTES=30 # Required. Locks the application. An unlocked mailbox on a shared desk is the exposure. IDLE_LOCK_MINUTES=15 # Required. False after installation. Enabling it adds a disclosure line to every message. TRACKING_ENABLED=false
You are building a production product version of Mailbutler. 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 ===== # Mailbutler · product brief ## Problem The visible email productivity add-on loop is buildable, but a credible replacement needs more than the first screen. Mailbutler earns its keep through deliverability, security, integration, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A companion that leaves the mailbox exactly as it found it, keeps your notes attached to the right message through a server's housekeeping, cancels a follow-up only when a person really replied, sends a scheduled message once, and never tells you somebody read an email it cannot know they read. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - desktop development machine - IMAP or Gmail OAuth credentials where supported - secure local keychain access ## Explicit non-goals for v1 - Shared inboxes and team assignment. - Two-way CRM synchronisation. - A hosted tracking service. - Mail merge at volume. - team routing and shared inbox rules - abuse response and security operations - calendar-provider edge cases and timezone correctness - provider-grade deliverability - push reliability across devices ## Success criteria - Message bytes are confirmed unchanged after annotating - A forced UIDVALIDITY change is confirmed to remap without misattaching - A same-subject stranger is confirmed not to cancel a follow-up - A crash mid-send is confirmed to leave one message - No template is confirmed to claim a read or a location ===== BRIEF.md ===== # Build brief · Mailbutler 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 an email companion that adds notes, follow-ups and scheduled send to one mailbox you own to replace Mailbutler. 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 Two rules. The first is that this tool does not change your mail. It reads a mailbox over IMAP, sends only what you explicitly compose, and never deletes, moves or rewrites a message. Notes, tasks and follow-ups live in a local database keyed on the message identifier, not inside the messages, so every other client you own keeps working exactly as it did. The second is about tracking, and it is the one everybody skips. Apple's Mail Privacy Protection downloads remote content in the background by default, regardless of whether the recipient engaged with the message, and routes it through two relays so that the address you see is not theirs. A tracking pixel firing therefore tells you that a machine fetched an image, and nothing more. Report exactly that. Never render a sentence like 'read at half past two in Berlin' from a pixel load, never use one to decide somebody is ignoring you, and keep the whole feature off by default with a plain statement of what it can and cannot tell you. ### Stack (fixed, do not substitute) - Access: IMAP and SMTP against one mailbox, with an app password or OAuth · one mailbox you own, not a shared inbox product with a permission model - Identity: Message-Id as the durable key, with folder, UID and UIDVALIDITY as a mapping · a UID means nothing outside its UIDVALIDITY, and servers do change it - Storage: SQLite holding your annotations and an index of the mailbox, not a copy of it · a full local copy is a second mailbox to secure and a second one to lose - Scheduled send: A local scheduler holding the composed message on disk until its time · a scheduled send that needs a server is a different product with different risks - Secrets: The platform keychain, never the database and never a file in the repository · a mailbox password in a plain file is the entire mailbox - Tracking: Off by default, reported as a remote content fetch and never as a read · the honest version of this feature is less impressive and considerably more useful ### Data model (create this before Phase 1) - accounts(id, host, port, username, secret_ref, uidvalidity, last_seen_uid, last_sync_at, state) · secret_ref names a keychain entry and never holds a value - messages(message_id, account_id, folder, uid, uidvalidity, subject, from_addr, to_json, date, snippet, thread_key) · an index, with bodies fetched on demand - notes(message_id, body_md, created_at, updated_at) · yours, local, and never written into the message - tasks(id, message_id, title, due_at, done_at) - followups(id, thread_key, message_id, due_at, cancel_on_reply, state, cancelled_by_message_id) · the cancelling message is recorded, so a wrong cancellation is explainable - scheduled_sends(id, mime_path, to_json, send_at, state, attempts, sent_message_id) · the composed message sits on disk, so a restart loses nothing - remote_fetches(message_id, at, user_agent, network_class, verdict) · verdict is fetched, and no value in this column means read - orphans(annotation_kind, annotation_id, lost_message_id, at) · annotations whose message could not be remapped, shown rather than reattached by guesswork ### Phase 1 · Reading a mailbox without breaking it Build: Connect over TLS, select folders read-only where the server supports it, and fetch incrementally by UID within the current UIDVALIDITY. Store Message-Id as the durable key for every annotation. Detect a UIDVALIDITY change and treat it as a full remap. Issue no delete, move or expunge command anywhere in the codebase. Trap: Caching messages by UID and resuming after the server has changed UIDVALIDITY points every stored annotation at a different message, and on a large mailbox nobody notices until a private note about a difficult client appears attached to a stranger's email. Key annotations on Message-Id, treat a UIDVALIDITY change as a remap of the whole folder, and show an annotation whose remap failed as orphaned rather than attaching it to a best guess. Done when: A forced UIDVALIDITY change triggers a remap and no annotation lands on a different message, tested against a local test server. No delete, move or expunge command exists in the codebase, verified by search and by review. Reconnecting after a dropped connection resumes from the last seen UID rather than refetching the folder. An annotation that cannot be remapped appears in the orphans list and is attached to nothing. Do not build yet: No notes, follow-ups, sending or tracking. ### Phase 2 · Notes and tasks that live beside the mail Build: Notes in Markdown per message and tasks with due dates, all stored locally. Full text search across notes and indexed headers. Export as one file per annotated message plus a manifest that maps them back to message identifiers, and an import that restores them into a fresh database. Trap: Storing a note by appending to the message body, or by uploading an edited copy back to the folder, rewrites the message, changes its identifier, invalidates its signature and leaves two copies in the mailbox, and the person discovers it when their phone shows the same email twice. Keep annotations in the local database and never upload a modified copy of an existing message. Done when: Adding a note leaves the message bytes on the server byte-identical, verified by fetching and comparing before and after. Notes and tasks export to files and reimport into a fresh database with their message links intact. Search over notes and indexed headers returns within a stated time on a mailbox of fifty thousand messages. No code path uploads a modified copy of an existing message, verified by review. Do not build yet: No follow-ups, scheduled send or tracking yet. ### Phase 3 · Follow-ups that cancel themselves Build: Build the thread key from References and In-Reply-To, falling back to a normalised subject only when both headers are absent. Set a follow-up on a sent message with a due date. Cancel it when a message arrives in the same thread from somebody other than you, recording which message cancelled it. Ignore automatic replies that identify themselves as such. Trap: Matching a thread on the subject line alone means an unrelated message headed 'Re: invoice' from a different person quietly cancels your follow-up, and a holiday auto-responder cancels it just as effectively, so the follow-up you were relying on simply never appears. Thread on References and In-Reply-To, fall back to the subject only when both are missing, and skip messages that declare themselves automatic. Done when: A reply threaded by References cancels the follow-up and the cancelling message is recorded, tested. An unrelated message with the same subject does not cancel it, tested with a crafted pair. An automatic reply that identifies itself does not cancel a follow-up. A message you sent yourself into the thread does not cancel your own follow-up. Do not build yet: No scheduled send or tracking yet. ### Phase 4 · Scheduled send that sends once, or not at all Build: Compose to a MIME file on disk with its send time recorded beside it. The scheduler claims the row in its own transaction before opening an SMTP connection, and records the resulting identifier afterwards. Decide a missed window policy in configuration: send if within a stated number of minutes, otherwise hold and ask. Trap: A laptop asleep at the send time either wakes at eleven and sends a good-morning message at an hour that reads oddly, or never sends it and nobody notices until the recipient does not reply. Decide the missed window explicitly, show held messages on the list with the reason, and test the behaviour by suspending the machine across a scheduled time. Done when: A machine suspended across a scheduled time applies the documented missed window policy, tested by suspending it. Claiming the row before connecting means a crash between the server accepting and the database recording leaves a row to reconcile rather than a second send, tested with a counting fake server. A composed message survives a restart because it is on disk. A sent row records its identifier and is never claimed again. Do not build yet: No tracking, keychain handling or export yet. ### Phase 5 · Telling the truth about tracking, and holding the credential properly Build: Keep tracking off after installation and require an explicit action to enable it. When enabled, add a disclosure line to the message. Report a pixel load as remote content fetched, with the caveat about privacy proxies shown beside the number and no location at all. Store the mailbox secret in the platform keychain, lock the application on idle, and provide one command that deletes every local file this tool created. Trap: Recording a pixel fetch as a read and deriving a city from the connecting address produces confident statements that are frequently false, because privacy proxies fetch every image on receipt and relay it from somewhere else entirely, and acting on those statements damages real relationships with people who did nothing. Report a fetch as a fetch, drop location entirely, and put the caveat next to the number rather than in the documentation. Done when: Tracking is off after a fresh install and enabling it requires an explicit action, tested. With tracking on, every message carries the disclosure line, asserted in the suite. No template renders a location or the word read for a remote content fetch, verified by searching the templates in the suite. The mailbox secret is in the platform keychain and appears in no file and no log, verified by search. Do not build yet: Nothing after this. ### Out of scope (and why) - Shared inboxes, assignment and team rules, which need a permission model and turn a personal tool into a service holding other people's mail. - Two-way CRM synchronisation, which is a maintained integration per system and is most of what the paid product charges for. - A hosted tracking service, which would make you the custodian of other people's recipients' behaviour. - Mail merge at volume through a personal mailbox, which is bulk marketing wearing a disguise and is how a personal address gets suspended. ### README must contain - That the tool never deletes, moves or rewrites a message, and how that is proved in the test suite - That annotations are keyed on the message identifier, and what happens to one whose message cannot be remapped - That a follow-up cancels on a real threaded reply and not on a matching subject or an auto-responder - What a tracking pixel actually reports, that privacy proxies fetch images on receipt, and why no location is shown - That the mailbox secret lives in the platform keychain and how to remove every local file the tool created ===== ARCHITECTURE.md ===== # Architecture · Mailbutler ## Stack | Part | Choice | Why | | --- | --- | --- | | Access | IMAP and SMTP against one mailbox, with an app password or OAuth | one mailbox you own, not a shared inbox product with a permission model | | Identity | Message-Id as the durable key, with folder, UID and UIDVALIDITY as a mapping | a UID means nothing outside its UIDVALIDITY, and servers do change it | | Storage | SQLite holding your annotations and an index of the mailbox, not a copy of it | a full local copy is a second mailbox to secure and a second one to lose | | Scheduled send | A local scheduler holding the composed message on disk until its time | a scheduled send that needs a server is a different product with different risks | | Secrets | The platform keychain, never the database and never a file in the repository | a mailbox password in a plain file is the entire mailbox | | Tracking | Off by default, reported as a remote content fetch and never as a read | the honest version of this feature is less impressive and considerably more useful | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Sync | Read-only IMAP, incremental fetch and remapping. | Any client library, provided no delete or expunge exists anywhere. | | Annotations | Notes, tasks, export and orphan handling. | Any store, provided the message on the server is never modified. | | Follow-ups | Threading, cancellation and its record. | Any threading, provided subject matching stays a last resort. | | Scheduler | Spooled messages, claims and the missed window. | Not swappable for send-then-record, which is where the second copy comes from. | | Tracking | Off by default, disclosure and honest wording. | Not swappable for anything that reports a read or a location. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATA_DIR` · required · The local database and the composed message spool. Removed entirely by the delete command. - `IMAP_HOST` · required · One mailbox, over TLS. - `IMAP_USERNAME` · required · The account. Its secret is in the keychain, not here. - `KEYCHAIN_SERVICE` · required · Names the keychain entry holding the mailbox secret. - `SMTP_HOST` · required · Used only when you send something you composed. - `MISSED_WINDOW_MINUTES` · required · A scheduled send later than this is held and asked about rather than sent. - `IDLE_LOCK_MINUTES` · required · Locks the application. An unlocked mailbox on a shared desk is the exposure. - `TRACKING_ENABLED` · required · False after installation. Enabling it adds a disclosure line to every message. ## 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 · Mailbutler product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: IMAP and SMTP against one mailbox, with an app password or OAuth, Message-Id as the durable key, with folder, UID and UIDVALIDITY as a mapping, SQLite holding your annotations and an index of the mailbox, not a copy of it, A local scheduler holding the composed message on disk until its time, The platform keychain, never the database and never a file in the repository, Off by default, reported as a remote content fetch and never as a read. - 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 - Caching messages by UID and resuming after the server has changed UIDVALIDITY points every stored annotation at a different message, and on a large mailbox nobody notices until a private note about a difficult client appears attached to a stranger's email. Key annotations on Message-Id, treat a UIDVALIDITY change as a remap of the whole folder, and show an annotation whose remap failed as orphaned rather than attaching it to a best guess. - Storing a note by appending to the message body, or by uploading an edited copy back to the folder, rewrites the message, changes its identifier, invalidates its signature and leaves two copies in the mailbox, and the person discovers it when their phone shows the same email twice. Keep annotations in the local database and never upload a modified copy of an existing message. - Matching a thread on the subject line alone means an unrelated message headed 'Re: invoice' from a different person quietly cancels your follow-up, and a holiday auto-responder cancels it just as effectively, so the follow-up you were relying on simply never appears. Thread on References and In-Reply-To, fall back to the subject only when both are missing, and skip messages that declare themselves automatic. - A laptop asleep at the send time either wakes at eleven and sends a good-morning message at an hour that reads oddly, or never sends it and nobody notices until the recipient does not reply. Decide the missed window explicitly, show held messages on the list with the reason, and test the behaviour by suspending the machine across a scheduled time. - Recording a pixel fetch as a read and deriving a city from the connecting address produces confident statements that are frequently false, because privacy proxies fetch every image on receipt and relay it from somewhere else entirely, and acting on those statements damages real relationships with people who did nothing. Report a fetch as a fetch, drop location entirely, and put the caveat next to the number rather than in the documentation. ===== MILESTONES.md ===== # Delivery milestones · Mailbutler Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Reading a mailbox without breaking it The index survives a server changing its identifiers, and nothing is ever deleted. ### Steps 1. Key annotations on Message-Id It is the only identifier that survives the server's housekeeping. Files: `src/mail/identity.ts`, `tests/uidvalidity.test.ts` 2. Select read-only where the server allows it The strongest guarantee is the one the protocol enforces. Files: `src/mail/imap.ts` 3. Show orphans instead of guessing A note on the wrong email is worse than a note with no email. Files: `src/ui/Orphans.tsx` ### Done when - [ ] A UIDVALIDITY change remaps without misattaching - [ ] No delete, move or expunge exists in the code - [ ] Reconnect resumes from the last seen UID - [ ] Unremappable annotations show as orphans ### Watch out - Caching messages by UID and resuming after the server has changed UIDVALIDITY points every stored annotation at a different message, and on a large mailbox nobody notices until a private note about a difficult client appears attached to a stranger's email. Key annotations on Message-Id, treat a UIDVALIDITY change as a remap of the whole folder, and show an annotation whose remap failed as orphaned rather than attaching it to a best guess. ## M2 · Notes and tasks that live beside the mail Annotations are local, exportable, and invisible to every other client. ### Steps 1. Store annotations locally, keyed on the identifier The mailbox stays exactly as the other clients left it. Files: `src/store/notes.ts` 2. Compare message bytes before and after The test that proves the first rule rather than asserting it. Files: `tests/untouched.test.ts` 3. Export with a manifest that maps back Annotations without their message links are a pile of text. Files: `src/export/annotations.ts` ### Done when - [ ] Message bytes are unchanged after annotating - [ ] Export and reimport preserve message links - [ ] Search meets its budget on a large mailbox - [ ] Nothing uploads a modified message ### Watch out - Storing a note by appending to the message body, or by uploading an edited copy back to the folder, rewrites the message, changes its identifier, invalidates its signature and leaves two copies in the mailbox, and the person discovers it when their phone shows the same email twice. Keep annotations in the local database and never upload a modified copy of an existing message. ## M3 · Follow-ups that cancel themselves A follow-up disappears the moment the person actually replies, and not before. ### Steps 1. Thread on References and In-Reply-To Subjects are a last resort, not a strategy. Files: `src/mail/threading.ts`, `tests/threading.test.ts` 2. Record what cancelled a follow-up So a wrong cancellation can be explained rather than argued about. Files: `src/followups/cancel.ts` 3. Skip self-declared automatic replies A holiday responder is not a reply. Files: `src/mail/autoreply.ts` ### Done when - [ ] A real reply cancels and is recorded - [ ] A same-subject stranger does not cancel - [ ] Automatic replies do not cancel - [ ] Your own message does not cancel ### Watch out - Matching a thread on the subject line alone means an unrelated message headed 'Re: invoice' from a different person quietly cancels your follow-up, and a holiday auto-responder cancels it just as effectively, so the follow-up you were relying on simply never appears. Thread on References and In-Reply-To, fall back to the subject only when both are missing, and skip messages that declare themselves automatic. ## M4 · Scheduled send that sends once, or not at all A message scheduled for the morning either goes at the right time or explains why it did not. ### Steps 1. Claim the row before connecting The lock happens before the network, or it does not help. Files: `src/schedule/send.ts`, `tests/once.test.ts` 2. Keep the composed message on disk A restart should cost nothing but a moment. Files: `src/compose/spool.ts` 3. Make the missed window a decision Held with a reason beats sent at a strange hour. Files: `src/schedule/policy.ts` ### Done when - [ ] A suspend across the time applies the policy - [ ] A crash mid-send leaves one message, not two - [ ] Composed messages survive a restart - [ ] Sent rows record an identifier and are final ### Watch out - A laptop asleep at the send time either wakes at eleven and sends a good-morning message at an hour that reads oddly, or never sends it and nobody notices until the recipient does not reply. Decide the missed window explicitly, show held messages on the list with the reason, and test the behaviour by suspending the machine across a scheduled time. ## M5 · Telling the truth about tracking, and holding the credential properly (production only) The tracking feature is honest and off by default, and the mailbox secret lives in the keychain. ### Steps 1. Default tracking off and disclose when on Both are asserted in the suite, not left to intention. Files: `src/tracking/config.ts`, `tests/disclosure.test.ts` 2. Forbid the word read in tracking templates A search in the test suite is a cheap way to keep a promise. Files: `tests/no-read-claim.test.ts` 3. Put the secret in the keychain And prove by search that it reached no file and no log. Files: `src/secrets/keychain.ts` ### Done when - [ ] Tracking is off until explicitly enabled - [ ] Every tracked message discloses it - [ ] No template claims a read or a location - [ ] The mailbox secret is only in the keychain ### Watch out - Recording a pixel fetch as a read and deriving a city from the connecting address produces confident statements that are frequently false, because privacy proxies fetch every image on receipt and relay it from somewhere else entirely, and acting on those statements damages real relationships with people who did nothing. Report a fetch as a fetch, drop location entirely, and put the caveat next to the number rather than in the documentation. ===== OPERATIONS.md ===== # Operations · Mailbutler ## Backup The local database and the composed message spool. Your annotations exist nowhere else, and the mailbox itself is the provider's problem, which is the point of not copying it. ## Restore Restore, then resync. Because annotations are keyed on the message identifier, a restored database reattaches on the next sync, and anything that cannot reattach appears in the orphans list rather than on the wrong email. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a desktop tool the checks live in the suite: no delete command, message bytes unchanged after annotating, a forced UIDVALIDITY change remapping cleanly, and no template claiming a read. Time since the last successful sync belongs in the interface. ## Incident checklist A note on the wrong message means the remap took a guess, and the orphans table plus the identifier mapping show how. A duplicate sent message means the row was claimed after the connection rather than before it. 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 - [ ] Message bytes are confirmed unchanged after annotating - [ ] A forced UIDVALIDITY change is confirmed to remap without misattaching - [ ] A same-subject stranger is confirmed not to cancel a follow-up - [ ] A crash mid-send is confirmed to leave one message - [ ] No template is confirmed to claim a read or a location ## Launch constraint Do not market omitted Mailbutler 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. The local database and the composed message spool. Removed entirely by the delete command. DATA_DIR=~/.local/share/mailcompanion # Required. One mailbox, over TLS. IMAP_HOST=imap.example.com # Required. The account. Its secret is in the keychain, not here. IMAP_USERNAME=you@example.com # Required. Names the keychain entry holding the mailbox secret. KEYCHAIN_SERVICE=mailcompanion # Required. Used only when you send something you composed. SMTP_HOST=smtp.example.com # Required. A scheduled send later than this is held and asked about rather than sent. MISSED_WINDOW_MINUTES=30 # Required. Locks the application. An unlocked mailbox on a shared desk is the exposure. IDLE_LOCK_MINUTES=15 # Required. False after installation. Enabling it adds a disclosure line to every message. TRACKING_ENABLED=false
# Mailbutler · indie build A local companion for one mailbox whose hard problems are keeping annotations attached to the right message when a server changes its identifiers, cancelling a follow-up only when a real reply arrives, and reporting tracking honestly enough to be worth having. Estimated effort: **weekend**. 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 | | --- | --- | --- | | Access | IMAP and SMTP against one mailbox, with an app password or OAuth | one mailbox you own, not a shared inbox product with a permission model | | Identity | Message-Id as the durable key, with folder, UID and UIDVALIDITY as a mapping | a UID means nothing outside its UIDVALIDITY, and servers do change it | | Storage | SQLite holding your annotations and an index of the mailbox, not a copy of it | a full local copy is a second mailbox to secure and a second one to lose | | Scheduled send | A local scheduler holding the composed message on disk until its time | a scheduled send that needs a server is a different product with different risks | | Secrets | The platform keychain, never the database and never a file in the repository | a mailbox password in a plain file is the entire mailbox | | Tracking | Off by default, reported as a remote content fetch and never as a read | the honest version of this feature is less impressive and considerably more useful | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Rust toolchain and the Tauri 2 prerequisites** · free - Why: A desktop process that holds a mailbox credential and a local database, rather than a server that holds your mail. - Get it: rustup.rs, then the Tauri 2 prerequisites page for your platform. - Verify: cargo --version prints and a blank Tauri 2 application launches - [ ] **Node.js 22 or newer** · free - Why: Builds the interface and runs its tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A mailbox you own, with an app password or OAuth client** · free with most providers - Why: This build touches exactly one mailbox, and using an app password rather than your account password means the credential can be revoked on its own. - Get it: Create an app password in your provider's security settings, or register an OAuth client with mail scopes. - Verify: An IMAP session lists folders and an SMTP session authenticates - [ ] **A local IMAP server you can break on purpose** · free - Why: Forcing a UIDVALIDITY change is the only way to test the remap, and you cannot do it on a real mailbox. - Get it: Any small IMAP server you can run locally and reset, seeded with a few thousand messages. - Verify: UIDVALIDITY can be changed on demand and the client notices - [ ] **A realistic mailbox export, including the awkward threads** · free - Why: Threading, auto-replies and forwarded chains only go wrong on real mail, and a generated corpus has none of those shapes. - Get it: Export your own mail, or assemble a few thousand messages including auto-responders and a thread with a reused subject. - Verify: The corpus contains at least one auto-reply and one unrelated message sharing a subject - [ ] **Your missed window, idle lock and tracking stance** · free - Why: All three are asserted in tests, and the tracking stance in particular is a promise the interface has to keep. - Get it: Write down how late a scheduled send may still go, how long until the application locks, and exactly what wording the tracking panel uses. - Verify: All three appear in configuration and are named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Shared inboxes and team assignment. - Two-way CRM synchronisation. - A hosted tracking service. - Mail merge at volume. - team routing and shared inbox rules - abuse response and security operations - calendar-provider edge cases and timezone correctness - provider-grade deliverability - push reliability across devices If one of those is essential to you, that is the reason to keep paying for Mailbutler, and the README should say so rather than pretend.
# Build brief · Mailbutler 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 an email companion that adds notes, follow-ups and scheduled send to one mailbox you own to replace Mailbutler. 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 Two rules. The first is that this tool does not change your mail. It reads a mailbox over IMAP, sends only what you explicitly compose, and never deletes, moves or rewrites a message. Notes, tasks and follow-ups live in a local database keyed on the message identifier, not inside the messages, so every other client you own keeps working exactly as it did. The second is about tracking, and it is the one everybody skips. Apple's Mail Privacy Protection downloads remote content in the background by default, regardless of whether the recipient engaged with the message, and routes it through two relays so that the address you see is not theirs. A tracking pixel firing therefore tells you that a machine fetched an image, and nothing more. Report exactly that. Never render a sentence like 'read at half past two in Berlin' from a pixel load, never use one to decide somebody is ignoring you, and keep the whole feature off by default with a plain statement of what it can and cannot tell you. ### Stack (fixed, do not substitute) - Access: IMAP and SMTP against one mailbox, with an app password or OAuth · one mailbox you own, not a shared inbox product with a permission model - Identity: Message-Id as the durable key, with folder, UID and UIDVALIDITY as a mapping · a UID means nothing outside its UIDVALIDITY, and servers do change it - Storage: SQLite holding your annotations and an index of the mailbox, not a copy of it · a full local copy is a second mailbox to secure and a second one to lose - Scheduled send: A local scheduler holding the composed message on disk until its time · a scheduled send that needs a server is a different product with different risks - Secrets: The platform keychain, never the database and never a file in the repository · a mailbox password in a plain file is the entire mailbox - Tracking: Off by default, reported as a remote content fetch and never as a read · the honest version of this feature is less impressive and considerably more useful ### Data model (create this before Phase 1) - accounts(id, host, port, username, secret_ref, uidvalidity, last_seen_uid, last_sync_at, state) · secret_ref names a keychain entry and never holds a value - messages(message_id, account_id, folder, uid, uidvalidity, subject, from_addr, to_json, date, snippet, thread_key) · an index, with bodies fetched on demand - notes(message_id, body_md, created_at, updated_at) · yours, local, and never written into the message - tasks(id, message_id, title, due_at, done_at) - followups(id, thread_key, message_id, due_at, cancel_on_reply, state, cancelled_by_message_id) · the cancelling message is recorded, so a wrong cancellation is explainable - scheduled_sends(id, mime_path, to_json, send_at, state, attempts, sent_message_id) · the composed message sits on disk, so a restart loses nothing - remote_fetches(message_id, at, user_agent, network_class, verdict) · verdict is fetched, and no value in this column means read - orphans(annotation_kind, annotation_id, lost_message_id, at) · annotations whose message could not be remapped, shown rather than reattached by guesswork ### Phase 1 · Reading a mailbox without breaking it Build: Connect over TLS, select folders read-only where the server supports it, and fetch incrementally by UID within the current UIDVALIDITY. Store Message-Id as the durable key for every annotation. Detect a UIDVALIDITY change and treat it as a full remap. Issue no delete, move or expunge command anywhere in the codebase. Trap: Caching messages by UID and resuming after the server has changed UIDVALIDITY points every stored annotation at a different message, and on a large mailbox nobody notices until a private note about a difficult client appears attached to a stranger's email. Key annotations on Message-Id, treat a UIDVALIDITY change as a remap of the whole folder, and show an annotation whose remap failed as orphaned rather than attaching it to a best guess. Done when: A forced UIDVALIDITY change triggers a remap and no annotation lands on a different message, tested against a local test server. No delete, move or expunge command exists in the codebase, verified by search and by review. Reconnecting after a dropped connection resumes from the last seen UID rather than refetching the folder. An annotation that cannot be remapped appears in the orphans list and is attached to nothing. Do not build yet: No notes, follow-ups, sending or tracking. ### Phase 2 · Notes and tasks that live beside the mail Build: Notes in Markdown per message and tasks with due dates, all stored locally. Full text search across notes and indexed headers. Export as one file per annotated message plus a manifest that maps them back to message identifiers, and an import that restores them into a fresh database. Trap: Storing a note by appending to the message body, or by uploading an edited copy back to the folder, rewrites the message, changes its identifier, invalidates its signature and leaves two copies in the mailbox, and the person discovers it when their phone shows the same email twice. Keep annotations in the local database and never upload a modified copy of an existing message. Done when: Adding a note leaves the message bytes on the server byte-identical, verified by fetching and comparing before and after. Notes and tasks export to files and reimport into a fresh database with their message links intact. Search over notes and indexed headers returns within a stated time on a mailbox of fifty thousand messages. No code path uploads a modified copy of an existing message, verified by review. Do not build yet: No follow-ups, scheduled send or tracking yet. ### Phase 3 · Follow-ups that cancel themselves Build: Build the thread key from References and In-Reply-To, falling back to a normalised subject only when both headers are absent. Set a follow-up on a sent message with a due date. Cancel it when a message arrives in the same thread from somebody other than you, recording which message cancelled it. Ignore automatic replies that identify themselves as such. Trap: Matching a thread on the subject line alone means an unrelated message headed 'Re: invoice' from a different person quietly cancels your follow-up, and a holiday auto-responder cancels it just as effectively, so the follow-up you were relying on simply never appears. Thread on References and In-Reply-To, fall back to the subject only when both are missing, and skip messages that declare themselves automatic. Done when: A reply threaded by References cancels the follow-up and the cancelling message is recorded, tested. An unrelated message with the same subject does not cancel it, tested with a crafted pair. An automatic reply that identifies itself does not cancel a follow-up. A message you sent yourself into the thread does not cancel your own follow-up. Do not build yet: No scheduled send or tracking yet. ### Phase 4 · Scheduled send that sends once, or not at all Build: Compose to a MIME file on disk with its send time recorded beside it. The scheduler claims the row in its own transaction before opening an SMTP connection, and records the resulting identifier afterwards. Decide a missed window policy in configuration: send if within a stated number of minutes, otherwise hold and ask. Trap: A laptop asleep at the send time either wakes at eleven and sends a good-morning message at an hour that reads oddly, or never sends it and nobody notices until the recipient does not reply. Decide the missed window explicitly, show held messages on the list with the reason, and test the behaviour by suspending the machine across a scheduled time. Done when: A machine suspended across a scheduled time applies the documented missed window policy, tested by suspending it. Claiming the row before connecting means a crash between the server accepting and the database recording leaves a row to reconcile rather than a second send, tested with a counting fake server. A composed message survives a restart because it is on disk. A sent row records its identifier and is never claimed again. Do not build yet: No tracking, keychain handling or export yet. ### Phase 5 · Telling the truth about tracking, and holding the credential properly Build: Keep tracking off after installation and require an explicit action to enable it. When enabled, add a disclosure line to the message. Report a pixel load as remote content fetched, with the caveat about privacy proxies shown beside the number and no location at all. Store the mailbox secret in the platform keychain, lock the application on idle, and provide one command that deletes every local file this tool created. Trap: Recording a pixel fetch as a read and deriving a city from the connecting address produces confident statements that are frequently false, because privacy proxies fetch every image on receipt and relay it from somewhere else entirely, and acting on those statements damages real relationships with people who did nothing. Report a fetch as a fetch, drop location entirely, and put the caveat next to the number rather than in the documentation. Done when: Tracking is off after a fresh install and enabling it requires an explicit action, tested. With tracking on, every message carries the disclosure line, asserted in the suite. No template renders a location or the word read for a remote content fetch, verified by searching the templates in the suite. The mailbox secret is in the platform keychain and appears in no file and no log, verified by search. Do not build yet: Nothing after this. ### Out of scope (and why) - Shared inboxes, assignment and team rules, which need a permission model and turn a personal tool into a service holding other people's mail. - Two-way CRM synchronisation, which is a maintained integration per system and is most of what the paid product charges for. - A hosted tracking service, which would make you the custodian of other people's recipients' behaviour. - Mail merge at volume through a personal mailbox, which is bulk marketing wearing a disguise and is how a personal address gets suspended. ### README must contain - That the tool never deletes, moves or rewrites a message, and how that is proved in the test suite - That annotations are keyed on the message identifier, and what happens to one whose message cannot be remapped - That a follow-up cancels on a real threaded reply and not on a matching subject or an auto-responder - What a tracking pixel actually reports, that privacy proxies fetch images on receipt, and why no location is shown - That the mailbox secret lives in the platform keychain and how to remove every local file the tool created
# Agent instructions · Mailbutler indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: IMAP and SMTP against one mailbox, with an app password or OAuth, Message-Id as the durable key, with folder, UID and UIDVALIDITY as a mapping, SQLite holding your annotations and an index of the mailbox, not a copy of it, A local scheduler holding the composed message on disk until its time, The platform keychain, never the database and never a file in the repository, Off by default, reported as a remote content fetch and never as a read. 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 - Caching messages by UID and resuming after the server has changed UIDVALIDITY points every stored annotation at a different message, and on a large mailbox nobody notices until a private note about a difficult client appears attached to a stranger's email. Key annotations on Message-Id, treat a UIDVALIDITY change as a remap of the whole folder, and show an annotation whose remap failed as orphaned rather than attaching it to a best guess. - Storing a note by appending to the message body, or by uploading an edited copy back to the folder, rewrites the message, changes its identifier, invalidates its signature and leaves two copies in the mailbox, and the person discovers it when their phone shows the same email twice. Keep annotations in the local database and never upload a modified copy of an existing message. - Matching a thread on the subject line alone means an unrelated message headed 'Re: invoice' from a different person quietly cancels your follow-up, and a holiday auto-responder cancels it just as effectively, so the follow-up you were relying on simply never appears. Thread on References and In-Reply-To, fall back to the subject only when both are missing, and skip messages that declare themselves automatic. - A laptop asleep at the send time either wakes at eleven and sends a good-morning message at an hour that reads oddly, or never sends it and nobody notices until the recipient does not reply. Decide the missed window explicitly, show held messages on the list with the reason, and test the behaviour by suspending the machine across a scheduled time.
# Build plan · Mailbutler A local companion for one mailbox whose hard problems are keeping annotations attached to the right message when a server changes its identifiers, cancelling a follow-up only when a real reply arrives, and reporting tracking honestly enough to be worth having. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Reading a mailbox without breaking it The index survives a server changing its identifiers, and nothing is ever deleted. ### Steps 1. Key annotations on Message-Id It is the only identifier that survives the server's housekeeping. Files: `src/mail/identity.ts`, `tests/uidvalidity.test.ts` 2. Select read-only where the server allows it The strongest guarantee is the one the protocol enforces. Files: `src/mail/imap.ts` 3. Show orphans instead of guessing A note on the wrong email is worse than a note with no email. Files: `src/ui/Orphans.tsx` ### Done when - [ ] A UIDVALIDITY change remaps without misattaching - [ ] No delete, move or expunge exists in the code - [ ] Reconnect resumes from the last seen UID - [ ] Unremappable annotations show as orphans ### Watch out - Caching messages by UID and resuming after the server has changed UIDVALIDITY points every stored annotation at a different message, and on a large mailbox nobody notices until a private note about a difficult client appears attached to a stranger's email. Key annotations on Message-Id, treat a UIDVALIDITY change as a remap of the whole folder, and show an annotation whose remap failed as orphaned rather than attaching it to a best guess. ## Phase 2 · Notes and tasks that live beside the mail Annotations are local, exportable, and invisible to every other client. ### Steps 1. Store annotations locally, keyed on the identifier The mailbox stays exactly as the other clients left it. Files: `src/store/notes.ts` 2. Compare message bytes before and after The test that proves the first rule rather than asserting it. Files: `tests/untouched.test.ts` 3. Export with a manifest that maps back Annotations without their message links are a pile of text. Files: `src/export/annotations.ts` ### Done when - [ ] Message bytes are unchanged after annotating - [ ] Export and reimport preserve message links - [ ] Search meets its budget on a large mailbox - [ ] Nothing uploads a modified message ### Watch out - Storing a note by appending to the message body, or by uploading an edited copy back to the folder, rewrites the message, changes its identifier, invalidates its signature and leaves two copies in the mailbox, and the person discovers it when their phone shows the same email twice. Keep annotations in the local database and never upload a modified copy of an existing message. ## Phase 3 · Follow-ups that cancel themselves A follow-up disappears the moment the person actually replies, and not before. ### Steps 1. Thread on References and In-Reply-To Subjects are a last resort, not a strategy. Files: `src/mail/threading.ts`, `tests/threading.test.ts` 2. Record what cancelled a follow-up So a wrong cancellation can be explained rather than argued about. Files: `src/followups/cancel.ts` 3. Skip self-declared automatic replies A holiday responder is not a reply. Files: `src/mail/autoreply.ts` ### Done when - [ ] A real reply cancels and is recorded - [ ] A same-subject stranger does not cancel - [ ] Automatic replies do not cancel - [ ] Your own message does not cancel ### Watch out - Matching a thread on the subject line alone means an unrelated message headed 'Re: invoice' from a different person quietly cancels your follow-up, and a holiday auto-responder cancels it just as effectively, so the follow-up you were relying on simply never appears. Thread on References and In-Reply-To, fall back to the subject only when both are missing, and skip messages that declare themselves automatic. ## Phase 4 · Scheduled send that sends once, or not at all A message scheduled for the morning either goes at the right time or explains why it did not. ### Steps 1. Claim the row before connecting The lock happens before the network, or it does not help. Files: `src/schedule/send.ts`, `tests/once.test.ts` 2. Keep the composed message on disk A restart should cost nothing but a moment. Files: `src/compose/spool.ts` 3. Make the missed window a decision Held with a reason beats sent at a strange hour. Files: `src/schedule/policy.ts` ### Done when - [ ] A suspend across the time applies the policy - [ ] A crash mid-send leaves one message, not two - [ ] Composed messages survive a restart - [ ] Sent rows record an identifier and are final ### Watch out - A laptop asleep at the send time either wakes at eleven and sends a good-morning message at an hour that reads oddly, or never sends it and nobody notices until the recipient does not reply. Decide the missed window explicitly, show held messages on the list with the reason, and test the behaviour by suspending the machine across a scheduled time. ## Not in this build - Shared inboxes and team assignment. - Two-way CRM synchronisation. - A hosted tracking service. - Mail merge at volume. ## After v1, if you want it - Snoozing a message locally, which needs nothing the follow-up machinery does not already have - Link click recording with the same honest wording, once the tracking panel has proved it refuses to overclaim - A second mailbox, which mostly means proving the identifier mapping holds per account
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The local database and the composed message spool. Removed entirely by the delete command. DATA_DIR=~/.local/share/mailcompanion # Required. One mailbox, over TLS. IMAP_HOST=imap.example.com # Required. The account. Its secret is in the keychain, not here. IMAP_USERNAME=you@example.com # Required. Names the keychain entry holding the mailbox secret. KEYCHAIN_SERVICE=mailcompanion # Required. Used only when you send something you composed. SMTP_HOST=smtp.example.com # Required. A scheduled send later than this is held and asked about rather than sent. MISSED_WINDOW_MINUTES=30 # Required. Locks the application. An unlocked mailbox on a shared desk is the exposure. IDLE_LOCK_MINUTES=15 # Required. False after installation. Enabling it adds a disclosure line to every message. TRACKING_ENABLED=false
# Mailbutler · product brief ## Problem The visible email productivity add-on loop is buildable, but a credible replacement needs more than the first screen. Mailbutler earns its keep through deliverability, security, integration, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A companion that leaves the mailbox exactly as it found it, keeps your notes attached to the right message through a server's housekeeping, cancels a follow-up only when a person really replied, sends a scheduled message once, and never tells you somebody read an email it cannot know they read. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - desktop development machine - IMAP or Gmail OAuth credentials where supported - secure local keychain access ## Explicit non-goals for v1 - Shared inboxes and team assignment. - Two-way CRM synchronisation. - A hosted tracking service. - Mail merge at volume. - team routing and shared inbox rules - abuse response and security operations - calendar-provider edge cases and timezone correctness - provider-grade deliverability - push reliability across devices ## Success criteria - Message bytes are confirmed unchanged after annotating - A forced UIDVALIDITY change is confirmed to remap without misattaching - A same-subject stranger is confirmed not to cancel a follow-up - A crash mid-send is confirmed to leave one message - No template is confirmed to claim a read or a location
# Build brief · Mailbutler 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 an email companion that adds notes, follow-ups and scheduled send to one mailbox you own to replace Mailbutler. 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 Two rules. The first is that this tool does not change your mail. It reads a mailbox over IMAP, sends only what you explicitly compose, and never deletes, moves or rewrites a message. Notes, tasks and follow-ups live in a local database keyed on the message identifier, not inside the messages, so every other client you own keeps working exactly as it did. The second is about tracking, and it is the one everybody skips. Apple's Mail Privacy Protection downloads remote content in the background by default, regardless of whether the recipient engaged with the message, and routes it through two relays so that the address you see is not theirs. A tracking pixel firing therefore tells you that a machine fetched an image, and nothing more. Report exactly that. Never render a sentence like 'read at half past two in Berlin' from a pixel load, never use one to decide somebody is ignoring you, and keep the whole feature off by default with a plain statement of what it can and cannot tell you. ### Stack (fixed, do not substitute) - Access: IMAP and SMTP against one mailbox, with an app password or OAuth · one mailbox you own, not a shared inbox product with a permission model - Identity: Message-Id as the durable key, with folder, UID and UIDVALIDITY as a mapping · a UID means nothing outside its UIDVALIDITY, and servers do change it - Storage: SQLite holding your annotations and an index of the mailbox, not a copy of it · a full local copy is a second mailbox to secure and a second one to lose - Scheduled send: A local scheduler holding the composed message on disk until its time · a scheduled send that needs a server is a different product with different risks - Secrets: The platform keychain, never the database and never a file in the repository · a mailbox password in a plain file is the entire mailbox - Tracking: Off by default, reported as a remote content fetch and never as a read · the honest version of this feature is less impressive and considerably more useful ### Data model (create this before Phase 1) - accounts(id, host, port, username, secret_ref, uidvalidity, last_seen_uid, last_sync_at, state) · secret_ref names a keychain entry and never holds a value - messages(message_id, account_id, folder, uid, uidvalidity, subject, from_addr, to_json, date, snippet, thread_key) · an index, with bodies fetched on demand - notes(message_id, body_md, created_at, updated_at) · yours, local, and never written into the message - tasks(id, message_id, title, due_at, done_at) - followups(id, thread_key, message_id, due_at, cancel_on_reply, state, cancelled_by_message_id) · the cancelling message is recorded, so a wrong cancellation is explainable - scheduled_sends(id, mime_path, to_json, send_at, state, attempts, sent_message_id) · the composed message sits on disk, so a restart loses nothing - remote_fetches(message_id, at, user_agent, network_class, verdict) · verdict is fetched, and no value in this column means read - orphans(annotation_kind, annotation_id, lost_message_id, at) · annotations whose message could not be remapped, shown rather than reattached by guesswork ### Phase 1 · Reading a mailbox without breaking it Build: Connect over TLS, select folders read-only where the server supports it, and fetch incrementally by UID within the current UIDVALIDITY. Store Message-Id as the durable key for every annotation. Detect a UIDVALIDITY change and treat it as a full remap. Issue no delete, move or expunge command anywhere in the codebase. Trap: Caching messages by UID and resuming after the server has changed UIDVALIDITY points every stored annotation at a different message, and on a large mailbox nobody notices until a private note about a difficult client appears attached to a stranger's email. Key annotations on Message-Id, treat a UIDVALIDITY change as a remap of the whole folder, and show an annotation whose remap failed as orphaned rather than attaching it to a best guess. Done when: A forced UIDVALIDITY change triggers a remap and no annotation lands on a different message, tested against a local test server. No delete, move or expunge command exists in the codebase, verified by search and by review. Reconnecting after a dropped connection resumes from the last seen UID rather than refetching the folder. An annotation that cannot be remapped appears in the orphans list and is attached to nothing. Do not build yet: No notes, follow-ups, sending or tracking. ### Phase 2 · Notes and tasks that live beside the mail Build: Notes in Markdown per message and tasks with due dates, all stored locally. Full text search across notes and indexed headers. Export as one file per annotated message plus a manifest that maps them back to message identifiers, and an import that restores them into a fresh database. Trap: Storing a note by appending to the message body, or by uploading an edited copy back to the folder, rewrites the message, changes its identifier, invalidates its signature and leaves two copies in the mailbox, and the person discovers it when their phone shows the same email twice. Keep annotations in the local database and never upload a modified copy of an existing message. Done when: Adding a note leaves the message bytes on the server byte-identical, verified by fetching and comparing before and after. Notes and tasks export to files and reimport into a fresh database with their message links intact. Search over notes and indexed headers returns within a stated time on a mailbox of fifty thousand messages. No code path uploads a modified copy of an existing message, verified by review. Do not build yet: No follow-ups, scheduled send or tracking yet. ### Phase 3 · Follow-ups that cancel themselves Build: Build the thread key from References and In-Reply-To, falling back to a normalised subject only when both headers are absent. Set a follow-up on a sent message with a due date. Cancel it when a message arrives in the same thread from somebody other than you, recording which message cancelled it. Ignore automatic replies that identify themselves as such. Trap: Matching a thread on the subject line alone means an unrelated message headed 'Re: invoice' from a different person quietly cancels your follow-up, and a holiday auto-responder cancels it just as effectively, so the follow-up you were relying on simply never appears. Thread on References and In-Reply-To, fall back to the subject only when both are missing, and skip messages that declare themselves automatic. Done when: A reply threaded by References cancels the follow-up and the cancelling message is recorded, tested. An unrelated message with the same subject does not cancel it, tested with a crafted pair. An automatic reply that identifies itself does not cancel a follow-up. A message you sent yourself into the thread does not cancel your own follow-up. Do not build yet: No scheduled send or tracking yet. ### Phase 4 · Scheduled send that sends once, or not at all Build: Compose to a MIME file on disk with its send time recorded beside it. The scheduler claims the row in its own transaction before opening an SMTP connection, and records the resulting identifier afterwards. Decide a missed window policy in configuration: send if within a stated number of minutes, otherwise hold and ask. Trap: A laptop asleep at the send time either wakes at eleven and sends a good-morning message at an hour that reads oddly, or never sends it and nobody notices until the recipient does not reply. Decide the missed window explicitly, show held messages on the list with the reason, and test the behaviour by suspending the machine across a scheduled time. Done when: A machine suspended across a scheduled time applies the documented missed window policy, tested by suspending it. Claiming the row before connecting means a crash between the server accepting and the database recording leaves a row to reconcile rather than a second send, tested with a counting fake server. A composed message survives a restart because it is on disk. A sent row records its identifier and is never claimed again. Do not build yet: No tracking, keychain handling or export yet. ### Phase 5 · Telling the truth about tracking, and holding the credential properly Build: Keep tracking off after installation and require an explicit action to enable it. When enabled, add a disclosure line to the message. Report a pixel load as remote content fetched, with the caveat about privacy proxies shown beside the number and no location at all. Store the mailbox secret in the platform keychain, lock the application on idle, and provide one command that deletes every local file this tool created. Trap: Recording a pixel fetch as a read and deriving a city from the connecting address produces confident statements that are frequently false, because privacy proxies fetch every image on receipt and relay it from somewhere else entirely, and acting on those statements damages real relationships with people who did nothing. Report a fetch as a fetch, drop location entirely, and put the caveat next to the number rather than in the documentation. Done when: Tracking is off after a fresh install and enabling it requires an explicit action, tested. With tracking on, every message carries the disclosure line, asserted in the suite. No template renders a location or the word read for a remote content fetch, verified by searching the templates in the suite. The mailbox secret is in the platform keychain and appears in no file and no log, verified by search. Do not build yet: Nothing after this. ### Out of scope (and why) - Shared inboxes, assignment and team rules, which need a permission model and turn a personal tool into a service holding other people's mail. - Two-way CRM synchronisation, which is a maintained integration per system and is most of what the paid product charges for. - A hosted tracking service, which would make you the custodian of other people's recipients' behaviour. - Mail merge at volume through a personal mailbox, which is bulk marketing wearing a disguise and is how a personal address gets suspended. ### README must contain - That the tool never deletes, moves or rewrites a message, and how that is proved in the test suite - That annotations are keyed on the message identifier, and what happens to one whose message cannot be remapped - That a follow-up cancels on a real threaded reply and not on a matching subject or an auto-responder - What a tracking pixel actually reports, that privacy proxies fetch images on receipt, and why no location is shown - That the mailbox secret lives in the platform keychain and how to remove every local file the tool created
# Architecture · Mailbutler ## Stack | Part | Choice | Why | | --- | --- | --- | | Access | IMAP and SMTP against one mailbox, with an app password or OAuth | one mailbox you own, not a shared inbox product with a permission model | | Identity | Message-Id as the durable key, with folder, UID and UIDVALIDITY as a mapping | a UID means nothing outside its UIDVALIDITY, and servers do change it | | Storage | SQLite holding your annotations and an index of the mailbox, not a copy of it | a full local copy is a second mailbox to secure and a second one to lose | | Scheduled send | A local scheduler holding the composed message on disk until its time | a scheduled send that needs a server is a different product with different risks | | Secrets | The platform keychain, never the database and never a file in the repository | a mailbox password in a plain file is the entire mailbox | | Tracking | Off by default, reported as a remote content fetch and never as a read | the honest version of this feature is less impressive and considerably more useful | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Sync | Read-only IMAP, incremental fetch and remapping. | Any client library, provided no delete or expunge exists anywhere. | | Annotations | Notes, tasks, export and orphan handling. | Any store, provided the message on the server is never modified. | | Follow-ups | Threading, cancellation and its record. | Any threading, provided subject matching stays a last resort. | | Scheduler | Spooled messages, claims and the missed window. | Not swappable for send-then-record, which is where the second copy comes from. | | Tracking | Off by default, disclosure and honest wording. | Not swappable for anything that reports a read or a location. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATA_DIR` · required · The local database and the composed message spool. Removed entirely by the delete command. - `IMAP_HOST` · required · One mailbox, over TLS. - `IMAP_USERNAME` · required · The account. Its secret is in the keychain, not here. - `KEYCHAIN_SERVICE` · required · Names the keychain entry holding the mailbox secret. - `SMTP_HOST` · required · Used only when you send something you composed. - `MISSED_WINDOW_MINUTES` · required · A scheduled send later than this is held and asked about rather than sent. - `IDLE_LOCK_MINUTES` · required · Locks the application. An unlocked mailbox on a shared desk is the exposure. - `TRACKING_ENABLED` · required · False after installation. Enabling it adds a disclosure line to every message. ## 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 · Mailbutler product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: IMAP and SMTP against one mailbox, with an app password or OAuth, Message-Id as the durable key, with folder, UID and UIDVALIDITY as a mapping, SQLite holding your annotations and an index of the mailbox, not a copy of it, A local scheduler holding the composed message on disk until its time, The platform keychain, never the database and never a file in the repository, Off by default, reported as a remote content fetch and never as a read. - 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 - Caching messages by UID and resuming after the server has changed UIDVALIDITY points every stored annotation at a different message, and on a large mailbox nobody notices until a private note about a difficult client appears attached to a stranger's email. Key annotations on Message-Id, treat a UIDVALIDITY change as a remap of the whole folder, and show an annotation whose remap failed as orphaned rather than attaching it to a best guess. - Storing a note by appending to the message body, or by uploading an edited copy back to the folder, rewrites the message, changes its identifier, invalidates its signature and leaves two copies in the mailbox, and the person discovers it when their phone shows the same email twice. Keep annotations in the local database and never upload a modified copy of an existing message. - Matching a thread on the subject line alone means an unrelated message headed 'Re: invoice' from a different person quietly cancels your follow-up, and a holiday auto-responder cancels it just as effectively, so the follow-up you were relying on simply never appears. Thread on References and In-Reply-To, fall back to the subject only when both are missing, and skip messages that declare themselves automatic. - A laptop asleep at the send time either wakes at eleven and sends a good-morning message at an hour that reads oddly, or never sends it and nobody notices until the recipient does not reply. Decide the missed window explicitly, show held messages on the list with the reason, and test the behaviour by suspending the machine across a scheduled time. - Recording a pixel fetch as a read and deriving a city from the connecting address produces confident statements that are frequently false, because privacy proxies fetch every image on receipt and relay it from somewhere else entirely, and acting on those statements damages real relationships with people who did nothing. Report a fetch as a fetch, drop location entirely, and put the caveat next to the number rather than in the documentation.
# Delivery milestones · Mailbutler Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Reading a mailbox without breaking it The index survives a server changing its identifiers, and nothing is ever deleted. ### Steps 1. Key annotations on Message-Id It is the only identifier that survives the server's housekeeping. Files: `src/mail/identity.ts`, `tests/uidvalidity.test.ts` 2. Select read-only where the server allows it The strongest guarantee is the one the protocol enforces. Files: `src/mail/imap.ts` 3. Show orphans instead of guessing A note on the wrong email is worse than a note with no email. Files: `src/ui/Orphans.tsx` ### Done when - [ ] A UIDVALIDITY change remaps without misattaching - [ ] No delete, move or expunge exists in the code - [ ] Reconnect resumes from the last seen UID - [ ] Unremappable annotations show as orphans ### Watch out - Caching messages by UID and resuming after the server has changed UIDVALIDITY points every stored annotation at a different message, and on a large mailbox nobody notices until a private note about a difficult client appears attached to a stranger's email. Key annotations on Message-Id, treat a UIDVALIDITY change as a remap of the whole folder, and show an annotation whose remap failed as orphaned rather than attaching it to a best guess. ## M2 · Notes and tasks that live beside the mail Annotations are local, exportable, and invisible to every other client. ### Steps 1. Store annotations locally, keyed on the identifier The mailbox stays exactly as the other clients left it. Files: `src/store/notes.ts` 2. Compare message bytes before and after The test that proves the first rule rather than asserting it. Files: `tests/untouched.test.ts` 3. Export with a manifest that maps back Annotations without their message links are a pile of text. Files: `src/export/annotations.ts` ### Done when - [ ] Message bytes are unchanged after annotating - [ ] Export and reimport preserve message links - [ ] Search meets its budget on a large mailbox - [ ] Nothing uploads a modified message ### Watch out - Storing a note by appending to the message body, or by uploading an edited copy back to the folder, rewrites the message, changes its identifier, invalidates its signature and leaves two copies in the mailbox, and the person discovers it when their phone shows the same email twice. Keep annotations in the local database and never upload a modified copy of an existing message. ## M3 · Follow-ups that cancel themselves A follow-up disappears the moment the person actually replies, and not before. ### Steps 1. Thread on References and In-Reply-To Subjects are a last resort, not a strategy. Files: `src/mail/threading.ts`, `tests/threading.test.ts` 2. Record what cancelled a follow-up So a wrong cancellation can be explained rather than argued about. Files: `src/followups/cancel.ts` 3. Skip self-declared automatic replies A holiday responder is not a reply. Files: `src/mail/autoreply.ts` ### Done when - [ ] A real reply cancels and is recorded - [ ] A same-subject stranger does not cancel - [ ] Automatic replies do not cancel - [ ] Your own message does not cancel ### Watch out - Matching a thread on the subject line alone means an unrelated message headed 'Re: invoice' from a different person quietly cancels your follow-up, and a holiday auto-responder cancels it just as effectively, so the follow-up you were relying on simply never appears. Thread on References and In-Reply-To, fall back to the subject only when both are missing, and skip messages that declare themselves automatic. ## M4 · Scheduled send that sends once, or not at all A message scheduled for the morning either goes at the right time or explains why it did not. ### Steps 1. Claim the row before connecting The lock happens before the network, or it does not help. Files: `src/schedule/send.ts`, `tests/once.test.ts` 2. Keep the composed message on disk A restart should cost nothing but a moment. Files: `src/compose/spool.ts` 3. Make the missed window a decision Held with a reason beats sent at a strange hour. Files: `src/schedule/policy.ts` ### Done when - [ ] A suspend across the time applies the policy - [ ] A crash mid-send leaves one message, not two - [ ] Composed messages survive a restart - [ ] Sent rows record an identifier and are final ### Watch out - A laptop asleep at the send time either wakes at eleven and sends a good-morning message at an hour that reads oddly, or never sends it and nobody notices until the recipient does not reply. Decide the missed window explicitly, show held messages on the list with the reason, and test the behaviour by suspending the machine across a scheduled time. ## M5 · Telling the truth about tracking, and holding the credential properly (production only) The tracking feature is honest and off by default, and the mailbox secret lives in the keychain. ### Steps 1. Default tracking off and disclose when on Both are asserted in the suite, not left to intention. Files: `src/tracking/config.ts`, `tests/disclosure.test.ts` 2. Forbid the word read in tracking templates A search in the test suite is a cheap way to keep a promise. Files: `tests/no-read-claim.test.ts` 3. Put the secret in the keychain And prove by search that it reached no file and no log. Files: `src/secrets/keychain.ts` ### Done when - [ ] Tracking is off until explicitly enabled - [ ] Every tracked message discloses it - [ ] No template claims a read or a location - [ ] The mailbox secret is only in the keychain ### Watch out - Recording a pixel fetch as a read and deriving a city from the connecting address produces confident statements that are frequently false, because privacy proxies fetch every image on receipt and relay it from somewhere else entirely, and acting on those statements damages real relationships with people who did nothing. Report a fetch as a fetch, drop location entirely, and put the caveat next to the number rather than in the documentation.
# Operations · Mailbutler ## Backup The local database and the composed message spool. Your annotations exist nowhere else, and the mailbox itself is the provider's problem, which is the point of not copying it. ## Restore Restore, then resync. Because annotations are keyed on the message identifier, a restored database reattaches on the next sync, and anything that cannot reattach appears in the orphans list rather than on the wrong email. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a desktop tool the checks live in the suite: no delete command, message bytes unchanged after annotating, a forced UIDVALIDITY change remapping cleanly, and no template claiming a read. Time since the last successful sync belongs in the interface. ## Incident checklist A note on the wrong message means the remap took a guess, and the orphans table plus the identifier mapping show how. A duplicate sent message means the row was claimed after the connection rather than before it. 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 - [ ] Message bytes are confirmed unchanged after annotating - [ ] A forced UIDVALIDITY change is confirmed to remap without misattaching - [ ] A same-subject stranger is confirmed not to cancel a follow-up - [ ] A crash mid-send is confirmed to leave one message - [ ] No template is confirmed to claim a read or a location ## Launch constraint Do not market omitted Mailbutler 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. The local database and the composed message spool. Removed entirely by the delete command. DATA_DIR=~/.local/share/mailcompanion # Required. One mailbox, over TLS. IMAP_HOST=imap.example.com # Required. The account. Its secret is in the keychain, not here. IMAP_USERNAME=you@example.com # Required. Names the keychain entry holding the mailbox secret. KEYCHAIN_SERVICE=mailcompanion # Required. Used only when you send something you composed. SMTP_HOST=smtp.example.com # Required. A scheduled send later than this is held and asked about rather than sent. MISSED_WINDOW_MINUTES=30 # Required. Locks the application. An unlocked mailbox on a shared desk is the exposure. IDLE_LOCK_MINUTES=15 # Required. False after installation. Enabling it adds a disclosure line to every message. TRACKING_ENABLED=false
$ choose a build depth, inspect the files, then open the complete pack in your agent
Mailbutler: Email is infrastructure. Customers pay to avoid lost messages, broken auth, spam problems, and the reputational cost of a flaky inbox.
xteam routing and shared inbox rules
xabuse response and security operations
xcalendar-provider edge cases and timezone correctness
xprovider-grade deliverability
xpush reliability across devices
Mailbutler pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| starter | $0/user | $0/user | No published numeric email cap; 14-day all-feature trial falls back to Starter; every sent email carries a visible Mailbutler watermark. |
| professional | $9/user | $7/user | No published numeric send cap; includes templates, mail merge, assignments, notes/tasks/tags and automations; Smart Assistant excluded. |
| smart | $14/user | $11/user | No published numeric send or AI quota; adds per-recipient tracking plus Smart Compose, Improve, Timing, Follow-ups, Task Finder and Summarize. |
| business | custom | — | Custom plan; no numeric caps published; adds a custom subdomain, CRM tracking sync and advanced signature controls. |
free tierNo published numeric email cap; $0 forever, but every sent email includes a visible Mailbutler watermark; 14-day full-feature trial
billingmonthly + annual (up to 25% off); USD/EUR; 14-day trial, no card
hidden costsMail Merge is constrained by the connected email provider's sending limits; Smart Assistant requires Smart or Business after trial; Business has no public price and custom SMTP is still marked coming soon.
verified 2026-08-12 · source ↗
Vibecode Mailbutler
Kinda. The core of Mailbutler is buildable in a weekend with the prompt on this page, but there are real gaps: team routing and shared inbox rules, abuse response and security operations. Read the honest list above before committing.
How much does Mailbutler cost?
Mailbutler's pricing is usage-based or varies by plan · The official pricing URL is recorded, but a stable current amount was not safely recoverable in this pass. Do not publish a number until rechecked..
What do I lose by replacing Mailbutler?
Honestly: team routing and shared inbox rules; abuse response and security operations; calendar-provider edge cases and timezone correctness; provider-grade deliverability; push reliability across devices. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Mailbutler?
Yes: Mailspring (Open-source desktop mail client and useful IMAP prior art.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.