imap-migration-tools/src/restore_imap_emails.py
Nathan Moinvaziri e932190618 Add imap_session module for centralized OAuth2 refresh (#21)
* Add .claude to gitignore

* Add imap_session module for centralized OAuth2 refresh

Create new imap_session.py module that combines imap_common and
imap_oauth2 for unified session management:

- ensure_connection(): Refreshes OAuth2 token and ensures connection health
- ensure_folder_session(): Ensures connection AND folder selection

Updated backup, migrate, and restore scripts to use the new module,
simplifying the proactive token refresh pattern in process_batch
functions.

* Move is_token_expired_error to imap_oauth2 module

Relocates the OAuth2 token expiration detection function to the oauth2-specific module where it logically belongs.

* Add retry logic for auth errors during email processing

Extract single-UID processing into dedicated functions with retry capability
for recoverable authentication failures. Broadens auth error detection beyond
token expiration to include session loss and authentication failures.

* Cache Microsoft tenant discovery results

Avoids repeated OpenID Connect discovery requests for the same email domain
during OAuth2 authentication flows. Normalizes domain to lowercase for
consistent cache keys.

* Add tests for is_token_expired_error and is_auth_error

Covers token expiration, session invalidation, and authentication failure
detection with case-insensitive matching verification. Ensures non-auth
errors like connection and timeout issues are correctly excluded.

* Add tests for imap_session module

Covers ensure_connection and ensure_folder_session functions including
OAuth2 token refresh, folder selection, and error handling scenarios.
Verifies connection change detection triggers folder re-selection.

* Add tests for auth error re-raising in get_message_ids_in_folder

Verifies that authentication errors are propagated to callers for
reconnection handling while non-auth errors return empty results.
Clarifies test names to distinguish error handling behaviors.
2026-02-06 13:26:44 -05:00

1229 lines
45 KiB
Python

"""
IMAP Email Restore Script
Restores emails from a local backup to an IMAP account.
Reads .eml files from a local directory and uploads them to the destination server.
Features:
- Folder Restoration: Recreates the folder structure from the backup.
- Gmail Labels Restoration: Uses labels_manifest.json to apply Gmail labels.
- Incremental Restore: Skips emails that already exist (based on Message-ID).
- Parallel Processing: Uses multithreading for fast uploads.
- Date Preservation: Restores emails with their original dates.
Configuration (Environment Variables):
DEST_IMAP_HOST, DEST_IMAP_USERNAME: Destination credentials.
DEST_IMAP_PASSWORD: Destination password (or App Password).
OAuth2 (Optional - instead of password):
DEST_OAUTH2_CLIENT_ID: OAuth2 Client ID
DEST_OAUTH2_CLIENT_SECRET: OAuth2 Client Secret (required for Google)
BACKUP_LOCAL_PATH: Source local directory containing the backup.
MAX_WORKERS: Number of concurrent threads (default: 4).
BATCH_SIZE: Number of emails to process per batch (default: 10).
APPLY_LABELS: Set to "true" to apply Gmail labels from manifest. Default is "false".
APPLY_FLAGS: Set to "true" to apply IMAP flags from manifest. Default is "false".
GMAIL_MODE: Set to "true" for Gmail restore mode. Default is "false".
DEST_DELETE: Set to "true" to delete emails from destination not found in local backup.
Default is "false".
Usage:
python3 restore_imap_emails.py \
--src-path "./my_backup" \
--dest-host "imap.gmail.com" \
--dest-user "you@gmail.com" \
--dest-pass "your-app-password"
Gmail Labels Restoration:
python3 restore_imap_emails.py \
--src-path "./gmail_backup" \
--dest-host "imap.gmail.com" \
--dest-user "you@gmail.com" \
--dest-pass "your-app-password" \
--apply-labels
This uploads emails and applies labels from labels_manifest.json to recreate
the original Gmail label structure.
"""
import argparse
import concurrent.futures
import json
import os
import sys
import threading
import time
from email import policy
from email.parser import BytesParser
from email.utils import parsedate_to_datetime
from enum import Enum
from typing import Optional
import imap_common
import imap_oauth2
import imap_session
import restore_cache
class UploadResult(Enum):
"""Result of an email upload operation."""
SUCCESS = "success"
ALREADY_EXISTS = "already_exists"
FAILURE = "failure"
# Defaults
MAX_WORKERS = 4 # Lower default for restore to avoid rate limits
BATCH_SIZE = 10
# Thread-local storage
thread_local = threading.local()
print_lock = threading.Lock()
def safe_print(message):
t_name = threading.current_thread().name
short_name = t_name.replace("ThreadPoolExecutor-", "T-").replace("MainThread", "MAIN")
with print_lock:
print(f"[{short_name}] {message}")
def get_thread_connection(dest_conf):
"""Get or create a thread-local IMAP connection."""
thread_local.dest = imap_session.ensure_connection(getattr(thread_local, "dest", None), dest_conf)
return thread_local.dest
def load_labels_manifest(local_path):
"""
Loads the labels manifest from the backup directory.
Returns the manifest dict or empty dict if not found.
"""
manifest_path = os.path.join(local_path, "labels_manifest.json")
if os.path.exists(manifest_path):
try:
with open(manifest_path, encoding="utf-8") as f:
manifest = json.load(f)
safe_print(f"Loaded labels manifest with {len(manifest)} entries.")
return manifest
except Exception as e:
safe_print(f"Warning: Could not load labels manifest: {e}")
return {}
def load_flags_manifest(local_path):
"""
Loads the flags manifest from the backup directory.
Returns the manifest dict or empty dict if not found.
"""
manifest_path = os.path.join(local_path, "flags_manifest.json")
if os.path.exists(manifest_path):
try:
with open(manifest_path, encoding="utf-8") as f:
manifest = json.load(f)
safe_print(f"Loaded flags manifest with {len(manifest)} entries.")
return manifest
except Exception as e:
safe_print(f"Warning: Could not load flags manifest: {e}")
return {}
def get_flags_from_manifest(manifest, message_id):
"""
Extract IMAP flags string from manifest entry.
Returns flags string like "\\Seen \\Flagged" or None.
"""
if not message_id or message_id not in manifest:
return None
entry = manifest[message_id]
if isinstance(entry, dict) and "flags" in entry:
flags = entry.get("flags", [])
if flags:
return " ".join(flags)
return None
def sync_flags_on_existing(imap_conn, folder_name, message_id, flags, size):
"""
Sync flags on an existing email in the given folder.
Finds the email by Message-ID and updates its flags.
Args:
imap_conn: IMAP connection
folder_name: Folder containing the email
message_id: Message-ID header value
flags: Space-separated flags string like "\\Seen \\Flagged"
size: Email size for verification
"""
try:
# Select folder
imap_conn.select(f'"{folder_name}"')
# Search for the message by Message-ID
search_id = message_id.strip("<>")
resp, data = imap_conn.search(None, f'HEADER Message-ID "{search_id}"')
if resp != "OK" or not data[0]:
return
msg_nums = data[0].split()
if not msg_nums:
return
# Use the first matching message
msg_num = msg_nums[0]
# Parse flags into a list
flag_list = flags.split() if flags else []
if not flag_list:
return
# Get current flags
resp, flag_data = imap_conn.fetch(msg_num, "(FLAGS)")
if resp != "OK":
return
# Check which flags need to be added
current_flags_str = str(flag_data[0]) if flag_data and flag_data[0] else ""
flags_to_add = []
for flag in flag_list:
# Normalize flag for comparison (case-insensitive)
if flag.lower() not in current_flags_str.lower():
flags_to_add.append(flag)
if flags_to_add:
# Add missing flags
flags_str = " ".join(flags_to_add)
resp, _ = imap_conn.store(msg_num, "+FLAGS", f"({flags_str})")
if resp == "OK":
for flag in flags_to_add:
safe_print(f" -> Synced flag: {flag}")
except Exception as e:
safe_print(f" -> Error syncing flags: {e}")
def extract_message_id_from_eml(file_path):
"""
Extract just the Message-ID from an .eml file efficiently.
Returns the Message-ID string or None on error.
"""
try:
with open(file_path, "rb") as f:
# Read just the first 64KB to get headers
header_bytes = f.read(65536)
msg_id = imap_common.extract_message_id(header_bytes)
return msg_id
except Exception:
return None
def parse_eml_file(file_path):
"""
Parse an .eml file and extract metadata.
Returns (message_id, date_str, raw_content, subject) or (None, None, None, None) on error.
"""
try:
with open(file_path, "rb") as f:
raw_content = f.read()
# Use compat32 to preserve raw headers with continuation lines
parser = BytesParser(policy=policy.compat32)
msg = parser.parsebytes(raw_content, headersonly=True)
message_id = imap_common.decode_message_id(msg.get("Message-ID"))
raw_subject = msg.get("Subject")
subject = imap_common.decode_mime_header(raw_subject) if raw_subject else "(No Subject)"
date_header = msg.get("Date")
# Parse date for IMAP INTERNALDATE
date_str = None
if date_header:
try:
dt = parsedate_to_datetime(date_header)
# Format: "DD-Mon-YYYY HH:MM:SS +ZZZZ"
date_str = dt.strftime('"%d-%b-%Y %H:%M:%S %z"')
except Exception:
pass
return message_id, date_str, raw_content, subject
except Exception as e:
safe_print(f"Error parsing {file_path}: {e}")
return None, None, None, None
def get_eml_files(folder_path):
"""
Get all .eml files in a folder.
Returns list of (file_path, filename) tuples.
"""
eml_files = []
if not os.path.exists(folder_path):
return eml_files
try:
for filename in os.listdir(folder_path):
if filename.endswith(".eml"):
file_path = os.path.join(folder_path, filename)
eml_files.append((file_path, filename))
except Exception as e:
safe_print(f"Error listing {folder_path}: {e}")
return eml_files
def email_exists_in_folder(imap_conn, message_id):
"""
Check if an email with the given Message-ID exists in the currently selected folder.
"""
if not message_id:
return False
try:
return imap_common.message_exists_in_folder(imap_conn, message_id)
except Exception:
return False
def upload_email(dest, folder_name, raw_content, date_str, message_id, flags=None, check_duplicate=True):
"""
Upload a single email to the destination folder.
Returns UploadResult enum: SUCCESS, ALREADY_EXISTS, or FAILURE.
Args:
flags: Optional string of IMAP flags like "\\Seen" for read emails.
check_duplicate: Whether to check for duplicates before uploading.
"""
try:
# Ensure folder exists
imap_common.ensure_folder_exists(dest, folder_name)
# Select folder
dest.select(f'"{folder_name}"')
# Check for duplicates if requested
if check_duplicate and message_id and email_exists_in_folder(dest, message_id):
return UploadResult.ALREADY_EXISTS
# Upload with original date and flags
success = imap_common.append_email(
dest,
folder_name,
raw_content,
date_str,
flags,
ensure_folder=False,
)
return UploadResult.SUCCESS if success else UploadResult.FAILURE
except Exception as e:
safe_print(f"Error uploading to {folder_name}: {e}")
return UploadResult.FAILURE
def get_labels_from_manifest(manifest, message_id):
"""
Get the list of labels for a message from the manifest.
Returns list of label strings or empty list.
"""
if not message_id or message_id not in manifest:
return []
entry = manifest[message_id]
if isinstance(entry, dict):
return entry.get("labels", [])
elif isinstance(entry, list):
return entry # Old format: list of labels
return []
def label_to_folder(label):
"""
Convert a Gmail label name to an IMAP folder path.
"""
if label == imap_common.FOLDER_INBOX:
return imap_common.FOLDER_INBOX
elif label in ("Sent Mail", "Starred", "Drafts", "Important"):
return f"[Gmail]/{label}"
else:
return label
def process_restore_batch(
eml_files,
folder_name,
dest_conf,
manifest,
apply_labels,
apply_flags,
full_restore: bool = False,
existing_dest_msg_ids_by_folder: Optional[dict[str, set[str]]] = None,
existing_dest_msg_ids_lock: Optional[threading.Lock] = None,
progress_cache_data: Optional[dict] = None,
progress_cache_lock: Optional[threading.Lock] = None,
progress_cache_path: Optional[str] = None,
dest_host: Optional[str] = None,
dest_user: Optional[str] = None,
):
"""
Process a batch of .eml files for restoration.
Args:
folder_name: Target folder, or "__GMAIL_MODE__" for per-email folder selection
manifest: Combined manifest with labels and/or flags
apply_labels: Whether to apply Gmail labels from manifest
apply_flags: Whether to apply IMAP flags from manifest
"""
dest = get_thread_connection(dest_conf)
if not dest:
safe_print("Error: Could not establish connection for batch.")
return
gmail_mode = folder_name == "__GMAIL_MODE__"
for file_path, filename in eml_files:
# Proactively refresh token if needed
dest = get_thread_connection(dest_conf)
if not dest:
safe_print(f"ERROR: Connection lost for {filename}")
return
try:
message_id, date_str, raw_content, subject = parse_eml_file(file_path)
if raw_content is None:
continue # No content, skip to next file
size = len(raw_content)
size_str = f"{size / 1024:.1f}KB"
# Truncate subject for display
display_subject = (subject[:40] + "...") if len(subject) > 40 else subject
# Get flags from manifest if apply_flags is enabled
flags = None
if apply_flags:
flags = get_flags_from_manifest(manifest, message_id)
# Get labels for this message
labels = get_labels_from_manifest(manifest, message_id) if apply_labels else []
# Determine target folder and remaining labels
if gmail_mode:
# In Gmail mode, upload to first valid label folder
# Skip system folders we can't upload to
skip_folders = {"[Gmail]/All Mail", "[Gmail]/Spam", "[Gmail]/Trash"}
target_folder = None
remaining_labels = []
for label in labels:
label_folder = label_to_folder(label)
if label_folder in skip_folders:
continue
if target_folder is None:
target_folder = label_folder
else:
remaining_labels.append(label)
# If no valid label found, use a dedicated label folder as fallback.
# This avoids placing non-draft messages into Gmail Drafts.
if target_folder is None:
target_folder = imap_common.FOLDER_RESTORED_UNLABELED
remaining_labels = []
else:
target_folder = folder_name
remaining_labels = labels
existing_dest_msg_ids: Optional[set[str]] = None
if existing_dest_msg_ids_by_folder is not None:
if existing_dest_msg_ids_lock is None:
existing_dest_msg_ids_lock = threading.Lock()
with existing_dest_msg_ids_lock:
existing_dest_msg_ids = existing_dest_msg_ids_by_folder.get(target_folder)
# Lazy-load per-folder progress cache (no destination scan).
if existing_dest_msg_ids is None:
built: set[str] = set()
if progress_cache_data is not None and progress_cache_lock is not None and dest_host and dest_user:
built = restore_cache.get_cached_message_ids(
progress_cache_data,
progress_cache_lock,
dest_host,
dest_user,
target_folder,
)
with existing_dest_msg_ids_lock:
existing_dest_msg_ids_by_folder.setdefault(target_folder, built)
existing_dest_msg_ids = existing_dest_msg_ids_by_folder[target_folder]
# Incremental default: if we already know we processed it before, skip entirely.
if (
not full_restore
and message_id
and existing_dest_msg_ids is not None
and message_id in existing_dest_msg_ids
):
safe_print(f"[{target_folder}] SKIP (already present) | {size_str:<8} | {display_subject}")
continue # Skip to next file
# Upload to target folder.
# Keep server-side duplicate checks enabled to avoid creating duplicates for emails
# that exist on the destination but are not in our local progress cache.
upload_result = upload_email(dest, target_folder, raw_content, date_str, message_id, flags)
# Only record progress when upload succeeds or email already exists.
# Failed uploads should not be marked as processed to allow retry on next run.
if upload_result in (UploadResult.SUCCESS, UploadResult.ALREADY_EXISTS):
restore_cache.record_progress(
message_id=message_id,
folder_name=target_folder,
existing_dest_msg_ids=existing_dest_msg_ids,
existing_dest_msg_ids_lock=existing_dest_msg_ids_lock,
progress_cache_path=progress_cache_path,
progress_cache_data=progress_cache_data,
progress_cache_lock=progress_cache_lock,
dest_host=dest_host,
dest_user=dest_user,
log_fn=safe_print,
)
if upload_result == UploadResult.ALREADY_EXISTS:
safe_print(f"[{target_folder}] SKIP (exists) | {size_str:<8} | {display_subject}")
# Full restore preserves legacy behavior: sync flags on existing email if requested
if full_restore and apply_flags and flags and message_id:
sync_flags_on_existing(dest, target_folder, message_id, flags, size)
elif upload_result == UploadResult.SUCCESS:
safe_print(f"[{target_folder}] UPLOADED | {size_str:<8} | {display_subject}")
# Show applied flags in same style as labels
if flags:
for flag in flags.split():
safe_print(f" -> Applied flag: {flag}")
else: # FAILURE
safe_print(f"[{target_folder}] FAILED | {size_str:<8} | {display_subject}")
# Apply remaining Gmail labels:
# - Full restore: apply/sync labels even for existing emails
# - Incremental (default): apply labels only for newly uploaded emails
if apply_labels and remaining_labels and (upload_result == UploadResult.SUCCESS or full_restore):
for label in remaining_labels:
label_folder = label_to_folder(label)
# Skip if this is the same as the target folder
if label_folder == target_folder:
continue
# Skip system folders we can't upload to
if label_folder in (
imap_common.GMAIL_ALL_MAIL,
imap_common.GMAIL_SPAM,
imap_common.GMAIL_TRASH,
):
continue
try:
# Ensure label folder exists
imap_common.ensure_folder_exists(dest, label_folder)
# Select and check for duplicate
dest.select(f'"{label_folder}"')
if not email_exists_in_folder(dest, message_id):
append_success = imap_common.append_email(
dest,
label_folder,
raw_content,
date_str,
flags,
ensure_folder=False,
)
if append_success:
# Look up the correct set for label_folder
label_folder_msg_ids: Optional[set[str]] = None
if existing_dest_msg_ids_by_folder is not None:
with existing_dest_msg_ids_lock:
label_folder_msg_ids = existing_dest_msg_ids_by_folder.get(label_folder)
# Lazy-load per-folder progress cache if not yet loaded
if label_folder_msg_ids is None:
built: set[str] = set()
if (
progress_cache_data is not None
and progress_cache_lock is not None
and dest_host
and dest_user
):
built = restore_cache.get_cached_message_ids(
progress_cache_data,
progress_cache_lock,
dest_host,
dest_user,
label_folder,
)
with existing_dest_msg_ids_lock:
existing_dest_msg_ids_by_folder.setdefault(label_folder, built)
label_folder_msg_ids = existing_dest_msg_ids_by_folder[label_folder]
restore_cache.record_progress(
message_id=message_id,
folder_name=label_folder,
existing_dest_msg_ids=label_folder_msg_ids,
existing_dest_msg_ids_lock=existing_dest_msg_ids_lock,
progress_cache_path=progress_cache_path,
progress_cache_data=progress_cache_data,
progress_cache_lock=progress_cache_lock,
dest_host=dest_host,
dest_user=dest_user,
log_fn=safe_print,
)
safe_print(f" -> Applied label: {label}")
else:
safe_print(f" -> Failed to apply label {label} (will retry on next restore)")
# If email exists in this label folder, sync flags (full restore only)
elif full_restore and apply_flags and flags:
sync_flags_on_existing(dest, label_folder, message_id, flags, size)
except Exception as e:
safe_print(f" -> Error applying label {label}: {e}")
except Exception as e:
safe_print(f"Error processing {filename}: {e}")
def get_local_message_ids(local_folder_path):
"""
Get a set of Message-IDs from all .eml files in a local folder.
Used for destination deletion sync.
"""
message_ids = set()
eml_files = get_eml_files(local_folder_path)
for file_path, _filename in eml_files:
message_id, _, _, _ = parse_eml_file(file_path)
if message_id:
message_ids.add(message_id)
return message_ids
def delete_orphan_emails_from_dest(imap_conn, folder_name, local_msg_ids):
"""
Delete emails from destination folder that don't exist in local backup.
Returns count of deleted emails.
"""
import re
deleted_count = 0
try:
imap_conn.select(f'"{folder_name}"', readonly=False)
resp, data = imap_conn.uid("search", None, "ALL")
if resp != "OK" or not data or not data[0]:
return 0
uids = data[0].split()
if not uids:
return 0
# Check each UID's Message-ID against local backup
batch_size = 100
uids_to_delete = []
for i in range(0, len(uids), batch_size):
batch = uids[i : i + batch_size]
uid_range = b",".join(batch)
try:
resp, items = imap_conn.uid("fetch", uid_range, "(UID BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])")
if resp != "OK":
continue
for item in items:
if isinstance(item, tuple) and len(item) >= 2:
# Extract UID from response
meta_str = (
item[0].decode("utf-8", errors="ignore") if isinstance(item[0], bytes) else str(item[0])
)
uid_match = re.search(r"UID\s+(\d+)", meta_str)
if not uid_match:
continue
uid = uid_match.group(1)
# Extract Message-ID
msg_id = imap_common.extract_message_id(item[1])
# If not in local backup, mark for deletion
if msg_id and msg_id not in local_msg_ids:
uids_to_delete.append(uid)
except Exception:
continue
# Delete orphan emails
for uid in uids_to_delete:
try:
imap_conn.uid("store", uid, "+FLAGS", "(\\Deleted)")
deleted_count += 1
except Exception:
pass
if deleted_count > 0:
imap_conn.expunge()
safe_print(f"[{folder_name}] Deleted {deleted_count} orphan emails from destination")
except Exception as e:
safe_print(f"Error deleting orphans from {folder_name}: {e}")
return deleted_count
def restore_folder(
folder_name,
local_folder_path,
dest_conf,
manifest,
apply_labels,
apply_flags,
dest_delete=False,
full_restore: bool = False,
cache_root: Optional[str] = None,
):
"""
Restore all emails from a local folder to the destination IMAP server.
"""
safe_print(f"--- Restoring Folder: {folder_name} ---")
eml_files = get_eml_files(local_folder_path)
if not eml_files:
safe_print(f"No .eml files found in {folder_name}")
# Even if empty, check for orphans to delete
if dest_delete:
dest = imap_common.get_imap_connection_from_conf(dest_conf)
if dest:
delete_orphan_emails_from_dest(dest, folder_name, set())
dest.logout()
return
safe_print(f"Found {len(eml_files)} emails to restore.")
cache_root = cache_root or local_folder_path
cache_path = restore_cache.get_dest_index_cache_path(cache_root, dest_conf["host"], dest_conf["user"])
cache_data: dict = restore_cache.load_dest_index_cache(cache_path)
cache_lock = threading.Lock()
safe_print(f"Using progress cache: {cache_path}")
# Incremental mode uses cached Message-IDs to skip already-processed emails.
existing_dest_msg_ids_by_folder: Optional[dict[str, set[str]]] = {folder_name: set()}
existing_dest_msg_ids_lock: Optional[threading.Lock] = threading.Lock()
try:
existing_dest_msg_ids_by_folder[folder_name] = restore_cache.get_cached_message_ids(
cache_data,
cache_lock,
dest_conf["host"],
dest_conf["user"],
folder_name,
)
safe_print(f"Cache has {len(existing_dest_msg_ids_by_folder[folder_name])} Message-IDs for this folder.")
except Exception as e:
# Fall back to an empty cache for this folder if reading cached Message-IDs fails.
safe_print(f"Warning: Failed to load cached Message-IDs for folder '{folder_name}': {e}")
existing_dest_msg_ids_by_folder[folder_name] = set()
# If dest_delete enabled, get local Message-IDs for comparison
local_msg_ids = None
if dest_delete:
safe_print("Building local Message-ID index for sync...")
local_msg_ids = get_local_message_ids(local_folder_path)
safe_print(f"Found {len(local_msg_ids)} unique Message-IDs in local backup.")
# Pre-fetch destination Message-IDs and filter duplicates (non-Gmail mode only)
gmail_mode = folder_name == "__GMAIL_MODE__"
files_to_restore = eml_files
if not gmail_mode:
dest_msg_ids = set()
try:
dest_tmp = imap_common.get_imap_connection_from_conf(dest_conf)
if dest_tmp:
# Ensure folder exists before selecting
if folder_name.upper() != "INBOX":
try:
dest_tmp.create(f'"{folder_name}"')
except Exception:
pass
dest_tmp.select(f'"{folder_name}"')
dest_msg_ids = set(imap_common.get_message_ids_in_folder(dest_tmp).values())
dest_tmp.logout()
except Exception:
dest_msg_ids = set()
safe_print(f"{len(dest_msg_ids)} existing messages in destination.")
# Pre-filter files to skip duplicates
if dest_msg_ids:
safe_print("Pre-filtering duplicates...")
files_to_restore = []
skipped = 0
for file_path, filename in eml_files:
msg_id = extract_message_id_from_eml(file_path)
if msg_id and msg_id in dest_msg_ids:
skipped += 1
else:
files_to_restore.append((file_path, filename))
safe_print(f"Skipping {skipped} duplicates, {len(files_to_restore)} to restore.")
if not files_to_restore:
safe_print("No new emails to restore.")
return
safe_print(f"Starting parallel restore of {len(files_to_restore)} emails...")
# Create batches
batches = [files_to_restore[i : i + BATCH_SIZE] for i in range(0, len(files_to_restore), BATCH_SIZE)]
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = []
for batch in batches:
futures.append(
executor.submit(
process_restore_batch,
batch,
folder_name,
dest_conf,
manifest,
apply_labels,
apply_flags,
full_restore,
existing_dest_msg_ids_by_folder,
existing_dest_msg_ids_lock,
cache_data,
cache_lock,
cache_path,
dest_conf.get("host"),
dest_conf.get("user"),
)
)
for future in concurrent.futures.as_completed(futures):
try:
future.result()
except Exception as e:
safe_print(f"Batch error: {e}")
# Delete orphan emails from destination if enabled
if dest_delete and local_msg_ids is not None:
safe_print("Syncing destination: removing emails not in local backup...")
dest = imap_session.ensure_connection(None, dest_conf)
if dest:
delete_orphan_emails_from_dest(dest, folder_name, local_msg_ids)
dest.logout()
# Force-flush progress cache at end.
restore_cache.maybe_save_dest_index_cache(cache_path, cache_data, cache_lock, force=True)
def restore_gmail_with_labels(local_path, dest_conf, manifest, apply_flags, full_restore: bool = False):
"""
Special restoration mode for Gmail: Upload emails to their first label folder
and then apply additional labels from the manifest.
This avoids putting all emails in INBOX - emails only appear in INBOX
if they originally had the INBOX label.
"""
# Find the All Mail folder in the backup
all_mail_path = os.path.join(local_path, "[Gmail]", "All Mail")
if not os.path.exists(all_mail_path):
# Try without subfolder structure
all_mail_path = local_path
safe_print("--- Gmail Restore with Labels ---")
safe_print(f"Source path: {all_mail_path}")
safe_print(f"Entries in manifest: {len(manifest)}")
eml_files = get_eml_files(all_mail_path)
if not eml_files:
safe_print("No .eml files found for restoration.")
return
safe_print(f"Found {len(eml_files)} emails to restore.\n")
# Process in batches
total = len(eml_files)
start_time = time.time()
batches = [eml_files[i : i + BATCH_SIZE] for i in range(0, len(eml_files), BATCH_SIZE)]
cache_path = restore_cache.get_dest_index_cache_path(local_path, dest_conf["host"], dest_conf["user"])
progress_cache_data: dict = restore_cache.load_dest_index_cache(cache_path)
progress_cache_lock = threading.Lock()
safe_print(f"Using progress cache: {cache_path}")
safe_print(
"Cache will be populated as restore runs (no up-front destination indexing). "
"First run may still do per-message duplicate checks; subsequent runs will skip quickly."
)
existing_dest_msg_ids_by_folder: Optional[dict[str, set[str]]] = {} # lazily loaded per folder
existing_dest_msg_ids_lock: Optional[threading.Lock] = threading.Lock()
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = []
for batch in batches:
# For Gmail, we use a special folder marker to indicate gmail-mode
# The process_restore_batch will determine the target folder per-email
# based on the manifest labels
futures.append(
executor.submit(
process_restore_batch,
batch,
"__GMAIL_MODE__", # Special marker - target determined per-email from manifest
dest_conf,
manifest,
True, # apply_labels
apply_flags, # apply_flags
full_restore,
existing_dest_msg_ids_by_folder,
existing_dest_msg_ids_lock,
progress_cache_data,
progress_cache_lock,
cache_path,
dest_conf.get("host"),
dest_conf.get("user"),
)
)
completed = 0
for future in concurrent.futures.as_completed(futures):
try:
future.result()
completed += 1
elapsed = time.time() - start_time
progress = (completed / len(batches)) * 100
safe_print(f"Progress: {progress:.1f}% ({elapsed:.0f}s elapsed)")
except Exception as e:
safe_print(f"Batch error: {e}")
elapsed = time.time() - start_time
safe_print(f"\nRestore completed in {elapsed:.1f}s")
# Force-flush progress cache at end.
restore_cache.maybe_save_dest_index_cache(cache_path, progress_cache_data, progress_cache_lock, force=True)
def get_backup_folders(local_path):
"""
Scan the backup directory and return list of folder paths.
Returns list of (folder_name, local_path) tuples.
"""
folders = []
def scan_dir(path, prefix=""):
try:
for item in os.listdir(path):
item_path = os.path.join(path, item)
if os.path.isdir(item_path):
# Check if this directory contains .eml files
has_eml = any(
f.endswith(".eml") for f in os.listdir(item_path) if os.path.isfile(os.path.join(item_path, f))
)
folder_name = f"{prefix}{item}" if prefix else item
if has_eml:
folders.append((folder_name, item_path))
# Recurse into subdirectories
scan_dir(item_path, f"{folder_name}/")
except Exception:
pass
scan_dir(local_path)
return folders
def main():
parser = argparse.ArgumentParser(description="Restore IMAP emails from local .eml files.")
# Source (Local Path)
env_path = os.getenv("BACKUP_LOCAL_PATH")
parser.add_argument(
"--src-path",
default=env_path,
required=not bool(env_path),
help="Local source path containing backup (or BACKUP_LOCAL_PATH)",
)
# Destination
default_dest_host = os.getenv("DEST_IMAP_HOST")
default_dest_user = os.getenv("DEST_IMAP_USERNAME")
default_dest_pass = os.getenv("DEST_IMAP_PASSWORD")
default_dest_client_id = os.getenv("DEST_OAUTH2_CLIENT_ID")
parser.add_argument(
"--dest-host",
default=default_dest_host,
required=not bool(default_dest_host),
help="Destination IMAP Server (or DEST_IMAP_HOST)",
)
parser.add_argument(
"--dest-user",
default=default_dest_user,
required=not bool(default_dest_user),
help="Destination Username (or DEST_IMAP_USERNAME)",
)
dest_auth_required = not bool(default_dest_pass or default_dest_client_id)
dest_auth = parser.add_mutually_exclusive_group(required=dest_auth_required)
dest_auth.add_argument(
"--dest-pass",
default=default_dest_pass,
help="Destination Password (or DEST_IMAP_PASSWORD)",
)
dest_auth.add_argument(
"--dest-oauth2-client-id",
default=default_dest_client_id,
dest="dest_client_id",
help="Destination OAuth2 Client ID (or DEST_OAUTH2_CLIENT_ID)",
)
dest_auth.add_argument(
"--dest-client-id",
default=default_dest_client_id,
dest="dest_client_id",
help=argparse.SUPPRESS,
)
parser.add_argument(
"--dest-oauth2-client-secret",
default=os.getenv("DEST_OAUTH2_CLIENT_SECRET"),
dest="dest_client_secret",
help="Destination OAuth2 Client Secret (if required) (or DEST_OAUTH2_CLIENT_SECRET)",
)
parser.add_argument(
"--dest-client-secret",
default=os.getenv("DEST_OAUTH2_CLIENT_SECRET"),
dest="dest_client_secret",
help=argparse.SUPPRESS,
)
# Config
parser.add_argument(
"--workers",
type=int,
default=int(os.getenv("MAX_WORKERS", 4)),
help="Thread count (default: 4)",
)
parser.add_argument(
"--batch",
type=int,
default=int(os.getenv("BATCH_SIZE", 10)),
help="Emails per batch",
)
# Gmail Labels
env_apply_labels = os.getenv("APPLY_LABELS", "false").lower() == "true"
parser.add_argument(
"--apply-labels",
action="store_true",
default=env_apply_labels,
help="Apply Gmail labels from labels_manifest.json",
)
env_apply_flags = os.getenv("APPLY_FLAGS", "false").lower() == "true"
parser.add_argument(
"--apply-flags",
action="store_true",
default=env_apply_flags,
help="Apply IMAP flags (read/starred/answered/draft) from manifest",
)
env_gmail_mode = os.getenv("GMAIL_MODE", "false").lower() == "true"
parser.add_argument(
"--gmail-mode",
action="store_true",
default=env_gmail_mode,
help="Gmail restore mode: Upload to INBOX and apply labels + flags from manifest",
)
env_full_restore = os.getenv("FULL_RESTORE", "false").lower() == "true"
parser.add_argument(
"--full-restore",
action="store_true",
default=env_full_restore,
help="Force full restore (legacy): process all emails and sync labels/flags for already-present messages.",
)
# Sync mode: delete from dest emails not in local backup
env_dest_delete = os.getenv("DEST_DELETE", "false").lower() == "true"
parser.add_argument(
"--dest-delete",
action="store_true",
default=env_dest_delete,
help="Delete emails from destination that don't exist in local backup (sync mode)",
)
# Optional folder filter
parser.add_argument("folder", nargs="?", help="Specific folder to restore")
args = parser.parse_args()
dest_use_oauth2 = bool(args.dest_client_id)
global MAX_WORKERS, BATCH_SIZE
MAX_WORKERS = args.workers
BATCH_SIZE = args.batch
# Acquire OAuth2 token if configured
dest_oauth2_token = None
dest_oauth2_provider = None
if dest_use_oauth2:
dest_oauth2_provider = imap_oauth2.detect_oauth2_provider(args.dest_host)
if not dest_oauth2_provider:
print(f"Error: Could not detect OAuth2 provider from host '{args.dest_host}'.")
sys.exit(1)
print(f"Acquiring OAuth2 token for destination ({dest_oauth2_provider})...")
dest_oauth2_token = imap_oauth2.acquire_oauth2_token_for_provider(
dest_oauth2_provider, args.dest_client_id, args.dest_user, args.dest_client_secret
)
if not dest_oauth2_token:
print("Error: Failed to acquire OAuth2 token for destination.")
sys.exit(1)
print("Destination OAuth2 token acquired successfully.\n")
# Use a dict so token updates propagate to worker threads
dest_conf = {
"host": args.dest_host,
"user": args.dest_user,
"password": args.dest_pass,
"oauth2_token": dest_oauth2_token,
"oauth2": {
"provider": dest_oauth2_provider,
"client_id": args.dest_client_id,
"email": args.dest_user,
"client_secret": args.dest_client_secret,
}
if dest_use_oauth2
else None,
}
# Expand path
local_path = os.path.expanduser(args.src_path)
if not os.path.exists(local_path):
print(f"Error: Source path does not exist: {local_path}")
sys.exit(1)
# Load manifest(s) if needed
manifest = {}
apply_labels = args.apply_labels or args.gmail_mode
apply_flags = args.apply_flags or args.gmail_mode
if apply_labels or apply_flags:
# Try to load labels manifest first (contains both labels and flags for Gmail backups)
manifest = load_labels_manifest(local_path)
# If no labels manifest, try flags-only manifest
if not manifest and apply_flags:
manifest = load_flags_manifest(local_path)
if not manifest:
print("Warning: No manifest found. Labels/flags will not be applied.")
print("\n--- Configuration Summary ---")
print(f"Source Path : {local_path}")
print(f"Destination Host: {args.dest_host}")
print(f"Destination User: {args.dest_user}")
print(
f"Destination Auth: {'OAuth2/' + dest_oauth2_provider + ' (XOAUTH2)' if dest_use_oauth2 else 'Basic (password)'}"
)
print(f"Workers : {args.workers}")
if args.gmail_mode:
print("Mode : Gmail Restore with Labels + Flags")
elif args.folder:
print(f"Target Folder : {args.folder}")
if apply_labels:
print(f"Apply Labels : Yes ({len(manifest)} mappings)")
if apply_flags:
print("Apply Flags : Yes (read/starred/answered/draft)")
if args.dest_delete:
print("Dest Delete : Yes (remove orphans from destination)")
print(
f"Restore Mode : {'Full (all emails)' if args.full_restore else 'Incremental (new emails only, use --full-restore to restore all)'}"
)
print("-----------------------------\n")
try:
# Test connection
dest = imap_common.get_imap_connection_from_conf(dest_conf)
if not dest:
print("Error: Could not connect to destination server.")
sys.exit(1)
if args.gmail_mode:
dest.logout()
# Special Gmail mode
restore_gmail_with_labels(local_path, dest_conf, manifest, apply_flags, full_restore=args.full_restore)
dest = None # Connection handled by restore_gmail_with_labels
elif args.folder:
# Restore specific folder
folder_path = os.path.join(local_path, args.folder.replace("/", os.sep))
if not os.path.exists(folder_path):
print(f"Error: Folder not found: {folder_path}")
sys.exit(1)
restore_folder(
args.folder,
folder_path,
dest_conf,
manifest,
apply_labels,
apply_flags,
args.dest_delete,
full_restore=args.full_restore,
cache_root=local_path,
)
dest.logout()
else:
# Restore all folders
folders = get_backup_folders(local_path)
if not folders:
print("No backup folders found.")
sys.exit(1)
print(f"Found {len(folders)} folders to restore.\n")
for folder_name, folder_path in folders:
# Skip manifest files
if folder_name in ("labels_manifest.json", "flags_manifest.json"):
continue
# Proactively refresh OAuth2 token and ensure connection is healthy between folders
dest = imap_session.ensure_connection(dest, dest_conf)
if not dest:
print("Fatal: Could not reconnect to destination IMAP server. Aborting.")
sys.exit(1)
restore_folder(
folder_name,
folder_path,
dest_conf,
manifest,
apply_labels,
apply_flags,
args.dest_delete,
full_restore=args.full_restore,
cache_root=local_path,
)
dest.logout()
print("\nRestore completed successfully.")
except KeyboardInterrupt:
print("\nRestore interrupted by user.")
sys.exit(0)
except Exception as e:
print(f"Fatal Error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()