Skip to main content
  1. Posts/

Keep Gmail Lean: Archive Everything Locally on Linux (Pi4/Pi5 Included)

·13 mins

Linux Email Archive (Pi4/Pi5 Compatible) #

Introduction #

If your Gmail account keeps growing, this setup gives you the best of both worlds: a lean cloud mailbox and a complete local archive you fully control. In this guide, you will mirror all mail to a Linux host using Maildir, index everything with Notmuch for fast local search, and enforce a retention policy that keeps only the last 90 days on Gmail while preserving full history locally.

This walkthrough is designed to be practical and production-friendly: secure credential handling with pass, unattended sync with cron, retention cleanup with imapfilter, and optional web access to your archive through Dovecot + Roundcube.

Quick Navigation #

Step 0: Set Environment Variables First #

Set these once in your shell before running the rest of the steps:

export GMAIL_ADDRESS="you@gmail.com"
export MAIL_ROOT="/srv/mail-archive"
export LOCAL_MAIL_USER="pi"
export ADMIN_USER="pi"
export MAIL_GROUP_GID="1003"
export PI_TIMEZONE="UTC"
export DOVECOT_LOGIN_USER="archive"
export DOVECOT_LOGIN_PASSWORD="change-this-strong-password"

Optional: persist them in your shell profile so they survive reboot/login:

cat >> ~/.profile <<'EOF'
export GMAIL_ADDRESS="you@gmail.com"
export MAIL_ROOT="/srv/mail-archive"
export LOCAL_MAIL_USER="pi"
export ADMIN_USER="pi"
export MAIL_GROUP_GID="1003"
export PI_TIMEZONE="UTC"
export DOVECOT_LOGIN_USER="archive"
export DOVECOT_LOGIN_PASSWORD="change-this-strong-password"
EOF

Reload your shell after updating ~/.profile:

source ~/.profile

Objective #

Create a local full-email archive on Linux while keeping only the last 3 months of emails on the remote Gmail account. This works on Raspberry Pi 4, Raspberry Pi 5, and standard Linux servers/VMs. All messages must be searchable locally.

Architecture #

Gmail (IMAP) → mbsync → Local Maildir (Linux host) → Notmuch index → Local search
  • Local archive: Full history of all emails, indexed and searchable via Notmuch
  • Remote mailbox: Rolling 3-month window; older mail deleted from Gmail but preserved locally
  • Host role: Mirror + search engine only. Gmail remains the primary sender/receiver.

Decisions Made #

DecisionChoiceRationale
Sync toolmbsync (isync)Mature, efficient, widely trusted, Maildir-native
Mail formatMaildirCompatible with mbsync, Notmuch, Mutt, and most Unix tools
Search engineNotmuchFast full-text indexing over Maildir; tag-based
Auth methodApp Password via passAvoids storing plaintext credentials; works with Gmail 2FA
Deletion scopeRemote only (Gmail)Local archive is never touched by retention policy
Retention toolimapfilterDeletes directly on Gmail IMAP server; mbsync is sync-only
StorageExternal USB SSD (for >50GB)SD card wear is a concern for frequent write workloads

Step 1: Enable IMAP on Gmail #

  1. Go to Gmail Settings → Forwarding and POP/IMAP
  2. Enable IMAP
  3. Since 2FA is required for App Passwords, enable it in your Google Account if not already active

Create an App Password #

  1. Open Google Account Security Settings
  2. Navigate to App Passwords
  3. Choose: App = Mail, Device = Other → name it Linux archive
  4. Copy the generated password into your password manager

Note: Use this app password everywhere below instead of your main Google password.


Step 2: Install Software on Linux #

sudo apt update
sudo apt install isync notmuch msmtp mutt pass imapfilter
PackageRole
isyncProvides the mbsync binary for IMAP sync
notmuchIndexes and searches local mail
imapfilterDeletes old messages directly on Gmail IMAP server
msmtpSends mail via SMTP (optional)
muttTerminal mail client (optional)
passUnix password manager for secure credential storage

Store credentials with pass #

1. Generate a GPG key (skip if you already have one):

gpg --full-generate-key
  • Key type: (1) RSA and RSA
  • Key size: 4096
  • Expiry: 0 (no expiry)
  • Enter your name and email
  • Set a passphrase — you’ll need this whenever GPG decrypts credentials

2. Initialize pass with your key:

