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:
parent
2ecdf33dc4
commit
485a5db8b9
3 changed files with 107 additions and 49 deletions
|
|
@ -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
|
||||||
|
|
@ -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
152
main.py
|
|
@ -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}")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue