Skip to content

Repository files navigation

Lede

Lede

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-Unsubscribe header (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.

Quick start (try it without Gmail)

pnpm install          # or npm install
pnpm seed             # load a few sample newsletters
pnpm dev              # open http://localhost:3000

The 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.


Sign-in & connecting Gmail (multi-user)

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.

1. Create a Google Cloud project + enable Gmail API

2. OAuth consent screen

  • 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.

3. Create a Web application OAuth client

  • 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.

4. Configure .env

cp .env.example .env

Set 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).

5. Sign in

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.

Sync

pnpm sync      # pull newsletters from Gmail, then auto-classify new senders
pnpm dev       # browse them

You 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 scratch

Classification shells out to the locally-authenticated Claude Code CLI (claude -p, Haiku model) in batches — there is no API key to configure.


Configuration (.env)

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.)


Running on Tailscale (systemd)

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.timer

The 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 to claude). Adjust the PATH= lines if yours differ.


Install as an app + notifications

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.net domain (tailscale cert / Caddy auto-HTTPS) — those certs are valid. http://localhost also 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-web

Install 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.


Testing & validation

pnpm test         # unit tests: parsing, sanitization, full sync (in-memory DB)
pnpm typecheck    # tsc --noEmit
pnpm validate     # typecheck + test
pnpm build        # production build

The 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.


How it works

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.

Project layout

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

About

newsletters

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages