From 686c4877d30839f2ec39ae36aa186e52acb4dc5d Mon Sep 17 00:00:00 2001 From: Jeena Date: Sun, 4 Jan 2026 14:09:02 +0900 Subject: [PATCH] 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 --- .env.example | 13 +++ .gitignore | 141 ++++++++++++++++++++++++ README.md | 59 ++++++++++ email_forwarder-fail-notify.service | 9 ++ email_forwarder.service | 12 ++ email_forwarder.service.d/override.conf | 3 + email_forwarder.timer | 9 ++ main.py | 133 ++++++++++++++++++++++ pyproject.toml | 10 ++ 9 files changed, 389 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 email_forwarder-fail-notify.service create mode 100644 email_forwarder.service create mode 100644 email_forwarder.service.d/override.conf create mode 100644 email_forwarder.timer create mode 100644 main.py create mode 100644 pyproject.toml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d0eb949 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +SOURCE_HOST=imap.gmx.com +SOURCE_PORT=993 +SOURCE_USER=your@gmx.com +SOURCE_PASS=password +DEST_HOST=yourserver.mxrouting.net +DEST_PORT=993 +DEST_USER=your@mxroute.com +DEST_PASS=password +FOLDER=INBOX +PROCESSED_FILE=processed_uids.txt +DRY_RUN=false +UPTIME_SUCCESS_URL=https://uptime.jeena.net/api/push/aUC76G2mpY?status=up&msg=OK&ping= +UPTIME_FAIL_URL=https://uptime.jeena.net/api/push/aUC76G2mpY?status=down&msg=Failed \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7377db7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,141 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDE +.vscode/ +.idea/ + +# Ruff +.ruff_cache/ + +# Local data +processed_uids.txt +processed_message_ids.txt +last_run.txt \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cefe0f0 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Email Forwarder + +This script forwards new emails from a source IMAP account (e.g., GMX) to a destination IMAP account (e.g., MXRoute), avoiding duplicates by tracking Message-IDs and using time-based searches. + +## Setup + +1. Copy `.env.example` to `.env` and fill in your IMAP credentials, Uptime URLs, and settings. + +2. Create virtual environment: `python -m venv venv` + +3. Activate and install: `source venv/bin/activate && pip install -e .` + +4. For user services (recommended): + - Copy services: `cp *.service *.timer ~/.config/systemd/user/` + - Copy drop-in: `cp -r email_forwarder.service.d ~/.config/systemd/user/` + - Edit `~/.config/systemd/user/email_forwarder.service.d/override.conf` to set `PROJECT_DIR` to your project path. + +5. Reload and enable: `systemctl --user daemon-reload && systemctl --user enable email_forwarder.timer && systemctl --user start email_forwarder.timer` + +6. Check status: `systemctl --user status email_forwarder.timer` + +## Configuration (.env) + +- `SOURCE_HOST`: Source IMAP server (e.g., imap.gmx.com) +- `SOURCE_PORT`: Port (default 993) +- `SOURCE_USER`: Source email/username +- `SOURCE_PASS`: Source password +- `DEST_HOST`: Destination IMAP server (e.g., yourserver.mxrouting.net) +- `DEST_PORT`: Port (default 993) +- `DEST_USER`: Destination email/username +- `DEST_PASS`: Destination password +- `FOLDER`: Mailbox folder (default INBOX) +- `DRY_RUN`: Set to `true` for testing without forwarding (default false) +- `UPTIME_SUCCESS_URL`: URL for success ping +- `UPTIME_FAIL_URL`: URL for failure ping + +## How It Works + +- Uses time-based search (`SINCE`) with `last_run.txt` to find emails since last run. +- Extracts Message-ID, checks against local file and destination IMAP for duplicates. +- Forwards via IMAP APPEND; updates `last_run.txt` on success. +- Logs activity; systemd handles Uptime Kuma pings. + +## Monitoring with Uptime Kuma + +- Success: Pings `${UPTIME_SUCCESS_URL}` after forwarding. +- Failure: `OnFailure` pings `${UPTIME_FAIL_URL}`. + +## Dependencies + +- imaplib, email (built-in) +- python-dotenv, imap-tools +- systemd (for user services) + +## Troubleshooting + +- Logs: `journalctl --user -u email_forwarder.service` +- Test: `source venv/bin/activate && python main.py` +- Timezone issues: Adjust UTC offset in script if needed. \ No newline at end of file diff --git a/email_forwarder-fail-notify.service b/email_forwarder-fail-notify.service new file mode 100644 index 0000000..2fb9344 --- /dev/null +++ b/email_forwarder-fail-notify.service @@ -0,0 +1,9 @@ +[Unit] +Description=Email Forwarder Failure Notification + +[Service] +Type=oneshot +ExecStart=/usr/bin/curl -fsS --retry 3 ${UPTIME_FAIL_URL} + +[Install] +WantedBy=default.target \ No newline at end of file diff --git a/email_forwarder.service b/email_forwarder.service new file mode 100644 index 0000000..b096f2c --- /dev/null +++ b/email_forwarder.service @@ -0,0 +1,12 @@ +[Unit] +Description=Email Forwarder + +[Service] +Type=oneshot +WorkingDirectory=${PROJECT_DIR} +ExecStart=${PROJECT_DIR}/venv/bin/python main.py +ExecStartPost=/usr/bin/curl -fsS --retry 3 ${UPTIME_SUCCESS_URL} +OnFailure=email_forwarder-fail-notify.service + +[Install] +WantedBy=default.target \ No newline at end of file diff --git a/email_forwarder.service.d/override.conf b/email_forwarder.service.d/override.conf new file mode 100644 index 0000000..f4ac3df --- /dev/null +++ b/email_forwarder.service.d/override.conf @@ -0,0 +1,3 @@ +[Service] +Environment=PROJECT_DIR=/home/jeena/Projects/email_forwarder +EnvironmentFile=${PROJECT_DIR}/.env \ No newline at end of file diff --git a/email_forwarder.timer b/email_forwarder.timer new file mode 100644 index 0000000..15b2a0f --- /dev/null +++ b/email_forwarder.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Run Email Forwarder every 5 minutes + +[Timer] +OnCalendar=*:0/5 +Persistent=true + +[Install] +WantedBy=timers.target \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..3eb9b97 --- /dev/null +++ b/main.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9ed22fa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[build-system] +requires = ["setuptools", "wheel"] + +[project] +name = "email-forwarder" +version = "0.1.0" +dependencies = [ + "imap-tools", + "python-dotenv", +] \ No newline at end of file