The part worth reading, first.
A calm, Meco-style reader for the newsletters that land in your Gmail — that quietly filters out the marketing. It pulls newsletters out of your inbox into a local SQLite database, classifies each sender so only the ones worth reading reach you, and presents them in a clean three-pane reader that works on both desktop and mobile web. Built to run on your own machine / Tailscale network — no login, no accounts, your data never leaves your box.
- Stack: Next.js 15 (App Router) · TypeScript · tRPC v11 · TanStack Query · SQLite (better-sqlite3 + Drizzle) · Tailwind + shadcn/ui · Gmail API (read-only) · Claude Code CLI (sender classification)
- How newsletters are detected: any message carrying a
List-Unsubscribeheader (works for Substack, beehiiv, Mailchimp, ConvertKit, Morning Brew, etc.). Personal email is ignored. - How the noise is removed: every sender is classified
editorial/marketing/transactional, and only editorial newsletters reach the feed — so a brand's "newsletter" of sale blasts never buries the writing you signed up for. See Sync.
pnpm install # or npm install
pnpm seed # load a few sample newsletters
pnpm dev # open http://localhost:3000The seed step lets you click around the reader immediately. When you're ready for real mail, connect Gmail (below) and hit the sync button (↻) in the app.
Lede is multi-user: each person signs in with Google, which also grants read-only Gmail access in the same consent. No write/delete scope is ever requested. Sign-in identity and the Gmail token are one and the same — the account you log in with is the inbox Lede reads.
- https://console.cloud.google.com/ → new project.
- APIs & Services → Library → "Gmail API" → Enable.
- APIs & Services → OAuth consent screen → User type External.
- App name + support email; Scopes: add
.../auth/gmail.readonly. - Publishing status: for unattended background sync, set this to In production (you can do this without full verification — users just see a one-time "Google hasn't verified this app" screen and click Advanced → continue). While it's left in Testing, Google expires refresh tokens after 7 days, which breaks the hourly sync.
- APIs & Services → Credentials → Create Credentials → OAuth client ID.
- Application type: Web application.
- Authorized redirect URI:
https://<your-host>/api/auth/callback(e.g.https://nl.mdabba.dev/api/auth/callback). - Copy the Client ID and Client secret.
cp .env.example .envSet the OAuth client, app URL, a session secret, and (optionally) the owner who inherits any pre-existing single-user data on first login:
GOOGLE_CLIENT_ID=...apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=...
APP_URL=https://nl.mdabba.dev
SESSION_SECRET=$(node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))")
OWNER_EMAIL=you@gmail.com # optional: claims legacy data
Then systemctl --user restart lede-web (or pnpm build && pnpm start).
Open the app → Continue with Google → approve read-only Gmail access. Anyone with a Google account who can reach the site can sign in and connect their own Gmail; data is fully isolated per user. Hit ↻ to sync, or wait for the hourly job.
pnpm sync # pull newsletters from Gmail, then auto-classify new senders
pnpm dev # browse themYou can also sync from inside the app with the ↻ button. Sync is incremental and idempotent — re-running only fetches messages you don't already have.
Every email with a List-Unsubscribe header looks like a "newsletter" to Gmail,
so a plain sync also pulls in marketing and transactional blasts. After importing,
sync runs a classifier (pnpm classify) that labels each new sender
editorial, marketing, or transactional and mutes everything that isn't
editorial — only editorial senders appear in the reader. Muted senders are listed
under Muted senders in the sidebar, where you can restore any the classifier
got wrong (it also re-classifies any sender you restore as a manual override).
pnpm classify # classify only newly-discovered senders (run automatically by sync)
pnpm classify --force # re-classify every sender from scratchClassification shells out to the locally-authenticated Claude Code CLI
(claude -p, Haiku model) in batches — there is no API key to configure.
| Variable | Default | Purpose |
|---|---|---|
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
— | OAuth client (Web application) |
APP_URL |
http://localhost:3400 |
Public base URL; OAuth redirect is <APP_URL>/api/auth/callback |
SESSION_SECRET |
— | Signs the session cookie (random 48+ bytes) |
OWNER_EMAIL |
— | First login with this email claims pre-existing single-user data |
NEWSLETTER_QUERY |
newer_than:60d |
Gmail search used to find candidate newsletters (classified after import) |
SYNC_MAX_MESSAGES |
300 |
Max messages pulled per sync |
DATABASE_PATH |
data/newsletters.db |
SQLite file location |
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY |
— | Web Push keys (see Notifications below) |
VAPID_SUBJECT |
mailto:admin@example.com |
Contact URI sent to push services |
NOTIFY_TIMEZONE |
Asia/Kolkata |
Timezone for the notification window |
NOTIFY_START_HOUR / NOTIFY_END_HOUR |
8 / 20 |
Only notify within [start, end) local hours |
Tweak NEWSLETTER_QUERY to taste, e.g. newer_than:30d for a shorter window.
It accepts any Gmail search operators.
Keep it broad — separating real newsletters from marketing is the classifier's
job, not the query's. (Note: Gmail has no server-side operator for the
List-Unsubscribe header — has:list-unsubscribe silently matches nothing — so
the query just casts a wide net and the parser + classifier do the filtering.)
Lede runs as systemd user services behind a reverse proxy (e.g. Caddy) on
your tailnet. The unit files live in deploy/systemd/:
| Unit | Role |
|---|---|
lede-web.service |
Next.js production server, bound to 127.0.0.1:3400 |
lede-sync.service |
oneshot: pnpm sync (imports + auto-classifies new senders) |
lede-sync.timer |
fires the sync hourly (and 5 min after boot) |
Install:
pnpm build
mkdir -p ~/.config/systemd/user
cp deploy/systemd/* ~/.config/systemd/user/
loginctl enable-linger "$USER" # run without an active login session
systemctl --user daemon-reload
systemctl --user enable --now lede-web.service lede-sync.timerThe web server binds to localhost only — point your proxy at 127.0.0.1:3400
and reach it from any device on the tailnet. There's no auth by design; keep it
on your private network. After a code change: pnpm build && systemctl --user restart lede-web. Logs: journalctl --user -u lede-web -f (or -u lede-sync).
The units assume Node at
~/.nvm/versions/node/<version>/bin, pnpm at~/.local/share/pnpm, and the Claude Code CLI at~/.local/bin(the classifier shells out toclaude). Adjust thePATH=lines if yours differ.
Lede is a PWA — installable to your home screen, with Web Push notifications when new newsletters arrive.
Prerequisites (especially for iOS):
- Served over HTTPS with a trusted cert. Service workers and push do not
work over plain HTTP or self-signed certs. With Tailscale, expose it on your
*.ts.netdomain (tailscale cert/ Caddy auto-HTTPS) — those certs are valid.http://localhostalso works for testing on the same machine. - iOS 16.4+ only delivers push to installed PWAs. You must Add to Home Screen first; notifications do nothing in a Safari tab.
Set up Web Push (one time):
node -e "console.log(require('web-push').generateVAPIDKeys())"
# put the keys in .env as VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY,
# set VAPID_SUBJECT=mailto:you@example.com, then: systemctl --user restart lede-webInstall on iPhone/iPad: open the site in Safari → Share → Add to Home Screen. Launch Lede from the new icon, then tap the 🔔 bell in the sidebar footer and allow notifications. You'll get an immediate test notification to confirm it works. (Android/desktop Chrome: use the install prompt / address-bar install icon, then the same bell.)
When notifications fire: after each hourly sync, Lede pushes a summary of new
posts from your subscribed (editorial) senders — marketing/transactional mail
never notifies. Notifications are sent only within NOTIFY_START_HOUR–
NOTIFY_END_HOUR (default 08:00–20:00) in NOTIFY_TIMEZONE; posts that
arrive outside the window are held and announced when it next opens. Each post is
notified at most once.
pnpm test # unit tests: parsing, sanitization, full sync (in-memory DB)
pnpm typecheck # tsc --noEmit
pnpm validate # typecheck + test
pnpm build # production buildThe sync engine is built against an injectable GmailSource interface, so the
entire import pipeline is tested end-to-end against in-memory fixtures
(test/fixtures/messages.ts) — no Gmail credentials required. Tests cover
sender/header parsing, base64url body extraction, newsletter classification,
HTML sanitization (script/handler/javascript: stripping), and that sync is
idempotent and correctly groups issues by sender.
Gmail API ──► sync engine ──► SQLite ──► tRPC ──► React reader
(readonly) parse + sanitize (Drizzle) (typed) (shadcn UI)
src/server/gmail/parse.ts— pure functions: header parsing, MIME body extraction, newsletter detection. Fully unit-tested.src/server/gmail/sanitize.ts— strips scripts/handlers/unsafe URLs; newsletter HTML is rendered inside a sandboxed iframe with no script execution, so it's isolated from the app.src/server/gmail/sync.ts— orchestrates list → fetch → parse → store, skipping already-imported and non-newsletter messages. New senders are stored muted until classified.src/server/classify/— batches new senders to the Claude Code CLI, labels them editorial/marketing/transactional, and subscribes only the editorial ones. Runs after every sync; manual overrides are preserved.src/server/trpc/— typed API (newsletters,messages,sync). Feed queries only return mail from subscribed senders.src/components/app/— the reader: sidebar (feeds + All/Unread/Starred), message list (infinite scroll), and reading pane.
src/
app/ Next.js routes, tRPC HTTP handler, providers
components/
app/ reader UI (sidebar, list, reading pane, iframe)
ui/ shadcn primitives
lib/ env loader, tRPC client, formatting helpers
server/
db/ Drizzle schema + SQLite bootstrap
gmail/ OAuth client, parse, sanitize, sync engine, live source
trpc/ routers + context
scripts/ gmail:auth, sync, seed
test/ vitest suites + Gmail fixtures