Enhance migrate script with restore cache, plus cleanup for code and tests (#23)
* Implement incremental migration with caching support and add tests for cache behavior. Cleanup for duplicated logic. * Rewrite tests for main scripts to use end to end testing with data setup instead of mocking. * Fix linting issues. * Fix SEARCH response handling in MockIMAPHandler to return empty response when no results are found * Refactor SEARCH command handling in MockIMAPHandler to streamline response generation for ALL queries and remove redundant code. * Add cache directory creation in load_progress_cache with logging for failures * Improve cache hit detection in process_single_uid to handle locking for existing destination message IDs * Add tests for imap_common functions and enhance email parsing in restore_imap_emails * Refactor test_backup_folder_discovery to create a parent directory for unreadable path * Add tests for Gmail mode fallback folder and dest-delete functionality in restore_imap_emails * Add tests for cache behavior and error handling in migration process
This commit is contained in:
parent
f53955a9bb
commit
dd5251f55d
21
README.md
21
README.md
@ -25,6 +25,7 @@ This repository contains a set of Python scripts designed to migrate emails betw
|
||||
- **Cleanup**: Optionally deletes messages from the source after successful transfer (effectively a "Move" operation).
|
||||
- *Improved for Gmail*: Automatically detects "Trash" folders to ensure emails are properly binned rather than just archived.
|
||||
- **Sync Mode**: Optionally deletes emails from destination that no longer exist in source (`--dest-delete`).
|
||||
- **Incremental Cache**: Supports `--migrate-cache <path>` to store a local map of processed emails, drastically speeding up subsequent runs by skipping known messages.
|
||||
- **Configurable**: Adjustable concurrency and batch sizes to respect server rate limits.
|
||||
|
||||
2. **`compare_imap_folders.py`** (The Validator)
|
||||
@ -258,11 +259,23 @@ python3 migrate_imap_emails.py \
|
||||
--dest-pass "dest-app-password"
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `--preserve-flags` is enabled automatically in `--gmail-mode`.
|
||||
- `--dest-delete` is not supported in `--gmail-mode`.
|
||||
### 1b. Fast Incremental Migration (Cached)
|
||||
Use a local cache file to remember processed emails. Use this for large migrations that may be interrupted or need to run multiple times.
|
||||
|
||||
### 1b. Preserve Flags (Any IMAP Server)
|
||||
```bash
|
||||
python3 migrate_imap_emails.py \
|
||||
--src-host "imap.source.com" \
|
||||
--src-user "source" \
|
||||
--src-pass "pass" \
|
||||
--dest-host "imap.dest.com" \
|
||||
--dest-user "dest" \
|
||||
--dest-pass "pass" \
|
||||
--migrate-cache "./migration_cache"
|
||||
```
|
||||
|
||||
To force a re-check of cached items without clearing the cache logic entirely, add `--full-migrate`.
|
||||
|
||||
### 1c. Preserve Flags (Any IMAP Server)
|
||||
Preserve IMAP flags (`\Seen`, `\Flagged`, `\Answered`, `\Draft`) during migration.
|
||||
|
||||
If an email already exists on the destination (duplicate), the script can still sync missing flags on the existing message.
|
||||
|
||||
@ -69,14 +69,7 @@ MANIFEST_FILENAME = "labels_manifest.json"
|
||||
|
||||
# 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}")
|
||||
safe_print = imap_common.safe_print
|
||||
|
||||
|
||||
def get_thread_connection(src_conf):
|
||||
|
||||
@ -60,7 +60,6 @@ Examples:
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
import imap_common
|
||||
import imap_oauth2
|
||||
@ -86,56 +85,6 @@ def get_email_count(conn, folder_name):
|
||||
return None
|
||||
|
||||
|
||||
def _is_ignored_local_dir(dirname: str) -> bool:
|
||||
return dirname.startswith(".") or dirname == "__pycache__"
|
||||
|
||||
|
||||
def list_local_folders(local_root: str) -> list[str]:
|
||||
"""List all folders under a local backup root in IMAP-style names.
|
||||
|
||||
The local backup format is expected to mirror IMAP folder hierarchy using
|
||||
subdirectories (e.g. "[Gmail]/All Mail" becomes "[Gmail]/All Mail/").
|
||||
"""
|
||||
folders: set[str] = set()
|
||||
|
||||
for dirpath, dirnames, _filenames in os.walk(local_root):
|
||||
dirnames[:] = [d for d in dirnames if not _is_ignored_local_dir(d)]
|
||||
|
||||
if os.path.abspath(dirpath) == os.path.abspath(local_root):
|
||||
continue
|
||||
|
||||
rel = os.path.relpath(dirpath, local_root)
|
||||
if rel == ".":
|
||||
continue
|
||||
|
||||
parts = [p for p in rel.split(os.sep) if p and not _is_ignored_local_dir(p)]
|
||||
if not parts:
|
||||
continue
|
||||
|
||||
folders.add("/".join(parts))
|
||||
|
||||
return sorted(folders)
|
||||
|
||||
|
||||
def get_local_email_count(local_root: str, folder_name: str) -> Optional[int]:
|
||||
"""Return the count of .eml files in a local folder, or None if missing/unreadable."""
|
||||
folder_path = os.path.join(local_root, *folder_name.split("/"))
|
||||
if not os.path.isdir(folder_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
count = 0
|
||||
for filename in os.listdir(folder_path):
|
||||
if not filename.endswith(".eml"):
|
||||
continue
|
||||
full_path = os.path.join(folder_path, filename)
|
||||
if os.path.isfile(full_path):
|
||||
count += 1
|
||||
return count
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
default_src_path = os.getenv("SRC_LOCAL_PATH")
|
||||
default_dest_path = os.getenv("DEST_LOCAL_PATH")
|
||||
@ -333,7 +282,7 @@ def main():
|
||||
# List Source Folders
|
||||
print("Listing folders in Source...")
|
||||
if src_is_local:
|
||||
folders = list_local_folders(args.src_path)
|
||||
folders = imap_common.list_local_folders(args.src_path)
|
||||
else:
|
||||
folders = imap_common.list_selectable_folders(src)
|
||||
|
||||
@ -354,12 +303,12 @@ def main():
|
||||
for folder_name in folders:
|
||||
# Get Counts
|
||||
if src_is_local:
|
||||
src_count = get_local_email_count(args.src_path, folder_name)
|
||||
src_count = imap_common.get_local_email_count(args.src_path, folder_name)
|
||||
else:
|
||||
src_count = get_email_count(src, folder_name)
|
||||
|
||||
if dest_is_local:
|
||||
dest_count = get_local_email_count(args.dest_path, folder_name)
|
||||
dest_count = imap_common.get_local_email_count(args.dest_path, folder_name)
|
||||
else:
|
||||
dest_count = get_email_count(dest, folder_name)
|
||||
|
||||
|
||||
@ -109,56 +109,10 @@ def count_emails(imap_server, username, password=None, oauth2_token=None):
|
||||
print(f"An error occurred: {e}")
|
||||
|
||||
|
||||
def _is_ignored_local_dir(dirname: str) -> bool:
|
||||
return dirname.startswith(".") or dirname == "__pycache__"
|
||||
|
||||
|
||||
def list_local_folders(local_root: str) -> list[str]:
|
||||
"""List all folders under a local backup root in IMAP-style names."""
|
||||
folders: set[str] = set()
|
||||
|
||||
for dirpath, dirnames, _filenames in os.walk(local_root):
|
||||
dirnames[:] = [d for d in dirnames if not _is_ignored_local_dir(d)]
|
||||
|
||||
if os.path.abspath(dirpath) == os.path.abspath(local_root):
|
||||
continue
|
||||
|
||||
rel = os.path.relpath(dirpath, local_root)
|
||||
if rel == ".":
|
||||
continue
|
||||
|
||||
parts = [p for p in rel.split(os.sep) if p and not _is_ignored_local_dir(p)]
|
||||
if not parts:
|
||||
continue
|
||||
|
||||
folders.add("/".join(parts))
|
||||
|
||||
return sorted(folders)
|
||||
|
||||
|
||||
def get_local_email_count(local_root: str, folder_name: str):
|
||||
"""Return the count of .eml files in a local folder, or None if missing/unreadable."""
|
||||
folder_path = os.path.join(local_root, *folder_name.split("/"))
|
||||
if not os.path.isdir(folder_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
count = 0
|
||||
for filename in os.listdir(folder_path):
|
||||
if not filename.endswith(".eml"):
|
||||
continue
|
||||
full_path = os.path.join(folder_path, filename)
|
||||
if os.path.isfile(full_path):
|
||||
count += 1
|
||||
return count
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def count_local_emails(local_path: str) -> None:
|
||||
print(f"Scanning local backup: {local_path}")
|
||||
|
||||
folders = list_local_folders(local_path)
|
||||
folders = imap_common.list_local_folders(local_path)
|
||||
if not folders:
|
||||
print("No folders found.")
|
||||
return
|
||||
@ -168,7 +122,7 @@ def count_local_emails(local_path: str) -> None:
|
||||
print("-" * 52)
|
||||
|
||||
for folder_name in folders:
|
||||
count = get_local_email_count(local_path, folder_name)
|
||||
count = imap_common.get_local_email_count(local_path, folder_name)
|
||||
if count is None:
|
||||
print(f"{folder_name:<40} {'N/A':>10}")
|
||||
continue
|
||||
|
||||
@ -10,11 +10,13 @@ import imaplib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
from email import policy
|
||||
from email.header import decode_header
|
||||
from email.parser import BytesParser
|
||||
|
||||
import imap_oauth2
|
||||
import restore_cache
|
||||
|
||||
# Standard IMAP flags
|
||||
FLAG_SEEN = "\\Seen"
|
||||
@ -42,6 +44,127 @@ CMD_SEARCH = "search"
|
||||
CMD_FETCH = "fetch"
|
||||
OP_ADD_FLAGS = "+FLAGS"
|
||||
|
||||
_print_lock = threading.Lock()
|
||||
|
||||
|
||||
def safe_print(message: str) -> None:
|
||||
"""Thread-safe print with short thread names for logs."""
|
||||
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 _is_ignored_local_dir(dirname: str) -> bool:
|
||||
return dirname.startswith(".") or dirname == "__pycache__"
|
||||
|
||||
|
||||
def list_local_folders(local_root: str) -> list[str]:
|
||||
"""List all folders under a local backup root in IMAP-style names."""
|
||||
folders: set[str] = set()
|
||||
|
||||
for dirpath, dirnames, _filenames in os.walk(local_root):
|
||||
dirnames[:] = [d for d in dirnames if not _is_ignored_local_dir(d)]
|
||||
|
||||
if os.path.abspath(dirpath) == os.path.abspath(local_root):
|
||||
continue
|
||||
|
||||
rel = os.path.relpath(dirpath, local_root)
|
||||
if rel == ".":
|
||||
continue
|
||||
|
||||
parts = [p for p in rel.split(os.sep) if p and not _is_ignored_local_dir(p)]
|
||||
if not parts:
|
||||
continue
|
||||
|
||||
folders.add("/".join(parts))
|
||||
|
||||
return sorted(folders)
|
||||
|
||||
|
||||
def get_local_email_count(local_root: str, folder_name: str) -> int | None:
|
||||
"""Return the count of .eml files in a local folder, or None if missing/unreadable."""
|
||||
folder_path = os.path.join(local_root, *folder_name.split("/"))
|
||||
if not os.path.isdir(folder_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
count = 0
|
||||
for filename in os.listdir(folder_path):
|
||||
if not filename.endswith(".eml"):
|
||||
continue
|
||||
full_path = os.path.join(folder_path, filename)
|
||||
if os.path.isfile(full_path):
|
||||
count += 1
|
||||
return count
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def get_backup_folders(local_path: str) -> list[tuple[str, str]]:
|
||||
"""Scan the backup directory and return list of (folder_name, folder_path) tuples."""
|
||||
folders: list[tuple[str, str]] = []
|
||||
|
||||
def scan_dir(path: str, prefix: str = "") -> None:
|
||||
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 extract_message_id_from_eml(file_path: str, read_limit: int = 65536) -> str | None:
|
||||
"""Extract just the Message-ID from an .eml file efficiently."""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
header_bytes = f.read(read_limit)
|
||||
|
||||
return extract_message_id(header_bytes)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def is_progress_cache_ready(cache_data: dict | None, cache_lock: threading.Lock | None) -> bool:
|
||||
"""Return True when cache data and lock are initialized."""
|
||||
return cache_data is not None and cache_lock is not None
|
||||
|
||||
|
||||
def load_progress_cache(
|
||||
cache_root: str,
|
||||
dest_host: str,
|
||||
dest_user: str,
|
||||
*,
|
||||
log_fn=None,
|
||||
) -> tuple[str, dict, threading.Lock]:
|
||||
"""Load or initialize a progress cache file for a destination."""
|
||||
try:
|
||||
os.makedirs(cache_root, exist_ok=True)
|
||||
except Exception as exc:
|
||||
if log_fn is not None:
|
||||
log_fn(f"Warning: unable to create cache directory '{cache_root}': {exc}")
|
||||
|
||||
cache_path = restore_cache.get_dest_index_cache_path(cache_root, dest_host, dest_user)
|
||||
cache_data = restore_cache.load_dest_index_cache(cache_path)
|
||||
cache_lock = threading.Lock()
|
||||
if log_fn is not None:
|
||||
log_fn(f"Using progress cache: {cache_path}")
|
||||
return cache_path, cache_data, cache_lock
|
||||
|
||||
|
||||
def ensure_folder_exists(imap_conn, folder_name: str) -> None:
|
||||
"""Best-effort create of a folder if it doesn't already exist.
|
||||
|
||||
@ -16,6 +16,10 @@ Features:
|
||||
applies additional Gmail labels by copying the message into label folders.
|
||||
- In Gmail mode, label preservation is enabled automatically.
|
||||
- Note: --dest-delete is not supported in --gmail-mode.
|
||||
- Cached Incremental Migration (--migrate-cache):
|
||||
- Uses a local JSON cache to track migrated Message-IDs.
|
||||
- Dramatically speeds up re-runs by skipping already processed emails without server checks.
|
||||
- Use --full-migrate to ignore cache skipping (force check) while still updating cache.
|
||||
|
||||
Configuration (Environment Variables):
|
||||
Source Account:
|
||||
@ -106,6 +110,17 @@ Usage Example:
|
||||
--dest-host "imap.gmail.com" \
|
||||
--dest-user "dest@gmail.com" \
|
||||
--dest-pass "DEST_APP_PASSWORD"
|
||||
|
||||
# Cached Incremental Migration (Recommended for large accounts):
|
||||
# Uses a local cache to track progress and skip already migrated emails.
|
||||
python3 migrate_imap_emails.py \
|
||||
--migrate-cache "./migration_cache" \
|
||||
--src-host "imap.example.com" \
|
||||
--src-user "source@example.com" \
|
||||
--src-pass "SOURCE_PASSWORD" \
|
||||
--dest-host "imap.example.com" \
|
||||
--dest-user "dest@example.com" \
|
||||
--dest-pass "DEST_PASSWORD"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@ -114,12 +129,14 @@ import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
import imap_common
|
||||
import imap_oauth2
|
||||
import imap_session
|
||||
import provider_exchange
|
||||
import provider_gmail
|
||||
import restore_cache
|
||||
|
||||
# Configuration defaults
|
||||
DELETE_FROM_SOURCE_DEFAULT = False
|
||||
@ -128,15 +145,7 @@ BATCH_SIZE = 10 # Initial default, updated in main
|
||||
|
||||
# Thread-local storage for IMAP connections
|
||||
thread_local = threading.local()
|
||||
print_lock = threading.Lock()
|
||||
|
||||
|
||||
def safe_print(message):
|
||||
t_name = threading.current_thread().name
|
||||
# Shorten thread name for cleaner logs e.g. ThreadPoolExecutor-0_0 -> T-0_0
|
||||
short_name = t_name.replace("ThreadPoolExecutor-", "T-").replace("MainThread", "MAIN")
|
||||
with print_lock:
|
||||
print(f"[{short_name}] {message}")
|
||||
safe_print = imap_common.safe_print
|
||||
|
||||
|
||||
def filter_preservable_flags(flags_str):
|
||||
@ -255,6 +264,14 @@ def process_single_uid(
|
||||
gmail_mode,
|
||||
label_index,
|
||||
check_duplicate,
|
||||
full_migrate: bool = False,
|
||||
existing_dest_msg_ids: Optional[set[str]] = None,
|
||||
existing_dest_msg_ids_lock: Optional[threading.Lock] = None,
|
||||
progress_cache_path: Optional[str] = None,
|
||||
progress_cache_data: Optional[dict] = None,
|
||||
progress_cache_lock: Optional[threading.Lock] = None,
|
||||
dest_host: Optional[str] = None,
|
||||
dest_user: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Migrate a single email by UID.
|
||||
@ -327,10 +344,27 @@ def process_single_uid(
|
||||
imap_common.ensure_folder_exists(dest, target_folder)
|
||||
dest.select(f'"{target_folder}"')
|
||||
|
||||
is_duplicate = bool(msg_id and check_duplicate and imap_common.message_exists_in_folder(dest, msg_id))
|
||||
is_cached = False
|
||||
is_duplicate = False
|
||||
|
||||
# check cache first if available
|
||||
cache_hit = False
|
||||
if not full_migrate and msg_id and existing_dest_msg_ids is not None:
|
||||
if existing_dest_msg_ids_lock is not None:
|
||||
with existing_dest_msg_ids_lock:
|
||||
cache_hit = msg_id in existing_dest_msg_ids
|
||||
else:
|
||||
cache_hit = msg_id in existing_dest_msg_ids
|
||||
|
||||
if cache_hit:
|
||||
is_cached = True
|
||||
is_duplicate = True
|
||||
safe_print(f"[{target_folder}] SKIP (cached) | {size_str:<8} | {subject[:40]}")
|
||||
elif bool(msg_id and check_duplicate and imap_common.message_exists_in_folder(dest, msg_id)):
|
||||
is_duplicate = True
|
||||
safe_print(f"[{target_folder}] SKIP (exists) | {size_str:<8} | {subject[:40]}")
|
||||
|
||||
if is_duplicate:
|
||||
safe_print(f"[{target_folder}] SKIP (exists) | {size_str:<8} | {subject[:40]}")
|
||||
if preserve_flags and flags and msg_id:
|
||||
sync_flags_on_existing(dest, target_folder, msg_id, flags, size)
|
||||
else:
|
||||
@ -348,6 +382,21 @@ def process_single_uid(
|
||||
for flag in flags.split():
|
||||
safe_print(f" -> Applied flag: {flag}")
|
||||
|
||||
# Update cache if processed effectively (copied or duplicate)
|
||||
if msg_id:
|
||||
restore_cache.record_progress(
|
||||
message_id=msg_id,
|
||||
folder_name=folder_name,
|
||||
existing_dest_msg_ids=existing_dest_msg_ids,
|
||||
existing_dest_msg_ids_lock=existing_dest_msg_ids_lock,
|
||||
progress_cache_path=progress_cache_path,
|
||||
progress_cache_data=progress_cache_data,
|
||||
progress_cache_lock=progress_cache_lock,
|
||||
dest_host=dest_host,
|
||||
dest_user=dest_user,
|
||||
log_fn=safe_print,
|
||||
)
|
||||
|
||||
# Apply remaining Gmail labels
|
||||
if apply_labels and remaining_labels and msg_id:
|
||||
for label in remaining_labels:
|
||||
@ -414,6 +463,12 @@ def process_batch(
|
||||
gmail_mode=False,
|
||||
label_index=None,
|
||||
check_duplicate=True,
|
||||
full_migrate=False,
|
||||
existing_dest_msg_ids: Optional[set[str]] = None,
|
||||
existing_dest_msg_ids_lock: Optional[threading.Lock] = None,
|
||||
progress_cache_path: Optional[str] = None,
|
||||
progress_cache_data: Optional[dict] = None,
|
||||
progress_cache_lock: Optional[threading.Lock] = None,
|
||||
):
|
||||
src, dest = get_thread_connections(src_conf, dest_conf)
|
||||
if not src or not dest:
|
||||
@ -434,6 +489,10 @@ def process_batch(
|
||||
safe_print(f"Error selecting folder {folder_name} in worker: {e}")
|
||||
return
|
||||
|
||||
# Extract info for cache update if needed
|
||||
dest_host = dest_conf.get("host")
|
||||
dest_user = dest_conf.get("user")
|
||||
|
||||
deleted_count = 0
|
||||
|
||||
for uid in uids:
|
||||
@ -471,6 +530,14 @@ def process_batch(
|
||||
gmail_mode,
|
||||
label_index,
|
||||
check_duplicate,
|
||||
full_migrate,
|
||||
existing_dest_msg_ids=existing_dest_msg_ids,
|
||||
existing_dest_msg_ids_lock=existing_dest_msg_ids_lock,
|
||||
progress_cache_path=progress_cache_path,
|
||||
progress_cache_data=progress_cache_data,
|
||||
progress_cache_lock=progress_cache_lock,
|
||||
dest_host=dest_host,
|
||||
dest_user=dest_user,
|
||||
)
|
||||
thread_local.src = src
|
||||
thread_local.dest = dest
|
||||
@ -504,9 +571,49 @@ def migrate_folder(
|
||||
preserve_flags=False,
|
||||
gmail_mode=False,
|
||||
label_index=None,
|
||||
progress_cache_path: Optional[str] = None,
|
||||
full_migrate=False,
|
||||
progress_cache_file: Optional[str] = None,
|
||||
progress_cache_data: Optional[dict] = None,
|
||||
progress_cache_lock: Optional[threading.Lock] = None,
|
||||
):
|
||||
safe_print(f"--- Preparing Folder: {folder_name} ---")
|
||||
|
||||
# Load cache if provided
|
||||
existing_dest_msg_ids = None
|
||||
existing_dest_msg_ids_lock = None
|
||||
dest_host = dest_conf.get("host")
|
||||
dest_user = dest_conf.get("user")
|
||||
cache_file = progress_cache_file
|
||||
|
||||
if progress_cache_path:
|
||||
if progress_cache_data is None or progress_cache_lock is None or cache_file is None:
|
||||
try:
|
||||
cache_file, progress_cache_data, progress_cache_lock = imap_common.load_progress_cache(
|
||||
progress_cache_path,
|
||||
dest_host,
|
||||
dest_user,
|
||||
log_fn=safe_print,
|
||||
)
|
||||
except Exception as e:
|
||||
safe_print(f"Warning: Failed to load cache: {e}")
|
||||
|
||||
if imap_common.is_progress_cache_ready(progress_cache_data, progress_cache_lock):
|
||||
try:
|
||||
existing_dest_msg_ids = restore_cache.get_cached_message_ids(
|
||||
progress_cache_data,
|
||||
progress_cache_lock,
|
||||
dest_host,
|
||||
dest_user,
|
||||
folder_name,
|
||||
)
|
||||
existing_dest_msg_ids_lock = threading.Lock()
|
||||
safe_print(f"Cache has {len(existing_dest_msg_ids)} Message-IDs for this folder.")
|
||||
except Exception as e:
|
||||
safe_print(f"Warning: Failed to read cache for folder '{folder_name}': {e}")
|
||||
existing_dest_msg_ids = set()
|
||||
existing_dest_msg_ids_lock = threading.Lock()
|
||||
|
||||
# Maintain folder structure (skip in Gmail mode; worker will create/select target label folders)
|
||||
if not gmail_mode:
|
||||
try:
|
||||
@ -547,7 +654,9 @@ def migrate_folder(
|
||||
safe_print(f"Pre-fetching destination Message-IDs for {folder_name}...")
|
||||
dest_uid_to_msgid = imap_common.get_message_ids_in_folder(dest)
|
||||
dest_msg_ids = set(dest_uid_to_msgid.values())
|
||||
safe_print(f"Found {len(dest_msg_ids)} existing messages in destination.")
|
||||
if existing_dest_msg_ids and not full_migrate:
|
||||
dest_msg_ids.update(existing_dest_msg_ids)
|
||||
safe_print(f"Found {len(dest_msg_ids)} existing messages in destination (server + cache).")
|
||||
|
||||
# Pre-fetch source Message-IDs and filter out duplicates before processing
|
||||
# Skip pre-filtering when preserve_flags is True (need to sync flags on duplicates)
|
||||
@ -603,6 +712,12 @@ def migrate_folder(
|
||||
gmail_mode,
|
||||
label_index,
|
||||
check_duplicate,
|
||||
full_migrate,
|
||||
existing_dest_msg_ids,
|
||||
existing_dest_msg_ids_lock,
|
||||
cache_file,
|
||||
progress_cache_data,
|
||||
progress_cache_lock,
|
||||
)
|
||||
)
|
||||
|
||||
@ -642,6 +757,10 @@ def migrate_folder(
|
||||
safe_print("Syncing destination: removing emails not in source...")
|
||||
delete_orphan_emails(dest, folder_name, src_msg_ids, dest_uid_to_msgid)
|
||||
|
||||
# Force-flush progress cache at end of folder migration.
|
||||
if cache_file and imap_common.is_progress_cache_ready(progress_cache_data, progress_cache_lock):
|
||||
restore_cache.maybe_save_dest_index_cache(cache_file, progress_cache_data, progress_cache_lock, force=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Migrate emails between IMAP accounts.")
|
||||
@ -781,6 +900,16 @@ def main():
|
||||
help="Gmail migration mode",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--migrate-cache",
|
||||
help="Path to directory for migration progress cache (enables incremental migration)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--full-migrate",
|
||||
action="store_true",
|
||||
help="Force full migration (ignore cache for skipping), but still update cache if --migrate-cache provided",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Assign to variables
|
||||
@ -794,6 +923,8 @@ def main():
|
||||
DEST_DELETE = args.dest_delete
|
||||
|
||||
gmail_mode = bool(args.gmail_mode)
|
||||
migrate_cache = args.migrate_cache
|
||||
full_migrate = args.full_migrate
|
||||
preserve_flags = bool(args.preserve_flags) or gmail_mode
|
||||
preserve_labels = bool(args.preserve_labels) or gmail_mode
|
||||
|
||||
@ -896,6 +1027,20 @@ def main():
|
||||
else None,
|
||||
}
|
||||
|
||||
progress_cache_file = None
|
||||
progress_cache_data = None
|
||||
progress_cache_lock = None
|
||||
if migrate_cache:
|
||||
try:
|
||||
progress_cache_file, progress_cache_data, progress_cache_lock = imap_common.load_progress_cache(
|
||||
migrate_cache,
|
||||
DEST_HOST,
|
||||
DEST_USER,
|
||||
log_fn=safe_print,
|
||||
)
|
||||
except Exception as e:
|
||||
safe_print(f"Warning: Failed to load progress cache: {e}")
|
||||
|
||||
try:
|
||||
# Initial connection to list folders
|
||||
safe_print("Connecting to Source to list folders...")
|
||||
@ -949,6 +1094,11 @@ def main():
|
||||
preserve_flags,
|
||||
gmail_mode,
|
||||
label_index,
|
||||
progress_cache_path=migrate_cache,
|
||||
full_migrate=full_migrate,
|
||||
progress_cache_file=progress_cache_file,
|
||||
progress_cache_data=progress_cache_data,
|
||||
progress_cache_lock=progress_cache_lock,
|
||||
)
|
||||
else:
|
||||
# Migration for all folders
|
||||
@ -972,6 +1122,11 @@ def main():
|
||||
preserve_flags,
|
||||
True,
|
||||
label_index,
|
||||
progress_cache_path=migrate_cache,
|
||||
full_migrate=full_migrate,
|
||||
progress_cache_file=progress_cache_file,
|
||||
progress_cache_data=progress_cache_data,
|
||||
progress_cache_lock=progress_cache_lock,
|
||||
)
|
||||
|
||||
if not gmail_mode:
|
||||
@ -1005,6 +1160,11 @@ def main():
|
||||
preserve_flags,
|
||||
False,
|
||||
None,
|
||||
progress_cache_path=migrate_cache,
|
||||
full_migrate=full_migrate,
|
||||
progress_cache_file=progress_cache_file,
|
||||
progress_cache_data=progress_cache_data,
|
||||
progress_cache_lock=progress_cache_lock,
|
||||
)
|
||||
|
||||
src_main.logout()
|
||||
|
||||
@ -80,14 +80,7 @@ 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}")
|
||||
safe_print = imap_common.safe_print
|
||||
|
||||
|
||||
def get_thread_connection(dest_conf):
|
||||
@ -209,22 +202,6 @@ def sync_flags_on_existing(imap_conn, folder_name, message_id, flags, size):
|
||||
safe_print(f" -> Error syncing flags: {e}")
|
||||
|
||||
|
||||
def extract_message_id_from_eml(file_path):
|
||||
"""
|
||||
Extract just the Message-ID from an .eml file efficiently.
|
||||
Returns the Message-ID string or None on error.
|
||||
"""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
# Read just the first 64KB to get headers
|
||||
header_bytes = f.read(65536)
|
||||
|
||||
msg_id = imap_common.extract_message_id(header_bytes)
|
||||
return msg_id
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def parse_eml_file(file_path):
|
||||
"""
|
||||
Parse an .eml file and extract metadata.
|
||||
@ -453,7 +430,11 @@ def process_restore_batch(
|
||||
# Lazy-load per-folder progress cache (no destination scan).
|
||||
if existing_dest_msg_ids is None:
|
||||
built: set[str] = set()
|
||||
if progress_cache_data is not None and progress_cache_lock is not None and dest_host and dest_user:
|
||||
if (
|
||||
imap_common.is_progress_cache_ready(progress_cache_data, progress_cache_lock)
|
||||
and dest_host
|
||||
and dest_user
|
||||
):
|
||||
built = restore_cache.get_cached_message_ids(
|
||||
progress_cache_data,
|
||||
progress_cache_lock,
|
||||
@ -555,8 +536,10 @@ def process_restore_batch(
|
||||
if label_folder_msg_ids is None:
|
||||
built: set[str] = set()
|
||||
if (
|
||||
progress_cache_data is not None
|
||||
and progress_cache_lock is not None
|
||||
imap_common.is_progress_cache_ready(
|
||||
progress_cache_data,
|
||||
progress_cache_lock,
|
||||
)
|
||||
and dest_host
|
||||
and dest_user
|
||||
):
|
||||
@ -689,6 +672,9 @@ def restore_folder(
|
||||
dest_delete=False,
|
||||
full_restore: bool = False,
|
||||
cache_root: Optional[str] = None,
|
||||
progress_cache_file: Optional[str] = None,
|
||||
progress_cache_data: Optional[dict] = None,
|
||||
progress_cache_lock: Optional[threading.Lock] = None,
|
||||
):
|
||||
"""
|
||||
Restore all emails from a local folder to the destination IMAP server.
|
||||
@ -709,11 +695,16 @@ def restore_folder(
|
||||
safe_print(f"Found {len(eml_files)} emails to restore.")
|
||||
|
||||
cache_root = cache_root or local_folder_path
|
||||
cache_path = restore_cache.get_dest_index_cache_path(cache_root, dest_conf["host"], dest_conf["user"])
|
||||
cache_data: dict = restore_cache.load_dest_index_cache(cache_path)
|
||||
cache_lock = threading.Lock()
|
||||
|
||||
safe_print(f"Using progress cache: {cache_path}")
|
||||
cache_path = progress_cache_file
|
||||
cache_data = progress_cache_data
|
||||
cache_lock = progress_cache_lock
|
||||
if cache_path is None or cache_data is None or cache_lock is None:
|
||||
cache_path, cache_data, cache_lock = imap_common.load_progress_cache(
|
||||
cache_root,
|
||||
dest_conf["host"],
|
||||
dest_conf["user"],
|
||||
log_fn=safe_print,
|
||||
)
|
||||
|
||||
# Incremental mode uses cached Message-IDs to skip already-processed emails.
|
||||
existing_dest_msg_ids_by_folder: Optional[dict[str, set[str]]] = {folder_name: set()}
|
||||
@ -768,7 +759,7 @@ def restore_folder(
|
||||
files_to_restore = []
|
||||
skipped = 0
|
||||
for file_path, filename in eml_files:
|
||||
msg_id = extract_message_id_from_eml(file_path)
|
||||
msg_id = imap_common.extract_message_id_from_eml(file_path)
|
||||
if msg_id and msg_id in dest_msg_ids:
|
||||
skipped += 1
|
||||
else:
|
||||
@ -777,6 +768,14 @@ def restore_folder(
|
||||
|
||||
if not files_to_restore:
|
||||
safe_print("No new emails to restore.")
|
||||
if dest_delete and local_msg_ids is not None:
|
||||
safe_print("Syncing destination: removing emails not in local backup...")
|
||||
dest = imap_session.ensure_connection(None, dest_conf)
|
||||
if dest:
|
||||
delete_orphan_emails_from_dest(dest, folder_name, local_msg_ids)
|
||||
dest.logout()
|
||||
|
||||
restore_cache.maybe_save_dest_index_cache(cache_path, cache_data, cache_lock, force=True)
|
||||
return
|
||||
|
||||
safe_print(f"Starting parallel restore of {len(files_to_restore)} emails...")
|
||||
@ -825,7 +824,16 @@ def restore_folder(
|
||||
restore_cache.maybe_save_dest_index_cache(cache_path, cache_data, cache_lock, force=True)
|
||||
|
||||
|
||||
def restore_gmail_with_labels(local_path, dest_conf, manifest, apply_flags, full_restore: bool = False):
|
||||
def restore_gmail_with_labels(
|
||||
local_path,
|
||||
dest_conf,
|
||||
manifest,
|
||||
apply_flags,
|
||||
full_restore: bool = False,
|
||||
progress_cache_file: Optional[str] = None,
|
||||
progress_cache_data: Optional[dict] = None,
|
||||
progress_cache_lock: Optional[threading.Lock] = None,
|
||||
):
|
||||
"""
|
||||
Special restoration mode for Gmail: Upload emails to their first label folder
|
||||
and then apply additional labels from the manifest.
|
||||
@ -856,11 +864,14 @@ def restore_gmail_with_labels(local_path, dest_conf, manifest, apply_flags, full
|
||||
|
||||
batches = [eml_files[i : i + BATCH_SIZE] for i in range(0, len(eml_files), BATCH_SIZE)]
|
||||
|
||||
cache_path = restore_cache.get_dest_index_cache_path(local_path, dest_conf["host"], dest_conf["user"])
|
||||
progress_cache_data: dict = restore_cache.load_dest_index_cache(cache_path)
|
||||
progress_cache_lock = threading.Lock()
|
||||
|
||||
safe_print(f"Using progress cache: {cache_path}")
|
||||
cache_path = progress_cache_file
|
||||
if cache_path is None or progress_cache_data is None or progress_cache_lock is None:
|
||||
cache_path, progress_cache_data, progress_cache_lock = imap_common.load_progress_cache(
|
||||
local_path,
|
||||
dest_conf["host"],
|
||||
dest_conf["user"],
|
||||
log_fn=safe_print,
|
||||
)
|
||||
safe_print(
|
||||
"Cache will be populated as restore runs (no up-front destination indexing). "
|
||||
"First run may still do per-message duplicate checks; subsequent runs will skip quickly."
|
||||
@ -913,36 +924,6 @@ def restore_gmail_with_labels(local_path, dest_conf, manifest, apply_flags, full
|
||||
restore_cache.maybe_save_dest_index_cache(cache_path, progress_cache_data, progress_cache_lock, force=True)
|
||||
|
||||
|
||||
def get_backup_folders(local_path):
|
||||
"""
|
||||
Scan the backup directory and return list of folder paths.
|
||||
Returns list of (folder_name, local_path) tuples.
|
||||
"""
|
||||
folders = []
|
||||
|
||||
def scan_dir(path, prefix=""):
|
||||
try:
|
||||
for item in os.listdir(path):
|
||||
item_path = os.path.join(path, item)
|
||||
if os.path.isdir(item_path):
|
||||
# Check if this directory contains .eml files
|
||||
has_eml = any(
|
||||
f.endswith(".eml") for f in os.listdir(item_path) if os.path.isfile(os.path.join(item_path, f))
|
||||
)
|
||||
folder_name = f"{prefix}{item}" if prefix else item
|
||||
|
||||
if has_eml:
|
||||
folders.append((folder_name, item_path))
|
||||
|
||||
# Recurse into subdirectories
|
||||
scan_dir(item_path, f"{folder_name}/")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
scan_dir(local_path)
|
||||
return folders
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Restore IMAP emails from local .eml files.")
|
||||
|
||||
@ -1126,6 +1107,19 @@ def main():
|
||||
if not manifest:
|
||||
print("Warning: No manifest found. Labels/flags will not be applied.")
|
||||
|
||||
progress_cache_file = None
|
||||
progress_cache_data = None
|
||||
progress_cache_lock = None
|
||||
try:
|
||||
progress_cache_file, progress_cache_data, progress_cache_lock = imap_common.load_progress_cache(
|
||||
local_path,
|
||||
args.dest_host,
|
||||
args.dest_user,
|
||||
log_fn=safe_print,
|
||||
)
|
||||
except Exception as e:
|
||||
safe_print(f"Warning: Failed to load progress cache: {e}")
|
||||
|
||||
print("\n--- Configuration Summary ---")
|
||||
print(f"Source Path : {local_path}")
|
||||
print(f"Destination Host: {args.dest_host}")
|
||||
@ -1159,7 +1153,16 @@ def main():
|
||||
if args.gmail_mode:
|
||||
dest.logout()
|
||||
# Special Gmail mode
|
||||
restore_gmail_with_labels(local_path, dest_conf, manifest, apply_flags, full_restore=args.full_restore)
|
||||
restore_gmail_with_labels(
|
||||
local_path,
|
||||
dest_conf,
|
||||
manifest,
|
||||
apply_flags,
|
||||
full_restore=args.full_restore,
|
||||
progress_cache_file=progress_cache_file,
|
||||
progress_cache_data=progress_cache_data,
|
||||
progress_cache_lock=progress_cache_lock,
|
||||
)
|
||||
dest = None # Connection handled by restore_gmail_with_labels
|
||||
elif args.folder:
|
||||
# Restore specific folder
|
||||
@ -1177,11 +1180,14 @@ def main():
|
||||
args.dest_delete,
|
||||
full_restore=args.full_restore,
|
||||
cache_root=local_path,
|
||||
progress_cache_file=progress_cache_file,
|
||||
progress_cache_data=progress_cache_data,
|
||||
progress_cache_lock=progress_cache_lock,
|
||||
)
|
||||
dest.logout()
|
||||
else:
|
||||
# Restore all folders
|
||||
folders = get_backup_folders(local_path)
|
||||
folders = imap_common.get_backup_folders(local_path)
|
||||
if not folders:
|
||||
print("No backup folders found.")
|
||||
sys.exit(1)
|
||||
@ -1208,6 +1214,9 @@ def main():
|
||||
args.dest_delete,
|
||||
full_restore=args.full_restore,
|
||||
cache_root=local_path,
|
||||
progress_cache_file=progress_cache_file,
|
||||
progress_cache_data=progress_cache_data,
|
||||
progress_cache_lock=progress_cache_lock,
|
||||
)
|
||||
|
||||
dest.logout()
|
||||
|
||||
@ -9,9 +9,9 @@ Tests cover:
|
||||
- Configuration validation
|
||||
"""
|
||||
|
||||
import imaplib
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@ -250,124 +250,6 @@ class TestGetExistingUids:
|
||||
result = backup_imap_emails.get_existing_uids(str(tmp_path))
|
||||
assert result == {"1"}
|
||||
|
||||
def test_os_error_handling(self, monkeypatch):
|
||||
"""Test handling of OS errors during listing."""
|
||||
|
||||
def mock_listdir(path):
|
||||
raise OSError("Access denied")
|
||||
|
||||
monkeypatch.setattr(os, "listdir", mock_listdir)
|
||||
|
||||
uids = backup_imap_emails.get_existing_uids("/some/path")
|
||||
assert len(uids) == 0
|
||||
|
||||
|
||||
class TestBackupErrorHandling:
|
||||
"""Tests for error handling scenarios in backup."""
|
||||
|
||||
def test_connection_error_in_worker(self, monkeypatch, tmp_path):
|
||||
"""Test worker handles connection failure gracefully."""
|
||||
# Mock get_imap_connection to fail
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", lambda *args: None)
|
||||
|
||||
# Should return None/Exit without crashing
|
||||
backup_imap_emails.process_batch([], "INBOX", ("h", "u", "p"), str(tmp_path))
|
||||
|
||||
def test_select_error_in_worker(self, monkeypatch, tmp_path):
|
||||
"""Test worker handles SELECT failure."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.select.side_effect = Exception("Select error")
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", lambda *args: mock_conn)
|
||||
|
||||
# Should log error and return
|
||||
backup_imap_emails.process_batch([], "INBOX", ("h", "u", "p"), str(tmp_path))
|
||||
mock_conn.select.assert_called()
|
||||
|
||||
def test_fetch_body_error(self, monkeypatch, tmp_path):
|
||||
"""Test handling of fetch body failure."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.select.return_value = "OK"
|
||||
|
||||
# Fetch body fails
|
||||
mock_conn.uid.return_value = ("NO", [None])
|
||||
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", lambda *args: mock_conn)
|
||||
|
||||
# Try processing one UID
|
||||
backup_imap_emails.process_batch([b"1"], "INBOX", ("h", "u", "p"), str(tmp_path))
|
||||
|
||||
# File should not exist
|
||||
assert not list(tmp_path.glob("*.eml"))
|
||||
|
||||
def test_write_error(self, monkeypatch, tmp_path):
|
||||
"""Test handling of file write error."""
|
||||
mock_conn = MagicMock()
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", lambda *args: mock_conn)
|
||||
|
||||
# Mock fetched data
|
||||
mock_conn.uid.return_value = ("OK", [(b"1 (RFC822 {10}", b"Content")])
|
||||
|
||||
# Mock open to fail
|
||||
def mock_open(*args, **kwargs):
|
||||
raise OSError("Disk full")
|
||||
|
||||
monkeypatch.setattr("builtins.open", mock_open)
|
||||
|
||||
backup_imap_emails.process_batch([b"1"], "INBOX", ("h", "u", "p"), str(tmp_path))
|
||||
|
||||
def test_folder_creation_error(self, monkeypatch, tmp_path):
|
||||
"""Test handling failure to create local folder."""
|
||||
|
||||
def mock_makedirs(path, exist_ok=False):
|
||||
raise OSError("Permission denied")
|
||||
|
||||
monkeypatch.setattr(os, "makedirs", mock_makedirs)
|
||||
|
||||
mock_conn = MagicMock()
|
||||
|
||||
# backup_folder should return early
|
||||
backup_imap_emails.backup_folder(mock_conn, "INBOX", str(tmp_path), ("h", "u", "p"))
|
||||
mock_conn.select.assert_not_called()
|
||||
|
||||
def test_select_folder_error(self, monkeypatch, tmp_path):
|
||||
"""Test handling of select folder failure in main loop."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.select.side_effect = Exception("Select failed")
|
||||
monkeypatch.setattr(os, "makedirs", lambda p, exist_ok: None)
|
||||
|
||||
backup_imap_emails.backup_folder(mock_conn, "INBOX", str(tmp_path), ("h", "u", "p"))
|
||||
mock_conn.uid.assert_not_called()
|
||||
|
||||
def test_search_error(self, monkeypatch, tmp_path):
|
||||
"""Test handling of search failure."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.uid.return_value = ("NO", [])
|
||||
monkeypatch.setattr(os, "makedirs", lambda p, exist_ok: None)
|
||||
|
||||
backup_imap_emails.backup_folder(mock_conn, "INBOX", str(tmp_path), ("h", "u", "p"))
|
||||
|
||||
def test_main_makedirs_error(self, monkeypatch, capsys):
|
||||
"""Test failure to create main backup directory."""
|
||||
env = {
|
||||
"SRC_IMAP_HOST": "h",
|
||||
"SRC_IMAP_USERNAME": "u",
|
||||
"SRC_IMAP_PASSWORD": "p",
|
||||
}
|
||||
monkeypatch.setattr(os, "environ", env)
|
||||
monkeypatch.setattr(sys, "argv", ["backup.py", "--dest-path", "/protected/path"])
|
||||
|
||||
def mock_makedirs(path):
|
||||
raise OSError("No permission")
|
||||
|
||||
monkeypatch.setattr(os, "makedirs", mock_makedirs)
|
||||
monkeypatch.setattr(os.path, "exists", lambda p: False)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
backup_imap_emails.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Error creating backup directory" in captured.out
|
||||
|
||||
|
||||
class TestGmailLabelsPreservation:
|
||||
"""Tests for Gmail labels manifest functionality."""
|
||||
@ -419,173 +301,77 @@ class TestGmailLabelsPreservation:
|
||||
result = backup_imap_emails.load_labels_manifest(str(tmp_path))
|
||||
assert result == {}
|
||||
|
||||
def test_get_message_ids_in_folder(self, monkeypatch):
|
||||
"""Test extraction of message IDs from a folder."""
|
||||
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
|
||||
]
|
||||
def test_get_message_ids_in_folder(self, single_mock_server):
|
||||
src_data = {
|
||||
"INBOX": [
|
||||
b"Subject: A\r\nMessage-ID: <msg1@test.com>\r\n\r\nBody",
|
||||
b"Subject: B\r\nMessage-ID: <msg2@test.com>\r\n\r\nBody",
|
||||
]
|
||||
}
|
||||
_server, port = single_mock_server(src_data)
|
||||
|
||||
result = backup_imap_emails.get_message_ids_in_folder(mock_conn, "INBOX", None)
|
||||
conn = imaplib.IMAP4("localhost", port)
|
||||
conn.login("user", "pass")
|
||||
|
||||
result = backup_imap_emails.get_message_ids_in_folder(conn, "INBOX", None)
|
||||
|
||||
assert "<msg1@test.com>" in result
|
||||
assert "<msg2@test.com>" in result
|
||||
assert "<msg3@test.com>" in result
|
||||
conn.logout()
|
||||
|
||||
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
|
||||
]
|
||||
def test_get_message_info_in_folder_read_status(self, single_mock_server):
|
||||
src_data = {
|
||||
"INBOX": [
|
||||
{"uid": 1, "flags": {"\\Seen"}, "content": b"Message-ID: <msg1@test.com>\r\n"},
|
||||
{"uid": 2, "flags": set(), "content": b"Message-ID: <msg2@test.com>\r\n"},
|
||||
{"uid": 3, "flags": {"\\Seen", "\\Answered"}, "content": b"Message-ID: <msg3@test.com>\r\n"},
|
||||
]
|
||||
}
|
||||
_server, port = single_mock_server(src_data)
|
||||
|
||||
result = backup_imap_emails.get_message_info_in_folder(mock_conn, "INBOX", None)
|
||||
conn = imaplib.IMAP4("localhost", port)
|
||||
conn.login("user", "pass")
|
||||
|
||||
result = backup_imap_emails.get_message_info_in_folder(conn, "INBOX", None)
|
||||
|
||||
assert "<msg1@test.com>" in result
|
||||
assert "\\Seen" in result["<msg1@test.com>"]["flags"] # Has \Seen flag
|
||||
assert "\\Seen" in result["<msg1@test.com>"]["flags"]
|
||||
assert "<msg2@test.com>" in result
|
||||
assert result["<msg2@test.com>"]["flags"] == [] # No flags
|
||||
assert result["<msg2@test.com>"]["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
|
||||
assert "\\Seen" in result["<msg3@test.com>"]["flags"]
|
||||
assert "\\Answered" in result["<msg3@test.com>"]["flags"]
|
||||
conn.logout()
|
||||
|
||||
def test_get_message_ids_in_folder_with_progress(self, monkeypatch):
|
||||
"""Test extraction of message IDs with progress callback."""
|
||||
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")",
|
||||
],
|
||||
), # fetch result
|
||||
]
|
||||
|
||||
progress_calls = []
|
||||
|
||||
def progress_cb(current, total):
|
||||
progress_calls.append((current, total))
|
||||
|
||||
backup_imap_emails.get_message_ids_in_folder(mock_conn, "INBOX", progress_cb)
|
||||
|
||||
# Progress should have been called
|
||||
assert len(progress_calls) > 0
|
||||
# Last call should show completion
|
||||
assert progress_calls[-1][0] == progress_calls[-1][1]
|
||||
|
||||
def test_get_message_ids_in_folder_select_error(self, monkeypatch):
|
||||
"""Test handling of folder select error."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.select.side_effect = Exception("Select failed")
|
||||
|
||||
result = backup_imap_emails.get_message_ids_in_folder(mock_conn, "INBOX")
|
||||
assert result == set()
|
||||
|
||||
def test_get_message_ids_in_folder_empty(self, monkeypatch):
|
||||
"""Test extraction from empty folder."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.select.return_value = ("OK", [b"0"])
|
||||
mock_conn.uid.return_value = ("OK", [b""])
|
||||
|
||||
result = backup_imap_emails.get_message_ids_in_folder(mock_conn, "INBOX", None)
|
||||
assert result == set()
|
||||
|
||||
def test_build_labels_manifest(self, monkeypatch, tmp_path):
|
||||
"""Test building labels manifest from mock folders."""
|
||||
mock_conn = MagicMock()
|
||||
|
||||
# Mock folder list - simulate Gmail structure
|
||||
mock_conn.list.return_value = (
|
||||
"OK",
|
||||
[
|
||||
b'(\\HasNoChildren) "/" "INBOX"',
|
||||
b'(\\HasNoChildren) "/" "Work"',
|
||||
b'(\\HasNoChildren) "/" "[Gmail]/All Mail"',
|
||||
b'(\\HasNoChildren) "/" "[Gmail]/Sent Mail"',
|
||||
def test_build_labels_manifest(self, single_mock_server, tmp_path):
|
||||
src_data = {
|
||||
"INBOX": [b"Message-ID: <msg1@test.com>\r\n"],
|
||||
"Work": [b"Message-ID: <msg1@test.com>\r\n"],
|
||||
"[Gmail]/Sent Mail": [b"Message-ID: <msg2@test.com>\r\n"],
|
||||
"[Gmail]/All Mail": [
|
||||
{"uid": 1, "flags": {"\\Seen", "\\Flagged"}, "content": b"Message-ID: <msg1@test.com>\r\n"},
|
||||
{"uid": 2, "flags": set(), "content": b"Message-ID: <msg2@test.com>\r\n"},
|
||||
],
|
||||
)
|
||||
|
||||
# Track which folder is selected
|
||||
folder_data = {
|
||||
"INBOX": {"<msg1@test.com>", "<msg2@test.com>"},
|
||||
"Work": {"<msg1@test.com>"},
|
||||
"[Gmail]/Sent Mail": {"<msg2@test.com>"},
|
||||
}
|
||||
_server, port = single_mock_server(src_data)
|
||||
|
||||
# Mock info for All Mail (with flags)
|
||||
all_mail_info = {
|
||||
"<msg1@test.com>": {"flags": ["\\Seen", "\\Flagged"]},
|
||||
"<msg2@test.com>": {"flags": []},
|
||||
}
|
||||
conn = imaplib.IMAP4("localhost", port)
|
||||
conn.login("user", "pass")
|
||||
|
||||
def mock_get_message_info_with_conf(conn, folder, src_conf, progress_cb=None):
|
||||
if folder == "[Gmail]/All Mail":
|
||||
return (all_mail_info, conn)
|
||||
return ({}, conn)
|
||||
result = backup_imap_emails.build_labels_manifest(conn, str(tmp_path))
|
||||
|
||||
def mock_get_message_ids_with_conf(conn, folder, src_conf, progress_cb=None):
|
||||
return (folder_data.get(folder, set()), conn)
|
||||
|
||||
monkeypatch.setattr(backup_imap_emails, "get_message_ids_in_folder_with_conf", mock_get_message_ids_with_conf)
|
||||
monkeypatch.setattr(backup_imap_emails, "get_message_info_in_folder_with_conf", mock_get_message_info_with_conf)
|
||||
|
||||
result = backup_imap_emails.build_labels_manifest(mock_conn, str(tmp_path))
|
||||
|
||||
# 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>"]["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"
|
||||
assert manifest_path.exists()
|
||||
|
||||
def test_build_labels_manifest_list_error(self, monkeypatch, tmp_path):
|
||||
"""Test handling of folder list error."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.list.return_value = ("NO", [])
|
||||
|
||||
result = backup_imap_emails.build_labels_manifest(mock_conn, str(tmp_path))
|
||||
assert result == {}
|
||||
conn.logout()
|
||||
|
||||
def test_preserve_labels_flag_integration(self, single_mock_server, monkeypatch, tmp_path):
|
||||
"""Test --preserve-labels flag creates manifest."""
|
||||
|
||||
@ -13,7 +13,6 @@ Tests cover:
|
||||
import imaplib
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@ -171,115 +170,6 @@ class TestGetEmailCount:
|
||||
conn.logout()
|
||||
|
||||
|
||||
class TestCompareFoldersErrorHandling:
|
||||
"""Tests for error handling in compare_imap_folders.py"""
|
||||
|
||||
def test_connection_failure(self, monkeypatch):
|
||||
"""Test graceful exit when connection fails."""
|
||||
mock_get = MagicMock(return_value=None)
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", mock_get)
|
||||
|
||||
# Test source fail
|
||||
env = {
|
||||
"SRC_IMAP_HOST": "h",
|
||||
"SRC_IMAP_USERNAME": "u",
|
||||
"SRC_IMAP_PASSWORD": "p",
|
||||
"DEST_IMAP_HOST": "h",
|
||||
"DEST_IMAP_USERNAME": "u",
|
||||
"DEST_IMAP_PASSWORD": "p",
|
||||
}
|
||||
monkeypatch.setattr(os, "environ", env)
|
||||
monkeypatch.setattr(sys, "argv", ["compare_imap_folders.py"])
|
||||
|
||||
compare_imap_folders.main()
|
||||
# Should call once and exit
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
def test_dest_connection_failure(self, monkeypatch):
|
||||
"""Test graceful exit when destination connection fails."""
|
||||
# Source OK, Dest None
|
||||
mock_src = MagicMock()
|
||||
mock_src.list.return_value = ("OK", [rb'(\HasNoChildren) "/" "INBOX"'])
|
||||
|
||||
def side_effect(h, u, p):
|
||||
if u == "src_u":
|
||||
return mock_src
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", side_effect)
|
||||
|
||||
env = {
|
||||
"SRC_IMAP_HOST": "h",
|
||||
"SRC_IMAP_USERNAME": "src_u",
|
||||
"SRC_IMAP_PASSWORD": "p",
|
||||
"DEST_IMAP_HOST": "h",
|
||||
"DEST_IMAP_USERNAME": "dest_u",
|
||||
"DEST_IMAP_PASSWORD": "p",
|
||||
}
|
||||
monkeypatch.setattr(os, "environ", env)
|
||||
monkeypatch.setattr(sys, "argv", ["compare_imap_folders.py"])
|
||||
|
||||
compare_imap_folders.main()
|
||||
|
||||
def test_list_failure(self, monkeypatch, capsys):
|
||||
"""Test handling of LIST command failure."""
|
||||
mock_src = MagicMock()
|
||||
mock_src.list.return_value = ("NO", [])
|
||||
mock_dest = MagicMock()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"imap_common.get_imap_connection", lambda h, u, p, oauth2_token=None: mock_src if u == "s" else mock_dest
|
||||
)
|
||||
|
||||
env = {
|
||||
"SRC_IMAP_HOST": "h",
|
||||
"SRC_IMAP_USERNAME": "s",
|
||||
"SRC_IMAP_PASSWORD": "p",
|
||||
"DEST_IMAP_HOST": "h",
|
||||
"DEST_IMAP_USERNAME": "d",
|
||||
"DEST_IMAP_PASSWORD": "p",
|
||||
}
|
||||
monkeypatch.setattr(os, "environ", env)
|
||||
monkeypatch.setattr(sys, "argv", ["compare_imap_folders.py"])
|
||||
|
||||
compare_imap_folders.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Failed to list source folders" in captured.out
|
||||
|
||||
def test_select_failure(self, single_mock_server):
|
||||
"""Test get_email_count handles select failure."""
|
||||
data = {"INBOX": []}
|
||||
_, port = single_mock_server(data)
|
||||
conn = imaplib.IMAP4("localhost", port)
|
||||
conn.login("user", "pass")
|
||||
|
||||
# Mocking select failure on a real connection object is hard without
|
||||
# using a pure mock. So let's use a Mock object instead of real conn.
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.select.return_value = ("NO", [b"Error"])
|
||||
|
||||
result = compare_imap_folders.get_email_count(mock_conn, "INBOX")
|
||||
assert result is None
|
||||
|
||||
def test_search_failure(self, single_mock_server):
|
||||
"""Test get_email_count handles search failure."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.select.return_value = ("OK", [b"Selected"])
|
||||
mock_conn.search.return_value = ("NO", [b"Error"])
|
||||
|
||||
result = compare_imap_folders.get_email_count(mock_conn, "INBOX")
|
||||
assert result is None
|
||||
|
||||
def test_imap_exception_in_count(self):
|
||||
"""Test exception handling in get_email_count."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.select.side_effect = imaplib.IMAP4.error("Crash")
|
||||
|
||||
result = compare_imap_folders.get_email_count(mock_conn, "INBOX")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestConfigValidation:
|
||||
"""Tests for configuration validation."""
|
||||
|
||||
|
||||
@ -9,16 +9,15 @@ Tests cover:
|
||||
- Configuration validation
|
||||
"""
|
||||
|
||||
import imaplib
|
||||
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 count_imap_emails
|
||||
import imap_common
|
||||
from conftest import make_single_mock_connection
|
||||
|
||||
|
||||
@ -98,102 +97,154 @@ class TestLocalEmailCounting:
|
||||
assert "TOTAL" in captured.out
|
||||
assert "3" in captured.out
|
||||
|
||||
def test_count_local_ignores_hidden_dirs(self, tmp_path, capsys):
|
||||
inbox_path = tmp_path / "INBOX"
|
||||
inbox_path.mkdir()
|
||||
(inbox_path / "1_a.eml").write_bytes(b"Subject: A\r\n\r\nBody")
|
||||
(inbox_path / "note.txt").write_text("ignore")
|
||||
|
||||
class TestEmailCountingErrors:
|
||||
"""Tests for error handling in email counting."""
|
||||
hidden_path = tmp_path / ".hidden"
|
||||
hidden_path.mkdir()
|
||||
(hidden_path / "1_hidden.eml").write_bytes(b"Subject: Hidden\r\n\r\nBody")
|
||||
|
||||
def test_connection_failure(self, monkeypatch):
|
||||
"""Test graceful exit when connection fails."""
|
||||
mock_get = MagicMock(return_value=None)
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", mock_get)
|
||||
cache_path = tmp_path / "__pycache__"
|
||||
cache_path.mkdir()
|
||||
(cache_path / "1_cache.eml").write_bytes(b"Subject: Cache\r\n\r\nBody")
|
||||
|
||||
# Should return silently without raising
|
||||
count_imap_emails.count_emails("host", "user", "pass")
|
||||
mock_get.assert_called_once()
|
||||
nested_path = tmp_path / "Projects" / "Sub"
|
||||
nested_path.mkdir(parents=True)
|
||||
(nested_path / "1_sub.eml").write_bytes(b"Subject: Sub\r\n\r\nBody")
|
||||
|
||||
def test_list_command_failure(self, monkeypatch):
|
||||
"""Test handling of LIST command failure."""
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.list.return_value = ("NO", [])
|
||||
mock_get = MagicMock(return_value=mock_mail)
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", mock_get)
|
||||
|
||||
count_imap_emails.count_emails("host", "user", "pass")
|
||||
mock_mail.list.assert_called_once()
|
||||
# Should exit early, so select should not be called
|
||||
mock_mail.select.assert_not_called()
|
||||
|
||||
def test_select_command_failure(self, monkeypatch):
|
||||
"""Test handling of SELECT command failure for a folder."""
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.list.return_value = ("OK", [rb'(\HasNoChildren) "/" "INBOX"'])
|
||||
# Fail selection
|
||||
mock_mail.select.return_value = ("NO", [b"Select failed"])
|
||||
|
||||
mock_get = MagicMock(return_value=mock_mail)
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", mock_get)
|
||||
monkeypatch.setattr("imap_common.normalize_folder_name", lambda x: "INBOX")
|
||||
|
||||
count_imap_emails.count_emails("host", "user", "pass")
|
||||
|
||||
mock_mail.select.assert_called_once()
|
||||
# Should skip search for this folder
|
||||
mock_mail.search.assert_not_called()
|
||||
|
||||
def test_search_command_failure(self, monkeypatch, capsys):
|
||||
"""Test handling of SEARCH command failure."""
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.list.return_value = ("OK", [rb'(\HasNoChildren) "/" "INBOX"'])
|
||||
mock_mail.select.return_value = ("OK", [b"Selected"])
|
||||
# Fail search
|
||||
mock_mail.search.return_value = ("NO", [b"Search failed"])
|
||||
|
||||
mock_get = MagicMock(return_value=mock_mail)
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", mock_get)
|
||||
monkeypatch.setattr("imap_common.normalize_folder_name", lambda x: "INBOX")
|
||||
|
||||
count_imap_emails.count_emails("host", "user", "pass")
|
||||
count_imap_emails.count_local_emails(str(tmp_path))
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Error" in captured.out
|
||||
assert "INBOX" in captured.out
|
||||
assert "Projects/Sub" in captured.out
|
||||
assert ".hidden" not in captured.out
|
||||
assert "__pycache__" not in captured.out
|
||||
|
||||
def test_imap_exception_during_list(self, monkeypatch, capsys):
|
||||
"""Test handling of IMAP4 exception during list command."""
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.list.side_effect = imaplib.IMAP4.error("Crash listing")
|
||||
mock_get = MagicMock(return_value=mock_mail)
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", mock_get)
|
||||
def test_get_local_email_count_unreadable_folder(self, tmp_path):
|
||||
inbox_path = tmp_path / "INBOX"
|
||||
inbox_path.mkdir()
|
||||
(inbox_path / "1_a.eml").write_bytes(b"Subject: A\r\n\r\nBody")
|
||||
|
||||
count_imap_emails.count_emails("host", "user", "pass")
|
||||
os.chmod(inbox_path, 0)
|
||||
try:
|
||||
result = imap_common.get_local_email_count(str(tmp_path), "INBOX")
|
||||
assert result is None
|
||||
finally:
|
||||
os.chmod(inbox_path, 0o700)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Failed to list mailboxes" in captured.out
|
||||
|
||||
def test_imap_exception_during_select(self, monkeypatch, capsys):
|
||||
"""Test handling of IMAP4 exception during folder selection."""
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.list.return_value = ("OK", [rb'(\HasNoChildren) "/" "INBOX"'])
|
||||
mock_mail.select.side_effect = imaplib.IMAP4.error("Crash selecting")
|
||||
class TestImapCommonHelpers:
|
||||
"""Tests for imap_common helpers via script tests."""
|
||||
|
||||
mock_get = MagicMock(return_value=mock_mail)
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", mock_get)
|
||||
monkeypatch.setattr("imap_common.normalize_folder_name", lambda x: "INBOX")
|
||||
def test_list_selectable_folders_filters_noselect(self):
|
||||
class FakeConn:
|
||||
def list(self):
|
||||
return (
|
||||
"OK",
|
||||
[
|
||||
b'(\\Noselect) "/" "Archive"',
|
||||
b'(\\HasNoChildren) "/" "INBOX"',
|
||||
'(\\HasNoChildren) "/" "Sent"',
|
||||
],
|
||||
)
|
||||
|
||||
count_imap_emails.count_emails("host", "user", "pass")
|
||||
result = imap_common.list_selectable_folders(FakeConn())
|
||||
assert result == ["INBOX", "Sent"]
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Should print Error for that folder
|
||||
assert "Error" in captured.out
|
||||
def test_list_selectable_folders_list_error(self):
|
||||
class FakeConn:
|
||||
def list(self):
|
||||
return ("NO", [])
|
||||
|
||||
def test_generic_exception(self, monkeypatch, capsys):
|
||||
"""Test handling of generic connection/runtime exceptions."""
|
||||
mock_get = MagicMock(side_effect=Exception("Generic Crash"))
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", mock_get)
|
||||
result = imap_common.list_selectable_folders(FakeConn())
|
||||
assert result == []
|
||||
|
||||
count_imap_emails.count_emails("host", "user", "pass")
|
||||
def test_list_selectable_folders_exception(self):
|
||||
class FakeConn:
|
||||
def list(self):
|
||||
raise Exception("list failed")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "An error occurred: Generic Crash" in captured.out
|
||||
result = imap_common.list_selectable_folders(FakeConn())
|
||||
assert result == []
|
||||
|
||||
def test_get_imap_connection_oauth2_uses_authenticate(self, monkeypatch):
|
||||
class FakeIMAP:
|
||||
def __init__(self, _host):
|
||||
self.auth_called = False
|
||||
self.login_called = False
|
||||
|
||||
def authenticate(self, _mechanism, auth_cb):
|
||||
self.auth_called = True
|
||||
auth_cb(None)
|
||||
|
||||
def login(self, _user, _password):
|
||||
self.login_called = True
|
||||
|
||||
monkeypatch.setattr(imap_common.imaplib, "IMAP4_SSL", FakeIMAP)
|
||||
|
||||
conn = imap_common.get_imap_connection("host", "user", oauth2_token="token")
|
||||
|
||||
assert isinstance(conn, FakeIMAP)
|
||||
assert conn.auth_called is True
|
||||
assert conn.login_called is False
|
||||
|
||||
def test_get_imap_connection_basic_login(self, monkeypatch):
|
||||
class FakeIMAP:
|
||||
def __init__(self, _host):
|
||||
self.auth_called = False
|
||||
self.login_called = False
|
||||
|
||||
def authenticate(self, _mechanism, _auth_cb):
|
||||
self.auth_called = True
|
||||
|
||||
def login(self, _user, _password):
|
||||
self.login_called = True
|
||||
|
||||
monkeypatch.setattr(imap_common.imaplib, "IMAP4_SSL", FakeIMAP)
|
||||
|
||||
conn = imap_common.get_imap_connection("host", "user", password="pass")
|
||||
|
||||
assert isinstance(conn, FakeIMAP)
|
||||
assert conn.login_called is True
|
||||
assert conn.auth_called is False
|
||||
|
||||
def test_ensure_connection_returns_same_conn_when_healthy(self):
|
||||
class GoodConn:
|
||||
def __init__(self):
|
||||
self.noop_calls = 0
|
||||
|
||||
def noop(self):
|
||||
self.noop_calls += 1
|
||||
|
||||
conn = GoodConn()
|
||||
result = imap_common.ensure_connection(conn, "host", "user", "pass")
|
||||
assert result is conn
|
||||
assert conn.noop_calls == 1
|
||||
|
||||
def test_ensure_connection_reconnects_on_noop_error(self, monkeypatch):
|
||||
class BadConn:
|
||||
def noop(self):
|
||||
raise Exception("fail")
|
||||
|
||||
new_conn = object()
|
||||
monkeypatch.setattr(imap_common, "get_imap_connection", lambda *args, **kwargs: new_conn)
|
||||
|
||||
result = imap_common.ensure_connection(BadConn(), "host", "user", "pass")
|
||||
assert result is new_conn
|
||||
|
||||
def test_ensure_connection_from_conf_reconnects_on_noop_error(self, monkeypatch):
|
||||
class BadConn:
|
||||
def noop(self):
|
||||
raise Exception("fail")
|
||||
|
||||
new_conn = object()
|
||||
monkeypatch.setattr(imap_common, "get_imap_connection_from_conf", lambda _conf: new_conn)
|
||||
|
||||
result = imap_common.ensure_connection_from_conf(BadConn(), {"host": "h", "user": "u"})
|
||||
assert result is new_conn
|
||||
|
||||
|
||||
class TestMainFunction:
|
||||
|
||||
@ -19,6 +19,7 @@ import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))
|
||||
|
||||
import imap_common
|
||||
import migrate_imap_emails
|
||||
from conftest import make_mock_connection
|
||||
|
||||
@ -356,6 +357,82 @@ class TestGmailModeLabels:
|
||||
assert "Work" in dest_server.folders
|
||||
assert len(dest_server.folders["Work"]) == 2
|
||||
|
||||
def test_gmail_mode_fallback_folder_for_unlabeled(self, mock_server_factory, monkeypatch):
|
||||
msg = b"Subject: Unlabeled\r\nMessage-ID: <gm-unlabeled@test>\r\n\r\nBody"
|
||||
|
||||
src_data = {
|
||||
"[Gmail]/All Mail": [msg],
|
||||
}
|
||||
dest_data = {"INBOX": []}
|
||||
|
||||
_, dest_server, p1, p2 = mock_server_factory(src_data, dest_data)
|
||||
|
||||
env = {
|
||||
"SRC_IMAP_HOST": "localhost",
|
||||
"SRC_IMAP_USERNAME": "src_user",
|
||||
"SRC_IMAP_PASSWORD": "p",
|
||||
"DEST_IMAP_HOST": "localhost",
|
||||
"DEST_IMAP_USERNAME": "dest_user",
|
||||
"DEST_IMAP_PASSWORD": "p",
|
||||
"MAX_WORKERS": "1",
|
||||
"GMAIL_MODE": "true",
|
||||
}
|
||||
monkeypatch.setattr(os, "environ", env)
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", make_mock_connection(p1, p2))
|
||||
|
||||
migrate_imap_emails.main()
|
||||
|
||||
assert imap_common.FOLDER_RESTORED_UNLABELED in dest_server.folders
|
||||
assert len(dest_server.folders[imap_common.FOLDER_RESTORED_UNLABELED]) == 1
|
||||
|
||||
|
||||
class TestCacheHitWithoutLock:
|
||||
"""Covers cached skip when no lock is provided."""
|
||||
|
||||
def test_cached_skip_without_lock(self, mock_server_factory):
|
||||
msg_id = "<cache-no-lock@test>"
|
||||
msg = f"Subject: Cached\r\nMessage-ID: {msg_id}\r\n\r\nBody".encode()
|
||||
src_data = {"INBOX": [msg]}
|
||||
dest_data = {"INBOX": []}
|
||||
|
||||
src_server, dest_server, p1, p2 = mock_server_factory(src_data, dest_data)
|
||||
|
||||
src = imaplib.IMAP4("localhost", p1)
|
||||
dest = imaplib.IMAP4("localhost", p2)
|
||||
src.login("src_user", "p")
|
||||
dest.login("dest_user", "p")
|
||||
|
||||
src.select('"INBOX"', readonly=False)
|
||||
|
||||
existing_dest_msg_ids = {msg_id}
|
||||
success, _src, _dest, deleted = migrate_imap_emails.process_single_uid(
|
||||
src,
|
||||
dest,
|
||||
b"1",
|
||||
"INBOX",
|
||||
False,
|
||||
None,
|
||||
False,
|
||||
False,
|
||||
None,
|
||||
True,
|
||||
False,
|
||||
existing_dest_msg_ids=existing_dest_msg_ids,
|
||||
existing_dest_msg_ids_lock=None,
|
||||
progress_cache_path=None,
|
||||
progress_cache_data=None,
|
||||
progress_cache_lock=None,
|
||||
dest_host="localhost",
|
||||
dest_user="dest_user",
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert deleted == 0
|
||||
assert len(dest_server.folders["INBOX"]) == 0
|
||||
|
||||
src.logout()
|
||||
dest.logout()
|
||||
|
||||
|
||||
class TestConfigValidation:
|
||||
"""Tests for configuration validation."""
|
||||
@ -454,6 +531,32 @@ class TestMigrateErrorHandling:
|
||||
|
||||
migrate_imap_emails.main()
|
||||
|
||||
def test_select_error_in_process_batch(self, mock_server_factory, monkeypatch):
|
||||
"""Cover process_batch select exception handling with real server data."""
|
||||
src_data = {"INBOX": [b"Subject: Test\r\nMessage-ID: <1>\r\n\r\nBody"]}
|
||||
dest_data = {"INBOX": []}
|
||||
|
||||
_src_server, _dest_server, p1, p2 = mock_server_factory(src_data, dest_data)
|
||||
|
||||
def raise_select(_self, _mailbox, readonly=False):
|
||||
raise RuntimeError("Select failed")
|
||||
|
||||
monkeypatch.setattr(imaplib.IMAP4, "select", raise_select)
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", make_mock_connection(p1, p2))
|
||||
|
||||
src_conf = {"host": "localhost", "user": "src_user", "password": "p"}
|
||||
dest_conf = {"host": "localhost", "user": "dest_user", "password": "p"}
|
||||
|
||||
migrate_imap_emails.process_batch(
|
||||
[b"1"],
|
||||
"INBOX",
|
||||
src_conf,
|
||||
dest_conf,
|
||||
delete_from_source=False,
|
||||
preserve_flags=False,
|
||||
gmail_mode=False,
|
||||
)
|
||||
|
||||
def test_fetch_error_in_worker(self, mock_server_factory, monkeypatch):
|
||||
"""Test error handling when fetching message details fails."""
|
||||
src_data = {"INBOX": [b"Subject: Test\r\nMessage-ID: <1>\r\n\r\nBody"]}
|
||||
@ -506,6 +609,35 @@ class TestMigrateErrorHandling:
|
||||
migrate_imap_emails.main()
|
||||
assert exc.value.code == 1
|
||||
|
||||
def test_main_logs_progress_cache_load_failure(self, mock_server_factory, monkeypatch, capsys):
|
||||
"""Cover progress cache load exception in main."""
|
||||
src_data = {"INBOX": [b"Subject: X\r\nMessage-ID: <x@test>\r\n\r\nBody"]}
|
||||
dest_data = {"INBOX": []}
|
||||
|
||||
_src_server, _dest_server, p1, p2 = mock_server_factory(src_data, dest_data)
|
||||
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", make_mock_connection(p1, p2))
|
||||
monkeypatch.setattr(
|
||||
imap_common, "load_progress_cache", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))
|
||||
)
|
||||
|
||||
env = {
|
||||
"SRC_IMAP_HOST": "localhost",
|
||||
"SRC_IMAP_USERNAME": "src_user",
|
||||
"SRC_IMAP_PASSWORD": "p",
|
||||
"DEST_IMAP_HOST": "localhost",
|
||||
"DEST_IMAP_USERNAME": "dest_user",
|
||||
"DEST_IMAP_PASSWORD": "p",
|
||||
"MAX_WORKERS": "1",
|
||||
}
|
||||
monkeypatch.setattr(os, "environ", env)
|
||||
monkeypatch.setattr(sys, "argv", ["migrate_imap_emails.py", "--migrate-cache", "./cache"])
|
||||
|
||||
migrate_imap_emails.main()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Warning: Failed to load progress cache" in captured.out
|
||||
|
||||
|
||||
class TestTrashHandling:
|
||||
"""Tests for trash folder related logic."""
|
||||
@ -571,6 +703,58 @@ class TestTrashHandling:
|
||||
assert len(src_server.folders["Trash"]) == 1
|
||||
|
||||
|
||||
class TestCommonMessageParsing:
|
||||
"""Covers imap_common message parsing helpers used by migrate."""
|
||||
|
||||
def test_parse_message_id_from_empty_bytes(self):
|
||||
assert imap_common.parse_message_id_from_bytes(b"") is None
|
||||
|
||||
def test_parse_message_id_and_subject_from_empty_bytes(self):
|
||||
msg_id, subject = imap_common.parse_message_id_and_subject_from_bytes(b"")
|
||||
assert msg_id is None
|
||||
assert subject == "(No Subject)"
|
||||
|
||||
def test_get_uid_to_message_id_map_empty(self):
|
||||
result = imap_common.get_uid_to_message_id_map(object(), [])
|
||||
assert result == {}
|
||||
|
||||
def test_extract_message_id_invalid_type(self):
|
||||
assert imap_common.extract_message_id(123) is None
|
||||
|
||||
def test_parse_message_id_from_invalid_type(self):
|
||||
assert imap_common.parse_message_id_from_bytes(123) is None
|
||||
|
||||
def test_parse_message_id_from_bytes_success(self):
|
||||
raw_message = b"Subject: X\r\nMessage-ID: <ok@test>\r\n\r\nBody"
|
||||
assert imap_common.parse_message_id_from_bytes(raw_message) == "<ok@test>"
|
||||
|
||||
def test_parse_message_id_and_subject_from_invalid_type(self):
|
||||
msg_id, subject = imap_common.parse_message_id_and_subject_from_bytes(123)
|
||||
assert msg_id is None
|
||||
assert subject == "(No Subject)"
|
||||
|
||||
def test_get_uid_to_message_id_map_missing_uid(self):
|
||||
class FakeConn:
|
||||
def uid(self, _cmd, _uids, _opts):
|
||||
return (
|
||||
"OK",
|
||||
[
|
||||
(
|
||||
b"1 (BODY[HEADER.FIELDS (MESSAGE-ID)] {40}",
|
||||
b"Message-ID: <x@test>\r\n",
|
||||
),
|
||||
b")",
|
||||
],
|
||||
)
|
||||
|
||||
result = imap_common.get_uid_to_message_id_map(FakeConn(), [b"1"])
|
||||
assert result == {}
|
||||
|
||||
def test_decode_mime_header_exception_path(self):
|
||||
result = imap_common.decode_mime_header(["not", "a", "header"])
|
||||
assert result == "['not', 'a', 'header']"
|
||||
|
||||
|
||||
class TestFilterPreservableFlags:
|
||||
"""Tests for filter_preservable_flags function."""
|
||||
|
||||
@ -733,3 +917,41 @@ class TestDestDeleteFunctionality:
|
||||
|
||||
# All dest emails should be deleted
|
||||
assert len(dest_server.folders["INBOX"]) == 0
|
||||
|
||||
def test_dest_delete_syncs_after_migration(self, mock_server_factory, monkeypatch):
|
||||
"""End-to-end: delete orphans after a successful migration batch."""
|
||||
src_data = {"INBOX": [b"Subject: Keep\r\nMessage-ID: <keep@test>\r\n\r\nBody"]}
|
||||
dest_data = {
|
||||
"INBOX": [
|
||||
b"Subject: Keep\r\nMessage-ID: <keep@test>\r\n\r\nBody",
|
||||
b"Subject: Orphan\r\nMessage-ID: <orphan@test>\r\n\r\nBody",
|
||||
]
|
||||
}
|
||||
|
||||
src_server, dest_server, p1, p2 = mock_server_factory(src_data, dest_data)
|
||||
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", make_mock_connection(p1, p2))
|
||||
|
||||
src = imaplib.IMAP4("localhost", p1)
|
||||
dest = imaplib.IMAP4("localhost", p2)
|
||||
src.login("src_user", "p")
|
||||
dest.login("dest_user", "p")
|
||||
|
||||
migrate_imap_emails.MAX_WORKERS = 1
|
||||
migrate_imap_emails.BATCH_SIZE = 1
|
||||
|
||||
migrate_imap_emails.migrate_folder(
|
||||
src,
|
||||
dest,
|
||||
"INBOX",
|
||||
False,
|
||||
{"host": "localhost", "user": "src_user", "password": "p"},
|
||||
{"host": "localhost", "user": "dest_user", "password": "p"},
|
||||
dest_delete=True,
|
||||
)
|
||||
|
||||
assert len(dest_server.folders["INBOX"]) == 1
|
||||
assert b"Message-ID: <keep@test>" in dest_server.folders["INBOX"][0]["content"]
|
||||
|
||||
src.logout()
|
||||
dest.logout()
|
||||
|
||||
248
test/test_migrate_with_cache.py
Normal file
248
test/test_migrate_with_cache.py
Normal file
@ -0,0 +1,248 @@
|
||||
"""End-to-end tests for migrate_imap_emails.py cache behavior."""
|
||||
|
||||
import imaplib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))
|
||||
|
||||
import imap_common
|
||||
import migrate_imap_emails
|
||||
import restore_cache
|
||||
from conftest import make_mock_connection
|
||||
|
||||
|
||||
def _run_migrate(monkeypatch, cache_dir, src_port, dest_port, full_migrate=False, extra_env=None):
|
||||
env = {
|
||||
"SRC_IMAP_HOST": "localhost",
|
||||
"SRC_IMAP_USERNAME": "src",
|
||||
"SRC_IMAP_PASSWORD": "p",
|
||||
"DEST_IMAP_HOST": "localhost",
|
||||
"DEST_IMAP_USERNAME": "dest",
|
||||
"DEST_IMAP_PASSWORD": "p",
|
||||
"MAX_WORKERS": "1",
|
||||
}
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
monkeypatch.setattr(os, "environ", env)
|
||||
|
||||
argv = [
|
||||
"migrate_imap_emails.py",
|
||||
"--src-host",
|
||||
"localhost",
|
||||
"--src-user",
|
||||
"src",
|
||||
"--src-pass",
|
||||
"p",
|
||||
"--dest-host",
|
||||
"localhost",
|
||||
"--dest-user",
|
||||
"dest",
|
||||
"--dest-pass",
|
||||
"p",
|
||||
"--migrate-cache",
|
||||
str(cache_dir),
|
||||
"--workers",
|
||||
"1",
|
||||
]
|
||||
if full_migrate:
|
||||
argv.append("--full-migrate")
|
||||
|
||||
monkeypatch.setattr(sys, "argv", argv)
|
||||
monkeypatch.setattr(
|
||||
migrate_imap_emails.imap_common,
|
||||
"get_imap_connection",
|
||||
make_mock_connection(src_port, dest_port, "src", "dest"),
|
||||
)
|
||||
|
||||
migrate_imap_emails.main()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_server_factory")
|
||||
class TestMigrationCache:
|
||||
"""End-to-end tests for incremental migration using local cache."""
|
||||
|
||||
def test_migrate_skips_cached_items(self, mock_server_factory, monkeypatch, tmp_path):
|
||||
msg_id = "<cached@test>"
|
||||
msg = f"Subject: Cached\r\nMessage-ID: {msg_id}\r\n\r\nBody".encode()
|
||||
src_data = {"INBOX": [msg]}
|
||||
dest_data = {"INBOX": []}
|
||||
|
||||
_src_server, dest_server, p1, p2 = mock_server_factory(src_data, dest_data)
|
||||
|
||||
cache_dir = tmp_path / "cache"
|
||||
cache_dir.mkdir()
|
||||
|
||||
# First run populates cache and copies message.
|
||||
_run_migrate(monkeypatch, cache_dir, p1, p2)
|
||||
assert len(dest_server.folders["INBOX"]) == 1
|
||||
|
||||
# Second run against a fresh destination should skip based on cache.
|
||||
_src_server2, dest_server2, p3, p4 = mock_server_factory(src_data, {"INBOX": []})
|
||||
_run_migrate(monkeypatch, cache_dir, p3, p4)
|
||||
assert len(dest_server2.folders["INBOX"]) == 0
|
||||
|
||||
def test_migrate_writes_to_cache(self, mock_server_factory, monkeypatch, tmp_path):
|
||||
msg_id = "<new@test>"
|
||||
msg = f"Subject: New\r\nMessage-ID: {msg_id}\r\n\r\nBody".encode()
|
||||
src_data = {"INBOX": [msg]}
|
||||
dest_data = {"INBOX": []}
|
||||
|
||||
_src_server, dest_server, p1, p2 = mock_server_factory(src_data, dest_data)
|
||||
|
||||
cache_dir = tmp_path / "cache"
|
||||
cache_dir.mkdir()
|
||||
|
||||
_run_migrate(monkeypatch, cache_dir, p1, p2)
|
||||
|
||||
assert len(dest_server.folders["INBOX"]) == 1
|
||||
|
||||
cache_path = restore_cache.get_dest_index_cache_path(str(cache_dir), "localhost", "dest")
|
||||
assert os.path.exists(cache_path)
|
||||
|
||||
with open(cache_path, encoding="utf-8") as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
msg_ids = set(cache_data.get("folders", {}).get("INBOX", {}).get("message_ids", []))
|
||||
assert msg_id in msg_ids
|
||||
|
||||
def test_full_migrate_ignores_cache(self, mock_server_factory, monkeypatch, tmp_path):
|
||||
msg_id = "<cached@test>"
|
||||
msg = f"Subject: Cached\r\nMessage-ID: {msg_id}\r\n\r\nBody".encode()
|
||||
src_data = {"INBOX": [msg]}
|
||||
|
||||
cache_dir = tmp_path / "cache"
|
||||
cache_dir.mkdir()
|
||||
|
||||
# Populate cache with an initial run.
|
||||
_src_server, _dest_server, p1, p2 = mock_server_factory(src_data, {"INBOX": []})
|
||||
_run_migrate(monkeypatch, cache_dir, p1, p2)
|
||||
|
||||
# Fresh destination should still copy when --full-migrate is set.
|
||||
_src_server2, dest_server2, p3, p4 = mock_server_factory(src_data, {"INBOX": []})
|
||||
_run_migrate(monkeypatch, cache_dir, p3, p4, full_migrate=True)
|
||||
|
||||
assert len(dest_server2.folders["INBOX"]) == 1
|
||||
|
||||
def test_cached_skip_with_preserve_flags(self, mock_server_factory, monkeypatch, tmp_path):
|
||||
msg_id = "<cached-preserve@test>"
|
||||
msg = f"Subject: Cached\r\nMessage-ID: {msg_id}\r\n\r\nBody".encode()
|
||||
src_data = {"INBOX": [msg]}
|
||||
|
||||
cache_dir = tmp_path / "cache"
|
||||
cache_dir.mkdir()
|
||||
|
||||
# Populate cache with initial run.
|
||||
_src_server, _dest_server, p1, p2 = mock_server_factory(src_data, {"INBOX": []})
|
||||
_run_migrate(monkeypatch, cache_dir, p1, p2)
|
||||
|
||||
# Preserve flags disables pre-filtering, so cache skip happens per message.
|
||||
_src_server2, dest_server2, p3, p4 = mock_server_factory(src_data, {"INBOX": []})
|
||||
_run_migrate(monkeypatch, cache_dir, p3, p4, extra_env={"PRESERVE_FLAGS": "true"})
|
||||
|
||||
assert len(dest_server2.folders["INBOX"]) == 0
|
||||
|
||||
def test_load_progress_cache_warns_on_unusable_root(self, tmp_path):
|
||||
cache_file = tmp_path / "cachefile"
|
||||
cache_file.write_text("not a directory")
|
||||
|
||||
messages = []
|
||||
_cache_path, _cache_data, _cache_lock = imap_common.load_progress_cache(
|
||||
str(cache_file),
|
||||
"host",
|
||||
"user",
|
||||
log_fn=messages.append,
|
||||
)
|
||||
|
||||
assert any("unable to create cache directory" in msg for msg in messages)
|
||||
|
||||
def test_migrate_folder_logs_cache_load_failure(self, mock_server_factory, monkeypatch, tmp_path, capsys):
|
||||
msg_id = "<cache-fail@test>"
|
||||
msg = f"Subject: Cached\r\nMessage-ID: {msg_id}\r\n\r\nBody".encode()
|
||||
src_data = {"INBOX": [msg]}
|
||||
dest_data = {"INBOX": []}
|
||||
|
||||
_src_server, dest_server, p1, p2 = mock_server_factory(src_data, dest_data)
|
||||
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", make_mock_connection(p1, p2))
|
||||
monkeypatch.setattr(
|
||||
imap_common, "load_progress_cache", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))
|
||||
)
|
||||
|
||||
src = imaplib.IMAP4("localhost", p1)
|
||||
dest = imaplib.IMAP4("localhost", p2)
|
||||
src.login("src", "p")
|
||||
dest.login("dest", "p")
|
||||
|
||||
monkeypatch.setattr(migrate_imap_emails, "get_thread_connections", lambda _src_conf, _dest_conf: (src, dest))
|
||||
|
||||
migrate_imap_emails.MAX_WORKERS = 1
|
||||
migrate_imap_emails.BATCH_SIZE = 1
|
||||
|
||||
migrate_imap_emails.migrate_folder(
|
||||
src,
|
||||
dest,
|
||||
"INBOX",
|
||||
False,
|
||||
{"host": "localhost", "user": "src", "password": "p"},
|
||||
{"host": "localhost", "user": "dest", "password": "p"},
|
||||
progress_cache_path=str(tmp_path / "cache"),
|
||||
progress_cache_file=None,
|
||||
progress_cache_data=None,
|
||||
progress_cache_lock=None,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Warning: Failed to load cache" in captured.out
|
||||
assert len(dest_server.folders["INBOX"]) == 1
|
||||
|
||||
src.logout()
|
||||
dest.logout()
|
||||
|
||||
def test_migrate_folder_logs_cache_read_failure(self, mock_server_factory, monkeypatch, tmp_path, capsys):
|
||||
msg_id = "<cache-read-fail@test>"
|
||||
msg = f"Subject: Cached\r\nMessage-ID: {msg_id}\r\n\r\nBody".encode()
|
||||
src_data = {"INBOX": [msg]}
|
||||
dest_data = {"INBOX": []}
|
||||
|
||||
_src_server, dest_server, p1, p2 = mock_server_factory(src_data, dest_data)
|
||||
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", make_mock_connection(p1, p2))
|
||||
monkeypatch.setattr(
|
||||
restore_cache,
|
||||
"get_cached_message_ids",
|
||||
lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("read fail")),
|
||||
)
|
||||
|
||||
src = imaplib.IMAP4("localhost", p1)
|
||||
dest = imaplib.IMAP4("localhost", p2)
|
||||
src.login("src", "p")
|
||||
dest.login("dest", "p")
|
||||
|
||||
monkeypatch.setattr(migrate_imap_emails, "get_thread_connections", lambda _src_conf, _dest_conf: (src, dest))
|
||||
|
||||
migrate_imap_emails.MAX_WORKERS = 1
|
||||
migrate_imap_emails.BATCH_SIZE = 1
|
||||
|
||||
migrate_imap_emails.migrate_folder(
|
||||
src,
|
||||
dest,
|
||||
"INBOX",
|
||||
False,
|
||||
{"host": "localhost", "user": "src", "password": "p"},
|
||||
{"host": "localhost", "user": "dest", "password": "p"},
|
||||
progress_cache_path=str(tmp_path / "cache"),
|
||||
progress_cache_file=None,
|
||||
progress_cache_data=None,
|
||||
progress_cache_lock=None,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Warning: Failed to read cache for folder 'INBOX'" in captured.out
|
||||
assert len(dest_server.folders["INBOX"]) == 1
|
||||
|
||||
src.logout()
|
||||
dest.logout()
|
||||
@ -9,10 +9,10 @@ Tests cover:
|
||||
- Configuration validation
|
||||
"""
|
||||
|
||||
import imaplib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@ -147,6 +147,15 @@ Body content.
|
||||
assert message_id is None
|
||||
assert raw_content is None
|
||||
|
||||
def test_parse_eml_file_unknown_charset_subject(self, tmp_path):
|
||||
"""Test parsing with a subject that uses an unknown charset."""
|
||||
file_path = tmp_path / "unknown_charset.eml"
|
||||
file_path.write_text("Subject: =?X-UNKNOWN?B?SGVsbG8=?=\r\nMessage-ID: <unknown@test>\r\n\r\nBody")
|
||||
|
||||
message_id, _date_str, _raw_content, subject = restore_imap_emails.parse_eml_file(str(file_path))
|
||||
assert message_id == "<unknown@test>"
|
||||
assert "Hello" in subject
|
||||
|
||||
|
||||
class TestGetEmlFiles:
|
||||
"""Tests for getting .eml files from a folder."""
|
||||
@ -175,46 +184,6 @@ class TestGetEmlFiles:
|
||||
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."""
|
||||
|
||||
@ -264,94 +233,6 @@ class TestConfigValidation:
|
||||
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 email_exists_in_folder to return False (not a duplicate)
|
||||
monkeypatch.setattr(restore_imap_emails, "email_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>",
|
||||
)
|
||||
|
||||
assert result == restore_imap_emails.UploadResult.SUCCESS
|
||||
mock_conn.append.assert_called_once()
|
||||
|
||||
def test_upload_email_duplicate(self, monkeypatch):
|
||||
"""Test upload returns ALREADY_EXISTS when message exists."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.select.return_value = ("OK", [b"1"])
|
||||
|
||||
# Mock email_exists_in_folder to return True (is a duplicate)
|
||||
monkeypatch.setattr(restore_imap_emails, "email_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>",
|
||||
check_duplicate=True,
|
||||
)
|
||||
|
||||
assert result == restore_imap_emails.UploadResult.ALREADY_EXISTS
|
||||
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 email_exists_in_folder to return False (not a duplicate)
|
||||
monkeypatch.setattr(restore_imap_emails, "email_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>",
|
||||
flags="\\Seen", # Mark as read
|
||||
)
|
||||
|
||||
assert result == restore_imap_emails.UploadResult.SUCCESS
|
||||
# Check that append was called with the \\Seen flag
|
||||
call_args = mock_conn.append.call_args
|
||||
assert call_args[0][1] == "(\\Seen)"
|
||||
|
||||
def test_upload_email_failure(self, monkeypatch):
|
||||
"""Test upload returns FAILURE when an exception occurs."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.select.side_effect = Exception("Connection error")
|
||||
|
||||
# Mock email_exists_in_folder to return False (not a duplicate)
|
||||
monkeypatch.setattr(restore_imap_emails, "email_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>",
|
||||
)
|
||||
|
||||
assert result == restore_imap_emails.UploadResult.FAILURE
|
||||
mock_conn.append.assert_not_called()
|
||||
|
||||
|
||||
class TestRestoreIntegration:
|
||||
"""Integration tests for restore functionality."""
|
||||
|
||||
@ -391,6 +272,41 @@ Body content.
|
||||
# Run restore
|
||||
restore_imap_emails.main()
|
||||
|
||||
def test_restore_all_folders_scans_backup(self, single_mock_server, monkeypatch, tmp_path):
|
||||
"""End-to-end: restore all folders from a backup tree."""
|
||||
inbox = tmp_path / "INBOX"
|
||||
inbox.mkdir()
|
||||
(inbox / "1_Test_Email.eml").write_text("Subject: Inbox\nMessage-ID: <inbox@test>\n\nBody")
|
||||
|
||||
archive = tmp_path / "Archive"
|
||||
archive.mkdir()
|
||||
subfolder = archive / "Sub"
|
||||
subfolder.mkdir()
|
||||
(subfolder / "2_Test_Email.eml").write_text("Subject: Archive\nMessage-ID: <archive@test>\n\nBody")
|
||||
|
||||
empty_folder = tmp_path / "Empty"
|
||||
empty_folder.mkdir()
|
||||
|
||||
dest_data = {"INBOX": []}
|
||||
server, port = single_mock_server(dest_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)])
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", make_single_mock_connection(port))
|
||||
|
||||
restore_imap_emails.main()
|
||||
|
||||
assert "INBOX" in server.folders
|
||||
assert "Archive/Sub" in server.folders
|
||||
assert len(server.folders["INBOX"]) == 1
|
||||
assert len(server.folders["Archive/Sub"]) == 1
|
||||
assert "Empty" not in server.folders
|
||||
|
||||
def test_restore_single_folder_full_restore_flag(self, single_mock_server, monkeypatch, tmp_path):
|
||||
"""Smoke test: --full-restore flag is accepted."""
|
||||
inbox = tmp_path / "INBOX"
|
||||
@ -439,44 +355,70 @@ Body content.
|
||||
assert len(result) == 2
|
||||
assert result["<msg1@test.com>"] == ["INBOX", "Work"]
|
||||
|
||||
def test_restore_gmail_mode_fallback_folder(self, single_mock_server, monkeypatch, tmp_path):
|
||||
"""End-to-end: Gmail mode with no labels uses fallback folder."""
|
||||
gmail_all_mail = tmp_path / "[Gmail]" / "All Mail"
|
||||
gmail_all_mail.mkdir(parents=True)
|
||||
(gmail_all_mail / "1_Test.eml").write_text("Subject: X\nMessage-ID: <no-labels@test>\n\nBody")
|
||||
|
||||
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>")
|
||||
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>")
|
||||
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)
|
||||
assert result is False
|
||||
|
||||
def test_email_exists_exception(self, monkeypatch):
|
||||
"""Test handling exception."""
|
||||
mock_conn = MagicMock()
|
||||
dest_data = {"INBOX": []}
|
||||
server, port = single_mock_server(dest_data)
|
||||
|
||||
env = {
|
||||
"DEST_IMAP_HOST": "localhost",
|
||||
"DEST_IMAP_USERNAME": "user",
|
||||
"DEST_IMAP_PASSWORD": "pass",
|
||||
}
|
||||
monkeypatch.setattr(os, "environ", env)
|
||||
monkeypatch.setattr(
|
||||
"imap_common.message_exists_in_folder",
|
||||
lambda *args: (_ for _ in ()).throw(Exception("Error")),
|
||||
sys,
|
||||
"argv",
|
||||
["restore_imap_emails.py", "--src-path", str(tmp_path), "--gmail-mode"],
|
||||
)
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", make_single_mock_connection(port))
|
||||
|
||||
result = restore_imap_emails.email_exists_in_folder(mock_conn, "<test@test.com>")
|
||||
assert result is False
|
||||
restore_imap_emails.main()
|
||||
|
||||
assert imap_common.FOLDER_RESTORED_UNLABELED in server.folders
|
||||
assert len(server.folders[imap_common.FOLDER_RESTORED_UNLABELED]) == 1
|
||||
|
||||
def test_restore_dest_delete_removes_orphans(self, single_mock_server, monkeypatch, tmp_path):
|
||||
"""End-to-end: --dest-delete removes messages not in local backup."""
|
||||
inbox = tmp_path / "INBOX"
|
||||
inbox.mkdir()
|
||||
(inbox / "1_keep.eml").write_text("Subject: Keep\nMessage-ID: <keep@test>\n\nBody")
|
||||
|
||||
dest_data = {
|
||||
"INBOX": [
|
||||
b"Subject: Keep\r\nMessage-ID: <keep@test>\r\n\r\nBody",
|
||||
b"Subject: Orphan\r\nMessage-ID: <orphan@test>\r\n\r\nBody",
|
||||
]
|
||||
}
|
||||
server, port = single_mock_server(dest_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),
|
||||
"--dest-delete",
|
||||
"INBOX",
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr("imap_common.get_imap_connection", make_single_mock_connection(port))
|
||||
|
||||
restore_imap_emails.main()
|
||||
|
||||
assert len(server.folders["INBOX"]) == 1
|
||||
assert b"Message-ID: <keep@test>" in server.folders["INBOX"][0]["content"]
|
||||
|
||||
|
||||
class TestRestoreProgressCache:
|
||||
@ -519,6 +461,60 @@ class TestRestoreProgressCache:
|
||||
assert "<b@test>" in ids
|
||||
|
||||
|
||||
class TestBackupFolderDiscovery:
|
||||
"""Tests for backup folder discovery helpers."""
|
||||
|
||||
def test_get_backup_folders_skips_unreadable(self, tmp_path):
|
||||
inbox_path = tmp_path / "INBOX"
|
||||
inbox_path.mkdir()
|
||||
(inbox_path / "1.eml").write_bytes(b"Subject: Inbox\r\n\r\nBody")
|
||||
|
||||
parent_path = tmp_path / "Parent"
|
||||
parent_path.mkdir()
|
||||
|
||||
unreadable_path = parent_path / "Unreadable"
|
||||
unreadable_path.mkdir()
|
||||
(unreadable_path / "1.eml").write_bytes(b"Subject: Hidden\r\n\r\nBody")
|
||||
os.chmod(unreadable_path, 0)
|
||||
|
||||
try:
|
||||
folders = imap_common.get_backup_folders(str(tmp_path))
|
||||
finally:
|
||||
os.chmod(unreadable_path, 0o700)
|
||||
|
||||
folder_names = {name for name, _path in folders}
|
||||
assert "INBOX" in folder_names
|
||||
assert "Unreadable" not in folder_names
|
||||
|
||||
def test_extract_message_id_from_eml_missing_file(self, tmp_path):
|
||||
missing_path = tmp_path / "missing.eml"
|
||||
assert imap_common.extract_message_id_from_eml(str(missing_path)) is None
|
||||
|
||||
def test_extract_message_id_from_eml_success(self, tmp_path):
|
||||
eml_path = tmp_path / "message.eml"
|
||||
eml_path.write_text("Message-ID: <ok@test>\r\nSubject: Hi\r\n\r\nBody")
|
||||
|
||||
assert imap_common.extract_message_id_from_eml(str(eml_path)) == "<ok@test>"
|
||||
|
||||
|
||||
class TestTrashFolderDetection:
|
||||
"""Tests for trash folder detection with string LIST entries."""
|
||||
|
||||
def test_detect_trash_folder_with_string_entries(self):
|
||||
class FakeConn:
|
||||
def list(self):
|
||||
return (
|
||||
"OK",
|
||||
[
|
||||
'(\\HasNoChildren) "/" "INBOX"',
|
||||
'(\\HasNoChildren \\Trash) "/" "Trash"',
|
||||
],
|
||||
)
|
||||
|
||||
result = imap_common.detect_trash_folder(FakeConn())
|
||||
assert result == "Trash"
|
||||
|
||||
|
||||
class TestGetLabelsFromManifest:
|
||||
"""Tests for get_labels_from_manifest function."""
|
||||
|
||||
@ -590,68 +586,81 @@ class TestLabelToFolder:
|
||||
assert result == "Projects/2024"
|
||||
|
||||
|
||||
class TestGmailModeDraftsFallbackRegression:
|
||||
def test_gmail_mode_no_labels_does_not_upload_to_drafts(self, monkeypatch):
|
||||
"""Regression: messages with no usable labels must not be uploaded to Gmail Drafts."""
|
||||
class TestRestoreE2EHelpers:
|
||||
"""End-to-end tests for restore helper functions using the mock IMAP server."""
|
||||
|
||||
captured = {}
|
||||
def test_upload_email_success(self, single_mock_server):
|
||||
dest_data = {"INBOX": []}
|
||||
server, port = single_mock_server(dest_data)
|
||||
|
||||
def fake_upload_email(dest, folder_name, raw_content, date_str, message_id, flags=None, check_duplicate=True):
|
||||
captured["folder_name"] = folder_name
|
||||
return restore_imap_emails.UploadResult.SUCCESS
|
||||
conn = imaplib.IMAP4("localhost", port)
|
||||
conn.login("user", "pass")
|
||||
|
||||
def fake_parse_eml_file(_path):
|
||||
return (
|
||||
"<no-labels@test>",
|
||||
'"01-Jan-2024 00:00:00 +0000"',
|
||||
b"Subject: X\r\nMessage-ID: <no-labels@test>\r\n\r\nBody",
|
||||
"X",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(restore_imap_emails, "get_thread_connection", lambda _conf: MagicMock())
|
||||
monkeypatch.setattr(restore_imap_emails, "upload_email", fake_upload_email)
|
||||
monkeypatch.setattr(restore_imap_emails, "parse_eml_file", fake_parse_eml_file)
|
||||
|
||||
restore_imap_emails.process_restore_batch(
|
||||
eml_files=[("/does/not/matter.eml", "x.eml")],
|
||||
folder_name="__GMAIL_MODE__",
|
||||
dest_conf=("host", "user", "pass"),
|
||||
manifest={},
|
||||
apply_labels=True,
|
||||
apply_flags=False,
|
||||
result = restore_imap_emails.upload_email(
|
||||
conn,
|
||||
"INBOX",
|
||||
b"Subject: Upload\r\nMessage-ID: <up@test>\r\n\r\nBody",
|
||||
'"15-Jan-2024 10:30:00 +0000"',
|
||||
"<up@test>",
|
||||
)
|
||||
|
||||
assert captured["folder_name"] == imap_common.FOLDER_RESTORED_UNLABELED
|
||||
assert captured["folder_name"] != "[Gmail]/Drafts"
|
||||
assert result == restore_imap_emails.UploadResult.SUCCESS
|
||||
assert len(server.folders["INBOX"]) == 1
|
||||
conn.logout()
|
||||
|
||||
def test_upload_email_duplicate(self, single_mock_server):
|
||||
dest_data = {"INBOX": [b"Subject: Dup\r\nMessage-ID: <dup@test>\r\n\r\nBody"]}
|
||||
server, port = single_mock_server(dest_data)
|
||||
|
||||
class TestSyncFlagsOnExisting:
|
||||
"""Tests for sync_flags_on_existing function."""
|
||||
conn = imaplib.IMAP4("localhost", port)
|
||||
conn.login("user", "pass")
|
||||
|
||||
def test_sync_flags_adds_missing(self):
|
||||
"""Test that missing flags are added to existing email."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.select.return_value = ("OK", [b"1"])
|
||||
mock_conn.search.return_value = ("OK", [b"1"])
|
||||
mock_conn.fetch.return_value = ("OK", [(b"1 (FLAGS ())", b"")])
|
||||
mock_conn.store.return_value = ("OK", None)
|
||||
result = restore_imap_emails.upload_email(
|
||||
conn,
|
||||
"INBOX",
|
||||
b"Subject: Dup\r\nMessage-ID: <dup@test>\r\n\r\nBody",
|
||||
'"15-Jan-2024 10:30:00 +0000"',
|
||||
"<dup@test>",
|
||||
check_duplicate=True,
|
||||
)
|
||||
|
||||
# Should not raise
|
||||
restore_imap_emails.sync_flags_on_existing(mock_conn, "INBOX", "<test@test.com>", "\\Seen \\Flagged", 1000)
|
||||
assert result == restore_imap_emails.UploadResult.ALREADY_EXISTS
|
||||
assert len(server.folders["INBOX"]) == 1
|
||||
conn.logout()
|
||||
|
||||
# Verify store was called with flags
|
||||
mock_conn.store.assert_called()
|
||||
def test_email_exists_in_folder(self, single_mock_server):
|
||||
dest_data = {"INBOX": [b"Subject: Exists\r\nMessage-ID: <exists@test>\r\n\r\nBody"]}
|
||||
_server, port = single_mock_server(dest_data)
|
||||
|
||||
def test_sync_flags_no_message_found(self):
|
||||
"""Test when message is not found."""
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.select.return_value = ("OK", [b"1"])
|
||||
mock_conn.search.return_value = ("OK", [b""]) # No match
|
||||
conn = imaplib.IMAP4("localhost", port)
|
||||
conn.login("user", "pass")
|
||||
conn.select('"INBOX"')
|
||||
|
||||
# Should not raise or call store
|
||||
restore_imap_emails.sync_flags_on_existing(mock_conn, "INBOX", "<test@test.com>", "\\Seen", 1000)
|
||||
assert restore_imap_emails.email_exists_in_folder(conn, "<exists@test>") is True
|
||||
assert restore_imap_emails.email_exists_in_folder(conn, "<missing@test>") is False
|
||||
conn.logout()
|
||||
|
||||
mock_conn.store.assert_not_called()
|
||||
def test_sync_flags_on_existing(self, single_mock_server):
|
||||
dest_data = {
|
||||
"INBOX": [{"uid": 1, "flags": set(), "content": b"Subject: Flag\r\nMessage-ID: <flag@test>\r\n\r\nBody"}]
|
||||
}
|
||||
_server, port = single_mock_server(dest_data)
|
||||
|
||||
conn = imaplib.IMAP4("localhost", port)
|
||||
conn.login("user", "pass")
|
||||
|
||||
restore_imap_emails.sync_flags_on_existing(conn, "INBOX", "<flag@test>", "\\Seen \\Flagged", 1000)
|
||||
|
||||
conn.select('"INBOX"')
|
||||
resp, data = conn.search(None, 'HEADER Message-ID "<flag@test>"')
|
||||
assert resp == "OK"
|
||||
msg_num = data[0].split()[0]
|
||||
resp, flag_data = conn.fetch(msg_num, "(FLAGS)")
|
||||
assert resp == "OK"
|
||||
flag_text = str(flag_data[0])
|
||||
assert "\\Seen" in flag_text
|
||||
assert "\\Flagged" in flag_text
|
||||
conn.logout()
|
||||
|
||||
|
||||
class TestDestDeleteRestoreArgument:
|
||||
|
||||
@ -80,6 +80,42 @@ class MockIMAPHandler(socketserver.StreamRequestHandler):
|
||||
self.current_folders[folder] = []
|
||||
self.send_response(tag, "OK CREATE completed")
|
||||
|
||||
elif cmd == "SEARCH":
|
||||
if not self.selected_folder:
|
||||
self.send_response(tag, RESPONSE_SELECT_FIRST)
|
||||
continue
|
||||
|
||||
msgs = self.current_folders[self.selected_folder]
|
||||
sub_args = args
|
||||
|
||||
header_msg_id = None
|
||||
try:
|
||||
m = re.search(r'HEADER\s+Message-ID\s+"([^"]+)"', sub_args, re.IGNORECASE)
|
||||
if m:
|
||||
header_msg_id = m.group(1)
|
||||
except Exception:
|
||||
header_msg_id = None
|
||||
|
||||
seq_nums = []
|
||||
for idx, m in enumerate(msgs, start=1):
|
||||
if "UNDELETED" in sub_args and "\\Deleted" in m["flags"]:
|
||||
continue
|
||||
if header_msg_id:
|
||||
msg_text = m["content"].decode("utf-8", errors="ignore")
|
||||
if header_msg_id not in msg_text:
|
||||
continue
|
||||
seq_nums.append(str(idx))
|
||||
|
||||
if "ALL" in sub_args.upper() and not header_msg_id:
|
||||
seq_nums = [str(idx) for idx in range(1, len(msgs) + 1)]
|
||||
|
||||
seq_str = " ".join(seq_nums)
|
||||
if seq_str:
|
||||
self.wfile.write(f"* SEARCH {seq_str}\r\n".encode())
|
||||
else:
|
||||
self.wfile.write(b"* SEARCH\r\n")
|
||||
self.send_response(tag, RESPONSE_SEARCH_COMPLETED)
|
||||
|
||||
elif cmd == "EXPUNGE":
|
||||
if self.selected_folder:
|
||||
msgs = self.current_folders[self.selected_folder]
|
||||
@ -125,7 +161,10 @@ class MockIMAPHandler(socketserver.StreamRequestHandler):
|
||||
valid_uids.append(str(m["uid"]))
|
||||
|
||||
uids_str = " ".join(valid_uids)
|
||||
self.wfile.write(f"* SEARCH {uids_str}\r\n".encode())
|
||||
if uids_str:
|
||||
self.wfile.write(f"* SEARCH {uids_str}\r\n".encode())
|
||||
else:
|
||||
self.wfile.write(b"* SEARCH\r\n")
|
||||
self.send_response(tag, RESPONSE_SEARCH_COMPLETED)
|
||||
|
||||
elif sub_cmd == "STORE":
|
||||
@ -292,50 +331,6 @@ class MockIMAPHandler(socketserver.StreamRequestHandler):
|
||||
print(f"MOCK APPEND ERROR: {e}")
|
||||
self.send_response(tag, "BAD APPEND")
|
||||
|
||||
elif cmd == "SEARCH":
|
||||
# Parse SEARCH ALL, SEARCH HEADER Message-ID "...", etc.
|
||||
# args might be: ALL, HEADER Message-ID "<123>", CHARSETS UTF-8 ...
|
||||
|
||||
found_indices = []
|
||||
|
||||
if not self.selected_folder:
|
||||
self.wfile.write(b"* SEARCH\r\n")
|
||||
self.send_response(tag, RESPONSE_SEARCH_COMPLETED)
|
||||
continue
|
||||
|
||||
msgs = self.current_folders[self.selected_folder]
|
||||
|
||||
if "ALL" in args.upper():
|
||||
# Return all message sequence numbers
|
||||
found_indices = [str(idx + 1) for idx in range(len(msgs))]
|
||||
elif "HEADER Message-ID" in args:
|
||||
# Extract value
|
||||
# Expected: ... HEADER Message-ID "value" ...
|
||||
try:
|
||||
# Split by 'MESSAGE-ID' (case insensitive?)
|
||||
# part after Message-ID
|
||||
post_mi = args.split("Message-ID", 1)[1].strip()
|
||||
# Should start with quote or value
|
||||
if post_mi.startswith('"'):
|
||||
search_val = post_mi.split('"', 2)[1]
|
||||
else:
|
||||
search_val = post_mi.split(" ", 1)[0]
|
||||
|
||||
search_val = search_val.replace("<", "").replace(">", "")
|
||||
|
||||
for idx, m in enumerate(msgs):
|
||||
content_str = m["content"].decode("utf-8", errors="ignore")
|
||||
if search_val in content_str:
|
||||
# Simple substring check is risky but okay for mock
|
||||
# Better: regex for Message-ID: <...search_val...>
|
||||
found_indices.append(str(idx + 1))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
indices_str = " ".join(found_indices)
|
||||
self.wfile.write(f"* SEARCH {indices_str}\r\n".encode())
|
||||
self.send_response(tag, RESPONSE_SEARCH_COMPLETED)
|
||||
|
||||
elif cmd == "STORE":
|
||||
# STORE <msg_set> +FLAGS (\Seen) (non-UID; uses message sequence numbers)
|
||||
if not self.selected_folder:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user