Files
connettore_metadataexporter/genera_pdf.py
T
2026-09-22 15:51:56 +02:00

502 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return Paragraph(escaped, S["code"])
def bullets(items):
return [Paragraph(f"• {i}", S["bullet"]) for i in items]
def note(text):
return Paragraph(f"<i>ℹ {text}</i>", 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 <b>rqwatch</b> (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 <b>metadata_exporter</b> di Rspamd. Ogni messaggio "
"analizzato da Rspamd viene inviato via POST al connettore, che provvede a:"
))
story += bullets([
"Salvare il file <b>.eml</b> grezzo nella directory di quarantena sul filesystem.",
"Inserire i metadati del messaggio nel database MySQL/MariaDB compatibile con <b>rqwatch</b>.",
"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 <b>requirements.txt</b> 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 <b>/etc/rspamd/local.d/metadata_exporter.conf</b> "
"con il contenuto del file <b>rspamd_metadata_exporter.conf</b> 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 <b>HTTP Basic Authentication</b> e verificano "
"che l'IP del chiamante sia presente in <b>RSPAMD_API_ACL</b>. "
"Se l'IP non è autorizzato viene restituito <b>HTTP 403</b>; "
"se le credenziali sono errate viene restituito <b>HTTP 401</b>."
))
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/<QID>/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 <QID>/ 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 <b>unknown/&lt;uuid&gt;/</b>."
))
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()