In the first post of this series, I talked about why I built InboxToKindle. The frustration of newsletters piling up in an inbox I rarely open, and the idea of routing them to the device I actually read on. This post is about how it works under the hood: the stack, the tradeoffs, and the pipeline that turns a raw email into a file sitting on your Kindle.
Choosing the Stack
The first real decision with any side project is which tools to reach for. There are always more options than you need, and the temptation to chase the interesting new thing is real. I made a deliberate choice to ignore it.
Django over FastAPI or Node.js. Django gives me an ORM, an admin panel, migrations, and authentication all at once. For an MVP where I'm one person doing everything, that matters. FastAPI is excellent, but I'd have been assembling pieces (auth library, admin tool, migration framework) that Django ships as a unit. I wanted to build the product, not the infrastructure around the product.
HTMX + custom CSS over React or Next.js. The frontend for InboxToKindle is not complicated. Users connect their newsletter services, link their Kindle email, and manage subscriptions. That's a form-heavy application, exactly what server-rendered HTML handles well. HTMX gives me dynamic interactions (form submissions without full page reloads, live status updates) without a JavaScript build pipeline. For styling, I wrote a custom design system I'm calling "Ink & Paper," inspired by the Kindle's e-ink display. Monochrome palette, clean typography with DM Sans and Instrument Serif, generous spacing. It felt right for an app whose entire purpose is readability. I went from zero to a usable interface in hours, not days.
django-tasks over Celery. This one deserves a dedicated post (and gets one). The short version: Django 6.0 introduced a built-in task framework, and the django-tasks package provides a database-backed worker for it. No Redis, no separate worker process to manage, no additional infrastructure cost. For the volume InboxToKindle handles, it's exactly the right tool.
uv for package management. Fast, reliable, and drops into CI/CD cleanly. Once you use it, going back to pip feels slow.
The Email Processing Pipeline
The core of InboxToKindle is a pipeline. An email arrives, gets processed, and lands on your Kindle as a readable EPUB. Each step is a separate concern.
Step 1: Email Arrives
Users get a personal inbox address when they sign up, something like yourname@inboxtokindle.com. They subscribe to newsletters with that address, or forward existing ones there. AWS SES receives the inbound email, stores the raw message in S3, and fires a notification to the application via SNS.
There's also a fallback: a management command that polls S3 directly for new emails. If the webhook fails or gets delayed, the scheduler picks them up on the next cycle. Both paths end in the same place, an InboundEmail record in the database with the raw payload stored as JSON.
Step 2: Parsing the Email
Not all email payloads look the same. InboxToKindle supports AWS SES/SNS, Mailgun, SendGrid, and Postmark formats, because different users come from different setups. The parser detects the format and normalizes everything into a consistent structure: to_address, from_address, subject, html_body, text_body, message_id, date. Everything downstream works with this normalized dict, regardless of how the email arrived.
This runs as a background task via django-tasks, so the webhook endpoint returns immediately and the heavy work happens asynchronously.
Step 3: Cleaning the Content
This is the hardest part of the entire pipeline, and the part I underestimated most.
Newsletter HTML is messy. It's designed to render in email clients with decades of quirks, not on e-ink screens. A typical newsletter contains tracking pixels, unsubscribe widgets, social sharing buttons, sticky headers, Substack branding, and inline styles that make text tiny or weirdly colored on a Kindle.
BeautifulSoup4 handles the cleaning. It strips tracking pixels (identified by 1x1 dimensions or known tracking domains), JavaScript, Substack subscription widgets, paywall prompts, share buttons, and most inline styles. What remains is the article text, its images, and minimal structure. That's what a Kindle actually needs.
Getting this right took iteration. Some newsletters break in interesting ways. Images embedded as base64, content wrapped in deeply nested tables, character encodings that produce garbage output. Each edge case required a fix.
Step 4: Handling Confirmation Emails
This is something I didn't think about until I ran into it. When you subscribe to a newsletter with your InboxToKindle address, the newsletter platform sends a confirmation email. That email shouldn't become an EPUB on your Kindle. It should reach you so you can click the confirmation link.
The system detects confirmation emails using a two-signal approach: it checks for keywords in the subject ("confirm", "verify", "activate") and uses BeautifulSoup to scan for confirmation links in the body. If the sender is a known platform like Substack or Beehiiv, the subject alone is enough.
When a confirmation is detected, it gets forwarded to the user's real email with the extracted links, and no Newsletter record is created. This means it doesn't count toward the free tier limit either.
Step 5: EPUB Generation
ebooklib handles EPUB creation. It's a pure Python library, no external dependencies. The pipeline takes the cleaned HTML, downloads external images, compresses them with Pillow (max 1200px wide, JPEG quality reduced progressively until each image is under 500KB), caps at 20 images per EPUB to keep file sizes reasonable, applies a minimal stylesheet tuned for e-ink readability, generates a cover image with the newsletter name and date, and compresses the result into a valid EPUB file. The finished file gets uploaded to S3.
The cover is a small touch, but it matters for Kindle's library view. Without it, all your newsletters look the same in the grid. With it, you can tell at a glance which one you're picking up.
Step 6: Delivery
The EPUB gets sent to the user's Kindle address via AWS SES, using the django-ses package. Amazon's Send-to-Kindle service accepts EPUB attachments from approved senders. That's why the onboarding flow asks users to add InboxToKindle's email address to their approved senders list in Amazon's settings.
One complication: SES has a 10MB attachment limit. The image compression handles most cases, but newsletters with dozens of high-resolution images can still exceed it. The fallback: if the file is too large, InboxToKindle sends the user a download link instead. Not ideal, but better than a silent failure.
Users can choose how they receive newsletters: instant delivery (the default, sent within minutes), a daily digest, or a weekly digest. Digests compile multiple newsletters into a single EPUB, which is nicer to read when you're catching up on several at once.
The Billing Side
InboxToKindle has a free tier of 15 newsletters per month. After that, you need the Pro plan ($10/month or $100/year for unlimited). Stripe handles payments.
The usage tracking is straightforward. A UsageTracker model counts deliveries per user per calendar month. Before creating a Newsletter record, the system calls can_deliver() to check whether the user has quota remaining or is on a Pro plan. Confirmation emails don't count toward the limit, since they never become newsletters.
I wanted the free tier to be genuinely useful, not a teaser that runs out on day two. Fifteen newsletters per month covers a couple of weekly subscriptions comfortably.
Authentication and Onboarding
django-allauth handles authentication, configured for email-only login (no usernames). The onboarding is a four-step wizard:
- Add your Kindle device. Enter your Kindle email address and country.
- Approve the sender. Instructions for adding InboxToKindle to Amazon's approved senders list. This step is just instructions, no form, but it's the one that trips people up the most.
- Verify it works. The app generates a random six-digit code, wraps it in a welcome EPUB, and sends it to your Kindle. You enter the code to prove the connection works end-to-end.
- Subscribe to your first newsletter. Shows your personal inbox address and encourages you to use it.
The verification step is my favorite. It tests the entire pipeline (EPUB generation, SES delivery, Kindle reception) before the user commits to subscribing anything. If something is misconfigured, you find out during onboarding, not after wondering why your newsletters never arrived.
Lessons from Building It
Simplicity compounds. Every time I chose the simpler option (django-tasks over Celery, HTMX over React, one server over a distributed setup) I saved time I could spend on the actual product. Complexity has a tax. Pay it only when you must.
Email HTML is a different world. I knew email rendering was inconsistent across clients, but I didn't appreciate how strange newsletter HTML gets in practice. Budget more time than you think for the content cleaning step if you're building anything that processes email content. This is also a good place to leverage existing libraries and tools, such as BeautifulSoup for parsing and Pillow for image processing. Don't try to reinvent the wheel here.
The 10MB SES limit is real. Image-heavy newsletters from publications like The Atlantic or Bloomberg used to hit it regularly. Adding image compression (resize to 1200px, progressive JPEG quality reduction, 20-image cap) solved most of it. The download-link fallback is still there for edge cases, but it triggers much less often now.
Confirmation emails need special handling. I only realized this after trying to subscribe to a newsletter that needed my confirmation. The forwarding logic solved the issue.
Onboarding should test the full path. The six-digit verification code during onboarding catches configuration problems early. Without it, the first failure a user sees would be a newsletter that never arrived, which is much harder to debug.
What's Next
The next post in this series goes deeper on django-tasks: how the DatabaseBackend works, why it's a compelling alternative to Celery for projects at this scale, and what you give up by leaving Redis out of the picture.
If you want to try InboxToKindle in the meantime, it's live at inboxtokindle.com.