Agents do more of my work every day. The most recent one runs my job search. It finds roles, fills in the application, and stops before the submit button so I can read it. Then Greenhouse emails me a verification code, and the bot needs that code to finish.
So the bot needs my email. I was not going to hand a bot my inbox. Thirteen years of receipts, statements, and everyone I have ever talked to, so it can copy eight characters out of one message? No.
This page is about the middle ground: giving each agent exactly the mail it needs, proving that is all it got, and being able to turn it off in one click.
Grok Bot lives in my Dock like any other app. It fills out forms, stops before Submit, and waits for me. The one thing it cannot do on its own is read the code Greenhouse emails me.
iCloud has a rules screen. Each rule forwards mail from one sender to one address on an isolated subdomain that Amazon SES receives for. The five Greenhouse senders that carry verification codes go to one address. Those five rules are the bot's entire view of my mail, and I can read them on one screen.
On the AWS side the mail stops being an email almost immediately. SES checks that the message really came from Greenhouse, drops it in a bucket that empties after a day, and a small Lambda pulls out the eight-character code. The code goes into a table with a one-hour expiry. The bot asks a tiny read-only endpoint for the latest code. That is all it can ask for. It never sees a subject line, never sees a body, and never has my iCloud password.
Everything about this path is small on purpose. One sender group. One field. One hour. If the bot turns out to be a bad actor, the worst it can do is read a code that has already expired.
The other agent is Claude, and it reads through MCP. The receipt page answers one question: how much did I spend on milk? It only knows about paper. Half of what I buy never prints a receipt. DoorDash, Apple, Amazon, Venmo, PayPal, Uber. Those live in my inbox too, and Claude wants them.
So the same pattern applies, with a wider aperture. Claude never opens the mailbox. It calls a small MCP server that answers questions about receipts as rows: merchant, date, cents, line items. One copy of that server runs on my Mac. The other lives in AWS, and the rest of this section is about how the rows get there.
I exported everything. 162,333 messages going back to 2013. Most of it is noise. About 30,000 came from senders that have ever sent me a receipt, and a little over 4,000 of those actually were receipts.
Then the de-duping. Apple sends the same receipt twice. DoorDash sends an estimate, then a final. After collapsing those I have 3,845 unique receipts and 4,851 line items. The same SQLite file also holds a snapshot of the 1,023 paper receipts and 3,516 card transactions from the bank. 376 of those transactions have a matching receipt so far.
DoorDash puts the restaurant in the subject. Apple hides the total in a table that is split across three cells. Venmo forgets the year. Each sender group gets its own small parser. Regular expressions, no AI. They pull merchant, date, and total 97 to 100 percent of the time.
Some hard truths came out of this. The last four digits of the card are almost never in the email, so matching to the bank has to lean on amount, date, and merchant. DoorDash totals since 2023 are estimates because the tip lands later, so I match against a band instead of a number. Amazon stopped listing items in its emails from 2020 to mid-2023. And Costco email is useless: 3 receipts in 2,256 messages. Costco arrives on paper.
The number I actually care about is coverage. Of the purchases on my card each month, how many can I point to a receipt for?
It is low. That is the point. The metric exists to be embarrassed by. The business card buys things that never email a receipt, so its line sinks toward zero. The personal card bounces around depending on how many paper receipts I bothered to photograph that month.
Exporting my mailbox by hand every few months is not a system. I wanted new receipts to show up on their own, so the receipt senders got rules too. Twenty-one of them, each forwarding one domain to the receipts address.
SES requires TLS, runs the spam and virus scans, stamps the DMARC result on the message, and drops the raw email in a private S3 bucket. My Mac pulls it down every night. Raw mail can be set to expire after a number of days, but that switch stays off until the Mac's copy is verified complete. That is it. S3 is just the archive.
Since it went live in July: 1,189 messages forwarded, 59 were receipts, and 14 failed the trust gate. Seven of those were Equinox marketing sent through a third party that failed DMARC alignment, six were flagged as spam by SES, and one was Amazon's own setup notice. The gate holds.
The SQLite file on my Mac is the primary. Every night a job pulls the new raw mail down from S3, parses it with the one and only set of parsers, and reconciles it against the bank. Then it takes a consistent copy of the file and uploads it back to S3 next to a manifest: a checksum, row counts, and when it was published. Then it exports a second, much smaller database with exactly two tables, receipt line items and card transactions, checks it against a schema contract, and uploads that under its own prefix. That job is a launchd agent at seven in the morning, and it has already run without me: 21 new messages, 3 receipts, a fresh copy in S3.
On the other side is a tiny AWS Lambda. Python standard library only. No container, no dependencies. Its role can read the small two-table export and nothing else: not the full copy, not the raw mail. It downloads that file on cold start, validates it against the same contract the exporter enforced, exact tables, exact columns, no views, no triggers, and refuses to serve anything if the check fails. Then it opens it read-only and speaks MCP over HTTPS behind the same OAuth gateway the job bot uses, with its own scope.
Now Claude on my phone, a scheduled agent, or Claude Code on any machine can ask spend questions in SQL over those two tables: what did I spend at grocery stores this quarter, per currency, and which card charges have no matching item. No message index, no order numbers, no card digits, no bank descriptors exist in that file, so there is nothing to leak through a clever query. Writes, and the richer tools the local server has, stay on the Mac. The export lags by a day, and the manifest tells the agent exactly how stale it is.
The first version was fancier. A Lambda woke up on every object that landed in S3, ran a second copy of every parser, and wrote a JSON file that nothing ever read. Two copies of 5,000 lines of regular expressions drifting apart. 92 percent of what it parsed was not a receipt.
I deleted the Lambda, its dead-letter queue, the alarm, the retry config, and the S3 trigger. Nine resources. SES to S3 is now just mail in a bucket. Fewer moving parts, same data, and one parser to fix when Apple changes its template again.
The one thing AWS still shares with the Mac is the schema contract of that two-table export: the version number, the table names, the column list, and the list of column names that must never appear. The exporter enforces it when it writes and the Lambda enforces it when it reads, and a test runs the real exporter against a synthetic primary and proves the Lambda accepts exactly that file and nothing else. That is how the second-parser problem does not come back.
A rules list is only a permission model if someone audits it. I have 13 years of mail indexed, so the audit is a query, not a scroll through the inbox: which senders have ever produced a receipt, and does each one have a rule? And the other direction: which rules forward mail that nothing ever parses?
| Rule | Verdict | Forwarded since July | Receipts |
|---|---|---|---|
| starbucks.com | Add. 53 receipts, 15 in the last two years, none forwarded. | 0 | 0 |
| github.com | Delete. Ten receipts in seven years, same address as every notification. | 756 | 0 |
| costco.com | Delete. Costco email is image-only; the warehouse receipts come from an export. | 67 | 0 |
| chase.com | Delete. Balance alerts, and nothing reads them. | 36 | 0 |
Twenty-one rules become nineteen. Those three deletes were 859 of the 1,189 messages, so the archive shrinks by 72 percent and nothing that gets parsed is lost. Seven other senders have parsers but have not emailed me a receipt in two years. They get a rule if they come back, not before. Adding a parser and adding a rule are the same moment.
The audit is a command, not a memory. iCloud has no API, so a text file in the repo mirrors the rules screen, and one command compares it against every sender that has ever produced a receipt. I run it after I touch the rules. It tells me what to add, what forwards mail nothing reads, and which parsers have no rule.
Every path has a switch I own. Delete a rule and that sender stops reaching any agent, in minutes. Delete the exported file and the receipt reader answers with nothing. Revoke the OAuth token and the endpoint stops answering at all. The bot never held a credential of mine, so there is nothing of mine to rotate.
The milk question now has an answer for the half of my life that never touches paper, and the job bot gets its code. Neither of them can read my email. I might have a DoorDash problem.
If you are still here, here is what is actually under the hood.
SES allows one active receipt rule set per account and region, so both inboxes share it: one rule per recipient address. The bucket policy only lets that exact receipt rule write under the raw prefix. Nothing else in the account can. The Lambda's role can read the two-table export and nothing else, so even raw SQL cannot reach a raw email or the full copy of the database.
Each server has its own Cognito scope. A token minted for the receipt replica cannot call the code reader, and the bot's token cannot call the replica. The code reader's bucket keeps mail for one day and its table rows expire after one hour; the receipt archive keeps raw mail until its retention switch is turned on.
Money is integer cents everywhere. Ingest is idempotent on the Message-ID and on a hash of the content, so re-running over an overlapping export adds nothing. The raw SQL tool only accepts SELECT and WITH, caps rows and response size, times out a slow statement, and can read only the two exported tables. Every money column carries its currency, so a query has to add up per currency; line items repeat the receipt total, so a receipt total is a DISTINCT, not a SUM.
The API Gateway integration window is 29 seconds, so the Lambda times out at 25. The exported database is well under a megabyte gzipped and is downloaded once per cold start.
Pulumi in Python defines all of it: the SES identity and DKIM records, both buckets and their policies, both Lambdas, and the gateway routes with their own OAuth scopes.
The code is on GitHub if you want to see how the sausage gets made.