I recently needed to find an email from eighteen months ago, a thread about project that had become relevant again. I knew the thread existed. I could picture writing some of those replies. But email search is surprisingly bad when you can't remember the exact phrasing, and after fifteen minutes of scrolling through results that weren't quite right, I gave up and reconstructed the conversation from memory.

This happens more often than I'd like. Email is where important communications happen, but it's a terrible archive. Threads get buried. Attachments scatter into different folders or disappear into the void of "original message below." Search works well for things you remember precisely and poorly for things you half-remember. And the things that matter most are usually the ones you need to find years later, when the details have faded.

Meanwhile, Notion had become my second brain for everything else. Project notes, meeting records, client information, reference materials. When I need to find something there, I find it. The structure I created helps rather than hinders.

The challenge was clear. The important emails lived in one system; the context for those emails lived in another. When I needed to connect a conversation to the project it concerned, I was mentally stitching together two separate databases. How could I simply and securely merge the relevant emails into my knowledge base?

Existing solutions

The usual solution is to dump everything into one place. Forward all emails to Notion, or use a Zapier integration that automatically creates entries. But I explored this approach, but the result would be chaos. My archive would fill up with newsletters, receipts, automated notifications, and the vast majority aren't worth keeping. The signal dominated by noise. What I actually wanted wasn't all my email in Notion, but the ability to choose which threads mattered enough to archive, then have them filed properly without further effort.

The workflow I imagined was simple: forward an email thread I want to keep, add a hashtag like #acme to the subject line to tag it by client, and have it appear in my Notion database with proper formatting and attachments. One action on my end; the rest handled automatically.

Existing solutions didn't fit. Zapier and Make are designed for automation, not curation. They process everything, which is the opposite of what I wanted. Third-party email-to-Notion services exist, but they route your email through their servers. That bothered me. Client emails contain confidential information, contract details, project specifics. Even brief transit through external infrastructure is unnecessary exposure.

The copy-paste approach works, technically, but loses everything that makes email useful as a record: formatting collapses, attachments detach, the thread structure disappears. You end up with a blob of text that requires mental effort to parse later.

So I built it myself.

How it works

The architecture is straightforward once you see it: Email → SES → S3 → Lambda → Notion.

AWS SES receives inbound email at a secret address I control. The email lands in an S3 bucket as raw MIME data. S3 triggers a Lambda function that parses the email, validates the sender, extracts the content, converts it to Notion's format, and creates the database entry with attachments uploaded. The whole process takes a few seconds.

The "secret inbox" pattern deserves explanation. The receiving address isn't notion@mydomain.com but something like notion-{long-random-string}@mydomain.com. The randomness is the first layer of security. No one can create entries in my Notion database without knowing this address, and the address is effectively unguessable.

The second layer is a sender whitelist. Even if someone discovered the address (unlikely, but possible), they'd need to forge the sender to match one of the authorized email addresses. Together, these make the system resistant to spam and abuse without requiring complex authentication.

Raw emails stay in S3 for seven days, then automatically delete. This gives me time to verify entries arrived correctly and investigate any failures, without accumulating an indefinite backlog of sensitive data in yet another location.

Why SES instead of a friendlier service like Postmark? I actually started with Postmark. Their inbound webhook API is genuinely pleasant: you get a well-structured JSON payload instead of raw MIME, which simplifies parsing considerably. But then I realized what that convenience implied: my emails were passing through Postmark's servers, being parsed by their systems, before the webhook fired. For a few hundred milliseconds, my client communications existed on infrastructure I didn't control.

SES is more work to configure (receipt rules, MX records, raw MIME parsing) but everything stays in my AWS account. The email never touches third-party infrastructure. That trade-off made sense for my use case.

Building with AI

I built this with Claude Code, but not in the way that phrase sometimes implies.

"Vibe coding" can produce functional software. It also produces software that works until it doesn't, with failure modes no one understands because no one wrote the code. The resulting system feels like a black box, even to the person who prompted it into existence.

I approached this project differently. Before writing any code, I produced a design document: 650 lines specifying every technical decision. What email headers to parse and how. How to handle the different formats Gmail, Outlook, and Apple Mail use for forwarded messages. What to do when attachment uploads fail. How to structure error logging so failures are diagnosable. The document existed before the AI generated a single line of code.

