A privacy-first, offline-capable Progressive Web App for Forward Email. Ships as static assets and runs entirely in the browser with local caching, full-text search, and multi-account support.
- Multi-account — Login with multiple Forward Email accounts, alias auth, and optional API key override
- Mailbox — Folders, message threading, bulk actions, keyboard shortcuts, attachment handling, PGP decryption
- Compose — Rich text editor (TipTap), CC/BCC, emoji picker, attachments, draft autosave, offline outbox queue
- Search — Full-text search with FlexSearch, optional body indexing, saved searches, background indexing
- Offline — IndexedDB caching for folders/messages/bodies, sync queue for pending actions, service worker caching
- Calendar — Month/week/day views, quick add/edit/delete, iCal export
- Contacts — CRUD operations, vCard import/export, deep links to compose/search
- Settings — Theme (system/light/dark), cache controls, keyboard shortcuts, PGP key management
| Category | Technologies |
|---|---|
| Framework | Svelte 5, Vite 5 |
| Styling | Tailwind CSS 4, PostCSS |
| State | Svelte Stores |
| Database | Dexie 4 (IndexedDB) |
| Search | FlexSearch |
| Editor | TipTap 2 |
| Calendar | schedule-x |
| Encryption | OpenPGP |
| Testing | Vitest, Playwright |
| Tooling | ESLint 9, Prettier 3, Husky, commitlint |
The application follows a client-first, offline-capable architecture with three main layers:
graph LR
CDN["Static Assets (CDN)"] --> SW["Service Worker"] --> MT["Main Thread"] --> WW["Web Workers"]
MT --> IDB["IndexedDB (Dexie)"]
IDB --> API["API (data fallback)"]
- Main Thread — Svelte components, stores, routing, UI rendering
- db.worker — Owns IndexedDB via Dexie, handles all database operations
- sync.worker — API fetching, message parsing (PostalMime), data normalization
- search.worker — FlexSearch indexing and query execution
Detailed architecture documentation is available in the docs/ directory:
- Vision & Architecture — Design principles and architectural patterns
- Worker Architecture — Worker responsibilities and message passing
- Cache & Indexing — Storage layers and data flow
- Search — FlexSearch setup and query parsing
- Service Worker — Asset caching strategy
- DB Schema & Recovery — Database management
src/
├── main.ts # App bootstrap, routing, service worker registration
├── config.ts # Environment configuration
├── stores/ # Svelte stores (state management)
│ ├── mailboxStore.ts # Message list, folders, threading
│ ├── mailboxActions.ts # Move, delete, flag, label actions
│ ├── messageStore.ts # Selected message, body, attachments
│ ├── searchStore.ts # Search queries and index health
│ ├── settingsStore.ts # User preferences, theme, PGP keys
│ └── ...
├── svelte/ # Svelte components
│ ├── Mailbox.svelte # Main email interface
│ ├── Compose.svelte # Email composer
│ ├── Calendar.svelte # Calendar view
│ ├── Contacts.svelte # Contact management
│ ├── Settings.svelte # User settings
│ └── components/ # Reusable components
├── workers/ # Web Workers
│ ├── db.worker.ts # IndexedDB operations
│ ├── sync.worker.ts # API sync and parsing
│ └── search.worker.ts # Search indexing
├── utils/ # Utilities
│ ├── remote.js # API client
│ ├── db.js # Database initialization
│ ├── storage.js # LocalStorage management
│ └── ...
├── lib/components/ui/ # UI component library (shadcn/ui)
├── styles/ # CSS (Tailwind + custom)
├── locales/ # i18n translations
└── types/ # TypeScript definitions
- Node.js 20+
- pnpm 9.0.0+
pnpm installpnpm dev # Start dev server (http://localhost:5174)pnpm build # Build to dist/ + generate service worker
pnpm preview # Preview production build locally
pnpm analyze # Build with bundle analyzerpnpm lint # Run ESLint
pnpm lint:fix # Fix linting issues
pnpm format # Check formatting
pnpm format:fix # Fix formatting
pnpm check # Run svelte-check# Unit tests (Vitest)
pnpm test # Run all tests
pnpm test:watch # Watch mode
pnpm test:coverage # Generate coverage report
# E2E tests (Playwright)
pnpm exec playwright install --with-deps # First-time setup
pnpm test:e2e # Run e2e testsThis project uses Conventional Commits enforced by commitlint. Every commit message must follow the format:
type(scope): description
| Type | When to use | Version bump |
|---|---|---|
feat |
New feature | minor |
fix |
Bug fix | patch |
docs |
Documentation only | none |
refactor |
Code change that neither fixes nor adds | none |
perf |
Performance improvement | patch |
test |
Adding or updating tests | none |
chore |
Build, CI, tooling changes | none |
Scope is optional: fix(compose): handle pasted recipients or fix: handle pasted recipients are both valid.
To trigger a major version bump, add a BREAKING CHANGE: footer:
feat: redesign settings page
BREAKING CHANGE: settings store schema changed, requires cache clear
Releases are cut locally using np. It runs pre-release checks (clean tree, tests, build), bumps package.json, creates a git tag, pushes, and drafts a GitHub Release.
Only release commits trigger production deployment — regular pushes to main run CI (lint, test, build) but do not deploy.
pnpm release # interactive version prompt, runs checks, pushes, drafts GitHub releaseCreate a .env file to override defaults:
# API base URL (Vite requires VITE_ prefix for client exposure)
VITE_WEBMAIL_API_BASE=https://api.forwardemail.netFirst time setup? See the complete Deployment Checklist for step-by-step instructions on Cloudflare, GitHub Actions, and DNS configuration.
graph TB
subgraph Edge["Cloudflare Edge"]
subgraph Worker["Cloudflare Worker"]
W1["SPA routing (returns index.html for /mailbox, etc)"]
W2["Cache headers (immutable for assets, no-cache HTML)"]
end
Worker --> R2
subgraph R2["Cloudflare R2"]
R2A["Static assets (dist/)"]
R2B["Fingerprinted bundles (/assets/*.js, *.css)"]
end
end
| Asset Type | Cache-Control | Reason |
|---|---|---|
index.html, /mailbox, /calendar, etc. |
no-cache, no-store |
Always fetch fresh HTML for updates |
/assets/* (JS, CSS) |
immutable, max-age=31536000 |
Fingerprinted by Vite, safe to cache forever |
sw.js, sw-*.js, version.json |
no-cache, must-revalidate |
Service worker must check for updates |
/icons/* |
max-age=2592000 |
30 days, rarely change |
Fonts (.woff2) |
immutable, max-age=31536000 |
Fingerprinted, cache forever |
The GitHub Actions workflow (.github/workflows/ci.yml) runs on every push to main and on pull requests:
Every push (CI):
- Install —
pnpm install --frozen-lockfile - Lint —
pnpm lint - Format —
pnpm format - Build —
pnpm build(Vite + Workbox service worker)
Release commits only (chore(release): x.y.z):
- Deploy to R2 — Sync
dist/to Cloudflare R2 bucket - Deploy Worker — Deploy CDN worker for SPA routing + cache headers
- Purge Cache — Clear Cloudflare edge cache
GitHub Secrets:
| Secret | Description |
|---|---|
R2_ACCOUNT_ID |
Cloudflare account ID (also used for Workers) |
R2_ACCESS_KEY_ID |
R2 API access key |
R2_SECRET_ACCESS_KEY |
R2 API secret key |
CLOUDFLARE_ZONE_ID |
Zone ID for cache purge |
CLOUDFLARE_API_TOKEN |
API token with R2 + Workers + Cache permissions |
GitHub Variables:
| Variable | Description |
|---|---|
R2_BUCKET |
R2 bucket name for static assets |
Create a token at My Profile → API Tokens → Create Token → Create Custom Token:
Permissions:
| Scope | Permission | Access |
|---|---|---|
| User | User Details | Read |
| Account | Workers Scripts | Edit |
| Zone | Cache Purge | Purge |
Account Resources:
- Select Include → Specific account → [Your Account]
- Or Include → All accounts (if you have only one)
Zone Resources:
- Select Include → Specific zone → [Your Domain]
- Or Include → All zones
⚠️ Common mistake: Setting permissions but leaving Account/Zone Resources as "All accounts from..." dropdown without explicitly selecting. You must click and select your specific account/zone.
The CDN worker (worker/) handles:
- SPA Routing — Returns
index.htmlfor navigation requests to/mailbox,/calendar,/contacts,/login - Cache Headers — Sets correct
Cache-Controlper asset type - Security Headers —
X-Content-Type-Options,X-Frame-Options
After first deployment, configure the custom domain:
- Cloudflare Dashboard → Workers & Pages → webmail-cdn
- Settings → Triggers → Add Custom Domain
- Enter your domain (e.g.,
mail.example.com)
# Build the app
pnpm build
# Deploy to R2 (requires AWS CLI configured with R2 credentials)
aws --endpoint-url "https://ACCOUNT_ID.r2.cloudflarestorage.com" \
s3 sync dist/ "s3://BUCKET_NAME/" --delete
# Deploy worker
cd worker
pnpm install
npx wrangler deploy
# Purge Cloudflare cache
curl -X POST "https://api.cloudflare.com/client/v4/zones/ZONE_ID/purge_cache" \
-H "Authorization: Bearer API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'Stale assets after deploy:
- Verify cache purge succeeded in GitHub Actions logs
- Check browser DevTools → Network → Disable cache and refresh
- Users with disk-cached HTML may need to clear browser cache or wait for the fallback recovery UI
SPA routes return 404:
- Ensure the worker is deployed and bound to your domain
- Check worker logs:
cd worker && npx wrangler tail
Service worker not updating:
- Check
version.jsonis being fetched fresh (no cache) - Verify
sw.jshasno-cacheheader in Network tab
Business Source License 1.1 - Forward Email LLC