a8dfb048fe
Going through code to ensure readability and removing redundancy's from Claude Code, also adding more mail services for forwarding and receiving.
344 lines
11 KiB
Python
344 lines
11 KiB
Python
"""Encrypted config management using age (via pyrage) and TOML.
|
|
|
|
The config file is stored as an age-encrypted TOML blob at data/config.age.
|
|
The encryption key is a scrypt-derived passphrase key from the master password.
|
|
|
|
Config schema (as TOML):
|
|
[proton]
|
|
email = "user@proton.me"
|
|
password = "..."
|
|
mailbox_password = "..." # optional, "" if not set
|
|
totp_secret = "..." # base32 TOTP secret
|
|
|
|
[icloud]
|
|
email = "user@icloud.com"
|
|
password = "..." # app-specific password
|
|
|
|
[gmail]
|
|
email = "user@gmail.com"
|
|
password = "..." # app-specific password
|
|
|
|
[preferences]
|
|
delivery_mode = "imap" # "imap" | "mbox"
|
|
poll_interval_min = 60 # integer minutes
|
|
"""
|
|
|
|
import io
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pyrage
|
|
import toml
|
|
|
|
CONFIG_PATH = Path(__file__).parent.parent / "data" / "config.age"
|
|
|
|
# Keys we expose to the rest of the app
|
|
REQUIRED_KEYS = {
|
|
"proton": ["email", "password", "totp_secret"],
|
|
"proton_receive": ["email", "password", "totp_secret"],
|
|
"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"],
|
|
}
|
|
|
|
DELIVERY_MODES = ("imap", "mbox", "proton")
|
|
MIN_INTERVAL_MIN = 15
|
|
|
|
|
|
class ConfigError(Exception):
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Low-level encrypt / decrypt helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _encrypt(plaintext: str, passphrase: str) -> bytes:
|
|
return pyrage.passphrase.encrypt(plaintext.encode(), passphrase)
|
|
|
|
|
|
def _decrypt(ciphertext: bytes, passphrase: str) -> str:
|
|
return pyrage.passphrase.decrypt(ciphertext, passphrase).decode()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def config_exists() -> bool:
|
|
return CONFIG_PATH.exists()
|
|
|
|
|
|
def save_config(data: dict[str, Any], passphrase: str) -> None:
|
|
"""Serialise *data* to TOML, encrypt with *passphrase*, write to disk."""
|
|
_validate(data)
|
|
plaintext = toml.dumps(data)
|
|
ciphertext = _encrypt(plaintext, passphrase)
|
|
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
CONFIG_PATH.write_bytes(ciphertext)
|
|
|
|
|
|
def load_config(passphrase: str) -> dict[str, Any]:
|
|
"""Decrypt the config file and return it as a dict."""
|
|
if not CONFIG_PATH.exists():
|
|
raise ConfigError("No config file found. Run --setup first.")
|
|
try:
|
|
ciphertext = CONFIG_PATH.read_bytes()
|
|
plaintext = _decrypt(ciphertext, passphrase)
|
|
except Exception as exc:
|
|
raise ConfigError(f"Failed to decrypt config (wrong master password?): {exc}") from exc
|
|
data = toml.loads(plaintext)
|
|
_validate(data)
|
|
return data
|
|
|
|
|
|
def update_config(passphrase: str, section: str, key: str, value: Any) -> None:
|
|
"""Load config, change one value, and re-save."""
|
|
data = load_config(passphrase)
|
|
if section not in data:
|
|
data[section] = {}
|
|
data[section][key] = value
|
|
save_config(data, passphrase)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _validate(data: dict[str, Any]) -> None:
|
|
for section, keys in REQUIRED_KEYS.items():
|
|
if section not in data:
|
|
raise ConfigError(f"Config missing section [{section}]")
|
|
for key in keys:
|
|
if key not in data[section]:
|
|
raise ConfigError(f"Config missing [{section}].{key}")
|
|
|
|
mode = data["preferences"]["delivery_mode"]
|
|
if mode not in DELIVERY_MODES:
|
|
raise ConfigError(
|
|
f"Invalid delivery_mode '{mode}'. Must be one of {DELIVERY_MODES}"
|
|
)
|
|
|
|
interval = data["preferences"]["poll_interval_min"]
|
|
if not isinstance(interval, int) or interval < MIN_INTERVAL_MIN:
|
|
raise ConfigError(
|
|
f"poll_interval_min must be an integer >= {MIN_INTERVAL_MIN}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Interactive setup wizard helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
INTERVAL_PRESETS = {
|
|
"1": 15,
|
|
"2": 30,
|
|
"3": 60,
|
|
"4": 360,
|
|
"5": 1440,
|
|
}
|
|
|
|
|
|
def build_config_interactively() -> tuple[dict[str, Any], str]:
|
|
"""Prompt the user for all settings and a master password.
|
|
|
|
Returns (config_dict, master_password).
|
|
"""
|
|
import getpass
|
|
|
|
print("\n=== MailRelay First-Time Setup ===\n")
|
|
|
|
# Service to forward
|
|
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 Mail mailbox password (leave blank if none): "
|
|
)
|
|
totp_secret = input("TOTP secret key (base32, from your 2FA setup): ").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: ")
|
|
|
|
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(" 1) Automatic IMAP push (default)")
|
|
print(" 2) Manual MBOX download")
|
|
mode_choice = input("Choose [1/2, default 1]: ").strip() or "1"
|
|
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(" 1) 15 minutes")
|
|
print(" 2) 30 minutes")
|
|
print(" 3) 1 hour (default)")
|
|
print(" 4) 6 hours")
|
|
print(" 5) 24 hours")
|
|
print(" 6) Custom")
|
|
interval_choice = input("Choose [1-6, default 3]: ").strip() or "3"
|
|
if interval_choice in INTERVAL_PRESETS:
|
|
poll_interval = INTERVAL_PRESETS[interval_choice]
|
|
else:
|
|
while True:
|
|
raw = input(f"Enter interval in minutes (min {MIN_INTERVAL_MIN}): ").strip()
|
|
if raw.isdigit() and int(raw) >= MIN_INTERVAL_MIN:
|
|
poll_interval = int(raw)
|
|
break
|
|
print(f"Please enter a whole number >= {MIN_INTERVAL_MIN}.")
|
|
|
|
print("\nSet a master password to encrypt your config.")
|
|
while True:
|
|
master_pw = getpass.getpass("Master password: ")
|
|
confirm = getpass.getpass("Confirm master password: ")
|
|
if master_pw == confirm:
|
|
break
|
|
print("Passwords do not match. Try again.")
|
|
|
|
config = {
|
|
"proton": {
|
|
"email": proton_email,
|
|
"password": proton_password,
|
|
"mailbox_password": proton_mailbox_pw,
|
|
"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": {
|
|
"email": icloud_email,
|
|
"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": {
|
|
"delivery_mode": delivery_mode,
|
|
"poll_interval_min": poll_interval,
|
|
},
|
|
}
|
|
|
|
return config, master_pw
|