Skip to content

Instantly share code, notes, and snippets.

@lagergren
Created May 29, 2026 15:52
Show Gist options
  • Select an option

  • Save lagergren/43270fac9fcb23335b27a1c6023791cd to your computer and use it in GitHub Desktop.

Select an option

Save lagergren/43270fac9fcb23335b27a1c6023791cd to your computer and use it in GitHub Desktop.
Incremental, resumable Gmail backup to local Maildir via mbsync (isync) -- Linux. Pull-only, never modifies the server.
#!/usr/bin/env bash
# Incremental Gmail backup to local Maildir via mbsync (isync) -- Linux version.
# "rsync for email": pull-only, never modifies the server, never deletes locally.
#
# Usage: ./gmail-backup-linux.sh you@gmail.com [destination-folder]
# The destination folder is created if missing. If it already holds a partial
# backup, mbsync resumes from where it left off (sync state lives in the folder).
# When omitted, it defaults to ${MAIL_BACKUP_DIR:-~/Mail}/<address>.
# First run prompts once for a Gmail app password. It is stored via secret-tool
# (GNOME Keyring) if available, otherwise in a chmod-600 file under ~/.config.
# Subsequent runs are incremental and need no input.
#
# Install mbsync first: sudo apt-get install isync
set -euo pipefail
EMAIL="${1:-}"
DEST_ARG="${2:-}"
if [[ -z "$EMAIL" ]]; then
echo "Usage: $0 <gmail-address> [destination-folder]" >&2
exit 1
fi
# Destination: the folder this account's Maildir is backed up into. Take it from
# the second argument; otherwise fall back to ${MAIL_BACKUP_DIR:-~/Mail}/<address>.
DEST="${DEST_ARG:-${MAIL_BACKUP_DIR:-$HOME/Mail}/$EMAIL}"
CONFIG_DIR="$HOME/.config/gmail-backup"
RC="$CONFIG_DIR/mbsyncrc-$EMAIL"
SERVICE="gmail-backup"
PWFILE="$CONFIG_DIR/pw-$EMAIL"
# Exit with a clear explanation when a tool the run depends on is missing, rather
# than failing later with a cryptic "command not found" mid-backup.
require_tool() {
local cmd="$1" why="$2" install="${3:-}"
command -v "$cmd" >/dev/null 2>&1 && return 0
echo "Required tool '$cmd' is not installed -- $why" >&2
[[ -n "$install" ]] && echo "Install it with: $install" >&2
exit 1
}
require_tool mbsync "it performs the IMAP-to-Maildir sync" "sudo apt-get install isync"
mkdir -p "$DEST" "$CONFIG_DIR"
chmod 700 "$CONFIG_DIR"
# Resolve to an absolute path so the generated mbsync config is independent of the
# directory the script happens to be invoked from. A folder that already contains a
# partial backup is reused as-is -- mkdir -p never touches existing contents, and
# mbsync's per-mailbox sync state (SyncState *, see below) lets it resume cleanly.
DEST="$(cd "$DEST" && pwd)"
# Pick a password backend. Override with MAIL_PW_BACKEND=secret-tool|file.
BACKEND="${MAIL_PW_BACKEND:-}"
if [[ -z "$BACKEND" ]]; then
if command -v secret-tool >/dev/null; then BACKEND="secret-tool"; else BACKEND="file"; fi
fi
case "$BACKEND" in
secret-tool)
require_tool secret-tool "the secret-tool password backend is selected (set MAIL_PW_BACKEND=file to use a chmod-600 file instead)" "sudo apt-get install libsecret-tools"
PASS_CMD="secret-tool lookup service $SERVICE account $EMAIL"
pw_have() { secret-tool lookup service "$SERVICE" account "$EMAIL" >/dev/null 2>&1; }
pw_store() { secret-tool store --label="$SERVICE $EMAIL" service "$SERVICE" account "$EMAIL"; }
;;
file)
PASS_CMD="cat $PWFILE"
pw_have() { [[ -s "$PWFILE" ]]; }
pw_store() { (umask 077; cat > "$PWFILE"); chmod 600 "$PWFILE"; }
;;
*)
echo "Unknown MAIL_PW_BACKEND: $BACKEND (use secret-tool or file)" >&2
exit 1
;;
esac
# Single-run lock: a killed-and-restarted run, or an accidental double-run, must
# not have two mbsync processes writing the same Maildir/sync state at once.
LOCK="$CONFIG_DIR/lock-$EMAIL"
if ! mkdir "$LOCK" 2>/dev/null; then
oldpid="$(cat "$LOCK/pid" 2>/dev/null || true)"
if [[ -n "$oldpid" ]] && kill -0 "$oldpid" 2>/dev/null; then
echo "A backup for $EMAIL is already running (pid $oldpid). Exiting." >&2
exit 1
fi
echo "Clearing stale lock (pid ${oldpid:-unknown} no longer running)."
rm -rf "$LOCK"
mkdir "$LOCK"
fi
echo "$$" > "$LOCK/pid"
trap 'rm -rf "$LOCK"' EXIT
# Store the app password on first run only.
if ! pw_have; then
echo "No stored app password for $EMAIL (backend: $BACKEND)."
echo "Create one at https://myaccount.google.com/apppasswords (needs 2FA enabled)."
printf "Paste the 16-char app password: "
read -rs APP_PW
echo
printf '%s' "$APP_PW" | pw_store
echo "Stored via $BACKEND."
fi
# Regenerate the per-account config each run so it always matches paths above.
cat > "$RC" <<EOF
IMAPAccount gmail
Host imap.gmail.com
Port 993
User $EMAIL
PassCmd "$PASS_CMD"
# SSLType (not the newer TLSType) for portability: it is the keyword in isync 1.3/
# early-1.4 builds and remains a recognized alias in newer ones, so it works on both.
SSLType IMAPS
AuthMechs LOGIN
SystemCertificates yes
IMAPStore gmail-remote
Account gmail
MaildirStore gmail-local
Subfolders Verbatim
Path $DEST/
Inbox $DEST/Inbox
Channel gmail
Far :gmail-remote:
Near :gmail-local:
# All Mail holds every archived/labeled message exactly once (labels are virtual),
# so this captures everything without the duplication you'd get syncing each label.
# Spam and Trash live outside All Mail, so include them too.
Patterns "[Gmail]/All Mail" "[Gmail]/Spam" "[Gmail]/Trash"
Sync Pull
Create Near
Remove None
Expunge None
CopyArrivalDate yes
# Keep each mailbox's sync state (.mbsyncstate) inside the destination folder
# itself rather than under ~/.mbsync. This makes the destination self-contained:
# point a run at a folder holding a partial backup and mbsync resumes from its
# recorded state instead of re-downloading.
SyncState *
EOF
chmod 600 "$RC"
echo "Backing up $EMAIL -> $DEST"
# Ctrl-C / TERM means "stop", not "fail" -- don't retry, just exit with state saved.
# Detection is driven primarily by the foreground child's exit code, not this flag:
# a terminal sends the signal to whichever child is running (mbsync or the backoff
# sleep), so both exit 130 (SIGINT) / 143 (SIGTERM) and the loop stops on that. The
# flag is a secondary guard (and is a no-op if the shell was started with SIGINT
# already ignored, e.g. backgrounded -- in which case there's no Ctrl-C anyway).
INTERRUPTED=0
trap 'INTERRUPTED=1' INT TERM
stop_interrupted() {
echo "Interrupted. Progress is saved -- re-run the same command to resume." >&2
exit 130
}
# Auto-resume loop: if mbsync dies on a network drop or killed connection, re-run
# it. mbsync replays its journal and continues from where it stopped -- no
# already-downloaded message is fetched twice.
# mbsync prints server/IMAP errors to stderr, so capture stderr to a file each
# attempt (stdout stays on the terminal, keeping mbsync's live progress counter)
# and scan it -- currently to recognise Gmail's daily-bandwidth-limit response.
attempt=0
max_attempts=20
ERRLOG="$CONFIG_DIR/last-stderr-$EMAIL.log"
while true; do
(( INTERRUPTED )) && stop_interrupted
# Capture mbsync's exit code directly: `rc=$?` after an `if mbsync; then` block
# would read the if-statement's status (0), not mbsync's, masking the failure.
rc=0
mbsync -c "$RC" gmail 2>"$ERRLOG" || rc=$?
[[ -s "$ERRLOG" ]] && cat "$ERRLOG" >&2 # surface mbsync's warnings/errors
if (( rc == 0 )); then
echo "Backup complete."
break
fi
# Stop if asked to: our trap fired, or mbsync was itself killed by a signal
# (128+SIGINT=130, 128+SIGTERM=143) -- e.g. Ctrl-C reaches mbsync before our trap.
if (( INTERRUPTED || rc == 130 || rc == 143 )); then
stop_interrupted
fi
# Gmail's daily IMAP download cap (2500 MB) -- the suspension lasts hours, so the
# retry loop below would just burn ~35 min for nothing. Stop now and say so.
# Matches "...bandwidth limit for downloads..." and "...command or bandwidth limits".
if grep -qi 'bandwidth limit' "$ERRLOG"; then
echo >&2
echo "Gmail's daily IMAP download limit (2500 MB/day) has been reached." >&2
echo "It resets automatically -- usually within an hour, up to 24h." >&2
echo "Progress is saved; re-run the same command later to resume." >&2
exit 75 # EX_TEMPFAIL: temporary failure, retry later
fi
attempt=$((attempt + 1))
if (( attempt >= max_attempts )); then
echo "mbsync failed $max_attempts times (last exit $rc). Re-run to resume." >&2
exit "$rc"
fi
backoff=$(( attempt * 10 < 300 ? attempt * 10 : 300 ))
echo "mbsync exited $rc; resuming in ${backoff}s (attempt $attempt/$max_attempts)..."
# A signal during the wait kills sleep (exit >128); capture that instead of
# letting `set -e` abort or falling through to another mbsync attempt. sleep
# exits non-zero only when interrupted, so any non-zero here means "stop now".
slrc=0
sleep "$backoff" || slrc=$?
if (( slrc != 0 || INTERRUPTED )); then
stop_interrupted
fi
done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment