Local Email with Doom Emacs, mu4e, and ProtonMail Bridge
The Problem with Web Email
If you spend most of your day in a terminal or text editor, context switching to a web browser for email feels… wrong. Every time I needed to check email, I'd lose focus. Open Firefox, wait for ProtonMail to load, click around with a mouse like some kind of GUI peasant.
I wanted something better:
- Offline access - Read and compose email without internet
- Keyboard-driven - Never touch the mouse
- Integrated with Emacs - Stay in my editor
- Secure - GPG signing with my YubiKey
- Fast search - Instant full-text search across thousands of messages
After some research, I landed on the classic Unix mail stack: mbsync + mu + mu4e. This post documents how I set it up with ProtonMail Bridge on Debian.
Architecture Overview
The stack has several components that work together:
flowchart TB
subgraph LOCAL["LOCAL SYSTEM"]
subgraph security["Security Layer"]
YK[/"YubiKey 5 (GPG Hardware)"/]
PASS["pass-secret-service (Rust)"]
GPGAGENT["gpg-agent"]
end
subgraph sync["Sync Layer"]
MBSYNC["mbsync (isync)"]
MAILDIR[("Maildir ~/.mail/")]
end
subgraph index["Index Layer"]
MU["mu (Xapian index)"]
end
subgraph client["Client Layer"]
MU4E["mu4e (Doom Emacs)"]
SMTPMAIL["smtpmail.el"]
end
YK --> GPGAGENT
GPGAGENT --> MU4E
PASS --> MBSYNC
MBSYNC <--> MAILDIR
MAILDIR --> MU
MU --> MU4E
MU4E --> SMTPMAIL
end
subgraph BRIDGE["ProtonMail Bridge"]
IMAP["IMAP: 127.0.0.1:1143"]
SMTP["SMTP: 127.0.0.1:1025"]
end
subgraph PROTON["ProtonMail Servers"]
SERVERS[("Encrypted at rest")]
end
MBSYNC <--> IMAP
SMTPMAIL --> SMTP
IMAP <--> SERVERS
SMTP --> SERVERS
Here's what each component does:
| Component | Purpose |
|---|---|
| ProtonMail Bridge | Decrypts ProtonMail and exposes standard IMAP/SMTP |
| mbsync | Bidirectional sync between IMAP and local Maildir |
| mu | Indexes Maildir for lightning-fast search |
| mu4e | Emacs interface to mu |
| pass-secret-service | Stores credentials via D-Bus secrets API |
| gpg-agent + YubiKey | Signs outgoing mail with hardware key |
Why This Stack?
You might ask: why not just use aerc or neomutt? Both are excellent terminal mail clients. But I'm already living in Doom Emacs for everything else - code, notes, org-mode, git. Having email in the same environment means:
- No context switching -
SPC o mopens email - Same keybindings - Evil mode everywhere
- Org integration - Link to emails, capture tasks from messages
- Unified search - Search mail like I search code
The tradeoff is complexity. This setup has more moving parts than a web client. But once configured, it's rock solid and incredibly fast.
Prerequisites
Before starting, you'll need:
- ProtonMail account with Bridge subscription
- ProtonMail Bridge installed and logged in
- Doom Emacs
- GPG with keys configured (YubiKey optional but recommended)
pass-secret-servicefor D-Bus secrets (I use the Rust implementation)
Step 1: Store Credentials
I use pass-secret-service to store the Bridge password. This exposes credentials via the standard D-Bus secrets API, backed by GPG-encrypted pass entries.
# Store the password (get this from ProtonMail Bridge UI)
echo -n "YOUR_BRIDGE_PASSWORD" | secret-tool store --label="ProtonMail [email protected]" \
service protonmail account walter-bio
# Verify it works
secret-tool lookup service protonmail account walter-bio
Why secret-tool instead of pass directly? Because mbsync can call any command to get the password, and secret-tool is the standard way to query D-Bus secrets. This also means other apps (like the Bridge itself) can use the same credential store.
Step 2: Export Bridge Certificate
ProtonMail Bridge uses a self-signed certificate for its local IMAP/SMTP servers. We need to tell mbsync to trust it.
You can export from Bridge CLI, but I found it easier to extract from the running server:
echo | openssl s_client -starttls imap -connect 127.0.0.1:1143 2>/dev/null \
| openssl x509 -outform PEM > ~/.config/protonmail/bridge-v3/cert.pemVerify the certificate looks correct:
openssl x509 -in ~/.config/protonmail/bridge-v3/cert.pem -noout -subject -issuer
# subject=C=CH, O=Proton AG, OU=Proton Mail, CN=127.0.0.1
# issuer=C=CH, O=Proton AG, OU=Proton Mail, CN=127.0.0.1Step 3: Install mu
On Debian/Ubuntu:
sudo apt install maildir-utils
mu --version # Should show 1.12.x or laterStep 4: Configure mbsync
Create ~/.mbsyncrc:
# ~/.mbsyncrc - Mail sync configuration
# chmod 600 this file!
IMAPAccount walter-bio
Host 127.0.0.1
Port 1143
User [email protected]
PassCmd "secret-tool lookup service protonmail account walter-bio"
TLSType STARTTLS
CertificateFile ~/.config/protonmail/bridge-v3/cert.pem
IMAPStore walter-bio-remote
Account walter-bio
MaildirStore walter-bio-local
Path ~/.mail/walter-bio/
Inbox ~/.mail/walter-bio/INBOX/
SubFolders Verbatim
Channel walter-bio
Far :walter-bio-remote:
Near :walter-bio-local:
Patterns * !"All Mail" !"All Mail/*"
Create Both
Expunge Both
SyncState *
A few notes:
TLSType STARTTLS- Bridge uses STARTTLS, not implicit TLSPassCmd- Callssecret-toolto get the passwordPatterns * !"All Mail"- Excludes Gmail-style "All Mail" folder (ProtonMail has this too, and syncing it duplicates everything)Create Both/Expunge Both- Bidirectional sync including deletions
Set permissions:
chmod 600 ~/.mbsyncrcStep 5: Initial Sync
Create the Maildir and run the first sync:
mkdir -p ~/.mail/walter-bio
mbsync walter-bioThis downloads all your mail. For my ~250MB mailbox, it took about 2 minutes. You'll see output like:
Maildir notice: no UIDVALIDITY in /home/walter/.mail/walter-bio/INBOX/, creating new. ... Channels: 1 Boxes: 18 Far: +0 *0 #0 -0 Near: +6132 *0 #0 -0
That last line shows 6132 messages synced. The data flow looks like this:
sequenceDiagram
participant Bridge as ProtonMail Bridge
participant mbsync
participant Maildir as ~/.mail/
participant mu as mu index
Note over Bridge: Decrypts ProtonMail
mbsync->>Bridge: IMAP FETCH
Bridge-->>mbsync: Messages
mbsync->>Maildir: Write to cur/new/tmp
Note over Maildir: 6132 messages ~616 MB
mu->>Maildir: Scan files
mu->>mu: Build Xapian index
Note over mu: Fast full-text search
Step 6: Initialize mu
mu init --maildir=~/.mail --my-address=[email protected]
mu indexThe index takes about 30 seconds for 6000 messages. Test it:
mu find from:anthropic | head -5Step 7: Enable mu4e in Doom
Edit ~/.doom.d/init.el and uncomment the mu4e module:
:email
(mu4e +org) ;; was ;;(mu4e +org +gmail)Then sync Doom:
~/.config/emacs/bin/doom syncStep 8: Configure mu4e
Add to ~/.doom.d/config.el:
;; ============================================================
;; mu4e Configuration
;; ============================================================
(after! mu4e
(setq mu4e-maildir "~/.mail"
mu4e-get-mail-command "mbsync -a"
mu4e-update-interval 300 ;; Sync every 5 minutes
mu4e-change-filenames-when-moving t ;; Required for mbsync!
mu4e-compose-signature-auto-include nil)
(setq mu4e-user-mail-address-list '("[email protected]"))
(setq mu4e-contexts
`(,(make-mu4e-context
:name "walter-bio"
:match-func (lambda (msg)
(when msg
(string-prefix-p "/walter-bio"
(mu4e-message-field msg :maildir))))
:vars '((user-mail-address . "[email protected]")
(user-full-name . "Walter Vargas")
(mu4e-drafts-folder . "/walter-bio/Drafts")
(mu4e-sent-folder . "/walter-bio/Sent")
(mu4e-trash-folder . "/walter-bio/Trash")
(mu4e-refile-folder . "/walter-bio/Archive")
(smtpmail-smtp-server . "127.0.0.1")
(smtpmail-smtp-service . 1025)
(smtpmail-stream-type . starttls)))))
(setq mu4e-context-policy 'pick-first))
;; GPG Auto-signing with YubiKey
(add-hook 'message-send-hook 'mml-secure-message-sign-pgpmime)
(setq mml-secure-openpgp-signers '("0xFC5C9E56351A061E")) ;; Your signing key
(setq mu4e-view-show-cryptographic-info t)
;; SMTP via smtpmail
(setq message-send-mail-function 'smtpmail-send-it
smtpmail-auth-credentials "~/.authinfo.gpg")
The critical setting is mu4e-change-filenames-when-moving t. Without this, mbsync will get confused about moved messages and you'll see duplicates.
Step 9: Configure SMTP Credentials
mu4e uses Emacs' built-in smtpmail for sending. It reads credentials from ~/.authinfo.gpg:
machine 127.0.0.1 port 1025 login [email protected] password YOUR_BRIDGE_PASSWORDEncrypt with your GPG key:
gpg -e -r [email protected] -o ~/.authinfo.gpg authinfo
shred -u authinfo # Securely delete plaintextThe Complete Flow
Here's what happens when you use mu4e:
flowchart LR
subgraph READ["Reading Email"]
direction TB
R1["SPC o m"] --> R2["mu4e opens"]
R2 --> R3["Shows INBOX"]
R3 --> R4["RET to read"]
R4 --> R5["Full message view"]
end
subgraph COMPOSE["Composing Email"]
direction TB
C1["C to compose"] --> C2["Write message"]
C2 --> C3["C-c C-c to send"]
C3 --> C4["YubiKey PIN prompt"]
C4 --> C5["GPG signs message"]
C5 --> C6["smtpmail sends via Bridge"]
end
subgraph SYNC["Background Sync"]
direction TB
S1["Every 5 min"] --> S2["mbsync -a"]
S2 --> S3["mu index"]
S3 --> S4["mu4e refreshes"]
end
Key Bindings (Doom Emacs)
| Key | Action |
|---|---|
SPC o m |
Open mu4e |
j/k |
Navigate messages |
RET |
Read message |
C |
Compose new |
R |
Reply |
F |
Forward |
d |
Mark for trash |
x |
Execute marks |
s |
Search |
; |
Switch context |
U |
Update mail |
Disk Space Considerations
One thing to be aware of: this setup duplicates your mail locally.
pie title Disk Usage (~250MB mailbox)
"Bridge Cache" : 250
"Maildir Copy" : 250
"mu Index" : 25
For my 250MB mailbox, I use about 525MB total. The benefits (offline access, fast search, survives Bridge updates) outweigh the cost for me.
Troubleshooting
mbsync: SSL handshake failed
Check that the certificate is correct and Bridge is running:
openssl s_client -starttls imap -connect 127.0.0.1:1143mu4e: "Cannot find mu"
Make sure mu is in your PATH and Doom knows about it:
which mu
~/.config/emacs/bin/doom env # Refresh Doom's envDuplicates appearing
Ensure mu4e-change-filenames-when-moving is t. If you already have duplicates:
rm -rf ~/.mail/walter-bio
mbsync walter-bio
mu index --rebuildYubiKey not prompting
Check gpg-agent is running and configured for your pinentry:
gpg-connect-agent updatestartuptty /byeWhat's Next
This setup gives me a solid foundation. Things I'm planning to add:
- Automated sync with systemd timer
- Desktop notifications via mako for new mail
- Additional accounts - Gmail, self-hosted Stalwart
- org-mode integration - Capture emails as tasks
For now, I'm enjoying the speed and keyboard-driven workflow. No more context switching to a browser just to check email.