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

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
__pycache__
*.pyc
.env
data/
.git
.gitignore
.venv
venv
tests/
*.md

9
.env.example Normal file
View File

@ -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

13
Dockerfile Normal file
View File

@ -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"]

156
README.md Normal file
View File

@ -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
```

0
app/__init__.py Normal file
View File

16
app/config.py Normal file
View File

@ -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()

19
app/database.py Normal file
View File

@ -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()

100
app/main.py Normal file
View File

@ -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})

0
app/models/__init__.py Normal file
View File

103
app/models/db_models.py Normal file
View File

@ -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")

0
app/routers/__init__.py Normal file
View File

97
app/routers/accounts.py Normal file
View File

@ -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"}

112
app/routers/filters.py Normal file
View File

@ -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"}

21
app/routers/yaml_sync.py Normal file
View File

@ -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

0
app/schemas/__init__.py Normal file
View File

141
app/schemas/schemas.py Normal file
View File

@ -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}

0
app/services/__init__.py Normal file
View File

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

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
app/services/imap_client.py Normal file
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
app/services/scheduler.py Normal file
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")

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)

1
app/static/app.js Normal file
View File

@ -0,0 +1 @@
// Gemeinsame Hilfsfunktionen

30
app/static/style.css Normal file
View File

@ -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;
}

View File

@ -0,0 +1,123 @@
{% extends "base.html" %}
{% block title %}{{ "Konto bearbeiten" if account else "Neues Konto" }} — IMAP Mail Filter{% endblock %}
{% block content %}
<h1>{{ "Konto bearbeiten" if account else "Neues Konto" }}</h1>
<form id="account-form">
<fieldset>
<legend>IMAP-Einstellungen</legend>
<label>
Name
<input type="text" name="name" value="{{ account.name if account else '' }}" required placeholder="z.B. Arbeit, Privat">
</label>
<div class="grid">
<label>
IMAP-Server
<input type="text" name="imap_host" value="{{ account.imap_host if account else '' }}" required placeholder="imap.example.com">
</label>
<label>
Port
<input type="number" name="imap_port" value="{{ account.imap_port if account else 993 }}" required>
</label>
</div>
<div class="grid">
<label>
Benutzername
<input type="text" name="username" value="{{ account.username if account else '' }}" required>
</label>
<label>
Passwort
<input type="password" name="password" value="" placeholder="{{ '(unverändert)' if account else '' }}">
</label>
</div>
<label>
<input type="checkbox" name="use_ssl" {{ 'checked' if (not account or account.use_ssl) else '' }}>
SSL verwenden
</label>
<label>
Polling-Intervall (Sekunden)
<input type="number" name="poll_interval_seconds" value="{{ account.poll_interval_seconds if account else 120 }}" min="30">
</label>
<label>
<input type="checkbox" name="enabled" {{ 'checked' if (not account or account.enabled) else '' }}>
Konto aktiv
</label>
</fieldset>
<fieldset>
<legend>SMTP-Einstellungen (optional, für Weiterleitung)</legend>
<div class="grid">
<label>
SMTP-Server
<input type="text" name="smtp_host" value="{{ account.smtp_host if account else '' }}" placeholder="smtp.example.com">
</label>
<label>
SMTP-Port
<input type="number" name="smtp_port" value="{{ account.smtp_port if account else 465 }}">
</label>
</div>
<div class="grid">
<label>
SMTP-Benutzer
<input type="text" name="smtp_username" value="{{ account.smtp_username if account else '' }}">
</label>
<label>
SMTP-Passwort
<input type="password" name="smtp_password" value="" placeholder="{{ '(unverändert)' if account else '' }}">
</label>
</div>
</fieldset>
<div role="group">
<button type="submit">{{ "Speichern" if account else "Erstellen" }}</button>
<a href="/accounts" role="button" class="outline">Abbrechen</a>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('account-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const data = {
name: form.name.value,
imap_host: form.imap_host.value,
imap_port: parseInt(form.imap_port.value),
use_ssl: form.use_ssl.checked,
username: form.username.value,
poll_interval_seconds: parseInt(form.poll_interval_seconds.value),
enabled: form.enabled.checked,
};
if (form.password.value) data.password = form.password.value;
if (form.smtp_host.value) {
data.smtp_host = form.smtp_host.value;
data.smtp_port = parseInt(form.smtp_port.value);
data.smtp_username = form.smtp_username.value;
if (form.smtp_password.value) data.smtp_password = form.smtp_password.value;
}
{% if account %}
const resp = await fetch('/api/accounts/{{ account.id }}', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data),
});
{% else %}
if (!data.password) { alert('Passwort ist erforderlich'); return; }
const resp = await fetch('/api/accounts/', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data),
});
{% endif %}
if (resp.ok) {
window.location.href = '/accounts';
} else {
const err = await resp.json();
alert('Fehler: ' + JSON.stringify(err));
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Konten — IMAP Mail Filter{% endblock %}
{% block content %}
<h1>IMAP-Konten</h1>
<a href="/accounts/new" role="button">Neues Konto</a>
{% if accounts %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Server</th>
<th>Benutzer</th>
<th>Status</th>
<th>Letzter Poll</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for acc in accounts %}
<tr>
<td>{{ acc.name }}</td>
<td>{{ acc.imap_host }}:{{ acc.imap_port }}</td>
<td>{{ acc.username }}</td>
<td>{{ "Aktiv" if acc.enabled else "Deaktiviert" }}</td>
<td>{{ acc.last_poll_at or "Noch nie" }}</td>
<td>
<div role="group">
<a href="/accounts/{{ acc.id }}/edit" role="button" class="outline small">Bearbeiten</a>
<button class="outline small contrast" onclick="deleteAccount({{ acc.id }}, '{{ acc.name }}')">Löschen</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>Noch keine Konten vorhanden.</p>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
async function deleteAccount(id, name) {
if (!confirm(`Konto "${name}" wirklich löschen? Alle Filterregeln werden ebenfalls gelöscht.`)) return;
await fetch(`/api/accounts/${id}`, {method: 'DELETE'});
location.reload();
}
</script>
{% endblock %}

34
app/templates/base.html Normal file
View File

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="de" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}IMAP Mail Filter{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="container-fluid">
<ul>
<li><strong>IMAP Mail Filter</strong></li>
</ul>
<ul>
<li><a href="/">Dashboard</a></li>
<li><a href="/accounts">Konten</a></li>
<li><a href="/filters">Filter</a></li>
<li><a href="/yaml">YAML</a></li>
</ul>
</nav>
<main class="container">
{% if flash_message %}
<article role="alert">{{ flash_message }}</article>
{% endif %}
{% block content %}{% endblock %}
</main>
<footer class="container">
<small>IMAP Mail Filter Service</small>
</footer>
<script src="/static/app.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Dashboard — IMAP Mail Filter{% endblock %}
{% block content %}
<h1>Dashboard</h1>
{% if accounts %}
<div class="grid">
{% for acc in accounts %}
<article>
<header>
<hgroup>
<h3>{{ acc.name }}</h3>
<p>{{ acc.username }}@{{ acc.imap_host }}</p>
</hgroup>
</header>
<p>
<strong>Status:</strong>
{% if acc.enabled %}
<ins>Aktiv</ins>
{% else %}
<del>Deaktiviert</del>
{% endif %}
</p>
<p><strong>Polling:</strong> alle {{ acc.poll_interval_seconds }}s</p>
<p><strong>Letzter Poll:</strong> {{ acc.last_poll_at or "Noch nie" }}</p>
<p><strong>Filterregeln:</strong> {{ acc.filter_rule_count }}</p>
<footer>
<div role="group">
<button class="outline" onclick="pollNow({{ acc.id }})">Jetzt prüfen</button>
<button class="outline" onclick="testConnection({{ acc.id }})">Verbindungstest</button>
</div>
</footer>
</article>
{% endfor %}
</div>
{% else %}
<article>
<p>Noch keine Konten eingerichtet. <a href="/accounts/new">Konto hinzufügen</a></p>
</article>
{% endif %}
<div id="status-message" style="display:none"></div>
{% endblock %}
{% block scripts %}
<script>
async function pollNow(accountId) {
const msg = document.getElementById('status-message');
msg.style.display = 'block';
msg.textContent = 'Polling läuft...';
msg.setAttribute('role', 'status');
try {
const resp = await fetch(`/api/accounts/${accountId}/poll-now`, {method: 'POST'});
const data = await resp.json();
msg.textContent = data.message;
setTimeout(() => location.reload(), 1500);
} catch(e) {
msg.textContent = 'Fehler: ' + e.message;
}
}
async function testConnection(accountId) {
const msg = document.getElementById('status-message');
msg.style.display = 'block';
msg.textContent = 'Teste Verbindung...';
try {
const resp = await fetch(`/api/accounts/${accountId}/test`, {method: 'POST'});
const data = await resp.json();
msg.textContent = data.message;
} catch(e) {
msg.textContent = 'Fehler: ' + e.message;
}
}
</script>
{% endblock %}

283
app/templates/filters.html Normal file
View File

@ -0,0 +1,283 @@
{% extends "base.html" %}
{% block title %}Filterregeln — IMAP Mail Filter{% endblock %}
{% block content %}
<h1>Filterregeln</h1>
<label for="account-select">Konto auswählen:</label>
<select id="account-select" onchange="loadFilters()">
<option value="">— Konto wählen —</option>
{% for acc in accounts %}
<option value="{{ acc.id }}" {{ 'selected' if selected_account_id == acc.id else '' }}>{{ acc.name }} ({{ acc.username }})</option>
{% endfor %}
</select>
<div id="filters-container">
<p>Bitte ein Konto auswählen.</p>
</div>
<dialog id="filter-dialog">
<article style="min-width: 60vw;">
<header>
<button aria-label="Close" rel="prev" onclick="document.getElementById('filter-dialog').close()"></button>
<h3 id="dialog-title">Neue Filterregel</h3>
</header>
<form id="filter-form">
<input type="hidden" id="filter-id" value="">
<label>
Name
<input type="text" id="filter-name" required placeholder="z.B. Newsletter sortieren">
</label>
<div class="grid">
<label>
Priorität
<input type="number" id="filter-priority" value="100">
</label>
<label>
Quellordner
<input type="text" id="filter-source-folder" value="INBOX">
</label>
</div>
<label>
<input type="checkbox" id="filter-stop-processing">
Nach Treffer keine weiteren Regeln anwenden
</label>
<h4>Bedingungen <small>(alle müssen zutreffen)</small></h4>
<div id="conditions-list"></div>
<button type="button" class="outline small" onclick="addCondition()">+ Bedingung</button>
<h4>Aktionen</h4>
<div id="actions-list"></div>
<button type="button" class="outline small" onclick="addAction()">+ Aktion</button>
<footer>
<div role="group">
<button type="submit">Speichern</button>
<button type="button" class="outline" onclick="document.getElementById('filter-dialog').close()">Abbrechen</button>
</div>
</footer>
</form>
</article>
</dialog>
{% endblock %}
{% block scripts %}
<script>
const FIELDS = [
{value: 'from', label: 'Von'},
{value: 'to', label: 'An'},
{value: 'subject', label: 'Betreff'},
{value: 'body', label: 'Text'},
{value: 'has_attachment', label: 'Hat Anhang'},
];
const MATCH_TYPES = [
{value: 'contains', label: 'enthält'},
{value: 'regex', label: 'Regex'},
{value: 'exact', label: 'exakt'},
];
const ACTION_TYPES = [
{value: 'move', label: 'Verschieben in Ordner', needsParam: true, paramLabel: 'Zielordner'},
{value: 'forward', label: 'Weiterleiten an', needsParam: true, paramLabel: 'E-Mail-Adresse'},
{value: 'delete', label: 'Löschen', needsParam: false},
{value: 'mark_read', label: 'Als gelesen markieren', needsParam: false},
];
async function loadFilters() {
const accountId = document.getElementById('account-select').value;
const container = document.getElementById('filters-container');
if (!accountId) {
container.innerHTML = '<p>Bitte ein Konto auswählen.</p>';
return;
}
const resp = await fetch(`/api/filters/account/${accountId}`);
const filters = await resp.json();
if (filters.length === 0) {
container.innerHTML = `
<p>Keine Filterregeln für dieses Konto.</p>
<button onclick="openNewFilter()">Neue Regel erstellen</button>
`;
return;
}
let html = '<button onclick="openNewFilter()" style="margin-bottom:1rem">Neue Regel</button><table><thead><tr><th>Prio</th><th>Name</th><th>Ordner</th><th>Bedingungen</th><th>Aktionen</th><th></th></tr></thead><tbody>';
for (const f of filters) {
const conds = f.conditions.map(c => {
const fieldLabel = FIELDS.find(x => x.value === c.field)?.label || c.field;
const matchLabel = MATCH_TYPES.find(x => x.value === c.match_type)?.label || c.match_type;
return `${c.negate ? 'NICHT ' : ''}${fieldLabel} ${matchLabel} "${c.value}"`;
}).join('<br>');
const acts = f.actions.map(a => {
const at = ACTION_TYPES.find(x => x.value === a.action_type);
return `${at?.label || a.action_type}${a.parameter ? ': ' + a.parameter : ''}`;
}).join('<br>');
html += `<tr>
<td>${f.priority}</td>
<td>${f.name}${f.stop_processing ? ' <small>(stop)</small>' : ''}</td>
<td>${f.source_folder}</td>
<td><small>${conds}</small></td>
<td><small>${acts}</small></td>
<td>
<div role="group">
<button class="outline small" onclick='editFilter(${JSON.stringify(f)})'>Bearbeiten</button>
<button class="outline small contrast" onclick="deleteFilter(${f.id})">Löschen</button>
</div>
</td>
</tr>`;
}
html += '</tbody></table>';
container.innerHTML = html;
}
function addCondition(data = null) {
const list = document.getElementById('conditions-list');
const idx = list.children.length;
const div = document.createElement('div');
div.className = 'grid';
div.style.marginBottom = '0.5rem';
div.innerHTML = `
<select name="cond_field_${idx}">
${FIELDS.map(f => `<option value="${f.value}" ${data?.field === f.value ? 'selected' : ''}>${f.label}</option>`).join('')}
</select>
<select name="cond_match_${idx}">
${MATCH_TYPES.map(m => `<option value="${m.value}" ${data?.match_type === m.value ? 'selected' : ''}>${m.label}</option>`).join('')}
</select>
<input type="text" name="cond_value_${idx}" value="${data?.value || ''}" placeholder="Wert" required>
<label style="white-space:nowrap"><input type="checkbox" name="cond_negate_${idx}" ${data?.negate ? 'checked' : ''}> NOT</label>
<button type="button" class="outline small contrast" onclick="this.parentElement.remove()">X</button>
`;
list.appendChild(div);
}
function addAction(data = null) {
const list = document.getElementById('actions-list');
const idx = list.children.length;
const div = document.createElement('div');
div.className = 'grid';
div.style.marginBottom = '0.5rem';
const selectedType = ACTION_TYPES.find(a => a.value === data?.action_type);
div.innerHTML = `
<select name="act_type_${idx}" onchange="toggleActionParam(this)">
${ACTION_TYPES.map(a => `<option value="${a.value}" data-needs-param="${a.needsParam}" data-param-label="${a.paramLabel || ''}" ${data?.action_type === a.value ? 'selected' : ''}>${a.label}</option>`).join('')}
</select>
<input type="text" name="act_param_${idx}" value="${data?.parameter || ''}" placeholder="${selectedType?.paramLabel || 'Parameter'}" ${selectedType?.needsParam === false && !data?.parameter ? 'style=display:none' : ''}>
<button type="button" class="outline small contrast" onclick="this.parentElement.remove()">X</button>
`;
list.appendChild(div);
}
function toggleActionParam(select) {
const opt = select.options[select.selectedIndex];
const input = select.parentElement.querySelector('input[type=text]');
if (opt.dataset.needsParam === 'true') {
input.style.display = '';
input.placeholder = opt.dataset.paramLabel;
} else {
input.style.display = 'none';
input.value = '';
}
}
function openNewFilter() {
document.getElementById('dialog-title').textContent = 'Neue Filterregel';
document.getElementById('filter-id').value = '';
document.getElementById('filter-name').value = '';
document.getElementById('filter-priority').value = '100';
document.getElementById('filter-source-folder').value = 'INBOX';
document.getElementById('filter-stop-processing').checked = false;
document.getElementById('conditions-list').innerHTML = '';
document.getElementById('actions-list').innerHTML = '';
addCondition();
addAction();
document.getElementById('filter-dialog').showModal();
}
function editFilter(f) {
document.getElementById('dialog-title').textContent = 'Regel bearbeiten';
document.getElementById('filter-id').value = f.id;
document.getElementById('filter-name').value = f.name;
document.getElementById('filter-priority').value = f.priority;
document.getElementById('filter-source-folder').value = f.source_folder;
document.getElementById('filter-stop-processing').checked = f.stop_processing;
document.getElementById('conditions-list').innerHTML = '';
document.getElementById('actions-list').innerHTML = '';
for (const c of f.conditions) addCondition(c);
for (const a of f.actions) addAction(a);
document.getElementById('filter-dialog').showModal();
}
function collectFormData() {
const accountId = document.getElementById('account-select').value;
const conditions = [];
const condRows = document.getElementById('conditions-list').children;
for (let i = 0; i < condRows.length; i++) {
const row = condRows[i];
const selects = row.querySelectorAll('select');
const input = row.querySelector('input[type=text]');
const checkbox = row.querySelector('input[type=checkbox]');
conditions.push({
field: selects[0].value,
match_type: selects[1].value,
value: input.value,
negate: checkbox.checked,
});
}
const actions = [];
const actRows = document.getElementById('actions-list').children;
for (let i = 0; i < actRows.length; i++) {
const row = actRows[i];
const select = row.querySelector('select');
const input = row.querySelector('input[type=text]');
actions.push({
action_type: select.value,
parameter: input.value || null,
});
}
return {
account_id: parseInt(accountId),
name: document.getElementById('filter-name').value,
priority: parseInt(document.getElementById('filter-priority').value),
source_folder: document.getElementById('filter-source-folder').value,
stop_processing: document.getElementById('filter-stop-processing').checked,
enabled: true,
conditions,
actions,
};
}
document.getElementById('filter-form').addEventListener('submit', async (e) => {
e.preventDefault();
const data = collectFormData();
const filterId = document.getElementById('filter-id').value;
let resp;
if (filterId) {
resp = await fetch(`/api/filters/${filterId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data),
});
} else {
resp = await fetch('/api/filters/', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data),
});
}
if (resp.ok) {
document.getElementById('filter-dialog').close();
loadFilters();
} else {
const err = await resp.json();
alert('Fehler: ' + JSON.stringify(err));
}
});
async function deleteFilter(id) {
if (!confirm('Filterregel wirklich löschen?')) return;
await fetch(`/api/filters/${id}`, {method: 'DELETE'});
loadFilters();
}
// Auto-load if account is pre-selected
if (document.getElementById('account-select').value) loadFilters();
</script>
{% endblock %}