gpg --list-keys                          # confirm key exists, note the email
pass init "$GMAIL_ADDRESS"

3. Insert the Gmail App Password:

pass insert gmail                        # paste your App Password when prompted

4. Verify it works:

pass show gmail                          # should print the app password

GPG agent (required for cron to work unattended) #

When mbsync runs from cron there is no terminal session, so GPG needs an agent to cache your passphrase. Add this to ~/.bashrc or ~/.profile:

export GPG_TTY=$(tty)

Set a long cache TTL in ~/.gnupg/gpg-agent.conf:

default-cache-ttl 86400      # 24 hours
max-cache-ttl 604800         # 7 days

Restart the agent:

gpg-connect-agent reloadagent /bye

After a reboot, run pass show gmail once manually to unlock the key. Cron jobs will then work unattended for the rest of the day/week without prompting.


Step 3: Configure mbsync #

Create ~/.mbsyncrc with your variables:

cat > ~/.mbsyncrc <<EOF
IMAPAccount gmail
Host imap.gmail.com
User ${GMAIL_ADDRESS}
PassCmd "pass show gmail"
TLSType IMAPS
Port 993
CertificateFile /etc/ssl/certs/ca-certificates.crt

IMAPStore gmail-remote
Account gmail

MaildirStore gmail-local
Path ${MAIL_ROOT}/gmail/
Inbox ${MAIL_ROOT}/gmail/Inbox/
SubFolders Verbatim

Channel gmail
Far :gmail-remote:
Near :gmail-local:
Patterns *
Create Both
SyncState *
EOF

chmod 600 ~/.mbsyncrc

Key decisions in this config:

  • PassCmd "pass show gmail": mbsync calls pass at runtime — no plaintext password is ever stored in the config file
  • TLSType IMAPS: use TLSType not SSLType — the latter is deprecated and causes a warning
  • CertificateFile: explicitly set to avoid SSL verification issues on minimal Linux installs (including Raspberry Pi OS)
  • SubFolders Verbatim: required to prevent mbsync from failing on the .notmuch/xapian subfolder. It must be combined with an explicit Inbox path; otherwise, mbsync defaults to ~/Maildir.
  • Expunge Far is intentionally omitted: mbsync is used for syncing only, never for deletion. Remote cleanup is handled exclusively by imapfilter.

Test the connection (before syncing) #

# Dry run — connects and lists folders but downloads nothing
mbsync --dry-run gmail

If this succeeds without errors, proceed to the full sync.

Run the initial full sync #

mbsync -a

This may take a while on the first run depending on mailbox size.

Verify mail landed locally #

ls "$MAIL_ROOT"/gmail/
find "$MAIL_ROOT"/gmail/ -type f | wc -l      # count synced messages

Step 4: Initialize Notmuch Index #

notmuch setup    # configure name, email, mail root -> set path to $MAIL_ROOT/gmail/
notmuch new      # index all synced mail

Verify the index:

notmuch count    # should return total number of messages
notmuch search from:someone@example.com

Step 5: Retention Policy (Keep Only Last 3 Months on Gmail) #

The goal is to delete messages older than 3 months directly on Gmail’s IMAP server, while leaving the local archive completely untouched.

mbsync is not used for deletion. It is a sync-only tool. Remote cleanup is handled exclusively by imapfilter, a lightweight Lua-scriptable IMAP client that operates directly on the server.

Install imapfilter #

sudo apt install imapfilter

Retention script #

Save as $MAIL_ROOT/scripts/gmail_retention.lua:

Important Gmail note: For regular mailboxes, delete_messages() works as expected. For [Gmail]/All Mail however, delete_messages() only archives messages instead of permanently deleting them. The correct approach for All Mail is to move messages to Trash first, then delete from Trash.

options.timeout = 120
options.subscribe = false
options.expunge = true

print('RUNNING - connecting to Gmail...')

