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:

  1. No context switching - SPC o m opens email
  2. Same keybindings - Evil mode everywhere
  3. Org integration - Link to emails, capture tasks from messages
  4. 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-service for 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.pem

Verify 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.1

Step 3: Install mu

On Debian/Ubuntu:

sudo apt install maildir-utils
mu --version  # Should show 1.12.x or later

Step 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 TLS
  • PassCmd - Calls secret-tool to get the password
  • Patterns * !"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 ~/.mbsyncrc

Step 5: Initial Sync

Create the Maildir and run the first sync:

mkdir -p ~/.mail/walter-bio
mbsync walter-bio

This 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 index

The index takes about 30 seconds for 6000 messages. Test it:

mu find from:anthropic | head -5

Step 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 sync

Step 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_PASSWORD

Encrypt with your GPG key:

gpg -e -r [email protected] -o ~/.authinfo.gpg authinfo
shred -u authinfo  # Securely delete plaintext

The 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:1143

mu4e: "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 env

Duplicates appearing

Ensure mu4e-change-filenames-when-moving is t. If you already have duplicates:

rm -rf ~/.mail/walter-bio
mbsync walter-bio
mu index --rebuild

YubiKey not prompting

Check gpg-agent is running and configured for your pinentry:

gpg-connect-agent updatestartuptty /bye

What'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.

References