Initial commit: IMAP Mail Filter Service
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user