5d60d33db1
Volledige Foodlinkk Command Center uitbreiding met social automatisering, reclamefolder filters, Proxmox monitoring en documentatie.
230 lines
7.5 KiB
Python
230 lines
7.5 KiB
Python
"""IMAP sync and CEO email approval helpers."""
|
|
from __future__ import annotations
|
|
|
|
import email
|
|
import imaplib
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from email.header import decode_header
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
|
|
from app.config import settings
|
|
from app.db import execute, execute_returning, fetch_all, fetch_one, json_param
|
|
|
|
|
|
def _decode_header(value: Optional[str]) -> str:
|
|
if not value:
|
|
return ""
|
|
parts = decode_header(value)
|
|
out: list[str] = []
|
|
for chunk, enc in parts:
|
|
if isinstance(chunk, bytes):
|
|
out.append(chunk.decode(enc or "utf-8", errors="replace"))
|
|
else:
|
|
out.append(str(chunk))
|
|
return " ".join(out).strip()
|
|
|
|
|
|
def _accounts_to_sync() -> list[dict[str, Any]]:
|
|
return fetch_all(
|
|
"""
|
|
SELECT id, label, email_address, imap_host, imap_port, imap_user,
|
|
imap_password, imap_use_ssl, last_sync_at
|
|
FROM email_accounts
|
|
WHERE is_active = TRUE AND sync_enabled = TRUE
|
|
AND imap_host IS NOT NULL AND imap_host <> ''
|
|
ORDER BY id
|
|
"""
|
|
)
|
|
|
|
|
|
def sync_all_accounts() -> dict[str, Any]:
|
|
accounts = _accounts_to_sync()
|
|
if not accounts:
|
|
return {"accounts": 0, "imported": 0, "skipped": 0, "message": "No IMAP accounts configured"}
|
|
|
|
imported = skipped = 0
|
|
details: list[dict[str, Any]] = []
|
|
for acct in accounts:
|
|
try:
|
|
result = _sync_account(acct)
|
|
imported += result["imported"]
|
|
skipped += result["skipped"]
|
|
details.append({"account_id": acct["id"], **result})
|
|
except Exception as exc: # noqa: BLE001
|
|
details.append({"account_id": acct["id"], "error": str(exc)})
|
|
|
|
return {"accounts": len(accounts), "imported": imported, "skipped": skipped, "details": details}
|
|
|
|
|
|
def _sync_account(acct: dict[str, Any]) -> dict[str, Any]:
|
|
host = acct["imap_host"]
|
|
port = int(acct.get("imap_port") or 993)
|
|
user = acct.get("imap_user") or acct["email_address"]
|
|
password = acct.get("imap_password") or ""
|
|
use_ssl = acct.get("imap_use_ssl", True)
|
|
|
|
if use_ssl:
|
|
mail = imaplib.IMAP4_SSL(host, port)
|
|
else:
|
|
mail = imaplib.IMAP4(host, port)
|
|
mail.login(user, password)
|
|
mail.select("INBOX")
|
|
|
|
status, data = mail.search(None, "UNSEEN")
|
|
if status != "OK":
|
|
mail.logout()
|
|
return {"imported": 0, "skipped": 0}
|
|
|
|
ids = data[0].split() if data[0] else []
|
|
imported = skipped = 0
|
|
for num in ids[-50:]:
|
|
status, msg_data = mail.fetch(num, "(RFC822)")
|
|
if status != "OK" or not msg_data or not msg_data[0]:
|
|
continue
|
|
raw = msg_data[0][1]
|
|
msg = email.message_from_bytes(raw)
|
|
message_id = (msg.get("Message-ID") or f"local-{acct['id']}-{num.decode()}").strip()
|
|
existing = fetch_one("SELECT id FROM emails WHERE message_id = %s", (message_id,))
|
|
if existing:
|
|
skipped += 1
|
|
continue
|
|
|
|
subject = _decode_header(msg.get("Subject"))
|
|
from_addr = _decode_header(msg.get("From"))
|
|
to_addrs = [_decode_header(msg.get("To"))] if msg.get("To") else []
|
|
body_text = ""
|
|
if msg.is_multipart():
|
|
for part in msg.walk():
|
|
if part.get_content_type() == "text/plain" and not part.get_filename():
|
|
payload = part.get_payload(decode=True)
|
|
if payload:
|
|
body_text = payload.decode(part.get_content_charset() or "utf-8", errors="replace")
|
|
break
|
|
else:
|
|
payload = msg.get_payload(decode=True)
|
|
if payload:
|
|
body_text = payload.decode(msg.get_content_charset() or "utf-8", errors="replace")
|
|
|
|
row = execute_returning(
|
|
"""
|
|
INSERT INTO emails (
|
|
message_id, direction, from_addr, to_addrs, subject, body_text,
|
|
received_at, is_read, raw_headers
|
|
) VALUES (%s, 'in', %s, %s, %s, %s, %s, FALSE, %s)
|
|
RETURNING id
|
|
""",
|
|
(
|
|
message_id,
|
|
from_addr,
|
|
to_addrs,
|
|
subject,
|
|
body_text[:50000] if body_text else None,
|
|
datetime.now(timezone.utc),
|
|
json_param({"account_id": acct["id"], "label": acct["label"]}),
|
|
),
|
|
)
|
|
imported += 1
|
|
if row:
|
|
_create_review_recommendation(int(row["id"]), subject, from_addr)
|
|
|
|
execute(
|
|
"UPDATE email_accounts SET last_sync_at = NOW() WHERE id = %s",
|
|
(acct["id"],),
|
|
)
|
|
mail.logout()
|
|
return {"imported": imported, "skipped": skipped}
|
|
|
|
|
|
def _create_review_recommendation(email_id: int, subject: str, from_addr: str) -> None:
|
|
execute_returning(
|
|
"""
|
|
INSERT INTO ai_recommendations (
|
|
recommendation_type, title, description, priority, status,
|
|
generated_by, related_entity_type, related_entity_id, data_sources
|
|
) VALUES (
|
|
'email_review', %s, %s, 'medium', 'pending',
|
|
'email-agent', 'email', %s, %s
|
|
)
|
|
RETURNING id
|
|
""",
|
|
(
|
|
f"Nieuwe e-mail: {subject[:200] or '(geen onderwerp)'}",
|
|
f"Inkomend bericht van {from_addr}. Beoordelen en eventueel beantwoorden.",
|
|
email_id,
|
|
json_param({"email_id": email_id, "from": from_addr}),
|
|
),
|
|
)
|
|
|
|
|
|
def list_pending_drafts() -> list[dict[str, Any]]:
|
|
return fetch_all(
|
|
"""
|
|
SELECT id, title, description, status, data_sources, created_at
|
|
FROM ai_recommendations
|
|
WHERE recommendation_type IN ('email_draft', 'email_review')
|
|
AND status = 'pending'
|
|
ORDER BY created_at DESC
|
|
LIMIT 50
|
|
"""
|
|
)
|
|
|
|
|
|
def create_draft(title: str, body: str, to_addr: str, subject: str) -> dict[str, Any]:
|
|
row = execute_returning(
|
|
"""
|
|
INSERT INTO ai_recommendations (
|
|
recommendation_type, title, description, priority, status,
|
|
generated_by, data_sources
|
|
) VALUES (
|
|
'email_draft', %s, %s, 'high', 'pending', 'email-agent', %s
|
|
)
|
|
RETURNING id, title, status, created_at
|
|
""",
|
|
(
|
|
title,
|
|
body,
|
|
json_param({"to": to_addr, "subject": subject, "body": body}),
|
|
),
|
|
)
|
|
return dict(row or {})
|
|
|
|
|
|
def approve_draft(rec_id: int) -> dict[str, Any]:
|
|
rec = fetch_one(
|
|
"""
|
|
SELECT id, recommendation_type, data_sources, status
|
|
FROM ai_recommendations WHERE id = %s
|
|
""",
|
|
(rec_id,),
|
|
)
|
|
if not rec:
|
|
raise ValueError("Recommendation not found")
|
|
if rec["status"] != "pending":
|
|
raise ValueError(f"Already {rec['status']}")
|
|
|
|
ds = rec.get("data_sources") or {}
|
|
if isinstance(ds, str):
|
|
ds = json.loads(ds)
|
|
|
|
if rec["recommendation_type"] == "email_draft":
|
|
payload = {
|
|
"to": ds.get("to", ""),
|
|
"subject": ds.get("subject", rec.get("title", "")),
|
|
"body": ds.get("body", ""),
|
|
}
|
|
with httpx.Client(timeout=30.0) as client:
|
|
resp = client.post(f"{settings.TOOLS_API_URL}/emails/send", json=payload)
|
|
resp.raise_for_status()
|
|
send_result = resp.json()
|
|
else:
|
|
send_result = {"action": "marked_reviewed"}
|
|
|
|
execute(
|
|
"UPDATE ai_recommendations SET status = 'approved', updated_at = NOW() WHERE id = %s",
|
|
(rec_id,),
|
|
)
|
|
return {"recommendation_id": rec_id, "send_result": send_result, "status": "approved"}
|