From f15fc6ee0ceac17a6f86b1404d0c2a8527e6547d Mon Sep 17 00:00:00 2001 From: ah-admin Date: Sun, 4 Oct 2026 14:17:12 +0200 Subject: [PATCH] v1.22: Verwaltung sendet Direkt-E-Mails an Mieter mit CC an die Verwaltung --- UAT-Featureliste Mieterportal.xlsx | Bin 0 -> 11548 bytes ...atureliste Mieterportal_recalc_verify.xlsx | Bin 0 -> 8055 bytes kimi.md | 21 +++ .../api/__pycache__/server.cpython-312.pyc | Bin 12158 -> 13945 bytes mail-server/api/server.py | 21 ++- make-uat-liste.py | 164 ++++++++++++++++++ server/src/index.js | 50 ++++++ server/src/mail.js | 17 +- src/pages/BenutzerVerwaltung.tsx | 124 ++++++++++++- src/store/store.tsx | 21 +++ 10 files changed, 410 insertions(+), 8 deletions(-) create mode 100644 UAT-Featureliste Mieterportal.xlsx create mode 100644 UAT-Featureliste Mieterportal_recalc_verify.xlsx create mode 100644 make-uat-liste.py diff --git a/UAT-Featureliste Mieterportal.xlsx b/UAT-Featureliste Mieterportal.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..c3a7849313cf65e5aeeede68b098b26b0cd331ea GIT binary patch literal 11548 zcmd^lXH-*Nw{GZ2k&bkfA}C0&N|BBrU3yiBfI=vtLujHPMLBy z0@4ZPqVM_6`{{YPCtDl9Pxw@mBJ(&Bsy*-zkt&K{b3VtIG0rnhB)1@*lm?7h)taxy8T_2b8 z+lqU(0jJ00YQTULWR?w{-6NEbUtIP~&dXf}X&`Z)}Wt({y;ms~)TpA!N#; zF&tQzAzED&TFEP&c4rn?Fr1oO>c6?bWSFIgyfU2w9RMKtcNfg<9DzT5==s>E)XYO1 zXe#Gum%YA$mpT=)@I=a9YM1ik#EjWqphd<^rEplSu`FYQ`AhGU71~%;tW>Y&)in%x zzSzRMkE&RnjTd8R5Cfjj_jf*94e07g3fIx}ZWYJOkyv6#wT&0lW65EC66>d}7nF}I zBfX!-!l>p}KDT)x!uRJNyFUrd5BPuVJ*e3L$E z@hQKeYx6MrWHjs7G&QuStY68;73qYxS7d=nj{zEY|AE-p_C__FR#=Zw*yXNxbX8Yb z%Xy+CYA<$baSe-lde6)kOU>Zli)zO+$|yld|(Pf#9f~W zw?3-WB#2>huA_S86js+n-1oM*UaY+fivHndHo-kLT3g9IJEzOXLL$XOon5rkfS38y zq};$7&?ExsAR}#>_7A~Tb|dc^HJUS|;btD)D4Uwm39zb=bU>652WGBw$N3x47#jQqeUS zVo!%Tr%{{Sn6z&L%aFQJmTui|F@88%7@3tDrlmG?1c4(GtP=6U4Aq8SyUq-Kc;8yk z??wpSRSRq?eBYYa?}V_v%XVJ)I%A#EN?Lvq;Xz7KzWjLTHbBqbWwd(`gWn?w^04=Hw2yTjTJ~0LLSNJwcx@>qJ8Dcty4Svr^TsZU z+3jl2PY#v2kDDQgWyxbM4Hb9^_C>0mM9( z(@@GZ7A?;rm(u>AW{E_k{0Si+nIvw~yVzIibm$p3ct5AUJ1%R@Cz&IIR6=pv;bB2X zd3N#Qx~Y0kCIgO%6KDN}m7?!)FIilVwiewiYnUfcZLyvc&xmiw?BEzjqnX{el_N?*Ke3vhxbq=%| z6!W;iI!P47ql=`aN4KOcmA?~UUAx0j#PT7&ppk?_TH*z@5RGFwrC)_ZPJo#KY3qQS z>R=hknSQ=tl!RIPW>2JZ?`Y&rl8+|BS8aPkB1m8i??ja1x6&N+Fwc`snx1@L^Y40j zUgiC6gx`kq!;!-9Eu|%*yV;WKd;&P>@%lIF19V=cIfFe%)ackJIF>ca);%S2Z1i>D4&3Nu42zYm2N&dP%fn^A?llVbv0?B=?T zJ?(AJilXj~i>5Seo?A&I?{1w=umlvv>px(oj8OI~m13zn3+dds-+W`u!jSbw*Rvs@ za4>1IG%aXd>p zRfaBtN0wqA=__2n&Q{M4#0KyGW3&%!BR zX;wA1%Sk?GVfNq)&YL0)^dFkqX1s>27#tC4UQa{i*6defj7%2?J}ypW0n0%}_gtxM znKVTktz*e7^EzjN-@a?p_h}pcD2BcgFQZWHyU;IaPaz;trNk#yG>^WOrIfZutp#1> zc+#vb0+ZvfAdR7_TU=>#P}Qm;b*Ugi=c}$2&G7*8T4w!#KOnflX&9tWx}ajbHL{wJ zSm?pzavJ0M-R#G?eTM?k@}pJLePfwI@57nJQ&7|+#ixnxEhK18a_G#T=&&ho@hz!u ze~iE;L+gp|kljJ|x5%RNjXlM$|3OfFkD|9gLexi&^UXU|lR;Ke^59|utUI?H7m>)Q zSFWLBr<{Ja0nhj>Ms9V=y#FrF?{d~i|wsfV)|CM#r|G8x|AA;H1EBzZ5OHAQMC ztcj1xJa_$WC!CE=WgO1T5qJ&f8Dv^;kVq=ycrkv&HV-x27cdPce^HS2t)O+1m%!au zZ;v4Lr3gGwE}n*eBqU!n49`w#0~s0^W>$Qzo!ZzEs-_-uM*EG0F=O1_s9`i5|m$kyUSzqI|} z<~?y)Zf?eeL+F&%jDnACUX7>V#Qu5HURT$)OM0c;k=EXq*Lw-ISu>RNC*O=sU5E;} zbWmxw&eUqzdtJ5~qfvP=s3VCoUG4;9(@*6W{qi%Q+6R*`_*$;JmtdR_3VBV zZHz8AV{rM!SCkqNqru+Lvh^qJ>JevMA?M+ZTHPq&-Mz|EYl&x@k#ObnanYi5(2&@V z3ZwI`G9gR8Bw5phm#E!}ulspVK;Cq!hgsI!I0l8-FRV-uYenHi+SQVYYskeeFVcga zCfL65WohC4A;?Qgb`a`E$c34bi3%tlYFB~UR9XFZbLL9xZeA3>P3j(i|q;WKlx^MQFi+d9%Y+<%aDI4yB-7nXEpp6 zX}2XQmr}8sC}(tIZ>eD?XtI{tu?9YHYWIRYFARZJc}xg}Ydn&DJxXg*$JnsU z84ozucr!poftfvhuQ6J?Zk1PCaHqNUys~U{c6+TwXa}+C%6fomGK37T&qhefjb(oD z&Ox>PID}4)Aih>1UEhkrG%M??KX0t1S5t4+q@)SgY%(YrL+DRO2Am!~O1^Z8mwi1m zIxw$j*Ub(RF5t53{=v-LuFBkg$gCJda)#Pg9a}|gb``dDS=ja-+snGH)QRC2?_x>V zj(JdNBJWWwW)|6vu2fX!pdyD-QdE7QrW}HjaO3kcq#DvEYSZW9&LwYx1pHDQ7Q3my za{`Zvan186kpb_|yyQ z7J#~DMk@o`V#Hu8;s;)J7xtC>Gd(jSuGFErmztw1&@alR()*da4{kTzV)x1}YDExL za;eQ2_E#Ds5^6Tn9BI$9YG*Tc`4AF?_z*Eg!NSqgT6qYPo;1e-9`j6VCXe2`3x`+nAZGqtX$4VkCU@ zXpdr&-1aC1)S{9(28XoLapCiHxB}s?;h%atq-)oCnRwG0NHkMC>^cOX@DGPNqF&q^ zhwzD@Eh^8-MYj=JV`R5$&|wIOF(mg0zE>C?vNW7|xLBK(@9kC3XZospj5=y>XU6*Y z$8(p{`L5yh+7GVa58QqGJIj_p=z`d~IcN}6bZT7`Vx?`A_c{1TBFK zQ8-AiG@QZtkAzk9ziuG!vBZwrS#oZ{>D&u+mSk zwi(@Sv}gW6;z#nH1WR6SmCq%9wOY?P1vuvs=Lt^DO*G>3s(&0XFHgse&P>XT7aH6P z+!W{of9oTE3AV52tgBYnR^dtm|FbG`^`7WRAOs!?u`2pS5})B`gCJ45eP3(kx*TYw%Ic$uJ3B%FHpnG z*lOZ0XR{eBK1FrKE;E3CH(a?G**1D)K8NBjLPlMw89(Cv4MNgQ)VG6ks^g1p1nNoy z{vjlkXFAAS)=c(=te?g}H73g21|o?KmE1d(&|hBihcGh#ZUdCj5FYM_?5l&S>4;XF z!P|br?f+KyjGmB`d`HLc&~TD>v<{)Tp^BmGJog{Qn8DTZk!%e|=0Cj?iorxl$Wwuu zd6{^Art8;sBYCwwv0Dh#jh*xHYyEoxkwk<3Z#qcynWQ=P-aLSmbar6<-ocuYKSc5y zGz9<;ufNWJw$UT9In=kYqmKJ*w>DA#KG1348;9r3r{3sZl84v9o1zC-^F_9qz}fN= zjf#=xzeP9e_z~HE$Ga4_YvC(EW;Yy>{RJQS@810uWi^AU(clyp9Ae2LDGqAH2?gZxG}vhhY0xF$x5CeEWTh>IUxr zT!2BZvdHu&P5v_r6(Cm|j$*DQNiYI+MH89V(#Z6w_f0RK_3MWCD#`!ptBU^seca-wUIzeThy**&l+#!{i%@7AnomOhzL)!A8=P8034lYI2g@H62uZ zfy2Z3aDyGsm6|$?}UI06M7$-BN7a#=?=q{yn$<`_n4cZAqXIk&4U+a z!;&_KgI;RAAdAUxV`)|a-6@Wi#G9SCq1e|%%$Ec$l)#LfWORfgssq4Jkkqy2ma+ z9+QX9SHsGJnFLFcH&$g;Rdy~;Y8&$MaZEMId^R>30sdMG1{Fy|J3u})GEA2IY{yK< zScJr{4X65O7tMG@K7jEy=6`Vy95CUuE{mDWvNX$f1|EuNUQMTuI}qO(BD$F*#GC`sodq0-sUs5xL5qLLtF7oJq4`1`y#h zQ`!`m%H0?^j8W2umI!G&3GDV0ILO`X%Hp~|S~iu2?DC*@_SshM&PXX;T&Y4i>#l#j zL`lKor{fJKRMG+~Y$i)Cr#8Pl85UcsSfd4-oc0-OsHBaUx;h@b;uE8&=2u4MF1%uj zpXym!Uam~(ao-+$i)h5+IJ#0B-bPQ>0eYf{oF)3ZrM7lC*7qgnULty?vEI1 zkU1+eMyMW{qAo3NQqQnBB-BCm5lVKie8^sra+HSqaOV!5S$n%qwl;OH!cK+g!RGGX zb=5ZUZHNl@Di8zWx$YJ`;-IO}C%d*#4>6PgE3+M{v7^);5rgIgpL)Zzh_X#S8QZB@ zxd>Ks)%2)nmF0!FW^N-BYV6W1TMp;t_N&LA)p`^lLc%K(USuLbd3huANqv3ZL5s^F zDw$J-l68f;rwisqt~KJLFx#5hFJnFDpge?$X&pC6*w($4dKQw7Fuv5TG<8DhJ*(G0 zSV-bRtjSDGe;Ro!l3$Hb%{w2k*^oibi>Ty5%bwah--cLS=c~EAR(F>O z!ED|Q_aT;r!Ru;GuzDN$64CR1VItI@{Xa_@wXgt;*h*( z7t8uV)bf*$Rqh?1>>;HVVM){DN^V}0Ob*IjOd(32lm^ew7c)pTk9yS>Cx??$;wL$B z++TH!XtQp@$aJzq5riIyw`wKq5Ix0SwV1TIM~6pUIW4*+QXLj8lTXq+9KA;qGT3gt zY5GFU3RMJ6r@K04TF)B9L41L97BGc*dwz4u=ctGH$`O_=V8+K-3`Abvm68rxznA)? z#X0QXo0yaEHl6Y$TW{EX*|_^w!xXsERXSU&K)sr#a}i}d>)_g)?kgX8tR&p<3 zwR|sYBYIwnRZwV$9Nx%(A~k<7z>kNM)F`!ID@a~nM{bgsVlWgzX#W_Wn~Vw|jvH1TQ3_ch7p^Ia50;_zY10U2;=HWNTnWFJaG) zfU)8oP9GI?nT8TJN>i<11D>ZtFg}n^47te$!Cp0B#``Q0LM`33|D%ORWoUqw5?Ybp z$h$Cj5kwWA5&K}sl=DvBYnVULqq~6SnH$QL(fK+Km&+mh?DRe3jdER%tsX}Kpr<+< zq~&iWg1HLxUuF#qKNg|?pwx0o;XG=sF-0L2CV266l)3{i;l5$5=={x^Q7l#glYF=J zwYp@RbeElbjvpNoL?h4kJiBs!w`9)3yqaD%7qc_o_>D@}kw;k@;VZ4>` zUB2PT;g0$Uq29Hm5dfodH>NUl7 zgoNI~EX}Ku!XAb5Je;)7=vn(-HsO^ierEhNf)a}x-0~I<3C@{!$&y$RWtSqQua}@B zDeN4sFj_`)w=`N+%nnW4q84svI;^EVLFLXxkR-d50hAXf?{%~bvLgp=M)s}6IMkmj z6Gkd|M3_2ZJ5h4BABx=f4LB_!@7&uKl9ow5rB9c7Nt=1J*thFXh|MhWRZgGReF-&m z01P{%3#^=9OUNSu?F{R2lcT@c^jz_GK0I{-;wM|Lk3K6S&qrP!%>+xxUdQ31;WQ`Tr$jcC_$$}_-lzrNJU|#ywEvgq)MfN&~Y*2eMcSz>9&%CwGuIE zxRa-sc^-461vzeoZQKP#oY54MYvCa%;Ms#+Bw;)j6QUvVB;giAffI#2@!*Vv@dYLc z$f0>gfYLViei=@D8kO^=j}+{C!@^-9dfo#B7EX-6|-q(L{=Uw4G82D<7E&Ls+g%gPa zw*pw}HN2NCVFWZsOIOGSO>yPHYdv8T<%u=&|3Gu1dj`|9N%XFcxLw7ZEI06;GV06_THSr6FB-3AE$$$c|= zQy(UIh|fQ#~D)M z_+!RzMj32jqH10~6`e97KNvRV(5^qI^Qe9)S^$rx;3#9^0IGi4?);>y`*ENQTb--U zj0d+q@ke8lO0Y`uQm)JAEzNsvIAhZ+0G!OGl#i(xQ3RsPR`^2UL+|9V60UT1>P%OVDgSF{vZs z`5lnRHZ%6>)T65^hBr7J##FtYS%ItRQ7fl}yRW@Uns?9*D6`L2`5(9UGlqFD;9= z&3*hy+1KG~+dLiCIw?7Mqdgk~T_a+#;bc*U{3nXaQ~gVyp9U3uSuz|g;58T#0(Tqi z-+2pc_Z!|$m@KHU9*(_9^>Fm16Js8|N#M$18a}$$i z>q4a*M|eaPlnV)ZovqF4g2XhX5akCtRD7QZ*mmfxi9XA&z|O-O_S9mZBFGllV8^OV z-EYrWP`+I@Y<$;vH85iYV=C8W!1J?dP(U{fO|qWp4D3}%NO{uce{kQ63vYBrh4Emw zhw-3Glcb(6B}8)`*8Og&+;eU~eroC^QTolqmF#UxH)Y}^Jj-wM(z|I}1@7@B3l^0l z8$-6Ej~p}Srq|a8yw_^C_xZ=zLIINJ0t6qd>b|bC1b3M7o=d1MoXnhLaeJIs2M-L0 z5?NxxYLv*0*J4IYC^mAH^yP)6#pwdHDqs7&xCdVvcs_cI{lZ0O>;jifZ|`u-E#G#R z326Aa)mFE3Ye480AX%x#wLt5}J6^t8Op93hP_%cv?A^VWOMO%aZS6cD^E`{t7p?5A zUu-^BotU|7#wwb+sHF!*@H8{gwCP~;QdaT=6dBXD5)#MK7dz?a@7gR<{P3bT6NMB? z^Qi4AHH)>F6OwvP14!f!ZOmN&en7N5`eM6E3_)NUF}i+B)pIqTt17Ce=;c;L13a>6 zWQ}dfjGOoFumo?CModr>`#+hpZf2#znDc!SBzjM5BE0TgV%hm}uGjmIG>!?brE9oK zU$U!;sH+7Hz|;|F1Lo%Xc}@CQ4Qk>ccFz^8T>7bBf!uztKHl2H+oLv>E9X>Bl4g{D}+8gAViDP1_s$siaw_*izzT&CE7 zQ{pif^~=h73(9P8hHjA2SW>3=6t|j`oo~^@rw-EAipu;qcX|eW_>T4n1m%{Mm_HfP zxD72|xXe8nNM+KajF;;eN~6#8p>I2k7+NnfXDahBTsv{FdWQF5+MG4i;YHbnpnnI^ zruU!4O+>PCMrZ)QrFz88w^AG*MLm6aS6Dm(ZX4t>Q7gzrp8Fic=FZTGoqzMz zus>PWT|2?9wMus@@wNK`02>tv8iLm9k+tET{fzSclat2;tjwZl%*FB|0d^7}CKxb4 z{kMI{??Y(S^RhlNW`<`Kcq zJn1E+b5+QJH}A2Ht%?cngD0g^SH4t}VPnXIvL12fN-%{~mNuSYzTFAAUwtO2P}vJ` zT&=Klk%x^_XAY{WXsf5c+0f7MpZoD@dC=0qJx!^%ZDZN_q4_qZr$cxKs%oJ;;N(_v zgd4jvZ%rMINw%RXY@~M5X%>l zI{JQ?9F)`0ovC_Ya(nIg%|JkR^v2d}=Ttwgn#jUN;X6oAp7)GjDJoxn$tq`QH6e;_ ze>X7;_-a+G>%>PHc#_3es4J$R`$k(X`&9rtZsFV^L0qe6Ry$h=j95jx9BWo(chg~x z+suu6Oyf<`t0+y)gBdgAL(_xujT zU@ZlZ_lSOY$MPazwoe)UyRV>ZmO6&3pwqqDFZ`^iGnBCdIRQaVrqA5%fnejGf!7k< zr+SrTt)`is&ae1f=6pwueK|H8@B>DT(^pbn*g4sSPl2Kyr@y#Q8Y3UJgGoeTrpX?4@j*MgcvGCTjVPKx?7oD!<`ulI#cA~Y4?g$~+z8=hK z9!~EH#n0hKHl(#Pv^6Jd30NV9MgSoOgn#tbndI~2< zwm(G^1t&<$x^RBqsw!oLI5X(ulE$qwn*?!+qrCcSjinBhg~E@RGhB8sMvA*rc6GzK zPlXS^owiBL%2-{V<{sraO&;@`5+XZR$kdh5(8&SU*Pvcu`s?F+1@eEbNc~g#&+^Z| zWB~wQw5ETT{#P;RpW=VkNB%87f7SgzR7?J;;LrM)e<`T&W5WHV;2*1H{?zj42J~+& zIQ0Lh<=1xfPc470-~Oe=7wsm~FD-ws=Kd7@bI$l%bcFS{8RSpFxIZ}fZ$aqY|FryH zjQpqYpS*!(jYygh?K+-0@B?njevkicYmXD z|M$v&@3+=Bv!>3Pv-dvd>F0pt?;#Na001;VoUMeeRHvd$0{mA${BZdjV=Q~?S!nSU1U5~P# zg;LX1Crj=M6ReOn4GrsrOT^b`d zq1HVB{S|_V5hd{sTsjjX0D$-JAsE>?n*55PKUP`3mF<3KQ`kyFL;g4wv^U)71%}_f zc3AXmwLx6aO70wtw-_9}j=Ib4dULt$YF~^ac7m5_M9CK0?#x*?Ko4zAArJKf$ef2L z<#Et^*lu5}6Rw&Gih$0=$?yO8pzSNJA{FC@t`!j2Wo%VBghzC=7r_f@4j!E9XUjnl za9)3kz{=0YuZUGpZncDwY!EP^5>oXSAMp%!A{eBgkC$PLvPHE zZ7@4{e5)%7oGe^sD&GZcDNzREX+>JQ8OH>8H%ar{?@h$sidC#j6+~j*xEPR>UR&pL zJJ^0^xE>S|zyHSIE9=ZeJ&bjJQImrfPn7LpLFw)nj64OsDs2luE1zdK0el6 z+K|fqGwb%t>3JcO!HwtS9KN<}kF*|p57-r~C~1c*skO9yu{0Cbu<9Hhk@4~C_KZia zlReHEB|_uC7I>bnHQ?fL+_se&(KGGLWkb5Sy!VE$x_2bpo(ySGxjv&M{m{B8R}{T6 z$70BM>Ug>&CjUdE>Wk46&xOrY^E8Y|@QYC^*SXQC(2nAvH`tAPFM?Z3LOY6voHs4# z=&wqwayN+0#iW)uA^1d9t0JS10b2Gh6MZB167`;*zm6yUN?t!FsGD02_M>W_ExkHQs_)L_ zgrs{uAFzt|wkSd<*s4z*jJtGNU&$Z`AzIQg^jn(K zcc)?N3)DQR;bZmBvpGpxL2qNT$xPg zDXIXA`dx}r+NhM`7Ca^~A;X6}q>fd@zSRze0S4Om9m8*wMk?_xUw-6{6Ef)f*&p+I zU?K(wFIJBi{@;P{XuKwhAU?U2t!xJ^q^k_QR@vrtzh2X;8n2*nPHX0<6X~%Bax1uW z1;QH~T&OuIFVPzUG%Pbe1ak?!r{KUq-m9e$T7|s9;7#oK3@Y(+N4OcD=4~lD_4~r_ z{mgSPZ>RPU2pxg((s-IRkA{QirEI}%7bWb|lU&ajj5HnlyE@fMBijwtBe{gh)zVsq z8bR>NMk&-;hO*KiphE%Nao=si)>~p@1BVAzarIoCTrTPO$`rUtTii_ z@V8hb6POkC%$t-qtCJ6^ zgZIlCErFFpN`u!gi@P$h@RW!#@nwwSALPqrA3RiTTw{`LQ|D`vh9bLV(a8-z~9==x3Zy=97Gm z*|3jQ`;=&)ScuH9({z}DGvCzu>q#5w4CGrOw&^VNH#2%;z{&?ese_z%ixd^XPN|TZ7Riq{ zS`!p!91HyRGtcMm>3t_)7SHu+_6!U2!7DOstSfzS_9RW9(tXeOaq8v7Oz!dAB9_Nk zk#?Rj6P~aV|)&UmOly4zeGKG|LJp3bnB!->n zxX7yT%ow)$F*X-j46+{kyK0`C6(tP8+3OwbvX-`}rLFKLgufS%hvt&72{ZtplMetO z`Llqy+BsS|nVXn6JF))$_`QlGq*^)6@DK;xU`VBRC)rTXv+kL6L@hrbsM#z`HKki% zK#wiUmSI7AS$}mes7kpp$mu!X)t9w)3%)=m6 zO#yp_2}^4`#S7c+JMNr?&#$1xO)2lddJ$nUDI0E8#dCIB+e(z~@fpu{^Bu=mYi(S6 z^dhLe*+;KiUvsGlnWbFg0QoluAK^MC>6dtE+I=|st}yqb$EMip;`Pj_!+XWGO2~mK zUf|NZI?l+^iA?rL2-~#xeC34t(b?3Q{0^A(#g%XdM68qE8EdF%eJ(XKo9p?e8->ef zA0-Qc(mEyHopD|p%dNK$gh+_Y`onF#G;p`9!9{aRW1qHP0JTT8mvIWxepDWGyZ-1f zKPq<+;M5(TF@=6en9*o7lLD{p80KW8NxSTfRq@Pqte@wtaWkT|7!CSA*1)uR^txsw zvW%2ysOp;P(3$tZTAkZmOXDI6k%y%RE3PK{iRUXhJLQd}Be4@$8@r_I z`*q7tyD#=WIS8+ty8JM$w#)M-&t&yK*gGhb7CzlRMqQZOTWF~B*>^^GQfI@fP#~Oj zcs=}03Ymy3^Ch>2X5{3!0@%x1C>y2ZXwUaY=8J`%ACh)DYulGvoamiu^Te7z!q-blnHWR#~rg5+AT61fzVtP`Y zps1kN)ULWt^(`-KHVzdGsVqL9ruYHtSI|vQ*}PU~Dc=7y>6v3!ijrpI`q}&l_h>8E zK$x2hdk(BV^PoZZ6xXxpI%=V^s5n#2a~M*(ZK<-^u8#zHQ@J!QM6$+pVmsf8-%(x~ z%$vJ}y|gR!b2t04%C_2TzkG0d`@{4ePmS*?<${n}zLz`q!9i9g!qwa+{j^u({$Ss$ z??A=3KMit%>|P724qSSi()(#nM2}p)+pkDbw_3iu7*U&7yV|rQZUhT$TudF=Jg*w9 zraVpXMvMLV(PC&ZyQto(K;Fe%*_W@favyzoq;qiV=TNqXw~>a;^7L}=MX~n8#iQmZ zHp^A5@iUl}@)@tCE;qPIaJH#=dIDlfJdjz*Bur)yp*-rHmcd{3A?5onBJ3)CBIi`se zSP|sTwEZfQZ@O{{YuJ(#4L>n-pPG}(6!G2DUJ17t+0CB&#WR=sQp^W-%o6&X7BLS@ zqu*61#6Nh)&+gR4tVP!o-shK$Y@EnUuqsJctS?^ajNam7lKr_Y!d#J{nOm`|*kb#Y zp7DspqvlYa6}oBn?4W?mKdR0rrI}?1PYigr6w<;A9kGS;1lx;%uxO-QswM zpe_j7cS$-o20sdnm$=QJyJ&GGhs3Eiso)4JP&5dGF%ap1I!=&K6j$DEdcg%^0SU`*1ww-je+gtK z*asm32`m0s<+A=*;j+;WL4CKfeJR3jWj`4bXTPOFf-so=5g0Fm+x|;7Tp<2BH2If6 zIMrXE_`j)C_dyuI__A9nXz*_+-Li_~n%uHFu0tbkS#f~z zx9!pc4d%F|`Wy6DC~eGL;>d@ffj?C5eJFZv10)&}=f9;wgfN)>p;8mUUA?8kScj(F zQXv52ZyTF2E7Mu~n-%&Ali$6I8HQ!PNQ2e&Sqe>VON4}-PhrBnqr4qv>x}}m^ zhx*-8p##-!8=DatjB!i#FVOD>Hv)&CZa98BP`=q-m5WQcI|c1Q*Hp9V9H8gDu}J`? zu{GBhG0KFGZ|!{}$bXt+WyQ*Q$@(hP%B<*gf8mdxHbSU&A06rr~9wz{o*EST^ep`#>N$9gch4JVZ|3-LpP>7 zhcoTHH+S|zfE5#omq-9WAo_pZ3vv89QG04!*v;_Vzt+TI1Wkz;e$|V0+q+kGpjE+B zI84h@w!pAS>yR&#ko&RU`)ZpO>@dS860z%Wxm^#VODMNWqJo_AYECQq_g#GBz?x%h z)v3vN3K~^3w0YR|q`)wJlyxHc}q5q|fQq4LFNq zoQnkM!k?9+Uyw4OLY7L!L*hnwaC4D4HcsG|87HcVr*=1JZDPy@Ks+EB% zwb8`q+VBF=%!kmH1T!?z8=g0Ta*F=^2d8@e16C-n!X0boa~38#;@D&C-TU0GnU=2c#Ej;Gc369X}%8+-T?F(uNQY9DQ7@c**5vazv z;NK)uK5P|^q%41p;V!n4&MPv_UEl90xWwjA#cIEsYS49ls2byr(8?i4Y@O;h0#BYlSNFimQn?dR{^QxgX>1H6crfMTu_fdK_#q1Oz4a?koM@O~i)5h8F zyArJL$sQ!<=*H!H645zl?9q7EdLN%HUw_)BhRKkBkqS2H*YnC{St|-+!87FKkt!^1 ziwM06CDf5~m<0xhm~6I8j8VAGg*kXJ7XwkBrTWvYu|zUonRe-sUN35Onvr|jdSRi8 zO_G0m+@Oy=%U%Xi@?na$B(fRB@D4

`NNx-)M;bu(vKPj{Q#Td)J!-+!A0vlw}$8 zu?|>sjqZn8(qWSd<8d2cAxRv2;9`3v2TcE7BGB;4m3vzu<|%sxWCYLX$G&XjYuBxL z=e6GCM`Vvw52{#Gm1c5(KCQwf*KaW$dW4g85ZWP3x$4Hur)aDfO;UB4SudT#Cz!l+ z>Fda9-SbQ(Ir`XZ=i~rj&3TX-$t!u~OYm?#1(YkGKAe-pDT7l_8 zlDj_=G5wM!l5NNG-bn_fLE+A2>zP!g2k6=a5%!hYn;??RnL8cQ@~K0$;*!X$hPj|W zKx>5S8+8gqZEoYf1PY=AIVSwC8bW$Q3nX?%oCJjSP-ez~8@0idkMxFWwwtt{ z==iN-30^+U#o;BQ?=M`G)6`a%R8CZr-hBQ}RPm^oc`xZ$YBdOp5l8C0UT<_XpLb;$ zm}PoC90W=jNtIETJ0Xp6UWis!->^`1h!$1!ohFaSjPe&q_)dtdl+Y6i@wdqRz<}h+ z-jHJ=>s-(#0HplE@-*y&98E|bCj$mXGb~2xJr)}(#?!D}a*qRGkKIC{$8`bh0{id2 zmlRe{_66SO2;lt>6aK01XkzWe`s~+xdTgz2D;t)Gcl%HV8|n~M2&c&&G13`=MmFvg zK6033dQ<6io`(>ff*tnGF7D!m1GTrZ9YRcUvDPpU37HhM#?@;=HaIM&gHR;8^<-wDfxo z!mru0+p&;G%-ZTI0N-WR81`@^+YrBhm0OsvY_8X6Yp3GX4hc~_Z$`&@b$w5$l3F$6 zD-Ko0RxJU3xy2%yJXc~}WK^a=q^5nZ=VY`V^f8;fQW;NOp>jHybghN*>$)R}CFQri z%O_I0iOH=hYmrEjo>3?gOrWnAR=tAlPb92~nsPfmKg;4+%uYL=&`R3Hq*W^$m-_~PUcv#AkUc9?!vHw%A0PoxQ zs~yW6H+CdpkCgU1<(K6>pK_PEOWK#Q68qN=Vj|7|Ai#!rU~C;{wmgh#qDgJO>CX@)!gh&oOFNnxAyo!CHUa# z(9i36ZOP%X=rf_~!}RkDX267Q&i711J7>GMGq!OeIfjh*Nm7wpJ8P%mpS%R5zI=3= zFrp&OKFuOb#O90Skk}AUhJiEt} zvWOY;CA*8Fvn@j%jXZ}tk1ndnzqC<#pFq@cHQGNiv}O?rT7rMxHRz){w~3&_L1YY0 zUzZdZP)S#pk;Y^wcv5mm@?$z46zI59BVnkJ#bgcWddVO5EWf*rAZX8>H&wVx0fFBs zRZPN#xp}Lm;)&;LikE2k?>1=kLYRRU9z*rIqDS&PZ%=4)U3Tw{m-S`tX})DW=RGbz z?-ZIBH@`Xma8l$veafkiz0>`j03?rqNC^1v97qW1G|9BC57v=7u2-yxr=glukkMwAA|;?-zfj=Jnp*QT`K-@<%eG=|J%ZG*YWPu`;Q~_!~duJUlZ|N z=ezU9KhD|kX#^hjf0;$@;@lmR|KLPD`i~EShIaDMhjgY7r1-&p$#nlCgiW71 zJ(~0Fzx!Wy|K0Cjoo`RS>^D5t>var$igx|cUu65pa8Ah{SIkW)KVeT+>bOl$Xh8IZ zf}VcP=Jncka5hh4x04qtId3CRTU#Y(>%pOb+r#r%2#0Oe@ovx)5NupoFoL=Yu7xdycvSu)#vK~cbAQSb%1y@5ce+t=?Cf^$8ZX9(5LHR}F_P{UkR%GU_Bl1#&= z=v(p^LzXhi_!VT#WFo;lt#t^6lMo{+AqRMFGCaFsaL}sVx^9pZSIY*<50p{Ft6X#_ z%J4~9&>~(P@5f_TYwn}C2i8y<&AN?t-AA|HPnP@0uO&69W+bmFEhXVeXJ!$@>q#iD z5E;mo)FQ0`{CLBIR5G9XhQjzDjl4+RbvV6<*}{a;AhkZuAo@F(KBPF#3?hfAL(S<*XxzoW-sYA?ommKGlEEdW3-aT#_rL>bml|! z4gZb8d1ghIdD(fsdD)yf-8I!UvomHcePXVEuyY~fJF|Gc@DKKf_D6=;j)PBjc$VJs zEPS_nq1(HV&Yy1{Pa505Y)qRho>J3}a%}IiT5~Bf78&on`0jFsd6J)cb6hdrGNGlR zC(litqDI=Wj%5-_8%X}#n%-Qi_`Fut>>{gaedr!JmF`k?(FM8ZK1jDH&_42sxeOg9 ze>d+__0VBqt}3%sndGA)_LCFYb?5@QntdG|nX~7Z5$Y#9bMvXwiQF0#A(wNj(3QD| zxpz={2pk3gFok;vM@V136A|*0{JqrqANjvP$%CM_16(B6xDs@Oe8#=Y9iao>^`oaE z!|3U&pzj(-DRfCgP{FP_QdrNDt2Pd$lUbXS^=FYhQw~y*uWW^+s@Oy_igIh-*NExh z50Bp*Z(ql#D!~(68&&ypfYE`BjWL^;m$07L;39ACYoRBYAr>Tb?M5_gLxMO70Tp5+U~s*ONS zh|ta@CgBTjC6B1JjJPFX5mhI2w8(5xHD4qbqna<4i&M?pq5oRMN?4kj8kSH6d6Z|_ z&6moKW$O+_a&hH7D28?6ofL+(8&@JeMf;nG2c)Z|?^+3k>u5i{7pCcb z^|pp#Mo<(@uxFVcqiKYgD>@}z=dTrm2zOG`dyGRNvCbL^c_qE48&)G=H(VfL2+56d z=W#I=B(3X=5EO<6aKo^z%@gGN1?=QQ?n4LP>U6sLLtamRxWTz$^C@)+TPVEvnt5@a zS=e&)%ib^2W(#LWmbSKh+50jKSMYm=G&HPqHRyE`s@#+fXyt4=GO4tY3?(@vgR?|p zEXX4RR^2G3m-BjJ1)CCcu#%>=y=m_O_u*Da74V4OUI&Y>({K{*Djsm5!^rcY0uzSFd1E_tqC^oB)CM?A+1MRv3)wT**< zbKS@+Bc7Qb0qaKB6UyA25Rz135&H%<(ir-`()b;u0o;wBjHP3-qWz9%2HjTQP~X-?ITQ*6 zgkV#kQy#-V2dx|6B)~Yp=+7uR6nFwUAj_tX9Q;F&uL8)|@fb+&0_+1Y0(=kPHo#4Q zTL9ZBNUHwOpnxR}C3twyC-&kx7*%+J=db~cQlYE#hkQY4R2hl*cu56VCn=RS@18)qPef|ke{qcG^k+2=aOWFc>t zH5R95Eaw(4XO%2lvzD!SE4ke5_8aXBMqe%ZcmmuOurf!JWLK$ zR#tgH^n$%6;OP^19}dS0;8&o?2Zh=j3Jmz@O?;F5vU2O_??EAdqtt*_*&Pb?%jA!f z7Wj&L0OZWzI+Kz*<)y&Q;=v;R7{>Hr0iPAzJs~Wd74U!UillJ`LVToOXuwaXF`{qS zPFcFDKw5LX=K%+N~A&PT2kWSq=cWoR=~yavr`z5;QRJiS2?HzQJl gAZ{%s6O}!$>q4jtEf|WQGqSWgK+>vxN;s1L01+=A!vFvP delta 1664 zcmY+DYj6`)6oB{aZkk7vG)>a9X`3c#T1y!Fehfd_LMybzG1WT7v53SpyDe#(Y`EF- zGE6%@Pz0rLML|HSq5}gnS~L2`A25uHk8!Lr&gc%~Z*?3eSpN72y=U7ncz5MDQO5M$=?+<19|SR^r($n;oM z2h7pWt4_j5ZhLhZz$8ukYFN@SUkjw^HD4nf&i&k)JG>)-?M2F(YbEgE5lIGk|1jR98tH@Pe=wUyD~Ktt}E+AiLVh13U~ z2RAJVnfR${y4&kAbCR=?%L-YM{#zHMuhqM#(hv-}StHgudKp#|C0;61{cEdmQvuA5 zc#aw4UY*kq=N`2zZ&PdEC_Z?kD;nH`wip+*sSr-w$G zH&s=5f3EP|st%lwoQ^E7@3~psd%M)^vb=1}yXjK1>iBYf^I#Wuqsu*H6TY&-5bbRF z45IXQi>FB6#TjaA-2m6=Kx;3I&=*_#H_zghL@5zh@2kf=Gm;~Sw-8tl@-D+Aj^O1W zcuq*XfGoAQ_0`rIqL3c~Fl>WUnLLN`qI5@>t4VRXyX_hOdn_y5sTreDNr^|J1g`;U zWYBmbldUx0=7!g)p?yQK-Vdw_nWaPR4>s*UbtguelCf!7N|21+O--Cq5-C!H9%Ecd zrV}$VIYHlPZ=1V_Df<0xL9aNjC^LEkSDS?WAu)vhFBdSTXml5mW30CsHAYl&9&NUa zO!mmp35CdeWI`@s#=SadrtYL7&CbZZ3PyTDJup3+_C}#HqR!6!}U40t-@}W8##$f?~z$fpvE}1_8Fgvc2vOr)&99YVAsh H`0@V$e#VW} diff --git a/mail-server/api/server.py b/mail-server/api/server.py index c78fdea..d4e57be 100644 --- a/mail-server/api/server.py +++ b/mail-server/api/server.py @@ -12,7 +12,8 @@ Endpunkte: GET /healthz → {"ok": true} GET /outbox → aktuelle Outbox (Demo/Debug) POST /send → {"ok": true, "id": ..., "versandt": true/false} - Body: {"to": "...", "subject": "...", "text": "...", "html": "..."} + Body: {"to": "...", "subject": "...", "text": "...", "html": "...", + optional "cc": "a@b.de, c@d.de", "from_address"/"from_name" (nur @hocks.eu)} Auth: Header X-Api-Token oder Body-Feld "token" == MAIL_API_TOKEN """ @@ -78,8 +79,10 @@ def rate_limit_ok() -> bool: def versenden(e: dict) -> bool: msg = EmailMessage() - msg["From"] = f"{SENDER_NAME} <{SENDER_ADDRESS}>" + msg["From"] = f"{e.get('from_name') or SENDER_NAME} <{e.get('from_address') or SENDER_ADDRESS}>" msg["To"] = e["to"] + if e.get("cc"): + msg["Cc"] = e["cc"] # Komma-getrennte CC-Empfaenger (v1.22) msg["Subject"] = e["subject"] msg.set_content(e.get("text") or "") if e.get("html"): @@ -172,12 +175,26 @@ class Handler(BaseHTTPRequestHandler): if not rate_limit_ok(): self._antwort(429, {"fehler": "Rate-Limit erreicht"}) return + # Optionaler eigener Absender (nur hocks.eu-Domain, sonst Fallback) + absender = str(body.get("from_address", "")).strip().lower() + absender_name = str(body.get("from_name", "")).strip() + if (not EMAIL_RE.match(absender) or not absender.endswith("@hocks.eu") + or len(absender_name) > 60 or any(c in absender_name for c in "<>\r\n")): + absender, absender_name = SENDER_ADDRESS, SENDER_NAME + # Optional CC (Komma-getrennt, einzeln validiert) – v1.22 + cc = ','.join( + a for a in (x.strip() for x in str(body.get("cc", "")).split(",")) + if a and EMAIL_RE.match(a) + ) eintrag = { "id": uuid.uuid4().hex[:12], "to": an, "subject": betreff, "text": str(body.get("text", "")), "html": str(body.get("html", "")), + "cc": cc, + "from_address": absender, + "from_name": absender_name, "versuche": 0, "versandt": False, "angenommenAm": time.strftime("%d.%m.%Y %H:%M:%S"), diff --git a/make-uat-liste.py b/make-uat-liste.py new file mode 100644 index 0000000..c4a28e2 --- /dev/null +++ b/make-uat-liste.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +"""UAT-Featureliste für das Hinterstein-Mieterportal (Arbeitsvorlage).""" +from openpyxl import Workbook +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.datavalidation import DataValidation + +AKZENT = '0F766E' # Hinterstein-Türkis +AKZENT_HELL = 'CCFBF1' +GRAU = 'F1F5F9' +DUNKEL = '22333B' +ROT = 'FEE2E2' +GELB = 'FEF9C3' +GRUEN = 'DCFCE7' + +wb = Workbook() + +# ── Blatt 1: Featureliste ───────────────────────────────────────────── +ws = wb.active +ws.title = 'Featureliste' +ws.sheet_view.showGridLines = False + +ws.merge_cells('B2:K2') +c = ws['B2'] +c.value = 'UAT & Feature-Wunschliste – Hinterstein Mieterportal' +c.font = Font(bold=True, size=16, color=DUNKEL) +ws.merge_cells('B3:K3') +c = ws['B3'] +c.value = ('Gewünschte Features erfassen: Zeile ergänzen, Modul wählen, Priorität setzen. ' + 'Die Entwicklung aktualisiert den Status. Beispielzeilen 001–003 gern überschreiben.') +c.font = Font(size=10, color='64748B') + +# Status-Kennzahlen (Formeln) +kennz = [('Offen', 'Offen'), ('In Prüfung', 'In Prüfung'), ('Umgesetzt', 'Umgesetzt'), ('Abgelehnt', 'Abgelehnt')] +for i, (label, wert) in enumerate(kennz): + col = 2 + i * 2 + lc = ws.cell(row=5, column=col, value=label) + lc.font = Font(size=9, bold=True, color='64748B') + vc = ws.cell(row=6, column=col, value=f'=COUNTIF($I$10:$I$109,"{wert}")') + vc.font = Font(size=14, bold=True, color=AKZENT) + vc.alignment = Alignment(horizontal='left') + +KOPF = ['ID', 'Modul', 'Feature / Anforderung', 'Beschreibung / Akzeptanzkriterium', + 'Priorität', 'Wunschtermin', 'Eingereicht von', 'Eingereicht am', 'Status', + 'Ergebnis im Test / Bemerkung'] +BREITEN = [7, 20, 30, 48, 11, 13, 15, 12, 12, 34] +SPALTEN = ['B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K'] +HEADER_ZEILE = 9 + +for j, (name, breite, buchstabe) in enumerate(zip(KOPF, BREITEN, SPALTEN)): + cell = ws.cell(row=HEADER_ZEILE, column=2 + j, value=name) + cell.font = Font(bold=True, color='FFFFFF', size=10) + cell.fill = PatternFill('solid', fgColor=DUNKEL) + cell.alignment = Alignment(vertical='center', wrap_text=True) + ws.column_dimensions[buchstabe].width = breite +ws.row_dimensions[HEADER_ZEILE].height = 28 + +MODULE = ['Dashboard', 'Mietkonto & Zahlungen', 'Schadensmeldungen', 'Zählerstände', + 'Steuer & Kosten', 'Dokumente', 'Nachrichten', 'Benutzerverwaltung', + 'Mein Profil', 'Häuser & Wohnungen', 'Mietverträge', 'Technik allgemein'] +PRIOS = ['Hoch', 'Mittel', 'Niedrig'] +STATUS = ['Offen', 'In Prüfung', 'Umgesetzt', 'Abgelehnt'] + +beispiele = [ + ['001', 'Steuer & Kosten', 'Nebenkostenabrechnung als PDF', + 'Pro Wohnung und Abrechnungsjahr eine PDF-Abrechnung aus Kosten + Zählerständen erzeugen und im Dokumentenmodul ablegen.', + 'Mittel', '', '', '', 'Offen', ''], + ['002', 'Benutzerverwaltung', 'E-Mail-Adressen importierter Mieter erfassen', + 'Fehlende E-Mail-Adressen der importierten Mieter erfassen, damit Benachrichtigungen versendet werden können.', + 'Hoch', '', '', '', 'Offen', ''], + ['003', 'Mietkonto & Zahlungen', 'Zahlungseingang manuell als erledigt markieren', + 'Verwaltung kann eine offene Buchung als „bezahlt“ markieren; Saldo und Fälligkeiten aktualisieren sich.', + 'Niedrig', '', '', '', 'Offen', ''], +] + +duenn = Side(style='thin', color='E2E8F0') +for i in range(100): + zeile = 10 + i + daten = beispiele[i] if i < len(beispiele) else [f'{i+1:03d}', '', '', '', '', '', '', '', 'Offen', ''] + for j, wert in enumerate(daten): + cell = ws.cell(row=zeile, column=2 + j, value=wert) + cell.font = Font(size=10) + cell.alignment = Alignment(vertical='top', wrap_text=(j in (2, 3, 9))) + cell.border = Border(bottom=duenn) + if i % 2 == 1: + cell.fill = PatternFill('solid', fgColor=GRAU) + +dv_modul = DataValidation(type='list', formula1='"' + ','.join(MODULE) + '"', allow_blank=True) +dv_prio = DataValidation(type='list', formula1='"' + ','.join(PRIOS) + '"', allow_blank=True) +dv_status = DataValidation(type='list', formula1='"' + ','.join(STATUS) + '"', allow_blank=True) +ws.add_data_validation(dv_modul) +ws.add_data_validation(dv_prio) +ws.add_data_validation(dv_status) +dv_modul.add('C10:C109') +dv_prio.add('F10:F109') +dv_status.add('J10:J109') + +# Bedingte Formatierung: Priorität + Status +from openpyxl.formatting.rule import CellIsRule +ws.conditional_formatting.add('F10:F109', + CellIsRule(operator='equal', formula=['"Hoch"'], + fill=PatternFill('solid', fgColor=ROT), font=Font(bold=True, size=10))) +ws.conditional_formatting.add('F10:F109', + CellIsRule(operator='equal', formula=['"Mittel"'], + fill=PatternFill('solid', fgColor=GELB), font=Font(size=10))) +ws.conditional_formatting.add('F10:F109', + CellIsRule(operator='equal', formula=['"Niedrig"'], + fill=PatternFill('solid', fgColor=GRUEN), font=Font(size=10))) +ws.conditional_formatting.add('J10:J109', + CellIsRule(operator='equal', formula=['"Umgesetzt"'], + fill=PatternFill('solid', fgColor=GRUEN), font=Font(size=10))) +ws.conditional_formatting.add('J10:J109', + CellIsRule(operator='equal', formula=['"Abgelehnt"'], + fill=PatternFill('solid', fgColor='E2E8F0'), font=Font(size=10, color='64748B'))) + +ws.freeze_panes = 'B10' +ws.sheet_view.zoomScale = 90 + +# ── Blatt 2: Anleitung ──────────────────────────────────────────────── +wa = wb.create_sheet('Anleitung') +wa.sheet_view.showGridLines = False +wa.column_dimensions['B'].width = 28 +wa.column_dimensions['C'].width = 95 + +wa.merge_cells('B2:C2') +c = wa['B2'] +c.value = 'So verwenden Sie diese Liste' +c.font = Font(bold=True, size=14, color=DUNKEL) + +bloecke = [ + ('Neuen Wunsch erfassen', + 'In der Tabelle „Featureliste“ eine leere Zeile nutzen (oder eine Beispielzeile überschreiben): ' + 'Modul aus dem Dropdown wählen, Feature kurz benennen und im Feld „Beschreibung / Akzeptanzkriterium“ ' + 'notieren, wann das Feature als fertig gilt. Je konkreter das Akzeptanzkriterium, desto schneller die Umsetzung.'), + ('Priorität', + 'Hoch = dringend für den Betrieb · Mittel = wichtig, aber zeitunkritisch · Niedrig = Nice-to-have.'), + ('Status (wird von der Entwicklung gepflegt)', + 'Offen → In Prüfung (Aufwand wird bewertet) → Umgesetzt (nach Auslieferung) bzw. Abgelehnt (mit Begründung in der letzten Spalte).'), + ('UAT – Abnahme testen', + 'Nach Umsetzung im Portal gegen das Akzeptanzkriterium testen. Ergebnis in der Spalte ' + '„Ergebnis im Test / Bemerkung“ festhalten: „OK“ oder Fehlerbeschreibung mit Schritten zum Nachvollziehen. ' + 'Gefundene Fehler bitte als neue Zeile mit Priorität „Hoch“ eintragen.'), + ('Übertrag an die Entwicklung', + 'Liste speichern und an die Entwicklung senden (E-Mail oder Kopie ins Projektverzeichnis). ' + 'Die Entwicklung legt die übernommenen Punkte als Versions-Einträge an und verweist auf die IDs.'), +] +zeile = 4 +for titel, text in bloecke: + tc = wa.cell(row=zeile, column=2, value=titel) + tc.font = Font(bold=True, size=11, color=AKZENT) + tc.alignment = Alignment(vertical='top') + cc = wa.cell(row=zeile, column=3, value=text) + cc.font = Font(size=10, color='334155') + cc.alignment = Alignment(vertical='top', wrap_text=True) + wa.row_dimensions[zeile].height = 58 + zeile += 2 + +wa.merge_cells(f'B{zeile}:C{zeile}') +c = wa.cell(row=zeile, column=2, value='Aktueller Portal-Stand: v1.21 (Live-Stammdaten importiert)') +c.font = Font(size=9, italic=True, color='94A3B8') + +out = 'UAT-Featureliste Mieterportal.xlsx' +wb.save(out) +print('gespeichert:', out) diff --git a/server/src/index.js b/server/src/index.js index 6f7094e..6eb822a 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -1238,11 +1238,61 @@ app.patch('/api/users/:id', auth, adminOnly, async (req, res, next) => { await pool.query('UPDATE users SET aktiv = $1 WHERE id = $2', [Boolean(b.aktiv), req.params.id]) if (b.einheitId !== undefined) await pool.query('UPDATE users SET einheit_id = $1 WHERE id = $2', [b.einheitId || null, req.params.id]) + if (b.email !== undefined) { + const email = String(b.email || '').trim() + if (email && !/^\S+@\S+\.\S+$/.test(email)) + return res.status(400).json({ fehler: 'Ungültige E-Mail-Adresse.' }) + if (email) { + const { rows: fremde } = await pool.query( + 'SELECT id FROM users WHERE lower(email) = lower($1) AND id <> $2', [email, req.params.id], + ) + if (fremde.length) + return res.status(400).json({ fehler: 'Diese E-Mail-Adresse wird bereits verwendet.' }) + } + await pool.query('UPDATE users SET email = $1 WHERE id = $2', [email, req.params.id]) + } const { rows: neu } = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]) res.json({ benutzer: oeffentlicherBenutzer(neu[0]) }) } catch (e) { next(e) } }) +// Direkte E-Mail der Verwaltung an einen Mieter (immer mit CC an die +// Verwaltung, damit die Kommunikation dort vollständig nachvollziehbar ist) +app.post('/api/users/:id/email', auth, adminOnly, async (req, res, next) => { + try { + const betreff = String((req.body || {}).betreff || '').trim() + const text = String((req.body || {}).text || '').trim() + if (!betreff || !text) + return res.status(400).json({ fehler: 'Betreff und Nachricht erforderlich.' }) + const { rows } = await pool.query( + `SELECT * FROM users WHERE id = $1 AND rolle = 'mieter'`, [req.params.id], + ) + const mieter = rows[0] + if (!mieter) return res.status(404).json({ fehler: 'Mieter nicht gefunden.' }) + if (!mieter.email) + return res.status(400).json({ fehler: `${mieter.name} hat keine E-Mail-Adresse hinterlegt (Profil).` }) + const { rows: einheitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [mieter.einheit_id]) + const label = mail.einheitLabel(mapUnit(einheitRows[0])) + const html = mail.baueHtmlMail({ + titel: 'Nachricht der Verwaltung', + untertitel: label, + einleitung: `Persönliche Nachricht von ${req.user.name} (Hinterstein Immobilien):`, + zeilen: [ + ['Wohnung', label], + ['Mieter', mieter.name], + ], + zitat: text, + aktionenHtml: mail.buttonHtml('Zum Mieterportal', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/nachrichten`), + }) + const cc = await mail.verwaltungEmails() + const textVersion = + `Nachricht der Verwaltung an ${mieter.name} (${label}):\n\n„${text}“\n\n` + + `— Gesendet von ${req.user.name} über das Mieterportal` + mail.sendeMail(mieter.email, betreff, textVersion, html, cc) + res.json({ ok: true }) + } catch (e) { next(e) } +}) + // ── Registrierungscodes (nur Verwaltung) ───────────────────── app.get('/api/codes', auth, adminOnly, async (_req, res, next) => { try { diff --git a/server/src/mail.js b/server/src/mail.js index c1f5578..1da690d 100644 --- a/server/src/mail.js +++ b/server/src/mail.js @@ -108,13 +108,17 @@ function buttonHtml(text, url) { return `

${htmlEscape(text)}

` } -async function sendeMail(an, betreff, text, html) { +async function sendeMail(an, betreff, text, html, cc) { if (!MAIL_API_URL || !MAIL_API_TOKEN || !an) return try { const res = await fetch(`${MAIL_API_URL}/send`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Api-Token': MAIL_API_TOKEN }, - body: JSON.stringify({ to: an, subject: betreff, text: `${text}\n\n${FUSSZEILE_TEXT}`, ...(html ? { html } : {}) }), + body: JSON.stringify({ + to: an, subject: betreff, text: `${text}\n\n${FUSSZEILE_TEXT}`, + ...(html ? { html } : {}), + ...(cc ? { cc } : {}), // v1.22: Komma-getrennte CC-Empfänger + }), }) if (!res.ok) console.warn(`[mail] Versand an ${an} fehlgeschlagen: HTTP ${res.status}`) } catch (e) { @@ -122,6 +126,14 @@ async function sendeMail(an, betreff, text, html) { } } +/** E-Mail-Adressen aller aktiven Verwaltungsnutzer (komma-getrennt, für CC). */ +async function verwaltungEmails() { + const { rows } = await require('./db').pool.query( + `SELECT email FROM users WHERE rolle = 'verwaltung' AND aktiv AND email <> ''`, + ) + return rows.map((r) => r.email).join(', ') +} + /** Alle aktiven Verwaltungsnutzer benachrichtigen. */ async function benachrichtigeVerwaltung(betreff, text, html) { const { rows } = await require('./db').pool.query( @@ -147,6 +159,7 @@ module.exports = { sendeMail, benachrichtigeVerwaltung, benachrichtigeMieter, + verwaltungEmails, einheitLabel, baueHtmlMail, buttonHtml, diff --git a/src/pages/BenutzerVerwaltung.tsx b/src/pages/BenutzerVerwaltung.tsx index 57397d9..92cdf20 100644 --- a/src/pages/BenutzerVerwaltung.tsx +++ b/src/pages/BenutzerVerwaltung.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { KeyRound, Plus, RefreshCcw, Search, ShieldCheck, UserCheck, UsersRound } from 'lucide-react' +import { KeyRound, Mail, Plus, RefreshCcw, Search, SendHorizonal, ShieldCheck, UserCheck, UsersRound } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { @@ -12,8 +12,9 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' import { useStore } from '../store/store' -import type { BenutzerRolle } from '../types' +import type { Benutzer, BenutzerRolle } from '../types' function Chip({ label, value, tone = 'slate' }: { label: string; value: number | string; tone?: 'slate' | 'amber' | 'teal' | 'blue' }) { const tones = { @@ -130,8 +131,104 @@ function NeuerBenutzerDialog() { ) } +function EmailDialog({ benutzer: b, einheitLabel }: { benutzer: Benutzer; einheitLabel: string }) { + const { sendeBenutzerEmail } = useStore() + const [open, setOpen] = useState(false) + const [betreff, setBetreff] = useState('') + const [text, setText] = useState('') + const [sende, setSende] = useState(false) + const [fehler, setFehler] = useState(null) + const [gesendet, setGesendet] = useState(false) + + const hatEmail = Boolean(b.email) + + const submit = async () => { + if (!betreff.trim() || !text.trim() || sende) return + setSende(true) + setFehler(null) + const err = await sendeBenutzerEmail(b.id, betreff.trim(), text.trim()) + setSende(false) + if (err) { + setFehler(err) + return + } + setGesendet(true) + setBetreff('') + setText('') + window.setTimeout(() => { + setGesendet(false) + setOpen(false) + }, 1500) + } + + return ( + + + + + + + E-Mail an {b.name} + +
+
+ +
+ {b.name} · {hatEmail ? b.email : 'keine E-Mail hinterlegt'} + {einheitLabel && {einheitLabel}} +
+

+ Die Verwaltung erhält automatisch eine Kopie (CC). +

+
+
+ + setBetreff(e.target.value)} + /> +
+
+ +