A self-hosted webmail client for people who want a calmer, more private inbox.
一个自托管的网页邮件客户端,让收件箱更安静、更私密。
Pebble turns your server into a private webmail portal. You log in through a browser, connect your email accounts (Gmail, IMAP, Outlook), and read/send/manage all your mail from one place. All data — messages, attachments, search index, settings — stays on your server.
Think of it as a self-hosted Gmail. No third party sees your inbox. No ads. No tracking.
Pebble 是什么? 它把你的服务器变成一个私人网页邮箱。在浏览器里登录,连接你的邮箱账户(支持 Gmail、IMAP、Outlook),在一个地方收发管理所有邮件。所有数据——邮件、附件、搜索索引、设置——都存在你自己的服务器上。
Pick the method that fits you.
This webmail fork is maintained at
https://github.com/clionertr/Pebble.git.https://github.com/QingJ01/Pebble.gitis the original upstream project; do not clone it when you want this webmail version.
You need Docker and Docker Compose installed. The installer pulls the latest tagged GHCR images, creates ./pebble, writes .env, starts the services, and checks http://127.0.0.1:9191. If Docker needs elevated privileges and passwordless sudo is available, the installer will use sudo -n docker automatically.
curl -fsSL https://raw.githubusercontent.com/clionertr/Pebble/master/deploy/install.sh | bashDuring setup you can accept or enter:
- your public URL; the default is auto-detected as
http://<server-ip>:9191 - your Pebble login password; leave it blank to generate a 32-character password
- optional Google/Microsoft OAuth credentials
Point your reverse proxy to http://127.0.0.1:9191. All Pebble data is stored in ./pebble/data.
Non-interactive examples:
# Fully automatic: detects IP and generates a 32-character login password
curl -fsSL https://raw.githubusercontent.com/clionertr/Pebble/master/deploy/install.sh | bash
# Use a domain and a provided password instead of generated defaults
curl -fsSL https://raw.githubusercontent.com/clionertr/Pebble/master/deploy/install.sh \
| PEBBLE_PASSWORD='your-secret-password' \
PEBBLE_PUBLIC_URL='https://mail.example.com' \
bashPrefer integrity verification over
curl | bash? Download the script, check its SHA-256 against the published checksum, then execute:curl -fsSLo install.sh https://raw.githubusercontent.com/clionertr/Pebble/master/deploy/install.sh curl -fsSLo install.sh.sha256 https://raw.githubusercontent.com/clionertr/Pebble/master/deploy/install.sh.sha256 sha256sum -c install.sh.sha256 # fails loudly on mismatch bash install.sh
You need: Rust (stable), Node.js 22+, pnpm 11+.
git clone https://github.com/clionertr/Pebble.git
cd Pebble
# Install frontend dependencies
pnpm install
# Copy and edit environment config
cp .env.example .env
# Generate a hash with: printf '%s' 'your-password' | cargo run -p pebble -- hash-password
# Set PEBBLE_PASSWORD_HASH in .env
# Terminal 1: Start the backend
cargo run -p pebble
# Terminal 2: Start the frontend dev server
pnpm dev:frontendOpen http://localhost:1420. The dev server proxies API calls to the backend at port 3000.
If you access Vite through a reverse proxy or remote dev domain, set comma-separated hostnames in PEBBLE_VITE_ALLOWED_HOSTS, for example PEBBLE_VITE_ALLOWED_HOSTS=pebble.example.com,dev.example.com.
Important dev rule: run only one backend process against the same ./data directory. If a release binary, cargo run, or a systemd service is already running, the search index will be locked and the next backend start will fail.
For a VPS that runs from source, use a process manager such as systemd. The mental model is:
- stop the old Pebble backend
- pull/build the new code
- start exactly one backend again
# One-time setup
git clone https://github.com/clionertr/Pebble.git /opt/pebble
cd /opt/pebble
pnpm install --frozen-lockfile
cp .env.example .env
printf '%s' 'your-password' | cargo run -p pebble -- hash-password
# Edit .env and set PEBBLE_PASSWORD_HASH to the generated hash.
# Direct source runs use single $ characters, for example '$2b$12$...'.Build and restart after code changes:
# Update code if this server tracks git
git pull --ff-only
# Build while the old service keeps serving traffic
pnpm install --frozen-lockfile
pnpm run build:frontend
cargo build --release -p pebble
# Restart once. systemd stops the old backend before starting the new one.
sudo systemctl restart pebbleServe dist/ with nginx (example config below). The backend listens on port 3000 by default.
Example systemd unit. A ready-to-edit copy is also available at deploy/pebble.service.example:
[Unit]
Description=Pebble webmail backend
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/pebble
EnvironmentFile=/opt/pebble/.env
ExecStart=/opt/pebble/target/release/pebble
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetInstall it with:
sudo cp deploy/pebble.service.example /etc/systemd/system/pebble.service
sudo systemctl daemon-reload
sudo systemctl enable --now pebbleFor quick manual testing without systemd, stop any existing Pebble process first, then run ./target/release/pebble from the repository root. The binary now reads .env from the current working directory.
All configuration goes into environment variables. You can set them in a .env file, pass them directly when running the binary, or use Docker Compose's env_file. Direct source runs read .env from the current working directory without an extra source .env step.
| Variable | What it is | How to get it |
|---|---|---|
PEBBLE_PASSWORD_HASH |
Your login password, bcrypt-hashed | printf '%s' 'your-password' | pebble hash-password |
This is the only required variable. Without it, the backend refuses to start.
If you want Gmail or Outlook support, you need OAuth credentials.
- Go to Google Cloud Console
- Create a project, then create an OAuth 2.0 Client ID of type Web application
- Add
https://your-domain.com/auth/callbackas an authorized redirect URI (usehttp://localhost:3000/auth/callbackfor local dev) - Copy the Client ID and Client Secret to your
.env:
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-your-secret
- Go to Azure App Registrations, register a new app
- Set redirect URI to
https://your-domain.com/auth/callback - The client type should be public/native (no client secret needed). If you registered as a web app, provide the secret.
MICROSOFT_CLIENT_ID=your-microsoft-client-id
# MICROSOFT_CLIENT_SECRET= (leave empty for public/native apps)
| Variable | Default | What it does |
|---|---|---|
PEBBLE_HOST |
127.0.0.1 |
IP address the server listens on. Set to 0.0.0.0 to accept external connections. |
PEBBLE_PORT |
3000 |
TCP port. |
OAUTH_REDIRECT_URL |
http://localhost:3000 |
Full URL where /auth/callback is reachable. Set to https://your-domain.com in production. |
ALLOWED_ORIGIN |
(empty) | CORS allowed origin. Leave empty for same-origin. Set to your frontend URL if hosting frontend and backend on different origins. |
PEBBLE_VAPID_PRIVATE_KEY |
auto-generated | Optional base64url VAPID private key for browser Web Push. If omitted, Pebble generates and stores one in its local data. |
PEBBLE_VAPID_PUBLIC_KEY |
derived | Optional VAPID public key. If set, it must match PEBBLE_VAPID_PRIVATE_KEY; otherwise the server refuses to start. |
Gmail can push new-email notifications to Pebble via Google Cloud Pub/Sub, instead of polling.
| Variable | What it is |
|---|---|
GMAIL_PUBSUB_TOPIC |
Full Pub/Sub topic: projects/<project-id>/topics/gmail-webmail-topic |
GMAIL_WEBHOOK_SECRET |
A random secret string for the webhook URL |
Setup steps:
- Enable Gmail API and Cloud Pub/Sub API in Google Cloud
- Create a Pub/Sub topic, grant
roles/pubsub.publishertogmail-api-push@system.gserviceaccount.com - Create a push subscription pointing at
https://your-domain.com/webhook/gmail?secret=<your-secret> - In Pebble, go to Settings → Accounts → Enable realtime Gmail per account
The recommended setup: nginx serves the frontend static files and proxies API calls to the backend.
server {
listen 443 ssl;
server_name mail.your-domain.com;
root /path/to/Pebble/dist;
index index.html;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "no-referrer" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; script-src 'self'; style-src 'self'; style-src-elem 'self'; style-src-attr 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'" always;
# Frontend SPA — fall back to index.html for client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# Backend API, SSE (real-time events), OAuth, and Gmail webhook
location ~ ^/(api|events|auth|webhook) {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Required for Server-Sent Events (real-time updates)
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
}The one-command installer writes a compose file from deploy/compose.prod.yml. If you want to maintain it manually, use the prebuilt GHCR images:
latest is updated only when this repository pushes a version tag such as v0.0.11.
name: pebble
services:
backend:
image: ghcr.io/clionertr/pebble:latest
volumes:
- ./data:/app/data
env_file:
- .env
environment:
PEBBLE_HOST: 0.0.0.0
PEBBLE_PORT: 3000
restart: unless-stopped
networks:
- pebble-net
frontend:
image: ghcr.io/clionertr/pebble-frontend:latest
ports:
- "127.0.0.1:9191:80"
depends_on:
- backend
restart: unless-stopped
networks:
- pebble-net
networks:
pebble-net:
driver: bridgeWith this setup, point your public reverse proxy (nginx, Caddy, 1Panel OpenResty, etc.) to http://127.0.0.1:9191.
All data lives in the ./data/ directory relative to where the backend runs:
| File / Directory | Contains |
|---|---|
data/pebble.db |
SQLite database with all messages, accounts, rules, settings |
data/pebble.key |
Encryption key for stored credentials (OAuth tokens, passwords) |
data/index/ |
Tantivy full-text search index |
data/attachments/ |
Downloaded email attachments |
data/logs/ |
Application logs |
Keep data/pebble.key safe. If you lose it, you lose access to all connected accounts and need to re-authenticate.
Browser (React SPA)
│ HTTP REST /api/*
│ SSE stream /events
│ OAuth flow /auth/login /auth/callback
▼
Nginx (serves frontend, proxies API)
│
▼
Rust HTTP Server (Axum, port 3000)
│
├── pebble-store SQLite database
├── pebble-search Tantivy full-text index
├── pebble-mail IMAP / Gmail / Outlook sync
├── pebble-crypto Credential encryption
├── pebble-oauth OAuth 2.0 + PKCE
├── pebble-rules Rules engine
├── pebble-translate Translation
└── pebble-privacy HTML sanitizing & tracker protection
For a deeper developer view, see docs/architecture.md and docs/integration-guide.md.
Pebble uses cookie-based session auth:
- You log in with your password → server creates a session (7-day TTL)
- Session cookie (
pebble_session) isHttpOnly; Secure; SameSite=Strict - All
/api/*endpoints require a valid session - Failed logins are rate-limited (5 attempts → 15-minute lock per IP)
- No registration, no multi-user — it's single-user by design
The frontend connects to GET /events via Server-Sent Events (SSE). The server pushes notifications for new mail, sync progress, and snooze wakeups. The SSE connection uses the same session cookie for auth.
On startup, the web app fetches GET /api/shell once for account metadata, folders, unread counts, and Gmail real-time configuration. Message and thread lists stay paginated through /api/inbox and /api/threads; Pebble does not load all historical mail into the startup snapshot.
Routine sync poll completions update the status bar only. The frontend refreshes shell/list caches on actual change signals such as mail:new, pending remote-write changes, network recovery, or one-shot sync completion.
Browser push notifications use Web Push + Service Worker so notifications can arrive after the Pebble tab is closed. Production browsers require HTTPS or another secure context; localhost works for development.
Pebble syncs with your providers in the background:
- Gmail: OAuth + Gmail API (history-based sync) + optional Pub/Sub push
- IMAP: Standard IMAP polling with configurable intervals
- Outlook: OAuth + Microsoft Graph API (experimental)
- Unified inbox across multiple accounts
- Gmail, IMAP, and experimental Outlook
- Thread view and message list view
- Archive, delete, star, mark read, batch actions, restore
- Snooze messages (bring them back later)
- Full-text search with advanced filters
- Rules engine for automatic mail organization
- Command palette and keyboard shortcuts
- Kanban board: Todo → Waiting → Done columns for email tasks
- Translation: Built-in translation providers, bilingual reading mode
- Templates: Reusable email templates
- Trusted Senders: Per-sender privacy controls (show images, etc.)
- WebDAV backup: Sync settings, rules, and Kanban data to a WebDAV server
- All data stored locally on your server
- No telemetry, no tracking
- HTML email sanitization (removes trackers)
- OAuth tokens encrypted at rest
| Layer | Technology |
|---|---|
| Backend | Rust + Axum |
| Frontend | React 19 + TypeScript |
| State | Zustand + TanStack Query |
| Database | SQLite (rusqlite) |
| Search | Tantivy |
| Styling | Tailwind CSS |
| i18n | i18next (English, Chinese) |
| Shortcut | Action |
|---|---|
J / K |
Move through messages |
Enter |
Open selected message |
E |
Archive |
S |
Toggle star |
R |
Reply |
A |
Reply all |
F |
Forward |
C |
Compose |
/ |
Focus search |
Esc |
Close, cancel, go back |
Shortcuts can be customized in Settings.
| Command | Purpose |
|---|---|
cargo run -p pebble |
Run backend dev server |
pnpm dev:frontend |
Run frontend dev server (proxies to backend) |
pnpm build:frontend |
Type-check and build frontend to dist/ |
cargo build --release -p pebble |
Build release backend binary |
pnpm test |
Run frontend tests (Vitest) |
cargo fmt --check |
Check Rust formatting |
cargo clippy --all-targets -- -D warnings |
Run Rust lint checks |
cargo test --workspace --all-targets |
Run all Rust tests |
sudo systemctl restart pebble |
Restart a source-deployed backend managed by systemd |
Your session expired (7-day TTL) or the backend restarted. Log in again.
Check that PEBBLE_PASSWORD_HASH in .env has $$ escaping (not $) when used with Docker Compose. Test with: docker exec pebble-backend env | grep PASSWORD.
For direct source runs, use normal single $ characters, usually quoted: PEBBLE_PASSWORD_HASH='$2b$12$...'. The backend reads .env automatically from the directory where you start it.
Pebble's full-text search index lives in data/index/. Tantivy allows only one writer, so this error almost always means another Pebble backend is still running with the same ./data directory.
Check and stop the old process:
sudo systemctl status pebble
sudo systemctl stop pebble
pgrep -af pebbleThen start only one backend again: either sudo systemctl start pebble, cargo run -p pebble, or ./target/release/pebble, not several at the same time.
If pgrep -af pebble shows no running process but the lock remains, reboot the server first. Only remove stale lock files under data/index/ after confirming no Pebble process is running and after backing up data/.
Make sure the nginx config proxies /api/* to the backend. The proxy rule should be: location ~ ^/(api|events|auth|webhook).
The SQLite database may have been corrupted by an unclean shutdown. Try: sqlite3 data/pebble.db "PRAGMA integrity_check;". If corrupted, restore from backup.
Check the backend logs: docker logs pebble-backend or tail -f data/logs/. Common issues: OAuth token expired (re-authenticate in Settings → Accounts), network proxy not configured, IMAP credentials wrong.
Pebble/
├── src/ React frontend (SPA)
│ ├── components/ Shared UI components
│ ├── features/ Inbox, compose, search, Kanban, settings, auth
│ ├── hooks/ React hooks and query helpers
│ ├── lib/ API client, SSE client, i18n, utilities
│ └── stores/ Zustand stores
├── server/ Rust HTTP backend (Axum)
│ └── src/
│ ├── main.rs Server entry point, route registration
│ ├── api/ REST API handlers (80+ endpoints)
│ ├── middleware/ Auth middleware (cookie validation)
│ ├── session.rs Cookie sessions + rate limiter
│ └── rpc/ Internal service layer
├── crates/ Rust workspace crates
│ ├── pebble-core/ Shared types and errors
│ ├── pebble-store/ SQLite persistence
│ ├── pebble-mail/ Mail providers and sync
│ ├── pebble-search/ Tantivy search index
│ ├── pebble-crypto/ Credential encryption
│ ├── pebble-oauth/ OAuth 2.0 and PKCE
│ ├── pebble-rules/ Rules engine
│ ├── pebble-translate/ Translation providers
│ └── pebble-privacy/ HTML sanitizing and tracker controls
├── deploy/ Docker and nginx configs
├── tests/ Frontend tests
└── site/ Screenshots
Pebble is licensed under GNU Affero General Public License v3.0.
Originally built by QingJ.
Web service re-architecture and documentation by Claude Opus 4.7.
Friend link: LINUX DO