From 61c4384111898b573101f0a5322cd5b3b072030c Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Thu, 19 Mar 2026 13:02:44 +0100 Subject: [PATCH] Initial commit: IMAP Mail Filter Service --- .dockerignore | 10 ++ .env.example | 9 + Dockerfile | 13 ++ README.md | 156 ++++++++++++++++++ app/__init__.py | 0 app/config.py | 16 ++ app/database.py | 19 +++ app/main.py | 100 +++++++++++ app/models/__init__.py | 0 app/models/db_models.py | 103 ++++++++++++ app/routers/__init__.py | 0 app/routers/accounts.py | 97 +++++++++++ app/routers/filters.py | 112 +++++++++++++ app/routers/yaml_sync.py | 21 +++ app/schemas/__init__.py | 0 app/schemas/schemas.py | 141 ++++++++++++++++ app/services/__init__.py | 0 app/services/encryption.py | 38 +++++ app/services/filter_engine.py | 116 +++++++++++++ app/services/imap_client.py | 227 +++++++++++++++++++++++++ app/services/scheduler.py | 137 ++++++++++++++++ app/services/yaml_service.py | 241 +++++++++++++++++++++++++++ app/static/app.js | 1 + app/static/style.css | 30 ++++ app/templates/account_form.html | 123 ++++++++++++++ app/templates/accounts.html | 51 ++++++ app/templates/base.html | 34 ++++ app/templates/dashboard.html | 75 +++++++++ app/templates/filters.html | 283 ++++++++++++++++++++++++++++++++ app/templates/yaml.html | 64 ++++++++ docker-compose.yml | 14 ++ requirements.txt | 10 ++ tests/__init__.py | 0 tests/test_filter_engine.py | 104 ++++++++++++ 34 files changed, 2345 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/db_models.py create mode 100644 app/routers/__init__.py create mode 100644 app/routers/accounts.py create mode 100644 app/routers/filters.py create mode 100644 app/routers/yaml_sync.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/schemas.py create mode 100644 app/services/__init__.py create mode 100644 app/services/encryption.py create mode 100644 app/services/filter_engine.py create mode 100644 app/services/imap_client.py create mode 100644 app/services/scheduler.py create mode 100644 app/services/yaml_service.py create mode 100644 app/static/app.js create mode 100644 app/static/style.css create mode 100644 app/templates/account_form.html create mode 100644 app/templates/accounts.html create mode 100644 app/templates/base.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/filters.html create mode 100644 app/templates/yaml.html create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_filter_engine.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5c12659 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +__pycache__ +*.pyc +.env +data/ +.git +.gitignore +.venv +venv +tests/ +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a3f7374 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Fernet-Schlüssel für Passwort-Verschlüsselung +# Generieren mit: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +ENCRYPTION_KEY= + +# Log-Level (DEBUG, INFO, WARNING, ERROR) +LOG_LEVEL=INFO + +# YAML-Konfiguration beim Start importieren +YAML_SYNC_ON_STARTUP=true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a54da53 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ +COPY config/ ./config/ + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..551ce5c --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# IMAP Mail Filter Service + +Ein Docker-basierter Service, der E-Mail-Filterregeln unabhängig vom Mail-Client ausführt. Egal ob du Evolution, Thunderbird oder einen anderen Client nutzt — die Filter laufen einfach weiter. + +## Features + +- **Mehrere IMAP-Konten** verwalten +- **Filterregeln** mit flexiblen Bedingungen (Von, An, Betreff, Text) +- **Matching**: enthält, exakt, Regex — jeweils mit Negierung +- **Aktionen**: Verschieben, Weiterleiten, Löschen, Als gelesen markieren +- **Web-UI** zur Verwaltung von Konten und Regeln +- **YAML Import/Export** für portable Konfiguration +- **Passwort-Verschlüsselung** mit Fernet +- **Konfigurierbares Polling-Intervall** pro Konto + +## Schnellstart + +### 1. `.env` erstellen + +```bash +cp .env.example .env +``` + +Encryption-Key generieren und in die `.env` eintragen: + +```bash +python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +``` + +Falls `cryptography` noch nicht installiert ist: + +```bash +pip install cryptography +python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +``` + +Den ausgegebenen Key in die `.env` eintragen: + +``` +ENCRYPTION_KEY=dein-generierter-key-hier +``` + +### 2. Mit Docker starten + +```bash +docker-compose up --build +``` + +Der Service ist dann unter **http://localhost:8080** erreichbar. + +### 3. Ohne Docker (lokal) + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +Erreichbar unter **http://localhost:8000**. + +## Benutzung + +### Web-UI + +- **Dashboard** (`/`) — Übersicht aller Konten mit Status und Quick-Actions +- **Konten** (`/accounts`) — IMAP-Konten anlegen, bearbeiten, Verbindungstest +- **Filter** (`/filters`) — Filterregeln pro Konto erstellen und verwalten +- **YAML** (`/yaml`) — Konfiguration exportieren und importieren + +### REST-API + +| Endpunkt | Beschreibung | +|---|---| +| `GET /api/accounts/` | Alle Konten auflisten | +| `POST /api/accounts/` | Neues Konto anlegen | +| `PUT /api/accounts/{id}` | Konto bearbeiten | +| `DELETE /api/accounts/{id}` | Konto löschen | +| `POST /api/accounts/{id}/test` | IMAP-Verbindungstest | +| `POST /api/accounts/{id}/poll-now` | Sofort nach neuen Mails prüfen | +| `GET /api/filters/account/{id}` | Filterregeln eines Kontos | +| `POST /api/filters/` | Neue Filterregel | +| `PUT /api/filters/{id}` | Filterregel bearbeiten | +| `DELETE /api/filters/{id}` | Filterregel löschen | +| `GET /api/yaml/export` | Konfiguration als YAML | +| `POST /api/yaml/import` | YAML-Datei importieren | + +### YAML-Konfiguration + +Filterregeln können auch direkt als YAML definiert werden. Passwörter lassen sich als Umgebungsvariablen referenzieren: + +```yaml +accounts: + - name: "Arbeit" + imap_host: "imap.example.com" + imap_port: 993 + use_ssl: true + username: "user@example.com" + password: "${WORK_IMAP_PW}" + poll_interval_seconds: 120 + filters: + - name: "Newsletter sortieren" + priority: 10 + source_folder: "INBOX" + stop_processing: true + conditions: + - field: "from" + match_type: "contains" + value: "newsletter@" + actions: + - action_type: "move" + parameter: "Newsletter" +``` + +Beim Start wird `config/filters.yaml` automatisch importiert (konfigurierbar via `YAML_SYNC_ON_STARTUP`). + +## Umgebungsvariablen + +| Variable | Standard | Beschreibung | +|---|---|---| +| `ENCRYPTION_KEY` | *(leer)* | Fernet-Key für Passwort-Verschlüsselung | +| `LOG_LEVEL` | `INFO` | Log-Level (DEBUG, INFO, WARNING, ERROR) | +| `YAML_SYNC_ON_STARTUP` | `true` | YAML-Datei beim Start importieren | +| `DATABASE_URL` | `sqlite:///data/mailfilter.db` | Datenbank-Pfad | + +## Projektstruktur + +``` +├── app/ +│ ├── main.py # FastAPI App + Web-Routen +│ ├── config.py # Konfiguration via Umgebungsvariablen +│ ├── database.py # SQLAlchemy Setup +│ ├── models/db_models.py # Datenbank-Modelle +│ ├── schemas/schemas.py # API Request/Response Schemas +│ ├── routers/ # REST-API Endpunkte +│ ├── services/ +│ │ ├── imap_client.py # IMAP-Verbindung und Mail-Aktionen +│ │ ├── filter_engine.py # Regelauswertung +│ │ ├── scheduler.py # Polling-Scheduler +│ │ ├── yaml_service.py # YAML Import/Export +│ │ └── encryption.py # Passwort-Verschlüsselung +│ ├── templates/ # Jinja2 HTML-Templates +│ └── static/ # CSS + JS +├── config/filters.yaml # YAML-Filterkonfiguration +├── data/ # SQLite-Datenbank (Docker Volume) +├── docker-compose.yml +├── Dockerfile +└── requirements.txt +``` + +## Tests + +```bash +source .venv/bin/activate +python -m pytest tests/ -v +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..2e17ef8 --- /dev/null +++ b/app/config.py @@ -0,0 +1,16 @@ +from pathlib import Path + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + database_url: str = "sqlite:///data/mailfilter.db" + yaml_config_path: str = "config/filters.yaml" + encryption_key: str = "" + yaml_sync_on_startup: bool = True + log_level: str = "INFO" + + model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} + + +settings = Settings() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..f13fe28 --- /dev/null +++ b/app/database.py @@ -0,0 +1,19 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from app.config import settings + +engine = create_engine(settings.database_url, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f79f2fb --- /dev/null +++ b/app/main.py @@ -0,0 +1,100 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from app.config import settings +from app.database import Base, engine + +logging.basicConfig( + level=getattr(logging, settings.log_level.upper(), logging.INFO), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Starte IMAP Mail Filter Service...") + Base.metadata.create_all(bind=engine) + logger.info("Datenbank initialisiert.") + if settings.yaml_sync_on_startup: + from app.services.yaml_service import import_from_file + result = import_from_file() + logger.info("YAML-Startup-Import: %s", result) + from app.services.scheduler import start_scheduler, stop_scheduler + start_scheduler() + yield + stop_scheduler() + logger.info("Service wird beendet.") + + +app = FastAPI(title="IMAP Mail Filter Service", lifespan=lifespan) +app.mount("/static", StaticFiles(directory="app/static"), name="static") +templates = Jinja2Templates(directory="app/templates") + +from fastapi import Depends, Request # noqa: E402 +from sqlalchemy.orm import Session # noqa: E402 + +from app.database import get_db # noqa: E402 +from app.models.db_models import Account # noqa: E402 +from app.routers import accounts, filters, yaml_sync # noqa: E402 + +app.include_router(accounts.router) +app.include_router(filters.router) +app.include_router(yaml_sync.router) + + +# --- Web-UI Routen --- + + +@app.get("/") +def dashboard(request: Request, db: Session = Depends(get_db)): + accs = db.query(Account).order_by(Account.name).all() + account_list = [] + for acc in accs: + account_list.append({ + "id": acc.id, + "name": acc.name, + "username": acc.username, + "imap_host": acc.imap_host, + "enabled": acc.enabled, + "poll_interval_seconds": acc.poll_interval_seconds, + "last_poll_at": acc.last_poll_at, + "filter_rule_count": len(acc.filter_rules), + }) + return templates.TemplateResponse("dashboard.html", {"request": request, "accounts": account_list}) + + +@app.get("/accounts") +def accounts_page(request: Request, db: Session = Depends(get_db)): + accs = db.query(Account).order_by(Account.name).all() + return templates.TemplateResponse("accounts.html", {"request": request, "accounts": accs}) + + +@app.get("/accounts/new") +def new_account_page(request: Request): + return templates.TemplateResponse("account_form.html", {"request": request, "account": None}) + + +@app.get("/accounts/{account_id}/edit") +def edit_account_page(account_id: int, request: Request, db: Session = Depends(get_db)): + account = db.get(Account, account_id) + return templates.TemplateResponse("account_form.html", {"request": request, "account": account}) + + +@app.get("/filters") +def filters_page(request: Request, account_id: int = 0, db: Session = Depends(get_db)): + accs = db.query(Account).order_by(Account.name).all() + return templates.TemplateResponse("filters.html", { + "request": request, + "accounts": accs, + "selected_account_id": account_id, + }) + + +@app.get("/yaml") +def yaml_page(request: Request): + return templates.TemplateResponse("yaml.html", {"request": request}) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/db_models.py b/app/models/db_models.py new file mode 100644 index 0000000..c25057e --- /dev/null +++ b/app/models/db_models.py @@ -0,0 +1,103 @@ +import enum +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class ConditionField(str, enum.Enum): + FROM = "from" + TO = "to" + SUBJECT = "subject" + BODY = "body" + HAS_ATTACHMENT = "has_attachment" + + +class MatchType(str, enum.Enum): + CONTAINS = "contains" + REGEX = "regex" + EXACT = "exact" + + +class ActionType(str, enum.Enum): + MOVE = "move" + FORWARD = "forward" + DELETE = "delete" + MARK_READ = "mark_read" + + +class Account(Base): + __tablename__ = "accounts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100)) + imap_host: Mapped[str] = mapped_column(String(255)) + imap_port: Mapped[int] = mapped_column(Integer, default=993) + use_ssl: Mapped[bool] = mapped_column(Boolean, default=True) + username: Mapped[str] = mapped_column(String(255)) + password: Mapped[str] = mapped_column(String(255)) + smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True) + smtp_port: Mapped[int | None] = mapped_column(Integer, nullable=True) + smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True) + smtp_password: Mapped[str | None] = mapped_column(String(255), nullable=True) + poll_interval_seconds: Mapped[int] = mapped_column(Integer, default=120) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + last_poll_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + filter_rules: Mapped[list["FilterRule"]] = relationship( + back_populates="account", cascade="all, delete-orphan" + ) + + +class FilterRule(Base): + __tablename__ = "filter_rules" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE")) + name: Mapped[str] = mapped_column(String(200)) + priority: Mapped[int] = mapped_column(Integer, default=100) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + stop_processing: Mapped[bool] = mapped_column(Boolean, default=False) + source_folder: Mapped[str] = mapped_column(String(255), default="INBOX") + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + account: Mapped["Account"] = relationship(back_populates="filter_rules") + conditions: Mapped[list["FilterCondition"]] = relationship( + back_populates="rule", cascade="all, delete-orphan" + ) + actions: Mapped[list["FilterAction"]] = relationship( + back_populates="rule", cascade="all, delete-orphan" + ) + + +class FilterCondition(Base): + __tablename__ = "filter_conditions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + rule_id: Mapped[int] = mapped_column(ForeignKey("filter_rules.id", ondelete="CASCADE")) + field: Mapped[ConditionField] = mapped_column(Enum(ConditionField)) + match_type: Mapped[MatchType] = mapped_column(Enum(MatchType)) + value: Mapped[str] = mapped_column(String(500)) + negate: Mapped[bool] = mapped_column(Boolean, default=False) + + rule: Mapped["FilterRule"] = relationship(back_populates="conditions") + + +class FilterAction(Base): + __tablename__ = "filter_actions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + rule_id: Mapped[int] = mapped_column(ForeignKey("filter_rules.id", ondelete="CASCADE")) + action_type: Mapped[ActionType] = mapped_column(Enum(ActionType)) + parameter: Mapped[str | None] = mapped_column(String(500), nullable=True) + + rule: Mapped["FilterRule"] = relationship(back_populates="actions") diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/accounts.py b/app/routers/accounts.py new file mode 100644 index 0000000..f98a2d8 --- /dev/null +++ b/app/routers/accounts.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.db_models import Account +from app.schemas.schemas import ( + AccountCreate, + AccountListResponse, + AccountResponse, + AccountUpdate, +) +from app.services.encryption import decrypt, encrypt +from app.services.imap_client import async_test_connection + +router = APIRouter(prefix="/api/accounts", tags=["accounts"]) + + +@router.get("/", response_model=list[AccountListResponse]) +def list_accounts(db: Session = Depends(get_db)): + accounts = db.query(Account).order_by(Account.name).all() + result = [] + for acc in accounts: + data = AccountListResponse.model_validate(acc) + data.filter_rule_count = len(acc.filter_rules) + result.append(data) + return result + + +@router.get("/{account_id}", response_model=AccountResponse) +def get_account(account_id: int, db: Session = Depends(get_db)): + account = db.get(Account, account_id) + if not account: + raise HTTPException(404, "Konto nicht gefunden") + return account + + +@router.post("/", response_model=AccountResponse, status_code=201) +def create_account(data: AccountCreate, db: Session = Depends(get_db)): + account_data = data.model_dump() + account_data["password"] = encrypt(account_data["password"]) + if account_data.get("smtp_password"): + account_data["smtp_password"] = encrypt(account_data["smtp_password"]) + account = Account(**account_data) + db.add(account) + db.commit() + db.refresh(account) + return account + + +@router.put("/{account_id}", response_model=AccountResponse) +def update_account(account_id: int, data: AccountUpdate, db: Session = Depends(get_db)): + account = db.get(Account, account_id) + if not account: + raise HTTPException(404, "Konto nicht gefunden") + for key, value in data.model_dump(exclude_unset=True).items(): + if key == "password" and value: + value = encrypt(value) + elif key == "smtp_password" and value: + value = encrypt(value) + setattr(account, key, value) + db.commit() + db.refresh(account) + return account + + +@router.delete("/{account_id}", status_code=204) +def delete_account(account_id: int, db: Session = Depends(get_db)): + account = db.get(Account, account_id) + if not account: + raise HTTPException(404, "Konto nicht gefunden") + db.delete(account) + db.commit() + + +@router.post("/{account_id}/test") +async def test_account_connection(account_id: int, db: Session = Depends(get_db)): + account = db.get(Account, account_id) + if not account: + raise HTTPException(404, "Konto nicht gefunden") + success = await async_test_connection( + host=account.imap_host, + port=account.imap_port, + username=account.username, + password=decrypt(account.password), + use_ssl=account.use_ssl, + ) + return {"success": success, "message": "Verbindung erfolgreich" if success else "Verbindung fehlgeschlagen"} + + +@router.post("/{account_id}/poll-now") +async def poll_now(account_id: int, db: Session = Depends(get_db)): + account = db.get(Account, account_id) + if not account: + raise HTTPException(404, "Konto nicht gefunden") + from app.services.scheduler import poll_account + await poll_account(account_id) + return {"message": f"Polling für '{account.name}' durchgeführt"} diff --git a/app/routers/filters.py b/app/routers/filters.py new file mode 100644 index 0000000..74bb151 --- /dev/null +++ b/app/routers/filters.py @@ -0,0 +1,112 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models.db_models import Account, FilterAction, FilterCondition, FilterRule +from app.schemas.schemas import FilterRuleCreate, FilterRuleResponse, FilterRuleUpdate + +router = APIRouter(prefix="/api/filters", tags=["filters"]) + + +@router.get("/account/{account_id}", response_model=list[FilterRuleResponse]) +def list_filters(account_id: int, db: Session = Depends(get_db)): + account = db.get(Account, account_id) + if not account: + raise HTTPException(404, "Konto nicht gefunden") + rules = ( + db.query(FilterRule) + .filter(FilterRule.account_id == account_id) + .order_by(FilterRule.priority) + .all() + ) + return rules + + +@router.get("/{rule_id}", response_model=FilterRuleResponse) +def get_filter(rule_id: int, db: Session = Depends(get_db)): + rule = db.get(FilterRule, rule_id) + if not rule: + raise HTTPException(404, "Filterregel nicht gefunden") + return rule + + +@router.post("/", response_model=FilterRuleResponse, status_code=201) +def create_filter(data: FilterRuleCreate, db: Session = Depends(get_db)): + account = db.get(Account, data.account_id) + if not account: + raise HTTPException(404, "Konto nicht gefunden") + + rule = FilterRule( + account_id=data.account_id, + name=data.name, + priority=data.priority, + enabled=data.enabled, + stop_processing=data.stop_processing, + source_folder=data.source_folder, + ) + db.add(rule) + db.flush() + + for cond_data in data.conditions: + cond = FilterCondition(rule_id=rule.id, **cond_data.model_dump()) + db.add(cond) + for action_data in data.actions: + action = FilterAction(rule_id=rule.id, **action_data.model_dump()) + db.add(action) + + db.commit() + db.refresh(rule) + return rule + + +@router.put("/{rule_id}", response_model=FilterRuleResponse) +def update_filter(rule_id: int, data: FilterRuleUpdate, db: Session = Depends(get_db)): + rule = db.get(FilterRule, rule_id) + if not rule: + raise HTTPException(404, "Filterregel nicht gefunden") + + update_data = data.model_dump(exclude_unset=True) + + # Update conditions if provided + if "conditions" in update_data: + for cond in rule.conditions: + db.delete(cond) + for cond_data in data.conditions: + cond = FilterCondition(rule_id=rule.id, **cond_data.model_dump()) + db.add(cond) + del update_data["conditions"] + + # Update actions if provided + if "actions" in update_data: + for action in rule.actions: + db.delete(action) + for action_data in data.actions: + action = FilterAction(rule_id=rule.id, **action_data.model_dump()) + db.add(action) + del update_data["actions"] + + for key, value in update_data.items(): + setattr(rule, key, value) + + db.commit() + db.refresh(rule) + return rule + + +@router.delete("/{rule_id}", status_code=204) +def delete_filter(rule_id: int, db: Session = Depends(get_db)): + rule = db.get(FilterRule, rule_id) + if not rule: + raise HTTPException(404, "Filterregel nicht gefunden") + db.delete(rule) + db.commit() + + +@router.put("/reorder/{account_id}") +def reorder_filters(account_id: int, rule_ids: list[int], db: Session = Depends(get_db)): + for priority, rule_id in enumerate(rule_ids): + rule = db.get(FilterRule, rule_id) + if rule and rule.account_id == account_id: + rule.priority = priority + db.commit() + return {"message": "Reihenfolge aktualisiert"} diff --git a/app/routers/yaml_sync.py b/app/routers/yaml_sync.py new file mode 100644 index 0000000..3dd5f7e --- /dev/null +++ b/app/routers/yaml_sync.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Depends, UploadFile +from fastapi.responses import PlainTextResponse +from sqlalchemy.orm import Session + +from app.database import get_db +from app.services.yaml_service import export_to_yaml, import_from_yaml + +router = APIRouter(prefix="/api/yaml", tags=["yaml"]) + + +@router.get("/export", response_class=PlainTextResponse) +def yaml_export(db: Session = Depends(get_db)): + return export_to_yaml(db) + + +@router.post("/import") +async def yaml_import(file: UploadFile, db: Session = Depends(get_db)): + content = await file.read() + yaml_str = content.decode("utf-8") + result = import_from_yaml(yaml_str, db) + return result diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py new file mode 100644 index 0000000..c935f7b --- /dev/null +++ b/app/schemas/schemas.py @@ -0,0 +1,141 @@ +from datetime import datetime + +from pydantic import BaseModel + +from app.models.db_models import ActionType, ConditionField, MatchType + + +# --- Filter Condition --- + + +class FilterConditionCreate(BaseModel): + field: ConditionField + match_type: MatchType + value: str + negate: bool = False + + +class FilterConditionResponse(FilterConditionCreate): + id: int + + model_config = {"from_attributes": True} + + +# --- Filter Action --- + + +class FilterActionCreate(BaseModel): + action_type: ActionType + parameter: str | None = None + + +class FilterActionResponse(FilterActionCreate): + id: int + + model_config = {"from_attributes": True} + + +# --- Filter Rule --- + + +class FilterRuleCreate(BaseModel): + name: str + account_id: int + priority: int = 100 + enabled: bool = True + stop_processing: bool = False + source_folder: str = "INBOX" + conditions: list[FilterConditionCreate] = [] + actions: list[FilterActionCreate] = [] + + +class FilterRuleUpdate(BaseModel): + name: str | None = None + priority: int | None = None + enabled: bool | None = None + stop_processing: bool | None = None + source_folder: str | None = None + conditions: list[FilterConditionCreate] | None = None + actions: list[FilterActionCreate] | None = None + + +class FilterRuleResponse(BaseModel): + id: int + account_id: int + name: str + priority: int + enabled: bool + stop_processing: bool + source_folder: str + conditions: list[FilterConditionResponse] + actions: list[FilterActionResponse] + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +# --- Account --- + + +class AccountCreate(BaseModel): + name: str + imap_host: str + imap_port: int = 993 + use_ssl: bool = True + username: str + password: str + smtp_host: str | None = None + smtp_port: int | None = None + smtp_username: str | None = None + smtp_password: str | None = None + poll_interval_seconds: int = 120 + enabled: bool = True + + +class AccountUpdate(BaseModel): + name: str | None = None + imap_host: str | None = None + imap_port: int | None = None + use_ssl: bool | None = None + username: str | None = None + password: str | None = None + smtp_host: str | None = None + smtp_port: int | None = None + smtp_username: str | None = None + smtp_password: str | None = None + poll_interval_seconds: int | None = None + enabled: bool | None = None + + +class AccountResponse(BaseModel): + id: int + name: str + imap_host: str + imap_port: int + use_ssl: bool + username: str + smtp_host: str | None + smtp_port: int | None + smtp_username: str | None + poll_interval_seconds: int + enabled: bool + last_poll_at: datetime | None + filter_rules: list[FilterRuleResponse] = [] + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class AccountListResponse(BaseModel): + id: int + name: str + imap_host: str + username: str + enabled: bool + last_poll_at: datetime | None + poll_interval_seconds: int + filter_rule_count: int = 0 + + model_config = {"from_attributes": True} diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/encryption.py b/app/services/encryption.py new file mode 100644 index 0000000..1ed9a88 --- /dev/null +++ b/app/services/encryption.py @@ -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 diff --git a/app/services/filter_engine.py b/app/services/filter_engine.py new file mode 100644 index 0000000..6206d50 --- /dev/null +++ b/app/services/filter_engine.py @@ -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 diff --git a/app/services/imap_client.py b/app/services/imap_client.py new file mode 100644 index 0000000..40027e3 --- /dev/null +++ b/app/services/imap_client.py @@ -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) diff --git a/app/services/scheduler.py b/app/services/scheduler.py new file mode 100644 index 0000000..874f947 --- /dev/null +++ b/app/services/scheduler.py @@ -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") diff --git a/app/services/yaml_service.py b/app/services/yaml_service.py new file mode 100644 index 0000000..e64cf23 --- /dev/null +++ b/app/services/yaml_service.py @@ -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) diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..1c243ba --- /dev/null +++ b/app/static/app.js @@ -0,0 +1 @@ +// Gemeinsame Hilfsfunktionen diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..b8af150 --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,30 @@ +/* Kleine Anpassungen über PicoCSS hinaus */ + +nav .container-fluid { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +footer { + margin-top: 2rem; + padding: 1rem 0; + text-align: center; + opacity: 0.6; +} + +table .small, button.small { + padding: 0.25rem 0.5rem; + font-size: 0.85rem; +} + +#status-message { + margin-top: 1rem; + padding: 1rem; + border-radius: 0.25rem; + background: var(--pico-muted-border-color); +} + +dialog article { + max-height: 90vh; + overflow-y: auto; +} diff --git a/app/templates/account_form.html b/app/templates/account_form.html new file mode 100644 index 0000000..f4ea8c1 --- /dev/null +++ b/app/templates/account_form.html @@ -0,0 +1,123 @@ +{% extends "base.html" %} +{% block title %}{{ "Konto bearbeiten" if account else "Neues Konto" }} — IMAP Mail Filter{% endblock %} +{% block content %} +

