Compare commits

5 Commits

Author SHA1 Message Date
Tyler 0c6a25a2a6 Delete setup.sh 2026-03-25 23:34:59 -04:00
Tyler 9f94852c60 renamed a file, updated reqs, readme, removed file
Renamed main.py to mail relay.py, integrated the setup.sh into mail relay.py --setup
2026-03-25 23:34:46 -04:00
Tyler 726ead2bcf rename 2026-03-25 23:31:55 -04:00
Tyler 169c4cbbeb Updated README
Updating README to show new features and added disclaimer to the end.
2026-03-25 21:51:44 -04:00
Tyler a8dfb048fe Fixing vibe coding and adding more features
Going through code to ensure readability and removing redundancy's from Claude Code, also adding more mail services for forwarding and receiving.
2026-03-25 21:27:28 -04:00
4 changed files with 435 additions and 222 deletions
+70 -81
View File
@@ -1,6 +1,6 @@
# MailRelay # MailRelay
A self-hosted email forwarding tool that monitors a Proton Mail account on a set interval, exports new emails using the official Proton Mail Export CLI, and forwards them to an iCloud Mail account. A self-hosted email forwarding service for iCloud, Gmail, Outlook, and Proton Mail. Primarily for Proton Mail's paid forwarding restriction, the tool provides a free and open source alternative for forwarding Proton Mail messages to any supported mailbox, while also being able to forward mail from other supported providers.
--- ---
@@ -9,10 +9,19 @@ A self-hosted email forwarding tool that monitors a Proton Mail account on a set
| Requirement | Notes | | Requirement | Notes |
|---|---| |---|---|
| Python 3.11+ | [python.org](https://www.python.org/downloads/) | | Python 3.11+ | [python.org](https://www.python.org/downloads/) |
| `wget` | Pre-installed on most Linux distros; on macOS: `brew install wget` | | Linux x86\_64, macOS, or Windows x86\_64 | Required for the Proton Export CLI (only needed when Proton is a source or destination) |
| Linux x86\_64 **or** macOS (for testing) | The Proton Export CLI download is the Linux x86\_64 build |
| iCloud app-specific password | Generate at [appleid.apple.com](https://appleid.apple.com) → Sign-In and Security → App-Specific Passwords | | iCloud app-specific password | Generate at [appleid.apple.com](https://appleid.apple.com) → Sign-In and Security → App-Specific Passwords |
| Proton Mail TOTP secret | The **base32 secret key** shown when you first enabled 2FA in Proton Mail settings (not a one-time code) | | Gmail app-specific password | Requires 2FA enabled. Generate at [myaccount.google.com](https://myaccount.google.com) → Security → 2-Step Verification → App passwords |
| Outlook app-specific password | Requires 2FA enabled. Generate at [account.microsoft.com](https://account.microsoft.com) → Security → Advanced security options → App passwords |
| Proton Mail TOTP secret | The **base32 secret key** shown when you first enabled 2FA in Proton Mail settings (not a one-time code). You can also find this in your password manager or TOTP app |
> [!NOTE]
> If your password/TOTP manager stores your TOTP secret as a URL, look for `secret=` in the URL.
>
> **Example:**
> `otpauth://totp/entry%20name:youremail%40proton.me?...&secret=your_secret_here&...`
>
> In this case, your TOTP secret would be `your_secret_here`.
--- ---
@@ -20,15 +29,15 @@ A self-hosted email forwarding tool that monitors a Proton Mail account on a set
``` ```
MailRelay/ MailRelay/
├── main.py Entry point — CLI flags, scheduler startup ├── mailrelay.py Entry point — CLI flags, venv bootstrap, scheduler startup
├── requirements.txt Python dependencies ├── requirements.txt Python dependencies
├── setup.sh One-shot venv + dependency installer ├── setup.sh Optional shell alias for `python mailrelay.py --setup`
├── modules/ ├── modules/
│ ├── __init__.py │ ├── __init__.py
│ ├── config.py Encrypted config read/write (age + TOML) │ ├── config.py Encrypted config read/write (age + TOML)
│ ├── database.py SQLite dedup tracking │ ├── database.py SQLite dedup tracking
│ ├── exporter.py pexpect automation of proton-mail-export-cli │ ├── exporter.py Proton CLI automation + IMAP fetch for other providers
│ ├── forwarder.py IMAP push to iCloud (Mode 1) │ ├── forwarder.py IMAP APPEND delivery
│ ├── logger.py Rotating log setup │ ├── logger.py Rotating log setup
│ ├── otp.py pyotp TOTP generation │ ├── otp.py pyotp TOTP generation
│ ├── packager.py MBOX generation and local download server (Mode 2) │ ├── packager.py MBOX generation and local download server (Mode 2)
@@ -37,7 +46,7 @@ MailRelay/
│ └── tools.py Proton Export CLI download + install manager │ └── tools.py Proton Export CLI download + install manager
├── tools/ ├── tools/
│ └── proton-export/ │ └── proton-export/
│ └── proton-mail-export-cli (downloaded on first run) │ └── proton-mail-export-cli (downloaded on first run, Proton only)
└── data/ └── data/
├── config.age Encrypted config (created on first run) ├── config.age Encrypted config (created on first run)
├── mailrelay.db SQLite database (created on first run) ├── mailrelay.db SQLite database (created on first run)
@@ -57,69 +66,51 @@ git clone <repo-url> mailrelay
cd mailrelay cd mailrelay
``` ```
### 2. Create the virtual environment and install dependencies ### 2. Run first-time setup
Run the included setup script:
```bash ```bash
bash setup.sh python mailrelay.py --setup
``` ```
This will: That's it. The setup wizard handles everything in one step:
- Verify Python 3.11+ is available
- Create a `.venv` virtual environment in the project directory
- Upgrade pip
- Install all required packages from `requirements.txt`
To activate the environment manually in future sessions: 1. **Checks Python 3.11+** is available
2. **Creates a `.venv`** virtual environment (if one doesn't already exist)
```bash 3. **Installs all dependencies** from `requirements.txt`
source .venv/bin/activate 4. **Restarts itself** inside the venv automatically
``` 5. **Prompts for your credentials and preferences** (see table below)
6. **Downloads the Proton Export CLI** only if Proton is your source or destination — you will be shown the download URL and asked to confirm your OS
or for fish
```fish
source .venv/bin/activate.fish
```
### 3. Run first-time setup
```bash
python main.py --setup
```
The setup wizard will:
1. Check for the **Proton Mail Export CLI** in `tools/proton-export/`. If it is not present, you will be prompted to download it automatically via `wget`. The Linux x86\_64 build is downloaded, extracted, and made executable — no manual steps required.
2. Prompt for your credentials and preferences:
| Prompt | What to enter | | Prompt | What to enter |
|---|---| |---|---|
| Proton Mail email | Your full `@proton.me` address | | Source service | The account you want to forward **from** |
| Proton Mail password | Your Proton account password | | Destination service | The account you want to forward **to** |
| Proton Mail mailbox password | Your mailbox password if set, or leave blank | | Credentials | Email and password (or app-specific password) for each selected service |
| TOTP secret key | The base32 secret from your Proton 2FA setup | | Proton TOTP secret | The base32 secret from your Proton 2FA setup (Proton accounts only) |
| iCloud email | Your full `@icloud.com` address |
| iCloud app-specific password | The password generated at appleid.apple.com |
| Delivery mode | `1` = automatic IMAP push (default), `2` = manual MBOX download | | Delivery mode | `1` = automatic IMAP push (default), `2` = manual MBOX download |
| Polling interval | Choose a preset or enter a custom number of minutes (minimum 15) | | Polling interval | Choose a preset or enter a custom number of minutes (minimum 15) |
| Master password | A password you choose to encrypt the config file | | Master password | A password you choose to encrypt the config file |
All credentials are stored in `data/config.age`, encrypted with [age](https://age-encryption.org/) using your master password. The plaintext is never written to disk. All credentials are stored in `data/config.age`, encrypted with [age](https://age-encryption.org/) using your master password. The plaintext is never written to disk.
### 4. Start MailRelay ### 3. Start MailRelay
```bash ```bash
python main.py python mailrelay.py
``` ```
You will be prompted for your master password once. MailRelay then runs in the foreground, syncing Proton Mail on your chosen interval. You will be prompted for your master password once. MailRelay then runs in the foreground, syncing on your chosen interval.
> **Headless / server use:** Pass the master password via environment variable to avoid the interactive prompt: > **Headless / server use:** Pass the master password via environment variable to avoid the interactive prompt:
> ```bash > ```bash
> MAILRELAY_MASTER_PASSWORD="your-master-password" python main.py > MAILRELAY_MASTER_PASSWORD="your-master-password" python mailrelay.py
> ```
> **Note:** After setup, activate the venv manually for future sessions if needed:
> ```bash
> source .venv/bin/activate # bash/zsh
> source .venv/bin/activate.fish # fish
> .venv\Scripts\activate # Windows
> ``` > ```
--- ---
@@ -128,29 +119,33 @@ You will be prompted for your master password once. MailRelay then runs in the f
| Flag | Description | | Flag | Description |
|---|---| |---|---|
| `--setup` | Run the first-time setup wizard | | `--setup` | Run the first-time setup wizard (creates venv, installs deps, configures) |
| `--run-now` | Trigger an immediate sync, then continue running on schedule | | `--run-now` | Trigger an immediate sync, then continue running on schedule |
| `--status` | Print a summary of the last run, next scheduled run, and any pending MBOX downloads | | `--status` | Print a summary of the last run, next scheduled run, and any pending MBOX downloads |
| `--logs` | Print the last 100 lines of the log file | | `--logs` | Print the last 100 lines of the log file |
| `--config` | Change a single config value without redoing full setup | | `--config` | Change a single config value without redoing full setup |
| `--debug` | Show all debug output on the console |
### Examples ### Examples
```bash ```bash
# First-time setup # First-time setup (installs deps + configures)
python main.py --setup python mailrelay.py --setup
# Start the service with an immediate sync # Start the service with an immediate sync
python main.py --run-now python mailrelay.py --run-now
# Trigger a sync while the service is running (type in the service terminal)
now
# Check status while the service is running in another terminal # Check status while the service is running in another terminal
python main.py --status python mailrelay.py --status
# Tail the log # Tail the log
python main.py --logs python mailrelay.py --logs
# Change the delivery mode # Change the delivery mode
python main.py --config python mailrelay.py --config
# > Setting to change: preferences.delivery_mode # > Setting to change: preferences.delivery_mode
# > New value: mbox # > New value: mbox
``` ```
@@ -161,7 +156,7 @@ python main.py --config
### Mode 1 — Automatic IMAP push (default) ### Mode 1 — Automatic IMAP push (default)
MailRelay connects to iCloud Mail over IMAP (port 993, SSL) and appends each new email directly to your inbox using your app-specific password. No mail client interaction needed. MailRelay connects to the destination mailbox over IMAP (port 993, SSL) and appends each new email directly to the inbox using your app-specific password. No mail client interaction needed. Supported for iCloud, Gmail, and Outlook.
If the IMAP push fails for any reason, MailRelay automatically falls back to Mode 2 for that sync cycle and logs the reason. If the IMAP push fails for any reason, MailRelay automatically falls back to Mode 2 for that sync cycle and logs the reason.
@@ -173,33 +168,24 @@ New emails are bundled into a timestamped `.mbox` file and served by a local HTT
http://127.0.0.1:8765/download/<filename>.mbox http://127.0.0.1:8765/download/<filename>.mbox
``` ```
The download URL is shown in `--status` output and written to the log. Import the `.mbox` into iCloud Mail via **File → Import Mailboxes** in the macOS Mail app. The download URL is shown in `--status` output and written to the log. Import the `.mbox` into macOS Mail via **File → Import Mailboxes**.
Once the file is downloaded, MailRelay marks the messages as delivered and deletes the file. If the file is not downloaded before the next sync cycle, MailRelay deletes it and reprocesses the messages on the next run. Once the file is downloaded, MailRelay marks the messages as delivered and deletes the file. If the file is not downloaded before the next sync cycle, MailRelay deletes it and reprocesses the messages on the next run.
### Mode 3 — Proton to Proton (automatic)
When both source and destination are Proton accounts, MailRelay uses the Proton Export CLI's restore operation to import emails directly into the destination account. No IMAP or MBOX involved.
--- ---
## How a sync cycle works ## How a sync cycle works
1. APScheduler fires at the configured interval (or `--run-now` is called) 1. APScheduler fires at the configured interval (or `--run-now` is called, or `now` is typed)
2. Any stale un-downloaded MBOX files from the previous cycle are cleaned up; their message IDs are cleared from the database so they will be re-processed 2. Any stale un-downloaded MBOX files from the previous cycle are cleaned up
3. A fresh TOTP code is generated from the stored secret 3. Emails are fetched from the source (Proton CLI export, or IMAP fetch for Gmail/Outlook/iCloud)
4. `proton-mail-export-cli` is launched via pexpect; credentials and the TOTP code are injected automatically 4. Each message ID is checked against SQLite — already-delivered messages are skipped
5. Exported `.eml` and `.metadata.json` pairs are scanned; each message ID is checked against SQLite 5. New messages are delivered in the configured mode
6. New messages only: Proton metadata fields are merged into the EML headers 6. Delivered message IDs are recorded in SQLite; the export directory is wiped
7. Delivery proceeds in the configured mode (IMAP push or MBOX bundle)
8. Delivered message IDs are recorded in SQLite; the export directory is wiped
---
## iCloud app-specific password
A standard Apple ID password will not work for IMAP access. Generate an app-specific password:
1. Go to [appleid.apple.com](https://appleid.apple.com)
2. Sign In and Security → App-Specific Passwords
3. Click **+** and give it a label (e.g. `MailRelay`)
4. Copy the generated password — it will only be shown once
--- ---
@@ -217,7 +203,7 @@ MailRelay needs the **base32 secret key** that backs your Proton 2FA, not a one-
Logs are written to `data/mailrelay.log` (rotating, max 5 MB, 3 backups). View the tail with: Logs are written to `data/mailrelay.log` (rotating, max 5 MB, 3 backups). View the tail with:
```bash ```bash
python main.py --logs python mailrelay.py --logs
``` ```
Or follow live: Or follow live:
@@ -240,4 +226,7 @@ tail -f data/mailrelay.log
| `APScheduler` | Background polling interval | | `APScheduler` | Background polling interval |
| `fastapi` + `uvicorn` | Local MBOX download server | | `fastapi` + `uvicorn` | Local MBOX download server |
All stdlib modules used (`imaplib`, `sqlite3`, `email`, `mailbox`, `logging`, `tarfile`) require no installation. All stdlib modules used (`imaplib`, `sqlite3`, `email`, `mailbox`, `logging`, `tarfile`, `zipfile`, `urllib`) require no installation.
> [!NOTE]
> All product and company names are trademarks™ or registered® trademarks of their respective holders. Use of them does not imply any affiliation with or endorsement by them.
+189 -27
View File
@@ -1,21 +1,91 @@
"""MailRelay — entry point and CLI. """MailRelay — entry point and CLI.
Usage examples: Usage examples:
python main.py --setup First-time setup wizard python mailrelay.py --setup First-time setup wizard (installs deps + configures)
python main.py Start the background service (prompts for master password) python mailrelay.py Start the background service
python main.py --run-now Immediate sync then keep running python mailrelay.py --debug Start with debug logs
python main.py --status Print last-run summary and exit python mailrelay.py --run-now Immediate sync then keep running
python main.py --logs Tail the log file and exit python mailrelay.py --status Print last-run summary and exit
python main.py --config Interactively change a single config value python mailrelay.py --logs Tail the log file and exit
python mailrelay.py --config Interactively change a single config value
""" """
import getpass
import os import os
import signal import subprocess
import sys import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Python version check — must happen before any other imports
# ---------------------------------------------------------------------------
if sys.version_info < (3, 11):
print(
f"ERROR: Python 3.11+ is required "
f"(found {sys.version_info.major}.{sys.version_info.minor})."
)
sys.exit(1)
# ---------------------------------------------------------------------------
# Virtual environment bootstrap — runs only during --setup, outside a venv
# ---------------------------------------------------------------------------
def _bootstrap_venv() -> None:
"""If --setup was requested and we're not in a venv, create one and re-exec.
Creates .venv/, installs requirements.txt, then replaces the current
process with the venv Python carrying the same arguments. After re-exec
this function returns immediately because sys.prefix != sys.base_prefix.
"""
if "--setup" not in sys.argv:
return
if sys.prefix != sys.base_prefix:
return # already inside a virtual environment
project_dir = Path(__file__).parent
venv_dir = project_dir / ".venv"
is_windows = sys.platform == "win32"
bin_dir = venv_dir / ("Scripts" if is_windows else "bin")
python_exe = bin_dir / ("python.exe" if is_windows else "python")
pip_exe = bin_dir / ("pip.exe" if is_windows else "pip")
if not venv_dir.exists():
print("Creating virtual environment at .venv ...")
subprocess.run([sys.executable, "-m", "venv", str(venv_dir)], check=True)
print("Virtual environment created.")
else:
print("Virtual environment already exists — skipping creation.")
print("Installing dependencies from requirements.txt ...")
subprocess.run(
[str(pip_exe), "install", "--quiet", "--upgrade", "pip"],
check=True,
)
subprocess.run(
[str(pip_exe), "install", "--quiet", "-r", str(project_dir / "requirements.txt")],
check=True,
)
print("Dependencies installed.\n")
# Replace this process with the venv Python, forwarding all arguments
os.execv(str(python_exe), [str(python_exe)] + sys.argv)
_bootstrap_venv()
# ---------------------------------------------------------------------------
# Imports — safe after bootstrap ensures deps are present
# ---------------------------------------------------------------------------
import getpass
import shutil
import signal
import tempfile
import threading
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from typing import Optional from typing import Optional
import typer import typer
@@ -102,17 +172,21 @@ def _run_setup() -> None:
_ensure_data_dirs() _ensure_data_dirs()
# Ensure the export CLI is present before finishing setup config_data, master_pw = cfg.build_config_interactively()
# Only download the Proton CLI if Proton is involved as source or destination
export_service = config_data["preferences"]["export_service"]
forward_service = config_data["preferences"]["forward_service"]
if export_service == "proton" or forward_service == "proton":
try: try:
tools.ensure_export_cli() tools.ensure_export_cli()
except tools.ToolSetupError as exc: except tools.ToolSetupError as exc:
log.error("Export CLI setup failed: %s", exc) log.error("Export CLI setup failed: %s", exc)
raise typer.Exit(code=1) raise typer.Exit(code=1)
config_data, master_pw = cfg.build_config_interactively()
cfg.save_config(config_data, master_pw) cfg.save_config(config_data, master_pw)
database.init_db() database.init_db()
print("\nSetup complete. Run 'python main.py' to start MailRelay.") print("\nSetup complete. Run 'python mailrelay.py' to start MailRelay.")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -124,21 +198,32 @@ def _start_service(run_now: bool = False) -> None:
_ensure_data_dirs() _ensure_data_dirs()
# Verify the export CLI is present (offers download if missing) conf = _load_config()
# Only need the Proton CLI if Proton is a source or destination
export_service = conf["preferences"]["export_service"]
forward_service = conf["preferences"]["forward_service"]
if export_service == "proton" or forward_service == "proton":
try: try:
tools.ensure_export_cli() tools.ensure_export_cli()
except tools.ToolSetupError as exc: except tools.ToolSetupError as exc:
log.error("Export CLI unavailable: %s", exc) log.error("Export CLI unavailable: %s", exc)
raise typer.Exit(code=1) raise typer.Exit(code=1)
conf = _load_config()
database.init_db() database.init_db()
packager.start_server() packager.start_server()
interval = conf["preferences"]["poll_interval_min"] interval = conf["preferences"]["poll_interval_min"]
_sync_lock = threading.Lock()
def sync(): def sync():
if _sync_lock.acquire(blocking=False):
try:
_sync_cycle(conf) _sync_cycle(conf)
finally:
_sync_lock.release()
else:
log.info("Sync already in progress — skipping.")
scheduler.start(sync, interval) scheduler.start(sync, interval)
@@ -159,7 +244,8 @@ def _start_service(run_now: bool = False) -> None:
) )
scheduler.run_now(sync) scheduler.run_now(sync)
log.info("MailRelay running. Press Ctrl+C to stop.") _start_stdin_listener(sync)
log.info("MailRelay running. Type 'now' + Enter to sync immediately. Press Ctrl+C to stop.")
# Keep the main thread alive; handle Ctrl+C / SIGTERM gracefully # Keep the main thread alive; handle Ctrl+C / SIGTERM gracefully
def _shutdown(sig, frame): def _shutdown(sig, frame):
@@ -204,18 +290,24 @@ def _sync_cycle(conf: dict) -> None:
if stale: if stale:
log.info("%d stale pending ID(s) cleared for re-processing.", len(stale)) log.info("%d stale pending ID(s) cleared for re-processing.", len(stale))
# 2. Generate TOTP code # 2. Fetch/export from source
export_service = conf["preferences"]["export_service"]
if export_service == "proton":
totp_code = otp.generate_totp(conf["proton"]["totp_secret"]) totp_code = otp.generate_totp(conf["proton"]["totp_secret"])
# 3. Run the export CLI
export_dir = exporter.run_export( export_dir = exporter.run_export(
email=conf["proton"]["email"], email=conf["proton"]["email"],
password=conf["proton"]["password"], password=conf["proton"]["password"],
totp_code=totp_code, totp_code=totp_code,
mailbox_password=conf["proton"].get("mailbox_password", ""), mailbox_password=conf["proton"].get("mailbox_password", ""),
) )
else:
export_dir = exporter.run_imap_fetch(
service=export_service,
email_addr=conf[export_service]["email"],
password=conf[export_service]["password"],
)
# 4. Scan, pair, deduplicate # 3. Scan, pair, deduplicate
new_emails = processor.scan_and_filter(export_dir) new_emails = processor.scan_and_filter(export_dir)
summary["emails_found"] = len(list(export_dir.glob("*.eml"))) summary["emails_found"] = len(list(export_dir.glob("*.eml")))
summary["emails_new"] = len(new_emails) summary["emails_new"] = len(new_emails)
@@ -224,9 +316,9 @@ def _sync_cycle(conf: dict) -> None:
log.info("No new emails to deliver.") log.info("No new emails to deliver.")
else: else:
delivery_mode = conf["preferences"]["delivery_mode"] delivery_mode = conf["preferences"]["delivery_mode"]
_deliver(conf, new_emails, delivery_mode, summary) _deliver(conf, new_emails, delivery_mode, conf["preferences"]["forward_service"], summary)
# 5. Clean up export directory # 4. Clean up export directory
_clean_export_dir(export_dir) _clean_export_dir(export_dir)
except exporter.ExportError as exc: except exporter.ExportError as exc:
@@ -249,12 +341,24 @@ def _sync_cycle(conf: dict) -> None:
) )
def _deliver(conf: dict, emails: list, mode: str, summary: dict) -> None: def _deliver(
if mode == "imap": conf: dict,
emails: list,
mode: str,
forward_service: str,
summary: dict,
) -> None:
if mode == "proton":
_deliver_proton(conf, emails, summary)
elif mode == "imap":
section = f"{forward_service}_receive"
imap_host, imap_port = forwarder.IMAP_SETTINGS[forward_service]
succeeded, failed = forwarder.push_emails( succeeded, failed = forwarder.push_emails(
emails, emails,
icloud_email=conf["icloud"]["email"], dest_email=conf[section]["email"],
icloud_password=conf["icloud"]["password"], dest_password=conf[section]["password"],
imap_host=imap_host,
imap_port=imap_port,
) )
summary["delivered"] = len(succeeded) summary["delivered"] = len(succeeded)
summary["failed"] = len(failed) summary["failed"] = len(failed)
@@ -271,6 +375,38 @@ def _deliver(conf: dict, emails: list, mode: str, summary: dict) -> None:
_deliver_mbox(emails, summary) _deliver_mbox(emails, summary)
def _deliver_proton(conf: dict, emails: list, summary: dict) -> None:
"""Restore new emails into the destination Proton account via the CLI."""
receive_conf = conf["proton_receive"]
# Build a matching export directory structure in a temp location
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
restore_base = Path(tempfile.mkdtemp())
restore_mail_dir = restore_base / receive_conf["email"] / f"mail_{timestamp}"
restore_mail_dir.mkdir(parents=True)
try:
for rich in emails:
(restore_mail_dir / f"{rich.message_id}.eml").write_bytes(rich.raw_bytes)
totp_code = otp.generate_totp(receive_conf["totp_secret"])
exporter.run_restore(
email=receive_conf["email"],
password=receive_conf["password"],
totp_code=totp_code,
restore_base=restore_base,
mailbox_password=receive_conf.get("mailbox_password", ""),
)
database.mark_delivered([e.message_id for e in emails])
summary["delivered"] = len(emails)
log.info("Proton restore delivered %d message(s).", len(emails))
except exporter.ExportError as exc:
log.error("Proton restore failed: %s", exc)
summary["failed"] = len(emails)
finally:
shutil.rmtree(restore_base, ignore_errors=True)
def _deliver_mbox(emails: list, summary: dict) -> None: def _deliver_mbox(emails: list, summary: dict) -> None:
url = packager.bundle_emails(emails) url = packager.bundle_emails(emails)
if url: if url:
@@ -331,7 +467,10 @@ def _print_status() -> None:
def _run_config_change() -> None: def _run_config_change() -> None:
print("\nAvailable settings:") print("\nAvailable settings:")
print(" proton.email | proton.password | proton.mailbox_password | proton.totp_secret") print(" proton.email | proton.password | proton.mailbox_password | proton.totp_secret")
print(" icloud.email | icloud.password") print(" proton_receive.email | proton_receive.password | proton_receive.mailbox_password | proton_receive.totp_secret")
print(" gmail.email | gmail.password | gmail_receive.email | gmail_receive.password")
print(" outlook.email | outlook.password | outlook_receive.email | outlook_receive.password")
print(" icloud.email | icloud.password | icloud_receive.email | icloud_receive.password")
print(" preferences.delivery_mode | preferences.poll_interval_min") print(" preferences.delivery_mode | preferences.poll_interval_min")
print() print()
@@ -367,6 +506,30 @@ def _run_config_change() -> None:
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _start_stdin_listener(sync_fn) -> None:
"""Start a daemon thread that watches stdin for the 'now' keyword.
Typing 'now' + Enter triggers an immediate sync without touching the
scheduler's interval — the next scheduled run fires at its normal time.
A lock inside sync_fn prevents two syncs from running simultaneously.
"""
def _reader():
while True:
try:
line = sys.stdin.readline()
except (EOFError, OSError):
break
if line.strip().lower() == "now":
log.info("'now' received — triggering immediate sync.")
threading.Thread(
target=sync_fn,
daemon=True,
name="mailrelay-adhoc-sync",
).start()
threading.Thread(target=_reader, daemon=True, name="mailrelay-stdin-listener").start()
def _prompt_master_password() -> str: def _prompt_master_password() -> str:
# Allow headless/server use via environment variable # Allow headless/server use via environment variable
env_pw = os.environ.get("MAILRELAY_MASTER_PASSWORD") env_pw = os.environ.get("MAILRELAY_MASTER_PASSWORD")
@@ -385,7 +548,6 @@ def _load_config() -> dict:
def _clean_export_dir(export_dir: Path) -> None: def _clean_export_dir(export_dir: Path) -> None:
"""Remove the mail_* export directory tree after a sync cycle.""" """Remove the mail_* export directory tree after a sync cycle."""
import shutil
try: try:
shutil.rmtree(export_dir) shutil.rmtree(export_dir)
log.debug("Removed export directory: %s", export_dir.name) log.debug("Removed export directory: %s", export_dir.name)
+144 -5
View File
@@ -14,6 +14,10 @@ Config schema (as TOML):
email = "user@icloud.com" email = "user@icloud.com"
password = "..." # app-specific password password = "..." # app-specific password
[gmail]
email = "user@gmail.com"
password = "..." # app-specific password
[preferences] [preferences]
delivery_mode = "imap" # "imap" | "mbox" delivery_mode = "imap" # "imap" | "mbox"
poll_interval_min = 60 # integer minutes poll_interval_min = 60 # integer minutes
@@ -31,11 +35,17 @@ CONFIG_PATH = Path(__file__).parent.parent / "data" / "config.age"
# Keys we expose to the rest of the app # Keys we expose to the rest of the app
REQUIRED_KEYS = { REQUIRED_KEYS = {
"proton": ["email", "password", "totp_secret"], "proton": ["email", "password", "totp_secret"],
"proton_receive": ["email", "password", "totp_secret"],
"icloud": ["email", "password"], "icloud": ["email", "password"],
"icloud_receive": ["email", "password"],
"gmail": ["email", "password"],
"gmail_receive": ["email", "password"],
"outlook": ["email", "password"],
"outlook_receive": ["email", "password"],
"preferences": ["delivery_mode", "poll_interval_min"], "preferences": ["delivery_mode", "poll_interval_min"],
} }
DELIVERY_MODES = ("imap", "mbox") DELIVERY_MODES = ("imap", "mbox", "proton")
MIN_INTERVAL_MIN = 15 MIN_INTERVAL_MIN = 15
@@ -142,22 +152,125 @@ def build_config_interactively() -> tuple[dict[str, Any], str]:
print("\n=== MailRelay First-Time Setup ===\n") print("\n=== MailRelay First-Time Setup ===\n")
proton_email = input("Proton Mail email address: ").strip() # Service to forward
proton_password = getpass.getpass("Proton Mail password: ") print("\nWhat service do you want to forward?")
print(" 1) Proton Email")
print(" 2) Gmail")
print(" 3) Outlook")
print(" 4) iCloud")
# Ensuring vaild choice
while True:
export_choice = input("Choose [1/2/3/4]: ").strip()
try:
if int(export_choice) in [1,2,3,4]:
break
print("Invalid option")
except ValueError:
print("Invalid option")
# Service to receive
print("\nWhat service do you want to receive?")
print(" 1) Different Proton email" if export_choice == "1" else " 1) Proton email")
print(" 2) Different Gmail" if export_choice == "2" else " 2) Gmail")
print(" 3) Different Outlook" if export_choice == "3" else " 3) Outlook")
print(" 4) Different iCloud" if export_choice == "4" else " 4) iCloud")
# Ensuring vaild choice
while True:
forward_choice = input("Choose [1/2/3/4]: ").strip()
try:
if int(forward_choice) in [1,2,3,4]:
break
print("Invalid option")
except ValueError:
print("Invalid option")
# Ensuring no missing value errors
proton_email = None
proton_email_receive = None
proton_password = None
proton_password_receive = None
proton_mailbox_pw = None
proton_mailbox_pw_receive = None
totp_secret = None
totp_secret_receive = None
gmail_email = None
gmail_email_receive = None
gmail_password = None
gmail_password_receive = None
outlook_email = None
outlook_email_receive = None
outlook_password = None
outlook_password_receive = None
icloud_email = None
icloud_email_receive = None
icloud_password = None
icloud_password_receive = None
# Proton account login info
if export_choice == "1":
proton_email = input("Proton email address to forward: ").strip()
proton_password = getpass.getpass("Proton password: ")
proton_mailbox_pw = getpass.getpass( proton_mailbox_pw = getpass.getpass(
"Proton Mail mailbox password (leave blank if none): " "Proton Mail mailbox password (leave blank if none): "
) )
totp_secret = input("TOTP secret key (base32, from your 2FA setup): ").strip() totp_secret = input("TOTP secret key (base32, from your 2FA setup): ").strip()
icloud_email = input("\niCloud email address: ").strip() if forward_choice == "1":
proton_email_receive = input("Proton email address to receive: ").strip()
proton_password_receive = getpass.getpass("Proton password: ")
proton_mailbox_pw_receive = getpass.getpass(
"Proton Mail mailbox password (leave blank if none): "
)
totp_secret_receive = input("TOTP secret key (base32, from your 2FA setup): ").strip()
# Gmail login info
if export_choice == "2":
gmail_email = input("\nGmail email address to forward: ").strip()
gmail_password = getpass.getpass("Google account app-specific password: ")
if forward_choice == "2":
gmail_email_receive = input("\nGmail email address to receive: ").strip()
gmail_password_receive = getpass.getpass("Google account app-specific password: ")
# Outlook login info
if export_choice == "3":
outlook_email = input("\nOutlook email address to forward: ").strip()
outlook_password = getpass.getpass("Microsoft app-specific password: ")
if forward_choice == "3":
outlook_email_receive = input("\nOutlook email address to receive: ").strip()
outlook_password_receive = getpass.getpass("Microsoft app-specific password: ")
# iCloud login info
if export_choice == "4":
icloud_email = input("\niCloud email address to forward: ").strip()
icloud_password = getpass.getpass("iCloud app-specific password: ") icloud_password = getpass.getpass("iCloud app-specific password: ")
if forward_choice == "4":
icloud_email_receive = input("\niCloud email address to receive: ").strip()
icloud_password_receive = getpass.getpass("iCloud app-specific password: ")
# Delivery Dialog
if forward_choice == "4":
print("\nDelivery mode:") print("\nDelivery mode:")
print(" 1) Automatic IMAP push to iCloud (default)") print(" 1) Automatic IMAP push (default)")
print(" 2) Manual MBOX download") print(" 2) Manual MBOX download")
mode_choice = input("Choose [1/2, default 1]: ").strip() or "1" mode_choice = input("Choose [1/2, default 1]: ").strip() or "1"
delivery_mode = "imap" if mode_choice != "2" else "mbox" delivery_mode = "imap" if mode_choice != "2" else "mbox"
elif forward_choice == "1":
print("\nUsing Proton export/import CLI tool")
delivery_mode = "proton"
else:
print("\nWARNING: GMAIL AND OUTLOOK DO NOT SUPPORT MBOX \nUsing IMAP push")
delivery_mode = "imap"
print("\nPolling interval:") print("\nPolling interval:")
print(" 1) 15 minutes") print(" 1) 15 minutes")
print(" 2) 30 minutes") print(" 2) 30 minutes")
@@ -191,10 +304,36 @@ def build_config_interactively() -> tuple[dict[str, Any], str]:
"mailbox_password": proton_mailbox_pw, "mailbox_password": proton_mailbox_pw,
"totp_secret": totp_secret, "totp_secret": totp_secret,
}, },
"proton_receive": {
"email": proton_email_receive,
"password": proton_password_receive,
"mailbox_password": proton_mailbox_pw_receive,
"totp_secret": totp_secret_receive,
},
"icloud": { "icloud": {
"email": icloud_email, "email": icloud_email,
"password": icloud_password, "password": icloud_password,
}, },
"icloud_receive": {
"email": icloud_email_receive,
"password": icloud_password_receive,
},
"gmail": {
"email": gmail_email,
"password": gmail_password,
},
"gmail_receive": {
"email": gmail_email_receive,
"password": gmail_password_receive,
},
"outlook": {
"email": outlook_email,
"password": outlook_password,
},
"outlook_receive": {
"email": outlook_email_receive,
"password": outlook_password_receive,
},
"preferences": { "preferences": {
"delivery_mode": delivery_mode, "delivery_mode": delivery_mode,
"poll_interval_min": poll_interval, "poll_interval_min": poll_interval,
-77
View File
@@ -1,77 +0,0 @@
#!/usr/bin/env bash
# MailRelay — virtual environment setup script
# Creates .venv, upgrades pip, and installs all required dependencies.
# Run once before first use: bash setup.sh
set -euo pipefail
VENV_DIR=".venv"
PYTHON="${PYTHON:-python3}"
MIN_PYTHON_MINOR=11 # 3.11+
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
green() { printf '\033[0;32m%s\033[0m\n' "$*"; }
yellow() { printf '\033[0;33m%s\033[0m\n' "$*"; }
red() { printf '\033[0;31m%s\033[0m\n' "$*"; }
die() { red "ERROR: $*"; exit 1; }
# ---------------------------------------------------------------------------
# Python version check
# ---------------------------------------------------------------------------
if ! command -v "$PYTHON" &>/dev/null; then
die "'$PYTHON' not found. Install Python 3.11+ and retry, or set PYTHON=/path/to/python3."
fi
PY_VERSION=$("$PYTHON" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
PY_MINOR=$("$PYTHON" -c 'import sys; print(sys.version_info.minor)')
PY_MAJOR=$("$PYTHON" -c 'import sys; print(sys.version_info.major)')
if [[ "$PY_MAJOR" -lt 3 || ( "$PY_MAJOR" -eq 3 && "$PY_MINOR" -lt "$MIN_PYTHON_MINOR" ) ]]; then
die "Python 3.${MIN_PYTHON_MINOR}+ required (found $PY_VERSION)."
fi
green "Python $PY_VERSION — OK"
# ---------------------------------------------------------------------------
# Create virtual environment
# ---------------------------------------------------------------------------
if [[ -d "$VENV_DIR" ]]; then
yellow "Virtual environment already exists at $VENV_DIR — skipping creation."
else
echo "Creating virtual environment at $VENV_DIR"
"$PYTHON" -m venv "$VENV_DIR"
green "Virtual environment created."
fi
# Activate
# shellcheck source=/dev/null
source "$VENV_DIR/bin/activate"
# ---------------------------------------------------------------------------
# Install dependencies
# ---------------------------------------------------------------------------
echo "Upgrading pip …"
pip install --quiet --upgrade pip
echo "Installing dependencies from requirements.txt …"
pip install --quiet -r requirements.txt
green "All dependencies installed."
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
echo ""
green "Setup complete!"
echo ""
echo " Activate the environment: source .venv/bin/activate"
echo " Run first-time setup: python main.py --setup"
echo " Start MailRelay: python main.py"
echo ""