local ok, err = pcall(function()
    account = IMAP {
        server   = 'imap.gmail.com',
        username = os.getenv('GMAIL_ADDRESS'),
        password = os.getenv('GMAIL_PASS'),
        ssl      = 'tls1.2',
    }

    print('CONNECTED - deleting old messages...')

    -- Delete from all regular mailboxes
    local mailboxes, folders = account:list_all('')
    for _, mailbox in ipairs(mailboxes) do
        local ok_inner, err_inner = pcall(function()
            local messages = account[mailbox]:is_older(90)
            if #messages > 0 then
                print('Deleting ' .. #messages .. ' messages from ' .. mailbox)
                messages:delete_messages()
            end
        end)
        if not ok_inner then
            print('ERROR on mailbox ' .. mailbox .. ': ' .. err_inner .. ' - skipping')
        end
    end

    -- For [Gmail]/All Mail: move to Trash first, then delete from Trash
    -- delete_messages() on All Mail only archives, not permanently deletes
    local ok_am, err_am = pcall(function()
        local messages = account['[Gmail]/All Mail']:is_older(90)
        if #messages > 0 then
            print('[Gmail]/All Mail: ' .. #messages .. ' old messages, moving to Trash...')
            messages:move_messages(account['[Gmail]/Trash'])
            print('Moved to Trash. Deleting from Trash...')
            local trash = account['[Gmail]/Trash']:select_all()
            trash:delete_messages()
            print('[Gmail]/All Mail: done.')
        end
    end)
    if not ok_am then
        print('ERROR on [Gmail]/All Mail: ' .. err_am .. ' - skipping')
    end
end)

if not ok then
    print('ERROR: ' .. err)
    print('Waiting 60 seconds before exit...')
    os.execute('sleep 60')
end

print('DONE')

Dry-run script #

Before running the real deletion, use this script to preview what would be deleted without changing anything. Save it as $MAIL_ROOT/scripts/gmail_retention_dryrun.lua:

options.timeout = 120
options.subscribe = false

print('RUNNING - connecting to Gmail...')

local total_all = 0
local total_old = 0

local ok, err = pcall(function()
    account = IMAP {
        server   = 'imap.gmail.com',
        username = os.getenv('GMAIL_ADDRESS'),
        password = os.getenv('GMAIL_PASS'),
        ssl      = 'tls1.2',
    }

    print('CONNECTED - listing mailboxes...')

    local mailboxes, folders = account:list_all('')
    print('Mailboxes found: ' .. #mailboxes)

    for _, mailbox in ipairs(mailboxes) do
        local ok_inner, err_inner = pcall(function()
            local all_messages = account[mailbox]:select_all()
            local old_messages = account[mailbox]:is_older(90)
            print(mailbox .. ': ' .. #all_messages .. ' total, ' .. #old_messages .. ' older than 90 days')
            total_all = total_all + #all_messages
            total_old = total_old + #old_messages
        end)
        if not ok_inner then
            print('ERROR on mailbox ' .. mailbox .. ': ' .. err_inner .. ' - skipping')
        end
    end

    -- Check [Gmail]/All Mail separately (catches Promotions, Social, Updates, Forums)
    local ok_am, err_am = pcall(function()
        local all_messages = account['[Gmail]/All Mail']:select_all()
        local old_messages = account['[Gmail]/All Mail']:is_older(90)
        print('[Gmail]/All Mail: ' .. #all_messages .. ' total, ' .. #old_messages .. ' older than 90 days')
        total_all = total_all + #all_messages
        total_old = total_old + #old_messages
    end)
    if not ok_am then
        print('[Gmail]/All Mail: NOT ACCESSIBLE')
    end
end)

if not ok then
    print('ERROR: ' .. err)
    os.execute('sleep 60')
end

print('---')
print('TOTAL: ' .. total_all .. ' messages, ' .. total_old .. ' would be deleted')
print('DONE')

Run the dry-run inside tmux — it can take a while with large mailboxes:

tmux new -s retention-dryrun
GMAIL_PASS=$(pass show gmail) imapfilter -c "$MAIL_ROOT"/scripts/gmail_retention_dryrun.lua

Detach with Ctrl+B then D, reattach later with tmux attach -t retention-dryrun.

Run the real deletion #

Run inside tmux with a retry loop in case of timeouts:

tmux new -s retention
while true; do
    GMAIL_PASS=$(pass show gmail) imapfilter -c "$MAIL_ROOT"/scripts/gmail_retention.lua
    echo "[$(date)] exited, waiting 60s..."
    sleep 60
done

Detach with Ctrl+B then D. When done, verify with the dry-run script — it should show 0 would be deleted.

Always verify before running #


Step 6: Automation via Cron #

crontab -e

Add the following:

MAIL_ROOT=/srv/mail-archive
GMAIL_ADDRESS=you@gmail.com

# Sync mail every hour
0 * * * * /usr/bin/mbsync -a >> ${MAIL_ROOT}/logs/mbsync.log 2>&1

# Re-index after sync (runs just after mbsync)
5 * * * * /usr/bin/notmuch new >> ${MAIL_ROOT}/logs/notmuch.log 2>&1

# Lock mail files to read-only after each sync (preserves metadata files as rw)
6 * * * * find ${MAIL_ROOT}/gmail -type f \( -name "*:2,*" -o -name "*.eml" \) -print0 | xargs -0 chmod 444 >> ${MAIL_ROOT}/logs/chmod.log 2>&1

# Run Gmail retention cleanup weekly on Sunday at 2am (deletes remote only, never local)
0 2 * * 0 GMAIL_ADDRESS=${GMAIL_ADDRESS} GMAIL_PASS=$(pass show gmail) /usr/bin/imapfilter -c ${MAIL_ROOT}/scripts/gmail_retention.lua >> ${MAIL_ROOT}/logs/retention.log 2>&1

Create the required directories:

mkdir -p "$MAIL_ROOT"/gmail
mkdir -p "$MAIL_ROOT"/logs
mkdir -p "$MAIL_ROOT"/scripts

Step 7: Storage Recommendations #

  • SD card: acceptable for OS and software only
  • External USB SSD: required for mail storage if archive exceeds ~10–20 GB (SD cards degrade quickly under frequent small writes)
  • Mount the SSD and ensure $MAIL_ROOT points to it
  • Optional: compress old folders periodically with tar to reclaim space

Directory layout #

$MAIL_ROOT/
├── gmail/          ← Maildir mail storage
├── logs/           ← mbsync, notmuch, retention logs
└── scripts/        ← gmail_retention.lua and future scripts

Step 8: Safety Checks #

Always verify the local archive is intact and Gmail retention is working correctly:

# Total messages in local archive (should grow over time, never shrink)
notmuch count

# Messages older than 3 months in local archive (expected to be large — we keep everything locally)
notmuch count date:..3months

# Confirm recent messages are present
notmuch search date:1month.. | head -20

Expected results:

  • notmuch count → total archive size, should never decrease
  • notmuch count date:..3months → large number is correct — local archive keeps all history
  • Gmail should only have ~last 3 months — verify with the dry-run script

Verify Gmail retention is clean:

GMAIL_PASS=$(pass show gmail) imapfilter -c "$MAIL_ROOT"/scripts/gmail_retention_dryrun.lua

The output should show 0 would be deleted if the weekly cron has run recently.

Additional safeguards:

  • Never run rm on $MAIL_ROOT/gmail/ - local archive is permanent
  • Avoid running multiple parallel sync/delete jobs — Gmail IMAP throttles aggressively
  • Keep logs from cron jobs and review them periodically
  • Consider a periodic backup of the local Maildir to an external drive or remote storage

Step 9: Accessing Mail from Windows (Dovecot + Roundcube) #

To read your archived mail from any browser on the local network, run Dovecot (IMAP server) and Roundcube (web mail client) in Docker on the Pi.

Key notes:

  • The dovecot/dovecot:2.4.2-arm64 image runs as UID 1000 (vmail)
  • Mail files must be owned by a shared group that both $LOCAL_MAIL_USER (mbsync) and UID 1000 (Dovecot) belong to
  • Dovecot needs group_add: ["$MAIL_GROUP_GID"] to access files owned by mailgroup

Set up file permissions #

Create a shared group and set correct ownership (run as $ADMIN_USER or with sudo):

sudo groupadd mailgroup
sudo usermod -aG mailgroup "$ADMIN_USER"
sudo usermod -aG mailgroup "$LOCAL_MAIL_USER"
sudo chown -R "$LOCAL_MAIL_USER":mailgroup "$MAIL_ROOT"/gmail
sudo find "$MAIL_ROOT"/gmail -type d -exec chmod 2775 {} +
sudo find "$MAIL_ROOT"/gmail -type f -exec chmod 664 {} +
# Lock actual mail files to read-only
sudo find "$MAIL_ROOT"/gmail -type f \( -name "*:2,*" -o -name "*.eml" \) -exec chmod 444 {} +

Note: GID of mailgroup must match the group_add value in docker-compose. Verify with getent group mailgroup and update $MAIL_GROUP_GID.

Create the project directory #

mkdir -p ~/projects/dovecot/conf.d

Create the docker-compose file #

Save as ~/projects/dovecot/docker-compose.yml:

services:
  dovecot:
    image: dovecot/dovecot:2.4.2-arm64
    container_name: dovecot
    restart: unless-stopped
    group_add:
      - "${MAIL_GROUP_GID}"
    volumes:
      - ${MAIL_ROOT}/gmail:/srv/vmail/${DOVECOT_LOGIN_USER}/mail
      - ./conf.d:/etc/dovecot/conf.d:ro
    environment:
      - TZ=${PI_TIMEZONE}
      - USER_PASSWORD=${DOVECOT_LOGIN_PASSWORD}
    networks:
      - mailnet

  roundcube:
    image: roundcube/roundcubemail:1.6.13-apache-nonroot
    container_name: roundcube
    restart: unless-stopped
    ports:
      - "8080:8000"
    environment:
      - ROUNDCUBEMAIL_DEFAULT_HOST=dovecot
      - ROUNDCUBEMAIL_DEFAULT_PORT=31143
      - ROUNDCUBEMAIL_SMTP_SERVER=
      - ROUNDCUBEMAIL_PLUGINS=archive
    depends_on:
      - dovecot
    networks:
      - mailnet

networks:
  mailnet:
    driver: bridge

Create the Dovecot config drop-in #

Save as ~/projects/dovecot/conf.d/auth-mechanisms.conf:

passdb static {
  password = $ENV:USER_PASSWORD
}

auth_mechanisms {
  plain = yes
  login = yes
}

auth_allow_cleartext = yes

mailbox_list_layout = fs

Note: mailbox_list_layout = fs is required for Dovecot to correctly read the Maildir folder structure synced by mbsync.

Start the stack #

cd ~/projects/dovecot
docker compose up -d
docker compose logs --follow

You should see: Dovecot v2.4.2 starting up for imap... with no errors.

Verify mailboxes are visible:

docker exec dovecot /dovecot/bin/doveadm mailbox list -u "$DOVECOT_LOGIN_USER"

Access Roundcube #

Open a browser and go to:

http://<host-ip>:8080

Login with username $DOVECOT_LOGIN_USER and the password set in USER_PASSWORD.

If folders don’t appear: Go to Settings → Folders in Roundcube and click Subscribe on the folders you want to see.

Find the host local IP #

hostname -I

Useful commands #

# Stop the stack
docker compose -f ~/projects/dovecot/docker-compose.yml down

# Restart the stack
docker compose -f ~/projects/dovecot/docker-compose.yml restart

# View logs
docker compose -f ~/projects/dovecot/docker-compose.yml logs --follow

# List mailboxes (debug)
docker exec dovecot /dovecot/bin/doveadm mailbox list -u "$DOVECOT_LOGIN_USER"

Troubleshooting #

ProblemLikely causeFix
Old messages still visible in Gmail after deletiondelete_messages() on [Gmail]/All Mail only archivesMove to [Gmail]/Trash first, then delete from Trash
Dovecot permission denied on mail directoryDovecot UID 1000 not in mailgroupAdd group_add: ["${MAIL_GROUP_GID}"] to dovecot service in docker-compose
Roundcube shows no foldersmailbox_list_layout not setAdd mailbox_list_layout = fs to conf.d/auth-mechanisms.conf
Roundcube folders not subscribedDefault subscription stateGo to Settings → Folders → Subscribe all
mbsync creates ~/Maildir instead of using $MAIL_ROOT/gmail/Inbox line missing from MaildirStoreAdd Inbox ${MAIL_ROOT}/gmail/Inbox/ to the MaildirStore block
mbsync errors on .notmuch/xapian subfolderSubFolders style not setAdd SubFolders Verbatim to the MaildirStore block in .mbsyncrc
mbsync shows SSLType is deprecated warningOld configReplace SSLType with TLSType in .mbsyncrc
imapfilter auth failsWrong password or env var not setRun GMAIL_PASS=$(pass show gmail) imapfilter -c ... manually to test
pass show gmail failsGPG key not foundRe-run gpg --full-generate-key, then pass init and pass insert gmail
mbsync works manually but fails in cronGPG passphrase not cachedRun pass show gmail once after reboot; check gpg-agent.conf TTL
Notmuch finds 0 messagesWrong mail root in setupRe-run notmuch setup, set path to $MAIL_ROOT/gmail/
Gmail blocks loginApp password not usedGenerate App Password in Google Account settings