{{ "Konto bearbeiten" if account else "Neues Konto" }}

+ +
+
+ IMAP-Einstellungen + +
+ + +
+
+ + +
+ + + +
+ +
+ SMTP-Einstellungen (optional, für Weiterleitung) +
+ + +
+
+ + +
+
+ +
+ + Abbrechen +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/accounts.html b/app/templates/accounts.html new file mode 100644 index 0000000..dad4369 --- /dev/null +++ b/app/templates/accounts.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Konten — IMAP Mail Filter{% endblock %} +{% block content %} +

IMAP-Konten

+ +Neues Konto + +{% if accounts %} + + + + + + + + + + + + + {% for acc in accounts %} + + + + + + + + + {% endfor %} + +
NameServerBenutzerStatusLetzter PollAktionen
{{ acc.name }}{{ acc.imap_host }}:{{ acc.imap_port }}{{ acc.username }}{{ "Aktiv" if acc.enabled else "Deaktiviert" }}{{ acc.last_poll_at or "Noch nie" }} +
+ Bearbeiten + +
+
+{% else %} +

Noch keine Konten vorhanden.

+{% endif %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..3efc60b --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,34 @@ + + + + + + {% block title %}IMAP Mail Filter{% endblock %} + + + + + +
+ {% if flash_message %} +
{{ flash_message }}
+ {% endif %} + {% block content %}{% endblock %} +
+ + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..13fdd9c --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% block title %}Dashboard — IMAP Mail Filter{% endblock %} +{% block content %} +

