Codice iniziale
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pdf
|
||||
genera_pdf.py
|
||||
*.md
|
||||
.git/
|
||||
@@ -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
@@ -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.
@@ -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
|
||||
@@ -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
@@ -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"<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/<uuid>/</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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user