Codice iniziale

This commit is contained in:
2026-09-22 15:51:56 +02:00
commit 0adf0526e7
10 changed files with 1257 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.env
__pycache__/
*.pyc
*.pdf
genera_pdf.py
*.md
.git/
+37
View File
@@ -0,0 +1,37 @@
# ── Server ────────────────────────────────────────────────────────────────────
# Indirizzo e porta su cui ascolta il connettore
LISTEN_HOST=127.0.0.1
LISTEN_PORT=8080
# Alias di questo server (deve corrispondere a ?server= in metadata_exporter.conf)
MY_API_SERVER_ALIAS=mx1
# ── Autenticazione API ────────────────────────────────────────────────────────
# Devono corrispondere a user/password in metadata_exporter.conf di Rspamd
RSPAMD_API_USER=rspamd
RSPAMD_API_PASS=cambia-questa-password
# IP(s) di Rspamd autorizzati a chiamare l'API (separati da virgola)
RSPAMD_API_ACL=127.0.0.1
# ── Quarantena ────────────────────────────────────────────────────────────────
# Directory dove vengono salvati i file .eml (QUARANTINE_DIR/YYYY-MM-DD/QID/mail.eml)
# Deve essere la stessa usata da rqwatch
QUARANTINE_DIR=/quarantine
# Quali azioni di Rspamd fanno scattare il salvataggio su disco
STORE_NO_ACTION=false
STORE_ADD_HEADER=true
STORE_REWRITE_SUBJECT=true
STORE_GREYLIST=false
STORE_DISCARD=true
STORE_REJECT=true
# ── Database MySQL/MariaDB (stesso di rqwatch) ─────────────────────────────────
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=rqwatch
DB_USER=rqwatch
DB_PASS=cambia-questa-password
MAILLOGS_TABLE=mail_logs
MAIL_RECIPIENTS_TABLE=mail_log_recipients
+17
View File
@@ -0,0 +1,17 @@
FROM python:3.12-slim
WORKDIR /app
RUN addgroup --gid 1001 connettore && \
adduser --uid 1001 --gid 1001 --no-create-home --disabled-password connettore
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
USER connettore
EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "2"]
Binary file not shown.
+193
View File
@@ -0,0 +1,193 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 5 0 R /F4 6 0 R /F5 8 0 R /F6 9 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 16 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
6 0 obj
<<
/BaseFont /Symbol /Name /F4 /Subtype /Type1 /Type /Font
>>
endobj
7 0 obj
<<
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 16 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F5 /Subtype /Type1 /Type /Font
>>
endobj
9 0 obj
<<
/BaseFont /ZapfDingbats /Name /F6 /Subtype /Type1 /Type /Font
>>
endobj
10 0 obj
<<
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 16 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
11 0 obj
<<
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 16 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
12 0 obj
<<
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 16 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
13 0 obj
<<
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 16 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
14 0 obj
<<
/PageMode /UseNone /Pages 16 0 R /Type /Catalog
>>
endobj
15 0 obj
<<
/Author (connettore_metadataexporter) /CreationDate (D:20260507180013+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260507180013+00'00') /Producer (ReportLab PDF Library - www.reportlab.com)
/Subject (Documentazione tecnica) /Title (connettore_metadataexporter \205 Guida) /Trapped /False
>>
endobj
16 0 obj
<<
/Count 6 /Kids [ 4 0 R 7 0 R 10 0 R 11 0 R 12 0 R 13 0 R ] /Type /Pages
>>
endobj
17 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 569
>>
stream
Gas2F9okbt&;KZL'ltn;dQ5VI"8gf5mA2sFiY\Y8M)TPl@)G$BQ![mfQt2u/JclA*lZo(a&^#@YphX,apGK#o&WiZ7!Ye<nr<5C8X2Bg'M+/XB1?en.W3kIg_f""aEG6\n8h/0hRFXjFi8Ei`"tM""(_f!/M+]T[6":`TWLV-[3ft8V1'l?KK6-4\GHA[JJ8lZ&W.E!0/^)5l6IGF2k<eA;pUt^cDJcWGmrL\r2ZRZuWW56Vc*;CsYUA=P%YAIWhq1#hImJLTRXU1o;\qkr))C`7=%k$9inP:V86uYOo)HWS4Q&c@IO'a/C$(XJ-,q3E*@pFBRgp+n_eiq;Su4%q91*$[Q:=U7LUX.Of[&c$NF1'fNC?kpUOPK(XTc>o^[p8SM!mH:2uYlC^\=X)J$HLX03^ulR3GhCW,Pq$(+NT)YgGk_W6iMZHt?cCQ-r>Q^QFfE"7t(2G.n228R9/NMO2/i&8XIu9B;VlV;O$pM1RP*7;d2o]op^@6LgafT5F'^r8'_%9/'8A1Ns<D_r6Xcc-Q<7g8f`u&L6/HXE25?mco.OOaU[2%pj/QjT~>endstream
endobj
18 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2052
>>
stream
Gau0DfiHiu&:N^llI!_(:i"[q,pCJd-OYjqBpcePUrILG)j5"=?U)#(Ot@U\']OccYh\&7\$fMk@V"JRl/,?1oR9Dfa,HBSdi2bViR%31I@T`]mBnHq=pPS@rSDL1ooD$2Q8<Z6[F21-)1`G*A35Of>i2Al8s<1;P8Pb6%8FUboW8aM#"@>kcSE/[$5rZRO&''fJ83bVkoQ>ng,#,<6B@7;L$AaXNZU5=U[p_D1+a^s`s[kLJ7=]T\q'SC$Cnh^+gL>[R<2ge.h7Ul@]oEjV0k%,#+b<906o#l^1Q)"h9ap*]r&Sg%IGRaYZrQ+a7$F5:HAuZTp75rDYu)!1c\,Ub-OdkF^>EdZsAmX)-*D9a@DZIT@<$R<Db3M:,8TnL\`pTk]1^fX8:Z:alOJ!D?\k?Z^IJRdl!3c'3S"_-qPbMk4(RU3s/32c>Yq=fc$f09+,a&MOMSuk#^PhOdEI(oq@rh'8e*,<s;B0K>,Vd8ZAX<5+;R!cZgWU+#u"V6aMUb;Ifir(K,[BhfS_f@S4:OnGX?5M)(nUo>O&7K)"dZ!ojdoL,k'G%LdE-+<$TjpZ[8W$]\W1IlB+pe9YTc.mp,S;tnL.nbAJM-Ba&"=/C^\iIg^Th40foWs.deRQ!R6LT2,h0u?79gqACh)%oUm'<-u'Uf\nULr'Ksl#=lt6(]5Oc9<7tk,%-/mh*7?^k$:_)FX.qHcisI?:VJ#4>b&]a6FK:TH2c-d8SWkUp9VmPctcLNLZ^YS-mG5<&L2MO5-i!9`eg_TMd,LMBm-=e$qd\h^XXr9X1#%n4B%RI=pc;2^h#a4i[f$3N]E7A\2(",W)sj1Y1Tmj()Ir-pAeSeDq`5h_YD\;]bO5:H;?k<2pWY1M2g1g4npd73#DF_%/JCpPdJl!Y<O#"ATU16Q_7'G,Kq"gS$L@T(`T&Nq8uMI(N,@E/2e@g07?)"m\p@S6e2ncKn!:LS"Y(+[;V@nk:/ZH>`37-i0h.,2h:.o6J(0AF)2H`_In.:+,DT&i@M?TW[,JY2s7^\VcW(e53T_P,DOs>d*lML=Y`]dI_V85.[s6\iP#b23,)Y0oW+IE%WB?ckNOfm8Ki@QP?]V,#Ok1PjdqPLJ:F!;P\F,IL)BXBuugFk`AuZH3$cfkbt7;(`#d66*@2h,E'_OQGMn&!XE$Xq5i:WI_5a2b6#qkXtJT?J[8(QVb`;iT\@X<8?)078"_';(Y?6rpSho",#LhUE7PSf-4S<#IWteZbOjl=a/bu!o+rN,7uim@D:QNPc50*&,t4>:(fHg"\a5H<bW@rr1A\Fl"P!@i"Gk1mIT_ZpWZK]c)?lH@@^faJI;S[9p9kA8;S:Rf@Okk0YMKVX\cko2DZNYi!beRBJ>[;$=u4+^e2,9d*2tVC+$+XZ6VYKrT7Z#Cicq?:JmUHY4*FF!:X7a]60I6FN^S/i6*2uM^?-FF!mU4N)sEhQGQn<t6")F!YsWm$?lGUDI\XP(js\u%]RntrDd](B_f32U=(_$OG"bRQ\/'2(p);`q6-"YkCZt/9Y8D;*a.Odr#i]tY(\n>m(iaa/nRuV8<\:/9?";YXE7p&#R[fhr(kED(lE7C8+dQ82F'Kc*>LJMp7aC.mHGZKZ%L1VO)6[mW,I!ne]tbX6&).G!Mu1]=_[:b]J!)p16X<m&HZ1R^7TaU):1*nE9\1\A,ZK>A[E'E.kZkAt[te?jY*e969bmUf@FCPSW_ggLJeRa8$`D?s4]Ct*Lb#iDHTdV>%H@p=@"S,T'7.i=8*7.,F>s2:<._Z\"LQk54Bn:VaJ(0b3[c-iSY?gtR];>CHTf4Uf(8SnQi?2aRI,4uXh`NE"/'[Mp$-%?mofG')=eu@gi,6XO<&@SF-#G9S!_qV6H<[*#h4=-F4Bf8N:Jb$#M?AMQgqho_f)"hi$3ku%DU8SE0/A/=r+cP?\dJp_@c8Xk81p0+0)s625oKW&XY<BrZ[]L3VrU3Fi_TK$Nj*Xf,nh'Tqg4`e7U<net\(,p&C0Ac?IlZr+rk<O;G3N#DtTLJY>Kf1,1IaH+&*#->C;[]TemZ7sk7<1>_R,~>endstream
endobj
19 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2130
>>
stream
Gb"/(gMYb*&:O"KbhK]4U57=4^gc9Bmaagq34@7(JH,m)0b1h$jQb_]rV@Q"@S:#V;NNN>%o)'9e-q+f_jN<uKRes;,ld7K!8b5)qN_,Ti8+O_'cl(NR(=rb^*k;9'KCtQ5$&.eI_kQ$]Ts4%Bq<sGZ$"(k9ik;'=9c8S(sU`;KFG5Si-k_$Pr*]=$WWBaWgF9=#[!t'&BAl(+GpoS0PqJ)E_P:e9mjFKTf[;6&9Kld5rWd@)crd=`#NZ%j^o7%U(IIImnKTG"^T7/S\EXiQ64KbRb[;OgOd;[S\i.Hbr4MU!(%Gjq5;4bg@o$KBkj&V+70E(\lXuD/_hKNeK'J\'UrhAFcHIF=,rRJ+ZWYnK@LYF`k.dt^uk_D4Pkq`+rUHEO?*SLY'oqkN5;H?7PH_-T,BYJ&qYpbRRV6P8p2E"[bL"&j[jdino[/>r*9R"^RbAt+h`e#fJ!megP5g>XoH2!*[8[K_MdCbS[j_)5JpD/q(PS_5DLVe%7jR7B%no-SD6P)MNM['.Xi%YY`7WVCt2SVo6h>Qc/SrjJ1]2q#-"7dCoRr%ej`=-Z3lBl3!Xu3DpA,t'^"^&.?.^N_VkJ;&b9lRYmpoSG_[hYkd)hlnT^5TDF:kp0ktQ%q]SQ,`eOh_Cb'Q_M..Rr'<G?\_A)SF6iaK"o%:!kO'%];)j[5qPQAKpD+1:)2r0iN7U)jB@Q^E4<-j$Ri@7lNbLSNPMa&pOUl>h<L/Wb[$3;]i&0TBP(?dN#HmU5pT$WE7U$2o3AE]-$+qW2=kj;TZc=0iLs(3NJpuZ/&?OVQD:d'SFFUZhISfa"Tf<nSs2GKouDE)c_b9SO"9a)2oc-R.l`t1D-[*5sK1DKFRDk#7\Mbi*oFruC-AD<1!c2.KEV?/@(J=<MiYk5T@=/X#QY,O41DC_#B[7YkaDcmjOmE/DW'[%]O5rnIEOe7#<JspIV(s-8)%@Z$3T.Sm``Tl:@dd9>':=sVe&'e($(1QMAhnMZ8Rq&INQO.&V(Bl7Oq2I*Kb/,!>prEM2XlGpY0)BOQ'-q_o5V+eUaKuB2Z[!>i4'2=KBf2L.<m0aej)hOET?kO#*[J_;c*ol<'maBNc",_4+;*IhI(nY)U,Wq12@=kmJ]Qc16Mrou49ud/0?tj]/<VU>pl4Y?RWB?t0/"%#s83*.$ldpt.`s]*36.0BYs#d#cdg$Foq5C8BK(Pm>nc.p&n'GE"?<CaQteJZNX>g78`<IUdXJE-M,L[!UD3/%V>.'K"*NZib^HOu2WupA982Pm,_^6(E\)!4ajq;$rIh.a7Ld6g_Yb8WF0V6r5>6)I\4/(f7[e=gS\l<9)Y6%$fHS"Mh)W,VM=>'Fgi`/'L8W7..9l=\Z=i7'+rc.='=8?dD'/B>ZB*/N8N2l;"%eb(ah;NTNcsqPa4<M8ccudCld0rWBO_X;/YoI=fL;P/]K<qfZ;D'[_c>gsN,Ps%7@DJ0-fT7_Y[=ImPZ9S?1l0Q(WgZQS$I<?b%Bd#7[_P8]c_cBHf5@f60j:!MLJE]W8sW2pnfIR:b<:OTdhMjFa(JEQ@a[Og!$fT_%:cS+X9KZOT32;Q?I7HWolr4*E+^!9eR[62"SPs@8Z,pRBGFsrmLFKb)&'XeHUq3K&2R[<*;g'm^lF39.)_T2$[.]4"Pen4YAp&)Md?]=pi#D'/uG\-9;GX5G'VI?]UX,fJts3'[=SN-4+1IDRh%(BmdMQa<@USmH7`No"X4[e3&.cM0ZTC!lJ6PEl)_0*c>lr(B720Yq`2_\-M^0.Z7f_%%sOQ>NVdNrY>RVj9`J;?h!&R!rT=(NXQsV!i@Ll:^f`8;M)4;gYt-l9qXYL3OY3kL:JlZ@F#!V=mnl4rb;hp"]e\Ju.7..Wr#lUXLAJKZ%k4/GB$7ON@a]n1S2.m0Q163*`++&B_i2E49#uS@h'Ug7;]]nfp/#RK)mi`&MQF`3F*XQWkrL*ier%*[%dH+cMX?Bnh2n_#FcgZW0k;H/M_o_kOetEbJC)t!rO<oh0T*3YH^nhifMm3];oq,5*(&^NEKKY#\f=5bjlFI(3BBj<^&Q:pg"=^4R!Ni88#pf[WII7^[\$O)m3`TaqiPpTP7&D9:keiH6$pNll2N8Tg-]U>X$*1LE=l%:iZHY=NHt~>endstream
endobj
20 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2622
>>
stream
Gb!Sl>Ar7U(4OT531,-H9Xg]alJA7;,cNlM9J3L8!rGg0?mNb>X=UoFS5-Y_s1X7&.8-o\Gr!"I@dCJ2FD`1G%aThC/Gr?;J$]8:S&=Fl"j15%#Ljn,bV*Be5Pf]%.=iat`7+13CWes3oXe3t!NNQ6J<I\S"0`eT;:g##^4PAO?R?Wqk=:p>$M.=sh'Y$iKRA[\SjsgjQqM,VKa:M;jTkHkP08!`EGa\rB_-m3-j8>=3[19`"WjBE4ib#or:ZQEKT25@p.9Ba;sOUUK4%N\kIj-aZZhfX]km/+/@CnR2l-=;j]A=mf5d"7hR8YQhOcA`hTY+6YI)r\a%0mSDSo2::FuFjrVXmJM=8oqp5aK%_:@T4e)feN(C>S@[U0RlBOL='DPi&21q.=+h@LCG3m!;;+,2*6%$^),X760(c!5Yp`iJ<P!/tO(c;t'VO[@8BI`kC3LW"BFP0^K)F9%/04q*9[r`k;]&q`/Kei;i72B0H*RD1H<%tN#s'4.!^nff-.@FnA!FaA%QH1TL$0g@h5*:f/V=n*bCV"q;9J>Cl]r)TUBLNS_i;6o>`oN)*mTFY8R5[-q&k80>?nb)f6KL#k$U(Cul6+77g%6@$i;-'&l.h(g2SBRWjj1er^8-&>?ktag:keF)R@_k\"eJ)WjVTC9Mcoc./<gR2OY9ci&KZq2J&jA3!g9j;W$8K#u./7<YQr.&jHnki&K>?.A?lf-gR&@)kn%Zff4D^-9k10>[l-4jSIo;nUrBb.C+UdfFp!bWt^Np_:/9&fI39$YcUlu(lYjP@2/<iAL3.Bf<<;NZKGV:=.J<eZ"0^LOP#&OHjC:c@XJeM@Ce*$nO-#UoGYtO";cgHtXVVp?q4;0D#f6e<Wb4pE&:=+U?Z,J8gRKB+c^iOotMVjZKbIVF#"eYnojL\O2nThiD37b_qa#QPA^n4t_KHu8`":qMa-5WBk*KNT'N=#i9Pi`QR8N^15MT^+J&AP?P15LNM\4eL.?oS@<.i%(\B$*i5X0aJ'Qjp8FppRTad,Ik(Qs\99R6u^Tf1!Cf<L(<'.9c6mcJeBenhk5U;1O2):6][OGMhrhFq?dfE&_fg?&D/<d.2V@9RLR[c]dg8nDLQo^"T6V%sV4BV,:G#:pES\$3,VKS*TD1I"!jlj23I0r[G,ihSB`s<OMhb/Sp:YhO.pqX1*":UScpn'3KfCip1.@\7AV.lXI7n.nn>Zolo2f#>r-<rC3VQ\S#TKp'8>,9?Y+m2EAHs^GqchW61!(!$\_(Ar!3jb"o#H5;B?V%FC9&[EqH&RS/d#W61Ci=PRm!V^.Jm+lQ77W[`Q&<h_/@Ega^K?EDN(2:Q4a8tmZoNTPg_;80o?^m@kD/!4a"P]GZun*p2T&V3,\77?V/l5isWppCF]qOenEcdt0rghnNcqB-sGoCj/\,i\*]!%^f@2grW$c6.lFZ2MN^B'r<Df]eB(#rk8:C!L5\9YOhO:k/:C<aleDWM("2VFH@"YFrWu31(h&nDC8XG1b7C-OYSt@%>4%4NT)J31ium=7:IT3Xo8nCJQg.n-QH0&"]*J.'uLCIoU)5gD+J=W;2952a?QXeL^['QM<ggfQW3d]>8P*@D;%FWH2'dWg!8=\u.?mD7"r=1D8;be`Qc3A"ddi-TDaU:qAfonU@(C=mT)\O\C6Pde(>D[G\EW*=5uLD#<hjjr2GNi,<ab5f?:GF.C#a$fq,\rgDS%W3t5g!!,Xd`P(37./LN%ZQFKC:j#*`MRt9ECg\W*Og1E%?s/<Shmr#%DY*Xc^m3T-9J"WqC(TlqP<'=P?BG^WpgUiNp,ZuZ7T=9OFNBp=b%X?Xc?c(qD/EGZYR.bMN#Y$EeG['@S#u\HC;75=YH9B`MdsMjH;$#GSM@ql#0)<c+g^"Y?YW1e%h73(DXAB7DB:XFGD<?(&F4u'>9?u*)S4/M53>;XFH4Xg!Q&7':B%+bqPGhLSShtU_.'OHo5Nr'[^S-Md;S>OJJ(F8bGB]uWLp6J8-YtG"U2MoqaAAJQLB"iZIlr109ZZ\&bkrJ.U9r1O"CA/PiN>=W$o6t[cFV.'@k4"HB(Rl_IB+XBj[+i4s\62m6)b]*MV_g[ed+4nFZX!a"Dq22O358ThZ<B&Y_hcAE'#'^`d&mjh\L@g*B\U&fo0PdJObPe1B\I'8u4(_K"$s\P&YF39"#>4%hOs^^f1+_#XR&fmbIeVb+GEb>,qC)Jq*`I2.F$ZgX(Ps5PSQTRRije/iL8be<4c*[af\?P,%2pi`.;=Q_h)0>rM3ZA*Lf/2MG0Wr-G1LqYBNQ3B4^%,fUI=*:phM<$tp1fjln7q.nI1=_29NL[],4<r;TcB.V8a>2J!?``]o*CP4.;nu@[h;D]\J?4I5!,sX/e!h.:^+d@1gk7nlOTUeKV".TiP0=#?K"/ig:"+tI*Bn,'MXG%.lJ&9BWKnHg0G$t9g.G+_?5g?*`CpNh,2X_%apimL+p709Olk5_\=J:aA$_ZDHWrcPI=-V#?"=p*\@QR.Z1Q`M>hthtrsi:T)pDS"bjQ-u@YJk[PT($#FLZ."lP]?ap?J(#fUf-LI7N'Hp=p8[Y?^C&CK,5_`6^V_iR,G<gT0$grS(0G06QBpMRBJY:07.!2>ti3lu;T8S$=)r~>endstream
endobj
21 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2650
>>
stream
Gau0D=`56N%Y"/Ui7fYGeEZoJbRU<@82Q.i=Oo#]$NW`-<L,/iLYU]`@/p(3*IVYO.`[09ilIr?=W^clMkgM'rjB]EE=22T!L!m!//o?8@(D""M_B%`3-dP^4!EL;$t"F$GRj`Uqk(ir1K[8\*Ekqr7Njao@9R!q_'4T\=0cEU!Uh$[^;Jqn(O(K:eP*\oplQS"&GUfb7=KmiESI]k>ZbCd&cW"C2[E^0g,m]8Z,7*?>hEG065c7[*X)b<Z8fs7`F-_9!\;D9V.E6LCXtl$`=eP-kkg[(TY(aM1_bb01JWYYY3-XnMu&K?*sr>cmn\n`o0XTO'DZNR,2O`P4ZBCOr34*"\;I75fPqFa*3k,]j7jWIgYN_tY1&-(2%;%k0joOhg>\RHG;QAkNbLORQ>IO8k_0K9"\WP)>ZN[Eb^a(1f14lN$tl@[pS=T*E`:?9(p0PM9iFTNZ^AJ24JT,)0J>@1?0M>>:(A*LRO1joA4NX>=JfL96c"'>kB&o$>0O.pn'5P:aZ',R^:YGXSP=RFR@X:?8I8&"@#_P/(XeVE1.SN!.3uY>WQNZZ9LJulMsL37h>?:qSOjc1X9nH_[,CqC!]Z@T,[ZlV!D5(4?<2>;=7@lu?]K0&6Y'>keQ1+M;/2ZLZGI`f8AVl.mKsl=%7A/S^_D=^.V05j`[p9h><Mj,<W&dMW+)8CnA+M$?n"BOQ<T=C1=l9+CTH4J]'^+PKpZC*9p4*VXc>#I6IDW`oe[ZR4N2<L+r*PL/H;sp%@n8b/arN$2IS7t5[5G_j[`NeLojNOEM"tu"2*]-ORCif8TaqbpfI8<LMdMo^YQbqj[I0G3\(2Y2`#Tj=^NYQcAP\NXW(FD%iZ5#HC)g=o,mc!rPSt-rgSBu_>!:dP9/1261SRg[pmPIDOA,@bmn0;A3^]U&@u)UXLBnLGBRq*D`BlqVrk378(sOVeh#Omm*Ll,iqpuphS7`jc'k!L[s[]bJ'AH9G\I)Fi!C('_4MVZXm$gOk4'g\O05CD[_n=&jML@<7l%jI&WP]VJW956ZE<\^<%O?]Z\W+R%;ADc>FsAY)(41%m7<*pKQ`L\<!>i2I`2qU;+S.<iLXVE3+9hhE?g(sf;C2L8;;u#H$2[;'o#P=3@RuQG:KnamgPS'=]n3Em]@ZBdY+I0GX3^Cn5_s>)u97HK9\i?g)r5Rn;-<2cg\i`cnIS(>jg!,cTXC6j1KhOKOGQEk8cV@mE#<_79+$:D,kDQC0TVU22ObiR:bLpk!5h^i>end>Spu-oI[&;'<94;VQ7_nB3u++ql/OG9cOEJYN(A$ei+qK*7`l?=X"]j[Ef?]WW7+2m>jt[C^(Q?IQY#SkNr-+D1ZD5(^ru7X6*4p(dB[54$;WkYV,._$UNC:?^sGrgWOg1T5X`=_W]J3fq^d);8]#V@D'Ihh8jK;ZfaZHZon)T0=&6Sa;W[bq^g;P\eX2J/\kjlA-./&2gNF2H0jY[f-GVaff#])2>FtT6u^;(]Ir]r*9.mE=k?Z=Sa79>BUer2?f>fF2FbI'O6r7T\oRGTr0SnH7Z5Z0Ca1.])iT/G7)&DFMgX.DaT%$X+(76bF_f!hidt`De<,E(5>;Dk@4seTQ(LG2PsArm#ML]KZO\oMReFmDbbK$:TJ=>C8@HFr%@3[Wh7#pu%r0gbC`h/",M@qaC>*T9K7eL7,^TbRm7VWZEeB[S^VTCk([12Rc[F:8*S0e+]C#gBmAFmS*FG,_1)u'aGfM[(%s(!1]jR=\QH44h,7(AO0Xc"A_*i)8[V"?8A.d.BHLh?2,+Mpj@/B'GqJLJ!3^:AA^KTio.`Tq-7Wot#V:^#&fE0/Hfq^gr&LkkP:ZnXJVt9#k0aF2a#)qQPg!VGmbL\iOj`3'rD!N:u5%<QdUP/nL:#$ksoS@7RBu3CiM@"PB@)6+<U3@/O+PYCdHiG*.9H>rYh191MH>nc-HG/H`p0Ol-**piBc]>HOSodlsn;@<)>'u5$ilh":YoJH>Ccsb]DRn.e!YCtYnJU>k@2&4bdNM1e@GL'c.:R!Jq\Ak)(oAK*J&RqoDs)`iFHBu)!Q]!ZO-H_ZnH!;C)hTh=l)NidHClPe,FK=FUAj3HqFZN>OkqiG\@*/kVEioN@.C\akQb](!Ec&ffgersm9$XDJZJ%?!rn#N".`5'`gXm+1TgfF78Y@?9?Gq=,A/3u07EP&qss<*LCbQ,(8#&]L%/p+cR9UJ&K5So>j8)$UB@1qRgA]Zc;sP\^<4f%.0FH@(LICJ=e1E/6]hlfKj.f>^;4B-SfX8.3mVt+aYsndn/tCA*8#5ILnnOX/>]O9(AU0*9_!KG!Ec*XT#Lgh5`@YPh;HMj`YA@<"aEJ)<D;5ZluEg'BhHAkh-fPQdFBp7cYfk^`TOqr),O#LBFB7NG?!R70=ng"-pQ!@c1I8h4X<a9<f1U=q\HFFm[o7VbJ+M626aribu\>CXm-*l2!L_80"9XseE[@6]E%=o*WpV)o%AJ5Gk/DkEF=IB>IZ9j/t4Gj0?i["RD!#X?e/Go#O?3rA[CLmo\mu*bO*.(b@\rc_;NLKRN(p-n7Ap@oS:OSB*R_qB>@g]K==4]ag'Zi*;>62<XU/=Ta4j6ILh(g6?%8Wgl<EOAuLd"lp*V/Qo3KRp3LT1Q[U>pc1RgfQa:9`q$VSd-$K~>endstream
endobj
22 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1676
>>
stream
Gau`ThfIL2&:Vr4Z#>tP0#j'(ac@^@/N4h!i8!,4A;3rH+mKo6b(ed7YJ6>R)jA*\d[(>5CpDFRgKf*>%g]]?,3JoG")s[Di"H,X+aF.K4$FoK0M9X)T32QNL=ch"qbH)6+*`36CV>7#n!"E>q(VutG?bbK!@/UDTCTD63.r@rq$+r2mHtHCWY\+,_nqN1&GhMVi\3iP`iNP25LZr$$@^nnJWG.a:^Th1e&O6l%RLpTRMaYf+^QB3pi3Wt\>N@JH"QCZ*/@t+?f1ke^[u'$Y";TXBjI1[e;W,([*g%)`6>$)_e$DMo-H&$T!e.GRS9qT5nK5lc(Do,eX,7]iLC+g_hZ^!W960<6k1/1H2.l]..VI]k-,II4NUQQ,<"e#%hZaR=nsWD8uSHhS=P'!p%EG^qCCerAS6KIY$j)WN*rd+bDS3u2PMVpH7e_@A<C4Fccd%1\`s3.k;S5B0(jQIB,=:B9eOH;?"a:S-7Y,b?ALcrG'5XTrHip]+I6=VFp3F`J8Ad%*k]:BU?#EQ,u\,$-/Ng=o\;@I`JU"V#H;!K4\S<O<i?774e%dcO=P2UBi0"^WfC\WQh!`A-B>buDsWRo-;tmp[[6homCMAo;oDk(f_J.BNj.g*p':SF4*)6<^U)7!fReU?-K)b\G.\OOpeK9M[%S?Wm-_-pD;NiB3`bIifJ\l0.Vsnm$]\s(2SGCW8$rA%?-@bng-C_mVtKb:7TJ_2L1YCT7ql(:>2,N4D8.m1"+>jP$"Ji&6B@Nr9ckAh6KAqdo8=8i_K@.EiZdb$E+l=k_!rZ3HUJ[Ha_hqWN$5I:%>Ie#\Esa:g`BCk$Q#Bu:kMOX89!eZfM!9P8_mKUY_NV=Wr+Rd)gTAr-[F2P=ZanR*^.c(4g]=",EEh:B#Gb*V`F=hmqG8h8SWaL()k5J8*O6&F=smThfL[n6=%WA!Gc:SnQit*U%fk<?q2;)PnX%^KP$:V`3&k(T_t8MP%4p6lbSIrXP!)<=5U&EgNDPUSW96k__)/"q/eU,?4<al`UN/i-O/mILqL"9ecPH5re;KAN?b_l#]/%=O,[!SMHT(E-H\)F*N<bod0;tp*/n0I?O/OAU)`ukX"'cRY*:KGH+">g.=7egq-5>s_@^l?)>EaMCbQmTkH3t;DBhc6UrO;2T5C`'TcB$^WJAqM%G,2_B@iKSiK\:Ik_QR'60i3dQXSD]Cu+6qf@aj-&b>=4LWG0IQtKMm.$dJkj\G?;kcNH;oE],kgnbOfD#@emYSBdR"f^>8h%+/9>NF,Xid*`QOU7ci<T(GjgAV3%6sQdC9,"GeeTmAT.>&V+8mlpO,Y+4=GGbRX6FH5bTjD(Y^3lJ!^ZuhEBldda/R\Bj:+r3@]u6/QjPTdh%29F8^+AB[7u!u25Bs_d2Sf'C'@U#UXe)1g*V2nk;(:ZFBuqGcRYnRCa^,4O"#20I]>KC)'hXpiH9j6q5(hM"["RUM&\hVVr'Y4?8<`d`SGuZq&?dR?;(VC5V-&,S9"@eMoq5Bkp]5iZB&/'X&!4k8/LMII_J&!88:IbFn!5K<O+N2lh:n]2ZM*b.8<3!J0C&DtO%HHaN;`%7+tFZVAP<1t<cEM4hA:=lXP5'aO=5^]I'^f<XQI'"l6kCO4:gUj=[)9qZ+4`V\fL<M?u6DrWhkoZTi.48?Sl"-Df6@(SG`[WIhgT~>endstream
endobj
xref
0 23
0000000000 65535 f
0000000073 00000 n
0000000154 00000 n
0000000261 00000 n
0000000373 00000 n
0000000578 00000 n
0000000683 00000 n
0000000760 00000 n
0000000965 00000 n
0000001080 00000 n
0000001163 00000 n
0000001369 00000 n
0000001575 00000 n
0000001781 00000 n
0000001987 00000 n
0000002057 00000 n
0000002387 00000 n
0000002481 00000 n
0000003141 00000 n
0000005285 00000 n
0000007507 00000 n
0000010221 00000 n
0000012963 00000 n
trailer
<<
/ID
[<f6dac22a545ee459e853ec7e4ce080b3><f6dac22a545ee459e853ec7e4ce080b3>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
/Info 15 0 R
/Root 14 0 R
/Size 23
>>
startxref
14731
%%EOF
+25
View File
@@ -0,0 +1,25 @@
services:
connettore:
build: .
image: connettore_metadataexporter:latest
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080" # esposto solo su loopback; rimuovere 127.0.0.1: per esposizione esterna
env_file:
- .env
volumes:
- quarantine:/quarantine # volume condiviso con rqwatch
networks:
- rqwatch_net
volumes:
quarantine:
driver: local
driver_opts:
type: none
o: bind
device: /quarantine # path sul host; modificare se necessario
networks:
rqwatch_net:
driver: bridge
+501
View File
@@ -0,0 +1,501 @@
#!/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()
+446
View File
@@ -0,0 +1,446 @@
#!/usr/bin/env python3
"""
connettore_metadataexporter
HTTP receiver for Rspamd metadata_exporter, compatibile con il database rqwatch.
Endpoints:
POST /api/metadata_importer_multipart — formatter = "multipart" (raccomandata)
POST /api/metadata_importer — formatter = "default" + meta_headers
"""
import email as _email
import email.header
import email.policy
import json
import logging
import os
import re
import secrets
import uuid
from datetime import date
from pathlib import Path
from typing import Optional
import pymysql
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import PlainTextResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from pymysql.cursors import DictCursor
load_dotenv()
# ─── Configurazione ───────────────────────────────────────────────────────────
API_USER: str = os.getenv("RSPAMD_API_USER", "rspamd")
API_PASS: str = os.getenv("RSPAMD_API_PASS", "")
API_ACL: set[str] = {ip.strip() for ip in os.getenv("RSPAMD_API_ACL", "127.0.0.1").split(",")}
QUARANTINE_DIR: str = os.getenv("QUARANTINE_DIR", "/quarantine")
SERVER_ALIAS: str = os.getenv("MY_API_SERVER_ALIAS", "mx1")
DB_HOST: str = os.getenv("DB_HOST", "127.0.0.1")
DB_PORT: int = int(os.getenv("DB_PORT", "3306"))
DB_NAME: str = os.getenv("DB_NAME", "rqwatch")
DB_USER: str = os.getenv("DB_USER", "rqwatch")
DB_PASS: str = os.getenv("DB_PASS", "")
MAILLOGS_TABLE: str = os.getenv("MAILLOGS_TABLE", "mail_logs")
MAIL_RECIPIENTS_TABLE: str = os.getenv("MAIL_RECIPIENTS_TABLE", "mail_log_recipients")
_store_flags: dict[str, str] = {
"no action": os.getenv("STORE_NO_ACTION", "false"),
"add header": os.getenv("STORE_ADD_HEADER", "true"),
"rewrite subject": os.getenv("STORE_REWRITE_SUBJECT", "true"),
"greylist": os.getenv("STORE_GREYLIST", "false"),
"discard": os.getenv("STORE_DISCARD", "true"),
"reject": os.getenv("STORE_REJECT", "true"),
}
STORE_ACTIONS: set[str] = {
k for k, v in _store_flags.items()
if v.strip().lower() in ("1", "true", "yes")
}
# Limiti di campo compatibili con rqwatch MailLog::FIELD_LIMITS
FIELD_LIMITS: dict[str, int] = {
"qid": 30, "server": 10, "subject": 1024, "action": 20,
"ip": 50, "mail_from": 255, "mime_from": 255,
"rcpt_to": 1024, "mime_to": 1024, "mail_location": 255, "message_id": 1024,
}
# ─── Logging ──────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [connettore] %(message)s",
)
log = logging.getLogger("connettore")
# ─── App ──────────────────────────────────────────────────────────────────────
app = FastAPI(docs_url=None, redoc_url=None)
_security = HTTPBasic()
# ─── Helper DB ────────────────────────────────────────────────────────────────
def _db() -> pymysql.Connection:
return pymysql.connect(
host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASS,
database=DB_NAME, charset="utf8mb4", cursorclass=DictCursor,
autocommit=False,
)
def _insert(data: dict, recipients: list[str]) -> int:
conn = _db()
try:
with conn.cursor() as cur:
cols = ", ".join(f"`{k}`" for k in data)
ph = ", ".join(["%s"] * len(data))
cur.execute(
f"INSERT INTO `{MAILLOGS_TABLE}` ({cols}) VALUES ({ph})",
list(data.values()),
)
db_id: int = cur.lastrowid
if db_id and recipients:
unique = list({r.lower().strip() for r in recipients if r.strip()})
cur.executemany(
f"INSERT INTO `{MAIL_RECIPIENTS_TABLE}` "
"(mail_log_id, recipient_email) VALUES (%s, %s)",
[(db_id, r) for r in unique],
)
conn.commit()
return db_id
except Exception:
conn.rollback()
raise
finally:
conn.close()
# ─── Helper email ─────────────────────────────────────────────────────────────
def _decode_header(value: str) -> str:
try:
parts = _email.header.decode_header(value)
out = []
for raw, charset in parts:
if isinstance(raw, bytes):
out.append(raw.decode(charset or "utf-8", errors="replace"))
else:
out.append(raw)
return "".join(out)
except Exception:
return value
def _parse_mime(raw: bytes) -> dict:
try:
msg = _email.message_from_bytes(raw, policy=_email.policy.compat32)
def hdr(name: str) -> str:
v = msg.get(name, "")
return _decode_header(str(v)) if v else ""
raw_lines = [f"{k}: {v}" for k, v in msg.items()]
headers_raw = "\r\n".join(raw_lines).encode("utf-8", "ignore").decode("utf-8")
return {
"mime_from": hdr("From"),
"mime_to": hdr("To")[:1024],
"mime_subject": hdr("Subject"),
"message_id": hdr("Message-ID"),
"headers_raw": headers_raw,
}
except Exception as exc:
log.warning(f"MIME parse error: {exc}")
return {
"mime_from": "", "mime_to": "", "mime_subject": "",
"message_id": "", "headers_raw": "",
}
# ─── Helper quarantena ────────────────────────────────────────────────────────
def _store_email(qid: str, raw: bytes) -> Optional[str]:
q = Path(QUARANTINE_DIR)
if not q.is_dir() or not os.access(str(q), os.W_OK):
log.error(f"Quarantine dir non accessibile: {QUARANTINE_DIR}")
return None
today = date.today().isoformat()
safe = qid if re.match(r"^[a-zA-Z0-9]+$", qid) else ""
subdir = safe if safe and safe != "unknown" else f"unknown/{uuid.uuid4().hex}"
mail_dir = q / today / subdir
mail_dir.mkdir(parents=True, exist_ok=True)
dest = mail_dir / "mail.eml"
try:
dest.write_bytes(raw)
return str(dest)
except Exception as exc:
log.error(f"Scrittura fallita {dest}: {exc}")
return None
def _has_virus(symbols) -> bool:
if isinstance(symbols, dict):
return any(
isinstance(v, dict) and v.get("group") == "antivirus"
for v in symbols.values()
)
if isinstance(symbols, list):
return any(
isinstance(s, dict) and s.get("group") == "antivirus"
for s in symbols
)
return False
def _trim_fields(data: dict) -> dict:
for field, limit in FIELD_LIMITS.items():
v = data.get(field)
if isinstance(v, str) and len(v) > limit:
log.warning(f"Campo '{field}' troncato a {limit} caratteri")
data[field] = v[:limit]
return data
def _sanitize_server(s: str) -> str:
return re.sub(r"[^a-zA-Z0-9.\-]", "", s)[:10]
# ─── Logica principale ────────────────────────────────────────────────────────
def _process(
*,
qid: str,
server: str,
subject: str,
score: float,
action: str,
symbols_json: str,
fuzzy_json: str,
ip: str,
mail_from: str,
rcpt_list: list[str],
size: int,
raw_email: bytes,
) -> int:
try:
symbols = json.loads(symbols_json)
except Exception:
symbols = {}
virus = _has_virus(symbols)
mail_stored, mail_location = 0, None
if action in STORE_ACTIONS or virus:
mail_location = _store_email(qid, raw_email)
if mail_location:
mail_stored = 1
log.info(f"{qid} in quarantena: {mail_location}")
else:
log.error(f"{qid} salvataggio quarantena fallito")
if not mail_from:
mail_from = "empty-mail-from@localhost"
mime = _parse_mime(raw_email)
data = {
"qid": qid,
"server": _sanitize_server(server),
"subject": mime["mime_subject"] or subject or "",
"score": round(score, 2),
"action": action,
"symbols": symbols_json or "[]",
"has_virus": 1 if virus else 0,
"fuzzy_hashes": fuzzy_json or "[]",
"ip": ip or "",
"mail_from": (mail_from or "").lower(),
"mime_from": mime["mime_from"],
"rcpt_to": "unknown" if not rcpt_list else ", ".join(r.lower() for r in rcpt_list),
"mime_to": mime["mime_to"],
"mail_stored": mail_stored,
"mail_location": mail_location,
"size": size,
"headers": mime["headers_raw"],
"message_id": mime["message_id"],
}
data = _trim_fields(data)
return _insert(data, rcpt_list)
# ─── Dipendenza: ACL + autenticazione ─────────────────────────────────────────
async def _auth(
request: Request,
credentials: HTTPBasicCredentials = Depends(_security),
) -> None:
client_ip = request.client.host if request.client else ""
if client_ip not in API_ACL:
log.warning(f"Richiesta da {client_ip} rifiutata (non in RSPAMD_API_ACL)")
raise HTTPException(status_code=403, detail="Forbidden")
ok = (
secrets.compare_digest(credentials.username.encode(), API_USER.encode())
and secrets.compare_digest(credentials.password.encode(), API_PASS.encode())
)
if not ok:
raise HTTPException(
status_code=401,
detail="Unauthorized",
headers={"WWW-Authenticate": 'Basic realm="rqwatch-api"'},
)
# ─── Endpoint: multipart/form-data (formatter = "multipart") ─────────────────
@app.post("/api/metadata_importer_multipart", response_class=PlainTextResponse)
async def metadata_importer_multipart(
request: Request,
metadata: str = Form(...),
message: UploadFile = File(...),
_: None = Depends(_auth),
) -> str:
try:
meta: dict = json.loads(metadata)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"metadata JSON non valido: {exc}")
raw_email = await message.read()
if not raw_email:
raise HTTPException(status_code=400, detail="File message vuoto")
qid = str(meta.get("qid") or "unknown")
if not re.match(r"^[a-zA-Z0-9]+$", qid):
qid = "unknown"
score = float(meta.get("score") or 0.0)
action = str(meta.get("action") or "")
server = request.query_params.get("server", SERVER_ALIAS)
if not qid and not score and not action:
raise HTTPException(status_code=400, detail="qid, score e action mancanti")
rcpt = meta.get("rcpt", [])
if isinstance(rcpt, str) and rcpt not in ("", "unknown"):
rcpt = [rcpt]
elif not isinstance(rcpt, list):
rcpt = []
rcpt = [r.lower().strip() for r in rcpt if isinstance(r, str) and r.strip()]
fuzzy = meta.get("fuzzy")
if isinstance(fuzzy, list):
fuzzy_json = json.dumps(fuzzy, ensure_ascii=False)
elif fuzzy in (None, "", "unknown"):
fuzzy_json = "[]"
else:
fuzzy_json = str(fuzzy)
symbols = meta.get("symbols", {})
if isinstance(symbols, (dict, list)):
symbols_json = json.dumps(symbols, ensure_ascii=False)
else:
symbols_json = str(symbols) if symbols else "[]"
try:
db_id = _process(
qid=qid,
server=server,
subject=str(meta.get("subject") or ""),
score=score,
action=action,
symbols_json=symbols_json,
fuzzy_json=fuzzy_json,
ip=str(meta.get("ip") or ""),
mail_from=str(meta.get("from") or ""),
rcpt_list=rcpt,
size=int(meta.get("size") or 0),
raw_email=raw_email,
)
except Exception as exc:
log.error(f"{qid} errore DB: {exc}")
raise HTTPException(status_code=500, detail="Errore database")
log.info(f"{qid} score:{score:.2f} action:'{action}' salvato [id:{db_id}]")
return "Message saved"
# ─── Endpoint: raw body + X-Rspamd-* headers (formatter = "default") ─────────
@app.post("/api/metadata_importer", response_class=PlainTextResponse)
async def metadata_importer(
request: Request,
_: None = Depends(_auth),
) -> str:
raw_email = await request.body()
if not raw_email:
raise HTTPException(status_code=400, detail="Body vuoto")
h = request.headers
qid = h.get("x-rspamd-qid", "unknown")
if qid and not re.match(r"^[a-zA-Z0-9]+$", qid):
qid = "unknown"
action = h.get("x-rspamd-action", "")
server = request.query_params.get("server", SERVER_ALIAS)
try:
score = float(h.get("x-rspamd-score") or "0")
except ValueError:
score = 0.0
try:
size = int(h.get("x-rspamd-size") or "0")
except ValueError:
size = 0
symbols_raw = h.get("x-rspamd-symbols", "[]")
fuzzy_raw = h.get("x-rspamd-fuzzy", "[]")
fuzzy_json = "[]" if fuzzy_raw in ("unknown", "", None) else fuzzy_raw
rcpt_raw = h.get("x-rspamd-rcpt", "[]")
try:
rcpt = json.loads(rcpt_raw) if rcpt_raw not in ("", "unknown") else []
if not isinstance(rcpt, list):
rcpt = [str(rcpt)] if rcpt else []
except Exception:
rcpt = []
rcpt = [r.lower().strip() for r in rcpt if isinstance(r, str) and r.strip()]
try:
db_id = _process(
qid=qid,
server=server,
subject=h.get("x-rspamd-subject", ""),
score=score,
action=action,
symbols_json=symbols_raw or "[]",
fuzzy_json=fuzzy_json,
ip=h.get("x-rspamd-ip", ""),
mail_from=h.get("x-rspamd-from", ""),
rcpt_list=rcpt,
size=size,
raw_email=raw_email,
)
except Exception as exc:
log.error(f"{qid} errore DB: {exc}")
raise HTTPException(status_code=500, detail="Errore database")
log.info(f"{qid} score:{score:.2f} action:'{action}' salvato [id:{db_id}]")
return "Message saved"
# ─── Entry point ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host=os.getenv("LISTEN_HOST", "127.0.0.1"),
port=int(os.getenv("LISTEN_PORT", "8080")),
reload=False,
)
+5
View File
@@ -0,0 +1,5 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
python-multipart>=0.0.9
pymysql>=1.1.0
python-dotenv>=1.0.0
+26
View File
@@ -0,0 +1,26 @@
# /etc/rspamd/local.d/metadata_exporter.conf
#
# Configurazione per inviare i messaggi al connettore_metadataexporter.
# Il connettore deve essere in ascolto su LISTEN_HOST:LISTEN_PORT.
#
# Usare l'endpoint "multipart" (formatter = "multipart") è la modalità
# raccomandata: invia metadata JSON + email raw come multipart/form-data.
#
# In alternativa, usare "metadata_importer" con formatter = "default"
# e meta_headers = true (sconsigliato, meta_headers è deprecato in Rspamd ≥ 3.14.2).
rules {
CONNETTORE {
backend = "http";
url = "http://127.0.0.1:8080/api/metadata_importer_multipart?server=mx1";
# user e password devono corrispondere a RSPAMD_API_USER / RSPAMD_API_PASS in .env
user = "rspamd";
password = "cambia-questa-password";
# Salva tutti i messaggi (clean + spam). Filtra per azione in .env STORE_*.
# Alternativa: "is_spam" per soli reject/add-header, "is_reject" per soli reject.
selector = "default";
formatter = "multipart";
timeout = 5;
}
}