From that design, I created an five-stage implementation plan. Each stage had explicit success criteria. Stage 3, for example, was "Subject Line Parsing," and it wasn't complete until unit tests passed for extracting hashtags, stripping forwarding prefixes (Fwd:, Re:, Fw:), and handling edge cases like missing hashtags or unusual formatting. The tests existed first; the implementation came after.

Claude Code accelerated this process substantially. It could read the design document, understand the specification, and generate implementations that mostly worked on the first try. But "mostly" is the key word. I reviewed every function, ran every test, and modified code that didn't match the specification or that introduced edge cases the AI hadn't considered. The AI was a collaborator, not a replacement for judgment.

This took days, not hours. A simpler, less rigorous "vibe coding" would have been faster initially. But I've learned that the time saved in development gets spent—with interest—debugging production failures. Rigor up front is amortized over the lifetime of the system. But without AI tooling, this could have easily taken weeks.

Technical details

The hashtag-in-subject pattern is more powerful than I expected. By putting #clientname at the start of the forwarded subject, I created a user-defined tagging system without any infrastructure. There's no database of valid clients. There's no configuration file to update when I take on new work. I just type a hashtag, and that hashtag becomes the organization. The simplicity is load-bearing—it means the system never needs maintenance when my client list changes.

Parsing forwarded email headers presented a genuine challenge. When you forward an email, your mail client prepends information about the original sender and date. But Gmail, Outlook, and Apple Mail do this differently. Gmail uses a ---------- Forwarded message --------- delimiter with specific headers. Outlook uses underscores and different header labels. Apple Mail says "Begin forwarded message:" and formats the headers as a styled block. The parser needs to handle all three, plus graceful degradation when it encounters a format it doesn't recognize.

There's also the self-reply problem. If I replied to a client's email and then forwarded the thread, the most recent sender is me, not the client. Naively extracting the "From" address from forwarded headers would file the email under my own address, which is useless. The parser detects this case and skips to the next message in the thread to find the actual client.

Converting email content to Notion's format requires two transformation stages. First, HTML becomes Markdown using Turndown, a library that handles the conversion reasonably well for typical email formatting. Then the Markdown becomes Notion's block structure—headings, paragraphs, lists, code blocks, each mapped to the appropriate block type. Notion has a 2000-character limit per rich text element, so long paragraphs need to be chunked. Links need to be extracted and converted. The details are tedious but important: getting them wrong produces database entries that look broken or lose information.

Attachments require careful filtering. Email contains many embedded images that aren't really attachments: company logos, signature images, tracking pixels. These use Content-ID references in the HTML and shouldn't be uploaded as files. Real attachments, the PDFs and documents I actually want to keep, have different characteristics. The filter distinguishes between them based on how they're referenced and whether they have a Content-ID. Executables are blocked entirely for security.

I added optional AI summarization using Claude 3.5 Haiku. When enabled, the system generates a two-to-three-sentence summary of each email and stores it both as a database property (for quick scanning) and as a callout block at the top of the page. This costs about a tenth of a cent per email. If the API call fails, the email still archives perfectly well, but the summaries make scanning through old entries faster. I've opted to disable this for now, since I'd rather use local or sandboxed models for privacy reasons.

What I learned

The manual forwarding step, which seemed like necessary friction, is a feature. Automatic archiving would mean dealing with the same filtering problem that makes email search difficult: too much noise, not enough signal. By requiring explicit forwarding, the system ensures I only archive what I've decided is worth keeping. Human judgment for selection, automation for everything else.

Hashtags solved a configuration problem I didn't know I had. Any system that files things into categories eventually needs a category list. But category lists need maintenance. New clients appear. Old categories become irrelevant. If the list lives in configuration, the system requires updates. Hashtags move that configuration into the action itself. I don't maintain a list of valid clients; I just type what I need, and the organization emerges from usage. This pattern (configuration through use rather than in advance) applies in many contexts.

What It Costs

The system costs almost nothing to run. AWS SES charges ten cents per thousand emails received. S3 storage with seven-day auto-deletion is essentially free. Lambda invocations fall well within the free tier for personal use. The optional AI summarization adds about a dollar per thousand emails. My total monthly cost is between zero and five dollars, depending on usage.

This matters because it removes financial pressure to justify the investment. The system can run indefinitely. I don't need to think about whether it's worth the subscription. When something costs nearly nothing, it can be infrastructure rather than a service.

The repository is open source. Terraform handles the AWS infrastructure, and the README covers setup. The result is a tool I use regularly, that costs almost nothing to run, and that I understand completely because I built it from first principles. When something eventually breaks, I'll know where to look.