Dashboard

+ +{% if accounts %} +
+ {% for acc in accounts %} +
+
+
+

{{ acc.name }}

+

{{ acc.username }}@{{ acc.imap_host }}

+
+
+

+ Status: + {% if acc.enabled %} + Aktiv + {% else %} + Deaktiviert + {% endif %} +

+

Polling: alle {{ acc.poll_interval_seconds }}s

+

Letzter Poll: {{ acc.last_poll_at or "Noch nie" }}

+

Filterregeln: {{ acc.filter_rule_count }}

+
+
+ + +
+
+
+ {% endfor %} +
+{% else %} +
+

Noch keine Konten eingerichtet. Konto hinzufügen

+
+{% endif %} + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/filters.html b/app/templates/filters.html new file mode 100644 index 0000000..99c6cc3 --- /dev/null +++ b/app/templates/filters.html @@ -0,0 +1,283 @@ +{% extends "base.html" %} +{% block title %}Filterregeln — IMAP Mail Filter{% endblock %} +{% block content %} +

Filterregeln

+ + + + +
+

Bitte ein Konto auswählen.

+
+ + +
+
+ +

Neue Filterregel

+
+
+ + +
+ + +
+ + +

Bedingungen (alle müssen zutreffen)

+
+ + +

Aktionen

