Implement email restoration functionality with Gmail labels support

- Added restore_imap_emails.py for restoring emails from local backups to IMAP accounts.
- Implemented loading of labels and flags from a manifest file.
- Added functionality to upload emails while preserving original dates and applying Gmail labels.
- Introduced multithreading for efficient email uploads.
- Created unit tests for email parsing, restoration, and configuration validation.
- Enhanced existing tests for Gmail labels preservation in backup process.
This commit is contained in:
Javier Callico 2026-01-28 18:31:19 -05:00
parent 2c230dcf64
commit c3a26c30bf
6 changed files with 1559 additions and 41 deletions

View File

@ -153,5 +153,5 @@ jobs:
- name: Check imports
run: |
cd src && python -c "import imap_common; import migrate_imap_emails; import backup_imap_emails; import count_imap_emails; import compare_imap_folders"
cd src && python -c "import imap_common; import migrate_imap_emails; import backup_imap_emails; import restore_imap_emails; import count_imap_emails; import compare_imap_folders"
echo "All imports successful"

113
README.md
View File

@ -40,6 +40,13 @@ This repository contains a set of Python scripts designed to migrate emails betw
- **Incremental**: Skips emails that have already been downloaded (based on UID) so you can run it periodically to fetch new messages.
- **Gmail Labels Preservation**: Creates a `labels_manifest.json` file mapping each email's Message-ID to its Gmail labels, enabling proper restoration with labels intact.
5. **`restore_imap_emails.py`** (The Restore)
- Uploads emails from a local backup to an IMAP server.
- **Format**: Reads `.eml` files and uploads them preserving original dates.
- **Structure**: Recreates the folder hierarchy on the destination server.
- **Incremental**: Skips emails that already exist (based on Message-ID and size).
- **Gmail Labels Restoration**: Applies labels from `labels_manifest.json` to recreate the original Gmail label structure.
## Getting Started
### 1. Prerequisites
@ -199,10 +206,20 @@ python3 backup_imap_emails.py --dest-path "./my_backup" "[Gmail]/Sent Mail"
```
### 6. Gmail Backup with Labels Preservation
When backing up a Gmail account, use `--preserve-labels` to create a manifest file that maps each email to its Gmail labels. This is essential for restoring emails to another Gmail account with labels intact.
When backing up a Gmail account, use `--gmail-mode` for the recommended workflow. This backs up `[Gmail]/All Mail` (no duplicates) and creates a labels manifest for restoration.
```bash
# Recommended: Backup All Mail with labels manifest
# Recommended: Use --gmail-mode for simplest workflow
python3 backup_imap_emails.py \
--src-host "imap.gmail.com" \
--src-user "you@gmail.com" \
--src-pass "your-app-password" \
--dest-path "./gmail_backup" \
--gmail-mode
```
This is equivalent to the more verbose:
```bash
python3 backup_imap_emails.py \
--src-host "imap.gmail.com" \
--src-user "you@gmail.com" \
@ -233,25 +250,110 @@ python3 backup_imap_emails.py \
**How it works:**
1. The script scans ALL folders in your Gmail account to identify which emails have which labels
2. Creates a `labels_manifest.json` file mapping each email's `Message-ID` to its labels
2. Creates a `labels_manifest.json` file mapping each email's `Message-ID` to its labels and IMAP flags
3. Downloads all emails from `[Gmail]/All Mail` (contains every email once, no duplicates)
**Example `labels_manifest.json`:**
```json
{
"<CAExample123@mail.gmail.com>": ["INBOX", "Work", "Projects/2024"],
"<CAExample456@mail.gmail.com>": ["Sent Mail", "Personal", "Starred"]
"<CAExample123@mail.gmail.com>": {
"labels": ["INBOX", "Work", "Projects/2024"],
"flags": ["\\Seen"]
},
"<CAExample456@mail.gmail.com>": {
"labels": ["Sent Mail", "Personal"],
"flags": ["\\Seen", "\\Flagged"]
}
}
```
**Preserved IMAP Flags:**
- `\Seen` - Email has been read
- `\Flagged` - Email is starred/flagged
- `\Answered` - Email has been replied to
- `\Draft` - Email is a draft
**Benefits:**
- ✅ No duplicate emails on disk (each email saved once)
- ✅ Labels are preserved for restoration
- ✅ Read/unread and starred status is preserved
- ✅ Includes system labels (INBOX, Sent Mail, Starred) and user labels
- ✅ Progress reporting for large accounts
**Note:** Gmail labels like "Important" that are auto-managed by Gmail are excluded from the manifest as they cannot be reliably restored.
### 7. Backup with Flags Only (Non-Gmail)
For non-Gmail servers, you can preserve read/starred status with `--preserve-flags`:
```bash
python3 backup_imap_emails.py \
--src-host "imap.example.com" \
--src-user "you@example.com" \
--src-pass "your-password" \
--dest-path "./my_backup" \
--preserve-flags \
"INBOX"
```
This creates a `flags_manifest.json` with the status of each email.
### 8. Restore Backup to IMAP Server
Restore emails from a local backup to any IMAP server.
```bash
# Restore all folders from backup
python3 restore_imap_emails.py \
--src-path "./my_backup" \
--dest-host "imap.gmail.com" \
--dest-user "you@gmail.com" \
--dest-pass "your-app-password"
# Restore with flags (read/starred status)
python3 restore_imap_emails.py \
--src-path "./my_backup" \
--dest-host "imap.example.com" \
--dest-user "you@example.com" \
--dest-pass "your-password" \
--apply-flags
# Restore a specific folder
python3 restore_imap_emails.py \
--src-path "./my_backup" \
--dest-host "imap.gmail.com" \
--dest-user "you@gmail.com" \
--dest-pass "your-app-password" \
"INBOX"
```
### 9. Gmail Restore with Labels
Restore a Gmail backup with full label structure using `--gmail-mode`:
```bash
python3 restore_imap_emails.py \
--src-path "./gmail_backup" \
--dest-host "imap.gmail.com" \
--dest-user "newaccount@gmail.com" \
--dest-pass "your-app-password" \
--gmail-mode
```
**How Gmail restore works:**
1. Reads emails from the backup (typically `[Gmail]/All Mail`)
2. Uploads each email to INBOX with original flags (read/starred/etc)
3. Looks up the Message-ID in `labels_manifest.json`
4. Copies the email to each label folder (e.g., "Work", "Personal", "Projects/2024")
**Alternatively**, restore folders individually with labels and flags applied:
```bash
python3 restore_imap_emails.py \
--src-path "./gmail_backup" \
--dest-host "imap.gmail.com" \
--dest-user "newaccount@gmail.com" \
--dest-pass "your-app-password" \
--apply-labels \
--apply-flags
```
## Troubleshooting
- **"Too many simultaneous connections"**:
@ -342,6 +444,7 @@ make ci
|-----------|-------------|
| `test_migrate_imap_emails.py` | Email migration tests (basic, duplicates, deletion, folders) |
| `test_backup_imap_emails.py` | Backup functionality tests |
| `test_restore_imap_emails.py` | Restore functionality tests |
| `test_count_imap_emails.py` | Email counting tests |
| `test_compare_imap_folders.py` | Folder comparison tests |
| `test_imap_common.py` | Shared utility function tests |

View File

@ -184,31 +184,38 @@ def is_gmail_label_folder(folder_name):
return False
def get_message_ids_in_folder(imap_conn, folder_name, progress_callback=None):
# Standard IMAP flags that can be preserved during migration
# \Recent is session-specific and cannot be set by clients
# \Deleted should not be preserved as it marks messages for removal
PRESERVABLE_FLAGS = {"\\Seen", "\\Answered", "\\Flagged", "\\Draft"}
def get_message_info_in_folder(imap_conn, folder_name, progress_callback=None):
"""
Returns a set of Message-IDs for all emails in a given folder.
Returns a dict of Message-IDs and their IMAP flags for all emails in a folder.
Returns: { "message-id": {"flags": ["\\Seen", "\\Flagged", ...]}, ... }
Optional progress_callback(current, total) for progress reporting.
"""
message_ids = set()
message_info = {}
try:
imap_conn.select(f'"{folder_name}"', readonly=True)
except Exception as e:
safe_print(f"Could not select folder {folder_name}: {e}")
return message_ids
return message_info
try:
resp, data = imap_conn.uid("search", None, "ALL")
if resp != "OK" or not data or not data[0]:
return message_ids
return message_info
uids = data[0].split()
if not uids:
return message_ids
return message_info
total_uids = len(uids)
# Fetch Message-IDs in batches - use larger batch for header-only fetches
# Fetch Message-IDs and FLAGS in batches - use larger batch for header-only fetches
batch_size = 200
for i in range(0, len(uids), batch_size):
batch = uids[i : i + batch_size]
@ -219,12 +226,24 @@ def get_message_ids_in_folder(imap_conn, folder_name, progress_callback=None):
progress_callback(min(i + batch_size, total_uids), total_uids)
try:
resp, items = imap_conn.uid("fetch", uid_range, "(BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])")
# Fetch both Message-ID header and FLAGS
resp, items = imap_conn.uid("fetch", uid_range, "(FLAGS BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])")
if resp != "OK":
continue
# Parse response - items come in pairs for each message
for item in items:
if isinstance(item, tuple) and len(item) >= 2:
# First element contains UID and FLAGS info
meta_str = item[0].decode("utf-8", errors="ignore") if isinstance(item[0], bytes) else str(item[0])
# Extract all preservable flags from the metadata
flags = []
for flag in PRESERVABLE_FLAGS:
if flag in meta_str:
flags.append(flag)
# Second element contains the header
header_data = item[1]
if isinstance(header_data, bytes):
header_str = header_data.decode("utf-8", errors="ignore")
@ -233,7 +252,7 @@ def get_message_ids_in_folder(imap_conn, folder_name, progress_callback=None):
if line.lower().startswith("message-id:"):
msg_id = line.split(":", 1)[1].strip()
if msg_id:
message_ids.add(msg_id)
message_info[msg_id] = {"flags": flags}
break
except Exception as e:
safe_print(f"Error fetching batch in {folder_name}: {e}")
@ -247,16 +266,26 @@ def get_message_ids_in_folder(imap_conn, folder_name, progress_callback=None):
except Exception as e:
safe_print(f"Error searching folder {folder_name}: {e}")
return message_ids
return message_info
def get_message_ids_in_folder(imap_conn, folder_name, progress_callback=None):
"""
Returns a set of Message-IDs for all emails in a given folder.
This is a convenience wrapper around get_message_info_in_folder.
Optional progress_callback(current, total) for progress reporting.
"""
info = get_message_info_in_folder(imap_conn, folder_name, progress_callback)
return set(info.keys())
def build_labels_manifest(imap_conn, local_path):
"""
Builds a manifest mapping Message-IDs to their Gmail labels.
Builds a manifest mapping Message-IDs to their Gmail labels and IMAP flags.
Scans all folders (labels) in the account and records which Message-IDs
appear in each label.
appear in each label, plus their flags from [Gmail]/All Mail.
Returns a dict: { "message-id": ["Label1", "Label2", ...], ... }
Returns a dict: { "message-id": {"labels": ["Label1", ...], "flags": ["\\Seen", ...]}, ... }
Saves the manifest to labels_manifest.json in the backup directory.
"""
import time
@ -277,6 +306,38 @@ def build_labels_manifest(imap_conn, local_path):
safe_print(f"Error listing folders: {e}")
return manifest
# First, scan [Gmail]/All Mail to get the authoritative flags for all emails
safe_print("[0/N] Scanning [Gmail]/All Mail for flags (read/starred/etc)...")
all_mail_start = time.time()
def all_mail_progress_cb(current, total):
elapsed = time.time() - start_time
print(
f"\r Progress: {current}/{total} emails scanned (elapsed: {elapsed:.0f}s) ",
end="",
flush=True,
)
all_mail_info = get_message_info_in_folder(imap_conn, "[Gmail]/All Mail", all_mail_progress_cb)
print() # New line after progress
# Initialize manifest with flags from All Mail
for msg_id, info in all_mail_info.items():
manifest[msg_id] = {"labels": [], "flags": info.get("flags", [])}
all_mail_elapsed = time.time() - all_mail_start
# Count flag statistics
read_count = sum(1 for m in manifest.values() if "\\Seen" in m.get("flags", []))
flagged_count = sum(1 for m in manifest.values() if "\\Flagged" in m.get("flags", []))
safe_print(f" -> {len(all_mail_info)} emails scanned ({all_mail_elapsed:.1f}s)")
safe_print(f" -> Read: {read_count}, Unread: {len(manifest) - read_count}, Starred: {flagged_count}\n")
# Keep connection alive
try:
imap_conn.noop()
except Exception:
pass
# Parse folder names and filter to label folders
label_folders = []
for f_info in folders:
@ -322,9 +383,10 @@ def build_labels_manifest(imap_conn, local_path):
for msg_id in message_ids:
if msg_id not in manifest:
manifest[msg_id] = []
if label_name not in manifest[msg_id]:
manifest[msg_id].append(label_name)
# Email not in All Mail (rare, but handle it)
manifest[msg_id] = {"labels": [], "flags": []}
if label_name not in manifest[msg_id]["labels"]:
manifest[msg_id]["labels"].append(label_name)
folder_elapsed = time.time() - folder_start
total_emails_scanned += len(message_ids)
@ -332,10 +394,13 @@ def build_labels_manifest(imap_conn, local_path):
# Summary
total_elapsed = time.time() - start_time
read_count = sum(1 for m in manifest.values() if "\\Seen" in m.get("flags", []))
flagged_count = sum(1 for m in manifest.values() if "\\Flagged" in m.get("flags", []))
safe_print("\nManifest building complete:")
safe_print(f" - Folders scanned: {total_folders}")
safe_print(f" - Folders scanned: {total_folders + 1}") # +1 for All Mail
safe_print(f" - Total email-label mappings: {total_emails_scanned}")
safe_print(f" - Unique emails with labels: {len(manifest)}")
safe_print(f" - Unique emails: {len(manifest)}")
safe_print(f" - Read: {read_count}, Unread: {len(manifest) - read_count}, Starred: {flagged_count}")
safe_print(f" - Time elapsed: {total_elapsed:.1f}s")
# Save manifest
@ -350,6 +415,90 @@ def build_labels_manifest(imap_conn, local_path):
return manifest
def build_flags_manifest(imap_conn, local_path, folders_to_scan=None):
"""
Builds a manifest mapping Message-IDs to their IMAP flags.
For non-Gmail servers, scans specified folders (or all folders if not specified).
Returns a dict: { "message-id": {"flags": ["\\Seen", ...]}, ... }
Saves the manifest to flags_manifest.json in the backup directory.
"""
import time
manifest = {}
start_time = time.time()
safe_print("--- Building Flags Manifest ---")
# Get folders to scan
if folders_to_scan:
all_folders = folders_to_scan
else:
try:
typ, folders = imap_conn.list()
if typ != "OK":
safe_print("Error: Could not list folders.")
return manifest
all_folders = [imap_common.normalize_folder_name(f) for f in folders]
except Exception as e:
safe_print(f"Error listing folders: {e}")
return manifest
total_folders = len(all_folders)
safe_print(f"Found {total_folders} folders to scan.\n")
# Scan each folder
for folder_idx, folder_name in enumerate(all_folders, 1):
folder_start = time.time()
def progress_cb(current, total):
elapsed = time.time() - start_time
print(
f"\r Progress: {current}/{total} emails scanned (elapsed: {elapsed:.0f}s) ",
end="",
flush=True,
)
safe_print(f"[{folder_idx}/{total_folders}] Scanning: {folder_name}")
folder_info = get_message_info_in_folder(imap_conn, folder_name, progress_cb)
print() # New line after progress
# Merge into manifest (keep first occurrence of flags)
for msg_id, info in folder_info.items():
if msg_id not in manifest:
manifest[msg_id] = {"flags": info.get("flags", [])}
folder_elapsed = time.time() - folder_start
safe_print(f" -> {len(folder_info)} emails ({folder_elapsed:.1f}s)")
# Keep connection alive
try:
imap_conn.noop()
except Exception:
pass
# Summary
total_elapsed = time.time() - start_time
read_count = sum(1 for m in manifest.values() if "\\Seen" in m.get("flags", []))
flagged_count = sum(1 for m in manifest.values() if "\\Flagged" in m.get("flags", []))
safe_print("\nFlags manifest building complete:")
safe_print(f" - Folders scanned: {total_folders}")
safe_print(f" - Unique emails: {len(manifest)}")
safe_print(f" - Read: {read_count}, Unread: {len(manifest) - read_count}, Flagged: {flagged_count}")
safe_print(f" - Time elapsed: {total_elapsed:.1f}s")
# Save manifest
manifest_path = os.path.join(local_path, "flags_manifest.json")
try:
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
safe_print(f"\nFlags manifest saved to: {manifest_path}")
except Exception as e:
safe_print(f"Error saving manifest: {e}")
return manifest
def load_labels_manifest(local_path):
"""
Loads an existing labels manifest from the backup directory.
@ -463,11 +612,21 @@ def main():
action="store_true",
help="Gmail only: Create a labels_manifest.json mapping Message-IDs to labels for restoration",
)
parser.add_argument(
"--preserve-flags",
action="store_true",
help="Preserve IMAP flags (read/unread, starred, answered, draft) in manifest for restoration",
)
parser.add_argument(
"--manifest-only",
action="store_true",
help="Gmail only: Build the labels manifest and exit without downloading emails",
)
parser.add_argument(
"--gmail-mode",
action="store_true",
help="Gmail backup mode: Build labels manifest and backup [Gmail]/All Mail only (recommended)",
)
parser.add_argument("folder", nargs="?", help="Specific folder to backup")
@ -511,12 +670,16 @@ def main():
print(f"Source Host : {args.src_host}")
print(f"Source User : {args.src_user}")
print(f"Destination Path: {local_path}")
if args.manifest_only:
if args.gmail_mode:
print("Mode : Gmail Backup (All Mail + Labels + Flags)")
elif args.manifest_only:
print("Mode : Manifest Only (no email download)")
elif args.folder:
print(f"Target Folder : {args.folder}")
if args.preserve_labels or args.manifest_only:
if args.preserve_labels or args.manifest_only or args.gmail_mode:
print("Preserve Labels : Yes (Gmail)")
if args.preserve_flags or args.gmail_mode:
print("Preserve Flags : Yes (read/starred/answered/draft)")
print("-----------------------------\n")
try:
@ -524,13 +687,21 @@ def main():
if not src:
sys.exit(1)
# Build labels manifest BEFORE backing up emails
# Build labels manifest BEFORE backing up emails (Gmail mode)
# This way we capture the label state at backup time
if args.preserve_labels or args.manifest_only:
if args.preserve_labels or args.manifest_only or args.gmail_mode:
print("Building Gmail labels manifest...")
print("This scans all folders to map Message-IDs to labels.\n")
print("This scans all folders to map Message-IDs to labels and flags.\n")
build_labels_manifest(src, local_path)
print("") # Blank line after manifest building
# Build flags-only manifest for non-Gmail servers
elif args.preserve_flags:
print("Building flags manifest...")
print("This scans folders to capture read/starred/etc status.\n")
# Get folders to scan
folders_to_scan = [args.folder] if args.folder else None
build_flags_manifest(src, local_path, folders_to_scan)
print("") # Blank line after manifest building
# If manifest-only mode, we're done
if args.manifest_only:
@ -542,7 +713,10 @@ def main():
print(f' python3 backup_imap_emails.py --dest-path "{local_path}" "[Gmail]/All Mail"')
sys.exit(0)
if args.folder:
# Gmail mode: backup only [Gmail]/All Mail
if args.gmail_mode:
backup_folder(src, "[Gmail]/All Mail", local_path, src_conf)
elif args.folder:
backup_folder(src, args.folder, local_path, src_conf)
else:
typ, folders = src.list()
@ -554,10 +728,14 @@ def main():
src.logout()
print("\nBackup completed successfully.")
if args.preserve_labels:
if args.preserve_labels or args.gmail_mode:
manifest_path = os.path.join(local_path, "labels_manifest.json")
print(f"\nGmail labels manifest saved to: {manifest_path}")
print("Use this file when restoring to reapply labels to emails.")
print("Use this file when restoring to reapply labels and flags to emails.")
elif args.preserve_flags:
manifest_path = os.path.join(local_path, "flags_manifest.json")
print(f"\nFlags manifest saved to: {manifest_path}")
print("Use this file when restoring to reapply read/starred status to emails.")
except KeyboardInterrupt:
print("\nBackup interrupted by user.")

729
src/restore_imap_emails.py Normal file
View File

@ -0,0 +1,729 @@
"""
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 and size).
- Parallel Processing: Uses multithreading for fast uploads.
- Date Preservation: Restores emails with their original dates.
Configuration:
DEST_IMAP_HOST, DEST_IMAP_USERNAME, DEST_IMAP_PASSWORD: Destination credentials.
BACKUP_LOCAL_PATH: Source local directory containing the backup.
Usage:
python3 restore_imap_emails.py --src-path "./my_backup" --dest-host "imap.gmail.com"
Gmail Labels Restoration:
python3 restore_imap_emails.py --src-path "./gmail_backup" --dest-host "imap.gmail.com" --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
import imap_common
# 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."""
if not hasattr(thread_local, "dest") or thread_local.dest is None:
thread_local.dest = imap_common.get_imap_connection(*dest_conf)
try:
if thread_local.dest:
thread_local.dest.noop()
except Exception:
thread_local.dest = imap_common.get_imap_connection(*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 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()
parser = BytesParser(policy=policy.default)
msg = parser.parsebytes(raw_content)
message_id = msg.get("Message-ID", "").strip()
subject = msg.get("Subject", "(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, size):
"""
Check if an email with the given Message-ID and size exists in the currently selected folder.
"""
if not message_id:
return False
try:
return imap_common.message_exists_in_folder(imap_conn, message_id, size)
except Exception:
return False
def upload_email(dest, folder_name, raw_content, date_str, message_id, subject, flags=None):
"""
Upload a single email to the destination folder.
Returns True on success, False on failure.
Args:
flags: Optional string of IMAP flags like "\\Seen" for read emails.
"""
try:
# Ensure folder exists
if folder_name.upper() != "INBOX":
try:
dest.create(f'"{folder_name}"')
except Exception:
pass # Folder may already exist
# Select folder
dest.select(f'"{folder_name}"')
# Check for duplicates
size = len(raw_content)
if message_id and email_exists_in_folder(dest, message_id, size):
return False # Already exists
# Upload with original date and flags
resp, _ = dest.append(f'"{folder_name}"', flags, date_str, raw_content)
return resp == "OK"
except Exception as e:
safe_print(f"Error uploading to {folder_name}: {e}")
return False
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 == "INBOX":
return "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):
"""
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:
try:
message_id, date_str, raw_content, subject = parse_eml_file(file_path)
if raw_content is None:
continue
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 Drafts as fallback (won't appear in INBOX)
if target_folder is None:
target_folder = "[Gmail]/Drafts"
remaining_labels = []
else:
target_folder = folder_name
remaining_labels = labels
# Upload to target folder (or check if exists)
uploaded = upload_email(dest, target_folder, raw_content, date_str, message_id, display_subject, flags)
if not uploaded:
safe_print(f"[{target_folder}] SKIP (exists) | {size_str:<8} | {display_subject}")
# Even if skipped, sync flags on existing email if requested
if apply_flags and flags and message_id:
sync_flags_on_existing(dest, target_folder, message_id, flags, size)
else:
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}")
# Apply remaining Gmail labels (always, whether uploaded or skipped)
# This ensures labels are synced even for existing emails
if apply_labels and remaining_labels:
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 ("[Gmail]/All Mail", "[Gmail]/Spam", "[Gmail]/Trash"):
continue
try:
# Ensure label folder exists
if label_folder.upper() != "INBOX":
try:
dest.create(f'"{label_folder}"')
except Exception:
pass
# Select and check for duplicate
dest.select(f'"{label_folder}"')
if not email_exists_in_folder(dest, message_id, size):
dest.append(f'"{label_folder}"', flags, date_str, raw_content)
safe_print(f" -> Applied label: {label}")
# If email exists in this label folder, sync flags
elif 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 restore_folder(folder_name, local_folder_path, dest_conf, manifest, apply_labels, apply_flags):
"""
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}")
return
safe_print(f"Found {len(eml_files)} emails to restore.")
# Create batches
batches = [eml_files[i : i + BATCH_SIZE] for i in range(0, len(eml_files), 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,
)
)
for future in concurrent.futures.as_completed(futures):
try:
future.result()
except Exception as e:
safe_print(f"Batch error: {e}")
def restore_gmail_with_labels(local_path, dest_conf, manifest, apply_flags):
"""
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)]
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
)
)
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")
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, help="Local source path containing backup")
# Destination
parser.add_argument(
"--dest-host",
default=os.getenv("DEST_IMAP_HOST"),
help="Destination IMAP Server",
)
parser.add_argument(
"--dest-user",
default=os.getenv("DEST_IMAP_USERNAME"),
help="Destination Username",
)
parser.add_argument(
"--dest-pass",
default=os.getenv("DEST_IMAP_PASSWORD"),
help="Destination Password",
)
# 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
parser.add_argument(
"--apply-labels",
action="store_true",
help="Apply Gmail labels from labels_manifest.json",
)
parser.add_argument(
"--apply-flags",
action="store_true",
help="Apply IMAP flags (read/starred/answered/draft) from manifest",
)
parser.add_argument(
"--gmail-mode",
action="store_true",
help="Gmail restore mode: Upload to INBOX and apply labels + flags from manifest",
)
# Optional folder filter
parser.add_argument("folder", nargs="?", help="Specific folder to restore")
args = parser.parse_args()
# Validate
missing = []
if not args.dest_host:
missing.append("DEST_IMAP_HOST")
if not args.dest_user:
missing.append("DEST_IMAP_USERNAME")
if not args.dest_pass:
missing.append("DEST_IMAP_PASSWORD")
if missing:
print(f"Error: Missing credentials: {', '.join(missing)}")
sys.exit(1)
if not args.src_path:
print("Error: Source path is required.")
print("Please provide --src-path or set environment variable BACKUP_LOCAL_PATH.")
sys.exit(1)
global MAX_WORKERS, BATCH_SIZE
MAX_WORKERS = args.workers
BATCH_SIZE = args.batch
dest_conf = (args.dest_host, args.dest_user, args.dest_pass)
# 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"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(f"Apply Flags : Yes (read/starred/answered/draft)")
print("-----------------------------\n")
try:
# Test connection
dest = imap_common.get_imap_connection(*dest_conf)
if not dest:
print("Error: Could not connect to destination server.")
sys.exit(1)
dest.logout()
if args.gmail_mode:
# Special Gmail mode
restore_gmail_with_labels(local_path, dest_conf, manifest, apply_flags)
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)
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
restore_folder(
folder_name,
folder_path,
dest_conf,
manifest,
apply_labels,
apply_flags,
)
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()

View File

@ -430,11 +430,11 @@ class TestGmailLabelsPreservation:
(
"OK",
[
(b"1 (BODY[HEADER.FIELDS (MESSAGE-ID)] {30}", b"Message-ID: <msg1@test.com>\r\n"),
(b"1 (FLAGS (\\Seen) BODY[HEADER.FIELDS (MESSAGE-ID)] {30}", b"Message-ID: <msg1@test.com>\r\n"),
b")",
(b"2 (BODY[HEADER.FIELDS (MESSAGE-ID)] {30}", b"Message-ID: <msg2@test.com>\r\n"),
(b"2 (FLAGS () BODY[HEADER.FIELDS (MESSAGE-ID)] {30}", b"Message-ID: <msg2@test.com>\r\n"),
b")",
(b"3 (BODY[HEADER.FIELDS (MESSAGE-ID)] {30}", b"Message-ID: <msg3@test.com>\r\n"),
(b"3 (FLAGS (\\Seen \\Answered) BODY[HEADER.FIELDS (MESSAGE-ID)] {30}", b"Message-ID: <msg3@test.com>\r\n"),
b")",
],
), # fetch result
@ -446,6 +446,35 @@ class TestGmailLabelsPreservation:
assert "<msg2@test.com>" in result
assert "<msg3@test.com>" in result
def test_get_message_info_in_folder_read_status(self, monkeypatch):
"""Test extraction of message IDs with read/unread status."""
mock_conn = MagicMock()
mock_conn.select.return_value = ("OK", [b"1"])
mock_conn.uid.side_effect = [
("OK", [b"1 2 3"]), # search result
(
"OK",
[
(b"1 (FLAGS (\\Seen) BODY[HEADER.FIELDS (MESSAGE-ID)] {30}", b"Message-ID: <msg1@test.com>\r\n"),
b")",
(b"2 (FLAGS () BODY[HEADER.FIELDS (MESSAGE-ID)] {30}", b"Message-ID: <msg2@test.com>\r\n"),
b")",
(b"3 (FLAGS (\\Seen \\Answered) BODY[HEADER.FIELDS (MESSAGE-ID)] {30}", b"Message-ID: <msg3@test.com>\r\n"),
b")",
],
), # fetch result
]
result = backup_imap_emails.get_message_info_in_folder(mock_conn, "INBOX", None)
assert "<msg1@test.com>" in result
assert "\\Seen" in result["<msg1@test.com>"]["flags"] # Has \Seen flag
assert "<msg2@test.com>" in result
assert result["<msg2@test.com>"]["flags"] == [] # No flags
assert "<msg3@test.com>" in result
assert "\\Seen" in result["<msg3@test.com>"]["flags"] # Has \Seen flag
assert "\\Answered" in result["<msg3@test.com>"]["flags"] # Also has \Answered
def test_get_message_ids_in_folder_with_progress(self, monkeypatch):
"""Test extraction of message IDs with progress callback."""
mock_conn = MagicMock()
@ -455,7 +484,7 @@ class TestGmailLabelsPreservation:
(
"OK",
[
(b"1 (BODY[HEADER.FIELDS (MESSAGE-ID)] {30}", b"Message-ID: <msg1@test.com>\r\n"),
(b"1 (FLAGS (\\Seen) BODY[HEADER.FIELDS (MESSAGE-ID)] {30}", b"Message-ID: <msg1@test.com>\r\n"),
b")",
],
), # fetch result
@ -512,20 +541,35 @@ class TestGmailLabelsPreservation:
"[Gmail]/Sent Mail": {"<msg2@test.com>"},
}
# Mock info for All Mail (with flags)
all_mail_info = {
"<msg1@test.com>": {"flags": ["\\Seen", "\\Flagged"]},
"<msg2@test.com>": {"flags": []},
}
def mock_get_message_ids(conn, folder, progress_cb=None):
return folder_data.get(folder, set())
def mock_get_message_info(conn, folder, progress_cb=None):
if folder == "[Gmail]/All Mail":
return all_mail_info
return {}
monkeypatch.setattr(backup_imap_emails, "get_message_ids_in_folder", mock_get_message_ids)
monkeypatch.setattr(backup_imap_emails, "get_message_info_in_folder", mock_get_message_info)
result = backup_imap_emails.build_labels_manifest(mock_conn, str(tmp_path))
# Check manifest structure
# Check manifest structure (new format with labels and flags)
assert "<msg1@test.com>" in result
assert "<msg2@test.com>" in result
assert "INBOX" in result["<msg1@test.com>"]
assert "Work" in result["<msg1@test.com>"]
assert "INBOX" in result["<msg2@test.com>"]
assert "Sent Mail" in result["<msg2@test.com>"]
assert "INBOX" in result["<msg1@test.com>"]["labels"]
assert "Work" in result["<msg1@test.com>"]["labels"]
assert "\\Seen" in result["<msg1@test.com>"]["flags"]
assert "\\Flagged" in result["<msg1@test.com>"]["flags"]
assert "INBOX" in result["<msg2@test.com>"]["labels"]
assert "Sent Mail" in result["<msg2@test.com>"]["labels"]
assert result["<msg2@test.com>"]["flags"] == []
# Check file was saved
manifest_path = tmp_path / "labels_manifest.json"
@ -619,3 +663,41 @@ class TestGmailLabelsPreservation:
"[Gmail]/Important",
}
assert backup_imap_emails.GMAIL_SYSTEM_FOLDERS == expected
def test_gmail_mode_flag(self, single_mock_server, monkeypatch, tmp_path):
"""Test --gmail-mode flag backs up All Mail and creates manifest."""
src_data = {
"INBOX": [b"Subject: Inbox Email\r\nMessage-ID: <1@test>\r\n\r\nBody"],
"Work": [b"Subject: Work Email\r\nMessage-ID: <1@test>\r\n\r\nBody"],
"[Gmail]/All Mail": [b"Subject: Inbox Email\r\nMessage-ID: <1@test>\r\n\r\nBody"],
}
server, port = single_mock_server(src_data)
env = {
"SRC_IMAP_HOST": "localhost",
"SRC_IMAP_USERNAME": "user",
"SRC_IMAP_PASSWORD": "pass",
}
monkeypatch.setattr(os, "environ", env)
monkeypatch.setattr(
sys,
"argv",
["backup_imap_emails.py", "--dest-path", str(tmp_path), "--gmail-mode"],
)
monkeypatch.setattr("imap_common.get_imap_connection", make_single_mock_connection(port))
backup_imap_emails.main()
# Check manifest was created
manifest_path = tmp_path / "labels_manifest.json"
assert manifest_path.exists()
# Check only [Gmail]/All Mail folder was backed up (not INBOX or Work)
all_mail_folder = tmp_path / "[Gmail]" / "All Mail"
assert all_mail_folder.exists()
# Should NOT have created separate INBOX or Work folders
inbox_folder = tmp_path / "INBOX"
work_folder = tmp_path / "Work"
assert not inbox_folder.exists()
assert not work_folder.exists()

View File

@ -0,0 +1,426 @@
"""
Tests for restore_imap_emails.py
Tests cover:
- Email parsing from .eml files
- Basic email restoration
- Gmail labels application
- Duplicate detection
- Configuration validation
"""
import json
import os
import sys
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))
import restore_imap_emails
from conftest import make_single_mock_connection
class TestLoadLabelsManifest:
"""Tests for loading labels manifest."""
def test_load_existing_manifest(self, tmp_path):
"""Test loading existing manifest file (old format - list of labels)."""
manifest_data = {
"<msg1@test.com>": ["INBOX", "Work"],
"<msg2@test.com>": ["Sent Mail", "Personal"],
}
manifest_path = tmp_path / "labels_manifest.json"
manifest_path.write_text(json.dumps(manifest_data))
result = restore_imap_emails.load_labels_manifest(str(tmp_path))
assert result == manifest_data
def test_load_manifest_new_format(self, tmp_path):
"""Test loading manifest with new format (dict with labels and flags)."""
manifest_data = {
"<msg1@test.com>": {"labels": ["INBOX", "Work"], "flags": ["\\Seen", "\\Flagged"]},
"<msg2@test.com>": {"labels": ["Sent Mail"], "flags": []},
}
manifest_path = tmp_path / "labels_manifest.json"
manifest_path.write_text(json.dumps(manifest_data))
result = restore_imap_emails.load_labels_manifest(str(tmp_path))
assert result == manifest_data
assert "\\Seen" in result["<msg1@test.com>"]["flags"]
assert result["<msg2@test.com>"]["flags"] == []
def test_load_nonexistent_manifest(self, tmp_path):
"""Test loading when manifest doesn't exist."""
result = restore_imap_emails.load_labels_manifest(str(tmp_path))
assert result == {}
def test_load_invalid_json_manifest(self, tmp_path):
"""Test loading invalid JSON manifest."""
manifest_path = tmp_path / "labels_manifest.json"
manifest_path.write_text("not valid json {{{")
result = restore_imap_emails.load_labels_manifest(str(tmp_path))
assert result == {}
class TestGetFlagsFromManifest:
"""Tests for extracting flags from manifest."""
def test_get_flags_new_format(self):
"""Test getting flags from manifest."""
manifest = {
"<msg1@test.com>": {"labels": ["INBOX"], "flags": ["\\Seen", "\\Flagged"]},
}
result = restore_imap_emails.get_flags_from_manifest(manifest, "<msg1@test.com>")
assert result == "\\Seen \\Flagged"
def test_get_flags_single_flag(self):
"""Test getting a single flag from manifest."""
manifest = {
"<msg1@test.com>": {"labels": ["INBOX"], "flags": ["\\Seen"]},
}
result = restore_imap_emails.get_flags_from_manifest(manifest, "<msg1@test.com>")
assert result == "\\Seen"
def test_get_flags_empty(self):
"""Test getting flags when no flags set."""
manifest = {
"<msg1@test.com>": {"labels": ["INBOX"], "flags": []},
}
result = restore_imap_emails.get_flags_from_manifest(manifest, "<msg1@test.com>")
assert result is None
def test_get_flags_not_in_manifest(self):
"""Test getting flags for message not in manifest."""
manifest = {}
result = restore_imap_emails.get_flags_from_manifest(manifest, "<msg1@test.com>")
assert result is None
class TestParseEmlFile:
"""Tests for parsing .eml files."""
def test_parse_simple_eml(self, tmp_path):
"""Test parsing a simple .eml file."""
eml_content = b"""From: sender@test.com
To: recipient@test.com
Subject: Test Email
Message-ID: <test123@test.com>
Date: Mon, 15 Jan 2024 10:30:00 +0000
This is the body of the email.
"""
eml_file = tmp_path / "test.eml"
eml_file.write_bytes(eml_content)
message_id, date_str, raw_content, subject = restore_imap_emails.parse_eml_file(str(eml_file))
assert message_id == "<test123@test.com>"
assert "Test Email" in subject
assert raw_content == eml_content
def test_parse_eml_no_message_id(self, tmp_path):
"""Test parsing .eml without Message-ID."""
eml_content = b"""From: sender@test.com
To: recipient@test.com
Subject: No ID Email
Date: Mon, 15 Jan 2024 10:30:00 +0000
Body content.
"""
eml_file = tmp_path / "test.eml"
eml_file.write_bytes(eml_content)
message_id, date_str, raw_content, subject = restore_imap_emails.parse_eml_file(str(eml_file))
assert message_id == ""
assert raw_content is not None
def test_parse_nonexistent_file(self):
"""Test parsing non-existent file."""
message_id, date_str, raw_content, subject = restore_imap_emails.parse_eml_file("/nonexistent/file.eml")
assert message_id is None
assert raw_content is None
class TestGetEmlFiles:
"""Tests for getting .eml files from a folder."""
def test_get_eml_files(self, tmp_path):
"""Test getting .eml files from a folder."""
(tmp_path / "email1.eml").write_text("content1")
(tmp_path / "email2.eml").write_text("content2")
(tmp_path / "other.txt").write_text("not an email")
result = restore_imap_emails.get_eml_files(str(tmp_path))
assert len(result) == 2
filenames = [f[1] for f in result]
assert "email1.eml" in filenames
assert "email2.eml" in filenames
def test_get_eml_files_empty_folder(self, tmp_path):
"""Test getting .eml files from empty folder."""
result = restore_imap_emails.get_eml_files(str(tmp_path))
assert result == []
def test_get_eml_files_nonexistent_folder(self):
"""Test getting .eml files from non-existent folder."""
result = restore_imap_emails.get_eml_files("/nonexistent/folder")
assert result == []
class TestGetBackupFolders:
"""Tests for scanning backup folder structure."""
def test_get_backup_folders(self, tmp_path):
"""Test scanning backup folders."""
# Create folder structure
inbox = tmp_path / "INBOX"
inbox.mkdir()
(inbox / "email1.eml").write_text("content")
sent = tmp_path / "Sent"
sent.mkdir()
(sent / "email2.eml").write_text("content")
result = restore_imap_emails.get_backup_folders(str(tmp_path))
assert len(result) == 2
folder_names = [f[0] for f in result]
assert "INBOX" in folder_names
assert "Sent" in folder_names
def test_get_backup_folders_nested(self, tmp_path):
"""Test scanning nested folder structure."""
gmail = tmp_path / "[Gmail]"
gmail.mkdir()
all_mail = gmail / "All Mail"
all_mail.mkdir()
(all_mail / "email.eml").write_text("content")
result = restore_imap_emails.get_backup_folders(str(tmp_path))
assert len(result) == 1
assert result[0][0] == "[Gmail]/All Mail"
def test_get_backup_folders_empty(self, tmp_path):
"""Test scanning empty backup folder."""
result = restore_imap_emails.get_backup_folders(str(tmp_path))
assert result == []
class TestConfigValidation:
"""Tests for configuration validation."""
def test_missing_credentials(self, monkeypatch, capsys):
"""Test that missing credentials cause exit."""
env = {}
monkeypatch.setattr(os, "environ", env)
monkeypatch.setattr(sys, "argv", ["restore_imap_emails.py", "--src-path", "/tmp"])
with pytest.raises(SystemExit) as exc_info:
restore_imap_emails.main()
assert exc_info.value.code == 1
def test_missing_src_path(self, monkeypatch, capsys):
"""Test that missing source path causes exit."""
env = {
"DEST_IMAP_HOST": "localhost",
"DEST_IMAP_USERNAME": "user",
"DEST_IMAP_PASSWORD": "pass",
}
monkeypatch.setattr(os, "environ", env)
monkeypatch.setattr(sys, "argv", ["restore_imap_emails.py"])
with pytest.raises(SystemExit) as exc_info:
restore_imap_emails.main()
assert exc_info.value.code == 1
def test_nonexistent_src_path(self, monkeypatch, capsys):
"""Test that non-existent source path causes exit."""
env = {
"DEST_IMAP_HOST": "localhost",
"DEST_IMAP_USERNAME": "user",
"DEST_IMAP_PASSWORD": "pass",
}
monkeypatch.setattr(os, "environ", env)
monkeypatch.setattr(
sys,
"argv",
["restore_imap_emails.py", "--src-path", "/nonexistent/path"],
)
with pytest.raises(SystemExit) as exc_info:
restore_imap_emails.main()
assert exc_info.value.code == 1
class TestUploadEmail:
"""Tests for email upload functionality."""
def test_upload_email_success(self, monkeypatch):
"""Test successful email upload."""
mock_conn = MagicMock()
mock_conn.create.return_value = ("OK", [])
mock_conn.select.return_value = ("OK", [b"1"])
mock_conn.append.return_value = ("OK", [])
# Mock message_exists_in_folder to return False (not a duplicate)
monkeypatch.setattr("imap_common.message_exists_in_folder", lambda *args: False)
result = restore_imap_emails.upload_email(
mock_conn,
"INBOX",
b"raw email content",
'"15-Jan-2024 10:30:00 +0000"',
"<test@test.com>",
"Test Subject",
)
assert result is True
mock_conn.append.assert_called_once()
def test_upload_email_duplicate(self, monkeypatch):
"""Test upload skips duplicate."""
mock_conn = MagicMock()
mock_conn.select.return_value = ("OK", [b"1"])
# Mock message_exists_in_folder to return True (is a duplicate)
monkeypatch.setattr("imap_common.message_exists_in_folder", lambda *args: True)
result = restore_imap_emails.upload_email(
mock_conn,
"INBOX",
b"raw email content",
'"15-Jan-2024 10:30:00 +0000"',
"<test@test.com>",
"Test Subject",
)
assert result is False
mock_conn.append.assert_not_called()
def test_upload_email_with_seen_flag(self, monkeypatch):
"""Test upload with \\Seen flag for read emails."""
mock_conn = MagicMock()
mock_conn.create.return_value = ("OK", [])
mock_conn.select.return_value = ("OK", [b"1"])
mock_conn.append.return_value = ("OK", [])
# Mock message_exists_in_folder to return False (not a duplicate)
monkeypatch.setattr("imap_common.message_exists_in_folder", lambda *args: False)
result = restore_imap_emails.upload_email(
mock_conn,
"INBOX",
b"raw email content",
'"15-Jan-2024 10:30:00 +0000"',
"<test@test.com>",
"Test Subject",
flags="\\Seen", # Mark as read
)
assert result is True
# Check that append was called with the \\Seen flag
call_args = mock_conn.append.call_args
assert call_args[0][1] == "\\Seen"
class TestRestoreIntegration:
"""Integration tests for restore functionality."""
def test_restore_single_folder(self, single_mock_server, monkeypatch, tmp_path):
"""Test restoring a single folder."""
# Create backup structure
inbox = tmp_path / "INBOX"
inbox.mkdir()
eml_content = b"""From: sender@test.com
To: recipient@test.com
Subject: Test Email
Message-ID: <test123@test.com>
Date: Mon, 15 Jan 2024 10:30:00 +0000
Body content.
"""
(inbox / "1_Test_Email.eml").write_bytes(eml_content)
# Start mock server (with empty data - we're uploading TO it)
src_data = {"INBOX": []}
server, port = single_mock_server(src_data)
env = {
"DEST_IMAP_HOST": "localhost",
"DEST_IMAP_USERNAME": "user",
"DEST_IMAP_PASSWORD": "pass",
}
monkeypatch.setattr(os, "environ", env)
monkeypatch.setattr(
sys,
"argv",
["restore_imap_emails.py", "--src-path", str(tmp_path), "INBOX"],
)
monkeypatch.setattr("imap_common.get_imap_connection", make_single_mock_connection(port))
# Run restore
restore_imap_emails.main()
def test_restore_with_labels_manifest(self, tmp_path):
"""Test that labels manifest is loaded correctly."""
# Create manifest
manifest_data = {
"<msg1@test.com>": ["INBOX", "Work"],
"<msg2@test.com>": ["Personal"],
}
manifest_path = tmp_path / "labels_manifest.json"
manifest_path.write_text(json.dumps(manifest_data))
# Load and verify
result = restore_imap_emails.load_labels_manifest(str(tmp_path))
assert len(result) == 2
assert result["<msg1@test.com>"] == ["INBOX", "Work"]
class TestEmailExistsInFolder:
"""Tests for duplicate detection."""
def test_email_exists_true(self, monkeypatch):
"""Test detecting existing email."""
mock_conn = MagicMock()
monkeypatch.setattr("imap_common.message_exists_in_folder", lambda *args: True)
result = restore_imap_emails.email_exists_in_folder(mock_conn, "<test@test.com>", 1000)
assert result is True
def test_email_exists_false(self, monkeypatch):
"""Test detecting non-existing email."""
mock_conn = MagicMock()
monkeypatch.setattr("imap_common.message_exists_in_folder", lambda *args: False)
result = restore_imap_emails.email_exists_in_folder(mock_conn, "<test@test.com>", 1000)
assert result is False
def test_email_exists_no_message_id(self, monkeypatch):
"""Test with no message ID."""
mock_conn = MagicMock()
result = restore_imap_emails.email_exists_in_folder(mock_conn, None, 1000)
assert result is False
def test_email_exists_exception(self, monkeypatch):
"""Test handling exception."""
mock_conn = MagicMock()
monkeypatch.setattr(
"imap_common.message_exists_in_folder",
lambda *args: (_ for _ in ()).throw(Exception("Error")),
)
result = restore_imap_emails.email_exists_in_folder(mock_conn, "<test@test.com>", 1000)
assert result is False