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:
Aissa
2026-06-09 00:41:27 +00:00
commit 5d60d33db1
212 changed files with 30044 additions and 0 deletions
+7
View File
@@ -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"]
View File
+21
View File
@@ -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()
+72
View File
@@ -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 {})
+229
View File
@@ -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"}
+81
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.115.0
uvicorn==0.30.6
psycopg2-binary==2.9.9
httpx==0.27.2