Initial commit: IMAP Mail Filter Service

This commit is contained in:
Stefan Hacker
2026-03-19 13:02:44 +01:00
parent 44fb27801d
commit 61c4384111
34 changed files with 2345 additions and 0 deletions
View File
+38
View File
@@ -0,0 +1,38 @@
import logging
from cryptography.fernet import Fernet, InvalidToken
from app.config import settings
logger = logging.getLogger(__name__)
_fernet: Fernet | None = None
def _get_fernet() -> Fernet | None:
global _fernet
if _fernet is not None:
return _fernet
if not settings.encryption_key:
logger.warning("ENCRYPTION_KEY nicht gesetzt — Passwörter werden im Klartext gespeichert!")
return None
_fernet = Fernet(settings.encryption_key.encode())
return _fernet
def encrypt(plaintext: str) -> str:
f = _get_fernet()
if f is None:
return plaintext
return f.encrypt(plaintext.encode()).decode()
def decrypt(ciphertext: str) -> str:
f = _get_fernet()
if f is None:
return ciphertext
try:
return f.decrypt(ciphertext.encode()).decode()
except InvalidToken:
# Might be unencrypted (e.g. from YAML import before encryption was set up)
return ciphertext
+116
View File
@@ -0,0 +1,116 @@
import logging
import re
from app.models.db_models import ActionType, ConditionField, FilterAction, FilterCondition, FilterRule, MatchType
from app.services.imap_client import IMAPClient, MailMessage
logger = logging.getLogger(__name__)
def _get_field_value(mail: MailMessage, field: ConditionField) -> str:
match field:
case ConditionField.FROM:
return mail.from_addr
case ConditionField.TO:
return mail.to_addr
case ConditionField.SUBJECT:
return mail.subject
case ConditionField.BODY:
return mail.body
case ConditionField.HAS_ATTACHMENT:
return str(mail.has_attachment).lower()
return ""
def _match(value: str, pattern: str, match_type: MatchType) -> bool:
match match_type:
case MatchType.CONTAINS:
return pattern.lower() in value.lower()
case MatchType.EXACT:
return value.lower() == pattern.lower()
case MatchType.REGEX:
try:
return bool(re.search(pattern, value, re.IGNORECASE))
except re.error:
logger.warning("Ungültiger Regex: %s", pattern)
return False
return False
def evaluate_conditions(mail: MailMessage, conditions: list[FilterCondition]) -> bool:
if not conditions:
return False
for cond in conditions:
field_value = _get_field_value(mail, cond.field)
result = _match(field_value, cond.value, cond.match_type)
if cond.negate:
result = not result
if not result:
return False
return True
def execute_action(
imap_client: IMAPClient,
mail: MailMessage,
action: FilterAction,
smtp_config: dict | None = None,
) -> bool:
match action.action_type:
case ActionType.MOVE:
if not action.parameter:
logger.error("Kein Zielordner für Move-Aktion angegeben")
return False
return imap_client.move_mail(mail.uid, action.parameter)
case ActionType.DELETE:
trash = action.parameter or "Trash"
return imap_client.delete_mail(mail.uid, trash)
case ActionType.MARK_READ:
return imap_client.mark_as_read(mail.uid)
case ActionType.FORWARD:
if not action.parameter or not smtp_config:
logger.error("Forward-Aktion: Zieladresse oder SMTP-Config fehlt")
return False
return imap_client.forward_mail(
uid=mail.uid,
to_address=action.parameter,
smtp_host=smtp_config["host"],
smtp_port=smtp_config["port"],
smtp_username=smtp_config["username"],
smtp_password=smtp_config["password"],
)
return False
def apply_rules(
imap_client: IMAPClient,
mail: MailMessage,
rules: list[FilterRule],
smtp_config: dict | None = None,
) -> list[dict]:
results = []
sorted_rules = sorted(rules, key=lambda r: r.priority)
for rule in sorted_rules:
if not rule.enabled:
continue
if not evaluate_conditions(mail, rule.conditions):
continue
logger.info("Regel '%s' trifft auf Mail %s zu (Betreff: %s)", rule.name, mail.uid, mail.subject)
for action in rule.actions:
success = execute_action(imap_client, mail, action, smtp_config)
results.append({
"rule": rule.name,
"action": action.action_type.value,
"parameter": action.parameter,
"success": success,
"mail_uid": mail.uid,
})
if rule.stop_processing:
logger.info("stop_processing aktiv — keine weiteren Regeln für Mail %s", mail.uid)
break
return results
+227
View File
@@ -0,0 +1,227 @@
import asyncio
import email
import imaplib
import logging
import smtplib
from dataclasses import dataclass, field
from email.header import decode_header
from email.message import Message
from email.mime.text import MIMEText
logger = logging.getLogger(__name__)
@dataclass
class MailMessage:
uid: str
from_addr: str = ""
to_addr: str = ""
subject: str = ""
body: str = ""
has_attachment: bool = False
raw: Message | None = field(default=None, repr=False)
def _decode_header_value(value: str | None) -> str:
if not value:
return ""
parts = decode_header(value)
decoded = []
for part, charset in parts:
if isinstance(part, bytes):
decoded.append(part.decode(charset or "utf-8", errors="replace"))
else:
decoded.append(part)
return " ".join(decoded)
def _has_attachment(msg: Message) -> bool:
if not msg.is_multipart():
return False
for part in msg.walk():
disposition = str(part.get("Content-Disposition") or "")
if "attachment" in disposition:
return True
return False
def _extract_body(msg: Message) -> str:
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
if content_type == "text/plain":
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
return ""
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
return ""
class IMAPClient:
def __init__(
self,
host: str,
port: int,
username: str,
password: str,
use_ssl: bool = True,
):
self.host = host
self.port = port
self.username = username
self.password = password
self.use_ssl = use_ssl
self._conn: imaplib.IMAP4 | None = None
def connect(self) -> None:
if self.use_ssl:
self._conn = imaplib.IMAP4_SSL(self.host, self.port)
else:
self._conn = imaplib.IMAP4(self.host, self.port)
self._conn.login(self.username, self.password)
logger.info("IMAP verbunden: %s@%s", self.username, self.host)
def disconnect(self) -> None:
if self._conn:
try:
self._conn.logout()
except Exception:
pass
self._conn = None
def __enter__(self):
self.connect()
return self
def __exit__(self, *args):
self.disconnect()
@property
def conn(self) -> imaplib.IMAP4:
if self._conn is None:
raise RuntimeError("Nicht verbunden. connect() zuerst aufrufen.")
return self._conn
def test_connection(self) -> bool:
try:
self.connect()
self.conn.select("INBOX", readonly=True)
self.conn.close()
self.disconnect()
return True
except Exception as e:
logger.error("Verbindungstest fehlgeschlagen: %s", e)
self.disconnect()
return False
def list_folders(self) -> list[str]:
status, data = self.conn.list()
if status != "OK":
return []
folders = []
for item in data:
if isinstance(item, bytes):
parts = item.decode("utf-8", errors="replace").split(' "/" ')
if len(parts) >= 2:
folder_name = parts[-1].strip().strip('"')
folders.append(folder_name)
return folders
def fetch_unseen(self, folder: str = "INBOX") -> list[MailMessage]:
self.conn.select(folder)
status, data = self.conn.uid("SEARCH", None, "UNSEEN")
if status != "OK" or not data[0]:
return []
uids = data[0].split()
messages = []
for uid in uids:
uid_str = uid.decode() if isinstance(uid, bytes) else str(uid)
status, msg_data = self.conn.uid("FETCH", uid_str, "(RFC822)")
if status != "OK" or not msg_data[0]:
continue
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
messages.append(
MailMessage(
uid=uid_str,
from_addr=_decode_header_value(msg.get("From")),
to_addr=_decode_header_value(msg.get("To")),
subject=_decode_header_value(msg.get("Subject")),
body=_extract_body(msg),
has_attachment=_has_attachment(msg),
raw=msg,
)
)
return messages
def move_mail(self, uid: str, target_folder: str) -> bool:
try:
self.conn.uid("COPY", uid, target_folder)
self.conn.uid("STORE", uid, "+FLAGS", "(\\Deleted)")
self.conn.expunge()
logger.info("Mail %s verschoben nach %s", uid, target_folder)
return True
except Exception as e:
logger.error("Fehler beim Verschieben von Mail %s: %s", uid, e)
return False
def delete_mail(self, uid: str, trash_folder: str = "Trash") -> bool:
return self.move_mail(uid, trash_folder)
def mark_as_read(self, uid: str) -> bool:
try:
self.conn.uid("STORE", uid, "+FLAGS", "(\\Seen)")
logger.info("Mail %s als gelesen markiert", uid)
return True
except Exception as e:
logger.error("Fehler beim Markieren von Mail %s: %s", uid, e)
return False
def forward_mail(
self,
uid: str,
to_address: str,
smtp_host: str,
smtp_port: int,
smtp_username: str,
smtp_password: str,
) -> bool:
try:
status, msg_data = self.conn.uid("FETCH", uid, "(RFC822)")
if status != "OK" or not msg_data[0]:
return False
original = email.message_from_bytes(msg_data[0][1])
subject = _decode_header_value(original.get("Subject"))
body = _extract_body(original)
fwd = MIMEText(
f"--- Weitergeleitete Nachricht ---\n"
f"Von: {original.get('From')}\n"
f"Betreff: {subject}\n\n{body}"
)
fwd["Subject"] = f"Fwd: {subject}"
fwd["From"] = smtp_username
fwd["To"] = to_address
with smtplib.SMTP_SSL(smtp_host, smtp_port) as smtp:
smtp.login(smtp_username, smtp_password)
smtp.send_message(fwd)
logger.info("Mail %s weitergeleitet an %s", uid, to_address)
return True
except Exception as e:
logger.error("Fehler beim Weiterleiten von Mail %s: %s", uid, e)
return False
async def async_test_connection(
host: str, port: int, username: str, password: str, use_ssl: bool = True
) -> bool:
client = IMAPClient(host, port, username, password, use_ssl)
return await asyncio.to_thread(client.test_connection)
+137
View File
@@ -0,0 +1,137 @@
import asyncio
import logging
from datetime import datetime
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from sqlalchemy.orm import Session
from app.database import SessionLocal
from app.models.db_models import Account, FilterRule
from app.services.encryption import decrypt
from app.services.filter_engine import apply_rules
from app.services.imap_client import IMAPClient
logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler()
def _build_smtp_config(account: Account) -> dict | None:
if account.smtp_host and account.smtp_username and account.smtp_password:
return {
"host": account.smtp_host,
"port": account.smtp_port or 465,
"username": account.smtp_username,
"password": decrypt(account.smtp_password),
}
return None
def _poll_account_sync(account_id: int) -> None:
db: Session = SessionLocal()
try:
account = db.get(Account, account_id)
if not account or not account.enabled:
return
rules = (
db.query(FilterRule)
.filter(FilterRule.account_id == account_id, FilterRule.enabled.is_(True))
.order_by(FilterRule.priority)
.all()
)
if not rules:
logger.debug("Keine aktiven Regeln für Konto '%s'", account.name)
return
# Collect unique source folders
source_folders = list({r.source_folder for r in rules})
smtp_config = _build_smtp_config(account)
client = IMAPClient(
host=account.imap_host,
port=account.imap_port,
username=account.username,
password=decrypt(account.password),
use_ssl=account.use_ssl,
)
with client:
for folder in source_folders:
folder_rules = [r for r in rules if r.source_folder == folder]
try:
messages = client.fetch_unseen(folder)
except Exception as e:
logger.error("Fehler beim Abrufen von %s/%s: %s", account.name, folder, e)
continue
if messages:
logger.info(
"Konto '%s', Ordner '%s': %d ungelesene Mails",
account.name, folder, len(messages),
)
for mail in messages:
results = apply_rules(client, mail, folder_rules, smtp_config)
for r in results:
level = logging.INFO if r["success"] else logging.ERROR
logger.log(
level,
"Konto '%s': %s %s -> %s (%s)",
account.name, r["action"], r.get("parameter", ""),
"OK" if r["success"] else "FEHLER", r["rule"],
)
account.last_poll_at = datetime.utcnow()
db.commit()
except Exception as e:
logger.error("Fehler beim Polling von Konto %s: %s", account_id, e)
finally:
db.close()
async def poll_account(account_id: int) -> None:
await asyncio.to_thread(_poll_account_sync, account_id)
def add_account_job(account: Account) -> None:
job_id = f"poll_account_{account.id}"
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
if account.enabled:
scheduler.add_job(
poll_account,
"interval",
seconds=account.poll_interval_seconds,
id=job_id,
args=[account.id],
replace_existing=True,
)
logger.info(
"Job für Konto '%s' registriert (alle %ds)",
account.name, account.poll_interval_seconds,
)
def remove_account_job(account_id: int) -> None:
job_id = f"poll_account_{account_id}"
if scheduler.get_job(job_id):
scheduler.remove_job(job_id)
logger.info("Job für Konto %s entfernt", account_id)
def start_scheduler() -> None:
db = SessionLocal()
try:
accounts = db.query(Account).filter(Account.enabled.is_(True)).all()
for account in accounts:
add_account_job(account)
finally:
db.close()
scheduler.start()
logger.info("Scheduler gestartet mit %d Jobs", len(scheduler.get_jobs()))
def stop_scheduler() -> None:
scheduler.shutdown(wait=False)
logger.info("Scheduler gestoppt")
+241
View File
@@ -0,0 +1,241 @@
import logging
import os
import re
from pathlib import Path
import yaml
from sqlalchemy.orm import Session
from app.config import settings
from app.database import SessionLocal
from app.models.db_models import (
Account,
ActionType,
ConditionField,
FilterAction,
FilterCondition,
FilterRule,
MatchType,
)
logger = logging.getLogger(__name__)
ENV_VAR_PATTERN = re.compile(r"\$\{(\w+)\}")
def _resolve_env_vars(value: str) -> str:
def replacer(match):
var_name = match.group(1)
env_val = os.environ.get(var_name, "")
if not env_val:
logger.warning("Umgebungsvariable %s nicht gesetzt", var_name)
return env_val
return ENV_VAR_PATTERN.sub(replacer, value)
def export_to_yaml(db: Session | None = None) -> str:
close_db = False
if db is None:
db = SessionLocal()
close_db = True
try:
accounts = db.query(Account).order_by(Account.name).all()
data = {"accounts": []}
for acc in accounts:
account_data = {
"name": acc.name,
"imap_host": acc.imap_host,
"imap_port": acc.imap_port,
"use_ssl": acc.use_ssl,
"username": acc.username,
"password": acc.password,
"poll_interval_seconds": acc.poll_interval_seconds,
"enabled": acc.enabled,
}
if acc.smtp_host:
account_data["smtp_host"] = acc.smtp_host
account_data["smtp_port"] = acc.smtp_port
account_data["smtp_username"] = acc.smtp_username
account_data["smtp_password"] = acc.smtp_password
filters_data = []
for rule in sorted(acc.filter_rules, key=lambda r: r.priority):
rule_data = {
"name": rule.name,
"priority": rule.priority,
"enabled": rule.enabled,
"stop_processing": rule.stop_processing,
"source_folder": rule.source_folder,
"conditions": [
{
"field": cond.field.value,
"match_type": cond.match_type.value,
"value": cond.value,
"negate": cond.negate,
}
for cond in rule.conditions
],
"actions": [
{
"action_type": action.action_type.value,
"parameter": action.parameter,
}
for action in rule.actions
],
}
filters_data.append(rule_data)
if filters_data:
account_data["filters"] = filters_data
data["accounts"].append(account_data)
return yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False)
finally:
if close_db:
db.close()
def export_to_file(path: str | None = None, db: Session | None = None) -> None:
path = path or settings.yaml_config_path
content = export_to_yaml(db)
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).write_text(content, encoding="utf-8")
logger.info("YAML exportiert nach %s", path)
def import_from_yaml(yaml_content: str, db: Session | None = None) -> dict:
close_db = False
if db is None:
db = SessionLocal()
close_db = True
try:
data = yaml.safe_load(yaml_content)
if not data or "accounts" not in data:
return {"error": "Ungültiges YAML-Format"}
stats = {"accounts_created": 0, "accounts_updated": 0, "rules_created": 0}
for acc_data in data["accounts"]:
password = _resolve_env_vars(acc_data.get("password", ""))
existing = (
db.query(Account)
.filter(
Account.username == acc_data["username"],
Account.imap_host == acc_data["imap_host"],
)
.first()
)
if existing:
existing.name = acc_data.get("name", existing.name)
existing.imap_port = acc_data.get("imap_port", existing.imap_port)
existing.use_ssl = acc_data.get("use_ssl", existing.use_ssl)
existing.password = password
existing.poll_interval_seconds = acc_data.get(
"poll_interval_seconds", existing.poll_interval_seconds
)
existing.enabled = acc_data.get("enabled", existing.enabled)
if acc_data.get("smtp_host"):
existing.smtp_host = acc_data["smtp_host"]
existing.smtp_port = acc_data.get("smtp_port")
existing.smtp_username = acc_data.get("smtp_username")
smtp_pw = acc_data.get("smtp_password", "")
existing.smtp_password = _resolve_env_vars(smtp_pw) if smtp_pw else None
account = existing
stats["accounts_updated"] += 1
else:
account = Account(
name=acc_data.get("name", acc_data["username"]),
imap_host=acc_data["imap_host"],
imap_port=acc_data.get("imap_port", 993),
use_ssl=acc_data.get("use_ssl", True),
username=acc_data["username"],
password=password,
poll_interval_seconds=acc_data.get("poll_interval_seconds", 120),
enabled=acc_data.get("enabled", True),
)
if acc_data.get("smtp_host"):
account.smtp_host = acc_data["smtp_host"]
account.smtp_port = acc_data.get("smtp_port")
account.smtp_username = acc_data.get("smtp_username")
smtp_pw = acc_data.get("smtp_password", "")
account.smtp_password = _resolve_env_vars(smtp_pw) if smtp_pw else None
db.add(account)
stats["accounts_created"] += 1
db.flush()
# Import filter rules
for filter_data in acc_data.get("filters", []):
# Check if rule with same name exists for this account
existing_rule = (
db.query(FilterRule)
.filter(
FilterRule.account_id == account.id,
FilterRule.name == filter_data["name"],
)
.first()
)
if existing_rule:
# Delete old conditions and actions
for c in existing_rule.conditions:
db.delete(c)
for a in existing_rule.actions:
db.delete(a)
db.delete(existing_rule)
db.flush()
rule = FilterRule(
account_id=account.id,
name=filter_data["name"],
priority=filter_data.get("priority", 100),
enabled=filter_data.get("enabled", True),
stop_processing=filter_data.get("stop_processing", False),
source_folder=filter_data.get("source_folder", "INBOX"),
)
db.add(rule)
db.flush()
for cond_data in filter_data.get("conditions", []):
cond = FilterCondition(
rule_id=rule.id,
field=ConditionField(cond_data["field"]),
match_type=MatchType(cond_data["match_type"]),
value=cond_data["value"],
negate=cond_data.get("negate", False),
)
db.add(cond)
for action_data in filter_data.get("actions", []):
action = FilterAction(
rule_id=rule.id,
action_type=ActionType(action_data["action_type"]),
parameter=action_data.get("parameter"),
)
db.add(action)
stats["rules_created"] += 1
db.commit()
logger.info("YAML-Import abgeschlossen: %s", stats)
return stats
except Exception as e:
db.rollback()
logger.error("YAML-Import fehlgeschlagen: %s", e)
return {"error": str(e)}
finally:
if close_db:
db.close()
def import_from_file(path: str | None = None, db: Session | None = None) -> dict:
path = path or settings.yaml_config_path
if not Path(path).exists():
logger.info("YAML-Datei nicht gefunden: %s", path)
return {"error": "Datei nicht gefunden"}
content = Path(path).read_text(encoding="utf-8")
return import_from_yaml(content, db)