Implement email forwarder
Add core IMAP forwarding logic with time-based searches and Message-ID deduplication to prevent duplicates in destination mailbox. Changes: - Implement SINCE-based email search using last_run.txt for incremental forwarding - Add Message-ID extraction and IMAP checks for deduplication - Configure systemd user services with drop-in overrides for portability - Integrate Uptime Kuma pings for monitoring - Set up virtual environment for dependency isolation - Update documentation and configuration templates
This commit is contained in:
commit
686c4877d3
9 changed files with 389 additions and 0 deletions
133
main.py
Normal file
133
main.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import imaplib
|
||||
import os
|
||||
import logging
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from email import message_from_bytes
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Configuration
|
||||
source_host = os.getenv("SOURCE_HOST")
|
||||
if not source_host:
|
||||
raise ValueError("SOURCE_HOST not set")
|
||||
source_port = int(os.getenv("SOURCE_PORT", "993"))
|
||||
source_user = os.getenv("SOURCE_USER")
|
||||
if not source_user:
|
||||
raise ValueError("SOURCE_USER not set")
|
||||
source_pass = os.getenv("SOURCE_PASS")
|
||||
if not source_pass:
|
||||
raise ValueError("SOURCE_PASS not set")
|
||||
dest_host = os.getenv("DEST_HOST")
|
||||
if not dest_host:
|
||||
raise ValueError("DEST_HOST not set")
|
||||
dest_port = int(os.getenv("DEST_PORT", "993"))
|
||||
dest_user = os.getenv("DEST_USER")
|
||||
if not dest_user:
|
||||
raise ValueError("DEST_USER not set")
|
||||
dest_pass = os.getenv("DEST_PASS")
|
||||
if not dest_pass:
|
||||
raise ValueError("DEST_PASS not set")
|
||||
folder = os.getenv("FOLDER", "INBOX")
|
||||
processed_file = os.getenv("PROCESSED_FILE", "processed_uids.txt")
|
||||
processed_msg_file = "processed_message_ids.txt"
|
||||
last_run_file = "last_run.txt"
|
||||
dry_run = os.getenv("DRY_RUN", "false").lower() == "true"
|
||||
|
||||
|
||||
def main():
|
||||
# Last run
|
||||
if os.path.exists(last_run_file):
|
||||
with open(last_run_file) as f:
|
||||
last_run_str = f.read().strip()
|
||||
last_run = datetime.fromisoformat(last_run_str)
|
||||
else:
|
||||
last_run = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
since_date = last_run.strftime("%d-%b-%Y")
|
||||
logging.info(f"Using SINCE {since_date}")
|
||||
|
||||
# Load processed Message-IDs
|
||||
processed_msg = set()
|
||||
if os.path.exists(processed_msg_file):
|
||||
with open(processed_msg_file, "r") as f:
|
||||
processed_msg = set(line.strip() for line in f if line.strip())
|
||||
|
||||
try:
|
||||
# Connect to source IMAP
|
||||
source_imap = imaplib.IMAP4_SSL(source_host, source_port)
|
||||
source_imap.login(source_user, source_pass)
|
||||
source_imap.select(folder)
|
||||
|
||||
# Search for emails since last run
|
||||
typ, data = source_imap.search(None, f'SINCE "{since_date}"')
|
||||
if typ != "OK":
|
||||
raise Exception(f"Search failed: {typ}")
|
||||
|
||||
uids = data[0].split()
|
||||
logging.info(f"Found {len(uids)} emails since {since_date}")
|
||||
|
||||
# Connect to destination IMAP
|
||||
dest_imap = imaplib.IMAP4_SSL(dest_host, dest_port)
|
||||
dest_imap.login(dest_user, dest_pass)
|
||||
dest_imap.select(folder)
|
||||
|
||||
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)
|
||||
msg_id = msg["Message-ID"]
|
||||
if not msg_id:
|
||||
logging.warning(f"No Message-ID for UID {uid_str}, 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}")
|
||||
else:
|
||||
logging.info(f"Dry-run: Would forward Message-ID {msg_id}")
|
||||
|
||||
dest_imap.logout()
|
||||
source_imap.logout()
|
||||
|
||||
if not dry_run:
|
||||
# Update last run
|
||||
with open(last_run_file, "w") as f:
|
||||
f.write(datetime.now(timezone.utc).isoformat())
|
||||
|
||||
logging.info(f"Email forwarding completed. Forwarded {forwarded} new emails.")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error during email forwarding: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue