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:
parent
2c230dcf64
commit
c3a26c30bf
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -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
113
README.md
@ -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 |
|
||||
|
||||
@ -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
729
src/restore_imap_emails.py
Normal 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()
|
||||
@ -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()
|
||||
|
||||
426
test/test_restore_imap_emails.py
Normal file
426
test/test_restore_imap_emails.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user