#!/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()