64
app/templates/yaml.html Normal file
View File

@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}YAML — IMAP Mail Filter{% endblock %}
{% block content %}
<h1>YAML Import / Export</h1>
<div class="grid">
<article>
<header><h3>Export</h3></header>
<p>Aktuelle Konfiguration als YAML-Datei herunterladen.</p>
<button onclick="exportYaml()">YAML exportieren</button>
<pre id="yaml-preview" style="max-height: 400px; overflow-y: auto; display: none;"></pre>
</article>
<article>
<header><h3>Import</h3></header>
<p>YAML-Datei hochladen um Konten und Filterregeln zu importieren.</p>
<form id="import-form">
<input type="file" name="file" accept=".yaml,.yml" required>
<button type="submit">Importieren</button>
</form>
<div id="import-result" style="display:none"></div>
</article>
</div>
{% endblock %}
{% block scripts %}
<script>
async function exportYaml() {
const resp = await fetch('/api/yaml/export');
const text = await resp.text();
const pre = document.getElementById('yaml-preview');
pre.textContent = text;
pre.style.display = 'block';
// Also trigger download
const blob = new Blob([text], {type: 'text/yaml'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'filters.yaml';
a.click();
URL.revokeObjectURL(url);
}
document.getElementById('import-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const resp = await fetch('/api/yaml/import', {method: 'POST', body: formData});
const result = await resp.json();
const div = document.getElementById('import-result');
div.style.display = 'block';
if (result.error) {
div.innerHTML = `<article role="alert">Fehler: ${result.error}</article>`;
} else {
div.innerHTML = `<article>
Import erfolgreich!<br>
Konten erstellt: ${result.accounts_created}<br>
Konten aktualisiert: ${result.accounts_updated}<br>
Regeln erstellt: ${result.rules_created}
</article>`;
}
});
</script>
{% endblock %}

14
docker-compose.yml Normal file
View File

@ -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

10
requirements.txt Normal file
View File

@ -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

0
tests/__init__.py Normal file
View File

104
tests/test_filter_engine.py Normal file
View File

@ -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