Created
May 29, 2026 15:52
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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