Implement multi-folder synchronization

Add support for syncing multiple IMAP folders with auto-creation of missing
dest folders and configurable folder selection.

Changes:
- Update main.py to loop over folders, with auto-create for dest
- Add FOLDERS env var for all or specific folders
- Update .env.example and README for FOLDERS config
This commit is contained in:
Jeena 2026-01-04 16:27:54 +09:00
parent 2ecdf33dc4
commit 485a5db8b9
3 changed files with 107 additions and 49 deletions

View file

@ -6,7 +6,7 @@ DEST_HOST=yourserver.mxrouting.net
DEST_PORT=993 DEST_PORT=993
DEST_USER=your@mxroute.com DEST_USER=your@mxroute.com
DEST_PASS=password DEST_PASS=password
FOLDER=INBOX FOLDERS=INBOX
DRY_RUN=false DRY_RUN=false
UPTIME_SUCCESS_URL=https://your-uptime-instance.com/api/push/your-token?status=up&msg=OK&ping= UPTIME_SUCCESS_URL=https://your-uptime-instance.com/api/push/your-token?status=up&msg=OK&ping=
UPTIME_FAIL_URL=https://your-uptime-instance.com/api/push/your-token?status=down&msg=Failed UPTIME_FAIL_URL=https://your-uptime-instance.com/api/push/your-token?status=down&msg=Failed

View file

@ -35,7 +35,7 @@ This will set up the virtual environment, systemd services, and provide post-ins
- `DEST_PORT`: Port (default 993) - `DEST_PORT`: Port (default 993)
- `DEST_USER`: Destination email/username - `DEST_USER`: Destination email/username
- `DEST_PASS`: Destination password - `DEST_PASS`: Destination password
- `FOLDER`: Mailbox folder (default INBOX) - `FOLDERS`: Folders to sync (default INBOX; use "all" for all folders or comma-separated list like "INBOX,Sent")
- `DRY_RUN`: Set to `true` for testing without forwarding (default false) - `DRY_RUN`: Set to `true` for testing without forwarding (default false)
- `UPTIME_SUCCESS_URL`: URL for success ping - `UPTIME_SUCCESS_URL`: URL for success ping
- `UPTIME_FAIL_URL`: URL for failure ping - `UPTIME_FAIL_URL`: URL for failure ping

152
main.py
View file

