Platform bundle: marketing publish, IT ops, packaging, agents mesh.
Volledige Foodlinkk Command Center uitbreiding met social automatisering, reclamefolder filters, Proxmox monitoring en documentatie.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8801"]
|
||||
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
|
||||
|
||||
class Settings:
|
||||
DB_HOST: str = os.getenv("DB_HOST", "foodlinkk_db")
|
||||
DB_PORT: int = int(os.getenv("DB_PORT", "5432"))
|
||||
DB_USER: str = os.getenv("DB_USER", "aissa")
|
||||
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "Foodlinkk#2026")
|
||||
DB_NAME: str = os.getenv("DB_NAME", "foodlinkk")
|
||||
TOOLS_API_URL: str = os.getenv("TOOLS_API_URL", "http://tools-api:8700")
|
||||
SYNC_INTERVAL_SEC: int = int(os.getenv("SYNC_INTERVAL_SEC", "300"))
|
||||
|
||||
@property
|
||||
def database_dsn(self) -> str:
|
||||
return (
|
||||
f"host={self.DB_HOST} port={self.DB_PORT} dbname={self.DB_NAME} "
|
||||
f"user={self.DB_USER} password={self.DB_PASSWORD}"
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,72 @@
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Optional
|
||||
|
||||
import psycopg2
|
||||
from psycopg2 import pool
|
||||
from psycopg2.extras import RealDictCursor, Json
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_connection_pool: Optional[pool.SimpleConnectionPool] = None
|
||||
|
||||
|
||||
def init_pool() -> None:
|
||||
global _connection_pool
|
||||
if _connection_pool is None:
|
||||
_connection_pool = pool.SimpleConnectionPool(1, 5, dsn=settings.database_dsn)
|
||||
|
||||
|
||||
def close_pool() -> None:
|
||||
global _connection_pool
|
||||
if _connection_pool is not None:
|
||||
_connection_pool.closeall()
|
||||
_connection_pool = None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_connection():
|
||||
if _connection_pool is None:
|
||||
init_pool()
|
||||
conn = _connection_pool.getconn()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
_connection_pool.putconn(conn)
|
||||
|
||||
|
||||
def fetch_all(query: str, params: Optional[tuple] = None) -> list[dict[str, Any]]:
|
||||
with get_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(query, params)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def fetch_one(query: str, params: Optional[tuple] = None) -> Optional[dict[str, Any]]:
|
||||
with get_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(query, params)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def execute(query: str, params: Optional[tuple] = None) -> int:
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, params)
|
||||
return cur.rowcount
|
||||
|
||||
|
||||
def execute_returning(query: str, params: Optional[tuple] = None) -> Optional[dict[str, Any]]:
|
||||
with get_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(query, params)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def json_param(value: Any) -> Json:
|
||||
return Json(value or {})
|
||||
@@ -0,0 +1,229 @@
|
||||
"""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"}
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Foodlinkk Email Agent — IMAP sync + CEO approval workflow."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
from app.db import close_pool, fetch_one, init_pool
|
||||
from app import imap_sync
|
||||
|
||||
|
||||
class DraftIn(BaseModel):
|
||||
title: str = Field(..., min_length=3, max_length=255)
|
||||
to: str
|
||||
subject: str
|
||||
body: str
|
||||
|
||||
|
||||
async def _sync_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
imap_sync.sync_all_accounts()
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(settings.SYNC_INTERVAL_SEC)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_pool()
|
||||
task = asyncio.create_task(_sync_loop())
|
||||
yield
|
||||
task.cancel()
|
||||
close_pool()
|
||||
|
||||
|
||||
app = FastAPI(title="Foodlinkk Email Agent", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, Any]:
|
||||
db_ok = bool(fetch_one("SELECT 1 AS ok"))
|
||||
accounts = fetch_one(
|
||||
"SELECT COUNT(*) AS n FROM email_accounts WHERE is_active = TRUE AND sync_enabled = TRUE"
|
||||
)
|
||||
return {
|
||||
"status": "ok" if db_ok else "degraded",
|
||||
"database": "connected" if db_ok else "error",
|
||||
"imap_accounts": int((accounts or {}).get("n") or 0),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/sync")
|
||||
def sync_now() -> dict[str, Any]:
|
||||
return imap_sync.sync_all_accounts()
|
||||
|
||||
|
||||
@app.get("/drafts/pending")
|
||||
def pending_drafts() -> dict[str, Any]:
|
||||
items = imap_sync.list_pending_drafts()
|
||||
return {"items": items, "count": len(items)}
|
||||
|
||||
|
||||
@app.post("/drafts")
|
||||
def create_draft(payload: DraftIn) -> dict[str, Any]:
|
||||
row = imap_sync.create_draft(payload.title, payload.body, payload.to, payload.subject)
|
||||
return {"draft": row}
|
||||
|
||||
|
||||
@app.post("/drafts/{rec_id}/approve")
|
||||
def approve_draft(rec_id: int) -> dict[str, Any]:
|
||||
try:
|
||||
return imap_sync.approve_draft(rec_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(502, str(exc)) from exc
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn==0.30.6
|
||||
psycopg2-binary==2.9.9
|
||||
httpx==0.27.2
|
||||
Reference in New Issue
Block a user