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