- rmauro.dev — Technical Blog/
- Posts/
- Keep Gmail Lean: Archive Everything Locally on Linux (Pi4/Pi5 Included)/
Keep Gmail Lean: Archive Everything Locally on Linux (Pi4/Pi5 Included)
Table of Contents
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
- Objective
- Architecture
- Decisions Made
- Step 1: Enable IMAP on Gmail
- Step 2: Install Software on Linux
- Step 3: Configure mbsync
- Step 4: Initialize Notmuch Index
- Step 5: Retention Policy (Keep Only Last 3 Months on Gmail)
- Step 6: Automation via Cron
- Step 7: Storage Recommendations
- Step 8: Safety Checks
- Step 9: Accessing Mail from Windows (Dovecot + Roundcube)
- Troubleshooting
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 #
| Decision | Choice | Rationale |
|---|---|---|
| Sync tool | mbsync (isync) | Mature, efficient, widely trusted, Maildir-native |
| Mail format | Maildir | Compatible with mbsync, Notmuch, Mutt, and most Unix tools |
| Search engine | Notmuch | Fast full-text indexing over Maildir; tag-based |
| Auth method | App Password via pass | Avoids storing plaintext credentials; works with Gmail 2FA |
| Deletion scope | Remote only (Gmail) | Local archive is never touched by retention policy |
| Retention tool | imapfilter | Deletes directly on Gmail IMAP server; mbsync is sync-only |
| Storage | External USB SSD (for >50GB) | SD card wear is a concern for frequent write workloads |
Step 1: Enable IMAP on Gmail #
- Go to Gmail Settings → Forwarding and POP/IMAP
- Enable IMAP
- Since 2FA is required for App Passwords, enable it in your Google Account if not already active
Create an App Password #
- Open Google Account Security Settings
- Navigate to App Passwords
- Choose: App =
Mail, Device =Other→ name itLinux archive - 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
| Package | Role |
|---|---|
isync | Provides the mbsync binary for IMAP sync |
notmuch | Indexes and searches local mail |
imapfilter | Deletes old messages directly on Gmail IMAP server |
msmtp | Sends mail via SMTP (optional) |
mutt | Terminal mail client (optional) |
pass | Unix 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 callspassat runtime — no plaintext password is ever stored in the config fileTLSType IMAPS: useTLSTypenotSSLType— the latter is deprecated and causes a warningCertificateFile: 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/xapiansubfolder. It must be combined with an explicitInboxpath; otherwise, mbsync defaults to~/Maildir.Expunge Faris intentionally omitted: mbsync is used for syncing only, never for deletion. Remote cleanup is handled exclusively byimapfilter.
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 Mailhowever,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_ROOTpoints to it - Optional: compress old folders periodically with
tarto 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 decreasenotmuch 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
rmon$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-arm64image 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 bymailgroup
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
mailgroupmust match thegroup_addvalue in docker-compose. Verify withgetent group mailgroupand 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 = fsis 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 #
| Problem | Likely cause | Fix |
|---|---|---|
| Old messages still visible in Gmail after deletion | delete_messages() on [Gmail]/All Mail only archives | Move to [Gmail]/Trash first, then delete from Trash |
| Dovecot permission denied on mail directory | Dovecot UID 1000 not in mailgroup | Add group_add: ["${MAIL_GROUP_GID}"] to dovecot service in docker-compose |
| Roundcube shows no folders | mailbox_list_layout not set | Add mailbox_list_layout = fs to conf.d/auth-mechanisms.conf |
| Roundcube folders not subscribed | Default subscription state | Go to Settings → Folders → Subscribe all |
mbsync creates ~/Maildir instead of using $MAIL_ROOT/gmail/ | Inbox line missing from MaildirStore | Add Inbox ${MAIL_ROOT}/gmail/Inbox/ to the MaildirStore block |
mbsync errors on .notmuch/xapian subfolder | SubFolders style not set | Add SubFolders Verbatim to the MaildirStore block in .mbsyncrc |
mbsync shows SSLType is deprecated warning | Old config | Replace SSLType with TLSType in .mbsyncrc |
imapfilter auth fails | Wrong password or env var not set | Run GMAIL_PASS=$(pass show gmail) imapfilter -c ... manually to test |
pass show gmail fails | GPG key not found | Re-run gpg --full-generate-key, then pass init and pass insert gmail |
| mbsync works manually but fails in cron | GPG passphrase not cached | Run pass show gmail once after reboot; check gpg-agent.conf TTL |
| Notmuch finds 0 messages | Wrong mail root in setup | Re-run notmuch setup, set path to $MAIL_ROOT/gmail/ |
| Gmail blocks login | App password not used | Generate App Password in Google Account settings |