Initial commit: IMAP Mail Filter Service
This commit is contained in:
parent
44fb27801d
commit
61c4384111
|
|
@ -0,0 +1,10 @@
|
|||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
data/
|
||||
.git
|
||||
.gitignore
|
||||
.venv
|
||||
venv
|
||||
tests/
|
||||
*.md
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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,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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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,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,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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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,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,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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
// Gemeinsame Hilfsfunktionen
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,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
|
||||
Loading…
Reference in New Issue