#!/usr/bin/env python3
"""Genera il PDF di documentazione per connettore_metadataexporter."""
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
HRFlowable, KeepTogether,
)
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY
from reportlab.platypus.tableofcontents import TableOfContents
OUTPUT = "connettore_metadataexporter.pdf"
W, H = A4
MARGIN = 2 * cm
# ─── Palette ──────────────────────────────────────────────────────────────────
BLUE = colors.HexColor("#1a4a7a")
LBLUE = colors.HexColor("#2d7dd2")
LGRAY = colors.HexColor("#f4f6f8")
DGRAY = colors.HexColor("#4a4a4a")
GREEN = colors.HexColor("#2e7d32")
ORANGE = colors.HexColor("#e65100")
WHITE = colors.white
BLACK = colors.black
# ─── Stili ────────────────────────────────────────────────────────────────────
base = getSampleStyleSheet()
def style(name, **kw):
s = ParagraphStyle(name, **kw)
return s
S = {
"title": style("title",
fontName="Helvetica-Bold", fontSize=26, textColor=WHITE,
alignment=TA_CENTER, spaceAfter=6),
"subtitle": style("subtitle",
fontName="Helvetica", fontSize=13, textColor=colors.HexColor("#cce0ff"),
alignment=TA_CENTER, spaceAfter=4),
"version": style("version",
fontName="Helvetica", fontSize=10, textColor=colors.HexColor("#aac8ff"),
alignment=TA_CENTER),
"h1": style("h1",
fontName="Helvetica-Bold", fontSize=15, textColor=BLUE,
spaceBefore=18, spaceAfter=6, borderPadding=(0, 0, 4, 0)),
"h2": style("h2",
fontName="Helvetica-Bold", fontSize=11, textColor=LBLUE,
spaceBefore=12, spaceAfter=4),
"body": style("body",
fontName="Helvetica", fontSize=9.5, textColor=DGRAY,
leading=15, spaceAfter=6, alignment=TA_JUSTIFY),
"bullet": style("bullet",
fontName="Helvetica", fontSize=9.5, textColor=DGRAY,
leading=14, leftIndent=14, spaceAfter=3,
bulletIndent=4, bulletFontName="Helvetica"),
"code": style("code",
fontName="Courier", fontSize=8.5, textColor=colors.HexColor("#1a1a1a"),
backColor=LGRAY, leading=13, leftIndent=10, rightIndent=10,
borderPadding=6, spaceAfter=6, spaceBefore=4),
"note": style("note",
fontName="Helvetica-Oblique", fontSize=9, textColor=colors.HexColor("#555"),
leading=13, spaceAfter=4),
"footer": style("footer",
fontName="Helvetica", fontSize=8, textColor=colors.HexColor("#888"),
alignment=TA_CENTER),
}
def h1(text):
return [
HRFlowable(width="100%", thickness=1.5, color=BLUE, spaceAfter=3),
Paragraph(text, S["h1"]),
]
def h2(text):
return [Paragraph(text, S["h2"])]
def p(text):
return Paragraph(text, S["body"])
def code(text):
escaped = text.replace("&", "&").replace("<", "<").replace(">", ">")
return Paragraph(escaped, S["code"])
def bullets(items):
return [Paragraph(f"• {i}", S["bullet"]) for i in items]
def note(text):
return Paragraph(f"ℹ {text}", S["note"])
def spacer(h=0.3):
return Spacer(1, h * cm)
def table(data, col_widths, header=True):
t = Table(data, colWidths=col_widths)
style_cmds = [
("FONTNAME", (0, 0), (-1, -1), "Helvetica"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, LGRAY]),
("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cccccc")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 7),
]
if header:
style_cmds += [
("BACKGROUND", (0, 0), (-1, 0), BLUE),
("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 9),
]
t.setStyle(TableStyle(style_cmds))
return t
# ─── Callback numerazione pagine ──────────────────────────────────────────────
class NumberedCanvas:
"""Aggiunge numero pagina in fondo, sovrascrivendo il canvas di reportlab."""
pass # gestito con onLaterPages/onFirstPage
def on_page(canvas, doc):
canvas.saveState()
canvas.setFont("Helvetica", 8)
canvas.setFillColor(colors.HexColor("#888888"))
canvas.drawCentredString(W / 2, 1.2 * cm, f"Pagina {doc.page}")
canvas.restoreState()
def on_first_page(canvas, doc):
# Cover page: sfondo blu
canvas.saveState()
canvas.setFillColor(BLUE)
canvas.rect(0, 0, W, H, fill=1, stroke=0)
# Banda inferiore
canvas.setFillColor(LBLUE)
canvas.rect(0, 0, W, 3.5 * cm, fill=1, stroke=0)
canvas.setFont("Helvetica", 8)
canvas.setFillColor(colors.HexColor("#cce0ff"))
canvas.drawCentredString(W / 2, 1.4 * cm, "connettore_metadataexporter • Documentazione tecnica")
canvas.restoreState()
# ─── Costruzione contenuto ────────────────────────────────────────────────────
def build_story():
story = []
# ── Cover ──────────────────────────────────────────────────────────────
story.append(Spacer(1, 5 * cm))
story.append(Paragraph("connettore_metadataexporter", S["title"]))
story.append(Spacer(1, 0.4 * cm))
story.append(Paragraph("Guida all'installazione e all'utilizzo", S["subtitle"]))
story.append(Spacer(1, 0.3 * cm))
story.append(Paragraph("v1.0 • Maggio 2026", S["version"]))
story.append(Spacer(1, 8 * cm))
story.append(Paragraph(
"Compatibile con rqwatch (bilias/rqwatch)",
style("compat", fontName="Helvetica", fontSize=10,
textColor=colors.HexColor("#aac8ff"), alignment=TA_CENTER)))
# forza nuova pagina
from reportlab.platypus import PageBreak
story.append(PageBreak())
# ── 1. Panoramica ──────────────────────────────────────────────────────
story += h1("1. Panoramica")
story.append(p(
"Il connettore è un servizio HTTP scritto in Python (FastAPI) che funge da "
"backend per il modulo metadata_exporter di Rspamd. Ogni messaggio "
"analizzato da Rspamd viene inviato via POST al connettore, che provvede a:"
))
story += bullets([
"Salvare il file .eml grezzo nella directory di quarantena sul filesystem.",
"Inserire i metadati del messaggio nel database MySQL/MariaDB compatibile con rqwatch.",
"Permettere a rqwatch di gestire, visualizzare e rilasciare i messaggi in quarantena.",
])
story.append(spacer())
# Schema flusso
story += h2("Flusso dei dati")
flow_data = [
["Componente", "Ruolo"],
["Rspamd", "Analizza le email e chiama il connettore via HTTP POST"],
["connettore_metadataexporter", "Riceve il POST, salva .eml su disco, scrive metadati nel DB"],
["MySQL / MariaDB", "Database condiviso con rqwatch (tabelle mail_logs, mail_log_recipients)"],
["rqwatch", "Interfaccia web per visualizzare, cercare e rilasciare i messaggi"],
["QUARANTINE_DIR", "Directory filesystem dove risiedono i file .eml"],
]
story.append(table(flow_data, [4.5*cm, 11.5*cm]))
story.append(spacer())
# ── 2. Requisiti ───────────────────────────────────────────────────────
story += h1("2. Requisiti")
story += bullets([
"Python 3.11 o superiore",
"MySQL 8+ oppure MariaDB 10.6+ (schema rqwatch già inizializzato)",
"Rspamd con il modulo metadata_exporter attivo",
"Directory di quarantena scrivibile dal processo Python",
"rqwatch installato e configurato sullo stesso database",
])
story.append(spacer())
# ── 3. Installazione ───────────────────────────────────────────────────
story += h1("3. Installazione")
story += h2("3.1 Dipendenze Python")
story.append(p("Installare le dipendenze dalla directory del progetto:"))
story.append(code("pip install -r requirements.txt"))
story.append(p("Il file requirements.txt contiene:"))
story += bullets([
"fastapi ≥ 0.115",
"uvicorn[standard] ≥ 0.30",
"python-multipart ≥ 0.0.9",
"pymysql ≥ 1.1",
"python-dotenv ≥ 1.0",
])
story.append(spacer(0.2))
story += h2("3.2 File di configurazione")
story.append(p(
"Copiare il template e personalizzare i valori:"
))
story.append(code("cp .env.example .env\nnano .env"))
story.append(spacer(0.2))
story += h2("3.3 Avvio del servizio")
story.append(p("Avvio diretto (sviluppo/test):"))
story.append(code("python main.py"))
story.append(p("Avvio con uvicorn (produzione):"))
story.append(code(
"uvicorn main:app --host 127.0.0.1 --port 8080 --workers 2"
))
story.append(note(
"In produzione si consiglia di usare un process manager come systemd "
"o supervisord per garantire il riavvio automatico."
))
story.append(spacer())
# ── 4. Configurazione (.env) ───────────────────────────────────────────
story += h1("4. Variabili di configurazione (.env)")
story += h2("4.1 Server")
env_server = [
["Variabile", "Default", "Descrizione"],
["LISTEN_HOST", "127.0.0.1", "Indirizzo IP su cui ascolta il connettore"],
["LISTEN_PORT", "8080", "Porta TCP"],
["MY_API_SERVER_ALIAS", "mx1", "Alias del server (deve corrispondere a ?server= nella URL di Rspamd)"],
]
story.append(table(env_server, [5*cm, 3*cm, 8*cm]))
story.append(spacer(0.3))
story += h2("4.2 Autenticazione API")
env_auth = [
["Variabile", "Default", "Descrizione"],
["RSPAMD_API_USER", "rspamd", "Username HTTP Basic Auth (deve corrispondere a metadata_exporter.conf)"],
["RSPAMD_API_PASS", "—", "Password HTTP Basic Auth"],
["RSPAMD_API_ACL", "127.0.0.1", "IP autorizzati a chiamare l'API (separati da virgola)"],
]
story.append(table(env_auth, [5*cm, 3*cm, 8*cm]))
story.append(spacer(0.3))
story += h2("4.3 Quarantena")
env_quar = [
["Variabile", "Default", "Descrizione"],
["QUARANTINE_DIR", "/quarantine", "Directory radice dove vengono salvati i file .eml"],
["STORE_NO_ACTION", "false", "Salva su disco anche i messaggi puliti"],
["STORE_ADD_HEADER", "true", "Salva messaggi con azione add header"],
["STORE_REWRITE_SUBJECT", "true", "Salva messaggi con azione rewrite subject"],
["STORE_GREYLIST", "false", "Salva messaggi in greylisting"],
["STORE_DISCARD", "true", "Salva messaggi scartati (discard)"],
["STORE_REJECT", "true", "Salva messaggi rifiutati (reject)"],
]
story.append(table(env_quar, [5.5*cm, 2.5*cm, 8*cm]))
story.append(spacer(0.3))
story += h2("4.4 Database")
env_db = [
["Variabile", "Default", "Descrizione"],
["DB_HOST", "127.0.0.1", "Host MySQL/MariaDB"],
["DB_PORT", "3306", "Porta MySQL/MariaDB"],
["DB_NAME", "rqwatch", "Nome del database"],
["DB_USER", "rqwatch", "Utente database"],
["DB_PASS", "—", "Password database"],
["MAILLOGS_TABLE", "mail_logs", "Tabella principale dei log"],
["MAIL_RECIPIENTS_TABLE", "mail_log_recipients", "Tabella destinatari"],
]
story.append(table(env_db, [5.5*cm, 2.5*cm, 8*cm]))
story.append(spacer())
# ── 5. Configurazione Rspamd ───────────────────────────────────────────
story += h1("5. Configurazione Rspamd")
story.append(p(
"Copiare (o creare) il file /etc/rspamd/local.d/metadata_exporter.conf "
"con il contenuto del file rspamd_metadata_exporter.conf incluso nel progetto, "
"adattando URL, user e password:"
))
story.append(code(
"rules {\n"
" CONNETTORE {\n"
" backend = \"http\";\n"
" url = \"http://127.0.0.1:8080/api/metadata_importer_multipart?server=mx1\";\n"
" user = \"rspamd\"; # = RSPAMD_API_USER in .env\n"
" password = \"la-tua-password\"; # = RSPAMD_API_PASS in .env\n"
" selector = \"default\"; # invia tutti i messaggi\n"
" formatter = \"multipart\"; # formato raccomandato\n"
" timeout = 5;\n"
" }\n"
"}"
))
story.append(p("Dopo aver salvato il file, ricaricare la configurazione di Rspamd:"))
story.append(code("rspamc reload\n# oppure\nsystemctl reload rspamd"))
story.append(spacer())
# ── 6. Endpoint HTTP ───────────────────────────────────────────────────
story += h1("6. Endpoint HTTP")
ep_data = [
["Endpoint", "Formatter Rspamd", "Content-Type", "Note"],
[
"POST /api/metadata_importer_multipart",
"multipart",
"multipart/form-data",
"Raccomandato. Invia JSON metadata + file .eml come form-data."
],
[
"POST /api/metadata_importer",
"default + meta_headers",
"message/rfc822",
"Email grezza nel body. Metadata negli header X-Rspamd-*. "
"meta_headers è deprecato in Rspamd ≥ 3.14.2."
],
]
story.append(table(ep_data, [5.5*cm, 2.8*cm, 3.2*cm, 5.5*cm]))
story.append(spacer(0.3))
story += h2("Autenticazione")
story.append(p(
"Tutti gli endpoint richiedono HTTP Basic Authentication e verificano "
"che l'IP del chiamante sia presente in RSPAMD_API_ACL. "
"Se l'IP non è autorizzato viene restituito HTTP 403; "
"se le credenziali sono errate viene restituito HTTP 401."
))
story += h2("Risposta")
resp_data = [
["Codice", "Corpo", "Significato"],
["200 OK", "Message saved", "Messaggio ricevuto, salvato e inserito nel DB"],
["400 Bad Request", "Messaggio di errore", "Payload mancante o non valido"],
["401 Unauthorized", "Unauthorized", "Credenziali errate"],
["403 Forbidden", "Forbidden", "IP non autorizzato in RSPAMD_API_ACL"],
["500 Internal Server Error", "Errore database", "Errore di scrittura su DB o filesystem"],
]
story.append(table(resp_data, [3*cm, 3.5*cm, 9.5*cm]))
story.append(spacer())
# ── 7. Ciclo di vita dei messaggi ──────────────────────────────────────
story += h1("7. Ciclo di vita dei messaggi")
lifecycle = [
["Evento", "Filesystem", "Database"],
[
"Ricezione da Rspamd",
"File .eml salvato in\nQUARANTINE_DIR/YYYY-MM-DD//mail.eml\n(solo se l'azione è in STORE_*)",
"Riga inserita in mail_logs\nmail_stored = 1 (o 0 se non salvato)\nDestinatari in mail_log_recipients"
],
[
"Rilascio da rqwatch",
"Il file rimane su disco\n(non viene cancellato)",
"released = 1\nrelease_date = now()"
],
[
"Pulizia cron (rqwatch)",
"Directory / cancellata\ndopo QUARANTINE_DAYS giorni",
"mail_stored = 0\nIl record rimane per lo storico"
],
]
story.append(table(lifecycle, [4*cm, 6*cm, 6*cm]))
story.append(spacer(0.3))
story.append(note(
"La pulizia automatica dei file è eseguita esclusivamente da rqwatch "
"tramite il comando bin/cli.php cron:quarantine -d (tipicamente via cron). "
"Il connettore non elimina mai file dal filesystem."
))
story.append(spacer())
# ── 8. Struttura del filesystem ────────────────────────────────────────
story += h1("8. Struttura directory quarantena")
story.append(code(
"/quarantine/ ← QUARANTINE_DIR\n"
" 2026-05-07/\n"
" ABC123DEF/ ← QID del messaggio\n"
" mail.eml ← email grezza (RFC 822)\n"
" XYZ789GHI/\n"
" mail.eml\n"
" 2026-05-06/\n"
" ..."
))
story.append(p(
"Se il QID non è disponibile o non è alfanumerico, il file viene salvato "
"sotto una directory unknown/<uuid>/."
))
story.append(spacer())
# ── 9. Schema database ─────────────────────────────────────────────────
story += h1("9. Colonne principali della tabella mail_logs")
db_cols = [
["Colonna", "Tipo", "Descrizione"],
["id", "INT AUTO_INCREMENT", "Chiave primaria"],
["qid", "VARCHAR(30)", "Queue-ID dell'MTA"],
["server", "VARCHAR(10)", "Alias del server mittente"],
["subject", "VARCHAR(1024)", "Oggetto del messaggio (MIME)"],
["score", "FLOAT(8,2)", "Punteggio spam di Rspamd"],
["action", "CHAR(20)", "Azione: reject, discard, add header, ecc."],
["symbols", "JSON", "Simboli Rspamd con score e opzioni"],
["has_virus", "TINYINT(1)", "1 se rilevato da antivirus"],
["fuzzy_hashes", "JSON", "Hash fuzzy Rspamd"],
["ip", "VARCHAR(50)", "IP del mittente SMTP"],
["mail_from", "VARCHAR(255)", "Envelope From (SMTP)"],
["mime_from", "VARCHAR(255)", "Header From (MIME)"],
["rcpt_to", "VARCHAR(1024)", "Destinatari (comma-separated)"],
["mime_to", "VARCHAR(1024)", "Header To (MIME)"],
["size", "BIGINT", "Dimensione messaggio in byte"],
["mail_stored", "TINYINT(1)", "1 se il file .eml è sul filesystem"],
["mail_location", "VARCHAR(255)", "Path assoluto del file .eml"],
["headers", "LONGTEXT", "Header MIME grezzi del messaggio"],
["message_id", "VARCHAR(1024)", "Message-ID"],
["released", "TINYINT(1)", "1 se il messaggio è stato rilasciato"],
["release_date", "DATETIME", "Data/ora del rilascio"],
["notified", "TINYINT(1)", "1 se è stata inviata notifica al destinatario"],
]
story.append(table(db_cols, [4.5*cm, 3.5*cm, 8*cm]))
story.append(spacer())
# ── 10. Risoluzione problemi ────────────────────────────────────────────
story += h1("10. Risoluzione problemi")
prob_data = [
["Sintomo", "Causa probabile", "Soluzione"],
[
"HTTP 403 da Rspamd",
"IP Rspamd non in RSPAMD_API_ACL",
"Aggiungere l'IP in .env e riavviare il connettore"
],
[
"HTTP 401 da Rspamd",
"User/password non corrispondenti",
"Verificare RSPAMD_API_USER/PASS in .env e in metadata_exporter.conf"
],
[
"Messaggio nel DB ma file assente",
"Azione non in STORE_* oppure errore di scrittura",
"Verificare le variabili STORE_* e i permessi di QUARANTINE_DIR"
],
[
"Errore 500 dal connettore",
"DB non raggiungibile o credenziali errate",
"Verificare DB_HOST, DB_USER, DB_PASS e che il servizio MySQL sia attivo"
],
[
"rqwatch non vede i messaggi",
"DB diverso o tabelle errate",
"Verificare che .env punti allo stesso DB usato da rqwatch"
],
]
story.append(table(prob_data, [4*cm, 4.5*cm, 7.5*cm]))
story.append(spacer())
story.append(HRFlowable(width="100%", thickness=0.5, color=colors.lightgrey))
story.append(spacer(0.3))
story.append(Paragraph(
"connettore_metadataexporter • Compatibile con rqwatch (github.com/bilias/rqwatch) • Maggio 2026",
S["footer"]
))
return story
# ─── Generazione PDF ──────────────────────────────────────────────────────────
def main():
doc = SimpleDocTemplate(
OUTPUT,
pagesize=A4,
leftMargin=MARGIN, rightMargin=MARGIN,
topMargin=MARGIN, bottomMargin=2 * cm,
title="connettore_metadataexporter – Guida",
author="connettore_metadataexporter",
subject="Documentazione tecnica",
)
story = build_story()
doc.build(story, onFirstPage=on_first_page, onLaterPages=on_page)
print(f"PDF generato: {OUTPUT}")
if __name__ == "__main__":
main()