@ -33,7 +33,7 @@ if not dest_user:
dest_pass = os.getenv("DEST_PASS") dest_pass = os.getenv("DEST_PASS")
if not dest_pass: if not dest_pass:
raise ValueError("DEST_PASS not set") raise ValueError("DEST_PASS not set")
folder = os.getenv("FOLDER", "INBOX") folders_config = os.getenv("FOLDERS", "INBOX")
processed_msg_file = "processed_message_ids.txt" processed_msg_file = "processed_message_ids.txt"
last_run_file = "last_run.txt" last_run_file = "last_run.txt"
dry_run = os.getenv("DRY_RUN", "false").lower() == "true" dry_run = os.getenv("DRY_RUN", "false").lower() == "true"
@ -57,71 +57,129 @@ def main():
processed_msg = set(line.strip() for line in f if line.strip()) processed_msg = set(line.strip() for line in f if line.strip())
try: try:
# Connect to source IMAP # Connect to source IMAP for listing
source_imap = imaplib.IMAP4_SSL(source_host, source_port) source_imap_list = imaplib.IMAP4_SSL(source_host, source_port)
source_imap.login(source_user, source_pass) source_imap_list.login(source_user, source_pass)
source_imap.select(folder)
# Search for emails since last run # Determine folders to sync
typ, data = source_imap.search(None, f'SINCE "{since_date}"') if folders_config == "all":
if typ != "OK": # List all folders
raise Exception(f"Search failed: {typ}") typ, folder_list = source_imap_list.list()
if typ != "OK":
raise Exception("Failed to list folders")
folders = []
for f in folder_list:
if f:
# Parse folder name, e.g., '(\\HasNoChildren) "/" "INBOX"'
parts = f.decode().split(' "/" ')
if len(parts) > 1:
folder_name = parts[1].strip('"')
# Skip system folders if desired (optional)
if folder_name not in ["Trash", "Junk", "Spam"]:
folders.append(folder_name)
else:
folders = [f.strip() for f in folders_config.split(",")]
uids = data[0].split() source_imap_list.logout()
logging.info(f"Found {len(uids)} emails since {since_date}")
logging.info(f"Syncing folders: {folders}")
# Connect to destination IMAP # Connect to destination IMAP
dest_imap = imaplib.IMAP4_SSL(dest_host, dest_port) dest_imap = imaplib.IMAP4_SSL(dest_host, dest_port)
dest_imap.login(dest_user, dest_pass) dest_imap.login(dest_user, dest_pass)
dest_imap.select(folder)
forwarded = 0 total_forwarded = 0
for uid in uids:
uid_str = uid.decode()
# Fetch raw message
typ, msg_data = source_imap.fetch(uid, "(RFC822)")
if typ != "OK":
logging.error(f"Failed to fetch UID {uid_str}")
continue
raw_msg = msg_data[0][1]
msg = message_from_bytes(raw_msg) for folder in folders:
msg_id = msg["Message-ID"] try:
if not msg_id: # Connect to source IMAP per folder
logging.warning(f"No Message-ID for UID {uid_str}, skipping") source_imap = imaplib.IMAP4_SSL(source_host, source_port)
continue source_imap.login(source_user, source_pass)
if msg_id in processed_msg: source_imap.select(folder)
logging.info(f"Skipping already processed Message-ID {msg_id}")
continue
# Check dest for duplicate # Search for emails since last run
typ, dest_data = dest_imap.search(None, f'HEADER Message-ID "{msg_id}"') typ, data = source_imap.search(None, f'SINCE "{since_date}"')
if typ == "OK" and dest_data[0]: if typ != "OK":
logging.info(f"Skipping duplicate Message-ID {msg_id} in dest") logging.error(f"Search failed for {folder}: {typ}")
continue source_imap.logout()
continue
if not dry_run: uids = data[0].split()
# Append to destination logging.info(f"Found {len(uids)} emails in {folder} since {since_date}")
dest_imap.append(folder, "", None, raw_msg)
# Mark as processed # Ensure dest folder exists
with open(processed_msg_file, "a") as f: try:
f.write(msg_id + "\n") dest_imap.select(folder)
processed_msg.add(msg_id) except:
forwarded += 1 logging.info(f"Creating folder {folder} in dest")
logging.info(f"Forwarded email Message-ID {msg_id}") dest_imap.create(folder)
else: dest_imap.select(folder)
logging.info(f"Dry-run: Would forward Message-ID {msg_id}")
forwarded = 0
for uid in uids:
uid_str = uid.decode()
# Fetch raw message
typ, msg_data = source_imap.fetch(uid, "(RFC822)")
if typ != "OK":
logging.error(f"Failed to fetch UID {uid_str} in {folder}")
continue
raw_msg = msg_data[0][1]
msg = message_from_bytes(raw_msg)
msg_id = msg["Message-ID"]
if not msg_id:
logging.warning(
f"No Message-ID for UID {uid_str} in {folder}, skipping"
)
continue
if msg_id in processed_msg:
logging.info(f"Skipping already processed Message-ID {msg_id}")
continue
# Check dest for duplicate
typ, dest_data = dest_imap.search(
None, f'HEADER Message-ID "{msg_id}"'
)
if typ == "OK" and dest_data[0]:
logging.info(f"Skipping duplicate Message-ID {msg_id} in dest")
continue
if not dry_run:
# Append to destination
dest_imap.append(folder, "", None, raw_msg)
# Mark as processed
with open(processed_msg_file, "a") as f:
f.write(msg_id + "\n")
processed_msg.add(msg_id)
forwarded += 1
logging.info(
f"Forwarded email Message-ID {msg_id} from {folder}"
)
else:
logging.info(
f"Dry-run: Would forward Message-ID {msg_id} from {folder}"
)
source_imap.logout()
total_forwarded += forwarded
logging.info(
f"Completed syncing {folder}: forwarded {forwarded} emails"
)
except Exception as e:
logging.error(f"Error syncing folder {folder}: {e}")
dest_imap.logout() dest_imap.logout()
source_imap.logout()
if not dry_run: if not dry_run:
# Update last run # Update last run
with open(last_run_file, "w") as f: with open(last_run_file, "w") as f:
f.write(datetime.now(timezone.utc).isoformat()) f.write(datetime.now(timezone.utc).isoformat())
logging.info(f"Email forwarding completed. Forwarded {forwarded} new emails.") logging.info(
f"Email forwarding completed. Total forwarded {total_forwarded} new emails."
)
except Exception as e: except Exception as e:
logging.error(f"Error during email forwarding: {e}") logging.error(f"Error during email forwarding: {e}")