Initial commit: IMAP migration tools

This commit is contained in:
Javier Callico 2026-01-23 17:52:05 -05:00 committed by GitHub Copilot
commit 7e1125159c
6 changed files with 851 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*$py.class
.DS_Store
.vscode/
env/
venv/
.env

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 jcallico
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

162
README.md Normal file
View File

@ -0,0 +1,162 @@
# IMAP Email Migration Tools
## Background
I was in need of migrating a Google account with more than 100,000 emails, and none of the freely available solutions worked reliably for me. They often timed out, crashed, or couldn't handle the volume. Hence, I created these simple, robust Python scripts that got the job done effectively.
### Disclaimer
These scripts are provided "as is", without warranty of any kind, express or implied. While they have been tested and used successfully for ONE large migration, the author assumes no liability for any data loss, corruption, or other issues that may arise from their use. Users are advised to review the code and test with non-critical data before performing large-scale operations. No support or maintenance is guaranteed.
## Project Overview
This repository contains a set of Python scripts designed to migrate emails between IMAP servers (supports Gmail, Outlook, etc.), verify the migration, and manage folder states.
### The Scripts
1. **`migrate_imap_emails.py`** (The Solution)
- Migrates emails folder-by-folder.
- **Multi-threaded**: Uses a thread pool to copy messages in parallel for high speed.
- **Smart De-duplication**: Checks if a message already exists in the destination (matching Message-ID and Size) and skips it if found.
- **Robust**: Preserves read/unread status (flags) and original dates.
- **Cleanup**: Optionally deletes messages from the source after successful transfer (effectively a "Move" operation).
- **Configurable**: Adjustable concurrency and batch sizes to respect server rate limits.
2. **`compare_imap_folders.py`** (The Validator)
- Connects to both Source and Destination accounts.
- Prints a side-by-side comparison table of message counts for every folder.
- Essential for verifying that the migration was successful and that counts match.
3. **`count_imap_emails.py`** (The Investigator)
- A simple utility to connect to a single account and count emails in all folders. Useful for initial assessment.
## Getting Started
### 1. Prerequisites
- **Python 3.6+**
- **No external installations required.**
The scripts use only the Python Standard Library, which is installed automatically with Python. You do **not** need to install anything else (no `pip install`).
*Used libraries: `imaplib`, `email`, `concurrent.futures`, `re`, `os`, `sys`, `threading`.*
### 2. Installation
Clone this repository or download the script files to your local machine.
#### macOS
macOS often comes with Python, but it's best to install the latest version.
- **Using Homebrew** (Recommended):
```bash
brew install python
```
- **Manual**: Download the installer from [python.org](https://www.python.org/downloads/mac-osx/).
#### Linux (Ubuntu/Debian)
Most Linux distributions come with Python 3 pre-installed. To ensure you have it:
```bash
sudo apt-get update
sudo apt-get install python3
```
#### Windows
1. Download the Python 3 executable installer from [python.org](https://www.python.org/downloads/windows/).
2. Run the installer and **ensure you check the box "Add Python to PATH"** at the bottom of the setup screen before clicking Install.
3. Open PowerShell or Command Prompt and verify installation:
```powershell
python --version
```
## Configuration & Running
The scripts use environment variables for configuration. Here is how to set them and run the scripts on different operating systems.
### Linux / macOS (Bash/Zsh)
1. **Set Environment Variables:**
```bash
# Source Account
export SRC_IMAP_SERVER="imap.gmail.com"
export SRC_IMAP_USERNAME="source@gmail.com"
export SRC_IMAP_PASSWORD="your-app-password"
# Destination Account
export DEST_IMAP_SERVER="imap.destination.com"
export DEST_IMAP_USERNAME="dest@domain.com"
export DEST_IMAP_PASSWORD="dest-app-password"
# Options (Optional)
export DELETE_FROM_SOURCE="false" # Set to "true" to delete from source after copy
export MAX_WORKERS=4 # Reduce threads if hitting connection limits
export BATCH_SIZE=10 # Emails per batch
```
2. **Run the Script:**
```bash
python3 migrate_imap_emails.py
```
### Windows (PowerShell)
1. **Set Environment Variables:**
```powershell
# Source Account
$env:SRC_IMAP_SERVER="imap.gmail.com"
$env:SRC_IMAP_USERNAME="source@gmail.com"
$env:SRC_IMAP_PASSWORD="your-app-password"
# Destination Account
$env:DEST_IMAP_SERVER="imap.destination.com"
$env:DEST_IMAP_USERNAME="dest@domain.com"
$env:DEST_IMAP_PASSWORD="dest-app-password"
# Options (Optional)
$env:DELETE_FROM_SOURCE="false" # Set to "true" to delete from source after copy
$env:MAX_WORKERS=4 # Reduce threads if hitting connection limits
$env:BATCH_SIZE=10 # Emails per batch
```
2. **Run the Script:**
```powershell
python migrate_imap_emails.py
```
## Usage
### 1. Run the Migration
The main migration process. It will create folders on the destination if they don't exist.
```bash
python3 migrate_imap_emails.py
```
*Note: If you interrupt the script (Ctrl+C), it handles the shutdown gracefully, finishing current batches before exiting.*
### 2. Verify the Migration
After migration, run this to see if the counts match up.
```bash
python3 compare_imap_folders.py
```
*Output Example:*
```
Folder Name | Source Count | Dest Count | Status
------------------------------------------------------------
INBOX | 1250 | 1250 | MATCH
[Gmail]/Sent Mail | 5432 | 5432 | MATCH
Archive | 200 | 150 | DIFF
```
### 3. Quick Count
To just check the source mailbox:
```bash
python3 count_imap_emails.py
```
## Troubleshooting
- **"Too many simultaneous connections"**:
IMAP servers (especially Gmail) limit the number of active connections per IP or user (typically ~15). Since `migrate_imap_emails.py` uses multiple threads, you may hit this limit.
**Solution**: Reduce `MAX_WORKERS` to `4` or `2` using the environment variable.
- **Authentication Errors**:
If you are using Gmail or Google Workspace, you generally **cannot** use your regular login password. You must enable 2-Step Verification and generate an **App Password**. Use that App Password in the `_PASSWORD` variable.
- **Timeouts / Socket Errors**:
Migrating 100k+ emails is network intensive. If the script crashes, simply run it again. The built-in de-duplication will skip already migrated messages and resume where it left off.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

153
compare_imap_folders.py Normal file
View File

@ -0,0 +1,153 @@
"""
IMAP Folder Comparison Script
This script compares email counts between a source IMAP account and a destination IMAP account.
It iterates through all folders found in the source account and checks the corresponding
folder in the destination account.
Configuration (Environment Variables):
Source Account:
SRC_IMAP_SERVER : Source IMAP Host
SRC_IMAP_USERNAME : Source Username/Email
SRC_IMAP_PASSWORD : Source Password
Destination Account:
DEST_IMAP_SERVER : Destination IMAP Host
DEST_IMAP_USERNAME : Destination Username/Email
DEST_IMAP_PASSWORD : Destination Password
Usage:
python3 compare_imap_folders.py
"""
import imaplib
import os
import sys
import re
def normalize_folder_name(folder_info_str):
# Regex to extract folder name: (flags) "delimiter" name
list_pattern = re.compile(r'\((?P<flags>.*?)\) "(?P<delimiter>.*)" "?(?P<name>.*)"?')
match = list_pattern.search(folder_info_str)
if match:
return match.group('name').strip('"')
return folder_info_str.split()[-1].strip('"')
def get_email_count(conn, folder_name):
try:
# Select folder in read-only mode
# Quote folder name handles spaces
typ, data = conn.select(f'"{folder_name}"', readonly=True)
if typ != 'OK':
return None
# SELECT command returns the number of messages in data[0]
# data[0] is bytes, e.g. b'123'
if data and data[0]:
return int(data[0])
return 0
except Exception as e:
# print(f"Error checking {folder_name}: {e}")
return None
def main():
# Source Credentials
SRC_HOST = os.getenv("SRC_IMAP_SERVER")
SRC_USER = os.getenv("SRC_IMAP_USERNAME")
SRC_PASS = os.getenv("SRC_IMAP_PASSWORD")
# Dest Credentials
DEST_HOST = os.getenv("DEST_IMAP_SERVER")
DEST_USER = os.getenv("DEST_IMAP_USERNAME")
DEST_PASS = os.getenv("DEST_IMAP_PASSWORD")
if not all([SRC_HOST, SRC_USER, SRC_PASS, DEST_HOST, DEST_USER, DEST_PASS]):
print("Error: Missing environment variables.")
print("Please ensure SRC_* and DEST_* variables are set (same as migration script).")
sys.exit(1)
print("\n--- Configuration Comparison Summary ---")
print(f"Source : {SRC_USER} @ {SRC_HOST}")
print(f"Destination : {DEST_USER} @ {DEST_HOST}")
print("----------------------------------------\n")
src = None
dest = None
try:
# Connect to Source
print("Connecting to Source...")
src = imaplib.IMAP4_SSL(SRC_HOST)
src.login(SRC_USER, SRC_PASS)
# Connect to Dest
print("Connecting to Destination...")
dest = imaplib.IMAP4_SSL(DEST_HOST)
dest.login(DEST_USER, DEST_PASS)
# List Source Folders
print("Listing folders in Source...")
typ, folders = src.list()
if typ != 'OK':
print("Failed to list source folders.")
return
# Prepare Table Header
header = f"{'Folder Name':<40} | {'Source':>10} | {'Dest':>10} | {'Diff':>10}"
print("-" * len(header))
print(header)
print("-" * len(header))
total_src = 0
total_dest = 0
# Iterate through Source folders
for folder_info in folders:
folder_info_str = folder_info.decode('utf-8')
folder_name = normalize_folder_name(folder_info_str)
# Get Counts
src_count = get_email_count(src, folder_name)
dest_count = get_email_count(dest, folder_name)
# Format for display
src_str = str(src_count) if src_count is not None else "Err"
dest_str = str(dest_count) if dest_count is not None else "N/A" # N/A usually means folder doesn't exist
diff_str = ""
if src_count is not None and dest_count is not None:
diff = src_count - dest_count
diff_str = str(diff)
total_src += src_count
total_dest += dest_count
elif src_count is not None:
total_src += src_count
print(f"{folder_name:<40} | {src_str:>10} | {dest_str:>10} | {diff_str:>10}")
print("-" * len(header))
print(f"{'TOTAL':<40} | {total_src:>10} | {total_dest:>10} | {total_src-total_dest:>10}")
except Exception as e:
print(f"\nFatal Error: {e}")
if "Too many simultaneous connections" in str(e):
print("Tip: Wait a few minutes for previous connections to timeout or check other active scripts.")
finally:
# Check source connection state and logout if possible
if src:
try:
src.logout()
except Exception:
pass
# Check dest connection state and logout if possible
if dest:
try:
dest.logout()
except Exception:
pass
if __name__ == "__main__":
main()

120
count_imap_emails.py Normal file
View File

@ -0,0 +1,120 @@
"""
IMAP Email Counting Script
This script connects to an IMAP server, iterates through all available folders/mailboxes,
and counts the number of emails in each. It provides a progressive output of counts
per folder and a grand total at the end.
Configuration (Environment Variables):
IMAP_SERVER : IMAP Host (e.g., imap.gmail.com)
IMAP_USERNAME : Username/Email
IMAP_PASSWORD : Password (or App Password)
Usage Example:
export IMAP_SERVER="imap.gmail.com"
export IMAP_USERNAME="user@gmail.com"
export IMAP_PASSWORD="secretpassword"
python3 count_imap_emails.py
"""
import imaplib
import os
import sys
import re
def count_emails(imap_server, username, password):
try:
# Connect to the IMAP server (using SSL)
print(f"Connecting to {imap_server}...")
mail = imaplib.IMAP4_SSL(imap_server)
# Login
print(f"Logging in as {username}...")
mail.login(username, password)
# List all mailboxes
print("Listing mailboxes...")
status, folders = mail.list()
if status != "OK":
print("Failed to list mailboxes.")
return
total_all_folders = 0
print(f"{'Folder Name':<40} {'Count':>10}")
print("-" * 52)
# Regex to extract folder name: (flags) "delimiter" name
# Examples:
# (\HasNoChildren) "/" "INBOX" -> Name is "INBOX"
# (\HasNoChildren) "/" Drafts -> Name is Drafts
list_pattern = re.compile(r'\((?P<flags>.*?)\) "(?P<delimiter>.*)" (?P<name>.*)')
for folder_info in folders:
folder_info_str = folder_info.decode('utf-8')
match = list_pattern.search(folder_info_str)
if match:
folder_name = match.group('name')
else:
# Fallback: simple split if regex fails, assumed last part
# This might fail for names with spaces if not quoted properly, but list usually quotes them.
parts = folder_info_str.split()
folder_name = parts[-1]
# Display name (remove quotes for printing)
display_name = folder_name.strip('"')
try:
# Select the mailbox (read-only is sufficient for counting)
# folder_name extracted from list usually handles quotes correctly for select
rv, _ = mail.select(folder_name, readonly=True)
if rv != 'OK':
print(f"{display_name:<40} {'Skipped':>10}")
continue
# Search for all emails
status, data = mail.search(None, "ALL")
if status == "OK":
# data[0] is space separated IDs
email_ids = data[0].split()
count = len(email_ids)
print(f"{display_name:<40} {count:>10}")
total_all_folders += count
else:
print(f"{display_name:<40} {'Error':>10}")
except imaplib.IMAP4.error:
print(f"{display_name:<40} {'Error':>10}")
print("-" * 52)
print(f"{'TOTAL':<40} {total_all_folders:>10}")
# Logout
mail.logout()
except imaplib.IMAP4.error as e:
print(f"IMAP Error: {e}")
except Exception as e:
print(f"An error occurred: {e}")
if __name__ == "__main__":
# Configuration - Replace these with your details
# Ideally, load these from environment variables for security
IMAP_SERVER = os.getenv("IMAP_SERVER", "imap.gmail.com")
USERNAME = os.getenv("IMAP_USERNAME", "")
PASSWORD = os.getenv("IMAP_PASSWORD", "")
if PASSWORD == "your_password":
print("Please configure the script with your IMAP credentials.")
print("You can set environment variables IMAP_SERVER, IMAP_USERNAME, and IMAP_PASSWORD.")
print("Or edit the script directly (not recommended for shared code).")
sys.exit(1)
print("\n--- Configuration Summary ---")
print(f"IMAP Server : {IMAP_SERVER}")
print(f"Username : {USERNAME}")
print("-----------------------------\n")
count_emails(IMAP_SERVER, USERNAME, PASSWORD)

387
migrate_imap_emails.py Normal file
View File

@ -0,0 +1,387 @@
"""
IMAP Email Migration Script
This script migrates emails from a source IMAP account to a destination IMAP account.
It iterates through all folders in the source account and copies emails to the destination.
It effectively handles folder creation and duplication checks (based on Message-ID and Size).
Features:
- Progressive migration (folder by folder, email by email).
- Safe duplicate detection (skips widely identical messages).
- Optional deletion from source (set DELETE_FROM_SOURCE=true).
Configuration (Environment Variables):
Source Account:
SRC_IMAP_SERVER : Source IMAP Host (e.g., imap.gmail.com)
SRC_IMAP_USERNAME : Source Username/Email
SRC_IMAP_PASSWORD : Source Password (or App Password)
Destination Account:
DEST_IMAP_SERVER : Destination IMAP Host
DEST_IMAP_USERNAME : Destination Username/Email
DEST_IMAP_PASSWORD : Destination Password
Options:
DELETE_FROM_SOURCE : Set to "true" to delete emails from source after successful transfer.
Default is "false" (Copy only).
MAX_WORKERS : Number of concurrent threads (default: 10).
BATCH_SIZE : Number of emails to process in a batch per thread (default: 10).
Usage Example:
export SRC_IMAP_SERVER="imap.gmail.com"
export SRC_IMAP_USERNAME="user@gmail.com"
export SRC_IMAP_PASSWORD="secretpassword"
export DEST_IMAP_SERVER="imap.other.com"
export DEST_IMAP_USERNAME="user@other.com"
export DEST_IMAP_PASSWORD="otherpassword"
python3 migrate_imap_emails.py
"""
import imaplib
import os
import sys
import re
import email
from email.parser import BytesParser
from email.header import decode_header
import concurrent.futures
import threading
# Configuration defaults
DELETE_FROM_SOURCE_DEFAULT = False
MAX_WORKERS = int(os.getenv("MAX_WORKERS", 10)) # Number of concurrent threads
BATCH_SIZE = int(os.getenv("BATCH_SIZE", 10)) # Emails per batch per thread
# 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}")
def decode_mime_header(header_value):
if not header_value:
return "(No Subject)"
try:
decoded_list = decode_header(header_value)
default_charset = 'utf-8'
text_parts = []
for bytes_data, encoding in decoded_list:
if isinstance(bytes_data, bytes):
if encoding:
try:
text_parts.append(bytes_data.decode(encoding, errors='ignore'))
except LookupError:
text_parts.append(bytes_data.decode(default_charset, errors='ignore'))
else:
text_parts.append(bytes_data.decode(default_charset, errors='ignore'))
else:
text_parts.append(str(bytes_data))
return "".join(text_parts)
except Exception:
return str(header_value)
def get_connection(host, user, password):
try:
conn = imaplib.IMAP4_SSL(host)
conn.login(user, password)
return conn
except Exception as e:
safe_print(f"Connection error to {host}: {e}")
return None
def get_thread_connections(src_conf, dest_conf):
# Initialize connections for this thread if they don't exist or are closed
if not hasattr(thread_local, "src") or thread_local.src is None:
thread_local.src = get_connection(*src_conf)
if not hasattr(thread_local, "dest") or thread_local.dest is None:
thread_local.dest = get_connection(*dest_conf)
# Simple check if alive (noop)
try:
if thread_local.src: thread_local.src.noop()
except:
thread_local.src = get_connection(*src_conf)
try:
if thread_local.dest: thread_local.dest.noop()
except:
thread_local.dest = get_connection(*dest_conf)
return thread_local.src, thread_local.dest
def normalize_folder_name(folder_info_str):
# Regex to extract folder name: (flags) "delimiter" name
list_pattern = re.compile(r'\((?P<flags>.*?)\) "(?P<delimiter>.*)" "?(?P<name>.*)"?')
match = list_pattern.search(folder_info_str)
if match:
return match.group('name').strip('"')
return folder_info_str.split()[-1].strip('"')
def get_msg_details(imap_conn, uid):
# Fetch headers (ID, Subject, Size)
# BODY.PEEK[HEADER.FIELDS (MESSAGE-ID SUBJECT)] prevents marking as read
resp, data = imap_conn.uid('fetch', uid, '(RFC822.SIZE BODY.PEEK[HEADER.FIELDS (MESSAGE-ID SUBJECT)])')
if resp != 'OK':
return None, None, None
msg_id = None
subject = "(No Subject)"
size = 0
for item in data:
if isinstance(item, tuple):
content = item[0].decode('utf-8', errors='ignore')
# Parse Size
size_match = re.search(r'RFC822\.SIZE\s+(\d+)', content)
if size_match:
size = int(size_match.group(1))
# Parse Headers
msg_bytes = item[1]
parser = BytesParser()
email_obj = parser.parsebytes(msg_bytes)
msg_id = email_obj.get('Message-ID')
raw_subject = email_obj.get('Subject')
if raw_subject:
subject = decode_mime_header(raw_subject)
return msg_id, size, subject
def message_exists_in_dest(dest_conn, msg_id, src_size):
if not msg_id:
return False
clean_id = msg_id.replace('"', '\\"')
try:
typ, data = dest_conn.search(None, f'(HEADER Message-ID "{clean_id}")')
if typ != 'OK':
return False
dest_ids = data[0].split()
if not dest_ids:
return False
for did in dest_ids:
resp, items = dest_conn.fetch(did, '(RFC822.SIZE)')
if resp == 'OK':
for item in items:
if isinstance(item, bytes):
content = item.decode('utf-8', errors='ignore')
else:
content = item[0].decode('utf-8', errors='ignore')
size_match = re.search(r'RFC822\.SIZE\s+(\d+)', content)
if size_match and int(size_match.group(1)) == src_size:
return True
except Exception:
return False
return False
def process_batch(uids, folder_name, src_conf, dest_conf, delete_from_source):
src, dest = get_thread_connections(src_conf, dest_conf)
if not src or not dest:
safe_print("Error: Could not establish connections in worker thread.")
return
# Select folders
try:
src.select(f'"{folder_name}"', readonly=False)
dest.select(f'"{folder_name}"')
except Exception as e:
safe_print(f"Error selecting folder {folder_name} in worker: {e}")
return
deleted_count = 0
for uid in uids:
try:
msg_id, size, subject = get_msg_details(src, uid)
# Format size for display
size_str = f"{size/1024:.1f}KB" if size else "0KB"
is_duplicate = False
if msg_id and size:
is_duplicate = message_exists_in_dest(dest, msg_id, size)
if is_duplicate:
safe_print(f"[{folder_name}] {'SKIP (Dup)':<18} | {size_str:<8} | {subject[:40]}")
# If it's a duplicate, we can still delete source if requested
if delete_from_source:
src.uid('store', uid, '+FLAGS', '(\\Deleted)')
deleted_count += 1
else:
# Fetch full message
resp, data = src.uid('fetch', uid, '(FLAGS INTERNALDATE BODY.PEEK[])')
if resp != 'OK':
safe_print(f"[{folder_name}] ERROR Fetch | {subject[:40]}")
continue
msg_content = None
flags = None
date_str = None
for item in data:
if isinstance(item, tuple):
msg_content = item[1]
meta = item[0].decode('utf-8', errors='ignore')
flags_match = re.search(r'FLAGS\s+\((.*?)\)', meta)
if flags_match:
flags = flags_match.group(1)
date_match = re.search(r'INTERNALDATE\s+"(.*?)"', meta)
if date_match:
date_str = f'"{date_match.group(1)}"'
if msg_content:
valid_flags = f"({flags})" if flags else None
dest.append(f'"{folder_name}"', valid_flags, date_str, msg_content)
safe_print(f"[{folder_name}] {'COPIED':<18} | {size_str:<8} | {subject[:40]}")
if delete_from_source:
src.uid('store', uid, '+FLAGS', '(\\Deleted)')
deleted_count += 1
except Exception as e:
safe_print(f"[{folder_name}] ERROR Exec | UID {uid}: {e}")
if delete_from_source and deleted_count > 0:
try:
src.expunge()
safe_print(f"[{folder_name}] Expunged {deleted_count} messages from batch.")
except Exception as e:
safe_print(f"[{folder_name}] ERROR Expunge: {e}")
def migrate_folder(src, dest, folder_name, delete_from_source, src_conf, dest_conf):
safe_print(f"--- Preparing Folder: {folder_name} ---")
# Maintain folder structure
try:
if folder_name.upper() != "INBOX":
dest.create(f'"{folder_name}"')
except Exception:
pass # Ignore if exists
# Select in main thread to get UIDs
try:
src.select(f'"{folder_name}"', readonly=False)
dest.select(f'"{folder_name}"')
except Exception as e:
safe_print(f"Skipping {folder_name}: {e}")
return
# Get UIDs
# Search for UNDELETED to avoid processing messages marked for deletion but not yet expunged
resp, data = src.uid('search', None, 'UNDELETED')
if resp != 'OK':
return
uids = data[0].split()
total = len(uids)
if total == 0:
safe_print(f"Folder {folder_name} is empty.")
return
safe_print(f"Found {total} messages. Starting parallel migration...")
# Create batches
uid_batches = [uids[i:i + BATCH_SIZE] for i in range(0, len(uids), BATCH_SIZE)]
executor = concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS)
try:
futures = []
for batch in uid_batches:
futures.append(executor.submit(
process_batch,
batch,
folder_name,
src_conf,
dest_conf,
delete_from_source
))
# Wait for all batches to complete
for future in concurrent.futures.as_completed(futures):
try:
future.result()
except Exception as e:
safe_print(f"Batch Error: {e}")
except KeyboardInterrupt:
safe_print("\n\n!!! Migration interrupted by user. Shutting down threads... !!!\n")
executor.shutdown(wait=False, cancel_futures=True)
raise # Re-raise to stop main loop
finally:
executor.shutdown(wait=True)
if delete_from_source:
safe_print(f"Expunging any remaining deleted messages from {folder_name}...")
try:
src.select(f'"{folder_name}"', readonly=False)
src.expunge()
except Exception as e:
safe_print(f"Error Expunging: {e}")
def main():
# Source Credentials
SRC_HOST = os.getenv("SRC_IMAP_SERVER")
SRC_USER = os.getenv("SRC_IMAP_USERNAME")
SRC_PASS = os.getenv("SRC_IMAP_PASSWORD")
# Dest Credentials
DEST_HOST = os.getenv("DEST_IMAP_SERVER")
DEST_USER = os.getenv("DEST_IMAP_USERNAME")
DEST_PASS = os.getenv("DEST_IMAP_PASSWORD")
DELETE_SOURCE = os.getenv("DELETE_FROM_SOURCE", "false").lower() == "true"
if not all([SRC_HOST, SRC_USER, SRC_PASS, DEST_HOST, DEST_USER, DEST_PASS]):
print("Error: Missing environment variables.")
sys.exit(1)
print("\n--- Configuration Summary ---")
print(f"Source Server : {SRC_HOST}")
print(f"Source User : {SRC_USER}")
print(f"Destination Host: {DEST_HOST}")
print(f"Destination User: {DEST_USER}")
print(f"Delete fm Source: {DELETE_SOURCE}")
print("-----------------------------\n")
src_conf = (SRC_HOST, SRC_USER, SRC_PASS)
dest_conf = (DEST_HOST, DEST_USER, DEST_PASS)
try:
# Initial connection to list folders
safe_print("Connecting to Source to list folders...")
src_main = imaplib.IMAP4_SSL(SRC_HOST)
src_main.login(SRC_USER, SRC_PASS)
# We need a dummy dest connection just to pass to migrate_folder for folder creation checks?
# Actually migrate_folder spawns threads, but it does folder creation validation on main thread first
safe_print("Connecting to Destination...")
dest_main = imaplib.IMAP4_SSL(DEST_HOST)
dest_main.login(DEST_USER, DEST_PASS)
typ, folders = src_main.list()
if typ == 'OK':
for folder_info in folders:
name = normalize_folder_name(folder_info.decode('utf-8'))
migrate_folder(src_main, dest_main, name, DELETE_SOURCE, src_conf, dest_conf)
src_main.logout()
dest_main.logout()
except KeyboardInterrupt:
safe_print("\n\nProcess terminated by user.")
sys.exit(0)
except Exception as e:
safe_print(f"Fatal Error: {e}")
if __name__ == "__main__":
main()