commit 0adf0526e77a25c786a3f68e20160c08a1d59873 Author: Fiorenza Date: Tue Sep 22 15:51:56 2026 +0200 Codice iniziale diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8b6cf4b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.env +__pycache__/ +*.pyc +*.pdf +genera_pdf.py +*.md +.git/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7bab1cb --- /dev/null +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..43e9601 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/__pycache__/main.cpython-313.pyc b/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..cf59a3d Binary files /dev/null and b/__pycache__/main.cpython-313.pyc differ diff --git a/connettore_metadataexporter.pdf b/connettore_metadataexporter.pdf new file mode 100644 index 0000000..f124c74 --- /dev/null +++ b/connettore_metadataexporter.pdf @@ -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!Yeo^[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/'8A1Nsendstream +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$2Q8i2Al8s<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@<$RYq=fc$f09+,a&MOMSuk#^PhOdEI(oq@rh'8e*,,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`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[;$=u4+^e2,9d*2tVC+$+XZ6VYKrT7Z#Cicq?:JmUHY4*FF!:X7a]60I6FN^S/i6*2uM^?-FF!mU4N)sEhQGQnm(iaa/nRuV8<\:/9?";YXE7p&#R[fhr(kED(lE7C8+dQ82F'Kc*>LJMp7aC.mHGZKZ%L1VO)6[mW,I!ne]tbX6&).G!Mu1]=_[:b]J!)p16XA[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&XYKf1,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_jNr*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'h':=sVe&'e($(1QMAhnMZ8Rq&INQO.&V(Bl7Oq2I*Kb/,!>prEM2XlGpY0)BOQ'-q_o5V+eUaKuB2Z[!>i4'2=KBf2L.pl4Y?RWB?t0/"%#s83*.$ldpt.`s]*36.0BYs#d#cdg$Foq5C8BK(Pm>nc.p&n'GE"?g78`.'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;NTNcsqPa4gsN,Ps%7@DJ0-fT7_Y[=ImPZ9S?1l0Q(WgZQS$Ilr(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!rOX$*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$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`iJCl]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./?.A?lf-gR&@)kn%Zff4D^-9k10>[l-4jSIo;nUrBb.C+UdfFp!bWt^Np_:/9&fI39$YcUlu(lYjP@2/Zolo2f#>r-,9?Y+m2EAHs^GqchW61!(!$\_(Ar!3jb"o#H5;B?V%FC9&[EqH&RS/d#W61Ci=PRm!V^.Jm+lQ77W[`Q&4%4NT)J31ium=7:IT3Xo8nCJQg.n-QH0&"]*J.'uLCIoU)5gD+J=W;2952a?QXeL^['QM8P*@D;%FWH2'dWg!8=\u.?mD7"r=1D8;be`Qc3A"ddi-TDaU:qAfonU@(C=mT)\O\C6Pde(>D[G\EW*=5uLD#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"Dq22O358ThZ4%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[],42J!?``]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,Gti3lu;T8S$=)r~>endstream +endobj +21 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 2650 +>> +stream +Gau0D=`56N%Y"/Ui7fYGeEZoJbRU<@82Q.i=Oo#]$NW`-ZbCd&cW"C2[E^0g,m]8Z,7*?>hEG065c7[*X)bcmn\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>#I6IDW`oe[ZR4N2!: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.)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%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)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*;>62pc1RgfQa: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_@AbuDsWRo-;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%fkg.=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&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!5Kendstream +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 +[] +% ReportLab generated PDF document -- digest (http://www.reportlab.com) + +/Info 15 0 R +/Root 14 0 R +/Size 23 +>> +startxref +14731 +%%EOF diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e13ccf4 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/genera_pdf.py b/genera_pdf.py new file mode 100644 index 0000000..ec7c779 --- /dev/null +++ b/genera_pdf.py @@ -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("&", "&").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() diff --git a/main.py b/main.py new file mode 100644 index 0000000..4a1995c --- /dev/null +++ b/main.py @@ -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, + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9b075c6 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/rspamd_metadata_exporter.conf b/rspamd_metadata_exporter.conf new file mode 100644 index 0000000..a4a712a --- /dev/null +++ b/rspamd_metadata_exporter.conf @@ -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; + } +}