+
+ + +
+
+ + +
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/yaml.html b/app/templates/yaml.html new file mode 100644 index 0000000..40b1708 --- /dev/null +++ b/app/templates/yaml.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}YAML — IMAP Mail Filter{% endblock %} +{% block content %} +

YAML Import / Export

+ +
+
+

Export

+

Aktuelle Konfiguration als YAML-Datei herunterladen.

+ + +
+ +
+

Import

+

YAML-Datei hochladen um Konten und Filterregeln zu importieren.

+
+ + +
+ +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1545128 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + mailfilter: + build: . + ports: + - "8080:8000" + volumes: + - ./data:/app/data + - ./config:/app/config + env_file: + - .env + environment: + - LOG_LEVEL=INFO + - YAML_SYNC_ON_STARTUP=true + restart: unless-stopped diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fe40379 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +fastapi>=0.110 +uvicorn[standard]>=0.27 +sqlalchemy>=2.0 +pydantic>=2.0 +pydantic-settings>=2.0 +jinja2>=3.1 +python-multipart>=0.0.6 +apscheduler>=3.10 +pyyaml>=6.0 +cryptography>=42.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_filter_engine.py b/tests/test_filter_engine.py new file mode 100644 index 0000000..f27ef08 --- /dev/null +++ b/tests/test_filter_engine.py @@ -0,0 +1,104 @@ +from unittest.mock import MagicMock + +from app.models.db_models import ActionType, ConditionField, MatchType +from app.services.filter_engine import evaluate_conditions +from app.services.imap_client import MailMessage + + +def _make_condition(field, match_type, value, negate=False): + cond = MagicMock() + cond.field = ConditionField(field) + cond.match_type = MatchType(match_type) + cond.value = value + cond.negate = negate + return cond + + +def _make_mail(**kwargs): + return MailMessage( + uid="1", + from_addr=kwargs.get("from_addr", "sender@example.com"), + to_addr=kwargs.get("to_addr", "me@example.com"), + subject=kwargs.get("subject", "Test Subject"), + body=kwargs.get("body", "This is the body."), + has_attachment=kwargs.get("has_attachment", False), + ) + + +def test_contains_match(): + mail = _make_mail(from_addr="newsletter@shop.com") + cond = _make_condition("from", "contains", "newsletter") + assert evaluate_conditions(mail, [cond]) is True + + +def test_contains_no_match(): + mail = _make_mail(from_addr="boss@work.com") + cond = _make_condition("from", "contains", "newsletter") + assert evaluate_conditions(mail, [cond]) is False + + +def test_exact_match(): + mail = _make_mail(subject="Important Update") + cond = _make_condition("subject", "exact", "important update") + assert evaluate_conditions(mail, [cond]) is True + + +def test_exact_no_match(): + mail = _make_mail(subject="Important Update!") + cond = _make_condition("subject", "exact", "important update") + assert evaluate_conditions(mail, [cond]) is False + + +def test_regex_match(): + mail = _make_mail(subject="Invoice #12345") + cond = _make_condition("subject", "regex", r"Invoice #\d+") + assert evaluate_conditions(mail, [cond]) is True + + +def test_negate(): + mail = _make_mail(from_addr="friend@example.com") + cond = _make_condition("from", "contains", "newsletter", negate=True) + assert evaluate_conditions(mail, [cond]) is True + + +def test_multiple_conditions_and(): + mail = _make_mail(from_addr="newsletter@shop.com", subject="Sale 50% off!") + cond1 = _make_condition("from", "contains", "newsletter") + cond2 = _make_condition("subject", "contains", "sale") + assert evaluate_conditions(mail, [cond1, cond2]) is True + + +def test_multiple_conditions_one_fails(): + mail = _make_mail(from_addr="newsletter@shop.com", subject="New arrivals") + cond1 = _make_condition("from", "contains", "newsletter") + cond2 = _make_condition("subject", "contains", "sale") + assert evaluate_conditions(mail, [cond1, cond2]) is False + + +def test_body_match(): + mail = _make_mail(body="Please confirm your subscription at http://example.com") + cond = _make_condition("body", "contains", "confirm your subscription") + assert evaluate_conditions(mail, [cond]) is True + + +def test_has_attachment_true(): + mail = _make_mail(has_attachment=True) + cond = _make_condition("has_attachment", "exact", "true") + assert evaluate_conditions(mail, [cond]) is True + + +def test_has_attachment_false(): + mail = _make_mail(has_attachment=False) + cond = _make_condition("has_attachment", "exact", "true") + assert evaluate_conditions(mail, [cond]) is False + + +def test_has_attachment_negate(): + mail = _make_mail(has_attachment=False) + cond = _make_condition("has_attachment", "exact", "true", negate=True) + assert evaluate_conditions(mail, [cond]) is True + + +def test_empty_conditions(): + mail = _make_mail() + assert evaluate_conditions(mail, []) is False