Compare commits

...

394 Commits

Author SHA1 Message Date
56b7d065b8 get rid of old stuff 2026-04-10 18:31:16 -05:00
96348c17ce remove bayarea 2026-04-09 17:58:08 -05:00
02b721ff51 remove annaville 2026-04-09 10:16:09 -05:00
3a628fe676 api on port 3000 2026-04-03 15:13:24 -05:00
93535750a2 internal name 2026-04-03 14:42:07 -05:00
c949457f4c greenlens_net 2026-04-03 14:37:12 -05:00
5292e2728f caddy_net 2026-04-03 14:35:20 -05:00
8c1770882b host.docker.internal 2026-04-03 13:28:24 -05:00
6837cf4f17 greenlenspro.com 2026-04-03 13:04:38 -05:00
5c61a74e3d Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-04-03 12:49:52 -05:00
ffe6bdd0f4 greenlenspro.com 2026-04-03 12:49:42 -05:00
f391e35221 innungsapp.com 2026-03-08 09:13:24 -05:00
9f88b58f96 innungsapp 2026-03-02 14:38:15 -06:00
9d32e0962e buddelectric.bayarea-cc.com 2026-02-10 09:15:40 -06:00
4d3ca7bb14 switch back 2026-02-03 11:59:17 -06:00
27f929dfbc update for samsung 2026-02-03 11:49:58 -06:00
be0642c389 reduce caching 2026-02-02 17:52:41 -06:00
69af529410 add volume 2026-02-02 13:13:07 -06:00
c4d8e980da host.docker.internal 2026-02-02 13:11:19 -06:00
676e0a91b6 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-02-02 12:18:10 -06:00
d626b19e4a www.bizmatch.net change 2026-02-02 12:17:51 -06:00
8efc6bfcd2 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-28 16:48:21 -06:00
7fcc380b0f english 2026-01-28 16:48:15 -06:00
38e327c847 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-28 16:40:46 -06:00
5e8559ec97 QRCode 2026-01-28 16:40:40 -06:00
2ee5fc8842 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-28 16:06:05 -06:00
9e107cb96c email setup page 2026-01-28 16:05:59 -06:00
0ef8eb0938 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-28 14:59:05 -06:00
05aac691b3 dfgfdg 2026-01-28 14:59:02 -06:00
de57180976 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-28 14:53:29 -06:00
45ae435223 fix 2026-01-28 14:53:22 -06:00
6cd4371829 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-28 14:44:06 -06:00
eec4458604 apple 2026-01-28 14:43:34 -06:00
4e2369f35c Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-28 14:14:30 -06:00
c73b400f52 ruehrgedoens 2026-01-28 14:14:09 -06:00
c6ee22ef12 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 16:47:48 -06:00
4842bd7f03 imap 2026-01-27 16:47:40 -06:00
6f289424e3 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 16:43:30 -06:00
900ae6c257 only pop 2026-01-27 16:43:19 -06:00
8ef9420396 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 16:38:54 -06:00
2dd1dc21b6 fix for wrong closing .. 2026-01-27 16:38:47 -06:00
0794242198 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 16:27:59 -06:00
d926064493 pop 2026-01-27 16:27:54 -06:00
0d6bf386d0 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 16:14:58 -06:00
3ad3ab38c8 <LoginName>{header.X-Anchormailbox}</LoginName> 2026-01-27 16:14:53 -06:00
200567f23c Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 15:48:03 -06:00
ab4958dfa2 dsfdsf 2026-01-27 15:47:42 -06:00
aa75224d03 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 15:20:33 -06:00
4530a2f80e fixed loginname 2026-01-27 14:09:36 -06:00
83ae97e627 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 13:58:05 -06:00
fa2ef2b743 mail.email-srvr.com cert 2026-01-27 13:57:16 -06:00
7f9042e612 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 13:28:32 -06:00
2cfa226361 debug 2026-01-27 13:28:17 -06:00
d37109a696 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 13:18:04 -06:00
4f7dc6f8b4 autodiscover 2026-01-27 13:17:55 -06:00
afdd3d903a Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 13:14:47 -06:00
6c6b4d345f autodiscover 2026-01-27 13:14:39 -06:00
28741de633 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 09:54:31 -06:00
7d1c0b9a6d remove debug 2026-01-27 09:54:29 -06:00
38b425e1d8 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 09:53:58 -06:00
b3d184259e info log 2026-01-27 09:53:54 -06:00
dfddc38c89 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 09:52:08 -06:00
29e35bfad6 email_autodiscover 2026-01-27 09:52:05 -06:00
fe9651409e Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 09:49:29 -06:00
9438eeaa75 container_name: caddy 2026-01-27 09:49:24 -06:00
286619fc62 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 09:48:08 -06:00
969aec9278 import 2026-01-27 09:48:04 -06:00
1bb297f0cf Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 09:43:11 -06:00
5dc09a5651 remove autodiscover 2026-01-27 09:43:07 -06:00
d0af616b8d Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-27 09:38:22 -06:00
7cbbaedd5e autodiscover 2026-01-27 09:38:06 -06:00
9acc06646a Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-25 14:34:56 -06:00
f541ea9248 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-25 14:34:03 -06:00
96fa643095 roundcube 2026-01-25 14:33:44 -06:00
c70f031dff Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-01 09:58:47 -06:00
61820fe772 qrmaster 2026-01-01 09:58:22 -06:00
7e3fac6907 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-01 06:24:03 -06:00
013f1c8994 path to container ... 2026-01-01 06:23:54 -06:00
c24049d3fb Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-01 06:18:58 -06:00
dde671fe3d neues root 2026-01-01 06:18:51 -06:00
f340cc0a43 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2026-01-01 06:11:35 -06:00
aded85eb66 use config-email in prod mode 2026-01-01 06:11:08 -06:00
9dd22589de Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-12-31 11:37:18 -06:00
0ddfa51265 api.email-bayarea.com 2025-12-31 11:36:59 -06:00
4a5222a781 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-12-31 10:56:21 -06:00
490721c8b6 pictures in bizmatch-projects folder 2025-12-31 10:55:53 -06:00
ce6a115684 config.email-bayarea.com 2025-12-31 10:50:35 -06:00
f79dde2d1d Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-11-17 15:52:26 -06:00
08d1c0f265 new certificate generation 2025-11-17 15:37:03 -06:00
3b3d20f89a actual function 2025-10-18 16:45:56 -05:00
286de26c87 actual 2025-10-16 21:34:23 -05:00
1b899985a1 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-10-13 15:47:09 -05:00
cd731c502b fix 2025-10-13 15:47:06 -05:00
f0096bc27f Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-10-13 15:22:20 -05:00
ac008aff8e SES - Lambda Invokation added 2025-10-13 15:21:54 -05:00
432259d459 remove emails prefix 2025-10-10 17:42:30 -05:00
b9066a8f59 ac 2025-10-09 17:58:27 -05:00
39d50b7d3b Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-10-02 18:46:23 -05:00
5533fbff14 fghfgh 2025-10-02 18:46:18 -05:00
824fbbe3eb Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-10-02 18:41:24 -05:00
08657e7282 xcvxcv 2025-10-02 18:41:19 -05:00
1968faab99 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-10-02 18:35:00 -05:00
2617f049f3 host.docker.internal 2025-10-02 18:34:54 -05:00
3d961d6536 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-10-02 18:29:02 -05:00
7f071435c7 reverse_prox 2025-10-02 18:28:41 -05:00
5469a01893 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-10-01 21:20:53 -05:00
d04bb2f5cb mount 2025-10-01 21:20:49 -05:00
1bf893f683 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-10-01 17:37:27 -05:00
fecfc59988 fix 2025-10-01 17:37:20 -05:00
a2c4ac8685 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-10-01 17:26:09 -05:00
29181ce13b new port 2025-10-01 17:25:57 -05:00
08489162bf Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-10-01 17:20:17 -05:00
d5b7986761 annavillesda 2025-10-01 17:19:53 -05:00
92afa46d5d Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-19 17:32:05 -05:00
3da5e3c814 s3mail 2025-09-19 17:31:51 -05:00
22eadee4cd retry mechanism 2025-09-17 17:38:49 -05:00
a709172a99 Fixes 2025-09-17 17:19:34 -05:00
0f29d06653 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-15 16:43:07 -05:00
ce296ecdab iitwelders 2025-09-15 16:39:41 -05:00
a22a30ac3b Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-14 16:38:02 -05:00
798842ba9b email prefix removed 2025-09-14 16:37:55 -05:00
9b490a9233 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 15:24:42 -05:00
5ed6c15ba2 fghfgh 2025-09-11 15:24:40 -05:00
bf5569522a Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 15:19:01 -05:00
b8915cb692 dfgdfg 2025-09-11 15:18:59 -05:00
3c84604de8 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 15:16:06 -05:00
b210e49ad4 dfgdfg 2025-09-11 15:15:59 -05:00
38a1a08c2a Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 15:12:46 -05:00
b8dd30987e sdfsdf 2025-09-11 15:12:43 -05:00
ee26c3dc0a Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 15:10:17 -05:00
bc038b0a70 dfgfdg 2025-09-11 15:10:11 -05:00
6331391f1c Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 15:06:37 -05:00
22af3a5273 sdfsd 2025-09-11 15:06:31 -05:00
ee3e5952ac Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 15:02:56 -05:00
7bd4c73306 fghfgh 2025-09-11 15:02:52 -05:00
541059c0c4 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 14:58:11 -05:00
cd545ee056 sdfsdf 2025-09-11 14:58:04 -05:00
ac68074178 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 14:53:17 -05:00
c62b72dac0 sdfsdf 2025-09-11 14:53:10 -05:00
ce0b44ac9c Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 14:45:50 -05:00
0d2d5d9e38 sdfsdf 2025-09-11 14:45:45 -05:00
3553cdcf59 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 14:43:00 -05:00
06f6ee43cc sdfdsf 2025-09-11 14:42:54 -05:00
834aa48d09 dfgdfg 2025-09-11 14:40:42 -05:00
14f6b30444 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 14:37:19 -05:00
e9a266534a asdsa 2025-09-11 14:37:16 -05:00
96f3ccbc1a Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 14:36:03 -05:00
3b3cb3aec1 sdgfsdf 2025-09-11 14:35:43 -05:00
669ef1b220 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 14:04:28 -05:00
ffee5c0568 replace 2025-09-11 14:04:22 -05:00
ee235d5863 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 14:00:19 -05:00
06e070f9b7 replace_response 2025-09-11 14:00:15 -05:00
dde2134d87 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 13:58:49 -05:00
0f7e8c1dd5 remove 2025-09-11 13:58:34 -05:00
bdcd57aba8 replace-response 2025-09-11 13:58:00 -05:00
57ec03cebe Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 13:35:29 -05:00
7282cbdd59 new name 2025-09-11 13:34:59 -05:00
082c465985 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 13:14:24 -05:00
101a128c9f order 2025-09-11 13:14:20 -05:00
8b5b984d22 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 13:12:03 -05:00
2562fc49b0 korrekturen 2025-09-11 13:10:53 -05:00
212fa09534 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 13:02:54 -05:00
a3eef3055e CADDY_VERSION=v2.9.1 2025-09-11 13:02:50 -05:00
f66016633e Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 12:56:39 -05:00
57fbce27f6 caddy with replace/response 2025-09-11 12:56:27 -05:00
b10f49a283 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 12:24:18 -05:00
47b5b7e8fd iitwelders 2. try 2025-09-11 12:24:15 -05:00
379cc87257 Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 12:18:13 -05:00
d80df95f43 iitwelders as proxy 2025-09-11 12:17:57 -05:00
ceaf82d5da Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-11 11:59:04 -05:00
fb5b0cc48e nqsltd & gregknoppcpa 2025-09-11 11:58:21 -05:00
dfadc74b2d :Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-09-01 13:55:13 -05:00
a3873f8649 bizmatch.net redir to www.bizmatch.net 2025-09-01 13:55:10 -05:00
cbe58d4cb2 ses2dms 2025-08-31 16:11:14 -05:00
7692aef4fc Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-08-28 17:34:11 -05:00
31aea63c61 wewer 2025-08-28 17:34:02 -05:00
973be97c70 deleted 2025-08-28 17:11:07 -05:00
bd38b9a5f2 home dir 2025-08-28 17:07:36 -05:00
09f6bf1a27 dms removed 2025-08-28 17:03:07 -05:00
b72cfdc67e sdfsdf 2025-08-28 16:52:29 -05:00
e96631bafd Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-08-28 16:47:25 -05:00
76b8f17ed3 changes 2025-08-28 16:47:18 -05:00
77ec9800aa Merge branch 'master' of git.bizmatch.net:aknuth/docker 2025-08-28 16:43:02 -05:00
3efc9ab1f1 changed port & 2025-08-28 16:43:00 -05:00
4c34709526 merge 2025-08-27 17:56:00 -05:00
b8e3cb6e1f hash 2025-08-27 17:53:54 -05:00
de7a541857 hamptonbrown 2025-08-27 17:50:16 -05:00
e8327f6824 neuer PW Hash 2025-08-25 18:10:27 -05:00
4ad79c3c29 tls 2025-08-25 17:52:36 -05:00
7e2b2ca310 sqllite 2025-08-22 18:05:34 -05:00
4be8ff61c5 change ports 2025-08-22 17:58:50 -05:00
f51e4ab44b changed formatting 2025-08-22 17:56:56 -05:00
338c630f57 docker-mailserver 2025-08-22 17:56:01 -05:00
08501f863a host.docker.internal instead of localhost 2025-08-22 15:19:54 -05:00
d5aaa64555 different comments signs 2025-08-22 15:05:03 -05:00
215d7a3978 cielectrical changed 2025-08-22 14:56:19 -05:00
217ad84815 localhost 2025-08-22 14:36:34 -05:00
e349f0142f cielectrical 2025-08-22 14:20:16 -05:00
8f1fdbfb96 change hostname 2025-08-18 17:20:20 -05:00
766f0d4a18 fancytextstuff 2025-08-18 17:17:41 -05:00
1369944996 dfgdfg 2025-08-01 15:14:23 -05:00
96950e43b0 dfgdfg 2025-08-01 15:13:12 -05:00
b9cc17b997 sdfsdf 2025-08-01 14:58:19 -05:00
e0db2595a9 dfgdfg 2025-08-01 14:55:08 -05:00
a6db7b130b sdfsdf 2025-08-01 12:43:16 -05:00
8bb05f499f sdfsd 2025-08-01 12:41:35 -05:00
0ce09ef969 updated env variables 2025-08-01 10:55:11 -05:00
e73641f258 sdfsdf 2025-07-31 17:51:52 -05:00
2e3ca446f5 asdas 2025-07-31 17:45:35 -05:00
1d88681f28 sdfdsf 2025-07-31 17:38:05 -05:00
24f6890357 sdfsdf 2025-07-31 17:37:22 -05:00
fc9102c5d3 sdfdsf 2025-07-31 17:28:10 -05:00
210148c305 sdfsdf 2025-07-31 17:20:37 -05:00
b126861406 sdfsdf 2025-07-31 16:53:20 -05:00
ee7b6fd1fb prod preparation 2025-07-30 17:30:39 -05:00
04e9f4ccec asdsad 2025-07-27 17:16:17 -05:00
e7519cc0b5 asdsad 2025-07-27 16:59:13 -05:00
3a585ea604 fsdf 2025-07-27 16:54:47 -05:00
1525cda50f sdfgds 2025-07-27 16:52:30 -05:00
80acd37da7 sdf 2025-07-27 15:59:45 -05:00
4efb69a356 asdfas 2025-07-27 15:59:17 -05:00
53827a8277 fix 2025-07-27 15:55:20 -05:00
55959ce22d test 2025-07-27 15:48:30 -05:00
d550342492 test 2025-07-27 15:20:04 -05:00
ade3a5780f no exception even if recipient list is empty 2025-07-21 15:33:04 -05:00
675c00209c check for valid Domain 2025-07-21 11:51:01 -05:00
5fadef1aac changes 2025-07-21 11:40:40 -05:00
7012f1ffd3 rejectUnauthorized: false 2025-07-15 10:26:53 -05:00
14a212cf14 increase payload 2025-07-15 10:19:39 -05:00
c66f27225c syntax error corrections 2025-07-15 10:13:54 -05:00
6359adb807 fixed 2025-07-08 16:36:36 -05:00
cb25a44d69 mailparser updated 2025-07-08 16:31:29 -05:00
9a3e279212 db 2025-07-07 15:18:53 -05:00
d84a5a69b0 db 2025-07-07 15:14:34 -05:00
b8152cbc39 db 2025-07-07 15:06:07 -05:00
b8fc26dc46 Port change 2025-07-07 15:05:22 -05:00
493743e8aa updated 2025-07-07 14:53:41 -05:00
b556ac8283 container_name changed 2025-07-07 14:13:59 -05:00
65866de63b upodate to node.js 2025-07-07 14:12:09 -05:00
0663a7c6bc autodiscover 2025-06-27 17:52:52 -05:00
9862c77f18 mail.email-srvr.com added 2025-06-23 20:06:47 -05:00
8d1afdeffd web.email-bayarea.com added 2025-06-23 19:32:45 -05:00
3a528b37a1 change retry method 2025-06-17 09:35:58 -05:00
68f5f0c3f4 check inboxes & domains 2025-06-16 19:53:50 -05:00
c645f71225 check for valid recipients 2025-06-16 19:38:45 -05:00
b2a0c6e611 requests 2025-06-15 22:59:05 -05:00
d92618f225 import requests 2025-06-15 22:56:56 -05:00
05b81d28db MAILCOW_API 2025-06-15 22:53:50 -05:00
ac7ebbb7f3 bugfix 2025-06-15 22:51:10 -05:00
9ef67da70e domain_exists 2025-06-15 22:35:12 -05:00
d6f90f444b fixed retry 2025-06-15 13:27:44 -05:00
3deaedc235 bugfix retry 2025-06-14 21:22:31 -05:00
d98a9086ca bugfix for retry function 2025-06-14 21:20:13 -05:00
34393b0807 /stats/<domain> 2025-06-14 20:50:01 -05:00
4388f6efc2 parse body, new metadata 2025-06-14 20:27:56 -05:00
fdbc32bed9 verbessertes Fehlerhandling 2025-06-14 20:22:34 -05:00
4943bccb3e Korrekturen 2025-06-13 17:27:05 -05:00
dc57e08030 bugfix 2025-06-13 16:44:03 -05:00
ce87a9e3a5 BugFixes 2025-06-13 16:40:54 -05:00
bd6d7a8c92 move of logging 2025-06-13 16:17:30 -05:00
67b97f514b another logging 2025-06-13 16:15:49 -05:00
434f94f882 BugFixing 2025-06-13 16:08:11 -05:00
26adce6ecf logging 2025-06-13 15:59:28 -05:00
fc6fa76bc0 updated Lambda & REST API 2025-06-13 11:00:52 -05:00
0d0391b6ee sendmail 2025-06-12 20:09:41 -05:00
24b05aa210 changes 2025-06-12 19:12:21 -05:00
9287e9be8b removed 2025-06-12 17:38:22 -05:00
f291076a3a Korrekturen 2025-06-10 22:05:49 -05:00
726d2607da parse EMail 2025-06-10 17:54:57 -05:00
7413b54af4 network_mode: host # Nutzt das Host-Netzwerk 2025-06-10 17:42:15 -05:00
5d961fad9d new SMTP HOST name 2025-06-10 17:09:45 -05:00
55781bea6f mailcow network removed 2025-06-10 17:05:38 -05:00
6c65798d56 mailcow-network 2025-06-10 17:01:48 -05:00
9ac6357a46 mailcowdockerized-postfix-mailcow-1 2025-06-09 19:53:18 -05:00
1758b29062 localhost 2025-06-09 19:44:54 -05:00
b2e4b85205 python-dotenv 2025-06-09 19:39:57 -05:00
71869cf458 logging 2025-06-09 19:31:30 -05:00
2ee59c6153 update to python 3.12 2025-06-09 19:12:31 -05:00
988fd2906c rename 2025-06-09 19:09:09 -05:00
dc968e1cac email api 2025-06-09 19:06:05 -05:00
3079593c79 port changed 2025-06-06 14:18:56 -05:00
853c02bcb9 mail.andreasknuth.de 2025-06-06 13:16:41 -05:00
aff78a12c8 mailcow 2025-06-06 13:14:50 -05:00
57f969fac6 mailcow-network added 2025-06-06 12:11:49 -05:00
bd0cc5debc name reverse proxy 2025-06-06 12:09:10 -05:00
04399202a5 mail.andreasknuth.de 2025-06-05 19:11:51 -05:00
ae64e98af0 wildduck v5 2025-06-05 18:12:30 -05:00
ca45cab9bd wildduck v4 2025-06-05 18:05:37 -05:00
73df166702 wildduck v3 2025-06-05 17:49:29 -05:00
2b6f04afbe wildduck 2. version 2025-06-05 17:44:40 -05:00
7748cdff65 added mongodb_config 2025-06-05 17:29:18 -05:00
be85896404 wildduck 1.version 2025-06-05 17:21:51 -05:00
991047d286 new processing 2025-05-22 18:18:50 -05:00
1b05ae48ad changed to actual postgres version 2025-05-15 14:44:07 -05:00
3dc5b74a8d dsfsdf 2025-03-23 17:41:49 -05:00
67ec6a74ac fwe 2025-03-23 23:01:10 +01:00
277b2b2b2f asd 2025-03-23 22:54:44 +01:00
f2d633059c new config 2025-03-23 22:53:13 +01:00
c9c41685b3 ssl 2025-03-23 22:48:02 +01:00
dcda223974 we 2025-03-23 21:41:53 +01:00
ae77abacf1 changed mail home 2025-03-23 21:37:58 +01:00
aeec0796f8 inside/outside port 2025-03-23 21:21:29 +01:00
46799bb63a sdf 2025-03-23 20:54:18 +01:00
08a2e13ada sdf 2025-03-23 20:48:00 +01:00
b6a60f8a20 change 2025-03-23 20:45:54 +01:00
538abb6e59 test 2025-03-23 20:10:30 +01:00
82a1baeaf2 passdb unserdb 2025-03-23 20:09:45 +01:00
2f32bd53df maillocation 2025-03-23 20:05:25 +01:00
3a12027852 log & mail location 2025-03-23 20:03:21 +01:00
d276f9f297 update conf 2025-03-23 19:52:59 +01:00
3b63b66472 dovecot 2.4 2025-03-23 19:50:18 +01:00
749abdb29a entrypoint entfernt 2025-03-23 19:06:17 +01:00
e4f00357da remove chown statement 2025-03-23 18:33:34 +01:00
b670438ca2 entrypoint.sh 2025-03-23 18:26:25 +01:00
3f669a53a4 bugfix 2025-03-23 14:13:31 +01:00
7e4b24fc6a change dir & file perms 2025-03-23 14:04:13 +01:00
1af90e6eb9 mailsync.bizmatch.net added 2025-03-23 11:42:11 +01:00
7873002167 s3 downloader API + startScript 2025-03-22 22:04:18 +01:00
ba36b6753a Anleitung 2025-03-22 14:31:59 +01:00
8233fc4fca no chown 2025-03-22 13:56:58 +01:00
be8115c3da setze Berechtigungen 2025-03-22 13:53:50 +01:00
62d1cc22cf The path has been updated 2025-03-22 13:45:39 +01:00
8c2f3a170d init script 2025-03-22 13:43:19 +01:00
311243f44d new ssl configs 2025-03-22 13:02:35 +01:00
6b0ea3d0f8 run docker as script 2025-03-22 12:53:27 +01:00
1a4d14b396 run as root 2025-03-22 12:52:12 +01:00
09b7a16fd1 112 2025-03-22 12:49:02 +01:00
92e7e06661 -config-dir 2025-03-22 12:43:41 +01:00
6df151937c log files 2025-03-22 12:41:18 +01:00
768b1b6e53 use other user 2025-03-22 12:36:36 +01:00
7cbe56a2f2 parameter included, mail proxy removed 2025-03-22 12:16:55 +01:00
0bffc8856f CNAME imap hinzugefügt 2025-03-22 11:56:47 +01:00
04d07414c9 chmod 2025-03-22 11:45:42 +01:00
fc1d072404 new aws scripts 2025-03-22 11:44:27 +01:00
212341744c update passwd 2025-03-21 18:25:52 +01:00
9905481e26 BugFix downloader, add IAM User to send EMails for a specific Domain 2025-03-20 23:06:51 +01:00
67e37f8985 fix for feedback URL 2025-03-20 14:14:47 +01:00
2174fe4869 AWS & Cloudflare Script 2025-03-20 14:02:25 +01:00
6ae4da137e actual state 2025-03-18 14:39:11 +01:00
b1ef230144 new domain 2025-03-17 16:42:19 +01:00
ec2949bd99 change dir 2025-03-17 16:21:51 +01:00
148ab14401 cloudflare docker 2025-03-17 16:20:30 +01:00
9cf7435380 new account 2025-03-17 15:47:58 +01:00
218e806336 downloader for many domains 2025-03-17 15:40:42 +01:00
ff8d54817a handle deletion, folder correction 2025-03-16 11:42:11 +01:00
044bb3a960 rm setup-permisssions, s3 deletion added 2025-03-16 11:10:51 +01:00
2179d5110f sad 2025-03-15 10:59:40 +01:00
68dc2ae779 fixes 2025-03-15 10:57:15 +01:00
4149655432 info@bizmatch.net 2025-03-15 10:41:41 +01:00
dae856b601 bizmatch.net 2025-03-15 10:37:09 +01:00
e283546633 fsdf 2025-03-15 10:27:41 +01:00
7f30b430dd dovecot 2.3.21.1 2025-03-15 10:24:09 +01:00
7351684c53 ert 2025-03-14 22:57:09 +01:00
1af82dfd23 port 143 2025-03-14 22:53:20 +01:00
5f9613e169 new test 2025-03-14 22:48:56 +01:00
df0903958f t1 2025-03-14 20:48:52 +01:00
8f3643e4c1 test 2025-03-14 20:46:50 +01:00
e0a47e5cc9 dovecot_config_version = 2.4.0 2025-03-14 20:44:22 +01:00
9bae8a4cbe Korrekturen 2025-03-14 20:43:12 +01:00
01c86681f2 dovecot.conf 2025-03-14 18:18:46 +01:00
76e83d7fd1 dovecot 1.version 2025-03-14 18:14:50 +01:00
c87cce7255 test 2025-03-01 21:50:43 +01:00
634791e9fd test 2025-03-01 21:49:03 +01:00
3dcbc01284 auth.bizmatch.net 2025-03-01 21:45:24 +01:00
b641acbb78 restart: always 2025-03-01 18:00:51 +01:00
1a83422d6f v7 2025-02-25 20:17:35 +01:00
8bd0cce4e3 bayarea Überarbeitung 2025-02-25 20:12:31 +01:00
c939f7c629 new acme_ca 2025-02-25 20:00:54 +01:00
24913f7f5e v6 2025-02-25 19:40:29 +01:00
80c29b40df issuer acme 2025-02-25 19:38:27 +01:00
f326cd6a9c issuer 2025-02-22 15:16:05 -06:00
fefda4846c email added 2025-02-22 14:00:38 -06:00
3f38793658 remove tls 2025-02-22 13:47:13 -06:00
6ea75209e7 remove local tls entries 2025-02-22 13:45:06 -06:00
f49e4b5d92 v5 2025-02-21 17:55:14 -06:00
70eae79aff v4 2025-02-21 17:54:36 -06:00
810476831e v3 2025-02-21 17:53:35 -06:00
9d4c6d283d v2 2025-02-21 17:44:44 -06:00
795757320d einzelne Einträge 2025-02-21 17:41:09 -06:00
eeb2a0e6b2 debug 2025-02-21 17:31:32 -06:00
b7cedcae3d working config 2025-02-21 14:25:56 -06:00
c128ea96c4 v1 2025-02-21 13:18:17 -06:00
409d6b6ce2 check for api 2025-02-21 13:15:07 -06:00
aecccbb023 new config for bayarea 2025-02-21 11:14:20 -06:00
a57b73fcc5 neuer Pfad im docker Container 2025-02-21 10:45:05 -06:00
fe29328c3b add path eithout space 2025-02-21 10:42:01 -06:00
872f58e727 add path to container 2025-02-21 10:38:30 -06:00
d653c1b5d1 remove comment stuff 2025-02-21 10:20:04 -06:00
1386a2ccdc caddyfile for bayarea-cc.com 2025-02-21 10:17:58 -06:00
5fd2ddd2a5 add one token for all domains 2025-02-14 17:03:46 -06:00
4d7a1c54be test switch ... 2025-02-14 15:55:05 -06:00
4fb91e8ba6 test 2025-02-14 15:33:52 -06:00
4b9807faca two different domains added 2025-02-14 15:30:08 -06:00
b4b0b1056a new cloudflare token 2025-02-14 15:16:53 -06:00
63 changed files with 19727 additions and 166 deletions

5
.gitignore vendored
View File

@@ -1,3 +1,8 @@
*.jar *.jar
auth auth
.env .env
.venv*
__pycache__
node_modules
ses-lambda-python/*
!ses-lambda-python/lambda_function.py

40
README Normal file
View File

@@ -0,0 +1,40 @@
Anleitung zur Generierung einer neuen Domain/DNS/Email
Grundlegendes
- Domain kaufen
- Domain bei cloudflare eintragen und den A Record und einen CNAME www eintragen (per Hand)
- bei Bedarf die Nameserver beim Domain Halter eintragen (Hetzner, networksolutions) und abwarten, das cloudflare den Status active anzeigt
AMAZON Einrichtung
- Die 3 Scripte zur Amamzon Konfiguration ausführen
1. awss3.sh
2. awsses.sh
3. awsiam.sh (Access Token und SMTP Passwort abspeichern !!!!)
- cloudflareDns.sh ausführen und warten bis die Identities alle auf Aktiv gesetzt sind
Server Arbeiten
- .env anpassen und neue Domain eintragen, den Bucket Namen sowie die Usernames
- dovecot_passwd_manager.py update ausführen um die passwd anzupassen
- Zertifikate erzeugen und kopieren
1. DOMAIN=imap.[DOMAIN] EMAIL=andreas.knuth@gmail.com docker compose run --rm certbot
2. cp -R letsencrypt/archive/imap.[DOMAIN] ../dovecot/ssl/
3. in der dovecor.conf folgenden Eintrag hinzufügen
local_name imap.[DOMAIN] {
ssl_cert = </etc/dovecot/ssl/imap.[DOMAIN]/fullchain1.pem
ssl_key = </etc/dovecot/ssl/imap.[DOMAIN]/privkey1.pem
}
Einrichten des EMail Clients
- IMAP
1. Server Name: imap.[DOMAIN]
2. Port: 993
3. UserName: Account Name -> z.B. info@[DOMAIN]
4. Security: SSL/TLS
5. Auth: Normal Passwd
-SMTP
1. Server Name: email-smtp.us-east-2.amazonaws.com
2. Port: 587
3. User Name: [Access Token von oben]
4. Authentication method: Normal password
5. Connection Security: STARTTLS
6. Passwort: SMTP Passwort, nicht der Security Token !!!!

View File

@@ -1,6 +0,0 @@
DB_HOST=postgres
DB_PORT=5432
DB_SCHEMA=public
POSTGRES_DB=bizmatch
POSTGRES_USER=bizmatch
POSTGRES_PASSWORD=xieng7Seih

View File

@@ -0,0 +1,44 @@
services:
app:
image: node:22-alpine
working_dir: /app
volumes:
- ~/git/bizmatch-project/bizmatch-server:/app
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DB_HOST=postgres
- DB_PORT=5432
- DB_NAME=${POSTGRES_DB}
- DB_USER=${POSTGRES_USER}
- DB_PASSWORD=${POSTGRES_PASSWORD}
env_file:
- ~/git/docker/app/.env # Pfad zur .env-Datei
command: sh -c "npm install && npm run build --omit=dev && node dist/src/main.js"
restart: unless-stopped
depends_on:
- postgres
networks:
- bizmatch
postgres:
container_name: bizmatchdb
image: postgres:latest
restart: always
volumes:
- ${PWD}/bizmatchdb-data:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
env_file:
- ~/git/docker/app/.env # Neu: Separate Env-File für Prod
ports:
- "5432:5432"
networks:
- bizmatch
networks:
bizmatch:
external: true

View File

@@ -0,0 +1,47 @@
services:
app:
image: node:22-alpine
container_name: bizmatch-app-prod # Neu: Unterscheide Namen
working_dir: /app
volumes:
- /home/aknuth/git/bizmatch-project-prod/bizmatch-server:/app # Verwende Prod-Checkout
ports:
- "3001:3000" # Neu: Host-Port 3001, Container-Port bleibt 3000
env_file:
- path: ./env.prod # Neu: Separate Env-File für Prod
required: true
environment:
- NODE_ENV=development # Neu: Production-Modus (für Nest.js-Config)
- DB_HOST=postgres-prod # Neu: Passe an neuen Service-Namen
- DB_PORT=5432
- DB_NAME=${POSTGRES_DB} # Neu: Separate DB-Name aus Env-File
- DB_USER=${POSTGRES_USER}
- DB_PASSWORD=${POSTGRES_PASSWORD}
command: sh -c "npm install && npm run build && node dist/src/main.js" # Entferne --omit=dev für Prod
restart: unless-stopped
depends_on:
- postgres-prod
networks:
- bizmatch-prod # Neu: Separates Network für Isolation
postgres-prod: # Neu: Umbenannt für Unterscheidung
container_name: bizmatchdb-prod
image: postgres:latest
restart: always
volumes:
- ${PWD}/bizmatchdb-data-prod:/var/lib/postgresql/data # Neu: Separates Daten-Volume
env_file:
- path: ./env.prod # Neu: Separate Env-File für Prod
required: true
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5433:5432" # Neu: Host-Port 5433, Container-Port bleibt 5432
networks:
- bizmatch-prod
networks:
bizmatch-prod:
external: true # Neu: Erstelle es mit `docker network create bizmatch-prod`

View File

@@ -1,25 +0,0 @@
version: '3.8'
services:
postgres:
container_name: postgres_app
image: postgres:15.5-alpine3.19
volumes:
- bizmatch_volume:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
networks:
- bizmatch
networks:
bizmatch:
external: true
volumes:
bizmatch_volume:
external: true

View File

@@ -1,86 +1,98 @@
{ {
acme_dns cloudflare q1P7J3uqS96FGj_iiX2mI8y1ulTaIFrTp8tyTXhG email {env.CLOUDFLARE_EMAIL}
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
acme_ca https://acme-v02.api.letsencrypt.org/directory
debug
}
# Prod: Neue Domains
www.bizmatch.net {
handle /pictures/* {
root * /home/aknuth/git/bizmatch-project/bizmatch-server # Prod-Ordner
file_server
}
# Statische Dateien (CSS, JS, Bilder) lange cachen, da sich der Name bei Änderungen ändert
header /assets/* Cache-Control "public, max-age=31536000, immutable"
header /*.css Cache-Control "public, max-age=31536000, immutable"
header /*.js Cache-Control "public, max-age=31536000, immutable"
# Die index.html und API-Antworten NIEMALS cachen
header /index.html Cache-Control "no-cache, no-store, must-revalidate"
handle {
reverse_proxy host.docker.internal:4200
}
log {
output file /var/log/caddy/access.prod.log # Separate Logs
}
encode gzip zstd
} }
bizmatch.net { bizmatch.net {
tls { redir https://www.bizmatch.net{uri} permanent
dns cloudflare q1P7J3uqS96FGj_iiX2mI8y1ulTaIFrTp8tyTXhG
} }
www.qrmaster.net {
handle {
reverse_proxy host.docker.internal:3050
} }
bayarea-cc.com { log {
tls { output file /var/log/caddy/qrmaster.log
dns cloudflare q1P7J3uqS96FGj_iiX2mI8y1ulTaIFrTp8tyTXhG format console
} }
encode gzip
} }
www.bizmatch.net { qrmaster.net {
tls { redir https://www.qrmaster.net{uri} permanent
dns cloudflare q1P7J3uqS96FGj_iiX2mI8y1ulTaIFrTp8tyTXhG
} }
www.innungsapp.com {
handle {
reverse_proxy host.docker.internal:3010
} }
www.bayarea-cc.com { log {
tls { output file /var/log/caddy/innungsapp.log
dns cloudflare q1P7J3uqS96FGj_iiX2mI8y1ulTaIFrTp8tyTXhG format console
} }
encode gzip
} }
innungsapp.com {
redir https://www.innungsapp.com{uri} permanent
}
auth.bizmatch.net { auth.bizmatch.net {
reverse_proxy keycloak:8080 { reverse_proxy https://bizmatch-net.firebaseapp.com {
header_up Host {http.request.host} header_up Host bizmatch-net.firebaseapp.com
header_up X-Real-IP {http.request.remote} header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-For {http.request.remote} header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {http.request.host} header_up X-Real-IP {remote_host}
header_up X-Forwarded-Server {http.request.host}
header_up X-Forwarded-Port {http.request.port}
header_up X-Forwarded-Proto {http.request.scheme}
header_up Upgrade {http.request.header.Upgrade}
header_up Connection {http.request.header.Connection}
# Entfernen des X-Frame-Options-Headers
# header_up -X-Frame-Options
}
tls {
dns cloudflare q1P7J3uqS96FGj_iiX2mI8y1ulTaIFrTp8tyTXhG
} }
} }
gitea.bizmatch.net { gitea.bizmatch.net {
reverse_proxy gitea:3500 reverse_proxy gitea:3500
tls {
dns cloudflare q1P7J3uqS96FGj_iiX2mI8y1ulTaIFrTp8tyTXhG
}
} }
dev.bizmatch.net { api.bizmatch.net {
handle /pictures/* { reverse_proxy host.docker.internal:3001 { # Neu: Proxy auf Prod-Port 3001
root * /home/aknuth/git/bizmatch-project/bizmatch-server
file_server
}
handle {
root * /srv
try_files {path} {path}/ /index.html
file_server
}
log {
output file /var/log/caddy/access.log {
roll_size 10MB
roll_keep 5
roll_keep_for 48h
}
}
encode gzip
tls {
dns cloudflare q1P7J3uqS96FGj_iiX2mI8y1ulTaIFrTp8tyTXhG
}
}
api-dev.bizmatch.net {
reverse_proxy host.docker.internal:3000 {
header_up X-Real-IP {http.request.header.CF-Connecting-IP} header_up X-Real-IP {http.request.header.CF-Connecting-IP}
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP} header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
header_up X-Forwarded-Proto {http.request.header.X-Forwarded-Proto} header_up X-Forwarded-Proto {http.request.header.X-Forwarded-Proto}
header_up CF-IPCountry {http.request.header.CF-IPCountry} header_up CF-IPCountry {http.request.header.CF-IPCountry}
} }
tls { }
dns cloudflare q1P7J3uqS96FGj_iiX2mI8y1ulTaIFrTp8tyTXhG
greenlenspro.com {
encode zstd gzip
@storage path /storage /storage/*
handle @storage {
uri strip_prefix /storage
reverse_proxy minio:9000
}
@api path /api /api/* /auth /auth/* /v1 /v1/* /health /plants /plants/*
handle @api {
reverse_proxy api:3000
}
handle {
reverse_proxy landing:3000
} }
} }

13
caddy/Dockerfile.caddy Normal file
View File

@@ -0,0 +1,13 @@
# Dockerfile.caddy
ARG CADDY_VERSION=2.9.1
FROM caddy:${CADDY_VERSION}-builder AS builder
# Caddy in exakt dieser Version + Plugins bauen
RUN xcaddy build ${CADDY_VERSION} \
--with github.com/caddy-dns/cloudflare \
--with github.com/caddyserver/replace-response
FROM caddy:${CADDY_VERSION}
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
RUN mkdir -p /var/log/caddy

View File

@@ -1,11 +1,13 @@
version: '3.7'
services: services:
caddy: caddy:
image: custom-caddy:2.9.1-rr1
container_name: caddy container_name: caddy
image: iarekylew00t/caddy-cloudflare:latest build:
context: .
dockerfile: Dockerfile.caddy
restart: unless-stopped restart: unless-stopped
ports: ports:
# - "80:80" - "80:80"
- "443:443" - "443:443"
extra_hosts: extra_hosts:
- 'host.docker.internal:host-gateway' - 'host.docker.internal:host-gateway'
@@ -13,16 +15,26 @@ services:
- bizmatch - bizmatch
- keycloak - keycloak
- gitea - gitea
- mail_network
- greenlens_net
volumes: volumes:
- $PWD/Caddyfile:/etc/caddy/Caddyfile - $PWD/Caddyfile:/etc/caddy/Caddyfile
- $PWD/email_autodiscover:/etc/caddy/email_autodiscover
- $PWD/email.mobileconfig.tpl:/etc/caddy/email.mobileconfig.tpl
- $PWD/email-setup:/var/www/email-setup
- caddy_data:/data - caddy_data:/data
- caddy_config:/config - caddy_config:/config
#- /home/aknuth/git/bizmatch/dist/bizmatch/browser:/srv - /home/aknuth/git/bizmatch-project/bizmatch/dist/bizmatch/browser:/home/aknuth/git/bizmatch-project/bizmatch/dist/bizmatch/browser
- /home/aknuth/git/bizmatch-project/bizmatch/dist/bizmatch/browser:/srv - /home/aknuth/git/bizmatch-project-prod/bizmatch/dist/bizmatch/browser:/home/aknuth/git/bizmatch-project-prod/bizmatch/dist/bizmatch/browser
- /home/aknuth/git/bizmatch-project/bizmatch-server/pictures:/home/aknuth/git/bizmatch-project/bizmatch-server/pictures - /home/aknuth/git/bizmatch-project/bizmatch-server/pictures:/home/aknuth/git/bizmatch-project/bizmatch-server/pictures
- /home/aknuth/git/bizmatch-project-prod/bizmatch-server/pictures:/home/aknuth/git/bizmatch-project-prod/bizmatch-server/pictures
- /home/aknuth/git/annaville-sda-site/dist:/home/aknuth/git/annaville-sda-site/dist:ro # ← DAS FEHLT!
- /home/aknuth/git/bay-area-affiliates/dist/bay-area-affiliates/browser:/app
- /home/aknuth/log/caddy:/var/log/caddy - /home/aknuth/log/caddy:/var/log/caddy
- /home/aknuth/git/config-email/frontend/dist:/home/aknuth/git/config-email/frontend/dist:ro
environment: environment:
- CLOUDFLARE_API_TOKEN=q1P7J3uqS96FGj_iiX2mI8y1ulTaIFrTp8tyTXhG - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}
- CLOUDFLARE_EMAIL=${CLOUDFLARE_EMAIL}
networks: networks:
bizmatch: bizmatch:
@@ -31,6 +43,10 @@ networks:
external: true external: true
gitea: gitea:
external: true external: true
mail_network:
external: true
greenlens_net:
external: true
volumes: volumes:
caddy_data: caddy_data:

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8" ?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
<Response xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a">
<Account>
<AccountType>email</AccountType>
<Action>settings</Action>
<Protocol>
<Type>IMAP</Type>
<Server>mail.email-srvr.com</Server>
<Port>993</Port>
<DomainRequired>off</DomainRequired>
<LoginName></LoginName>
<SPA>off</SPA>
<SSL>on</SSL>
<AuthRequired>on</AuthRequired>
</Protocol>
<Protocol>
<Type>SMTP</Type>
<Server>mail.email-srvr.com</Server>
<Port>465</Port>
<DomainRequired>off</DomainRequired>
<LoginName></LoginName>
<SPA>off</SPA>
<SSL>on</SSL>
<AuthRequired>on</AuthRequired>
</Protocol>
</Account>
</Response>
</Autodiscover>

BIN
caddy/email-setup/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Setup</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #f2f2f7; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; padding: 20px; box-sizing: border-box; }
.card { background: white; padding: 2.5rem; border-radius: 24px; box-shadow: 0 12px 30px rgba(0,0,0,0.1); width: 100%; max-width: 420px; text-align: center; transition: all 0.3s ease; }
.logo { width: 80px; height: 80px; margin-bottom: 1.5rem; }
h1 { margin: 0 0 1rem 0; color: #1a1a1a; font-size: 1.8rem; }
p { color: #666; line-height: 1.5; margin-bottom: 2rem; }
/* Input Section */
#input-section { transition: opacity 0.3s ease; }
input { width: 100%; padding: 16px; margin-bottom: 16px; border: 2px solid #eee; border-radius: 14px; font-size: 16px; box-sizing: border-box; transition: border-color 0.2s; outline: none; }
input:focus { border-color: #007AFF; }
button { width: 100%; padding: 16px; background: #007AFF; color: white; border: none; border-radius: 14px; font-size: 18px; font-weight: 600; cursor: pointer; transition: background 0.2s, transform 0.1s; }
button:hover { background: #0062cc; }
button:active { transform: scale(0.98); }
/* QR Section (initially hidden) */
#qr-section { display: none; opacity: 0; transition: opacity 0.5s ease; }
#qrcode { margin: 2rem auto; padding: 15px; background: white; border-radius: 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); display: inline-block; }
#qrcode img { margin: auto; } /* Centers the generated QR code */
.hint { font-size: 0.9rem; color: #888; margin-top: 1.5rem; }
.hint strong { color: #333; }
.error { color: #d32f2f; background: #fde8e8; padding: 10px; border-radius: 8px; font-size: 0.9rem; display: none; margin-bottom: 16px; }
.back-btn { background: transparent; color: #007AFF; margin-top: 1rem; font-size: 16px; }
.back-btn:hover { background: #f0f8ff; }
</style>
</head>
<body>
<div class="card">
<img src="/logo.png" alt="Logo" class="logo">
<div id="input-section">
<h1>Email Setup</h1>
<p>Enter your email address to automatically configure your iPhone or iPad.</p>
<div id="error-msg" class="error">Please enter a valid email address.</div>
<input type="email" id="email" placeholder="name@company.com" required autocomplete="email">
<button onclick="generateQR()">Generate QR Code</button>
</div>
<div id="qr-section">
<h1>Scan me!</h1>
<p>Open the <strong>Camera app</strong> on your iPhone and point it at this code.</p>
<div id="qrcode"></div>
<p class="hint">
Tap the banner that appears at the top.<br>
Click <strong>"Allow"</strong> and then go to <strong>Settings</strong> to install the profile.
</p>
<button class="back-btn" onclick="resetForm()">Back</button>
</div>
</div>
<script>
const inputSection = document.getElementById('input-section');
const qrSection = document.getElementById('qr-section');
const emailInput = document.getElementById('email');
const errorMsg = document.getElementById('error-msg');
let qrcode = null;
function generateQR() {
const email = emailInput.value.trim();
if (!email || !email.includes('@') || email.split('@')[1].length < 3) {
errorMsg.style.display = 'block';
emailInput.focus();
return;
}
errorMsg.style.display = 'none';
const domain = email.split('@')[1];
// The magic link
const targetUrl = `https://autodiscover.${domain}/apple?email=${email}`;
// Hide input, show QR
inputSection.style.display = 'none';
qrSection.style.display = 'block';
setTimeout(() => qrSection.style.opacity = '1', 50);
// Generate (or update) QR Code
if (qrcode === null) {
qrcode = new QRCode(document.getElementById("qrcode"), {
text: targetUrl,
width: 200,
height: 200,
colorDark : "#000000",
colorLight : "#ffffff",
correctLevel : QRCode.CorrectLevel.H
});
} else {
qrcode.clear();
qrcode.makeCode(targetUrl);
}
}
function resetForm() {
qrSection.style.opacity = '0';
setTimeout(() => {
qrSection.style.display = 'none';
inputSection.style.display = 'block';
emailInput.value = '';
emailInput.focus();
}, 300);
}
emailInput.addEventListener("keypress", function(event) {
if (event.key === "Enter") generateQR();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>EmailAccountDescription</key>
<string>{{.Req.URL.Query.Get "email"}}</string>
<key>EmailAccountName</key>
<string>{{.Req.URL.Query.Get "email"}}</string>
<key>EmailAccountType</key>
<string>EmailTypeIMAP</string>
<key>EmailAddress</key>
<string>{{.Req.URL.Query.Get "email"}}</string>
<key>IncomingMailServerAuthentication</key>
<string>EmailAuthPassword</string>
<key>IncomingMailServerHostName</key>
<string>mail.email-srvr.com</string>
<key>IncomingMailServerPortNumber</key>
<integer>993</integer>
<key>IncomingMailServerUseSSL</key>
<true/>
<key>IncomingMailServerUsername</key>
<string>{{.Req.URL.Query.Get "email"}}</string>
<key>OutgoingMailServerAuthentication</key>
<string>EmailAuthPassword</string>
<key>OutgoingMailServerHostName</key>
<string>mail.email-srvr.com</string>
<key>OutgoingMailServerPortNumber</key>
<integer>465</integer>
<key>OutgoingMailServerUseSSL</key>
<true/>
<key>OutgoingMailServerUsername</key>
<string>{{.Req.URL.Query.Get "email"}}</string>
<key>PayloadDescription</key>
<string>E-Mail Konfiguration für {{.Req.URL.Query.Get "email"}}</string>
<key>PayloadDisplayName</key>
<string>{{.Req.URL.Query.Get "email"}}</string>
<key>PayloadIdentifier</key>
<string>com.email-srvr.profile.{{.Req.URL.Query.Get "email"}}</string>
<key>PayloadType</key>
<string>com.apple.mail.managed</string>
<key>PayloadUUID</key>
<string>{{uuidv4}}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</array>
<key>PayloadDescription</key>
<string>Automatische E-Mail Einrichtung für {{.Req.URL.Query.Get "email"}}</string>
<key>PayloadDisplayName</key>
<string>E-Mail Einstellungen</string>
<key>PayloadIdentifier</key>
<string>com.email-srvr.profile.root</string>
<key>PayloadOrganization</key>
<string>IT Support</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>{{uuidv4}}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>

97
caddy/email_autodiscover Normal file
View File

@@ -0,0 +1,97 @@
(email_settings) {
# 1. Autodiscover für Outlook
route /autodiscover/autodiscover.xml {
header Content-Type "application/xml"
# Wir nutzen {header.X-Anchormailbox} um die Email dynamisch einzufügen
respond `<?xml version="1.0" encoding="utf-8"?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
<Response xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a">
<Account>
<AccountType>email</AccountType>
<Action>settings</Action>
<Protocol>
<Type>IMAP</Type>
<Server>mail.email-srvr.com</Server>
<Port>993</Port>
<DomainRequired>on</DomainRequired>
<LoginName>{header.X-Anchormailbox}</LoginName>
<SPA>off</SPA>
<SSL>on</SSL>
<AuthRequired>on</AuthRequired>
</Protocol>
<Protocol>
<Type>POP3</Type>
<Server>mail.email-srvr.com</Server>
<Port>995</Port>
<DomainRequired>on</DomainRequired>
<LoginName>{header.X-Anchormailbox}</LoginName>
<SPA>off</SPA>
<SSL>on</SSL>
<AuthRequired>on</AuthRequired>
</Protocol>
<Protocol>
<Type>SMTP</Type>
<Server>mail.email-srvr.com</Server>
<Port>465</Port>
<DomainRequired>on</DomainRequired>
<LoginName>{header.X-Anchormailbox}</LoginName>
<SPA>off</SPA>
<SSL>on</SSL>
<AuthRequired>on</AuthRequired>
</Protocol>
</Account>
</Response>
</Autodiscover>` 200
}
# 2. JSON Autodiscover (Modern Outlook) - bleibt gleich
route /autodiscover/autodiscover.json {
header Content-Type "application/json"
respond `{
"Protocol": "AutodiscoverV1",
"Url": "https://autodiscover.bayarea-cc.com/autodiscover/autodiscover.xml"
}` 200
}
# 3. Thunderbird Autoconfig - bleibt gleich (dort funktioniert %EMAILADDRESS% ja nativ)
route /mail/config-v1.1.xml {
header Content-Type "application/xml"
respond `<?xml version="1.0"?>
<clientConfig version="1.1">
<emailProvider id="email-srvr.com">
<displayName>Rackspace Email</displayName>
<incomingServer type="imap">
<hostname>mail.email-srvr.com</hostname>
<port>993</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>mail.email-srvr.com</hostname>
<port>465</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
</emailProvider>
</clientConfig>` 200
}
# NEU: Apple MobileConfig Route
# Aufrufbar über: /apple?email=kunde@domain.de
route /apple {
# KORREKTUR: Wir müssen Caddy sagen, dass er diesen MIME-Type bearbeiten soll!
templates {
mime "application/x-apple-aspen-config"
}
# Den richtigen MIME-Type setzen
header Content-Type "application/x-apple-aspen-config; charset=utf-8"
# Pfad zur Datei im Container
root * /etc/caddy
rewrite * /email.mobileconfig.tpl
file_server
}
}

View File

@@ -0,0 +1,10 @@
services:
certbot:
image: certbot/dns-cloudflare
volumes:
- ./letsencrypt:/etc/letsencrypt
- ./cloudflare:/cloudflare
environment:
- DOMAIN=${DOMAIN:-mail.haiky.app}
- EMAIL=${EMAIL:-deine-email@example.com}
command: certonly --dns-cloudflare --dns-cloudflare-credentials /cloudflare/cloudflare.ini --email ${EMAIL:-deine-email@example.com} --agree-tos --no-eff-email -d ${DOMAIN:-mail.haiky.app}

11
certbot/get-certificate.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
DOMAIN=${1:-mail.haiky.app}
EMAIL=${2:-deine-email@example.com}
echo "Generiere Zertifikat für $DOMAIN mit E-Mail $EMAIL"
docker compose run --rm certbot certonly --dns-cloudflare --dns-cloudflare-credentials /cloudflare/cloudflare.ini --email "$EMAIL" --agree-tos --no-eff-email -d "$DOMAIN"
echo "Ändere Berechtigungen der generierten Zertifikate"
sudo chown -R $(id -u):$(id -g) ./letsencrypt
echo "Fertig! Zertifikate wurden in ./letsencrypt/live/$DOMAIN/ gespeichert"

209
dovecot/awsdomain.sh Executable file
View File

@@ -0,0 +1,209 @@
#!/bin/bash
# awsdomain.sh - Konfiguriert Cloudflare mit den Amazon SES Angaben
if [ -z "$DOMAIN_NAME" ]; then
echo "Fehler: DOMAIN_NAME ist nicht gesetzt."
echo "Bitte setzen Sie die Variable mit: export DOMAIN_NAME='IhreDomain.de'"
exit 1 # Skript mit Fehlercode beenden
fi
AWS_REGION="us-east-2"
EMAIL_PREFIX="emails/"
S3_BUCKET_NAME=$(echo "$DOMAIN_NAME" | tr '.' '-' | awk '{print $0 "-emails"}')
# Ersetzen Sie alle Punkte durch Bindestriche und erstellen Sie den RULE_NAME
RULE_NAME="store-$(echo "$DOMAIN_NAME" | tr '.' '-')-to-s3"
# ------------------------
# S3 Bucket erstellen
# ------------------------
echo "S3 Bucket erstellen..."
aws s3api create-bucket \
--bucket ${S3_BUCKET_NAME} \
--region ${AWS_REGION} \
--create-bucket-configuration LocationConstraint=${AWS_REGION}
# Öffentlichen Zugriff blockieren
aws s3api put-public-access-block \
--bucket ${S3_BUCKET_NAME} \
--public-access-block-configuration "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
# Lebenszyklus-Konfiguration hinzufügen
aws s3api put-bucket-lifecycle-configuration \
--bucket ${S3_BUCKET_NAME} \
--lifecycle-configuration '{
"Rules": [
{
"ID": "DeleteOldEmails",
"Status": "Enabled",
"Expiration": {
"Days": 90
},
"Filter": {
"Prefix": ""
}
}
]
}'
echo "S3 Bucket Policy hinzufügen..."
aws s3api put-bucket-policy \
--bucket ${S3_BUCKET_NAME} \
--policy '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ses.amazonaws.com"
},
"Action": [
"s3:PutObject",
"s3:GetBucketLocation",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::'${S3_BUCKET_NAME}'",
"arn:aws:s3:::'${S3_BUCKET_NAME}'/*"
]
}
]
}'
# ------------------------
# SES Domain-Identität erstellen
# ------------------------
echo "SES Domain-Identität erstellen..."
aws sesv2 create-email-identity \
--email-identity ${DOMAIN_NAME} \
--region ${AWS_REGION}
# DKIM-Signierung aktivieren
aws sesv2 put-email-identity-dkim-attributes \
--email-identity ${DOMAIN_NAME} \
--signing-enabled \
--region ${AWS_REGION}
# Mail-From-Domain konfigurieren
aws sesv2 put-email-identity-mail-from-attributes \
--email-identity ${DOMAIN_NAME} \
--mail-from-domain "mail.${DOMAIN_NAME}" \
--behavior-on-mx-failure USE_DEFAULT_VALUE \
--region ${AWS_REGION}
# 3. Receipt Rule Set erstellen
echo "Receipt Rule for bizmatch ruleset erstellen..."
aws ses create-receipt-rule --rule-set-name "bizmatch-ruleset" --rule '{
"Name": "'"${RULE_NAME}"'",
"Enabled": true,
"ScanEnabled": true,
"Actions": [{
"S3Action": {
"BucketName": "'"${S3_BUCKET_NAME}"'",
"ObjectKeyPrefix": "emails/"
}
}],
"TlsPolicy": "Require"
}'
# --------------------------
# IAM-User erstellen
# -------------------------
USER_NAME="${DOMAIN_NAME//./-}-ses-user" # Ersetzt Punkte durch Bindestriche für validen IAM-Username
NODE_SCRIPT_PATH="./generate_ses_smtp_password.js"
# Prüfen, ob das Node.js-Script existiert
if [ ! -f "$NODE_SCRIPT_PATH" ]; then
echo "Fehler: Das Node.js-Script '$NODE_SCRIPT_PATH' wurde nicht gefunden."
echo "Bitte stelle sicher, dass das Script im angegebenen Pfad existiert."
exit 1
fi
echo "Erstelle IAM-User: $USER_NAME"
aws iam create-user --user-name $USER_NAME
# 2. Policy-Dokument für SES-Vollzugriff erstellen
POLICY_NAME="${USER_NAME}-SendRawEmailPolicy"
POLICY_DOCUMENT='{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "ses:SendRawEmail",
"Resource": "*"
}
]
}'
echo "Erstelle benutzerdefinierte Policy für SES SendRawEmail"
POLICY_ARN=$(aws iam create-policy \
--policy-name $POLICY_NAME \
--policy-document "$POLICY_DOCUMENT" \
--query 'Policy.Arn' \
--output text)
echo "Hänge Policy an: $POLICY_ARN"
aws iam attach-user-policy \
--user-name $USER_NAME \
--policy-arn $POLICY_ARN
# 4. Access Key und Secret Key für den User erstellen
echo "Erstelle Access Key für den User: $USER_NAME"
KEY_OUTPUT=$(aws iam create-access-key --user-name $USER_NAME)
# 5. Keys ausgeben (am besten in eine sichere Datei speichern)
echo "Zugriffsschlüssel wurden erstellt. Bitte sicher aufbewahren:"
echo "$KEY_OUTPUT" | jq .
# Optional: Keys in separaten Variablen speichern für weitere Verwendung
ACCESS_KEY=$(echo "$KEY_OUTPUT" | jq -r .AccessKey.AccessKeyId)
SECRET_KEY=$(echo "$KEY_OUTPUT" | jq -r .AccessKey.SecretAccessKey)
echo "ACCESS_KEY: $ACCESS_KEY"
echo "SECRET_KEY: $SECRET_KEY"
echo "WICHTIG: Speichere den Secret Key jetzt, da er später nicht mehr abgerufen werden kann!"
# --------------------------
# SMTP Passwort generieren
# --------------------------
echo -e "\nGeneriere SMTP-Passwort für Region $AWS_REGION..."
# Führe das Node.js-Script aus, um das SMTP-Passwort zu generieren
SMTP_PASSWORD=$(node "$NODE_SCRIPT_PATH" "$SECRET_KEY" "$AWS_REGION")
# Prüfen, ob die Ausführung erfolgreich war
if [ $? -ne 0 ]; then
echo "Fehler bei der Generierung des SMTP-Passworts. Bitte überprüfe das Node.js-Script."
exit 1
fi
# SMTP-Benutzername ist der Access Key
SMTP_USERNAME="$ACCESS_KEY"
# Ausgabe der SMTP-Anmeldeinformationen
echo -e "\nSMTP-Anmeldeinformationen für Amazon SES in Region $AWS_REGION:"
echo "--------------------------------------------------------------"
echo "SMTP-Server: email-smtp.$AWS_REGION.amazonaws.com"
echo "SMTP-Port: 587 (TLS) oder 465 (SSL)"
echo "SMTP-Benutzername: $SMTP_USERNAME"
echo "SMTP-Passwort: $SMTP_PASSWORD"
# Speichere die Anmeldeinformationen in einer Datei
echo -e "\nSpeichere SMTP-Anmeldeinformationen in ses_smtp_credentials.txt"
cat > "ses_smtp_credentials.txt" << EOF
SMTP-Server: email-smtp.$AWS_REGION.amazonaws.com
SMTP-Port: 587 (TLS) oder 465 (SSL)
SMTP-Benutzername: $SMTP_USERNAME
SMTP-Passwort: $SMTP_PASSWORD
EOF
# Hinweise für die weitere Konfiguration
echo -e "\nHinweise:"
echo "1. Stellen Sie sicher, dass Ihre Domains in Amazon SES verifiziert sind."
echo "2. Bei Bedarf beantragen Sie die Aufhebung der SES-Sandbox-Einschränkungen."
echo "3. Für SMTP-Anwendungen verwenden Sie die SMTP-Anmeldeinformationen (nicht die IAM-Zugangsdaten)."
# Format für .env-Datei
echo -e "\nFür .env-Datei:"
echo "AWS_SES_SMTP_USERNAME=$SMTP_USERNAME"
echo "AWS_SES_SMTP_PASSWORD=$SMTP_PASSWORD"
echo "AWS_SES_SMTP_HOST=email-smtp.$AWS_REGION.amazonaws.com"
echo "AWS_SES_SMTP_PORT=587"

127
dovecot/awsiam.sh Executable file
View File

@@ -0,0 +1,127 @@
#!/bin/bash
# awsiam.sh - Erstellt einen IAM-Benutzer für Amazon SES mit SMTP-Zugangsdaten
# Überprüfen, ob die Domain-Variable gesetzt ist
if [ -z "$DOMAIN_NAME" ]; then
echo "Fehler: DOMAIN_NAME ist nicht gesetzt."
echo "Bitte setzen Sie die Variable mit: export DOMAIN_NAME='IhreDomain.de'"
exit 1
fi
# Konfiguration
AWS_REGION=${AWS_REGION:-"us-east-2"}
USER_NAME="${DOMAIN_NAME//./-}-ses-user" # Ersetzt Punkte durch Bindestriche für validen IAM-Username
NODE_SCRIPT_PATH="./generate_ses_smtp_password.js"
OUTPUT_FILE="${DOMAIN_NAME//./_}_ses_credentials.txt" # Sichere Dateibenennung
# Prüfen, ob das Node.js-Script existiert
if [ ! -f "$NODE_SCRIPT_PATH" ]; then
echo "Fehler: Das Node.js-Script '$NODE_SCRIPT_PATH' wurde nicht gefunden."
echo "Bitte stelle sicher, dass das Script im angegebenen Pfad existiert."
exit 1
fi
echo "=== IAM-Benutzer für SES SMTP-Zugang erstellen ==="
echo "Domain: $DOMAIN_NAME"
echo "Region: $AWS_REGION"
echo "IAM-Benutzername: $USER_NAME"
# --------------------------
# IAM-User erstellen
# --------------------------
echo "Erstelle IAM-User: $USER_NAME"
aws iam create-user --user-name $USER_NAME
# Benutzerdefinierte Policy für SES-Sendeberechtigungen erstellen
POLICY_NAME="${USER_NAME}-SendRawEmailPolicy"
POLICY_DOCUMENT='{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "ses:SendRawEmail",
"Resource": "*"
}
]
}'
echo "Erstelle benutzerdefinierte Policy für SES SendRawEmail"
POLICY_ARN=$(aws iam create-policy \
--policy-name $POLICY_NAME \
--policy-document "$POLICY_DOCUMENT" \
--query 'Policy.Arn' \
--output text)
echo "Hänge Policy an: $POLICY_ARN"
aws iam attach-user-policy \
--user-name $USER_NAME \
--policy-arn $POLICY_ARN
# Access Key und Secret Key für den User erstellen
echo "Erstelle Access Key für den User: $USER_NAME"
KEY_OUTPUT=$(aws iam create-access-key --user-name $USER_NAME)
# Keys ausgeben und in Variablen speichern
echo "Zugriffsschlüssel wurden erstellt. Bitte sicher aufbewahren:"
echo "$KEY_OUTPUT" | jq .
ACCESS_KEY=$(echo "$KEY_OUTPUT" | jq -r .AccessKey.AccessKeyId)
SECRET_KEY=$(echo "$KEY_OUTPUT" | jq -r .AccessKey.SecretAccessKey)
echo "ACCESS_KEY: $ACCESS_KEY"
echo "SECRET_KEY: $SECRET_KEY"
echo "WICHTIG: Speichere den Secret Key jetzt, da er später nicht mehr abgerufen werden kann!"
# --------------------------
# SMTP Passwort generieren
# --------------------------
echo -e "\nGeneriere SMTP-Passwort für Region $AWS_REGION..."
# Führe das Node.js-Script aus, um das SMTP-Passwort zu generieren
SMTP_PASSWORD=$(node "$NODE_SCRIPT_PATH" "$SECRET_KEY" "$AWS_REGION")
# Prüfen, ob die Ausführung erfolgreich war
if [ $? -ne 0 ]; then
echo "Fehler bei der Generierung des SMTP-Passworts. Bitte überprüfe das Node.js-Script."
exit 1
fi
# SMTP-Benutzername ist der Access Key
SMTP_USERNAME="$ACCESS_KEY"
# Ausgabe der SMTP-Anmeldeinformationen
echo -e "\nSMTP-Anmeldeinformationen für Amazon SES in Region $AWS_REGION:"
echo "--------------------------------------------------------------"
echo "SMTP-Server: email-smtp.$AWS_REGION.amazonaws.com"
echo "SMTP-Port: 587 (TLS) oder 465 (SSL)"
echo "SMTP-Benutzername: $SMTP_USERNAME"
echo "SMTP-Passwort: $SMTP_PASSWORD"
# Speichere die Anmeldeinformationen in einer Datei
echo -e "\nSpeichere SMTP-Anmeldeinformationen in $OUTPUT_FILE"
cat > "$OUTPUT_FILE" << EOF
DOMAIN_NAME: $DOMAIN_NAME
SMTP-Server: email-smtp.$AWS_REGION.amazonaws.com
SMTP-Port: 587 (TLS) oder 465 (SSL)
SMTP-Benutzername: $SMTP_USERNAME
SMTP-Passwort: $SMTP_PASSWORD
IAM-Benutzer: $USER_NAME
Access Key ID: $ACCESS_KEY
Secret Access Key: $SECRET_KEY
EOF
chmod 600 "$OUTPUT_FILE" # Nur für den Besitzer lesbar machen
# Format für .env-Datei
echo -e "\nFür .env-Datei:"
echo "AWS_SES_SMTP_USERNAME=$SMTP_USERNAME"
echo "AWS_SES_SMTP_PASSWORD=$SMTP_PASSWORD"
echo "AWS_SES_SMTP_HOST=email-smtp.$AWS_REGION.amazonaws.com"
echo "AWS_SES_SMTP_PORT=587"
echo -e "\nHinweise:"
echo "1. Die SMTP-Anmeldeinformationen wurden in $OUTPUT_FILE gespeichert."
echo "2. Verwenden Sie diese SMTP-Anmeldeinformationen in Ihrer E-Mail-Anwendung oder Ihrem E-Mail-Server."
echo "3. Der IAM-Benutzer hat nur die Berechtigung, E-Mails über SES zu senden."

85
dovecot/awss3.sh Executable file
View File

@@ -0,0 +1,85 @@
#!/bin/bash
# awss3.sh - Erstellt einen S3-Bucket für Amazon SES E-Mail-Speicherung
# Überprüfen, ob die Domain-Variable gesetzt ist
if [ -z "$DOMAIN_NAME" ]; then
echo "Fehler: DOMAIN_NAME ist nicht gesetzt."
echo "Bitte setzen Sie die Variable mit: export DOMAIN_NAME='IhreDomain.de'"
exit 1
fi
# Konfiguration
AWS_REGION=${AWS_REGION:-"us-east-2"}
EMAIL_PREFIX=${EMAIL_PREFIX:-"emails/"}
S3_BUCKET_NAME=$(echo "$DOMAIN_NAME" | tr '.' '-' | awk '{print $0 "-emails"}')
echo "=== S3 Bucket Configuration für $DOMAIN_NAME ==="
echo "Region: $AWS_REGION"
echo "Bucket-Name: $S3_BUCKET_NAME"
echo "E-Mail-Präfix: $EMAIL_PREFIX"
# ------------------------
# S3 Bucket erstellen
# ------------------------
echo "S3 Bucket erstellen..."
aws s3api create-bucket \
--bucket ${S3_BUCKET_NAME} \
--region ${AWS_REGION} \
--create-bucket-configuration LocationConstraint=${AWS_REGION}
# Öffentlichen Zugriff blockieren
echo "Öffentlichen Zugriff blockieren..."
aws s3api put-public-access-block \
--bucket ${S3_BUCKET_NAME} \
--public-access-block-configuration "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
# Lebenszyklus-Konfiguration hinzufügen
echo "Lebenszyklus-Konfiguration hinzufügen (E-Mails werden nach 90 Tagen gelöscht)..."
aws s3api put-bucket-lifecycle-configuration \
--bucket ${S3_BUCKET_NAME} \
--lifecycle-configuration '{
"Rules": [
{
"ID": "DeleteOldEmails",
"Status": "Enabled",
"Expiration": {
"Days": 90
},
"Filter": {
"Prefix": ""
}
}
]
}'
echo "S3 Bucket Policy hinzufügen für SES-Zugriff..."
aws s3api put-bucket-policy \
--bucket ${S3_BUCKET_NAME} \
--policy '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ses.amazonaws.com"
},
"Action": [
"s3:PutObject",
"s3:GetBucketLocation",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::'${S3_BUCKET_NAME}'",
"arn:aws:s3:::'${S3_BUCKET_NAME}'/*"
]
}
]
}'
echo "S3 Bucket $S3_BUCKET_NAME wurde erfolgreich erstellt und konfiguriert."
echo "Bucket-ARN: arn:aws:s3:::$S3_BUCKET_NAME"
# Exportiere Variablen für andere Scripte
echo
echo "Für die Verwendung in den anderen Scripten setzen Sie:"
echo "export S3_BUCKET_NAME=$S3_BUCKET_NAME"

155
dovecot/awsses.sh Executable file
View File

@@ -0,0 +1,155 @@
#!/bin/bash
# awsses.sh - Konfiguriert Amazon SES für eine Domain und erstellt eine Receipt Rule
# Überprüfen, ob die Domain-Variable gesetzt ist
if [ -z "$DOMAIN_NAME" ]; then
echo "Fehler: DOMAIN_NAME ist nicht gesetzt."
echo "Bitte setzen Sie die Variable mit: export DOMAIN_NAME='IhreDomain.de'"
exit 1
fi
# Überprüfen, ob S3_BUCKET_NAME gesetzt ist
if [ -z "$S3_BUCKET_NAME" ]; then
echo "Warnung: S3_BUCKET_NAME ist nicht gesetzt."
echo "Wird automatisch aus DOMAIN_NAME generiert, verwenden Sie idealerweise zuerst awss3.sh."
S3_BUCKET_NAME=$(echo "$DOMAIN_NAME" | tr '.' '-' | awk '{print $0 "-emails"}')
echo "Generierter Bucket-Name: $S3_BUCKET_NAME"
fi
# Konfiguration
AWS_REGION=${AWS_REGION:-"us-east-2"}
EMAIL_PREFIX=${EMAIL_PREFIX:-""}
RULE_NAME="store-$(echo "$DOMAIN_NAME" | tr '.' '-')-to-s3"
echo "=== SES Konfiguration für $DOMAIN_NAME ==="
echo "Region: $AWS_REGION"
echo "S3 Bucket: $S3_BUCKET_NAME"
echo "Receipt Rule Name: $RULE_NAME"
# ------------------------
# SES Domain-Identität erstellen
# ------------------------
echo "SES Domain-Identität erstellen..."
IDENTITY_RESULT=$(aws sesv2 create-email-identity \
--email-identity ${DOMAIN_NAME} \
--region ${AWS_REGION})
echo "Identity erstellt. Überprüfen Sie die DNS-Einträge für die Domain-Verifizierung."
echo "$IDENTITY_RESULT" | jq .
# DKIM-Signierung aktivieren
echo "DKIM-Signierung aktivieren..."
aws sesv2 put-email-identity-dkim-attributes \
--email-identity ${DOMAIN_NAME} \
--signing-enabled \
--region ${AWS_REGION}
# Mail-From-Domain konfigurieren
echo "Mail-From-Domain konfigurieren..."
aws sesv2 put-email-identity-mail-from-attributes \
--email-identity ${DOMAIN_NAME} \
--mail-from-domain "mail.${DOMAIN_NAME}" \
--behavior-on-mx-failure USE_DEFAULT_VALUE \
--region ${AWS_REGION}
# Überprüfen, ob der Rule Set existiert, sonst erstellen
echo "Überprüfe oder erstelle Receipt Rule Set..."
RULESET_EXISTS=$(aws ses describe-receipt-rule-sets --region ${AWS_REGION} | jq -r '.RuleSets[] | select(.Name == "bizmatch-ruleset") | .Name')
if [ -z "$RULESET_EXISTS" ]; then
echo "Receipt Rule Set 'bizmatch-ruleset' existiert nicht, wird erstellt..."
aws ses create-receipt-rule-set --rule-set-name "bizmatch-ruleset" --region ${AWS_REGION}
else
echo "Receipt Rule Set 'bizmatch-ruleset' existiert bereits."
fi
# Receipt Rule erstellen
echo "Receipt Rule für E-Mail-Empfang erstellen..."
aws ses create-receipt-rule --rule-set-name "bizmatch-ruleset" --rule '{
"Name": "'"${RULE_NAME}"'",
"Enabled": true,
"ScanEnabled": true,
"Actions": [{
"S3Action": {
"BucketName": "'"${S3_BUCKET_NAME}"'",
"ObjectKeyPrefix": "'"${EMAIL_PREFIX}"'"
}
}],
"TlsPolicy": "Require",
"Recipients": ["'"${DOMAIN_NAME}"'"]
}' --region ${AWS_REGION}
# Prüfen, ob der Rule Set aktiv ist
ACTIVE_RULESET=$(aws ses describe-active-receipt-rule-set --region ${AWS_REGION} | jq -r '.Metadata.Name')
if [ "$ACTIVE_RULESET" != "bizmatch-ruleset" ]; then
echo "Aktiviere Rule Set 'bizmatch-ruleset'..."
aws ses set-active-receipt-rule-set --rule-set-name "bizmatch-ruleset" --region ${AWS_REGION}
else
echo "Rule Set 'bizmatch-ruleset' ist bereits aktiv."
fi
# ------------------------
# Lambda-Funktion mit SES verknüpfen
# ------------------------
echo "Verknüpfe Lambda-Funktion 'ses-to-sqs' mit SES..."
# Lambda ARN ermitteln
LAMBDA_ARN=$(aws lambda get-function \
--function-name ses-to-sqs \
--region ${AWS_REGION} \
--query 'Configuration.FunctionArn' \
--output text)
if [ -z "$LAMBDA_ARN" ]; then
echo "FEHLER: Lambda-Funktion 'ses-to-sqs' nicht gefunden!"
echo "Bitte zuerst Lambda-Funktion deployen."
exit 1
fi
echo "Lambda ARN: $LAMBDA_ARN"
# SES Permission für Lambda hinzufügen (falls noch nicht vorhanden)
echo "Füge SES-Berechtigung zur Lambda-Funktion hinzu..."
aws lambda add-permission \
--function-name ses-to-sqs \
--statement-id "AllowSESInvoke-${DOMAIN_NAME//./}" \
--action "lambda:InvokeFunction" \
--principal ses.amazonaws.com \
--source-account $(aws sts get-caller-identity --query Account --output text) \
--region ${AWS_REGION} 2>/dev/null || echo "Permission bereits vorhanden"
# Receipt Rule UPDATE: Lambda Action hinzufügen
echo "Aktualisiere Receipt Rule mit Lambda Action..."
aws ses update-receipt-rule --rule-set-name "bizmatch-ruleset" --rule '{
"Name": "'"${RULE_NAME}"'",
"Enabled": true,
"ScanEnabled": true,
"Actions": [
{
"S3Action": {
"BucketName": "'"${S3_BUCKET_NAME}"'",
"ObjectKeyPrefix": "'"${EMAIL_PREFIX}"'"
}
},
{
"LambdaAction": {
"FunctionArn": "'"${LAMBDA_ARN}"'",
"InvocationType": "Event"
}
}
],
"TlsPolicy": "Require",
"Recipients": ["'"${DOMAIN_NAME}"'"]
}' --region ${AWS_REGION}
echo "✅ Lambda-Funktion erfolgreich mit SES verknüpft!"
echo "SES-Konfiguration für $DOMAIN_NAME abgeschlossen."
echo
echo "WICHTIG: Überprüfen Sie die Ausgabe oben für DNS-Einträge, die Sie bei Ihrem DNS-Provider setzen müssen:"
echo "1. DKIM-Einträge (3 CNAME-Einträge)"
echo "2. MAIL FROM MX und TXT-Einträge"
echo "3. SPF-Eintrag (TXT): v=spf1 include:amazonses.com ~all"
echo
echo "Nach dem Setzen der DNS-Einträge kann es bis zu 72 Stunden dauern, bis die Verifizierung abgeschlossen ist."

160
dovecot/cloudflareDns.sh Executable file
View File

@@ -0,0 +1,160 @@
#!/bin/bash
# Cloudflare API-Konfiguration
# Setze deine API-Schlüssel und Zone-ID als Umgebungsvariablen oder ersetze sie direkt
# CF_ZONE_ID="1b7756cee93ed8ba8c05bdc3cb0a5da8" # Die Zone-ID deiner Domain bei Cloudflare
AWS_REGION="us-east-2" # AWS-Region
if [ -z "$DOMAIN_NAME" ]; then
echo "Fehler: DOMAIN_NAME ist nicht gesetzt."
echo "Bitte setzen Sie die Variable mit: export DOMAIN_NAME='IhreDomain.de'"
exit 1 # Skript mit Fehlercode beenden
fi
# Überprüfen, ob der erforderliche API-Token gesetzt ist
if [ -z "$CF_API_TOKEN" ]; then
echo "Fehler: Bitte setze CF_API_TOKEN als Umgebungsvariable oder im Skript."
exit 1
fi
# Zone ID basierend auf Domain-Namen abrufen
echo "Zone ID für $DOMAIN_NAME abrufen..."
ZONE_RESPONSE=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=$DOMAIN_NAME" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json")
# Überprüfen, ob die Antwort erfolgreich war
if [ "$(echo $ZONE_RESPONSE | jq -r '.success')" != "true" ]; then
echo "Fehler beim Abrufen der Zone ID:"
echo $ZONE_RESPONSE | jq .
exit 1
fi
# Zone ID extrahieren
CF_ZONE_ID=$(echo $ZONE_RESPONSE | jq -r '.result[0].id')
# Überprüfen, ob eine Zone ID gefunden wurde
if [ -z "$CF_ZONE_ID" ] || [ "$CF_ZONE_ID" = "null" ]; then
echo "Keine Zone ID für $DOMAIN_NAME gefunden. Bitte stelle sicher, dass die Domain bei Cloudflare registriert ist."
exit 1
fi
echo "Zone ID für $DOMAIN_NAME: $CF_ZONE_ID"
# Hilfsfunktion für DNS-Einträge anlegen
create_dns_record() {
local TYPE=$1
local NAME=$2
local CONTENT=$3
local PROXIED=$4
local TTL=$5
local PRIORITY=$6 # Neu: MX-Priority
# Standardwerte für Proxied und TTL setzen, falls nicht angegeben
if [ -z "$PROXIED" ]; then
PROXIED="false"
fi
if [ -z "$TTL" ]; then
TTL=3600 # 1 Stunde
fi
echo "Erstelle $TYPE-Eintrag für $NAME mit Inhalt $CONTENT..."
# Json Payload vorbereiten abhängig vom Record-Typ
local JSON_DATA=""
if [ "$TYPE" = "MX" ]; then
# Bei MX-Einträgen müssen wir die Priority separat angeben
if [ -z "$PRIORITY" ]; then
PRIORITY=10 # Standard-Priority, falls nicht angegeben
fi
JSON_DATA="{
\"type\": \"$TYPE\",
\"name\": \"$NAME\",
\"content\": \"$CONTENT\",
\"ttl\": $TTL,
\"priority\": $PRIORITY,
\"proxied\": $PROXIED
}"
elif [ "$TYPE" = "TXT" ]; then
# Bei TXT-Einträgen müssen wir sicherstellen, dass der Inhalt in Anführungszeichen steht
# Aber Anführungszeichen innerhalb von JSON müssen escaped werden
# Wir entfernen zuerst alle vorhandenen Anführungszeichen und fügen sie dann korrekt hinzu
CONTENT=$(echo "$CONTENT" | sed 's/"//g')
JSON_DATA="{
\"type\": \"$TYPE\",
\"name\": \"$NAME\",
\"content\": \"\\\"$CONTENT\\\"\",
\"ttl\": $TTL,
\"proxied\": $PROXIED
}"
else
# Für alle anderen Record-Typen (z.B. CNAME)
JSON_DATA="{
\"type\": \"$TYPE\",
\"name\": \"$NAME\",
\"content\": \"$CONTENT\",
\"ttl\": $TTL,
\"proxied\": $PROXIED
}"
fi
# API-Aufruf an Cloudflare
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "$JSON_DATA" | jq .
}
# DKIM-Einträge abrufen und bei Cloudflare eintragen
echo "DKIM-Tokens abrufen von AWS SES..."
DKIM_TOKENS=$(aws ses get-identity-dkim-attributes \
--identities ${DOMAIN_NAME} \
--region ${AWS_REGION} \
--query "DkimAttributes.\"${DOMAIN_NAME}\".DkimTokens" \
--output text)
# Überprüfen, ob DKIM-Tokens abgerufen wurden
if [ -z "$DKIM_TOKENS" ]; then
echo "Fehler: Konnte DKIM-Tokens nicht abrufen. Ist die Domain bei AWS SES verifiziert?"
exit 1
fi
# Domain-Verifizierungstoken abrufen
VERIFICATION_TOKEN=$(aws ses get-identity-verification-attributes \
--identities ${DOMAIN_NAME} \
--region ${AWS_REGION} \
--query "VerificationAttributes.\"${DOMAIN_NAME}\".VerificationToken" \
--output text)
# DKIM-Einträge anlegen
echo "DKIM-Einträge anlegen bei Cloudflare..."
for TOKEN in ${DKIM_TOKENS}; do
create_dns_record "CNAME" "${TOKEN}._domainkey.${DOMAIN_NAME}" "${TOKEN}.dkim.amazonses.com" "false" 3600
done
# Domain-Verifizierungs-TXT-Eintrag anlegen
echo "Domain-Verifizierungs-TXT-Eintrag anlegen bei Cloudflare..."
create_dns_record "TXT" "_amazonses.${DOMAIN_NAME}" "${VERIFICATION_TOKEN}" "false" 3600
# MX-Einträge anlegen
echo "MX-Einträge anlegen bei Cloudflare..."
create_dns_record "MX" "${DOMAIN_NAME}" "inbound-smtp.${AWS_REGION}.amazonaws.com" "false" 3600 10
create_dns_record "MX" "mail.${DOMAIN_NAME}" "feedback-smtp.${AWS_REGION}.amazonses.com" "false" 3600 10
# CNAME für mail.{Domain} anlegen
echo "CNAME für mail.${DOMAIN_NAME} anlegen bei Cloudflare..."
create_dns_record "CNAME" "imap.${DOMAIN_NAME}" "${DOMAIN_NAME}" "false" 3600
# SPF-Eintrag anlegen
echo "SPF-Eintrag anlegen bei Cloudflare..."
create_dns_record "TXT" "mail.${DOMAIN_NAME}" "v=spf1 include:amazonses.com ~all" "false" 3600
# DMARC-Eintrag anlegen
echo "DMARC-Eintrag anlegen bei Cloudflare..."
create_dns_record "TXT" "_dmarc.${DOMAIN_NAME}" "v=DMARC1; p=quarantine; pct=100; rua=mailto:postmaster@${DOMAIN_NAME}" "false" 3600
echo "DNS-Einrichtung abgeschlossen."
echo "Es kann bis zu 72 Stunden dauern, bis AWS SES die Domain verifiziert hat."

View File

@@ -0,0 +1,52 @@
# Dovecot Konfiguration mit Plain-Text Passwörtern
# Für Version 2.3.21.1
# Protokolle aktivieren
protocols = imap pop3
# Logging
log_path = /var/log/dovecot.log
info_log_path = /var/log/dovecot-info.log
debug_log_path = /var/log/dovecot-debug.log
# Mail-Location
# Für Benutzer mit @ in der E-Mail-Adresse
mail_location = maildir:/var/mail/%d/%n
# Authentifizierung
auth_mechanisms = plain login
disable_plaintext_auth = no
# Benutzerdatenbank (passwd-datei)
passdb {
driver = passwd-file
args = scheme=PLAIN username_format=%u /etc/dovecot/passwd
}
userdb {
driver = passwd-file
args = username_format=%u /etc/dovecot/passwd
}
# Passwort-Schema (plaintext) muss in der passdb definiert werden
# Die globale Einstellung default_pass_scheme ist nicht verfügbar
# Mail-Berechtigungen
mail_uid = vmail
mail_gid = vmail
# SSL-Konfiguration
ssl = yes
ssl_cert = </etc/dovecot/ssl/imap.bizmatch.net/fullchain1.pem
ssl_key = </etc/dovecot/ssl/imap.bizmatch.net/privkey1.pem
# In dovecot.conf hinzufügen
local_name imap.haiky.app {
ssl_cert = </etc/dovecot/ssl/imap.haiky.app/fullchain1.pem
ssl_key = </etc/dovecot/ssl/imap.haiky.app/privkey1.pem
}
local_name imap.andreasknuth.de {
ssl_cert = </etc/dovecot/ssl/imap.andreasknuth.de/fullchain1.pem
ssl_key = </etc/dovecot/ssl/imap.andreasknuth.de/privkey1.pem
}

View File

@@ -0,0 +1,41 @@
# /etc/dovecot/dovecot.conf (im Container)
# Grundlegende Einstellungen
protocols = imap
listen = *, ::
# Benutzerauthentifizierung
auth_mechanisms = plain login
# Plaintext-Authentifizierung erlauben
disable_plaintext_auth = no
passdb {
driver = passwd-file
args = /etc/dovecot/users
}
userdb {
driver = passwd-file
args = /etc/dovecot/users
}
# Mail-Speicherort
mail_location = maildir:/var/mail/%u
# Da wir hinter Caddy sind, können wir TLS auf Dovecot deaktivieren
# oder nur intern auf nicht-exponierten Ports aktivieren
ssl = no
# Wenn Sie dennoch direkten Zugriff auf Dovecot ermöglichen möchten:
# ssl = yes
# ssl_cert = </etc/dovecot/ssl/fullchain.pem
# ssl_key = </etc/dovecot/ssl/privkey.pem
# IMAP-Konfiguration
# protocol imap {
# mail_plugins = $mail_plugins
# }
# Logging
log_path = /var/log/dovecot.log
info_log_path = /var/log/dovecot-info.log

View File

@@ -0,0 +1,2 @@
user1:password123
user2:secret456

View File

@@ -0,0 +1,72 @@
dovecot_config_version = 2.4.0
dovecot_storage_version = 2.4.0
# Dovecot 2.4.x Konfiguration
# Protokolle (korrigiert zurück zu 'imap')
protocols = imap pop3
import_environment {
USER_PASSWORD=%{env:USER_PASSWORD|default('password')}
DOVEADM_PASSWORD=%{env:DOVEADM_PASSWORD|default('supersecret')}
}
# Logging (Block-Syntax)
log_path = /var/log/dovecot.log
info_log_path = /var/log/dovecot-info.log
debug_log_path = /var/log/dovecot-debug.log
# Mail-Location
mail_driver=maildir
mailbox_list_layout=index
mailbox_list_utf8=yes
mail_path=~/mail
mail_home=/var/vmail/%{user | domain }/%{user | username }
mail_utf8_extensions = yes
default_internal_user = vmail
default_login_user = vmail
default_internal_group = vmail
mail_uid = vmail
mail_gid = vmail
# Authentifizierung
# auth_mechanisms = plain login
# auth_allow_cleartext = yes
# Passwd-Datenbank (mit Namen und korrekter Syntax)
# passdb passwd-file {
# passdb_driver = passwd-file
# passdb_args = username_format=%u password_hash=plaintext /etc/dovecot/passwd
# }
passdb static {
password=%{env:USER_PASSWORD}
}
# userdb passwd-file {
# userdb_driver = passwd-file
# userdb_args = username_format=%u uid=vmail gid=vmail /etc/dovecot/passwd
# }
ssl = yes
ssl_server_cert_file = /etc/dovecot/ssl/imap.bizmatch.net/fullchain1.pem
ssl_server_key_file = /etc/dovecot/ssl/imap.bizmatch.net/privkey1.pem
# Mail-Berechtigungen (nicht mehr in Service-Blöcken nötig)
# uid/gid jetzt direkt in userdb definiert
# SSL-Einstellungen
# ssl = yes
# ssl_cert = </etc/dovecot/ssl/imap.bizmatch.net/fullchain1.pem
# ssl_key = </etc/dovecot/ssl/imap.bizmatch.net/privkey1.pem
# SNI-Konfiguration (korrigierte Syntax)
# service imap-login {
# ssl_server_name = imap.haiky.app {
# ssl_cert = </etc/dovecot/ssl/imap.haiky.app/fullchain1.pem
# ssl_key = </etc/dovecot/ssl/imap.haiky.app/privkey1.pem
# }
# ssl_server_name = imap.andreasknuth.de {
# ssl_cert = </etc/dovecot/ssl/imap.andreasknuth.de/fullchain1.pem
# ssl_key = </etc/dovecot/ssl/imap.andreasknuth.de/privkey1.pem
# }
# }

View File

@@ -0,0 +1,233 @@
dovecot_config_version = 2.4.0
dovecot_storage_version = 2.4.0
base_dir = /run/dovecot
state_dir = /run/dovecot
protocols = imap submission lmtp sieve
import_environment {
USER_PASSWORD=%{env:USER_PASSWORD|default('password')}
DOVEADM_PASSWORD=%{env:DOVEADM_PASSWORD|default('supersecret')}
}
mail_driver=maildir
mailbox_list_layout=fs
mailbox_list_utf8=yes
mail_path=./
mail_home=/var/mail/%{user | domain }/%{user | username }
mail_utf8_extensions = yes
default_internal_user = vmail
default_login_user = vmail
default_internal_group = vmail
mail_uid = vmail
mail_gid = vmail
passdb static {
password=%{env:USER_PASSWORD}
}
# namespace inbox {
# inbox = yes
# separator = /
# }
ssl_server {
cert_file = /etc/dovecot/ssl/imap.bizmatch.net/fullchain1.pem
key_file = /etc/dovecot/ssl/imap.bizmatch.net/privkey1.pem
}
mail_attribute {
dict file {
path = %{home}/dovecot-attributes
}
}
log_path = /dev/stdout
imap_hibernate_timeout = 5s
mail_plugins {
fts = yes
fts_flatcurve = yes
mail_log = yes
notify = yes
}
mail_log_events = delete undelete expunge save copy mailbox_create mailbox_delete mailbox_rename flag_change
fts_autoindex = yes
fts_autoindex_max_recent_msgs = 999
fts_search_add_missing = yes
language_filters = normalizer-icu snowball stopwords
language_tokenizers = generic email-address
language_tokenizer_generic_algorithm = simple
language en {
default = yes
filters = lowercase snowball english-possessive stopwords
}
fts flatcurve {
substring_search = yes
}
protocol imap {
mail_plugins {
imap_sieve = yes
imap_filter_sieve = yes
}
}
protocol lmtp {
mail_plugins {
sieve = yes
}
}
service imap-login {
process_min_avail = 1
client_limit = 100
inet_listener imap {
port = 31143
}
inet_listener imaps {
port = 31993
}
}
service pop3-login {
process_min_avail = 1
client_limit = 100
inet_listener pop3 {
port = 31110
}
inet_listener pop3s {
port = 31990
}
}
service submission-login {
process_min_avail = 1
client_limit = 100
inet_listener submission {
port = 31587
}
inet_listener submissions {
port = 31465
ssl = yes
}
}
service managesieve-login {
process_min_avail = 1
client_limit = 100
inet_listener sieve {
port = 34190
}
}
service doveadm {
inet_listener http {
port = 8080
ssl = yes
}
}
service stats {
process_min_avail = 1
inet_listener http {
port = 9090
ssl = yes
}
}
service lmtp {
inet_listener lmtps {
port = 31024
ssl = yes
}
}
doveadm_password = ${env:DOVEADM_PASSWORD}
event_exporter log {
format = json
time_format = rfc3339
}
metric auth_success {
filter = (event=auth_request_finished AND success=yes)
}
metric auth_failure {
filter = (event=auth_request_finished AND NOT success=yes)
exporter = log
}
metric imap_command {
filter = event=imap_command_finished
group_by cmd_name {
method discrete {
}
}
group_by tagged_reply_state {
method discrete {
}
}
}
metric smtp_command {
filter = event=smtp_server_command_finished and protocol=submission
group_by cmd_name {
method discrete {
}
}
group_by status_code {
method discrete {
}
}
group_by duration {
method exponential {
base = 10
min_magnitude = 1
max_magnitude = 5
}
}
}
metric lmtp_command {
filter = event=smtp_server_command_finished and protocol=lmtp
group_by cmd_name {
method discrete {
}
}
group_by status_code {
method discrete {
}
}
group_by duration {
method exponential {
base = 10
min_magnitude = 1
max_magnitude = 5
}
}
}
metric mail_delivery {
filter = event=mail_delivery_finished
group_by duration {
method exponential {
base = 10
min_magnitude = 1
max_magnitude = 5
}
}
}
!include_try conf.d/*.conf

6
dovecot/config/passwd Normal file
View File

@@ -0,0 +1,6 @@
# Format: Benutzername:Passwort:UID:GID:Benutzerinfo:Home-Verzeichnis:Shell
# Für Plaintext-Passwörter
user1:{PLAIN}geheim:1000:1000::/var/mail/user1:/bin/false
bizmatch.net:{PLAIN}passwort123:1001:1000::/var/mail/bizmatch.net:/bin/false
info@bizmatch.net:{PLAIN}passwort123:1001:1000::/var/mail/bizmatch.net/info:/bin/false
aknuth@haiky.app:{PLAIN}passwort123:1001:1000::/var/mail/haiky.app/aknuth:/bin/false

View File

@@ -0,0 +1,25 @@
services:
dovecot:
image: dovecot/dovecot:2.3.21.1
container_name: dovecot
restart: unless-stopped
ports:
- "143:143" # IMAP
- "993:993" # IMAPS
- "110:110" # POP3
- "995:995" # POP3S
volumes:
- ./config:/etc/dovecot
- ./ssl:/etc/dovecot/ssl
- ./mail:/var/mail
- ./log:/var/log
- ./entrypoint.sh:/entrypoint.sh # Custom Entrypoint-Script
environment:
- UMASK=002 # Wird von unserem Entrypoint verwendet
# entrypoint: ["/bin/sh", "/entrypoint.sh"]
networks:
- mail_network
networks:
mail_network:
driver: bridge

View File

@@ -0,0 +1,24 @@
services:
dovecot:
image: dovecot/dovecot:2.4-latest # Oder spezifische 2.4.x Version
container_name: dovecot24
restart: unless-stopped
ports:
- "143:143" # IMAP
- "993:31993" # IMAPS (SSL/TLS)
- "110:110" # POP3
- "995:995" # POP3S (SSL/TLS)
volumes:
- ./config/dovecot241.conf:/etc/dovecot/dovecot.conf # Pfad zur Konfig
- ./ssl:/etc/dovecot/ssl
- ./mail:/var/mail
- ./log:/var/log
environment:
- USER_PASSWORD=SUPERSECRET
command: ["dovecot", "-F"] # Foreground mit eigener Konfig
networks:
- mail_network
networks:
mail_network:
driver: bridge

View File

@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""
Dovecot Passwd Manager
Verwaltet Benutzerkonten in der Dovecot passwd-Datei basierend auf den konfigurierten
E-Mail-Domains und Benutzernamen. Das Script liest die gleichen Umgebungsvariablen wie
der s3_email_downloader.py und kann separat ausgeführt werden.
Nutzung:
python3 dovecot_passwd_manager.py # Nur Überprüfung, keine Änderungen
python3 dovecot_passwd_manager.py update # Aktualisiert die passwd-Datei
python3 dovecot_passwd_manager.py force # Erzwingt Aktualisierung auch ohne Änderungen
"""
import os
import sys
import json
import logging
import datetime
import subprocess
import filecmp
import shutil
from pathlib import Path
from tempfile import NamedTemporaryFile
from dotenv import load_dotenv
# .env-Datei laden
load_dotenv()
# Logging konfigurieren
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('dovecot_passwd_manager.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger("dovecot-passwd-manager")
# Konfiguration
MAIL_DIR = os.environ.get('MAIL_DIR', './mail')
DOVECOT_PASSWD_FILE = os.environ.get('DOVECOT_PASSWD_FILE', './config/passwd')
DOVECOT_PASSWD_BACKUP = os.environ.get('DOVECOT_PASSWD_BACKUP', './config/passwd.bak')
DOVECOT_CONTAINER = os.environ.get('DOVECOT_CONTAINER', 'dovecot')
DEFAULT_PASSWORD_PATTERN = os.environ.get('DEFAULT_PASSWORD_PATTERN', '{domain}{year}!')
UID = os.environ.get('MAIL_UID', '1001')
GID = os.environ.get('MAIL_GID', '1000')
AWS_REGION = os.environ.get('AWS_REGION', 'us-east-2')
# Domains-Konfiguration
DOMAINS_CONFIG_FILE = os.environ.get('DOMAINS_CONFIG_FILE', 'domains_config.json')
def load_domains_config():
"""Lädt die Domain-Konfiguration aus einer JSON-Datei oder aus Umgebungsvariablen"""
domains = {}
# Versuchen, Konfiguration aus JSON-Datei zu laden
config_file = Path(DOMAINS_CONFIG_FILE)
if config_file.exists():
try:
with open(config_file, 'r') as f:
domains = json.load(f)
logger.info(f"Domain-Konfiguration aus {DOMAINS_CONFIG_FILE} geladen")
return domains
except Exception as e:
logger.error(f"Fehler beim Laden der Domain-Konfiguration aus {DOMAINS_CONFIG_FILE}: {str(e)}")
# Fallback: Konfiguration aus Umgebungsvariablen
domain_index = 1
while True:
domain_key = f"DOMAIN_{domain_index}"
domain_name = os.environ.get(domain_key)
if not domain_name:
break # Keine weitere Domain-Definition gefunden
bucket = os.environ.get(f"{domain_key}_BUCKET", "")
prefix = os.environ.get(f"{domain_key}_PREFIX", "emails/")
usernames = os.environ.get(f"{domain_key}_USERNAMES", "")
region = os.environ.get(f"{domain_key}_REGION", AWS_REGION)
if domain_name and usernames:
domains[domain_name] = {
"bucket": bucket,
"prefix": prefix,
"usernames": usernames.split(','),
"region": region
}
logger.info(f"Domain {domain_name} aus Umgebungsvariablen konfiguriert")
else:
logger.warning(f"Unvollständige Konfiguration für {domain_name}, wird übersprungen")
domain_index += 1
# Fallback für Abwärtskompatibilität
if not domains:
old_domain = os.environ.get('VALID_DOMAIN', '')
old_bucket = os.environ.get('S3_BUCKET', '')
old_prefix = os.environ.get('EMAIL_PREFIX', 'emails/')
old_usernames = os.environ.get('VALID_USERNAMES', '')
if old_domain and old_usernames:
domains[old_domain] = {
"bucket": old_bucket,
"prefix": old_prefix,
"usernames": old_usernames.split(','),
"region": AWS_REGION
}
logger.info(f"Alte Konfiguration für Domain {old_domain} geladen")
return domains
def generate_password(domain):
"""Generiert ein Passwort nach dem Muster {domain}{year}!"""
current_year = datetime.datetime.now().year
domain_prefix = domain.split('.')[0] # Nur den ersten Teil der Domain verwenden
return DEFAULT_PASSWORD_PATTERN.replace('{domain}', domain_prefix).replace('{year}', str(current_year))
def get_required_emails(domains_config):
"""
Ermittelt eine Liste aller E-Mail-Adressen, die in der passwd-Datei
vorhanden sein sollten, basierend auf der Domain-Konfiguration.
"""
required_emails = []
for domain, config in domains_config.items():
for username in config["usernames"]:
email = f"{username}@{domain}"
required_emails.append(email)
return sorted(required_emails)
def get_existing_emails(passwd_file):
"""
Liest die bestehende passwd-Datei und gibt eine Liste aller
bereits konfigurierten E-Mail-Adressen zurück.
"""
existing_emails = []
if os.path.exists(passwd_file):
try:
with open(passwd_file, 'r') as f:
for line in f:
if line.strip() and ':' in line:
email = line.strip().split(':')[0]
existing_emails.append(email)
except Exception as e:
logger.error(f"Fehler beim Lesen der passwd-Datei: {str(e)}")
return sorted(existing_emails)
def read_existing_entries(passwd_file):
"""
Liest alle bestehenden Einträge aus der passwd-Datei und
gibt ein Dictionary mit E-Mail-Adressen als Schlüssel zurück.
"""
existing_entries = {}
if os.path.exists(passwd_file):
try:
with open(passwd_file, 'r') as f:
for line in f:
if line.strip() and ':' in line:
parts = line.strip().split(':')
email = parts[0]
existing_entries[email] = line.strip()
except Exception as e:
logger.error(f"Fehler beim Lesen der passwd-Datei: {str(e)}")
return existing_entries
def update_dovecot_passwd(domains_config, force=False):
"""
Aktualisiert die Dovecot-Passwortdatei basierend auf der Domain-Konfiguration.
Erstellt eine neue temporäre Datei und vergleicht sie mit der vorhandenen,
um festzustellen, ob ein Reload von Dovecot erforderlich ist.
Returns:
bool: True, wenn Änderungen vorgenommen wurden, sonst False
"""
logger.info("Überprüfe Dovecot-Passwortdatei...")
# Prüfen, ob Aktualisierung überhaupt notwendig ist
required_emails = get_required_emails(domains_config)
existing_emails = get_existing_emails(DOVECOT_PASSWD_FILE)
# Prüfen auf fehlende oder überflüssige E-Mail-Adressen
missing_emails = [email for email in required_emails if email not in existing_emails]
surplus_emails = [email for email in existing_emails if email not in required_emails]
if not missing_emails and not surplus_emails and not force:
logger.info("Alle erforderlichen E-Mail-Adressen sind bereits konfiguriert, keine Änderung notwendig")
return False
if missing_emails:
logger.info(f"Fehlende E-Mail-Adressen: {', '.join(missing_emails)}")
if surplus_emails:
logger.info(f"Überflüssige E-Mail-Adressen: {', '.join(surplus_emails)}")
logger.info("Aktualisiere Dovecot-Passwortdatei...")
# Temporäre Datei für die neue Passwd erstellen
temp_passwd = NamedTemporaryFile(delete=False, mode='w')
temp_passwd_path = temp_passwd.name
try:
# Bestehende Einträge einlesen
existing_entries = read_existing_entries(DOVECOT_PASSWD_FILE)
# Neue Einträge generieren
new_entries = {}
for domain, config in domains_config.items():
domain_password = generate_password(domain)
for username in config["usernames"]:
email = f"{username}@{domain}"
# Wenn der Eintrag bereits existiert, verwenden wir diesen (damit Passwörter erhalten bleiben)
if email in existing_entries:
new_entries[email] = existing_entries[email]
else:
# Format: email:{PLAIN}password:uid:gid::/var/mail/domain/username:/bin/false
new_entry = f"{email}:{{PLAIN}}{domain_password}:{UID}:{GID}::/var/mail/{domain}/{username}:/bin/false"
new_entries[email] = new_entry
logger.info(f"Neuer E-Mail-Account erstellt: {email} mit Standardpasswort")
# Sortierte Einträge in die temporäre Datei schreiben
for email in sorted(new_entries.keys()):
temp_passwd.write(f"{new_entries[email]}\n")
temp_passwd.close()
# Prüfen, ob Änderungen vorgenommen wurden
if os.path.exists(DOVECOT_PASSWD_FILE) and filecmp.cmp(temp_passwd_path, DOVECOT_PASSWD_FILE):
logger.info("Keine inhaltlichen Änderungen an der passwd-Datei erforderlich")
os.unlink(temp_passwd_path)
return False
# Sicherungskopie erstellen
if os.path.exists(DOVECOT_PASSWD_FILE):
try:
shutil.copy2(DOVECOT_PASSWD_FILE, DOVECOT_PASSWD_BACKUP)
logger.info(f"Sicherungskopie erstellt: {DOVECOT_PASSWD_BACKUP}")
except Exception as e:
logger.warning(f"Konnte keine Sicherungskopie erstellen: {str(e)}")
# Neue Datei aktivieren
try:
# Stellen Sie sicher, dass das Verzeichnis existiert
passwd_dir = os.path.dirname(DOVECOT_PASSWD_FILE)
if passwd_dir and not os.path.exists(passwd_dir):
os.makedirs(passwd_dir, exist_ok=True)
shutil.move(temp_passwd_path, DOVECOT_PASSWD_FILE)
# Berechtigungen setzen
os.chmod(DOVECOT_PASSWD_FILE, 0o600) # Nur Besitzer darf lesen/schreiben
logger.info(f"Neue passwd-Datei aktiviert: {DOVECOT_PASSWD_FILE}")
return True
except Exception as e:
logger.error(f"Fehler beim Aktivieren der neuen passwd-Datei: {str(e)}")
return False
except Exception as e:
logger.error(f"Fehler bei der Aktualisierung der passwd-Datei: {str(e)}")
if os.path.exists(temp_passwd_path):
os.unlink(temp_passwd_path)
return False
def reload_dovecot():
"""
Führt einen Reload von Dovecot durch, um die neue Passwortdatei zu aktivieren,
ohne den Container neu starten zu müssen.
"""
try:
logger.info(f"Führe Dovecot-Reload durch...")
result = subprocess.run(
["docker", "exec", DOVECOT_CONTAINER, "doveadm", "reload"],
capture_output=True,
text=True,
check=False
)
if result.returncode == 0:
logger.info("Dovecot-Reload erfolgreich durchgeführt")
return True
else:
logger.error(f"Dovecot-Reload fehlgeschlagen: {result.stderr}")
return False
except Exception as e:
logger.error(f"Fehler beim Dovecot-Reload: {str(e)}")
return False
def main():
"""Hauptfunktion"""
logger.info("Dovecot Passwd Manager gestartet")
# Kommandozeilenargumente prüfen
update_mode = False
force_mode = False
if len(sys.argv) > 1:
if sys.argv[1].lower() == "update":
update_mode = True
elif sys.argv[1].lower() == "force":
update_mode = True
force_mode = True
try:
# Domain-Konfigurationen laden
domains_config = load_domains_config()
if not domains_config:
logger.error("Keine Domain-Konfigurationen gefunden. Bitte konfigurieren Sie mindestens eine Domain.")
return
logger.info(f"Folgende Domains werden verarbeitet: {', '.join(domains_config.keys())}")
# Im Nur-Prüfungs-Modus zeigen wir einfach die erforderlichen Änderungen an
if not update_mode:
required_emails = get_required_emails(domains_config)
existing_emails = get_existing_emails(DOVECOT_PASSWD_FILE)
missing_emails = [email for email in required_emails if email not in existing_emails]
surplus_emails = [email for email in existing_emails if email not in required_emails]
if not missing_emails and not surplus_emails:
logger.info("Alle erforderlichen E-Mail-Adressen sind bereits konfiguriert")
else:
if missing_emails:
logger.info(f"Fehlende E-Mail-Adressen: {', '.join(missing_emails)}")
if surplus_emails:
logger.info(f"Überflüssige E-Mail-Adressen: {', '.join(surplus_emails)}")
logger.info("Verwenden Sie 'update' als Parameter, um die Änderungen anzuwenden")
return
# Im Update-Modus führen wir die Änderungen durch
changes_made = update_dovecot_passwd(domains_config, force=force_mode)
if changes_made:
reload_dovecot()
else:
logger.info("Keine Änderungen vorgenommen, Dovecot-Reload nicht erforderlich")
except Exception as e:
logger.error(f"Fehler: {str(e)}")
import traceback
logger.error(traceback.format_exc())
if __name__ == "__main__":
main()

34
dovecot/entrypoint.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/bin/sh
# entrypoint.sh - Custom entrypoint für Dovecot mit UMASK-Einstellung
# UMASK aus Umgebungsvariable setzen (Standard ist 002, wenn nicht anders angegeben)
CUSTOM_UMASK=${UMASK:-002}
echo "Setting umask to $CUSTOM_UMASK"
umask $CUSTOM_UMASK
# UMASK auch in /etc/login.defs setzen für zukünftige Logins
sed -i "s/^UMASK\s*[0-9]\+/UMASK $CUSTOM_UMASK/" /etc/login.defs
# Falls UMASK noch nicht existiert, hinzufügen
grep -q "^UMASK" /etc/login.defs || echo "UMASK $CUSTOM_UMASK" >> /etc/login.defs
# Dovecot-Einstellungen anwenden (falls nötig)
if [ -n "$DOVECOT_UID" ] && [ -n "$DOVECOT_GID" ]; then
echo "Configuring Dovecot with UID=$DOVECOT_UID, GID=$DOVECOT_GID"
sed -i "s/^mail_uid\s*=.*/mail_uid = $DOVECOT_UID/" /etc/dovecot/dovecot.conf
sed -i "s/^mail_gid\s*=.*/mail_gid = $DOVECOT_GID/" /etc/dovecot/dovecot.conf
fi
# Wenn nicht genügend Berechtigungen für Mail-Verzeichnisse, automatisch korrigieren
echo "Checking mail directory permissions..."
find /var/mail -type d -exec chmod 775 {} \; 2>/dev/null || true
find /var/mail -type f -exec chmod 664 {} \; 2>/dev/null || true
# Original Docker-Entrypoint ausführen
# oder direkter Start von Dovecot, je nach Image
if [ -f "/docker-entrypoint.sh" ]; then
echo "Executing original docker-entrypoint.sh"
exec /docker-entrypoint.sh "$@"
else
echo "Starting dovecot"
exec dovecot -F
fi

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* Obtaining Amazon SES SMTP credentials by converting existing AWS credentials
*
* Script based on:
* https://docs.aws.amazon.com/ses/latest/dg/smtp-credentials.html
*/
const crypto = require('crypto');
const SMTP_REGIONS = [
'us-east-2', // US East (Ohio)
'us-east-1', // US East (N. Virginia)
'us-west-2', // US West (Oregon)
'ap-south-1', // Asia Pacific (Mumbai)
'ap-northeast-2', // Asia Pacific (Seoul)
'ap-southeast-1', // Asia Pacific (Singapore)
'ap-southeast-2', // Asia Pacific (Sydney)
'ap-northeast-1', // Asia Pacific (Tokyo)
'ca-central-1', // Canada (Central)
'eu-central-1', // Europe (Frankfurt)
'eu-west-1', // Europe (Ireland)
'eu-west-2', // Europe (London)
'sa-east-1', // South America (Sao Paulo)
'us-gov-west-1', // AWS GovCloud (US)
];
// These values are required to calculate the signature. Do not change them.
const DATE = '11111111';
const SERVICE = 'ses';
const MESSAGE = 'SendRawEmail';
const TERMINAL = 'aws4_request';
const VERSION = [0x04];
function sign(key, msg) {
return crypto.createHmac('sha256', key).update(msg).digest();
}
function calculate_key(secret_access_key, region) {
if (!SMTP_REGIONS.includes(region)) {
throw new Error(`The ${region} Region doesn't have an SMTP endpoint`);
}
let signature;
signature = sign(`AWS4${secret_access_key}`, DATE);
signature = sign(signature, region);
signature = sign(signature, SERVICE);
signature = sign(signature, TERMINAL);
signature = sign(signature, MESSAGE);
const signature_and_version = Buffer.concat([
Buffer.from(VERSION),
signature,
]);
const smtp_password = Buffer.from(signature_and_version).toString('base64');
return smtp_password;
}
function main() {
const [secret, region] = process.argv.slice(2);
console.log(calculate_key(secret, region));
}
if (require.main === module) {
main();
}

717
dovecot/s3_email_processor_api.py Executable file
View File

@@ -0,0 +1,717 @@
#!/usr/bin/env python3
"""
Optimierte S3 E-Mail Processor REST API
Mit Kompression, verbesserter Fehlerbehandlung und Request-Tracking
"""
import os
import time
import logging
import json
import re
import hashlib
import base64
import gzip
import boto3
from pathlib import Path
from email.parser import BytesParser
from email import policy
from dotenv import load_dotenv
from flask import Flask, request, jsonify, abort
from functools import wraps
# .env-Datei laden
load_dotenv()
# Flask-App initialisieren
app = Flask(__name__)
# Logging konfigurieren mit Request-ID-Support
class RequestIDFilter(logging.Filter):
def filter(self, record):
from flask import g
record.request_id = getattr(g, 'request_id', 'no-request')
return True
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - [%(request_id)s] - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('s3_email_processor_api.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger("s3-email-processor-api")
logger.addFilter(RequestIDFilter())
# Konfiguration
MAIL_DIR = os.environ.get('MAIL_DIR', './mail')
API_TOKEN = os.environ.get('API_TOKEN')
# Request-Tracking für Duplikat-Erkennung
processed_requests = {}
REQUEST_CACHE_SIZE = 1000
REQUEST_CACHE_TTL = 3600 # 1 Stunde
def require_token(f):
@wraps(f)
def decorated_function(*args, **kwargs):
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
logger.warning("Fehlender Authorization-Header")
abort(401, description="Fehlender Authorization-Header")
token = auth_header[7:]
if not API_TOKEN or token != API_TOKEN:
logger.warning("Ungültiger API-Token")
abort(403, description="Ungültiger API-Token")
return f(*args, **kwargs)
return decorated_function
def setup_request_context():
"""Setzt Request-Kontext für Logging auf"""
from flask import g
g.request_id = request.headers.get('X-Request-ID', f'req-{int(time.time())}-{id(request)}')
@app.before_request
def before_request():
setup_request_context()
def is_duplicate_request(request_id, etag):
"""Prüft, ob Request bereits verarbeitet wurde"""
if not request_id or not etag:
return False
key = f"{request_id}:{etag}"
current_time = time.time()
# Cache cleanup
if len(processed_requests) > REQUEST_CACHE_SIZE:
cutoff_time = current_time - REQUEST_CACHE_TTL
processed_requests.clear() # Einfache Lösung: kompletten Cache leeren
# Duplikat-Check
if key in processed_requests:
last_processed = processed_requests[key]
if current_time - last_processed < REQUEST_CACHE_TTL:
return True
# Request als verarbeitet markieren
processed_requests[key] = current_time
return False
def load_domains_config():
"""Lädt die Domain-Konfiguration aus Umgebungsvariablen"""
domains = {}
domain_index = 1
while True:
domain_key = f"DOMAIN_{domain_index}"
domain_name = os.environ.get(domain_key)
if not domain_name:
break
bucket = os.environ.get(f"{domain_key}_BUCKET", "")
prefix = os.environ.get(f"{domain_key}_PREFIX", "emails/")
usernames = os.environ.get(f"{domain_key}_USERNAMES", "")
region = os.environ.get(f"{domain_key}_REGION", "us-east-2")
if bucket and usernames:
domains[domain_name.lower()] = {
"bucket": bucket,
"prefix": prefix,
"usernames": usernames.split(','),
"region": region
}
logger.info(f"Domain {domain_name} konfiguriert")
else:
logger.warning(f"Unvollständige Konfiguration für {domain_name}")
domain_index += 1
return domains
def extract_email_address(address):
"""Extrahiert die E-Mail-Adresse aus einem komplexen Adressformat"""
if not address:
return None
if '@' in address and '<' not in address:
return address.strip()
match = re.search(r'<([^>]+)>', address)
if match:
return match.group(1)
return address.strip()
def is_valid_recipient(to_address, domains_config):
"""Prüft, ob die Empfängeradresse gültig ist"""
email = extract_email_address(to_address)
if not email or '@' not in email:
return False, None
username, domain = email.split('@', 1)
if domain.lower() in domains_config:
domain_config = domains_config[domain.lower()]
if username.lower() in [u.lower() for u in domain_config["usernames"]]:
return True, domain.lower()
return False, None
def get_maildir_path(to_address, mail_dir):
"""Ermittelt den Pfad im Maildir-Format"""
email = extract_email_address(to_address)
if '@' in email:
user, domain = email.split('@', 1)
else:
return None
mail_dir_path = Path(mail_dir)
domain_dir = mail_dir_path / domain
user_dir = domain_dir / user
# Maildir-Struktur erstellen
for directory in [mail_dir_path, domain_dir, user_dir]:
directory.mkdir(parents=True, exist_ok=True)
os.chmod(directory, 0o775)
# Maildir-Unterverzeichnisse
for subdir in ['cur', 'new', 'tmp']:
subdir_path = user_dir / subdir
subdir_path.mkdir(exist_ok=True)
return user_dir
def store_email(email_content, to_address, message_id, s3_key, mail_dir):
"""Speichert eine E-Mail im Maildir-Format"""
try:
maildir = get_maildir_path(to_address, mail_dir)
if not maildir:
logger.error(f"Konnte Maildir für {to_address} nicht ermitteln")
return False
# Eindeutigen Dateinamen generieren
timestamp = int(time.time())
hostname = 'mail'
unique_id = hashlib.md5(f"{s3_key}:{timestamp}".encode()).hexdigest()
filename = f"{timestamp}.{unique_id}.{hostname}:2,"
email_path = maildir / 'new' / filename
with open(email_path, 'wb') as f:
f.write(email_content)
os.chmod(email_path, 0o664)
logger.info(f"E-Mail gespeichert: {email_path} ({len(email_content)} Bytes)")
return True
except Exception as e:
logger.error(f"Fehler beim Speichern der E-Mail {s3_key}: {str(e)}")
return False
def process_single_email(email_content, bucket, key, domain_name):
"""Verarbeitet eine einzelne E-Mail mit verbesserter Fehlerbehandlung"""
try:
all_domains_config = load_domains_config()
domain_name = domain_name.lower()
if domain_name not in all_domains_config:
logger.error(f"Domain {domain_name} nicht konfiguriert")
return {
"action": "error",
"error": f"Domain {domain_name} nicht konfiguriert",
"status": "error"
}
# Header parsen mit besserer Fehlerbehandlung
try:
headers = BytesParser(policy=policy.default).parsebytes(email_content, headersonly=True)
to_address = headers.get('To', '')
from_address = headers.get('From', '')
date = headers.get('Date', '')
message_id = headers.get('Message-ID', '')
subject = headers.get('Subject', '')
# Header-Validierung
if not to_address:
logger.warning(f"E-Mail ohne To-Header: {key}")
return {
"action": "invalid",
"error": "Fehlender To-Header",
"status": "rejected"
}
logger.info(f"Verarbeite: '{subject}' von {from_address} an {to_address}")
except Exception as e:
logger.error(f"Fehler beim Parsen der E-Mail-Header {key}: {str(e)}")
return {
"action": "invalid",
"error": f"Header-Parsing-Fehler: {str(e)}",
"status": "error"
}
# Empfänger validieren
is_valid, recipient_domain = is_valid_recipient(to_address, all_domains_config)
if not is_valid:
logger.info(f"Ungültige Empfängeradresse: {to_address}")
return {
"action": "invalid",
"message": f"Ungültige Empfängeradresse: {to_address}",
"status": "rejected",
"recipient": to_address,
"reason": "invalid_recipient"
}
# Domain-Zugehörigkeit prüfen
if recipient_domain != domain_name:
logger.info(f"E-Mail gehört zu Domain {recipient_domain}, nicht zu {domain_name}")
return {
"action": "wrong_domain",
"message": f"E-Mail gehört zu Domain {recipient_domain}",
"status": "skipped",
"expected_domain": domain_name,
"actual_domain": recipient_domain
}
# E-Mail speichern
if store_email(email_content, to_address, message_id, key, MAIL_DIR):
logger.info(f"E-Mail erfolgreich gespeichert für {to_address}")
return {
"action": "stored",
"message": f"E-Mail erfolgreich gespeichert",
"status": "success",
"recipient": to_address,
"sender": from_address,
"subject": subject,
"size": len(email_content)
}
else:
return {
"action": "error",
"error": "Fehler beim Speichern der E-Mail",
"status": "error"
}
except Exception as e:
logger.error(f"Unerwarteter Fehler bei E-Mail {key}: {str(e)}", exc_info=True)
return {
"action": "error",
"error": str(e),
"status": "error"
}
# API-Endpunkte
@app.route('/health', methods=['GET'])
def health_check():
"""Erweiterte Gesundheitsprüfung"""
from flask import g
# Basis-Gesundheitscheck
health_status = {
"status": "OK",
"message": "S3 E-Mail Processor API ist aktiv",
"timestamp": int(time.time()),
"version": "2.0",
"request_id": getattr(g, 'request_id', 'health-check')
}
# Erweiterte Checks
try:
# Maildir-Zugriff prüfen
mail_path = Path(MAIL_DIR)
if mail_path.exists() and mail_path.is_dir():
health_status["mail_dir"] = "accessible"
else:
health_status["mail_dir"] = "not_accessible"
health_status["status"] = "WARNING"
# Domain-Konfiguration prüfen
domains = load_domains_config()
health_status["configured_domains"] = len(domains)
health_status["domains"] = list(domains.keys())
# Cache-Status
health_status["request_cache_size"] = len(processed_requests)
except Exception as e:
health_status["status"] = "ERROR"
health_status["error"] = str(e)
return jsonify(health_status)
@app.route('/process/<domain>', methods=['POST'])
@require_token
def process_email(domain):
"""Verarbeitet eine einzelne E-Mail mit verbesserter Fehlerbehandlung"""
from flask import g
start_time = time.time()
try:
# JSON-Payload validieren
if not request.is_json:
return jsonify({"error": "Content-Type muss application/json sein"}), 400
data = request.get_json()
# Erforderliche Felder prüfen
required_fields = ['bucket', 'key', 'email_content', 'domain']
missing_fields = [field for field in required_fields if field not in data]
if missing_fields:
return jsonify({
"error": f"Fehlende Felder: {', '.join(missing_fields)}"
}), 400
# Duplikat-Check
request_id = data.get('request_id', g.request_id)
etag = data.get('etag')
if is_duplicate_request(request_id, etag):
logger.info(f"Duplikat-Request erkannt: {request_id}:{etag}")
return jsonify({
"action": "duplicate",
"message": "Request bereits verarbeitet",
"status": "skipped",
"request_id": request_id
})
# E-Mail-Content dekodieren
try:
email_base64 = data['email_content']
# Kompression-Support
if data.get('compressed', False):
compressed_data = base64.b64decode(email_base64)
email_content = gzip.decompress(compressed_data)
logger.info(f"E-Mail dekomprimiert: {data.get('compressed_size', 0)} -> {len(email_content)} Bytes")
else:
email_content = base64.b64decode(email_base64)
except Exception as e:
logger.error(f"Fehler beim Dekodieren des E-Mail-Contents: {str(e)}")
return jsonify({
"error": f"Content-Dekodierung fehlgeschlagen: {str(e)}"
}), 400
# Größen-Validierung
email_size = len(email_content)
max_size = 25 * 1024 * 1024 # 25MB
if email_size > max_size:
logger.warning(f"E-Mail zu groß: {email_size} Bytes")
return jsonify({
"action": "too_large",
"error": f"E-Mail zu groß: {email_size} Bytes (max: {max_size})",
"status": "rejected"
}), 413
# E-Mail verarbeiten
logger.info(f"Verarbeite E-Mail: {data['key']} ({email_size} Bytes)")
result = process_single_email(
email_content=email_content,
bucket=data['bucket'],
key=data['key'],
domain_name=domain
)
# Performance-Metriken hinzufügen
processing_time = time.time() - start_time
result.update({
"processing_time_ms": round(processing_time * 1000, 2),
"request_id": request_id,
"email_size": email_size
})
# Log-Level basierend auf Ergebnis
if result.get("status") == "success":
logger.info(f"E-Mail erfolgreich verarbeitet in {processing_time:.2f}s")
elif result.get("status") in ["rejected", "skipped"]:
logger.info(f"E-Mail {result.get('action')}: {result.get('message')}")
else:
logger.error(f"E-Mail-Verarbeitung fehlgeschlagen: {result.get('error')}")
# HTTP-Status basierend auf Ergebnis
if result.get("status") == "error":
return jsonify(result), 500
elif result.get("action") == "too_large":
return jsonify(result), 413
else:
return jsonify(result), 200
except Exception as e:
processing_time = time.time() - start_time
logger.error(f"Unerwarteter Fehler nach {processing_time:.2f}s: {str(e)}", exc_info=True)
return jsonify({
"error": "Interner Server-Fehler",
"details": str(e),
"processing_time_ms": round(processing_time * 1000, 2),
"request_id": getattr(g, 'request_id', 'unknown')
}), 500
@app.route('/retry/<email_id>/<domain>', methods=['GET'])
@require_token
def retry_single_email(email_id, domain):
"""
Retry-Endpunkt für einzelne E-Mail basierend auf ID
Sucht die E-Mail in S3 anhand der ID und verarbeitet sie erneut
"""
from flask import g
start_time = time.time()
logger.info(f"Retry-Request für E-Mail-ID: {email_id} in Domain: {domain}")
try:
# Domain-Konfiguration laden
all_domains_config = load_domains_config()
domain_name = domain.lower()
if domain_name not in all_domains_config:
logger.error(f"Domain {domain_name} nicht konfiguriert")
return jsonify({
"error": f"Domain {domain_name} nicht konfiguriert",
"status": "error",
"email_id": email_id
}), 400
domain_config = all_domains_config[domain_name]
bucket = domain_config["bucket"]
prefix = domain_config["prefix"]
region = domain_config["region"]
# S3-Client initialisieren
s3_client = boto3.client('s3', region_name=region)
# E-Mail anhand der ID suchen
logger.info(f"Suche E-Mail mit ID {email_id} in Bucket {bucket}")
try:
# Paginator für alle Objekte im Bucket
paginator = s3_client.get_paginator('list_objects_v2')
pages = paginator.paginate(Bucket=bucket, Prefix=prefix)
found_key = None
for page in pages:
if 'Contents' not in page:
continue
for obj in page['Contents']:
key = obj['Key']
# Verzeichnisse überspringen
if key.endswith('/'):
continue
# E-Mail-ID aus dem Key extrahieren (oft Teil des Dateinamens)
# Verschiedene Möglichkeiten prüfen:
# 1. ID ist Teil des Dateinamens
if email_id in key:
found_key = key
break
# 2. ID könnte in Message-ID der E-Mail sein - Header laden und prüfen
try:
# Nur Header laden für Performance
response = s3_client.get_object(
Bucket=bucket,
Key=key,
Range='bytes=0-2048' # Nur erste 2KB für Header
)
header_content = response['Body'].read()
# Nach Message-ID suchen
if email_id.encode() in header_content:
found_key = key
break
except Exception as header_e:
# Header-Check fehlgeschlagen, weiter mit nächster E-Mail
logger.debug(f"Header-Check für {key} fehlgeschlagen: {str(header_e)}")
continue
if found_key:
break
if not found_key:
logger.warning(f"E-Mail mit ID {email_id} nicht gefunden")
return jsonify({
"error": f"E-Mail mit ID {email_id} nicht gefunden",
"status": "not_found",
"email_id": email_id,
"searched_bucket": bucket,
"searched_prefix": prefix
}), 404
logger.info(f"E-Mail gefunden: {found_key}")
except Exception as search_e:
logger.error(f"Fehler beim Suchen der E-Mail: {str(search_e)}")
return jsonify({
"error": f"Fehler beim Suchen: {str(search_e)}",
"status": "error",
"email_id": email_id
}), 500
# E-Mail laden und verarbeiten
try:
response = s3_client.get_object(Bucket=bucket, Key=found_key)
email_content = response['Body'].read()
logger.info(f"E-Mail geladen: {len(email_content)} Bytes")
# E-Mail verarbeiten (gleiche Logik wie bei process_email)
result = process_single_email(
email_content=email_content,
bucket=bucket,
key=found_key,
domain_name=domain_name
)
# Performance-Metriken hinzufügen
processing_time = time.time() - start_time
result.update({
"processing_time_ms": round(processing_time * 1000, 2),
"request_id": getattr(g, 'request_id', 'retry-request'),
"email_size": len(email_content),
"email_id": email_id,
"s3_key": found_key,
"retry": True
})
# Bei erfolgreichem Processing E-Mail aus S3 löschen
if result.get('action') == 'stored':
try:
s3_client.delete_object(Bucket=bucket, Key=found_key)
logger.info(f"E-Mail nach erfolgreichem Retry aus S3 gelöscht: {found_key}")
result["s3_deleted"] = True
except Exception as delete_e:
logger.warning(f"Konnte E-Mail nach Retry nicht löschen: {str(delete_e)}")
result["s3_deleted"] = False
# Logging basierend auf Ergebnis
if result.get("status") == "success":
logger.info(f"Retry erfolgreich für E-Mail-ID {email_id} in {processing_time:.2f}s")
else:
logger.warning(f"Retry für E-Mail-ID {email_id} nicht erfolgreich: {result.get('message')}")
# HTTP-Status basierend auf Ergebnis
if result.get("status") == "error":
return jsonify(result), 500
else:
return jsonify(result), 200
except Exception as process_e:
logger.error(f"Fehler beim Laden/Verarbeiten der E-Mail {found_key}: {str(process_e)}")
return jsonify({
"error": f"Verarbeitungsfehler: {str(process_e)}",
"status": "error",
"email_id": email_id,
"s3_key": found_key
}), 500
except Exception as e:
processing_time = time.time() - start_time
logger.error(f"Unerwarteter Fehler bei Retry nach {processing_time:.2f}s: {str(e)}", exc_info=True)
return jsonify({
"error": "Interner Server-Fehler beim Retry",
"details": str(e),
"processing_time_ms": round(processing_time * 1000, 2),
"request_id": getattr(g, 'request_id', 'unknown'),
"email_id": email_id
}), 500
@app.route('/stats', methods=['GET'])
@require_token
def get_stats():
"""API-Statistiken und Metriken"""
try:
stats = {
"api_version": "2.0",
"uptime_seconds": int(time.time() - app.start_time) if hasattr(app, 'start_time') else 0,
"request_cache": {
"size": len(processed_requests),
"max_size": REQUEST_CACHE_SIZE,
"ttl_seconds": REQUEST_CACHE_TTL
},
"configuration": {
"mail_dir": MAIL_DIR,
"domains": len(load_domains_config())
}
}
# Maildir-Statistiken
try:
mail_path = Path(MAIL_DIR)
if mail_path.exists():
domain_stats = {}
for domain_dir in mail_path.iterdir():
if domain_dir.is_dir():
user_count = len([d for d in domain_dir.iterdir() if d.is_dir()])
domain_stats[domain_dir.name] = {"users": user_count}
stats["maildir_stats"] = domain_stats
except Exception as e:
stats["maildir_error"] = str(e)
return jsonify(stats)
except Exception as e:
return jsonify({"error": str(e)}), 500
# Cache-Cleanup-Task (läuft alle 10 Minuten)
import threading
def cleanup_cache():
"""Bereinigt den Request-Cache periodisch"""
while True:
try:
current_time = time.time()
cutoff_time = current_time - REQUEST_CACHE_TTL
# Alte Einträge entfernen
keys_to_remove = [
key for key, timestamp in processed_requests.items()
if timestamp < cutoff_time
]
for key in keys_to_remove:
processed_requests.pop(key, None)
if keys_to_remove:
logger.info(f"Cache bereinigt: {len(keys_to_remove)} alte Einträge entfernt")
except Exception as e:
logger.error(f"Fehler bei Cache-Bereinigung: {str(e)}")
# 10 Minuten warten
time.sleep(600)
# Hintergrund-Thread für Cache-Bereinigung starten
cleanup_thread = threading.Thread(target=cleanup_cache, daemon=True)
cleanup_thread.start()
if __name__ == "__main__":
# Start-Zeit für Uptime-Tracking
app.start_time = time.time()
# Überprüfungen beim Start
if not API_TOKEN:
logger.warning("WARNUNG: Kein API_TOKEN definiert!")
domains = load_domains_config()
logger.info(f"API startet mit {len(domains)} konfigurierten Domains")
# Server starten
app.run(host='0.0.0.0', port=5000, debug=False)

38
dovecot/setup_email_domain.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
# setup_email_domain.sh - Ein Wrapper-Script, das alle drei Skripte in der richtigen Reihenfolge ausführt
# Überprüfen, ob die Domain-Variable gesetzt ist
if [ -z "$1" ]; then
echo "Fehler: Keine Domain angegeben."
echo "Verwendung: ./setup_email_domain.sh domain.de [region]"
exit 1
fi
DOMAIN_NAME=$1
AWS_REGION=${2:-"us-east-2"}
# Variablen exportieren
export DOMAIN_NAME
export AWS_REGION
echo "=== AWS E-Mail-Infrastruktur für $DOMAIN_NAME einrichten ==="
echo "AWS-Region: $AWS_REGION"
echo
# Skripte nacheinander ausführen
echo "1. S3-Bucket erstellen..."
./awss3.sh
echo
echo "2. SES-Konfiguration einrichten..."
export S3_BUCKET_NAME=$(echo "$DOMAIN_NAME" | tr '.' '-' | awk '{print $0 "-emails"}')
./awsses.sh
echo
echo "3. IAM-Benutzer und SMTP-Zugangsdaten erstellen..."
./awsiam.sh
echo
echo "=== Setup abgeschlossen ==="
echo "Alle Schritte wurden abgeschlossen. Bitte überprüfen Sie die Ausgaben der einzelnen Skripte."
echo "Vergessen Sie nicht, die benötigten DNS-Einträge für Ihre Domain zu setzen, um die SES-Verifizierung abzuschließen."

75
dovecot/start_email_api.sh Executable file
View File

@@ -0,0 +1,75 @@
#!/bin/bash
# start_email_api.sh
# Dieses Script startet die S3 Email Downloader REST API in einer virtuellen Python-Umgebung
# Verzeichnis, in dem sich das Script befindet
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Name der virtuellen Umgebung
VENV_NAME="venv"
# Python-Executable (falls spezifische Version benötigt wird)
PYTHON_EXEC="python3"
# API Port
PORT=${EMAIL_API_PORT:-5000}
# Umgebungsvariablen Datei
ENV_FILE=".env"
# Prüfen, ob virtuelle Umgebung existiert
if [ ! -d "$VENV_NAME" ]; then
echo "Virtuelle Umgebung wird erstellt..."
$PYTHON_EXEC -m venv "$VENV_NAME"
if [ $? -ne 0 ]; then
echo "Fehler beim Erstellen der virtuellen Umgebung!"
exit 1
fi
fi
# Virtuelle Umgebung aktivieren
source "$VENV_NAME/bin/activate"
# Abhängigkeiten installieren, falls erforderlich
echo "Abhängigkeiten werden installiert..."
pip install --upgrade pip
pip install boto3 flask python-dotenv gunicorn
# Prüfen, ob .env-Datei existiert
if [ ! -f "$ENV_FILE" ]; then
echo "WARNUNG: $ENV_FILE nicht gefunden!"
echo "Bitte erstellen Sie eine .env-Datei mit den erforderlichen Konfigurationen."
echo "Beispiel:"
echo "API_TOKEN=ihr_geheimer_token"
echo "MAIL_DIR=./mail"
echo "AWS_REGION=us-east-2"
echo "AWS_ACCESS_KEY_ID=your_access_key"
echo "AWS_SECRET_ACCESS_KEY=your_secret_key"
echo "DOMAIN_1=example.com"
echo "DOMAIN_1_BUCKET=example-bucket"
echo "DOMAIN_1_USERNAMES=user1,user2,user3"
exit 1
fi
# Überprüfen, ob API_TOKEN in der .env-Datei gesetzt ist
if ! grep -q "API_TOKEN" "$ENV_FILE"; then
echo "WARNUNG: API_TOKEN nicht in $ENV_FILE gefunden!"
echo "Die API wird ohne Token-Schutz gestartet. Dies ist unsicher für Produktionsumgebungen."
read -p "Möchten Sie fortfahren? (j/n): " choice
if [[ ! "$choice" =~ ^[jJyY]$ ]]; then
echo "Abbruch."
exit 1
fi
fi
# Prüfen, ob das Python-Script existiert
API_SCRIPT="s3_email_processor_api.py"
if [ ! -f "$API_SCRIPT" ]; then
echo "Fehler: $API_SCRIPT nicht gefunden!"
exit 1
fi
# API im Produktionsmodus mit Gunicorn starten
echo "Starte S3 Email Downloader API auf Port $PORT..."
exec gunicorn --bind "0.0.0.0:$PORT" --workers 2 "s3_email_processor_api:app"

View File

@@ -0,0 +1,21 @@
services:
email-api:
container_name: email-api
image: python:3.12-slim
restart: unless-stopped
network_mode: host
volumes:
- ./email_api:/app
- /var/mail:/var/mail # Maildir-Zugriff für Health-Check
working_dir: /app
env_file:
- .env
environment:
- API_TOKEN=${API_TOKEN}
- AWS_REGION=${AWS_REGION}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
command: >
bash -c "pip install --upgrade pip &&
pip install flask python-dotenv boto3 requests &&
python app.py"

View File

@@ -0,0 +1,50 @@
version: '3.8'
services:
email-api:
container_name: email-api
image: node:22-slim
restart: unless-stopped
network_mode: host
volumes:
- ./email_api:/app
- /var/mail:/var/mail # Maildir access for health check
working_dir: /app
env_file:
- .env
environment:
- API_TOKEN=${API_TOKEN}
- AWS_REGION=${AWS_REGION}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
- SMTP_HOST=${SMTP_HOST:-localhost}
- SMTP_PORT=${SMTP_PORT:-25}
- MAILCOW_API_KEY=${MAILCOW_API_KEY}
- MAILCOW_API=${MAILCOW_API}
- PGHOST=postgres
- PGUSER=${PGUSER:-email_user}
- PGPASSWORD=${PGPASSWORD:-email_password}
- PGDATABASE=${PGDATABASE:-email_db}
- PGPORT=${PGPORT:-5433}
command: >
bash -c "npm install && node app.js"
depends_on:
- postgres
postgres:
container_name: email-api-postgres
image: postgres:16
restart: unless-stopped
network_mode: host
environment:
- POSTGRES_USER=${PGUSER:-email_user}
- POSTGRES_PASSWORD=${PGPASSWORD:-email_password}
- POSTGRES_DB=${PGDATABASE:-email_db}
volumes:
- email_postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
command: >
postgres -c port=${PGPORT:-5433}
volumes:
email_postgres_data:

300
email_api/email_api/app.js Normal file
View File

@@ -0,0 +1,300 @@
import express from 'express';
import { Pool } from 'pg';
import AWS from 'aws-sdk';
import nodemailer from 'nodemailer';
import { simpleParser } from 'mailparser';
import { Base64 } from 'js-base64';
import { createGzip, gunzipSync } from 'zlib';
import { createLogger, format, transports } from 'winston';
import { config } from 'dotenv';
// Load environment variables
config();
// Check Node.js version
const [major] = process.versions.node.split('.').map(Number);
if (major < 22) {
throw new Error('Node.js 22 or higher required');
}
// Logger setup
const logger = createLogger({
level: 'info',
format: format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
format.printf(({ timestamp, level, message }) => `${timestamp} ${level.toUpperCase()} ${message}`)
),
transports: [new transports.Console()]
});
const app = express();
app.use(express.json({ limit: '20mb' }));
app.use(express.urlencoded({ limit: '20mb', extended: true }));
const SMTP_HOST = process.env.SMTP_HOST || 'localhost';
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '25', 10);
const API_TOKEN = process.env.API_TOKEN;
const AWS_REGION = process.env.AWS_REGION || 'us-east-1';
const API_KEY = process.env.MAILCOW_API_KEY;
const MAILCOW_API = process.env.MAILCOW_API;
// PostgreSQL client
const pool = new Pool({
user: process.env.PGUSER || 'email_user',
password: process.env.PGPASSWORD || 'email_password',
host: process.env.PGHOST || 'postgres',
database: process.env.PGDATABASE || 'email_db',
port: parseInt(process.env.PGPORT || '5433', 10)
});
// AWS S3 client
const s3Client = new AWS.S3({ region: AWS_REGION });
// Nodemailer transporter
const transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: false, // Adjust if SMTP requires TLS
tls: {
rejectUnauthorized: false
}
});
// Utility to check if domain exists
async function domainExists(domain) {
try {
const response = await fetch(`${MAILCOW_API}/get/domain/all`, {
headers: { 'X-API-Key': API_KEY },
signal: AbortSignal.timeout(5000)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const domains = await response.json();
return domains.some(d => d.domain_name?.toLowerCase() === domain.toLowerCase());
} catch (error) {
logger.error(`Error checking domain '${domain}': ${error.message}`);
throw error;
}
}
// Utility to check if inbox exists
async function inboxExists(domain, localPart) {
if (!(await domainExists(domain))) {
logger.info(`Domain '${domain}' unknown skip mailbox lookup`);
return false;
}
try {
const response = await fetch(`${MAILCOW_API}/get/mailbox/all/${domain}`, {
headers: { 'X-API-Key': API_KEY },
signal: AbortSignal.timeout(5000)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const mailboxes = await response.json();
return mailboxes.some(m => m.local_part?.toLowerCase() === localPart.toLowerCase());
} catch (error) {
logger.error(`Error checking inbox '${localPart}@${domain}': ${error.message}`);
throw error;
}
}
// Utility to mark email as processed in PostgreSQL
async function markEmailAsProcessed(domain, key, status, processor = 'rest-api', fromAddr = null, toAddrs = []) {
try {
await pool.query(
`INSERT INTO email_statuses (domain, s3_key, status, timestamp, processor, from_addr, to_addrs)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (domain, s3_key) DO UPDATE SET
status = EXCLUDED.status,
timestamp = EXCLUDED.timestamp,
processor = EXCLUDED.processor,
from_addr = EXCLUDED.from_addr,
to_addrs = EXCLUDED.to_addrs`,
[domain, key, status, Math.floor(Date.now() / 1000), processor, fromAddr, toAddrs]
);
logger.info(`Marked ${domain}/${key} as ${status} in database`);
return true;
} catch (error) {
logger.error(`Error marking ${domain}/${key} in database: ${error.message}`);
return false;
}
}
// Process endpoint
app.post('/process/:domain', async (req, res) => {
const { domain } = req.params;
const auth = req.headers['authorization']; // Fixed: Use req.headers['authorization'] instead of req.headers.get
if (auth !== `Bearer ${API_TOKEN}`) {
return res.status(401).json({ error: 'Unauthorized' });
}
const data = req.body;
if (!data) {
return res.status(400).json({ error: 'Invalid payload' });
}
const requestId = data.request_id || 'no-request-id';
const payloadSummary = Object.fromEntries(
Object.entries(data)
.filter(([k, v]) => k !== 'email_content' || typeof v === 'string')
.map(([k, v]) => [k, k === 'email_content' ? v.length : v])
);
logger.info(`[${requestId}] INCOMING POST /process/${domain}: payload_summary=${JSON.stringify(payloadSummary)}`);
let recipients = [];
let parser;
let fromAddr = `lambda@${req.params.domain}`;
try {
// Decode and parse email
const content = data.email_content;
const compressed = data.compressed || false;
const raw = Base64.decode(content);
const emailBytes = compressed ? gunzipSync(Buffer.from(raw, 'binary')).toString('binary') : raw;
const emailBuffer = Buffer.from(emailBytes, 'binary');
parser = await simpleParser(emailBuffer);
fromAddr = parser.from?.value[0]?.address || `lambda@${domain}`;
recipients = [
...(parser.to?.value || []),
...(parser.cc?.value || []),
...(parser.bcc?.value || [])
].map(addr => addr.address).filter(Boolean);
if (!recipients.length) {
await markEmailAsProcessed(domain, data.s3_key, 'noRecipients', 'rest-api', fromAddr, []);
return res.status(400).json({ error: 'No recipients' });
}
// Filter valid recipients
const validRecipients = [];
for (const addr of recipients) {
const [local, dom] = addr.split('@');
if (!dom || dom.toLowerCase() !== domain.toLowerCase()) {
continue;
}
if (await inboxExists(domain, local)) {
validRecipients.push(addr);
} else {
logger.info(`Skipping non-existent inbox: ${addr}`);
}
}
if (!validRecipients.length) {
logger.info(`[${requestId}] No valid inboxes for ${domain} skip.`);
await markEmailAsProcessed(domain, data.s3_key, 'unknownUser', 'rest-api', fromAddr, recipients);
return res.status(404).json({ message: 'No valid inboxes skipped' });
}
// Send email
await transporter.sendMail({
from: fromAddr,
to: validRecipients,
raw: emailBytes
});
// Mark as processed
await markEmailAsProcessed(domain, data.s3_key, 'true', 'rest-api', fromAddr, validRecipients);
res.status(200).json({
message: 'Email forwarded',
forwarded_to: validRecipients
});
} catch (error) {
logger.error(`[${requestId}] Error in /process/${domain}: ${error.message}`);
await markEmailAsProcessed(domain, data.s3_key, 'error', 'rest-api', parser?.from?.value[0]?.address || `lambda@${domain}`, recipients);
res.status(500).json({ error: 'Internal server error' });
}
});
// Stats endpoint
app.get('/stats/:domain', async (req, res) => {
const { domain } = req.params;
const auth = req.headers['authorization']; // Fixed: Use req.headers['authorization'] instead of req.headers.get
if (auth !== `Bearer ${API_TOKEN}`) {
return res.status(401).json({ error: 'Unauthorized' });
}
const bucket = domain.replace(/\./g, '-') + '-emails';
let total = 0;
const counts = { true: 0, unknownDomain: 0, unknownUser: 0, noRecipients: 0, error: 0 };
const details = { unknownDomain: [], unknownUser: [], noRecipients: [], error: [] };
try {
// Fetch statuses from database
const { rows: domainStatuses } = await pool.query(
'SELECT s3_key, status, from_addr, to_addrs FROM email_statuses WHERE domain = $1',
[domain]
);
const statusMap = domainStatuses.reduce((acc, row) => {
acc[row.s3_key] = { status: row.status, from: row.from_addr, to: row.to_addrs || [] };
return acc;
}, {});
// List S3 objects
let continuationToken;
do {
const params = { Bucket: bucket, ContinuationToken: continuationToken };
const data = await s3Client.listObjectsV2(params).promise();
continuationToken = data.NextContinuationToken;
for (const obj of data.Contents || []) {
const key = obj.Key;
total += 1;
const statusInfo = statusMap[key] || { status: 'none' };
const status = statusInfo.status;
if (status in counts) {
counts[status] += 1;
}
if (status in details) {
try {
const objData = await s3Client.getObject({ Bucket: bucket, Key: key }).promise();
const parser = new MailParser();
await new Promise((resolve, reject) => {
parser.on('error', reject);
parser.on('end', resolve);
parser.write(objData.Body);
parser.end();
});
const fromAddr = parser.from?.value[0]?.address || null;
const toAddrs = [
...(parser.to?.value || []),
...(parser.cc?.value || []),
...(parser.bcc?.value || [])
].map(addr => addr.address).filter(Boolean);
details[status].push({
key,
from: statusInfo.from || fromAddr,
to: statusInfo.to || toAddrs
});
} catch (error) {
logger.error(`Error parsing ${bucket}/${key}: ${error.message}`);
}
}
}
} while (continuationToken);
const result = {
domain,
total_messages: total,
successful: counts.true,
wrong_domain: counts.unknownDomain,
unknown_user: counts.unknownUser,
no_recipients: counts.noRecipients,
errors: counts.error,
details
};
logger.info(`Stats for ${domain}: ${JSON.stringify(result)}`);
res.status(200).json(result);
} catch (error) {
logger.error(`Error in /stats/${domain}: ${error.message}`);
res.status(500).json({ error: 'Internal server error' });
}
});
// Start server
app.listen(5000, '0.0.0.0', () => {
logger.info('Server running on http://0.0.0.0:5000');
});

359
email_api/email_api/app.py Normal file
View File

@@ -0,0 +1,359 @@
import sys
from flask import Flask, request, jsonify
import smtplib
import base64
import gzip
import logging
import os
import time
import boto3
from email.parser import BytesParser
from email.policy import default
from email.utils import getaddresses
import requests
if sys.version_info < (3, 12):
raise RuntimeError("Python 3.12 oder höher erforderlich")
# --- Logging mit Timestamp ---
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
logger = logging.getLogger(__name__)
load_dotenv = None
try:
from dotenv import load_dotenv as _ld
load_dotenv = _ld
except ImportError:
pass
if load_dotenv:
load_dotenv()
app = Flask(__name__)
SMTP_HOST = "localhost"
SMTP_PORT = 25
API_TOKEN = os.environ.get('API_TOKEN')
AWS_REGION = os.environ.get('AWS_REGION', 'us-east-1')
API_KEY = os.environ['MAILCOW_API_KEY']
MAILCOW_API = os.environ['MAILCOW_API']
s3_client = boto3.client('s3', region_name=AWS_REGION)
def domain_exists(domain):
"""
Prüft per /get/domain/all, ob `domain` im System ist.
"""
url = f"{MAILCOW_API}/get/domain/all"
headers = {'X-API-Key': API_KEY}
resp = requests.get(url, headers=headers, timeout=5)
resp.raise_for_status()
domains = resp.json()
return any(d.get('domain_name', '').lower() == domain.lower() for d in domains)
def inbox_exists(domain, local_part):
"""
Liefert True, wenn domain im System ist UND local_part@domain ein Postfach hat.
"""
# 1) Domain-Check
if not domain_exists(domain):
logger.info(f"Domain '{domain}' unknown skip mailbox lookup")
return False
# 2) Nur dann Mailbox-Listing holen
url = f"{MAILCOW_API}/get/mailbox/all/{domain}"
headers = {'X-API-Key': API_KEY}
resp = requests.get(url, headers=headers, timeout=5)
resp.raise_for_status()
mailboxes = resp.json()
return any(m.get('local_part', '').lower() == local_part.lower() for m in mailboxes)
def mark_email_as_processed(bucket, key, status, processor='rest-api'):
"""Setzt processed-Metadaten auf einen beliebigen Status."""
try:
s3_client.copy_object(
Bucket=bucket,
Key=key,
CopySource={'Bucket': bucket, 'Key': key},
Metadata={
'processed': status,
'processed_timestamp': str(int(time.time())),
'processor': processor
},
MetadataDirective='REPLACE'
)
return True
except Exception as e:
logger.error(f"Fehler beim Markieren {bucket}/{key}: {e}")
return False
@app.route('/stats/<domain>', methods=['GET'])
def stats_domain(domain):
# Auth
auth = request.headers.get('Authorization')
if auth != f'Bearer {API_TOKEN}':
return jsonify({'error': 'Unauthorized'}), 401
bucket = domain.replace('.', '-') + '-emails'
paginator = s3_client.get_paginator('list_objects_v2')
total = 0
counts = {
'true': 0,
'unknownDomain': 0,
'unknownUser': 0
}
details = {
'unknownDomain': [],
'unknownUser': []
}
for page in paginator.paginate(Bucket=bucket):
for obj in page.get('Contents', []):
key = obj['Key']
total += 1
head = s3_client.head_object(Bucket=bucket, Key=key)
meta = head.get('Metadata', {})
status = meta.get('processed', 'none')
if status in counts:
counts[status] += 1
else:
# wir ignorieren andere Status
continue
# Für unknownDomain und unknownUser zusätzlich E-Mail parsen
if status in ('unknownDomain', 'unknownUser'):
body = s3_client.get_object(Bucket=bucket, Key=key)['Body'].read()
try:
msg = BytesParser(policy=default).parsebytes(body)
from_addr = getaddresses(msg.get_all('from', []))[0][1] if msg.get_all('from') else None
to_addrs = [addr for _n, addr in getaddresses(msg.get_all('to', []))]
except Exception as e:
logger.error(f"Fehler beim Parsen {bucket}/{key}: {e}")
from_addr = None
to_addrs = []
details[status].append({
'key': key,
'from': from_addr,
'to': to_addrs
})
result = {
'domain': domain,
'total_messages': total,
'successful': counts['true'],
'wrong_domain': counts['unknownDomain'],
'unknown_user': counts['unknownUser'],
'details': details
}
logger.info(f"Stats for {domain}: {result}")
return jsonify(result), 200
@app.route('/process/<domain>', methods=['POST'])
def process_email(domain):
auth = request.headers.get('Authorization')
if auth != f'Bearer {API_TOKEN}':
return jsonify({'error': 'Unauthorized'}), 401
data = request.get_json()
if not data:
return jsonify({'error': 'Invalid payload'}), 400
request_id = data.get('request_id', 'no-request-id')
payload_summary = {
k: (len(v) if k == 'email_content' else v)
for k, v in data.items()
if k != 'email_content' or isinstance(v, (str, bytes))
}
logger.info(
f"[{request_id}] INCOMING POST /process/{domain}: "
f"payload_summary={payload_summary}"
)
# 1) E-Mail decodieren und parsen wie gehabt
content = data.get('email_content')
compressed = data.get('compressed', False)
raw = base64.b64decode(content)
email_bytes = gzip.decompress(raw) if compressed else raw
msg = BytesParser(policy=default).parsebytes(email_bytes)
from_addr = getaddresses(msg.get_all('from', []))[0][1] if msg.get_all('from') else f'lambda@{domain}'
recipients = []
for hdr in ('to','cc','bcc'):
recipients += [addr for _n, addr in getaddresses(msg.get_all(hdr, []))]
if not recipients:
return jsonify({'error': 'No recipients'}), 400
# 2) Filter: nur Postfächer der angefragten Domain, die auch existieren
valid_recipients = []
for addr in recipients:
try:
local, dom = addr.split('@', 1)
except ValueError:
continue
if dom.lower() != domain.lower():
# andere Domain: überspringen
continue
if inbox_exists(domain, local):
valid_recipients.append(addr)
else:
logger.info(f"Skipping non-existent inbox: {addr}")
if not valid_recipients:
logger.info(f"[{request_id}] Keine gültigen Inboxes für {domain} skip.")
return jsonify({'message': 'No valid inboxes skipped'}), 404
# 3) Senden an die gefilterten Adressen
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as smtp:
smtp.sendmail(from_addr, valid_recipients, email_bytes)
return jsonify({
'message': 'Email forwarded',
'forwarded_to': valid_recipients
}), 200
@app.route('/retry/<domain>', methods=['GET'])
def retry_domain_emails(domain):
auth = request.headers.get('Authorization')
if auth != f'Bearer {API_TOKEN}':
return jsonify({'error': 'Unauthorized'}), 401
# 1) Domain-Check ganz am Anfang
if not domain_exists(domain):
logger.info(f"Retry aborted: unknown domain '{domain}'")
return jsonify({'error': f"Unknown domain '{domain}'"}), 404
bucket = domain.replace('.', '-') + '-emails'
paginator = s3_client.get_paginator('list_objects_v2')
# 2) alle unprocessed Keys sammeln
unprocessed = []
for page in paginator.paginate(Bucket=bucket):
for obj in page.get('Contents', []):
head = s3_client.head_object(Bucket=bucket, Key=obj['Key'])
if head.get('Metadata', {}).get('processed') != 'true':
unprocessed.append(obj['Key'])
request_id = f"retry-{domain}-{int(time.time())}"
logger.info(f"[{request_id}] RETRY for domain={domain}, keys={unprocessed}")
results = {'processed': [], 'failed': []}
for key in unprocessed:
try:
body = s3_client.get_object(Bucket=bucket, Key=key)['Body'].read()
msg = BytesParser(policy=default).parsebytes(body)
from_addr = (
getaddresses(msg.get_all('from', []))[0][1]
if msg.get_all('from') else f'retry@{domain}'
)
# Sammle alle To/Cc/Bcc
recipients = []
for hdr in ('to', 'cc', 'bcc'):
recipients += [addr for _n, addr in getaddresses(msg.get_all(hdr, []))]
if not recipients:
# gar keine Adressen → überspringen
mark_email_as_processed(bucket, key, 'unknownDomain')
results['processed'].append(key)
results['failed'].append({
'key': key,
'status': 'unknownDomain',
'reason': 'no recipients'
})
continue
# 3) Domain-Match: nur Mails, die an die angefragte Domain adressiert sind
domains_in_mail = {addr.split('@')[-1].lower() for addr in recipients if '@' in addr}
if domain.lower() not in domains_in_mail:
mark_email_as_processed(bucket, key, 'unknownDomain')
results['processed'].append(key)
results['failed'].append({
'key': key,
'status': 'unknownDomain',
'from': from_addr,
'to': recipients
})
continue
# 4) Inbox-Check: nur existierende Postfächer zulassen
valid_recipients = []
for addr in recipients:
try:
local, dom = addr.split('@', 1)
except ValueError:
continue
if dom.lower() == domain.lower() and inbox_exists(domain, local):
valid_recipients.append(addr)
else:
logger.info(f"Skipping non-existent inbox: {addr}")
if not valid_recipients:
mark_email_as_processed(bucket, key, 'unknownUser')
results['processed'].append(key)
results['failed'].append({
'key': key,
'status': 'unknownUser',
'from': from_addr,
'to': recipients
})
continue
# 5) Versand an die validierten Adressen
try:
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as smtp:
smtp.sendmail(from_addr, valid_recipients, body)
mark_email_as_processed(bucket, key, 'true')
results['processed'].append(key)
except smtplib.SMTPRecipientsRefused as e:
# falls Mailcow einzelne Adressen ablehnt
mark_email_as_processed(bucket, key, 'unknownUser')
refused = {
addr: {'code': code, 'message': msg.decode('utf-8','ignore') if isinstance(msg, bytes) else str(msg)}
for addr, (code, msg) in e.recipients.items()
}
results['processed'].append(key)
results['failed'].append({
'key': key,
'status': 'unknownUser',
'from': from_addr,
'to': valid_recipients,
'refused': refused
})
except Exception as e:
# alle anderen SMTP-Fehler behandeln wir als unknownDomain
mark_email_as_processed(bucket, key, 'unknownDomain')
results['processed'].append(key)
results['failed'].append({
'key': key,
'status': 'unknownDomain',
'from': from_addr,
'to': valid_recipients,
'error': str(e)
})
except Exception as e:
# Parsing- oder S3-Fehler
results['failed'].append({'key': key, 'error': str(e)})
return jsonify(results), 200
@app.route('/health', methods=['GET'])
def health_check():
return jsonify({'status': 'OK'}), 200
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)

View File

@@ -0,0 +1,15 @@
{
"name": "email-api",
"version": "1.0.0",
"type": "module",
"dependencies": {
"express": "^4.19.2",
"aws-sdk": "^2.1650.0",
"nodemailer": "^6.9.14",
"mailparser": "^3.7.1",
"js-base64": "^3.7.7",
"winston": "^3.13.1",
"dotenv": "^16.4.5",
"pg": "^8.12.0"
}
}

11
email_api/init.sql Normal file
View File

@@ -0,0 +1,11 @@
CREATE TABLE email_statuses (
id SERIAL PRIMARY KEY,
domain VARCHAR(255) NOT NULL,
s3_key VARCHAR(1024) NOT NULL,
status VARCHAR(50) NOT NULL,
timestamp BIGINT NOT NULL,
processor VARCHAR(50) NOT NULL,
from_addr TEXT,
to_addrs TEXT[],
UNIQUE (domain, s3_key)
);

View File

@@ -20,7 +20,7 @@ services:
volumes: volumes:
- gitea-data:/data - gitea-data:/data
#- ./gitea/gitea-ssh:/data/git/.ssh #- ./gitea/gitea-ssh:/data/git/.ssh
- /home/git/.ssh/:/data/git/.ssh #- /home/git/.ssh/:/data/git/.ssh
ports: ports:
- "3500:3500" - "3500:3500"
- "2222:22" - "2222:22"

1535
haikydb_backup.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,62 +0,0 @@
version: '3.8'
services:
postgres:
container_name: postgres_keycloak
image: postgres:15.7-alpine3.19
volumes:
- postgres_volume:/var/lib/postgresql/data
# - ./pg_hba.conf:/var/lib/postgresql/data/pg_hba.conf
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: "test1234"
# ports:
#- "2345:5432"
networks:
- keycloak
auth:
container_name: keycloak
image: quay.io/keycloak/keycloak:23.0.7
# restart: unless-stopped
ports:
- "8080:8080"
environment:
- KC_DB=postgres
- KC_DB_URL_HOST=${DB_HOST}
- KC_DB_URL_DATABASE=${POSTGRES_DB}
- KC_DB_USERNAME=${POSTGRES_USER}
- KC_DB_PASSWORD=test1234
- KC_PROXY=edge
- KC_HOSTNAME=${HOSTNAME}
- KC_HOSTNAME_ADMIN=${HOSTNAME}
# - KC_TRANSACTION_XA_ENABLED=false
- KC_METRICS_ENABLED=true
- KC_HEALTH_ENABLED=true
- KC_HOSTNAME_STRICT=false
- KC_HTTP_ENABLED=true
- KC_HOSTNAME_STRICT_HTTPS=false
# - PROXY_ADDRESS_FORWARDING=true
- KC_LOG_LEVEL=INFO
depends_on:
- postgres
# entrypoint: ["/opt/keycloak/wait-for-postgres.sh", "postgres_keycloak", "/opt/keycloak/bin/kc.sh", "start"]
# entrypoint: ["/opt/keycloak/bin/kc.sh", "start", "--db-password='test1234'"]
entrypoint: ["/opt/keycloak/bin/kc.sh", "start"]
volumes:
- ./auth/import:/opt/keycloak/data/import
- ./keywind.jar:/opt/keycloak/providers/keywind.jar
- ./redirect-uri-authenticator-1.0.0.jar:/opt/keycloak/providers/redirect-uri-authenticator-1.0.0.jar
- ./wait-for-postgres.sh:/opt/keycloak/wait-for-postgres.sh
networks:
- keycloak
networks:
keycloak:
external: true
volumes:
postgres_volume:
external: true

View File

@@ -0,0 +1,689 @@
services:
unbound-mailcow:
image: ghcr.io/mailcow/unbound:1.24
environment:
- TZ=${TZ}
- SKIP_UNBOUND_HEALTHCHECK=${SKIP_UNBOUND_HEALTHCHECK:-n}
volumes:
- ./data/hooks/unbound:/hooks:Z
- ./data/conf/unbound/unbound.conf:/etc/unbound/unbound.conf:ro,Z
restart: always
tty: true
networks:
mailcow-network:
ipv4_address: ${IPV4_NETWORK:-172.22.1}.254
aliases:
- unbound
mysql-mailcow:
image: mariadb:10.11
depends_on:
- unbound-mailcow
- netfilter-mailcow
stop_grace_period: 45s
volumes:
- mysql-vol-1:/var/lib/mysql/
- mysql-socket-vol-1:/var/run/mysqld/
- ./data/conf/mysql/:/etc/mysql/conf.d/:ro,Z
environment:
- TZ=${TZ}
- MYSQL_ROOT_PASSWORD=${DBROOT}
- MYSQL_DATABASE=${DBNAME}
- MYSQL_USER=${DBUSER}
- MYSQL_PASSWORD=${DBPASS}
- MYSQL_INITDB_SKIP_TZINFO=1
restart: always
ports:
- "${SQL_PORT:-127.0.0.1:13306}:3306"
networks:
mailcow-network:
aliases:
- mysql
redis-mailcow:
image: redis:7.4.2-alpine
entrypoint: ["/bin/sh","/redis-conf.sh"]
volumes:
- redis-vol-1:/data/
- ./data/conf/redis/redis-conf.sh:/redis-conf.sh:z
restart: always
depends_on:
- netfilter-mailcow
ports:
- "${REDIS_PORT:-127.0.0.1:7654}:6379"
environment:
- TZ=${TZ}
- REDISPASS=${REDISPASS}
- REDISMASTERPASS=${REDISMASTERPASS:-}
sysctls:
- net.core.somaxconn=4096
networks:
mailcow-network:
ipv4_address: ${IPV4_NETWORK:-172.22.1}.249
aliases:
- redis
clamd-mailcow:
image: ghcr.io/mailcow/clamd:1.70
restart: always
depends_on:
unbound-mailcow:
condition: service_healthy
dns:
- ${IPV4_NETWORK:-172.22.1}.254
environment:
- TZ=${TZ}
- SKIP_CLAMD=${SKIP_CLAMD:-n}
volumes:
- ./data/conf/clamav/:/etc/clamav/:Z
- clamd-db-vol-1:/var/lib/clamav
networks:
mailcow-network:
aliases:
- clamd
rspamd-mailcow:
image: ghcr.io/mailcow/rspamd:2.2
stop_grace_period: 30s
depends_on:
- dovecot-mailcow
- clamd-mailcow
environment:
- TZ=${TZ}
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
- IPV6_NETWORK=${IPV6_NETWORK:-fd4d:6169:6c63:6f77::/64}
- REDIS_SLAVEOF_IP=${REDIS_SLAVEOF_IP:-}
- REDIS_SLAVEOF_PORT=${REDIS_SLAVEOF_PORT:-}
- REDISPASS=${REDISPASS}
- SPAMHAUS_DQS_KEY=${SPAMHAUS_DQS_KEY:-}
volumes:
- ./data/hooks/rspamd:/hooks:Z
- ./data/conf/rspamd/custom/:/etc/rspamd/custom:z
- ./data/conf/rspamd/override.d/:/etc/rspamd/override.d:Z
- ./data/conf/rspamd/local.d/:/etc/rspamd/local.d:Z
- ./data/conf/rspamd/plugins.d/:/etc/rspamd/plugins.d:Z
- ./data/conf/rspamd/lua/:/etc/rspamd/lua/:ro,Z
- ./data/conf/rspamd/rspamd.conf.local:/etc/rspamd/rspamd.conf.local:Z
- ./data/conf/rspamd/rspamd.conf.override:/etc/rspamd/rspamd.conf.override:Z
- rspamd-vol-1:/var/lib/rspamd
restart: always
hostname: rspamd
dns:
- ${IPV4_NETWORK:-172.22.1}.254
networks:
mailcow-network:
aliases:
- rspamd
php-fpm-mailcow:
image: ghcr.io/mailcow/phpfpm:1.93
command: "php-fpm -d date.timezone=${TZ} -d expose_php=0"
depends_on:
- redis-mailcow
volumes:
- ./data/hooks/phpfpm:/hooks:Z
- ./data/web:/web:z
- ./data/conf/rspamd/dynmaps:/dynmaps:ro,z
- ./data/conf/rspamd/custom/:/rspamd_custom_maps:z
- ./data/conf/dovecot/auth/mailcowauth.php:/mailcowauth/mailcowauth.php:z
- ./data/web/inc/functions.inc.php:/mailcowauth/functions.inc.php:z
- ./data/web/inc/functions.auth.inc.php:/mailcowauth/functions.auth.inc.php:z
- ./data/web/inc/sessions.inc.php:/mailcowauth/sessions.inc.php:z
- ./data/web/inc/functions.mailbox.inc.php:/mailcowauth/functions.mailbox.inc.php:z
- ./data/web/inc/functions.ratelimit.inc.php:/mailcowauth/functions.ratelimit.inc.php:z
- ./data/web/inc/functions.acl.inc.php:/mailcowauth/functions.acl.inc.php:z
- rspamd-vol-1:/var/lib/rspamd
- mysql-socket-vol-1:/var/run/mysqld/
- ./data/conf/sogo/:/etc/sogo/:z
- ./data/conf/rspamd/meta_exporter:/meta_exporter:ro,z
- ./data/conf/phpfpm/crons:/crons:z
- ./data/conf/phpfpm/sogo-sso/:/etc/sogo-sso/:z
- ./data/conf/phpfpm/php-fpm.d/pools.conf:/usr/local/etc/php-fpm.d/z-pools.conf:Z
- ./data/conf/phpfpm/php-conf.d/opcache-recommended.ini:/usr/local/etc/php/conf.d/opcache-recommended.ini:Z
- ./data/conf/phpfpm/php-conf.d/upload.ini:/usr/local/etc/php/conf.d/upload.ini:Z
- ./data/conf/phpfpm/php-conf.d/other.ini:/usr/local/etc/php/conf.d/zzz-other.ini:Z
- ./data/conf/dovecot/global_sieve_before:/global_sieve/before:z
- ./data/conf/dovecot/global_sieve_after:/global_sieve/after:z
- ./data/assets/templates:/tpls:z
- ./data/conf/nginx/:/etc/nginx/conf.d/:z
dns:
- ${IPV4_NETWORK:-172.22.1}.254
environment:
- REDIS_SLAVEOF_IP=${REDIS_SLAVEOF_IP:-}
- REDIS_SLAVEOF_PORT=${REDIS_SLAVEOF_PORT:-}
- REDISPASS=${REDISPASS}
- LOG_LINES=${LOG_LINES:-9999}
- TZ=${TZ}
- DBNAME=${DBNAME}
- DBUSER=${DBUSER}
- DBPASS=${DBPASS}
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
- MAILCOW_PASS_SCHEME=${MAILCOW_PASS_SCHEME:-BLF-CRYPT}
- IMAP_PORT=${IMAP_PORT:-143}
- IMAPS_PORT=${IMAPS_PORT:-993}
- POP_PORT=${POP_PORT:-110}
- POPS_PORT=${POPS_PORT:-995}
- SIEVE_PORT=${SIEVE_PORT:-4190}
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
- IPV6_NETWORK=${IPV6_NETWORK:-fd4d:6169:6c63:6f77::/64}
- SUBMISSION_PORT=${SUBMISSION_PORT:-587}
- SMTPS_PORT=${SMTPS_PORT:-465}
- SMTP_PORT=${SMTP_PORT:-25}
- API_KEY=${API_KEY:-invalid}
- API_KEY_READ_ONLY=${API_KEY_READ_ONLY:-invalid}
- API_ALLOW_FROM=${API_ALLOW_FROM:-invalid}
- COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME:-mailcow-dockerized}
- SKIP_FTS=${SKIP_FTS:-y}
- SKIP_CLAMD=${SKIP_CLAMD:-n}
- SKIP_OLEFY=${SKIP_OLEFY:-n}
- SKIP_SOGO=${SKIP_SOGO:-n}
- ALLOW_ADMIN_EMAIL_LOGIN=${ALLOW_ADMIN_EMAIL_LOGIN:-n}
- MASTER=${MASTER:-y}
- DEV_MODE=${DEV_MODE:-n}
- DEMO_MODE=${DEMO_MODE:-n}
- WEBAUTHN_ONLY_TRUSTED_VENDORS=${WEBAUTHN_ONLY_TRUSTED_VENDORS:-n}
- CLUSTERMODE=${CLUSTERMODE:-}
- ADDITIONAL_SERVER_NAMES=${ADDITIONAL_SERVER_NAMES:-}
restart: always
labels:
ofelia.enabled: "true"
ofelia.job-exec.phpfpm_keycloak_sync.schedule: "@every 1m"
ofelia.job-exec.phpfpm_keycloak_sync.no-overlap: "true"
ofelia.job-exec.phpfpm_keycloak_sync.command: "/bin/bash -c \"php /crons/keycloak-sync.php || exit 0\""
ofelia.job-exec.phpfpm_ldap_sync.schedule: "@every 1m"
ofelia.job-exec.phpfpm_ldap_sync.no-overlap: "true"
ofelia.job-exec.phpfpm_ldap_sync.command: "/bin/bash -c \"php /crons/ldap-sync.php || exit 0\""
networks:
mailcow-network:
aliases:
- phpfpm
sogo-mailcow:
image: ghcr.io/mailcow/sogo:1.133
environment:
- DBNAME=${DBNAME}
- DBUSER=${DBUSER}
- DBPASS=${DBPASS}
- TZ=${TZ}
- LOG_LINES=${LOG_LINES:-9999}
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
- MAILCOW_PASS_SCHEME=${MAILCOW_PASS_SCHEME:-BLF-CRYPT}
- ACL_ANYONE=${ACL_ANYONE:-disallow}
- ALLOW_ADMIN_EMAIL_LOGIN=${ALLOW_ADMIN_EMAIL_LOGIN:-n}
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
- SOGO_EXPIRE_SESSION=${SOGO_EXPIRE_SESSION:-480}
- SKIP_SOGO=${SKIP_SOGO:-n}
- MASTER=${MASTER:-y}
- REDIS_SLAVEOF_IP=${REDIS_SLAVEOF_IP:-}
- REDIS_SLAVEOF_PORT=${REDIS_SLAVEOF_PORT:-}
- REDISPASS=${REDISPASS}
dns:
- ${IPV4_NETWORK:-172.22.1}.254
volumes:
- ./data/hooks/sogo:/hooks:Z
- ./data/conf/sogo/:/etc/sogo/:z
- ./data/web/inc/init_db.inc.php:/init_db.inc.php:z
- ./data/conf/sogo/custom-favicon.ico:/usr/lib/GNUstep/SOGo/WebServerResources/img/sogo.ico:z
- ./data/conf/sogo/custom-shortlogo.svg:/usr/lib/GNUstep/SOGo/WebServerResources/img/sogo-compact.svg:z
- ./data/conf/sogo/custom-fulllogo.svg:/usr/lib/GNUstep/SOGo/WebServerResources/img/sogo-full.svg:z
- ./data/conf/sogo/custom-fulllogo.png:/usr/lib/GNUstep/SOGo/WebServerResources/img/sogo-logo.png:z
- ./data/conf/sogo/custom-theme.js:/usr/lib/GNUstep/SOGo/WebServerResources/js/theme.js:z
- ./data/conf/sogo/custom-sogo.js:/usr/lib/GNUstep/SOGo/WebServerResources/js/custom-sogo.js:z
- mysql-socket-vol-1:/var/run/mysqld/
- sogo-web-vol-1:/sogo_web
- sogo-userdata-backup-vol-1:/sogo_backup
labels:
ofelia.enabled: "true"
ofelia.job-exec.sogo_sessions.schedule: "@every 1m"
ofelia.job-exec.sogo_sessions.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu sogo /usr/sbin/sogo-tool -v expire-sessions $${SOGO_EXPIRE_SESSION} || exit 0\""
ofelia.job-exec.sogo_ealarms.schedule: "@every 1m"
ofelia.job-exec.sogo_ealarms.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu sogo /usr/sbin/sogo-ealarms-notify -p /etc/sogo/cron.creds || exit 0\""
ofelia.job-exec.sogo_eautoreply.schedule: "@every 5m"
ofelia.job-exec.sogo_eautoreply.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu sogo /usr/sbin/sogo-tool update-autoreply -p /etc/sogo/cron.creds || exit 0\""
ofelia.job-exec.sogo_backup.schedule: "@every 24h"
ofelia.job-exec.sogo_backup.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu sogo /usr/sbin/sogo-tool backup /sogo_backup ALL || exit 0\""
restart: always
networks:
mailcow-network:
ipv4_address: ${IPV4_NETWORK:-172.22.1}.248
aliases:
- sogo
dovecot-mailcow:
image: ghcr.io/mailcow/dovecot:2.33
depends_on:
- mysql-mailcow
- netfilter-mailcow
- redis-mailcow
dns:
- ${IPV4_NETWORK:-172.22.1}.254
cap_add:
- NET_BIND_SERVICE
volumes:
- ./data/hooks/dovecot:/hooks:Z
- ./data/conf/dovecot:/etc/dovecot:z
- ./data/assets/ssl:/etc/ssl/mail/:ro,z
- ./data/conf/sogo/:/etc/sogo/:z
- ./data/conf/phpfpm/sogo-sso/:/etc/phpfpm/:z
- vmail-vol-1:/var/vmail
- vmail-index-vol-1:/var/vmail_index
- crypt-vol-1:/mail_crypt/
- ./data/conf/rspamd/custom/:/etc/rspamd/custom:z
- ./data/assets/templates:/templates:z
- rspamd-vol-1:/var/lib/rspamd
- mysql-socket-vol-1:/var/run/mysqld/
environment:
- DOVECOT_MASTER_USER=${DOVECOT_MASTER_USER:-}
- DOVECOT_MASTER_PASS=${DOVECOT_MASTER_PASS:-}
- MAILCOW_REPLICA_IP=${MAILCOW_REPLICA_IP:-}
- DOVEADM_REPLICA_PORT=${DOVEADM_REPLICA_PORT:-}
- LOG_LINES=${LOG_LINES:-9999}
- DBNAME=${DBNAME}
- DBUSER=${DBUSER}
- DBPASS=${DBPASS}
- TZ=${TZ}
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
- MAILCOW_PASS_SCHEME=${MAILCOW_PASS_SCHEME:-BLF-CRYPT}
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
- ALLOW_ADMIN_EMAIL_LOGIN=${ALLOW_ADMIN_EMAIL_LOGIN:-n}
- MAILDIR_GC_TIME=${MAILDIR_GC_TIME:-7200}
- ACL_ANYONE=${ACL_ANYONE:-disallow}
- SKIP_FTS=${SKIP_FTS:-y}
- FTS_HEAP=${FTS_HEAP:-512}
- FTS_PROCS=${FTS_PROCS:-3}
- MAILDIR_SUB=${MAILDIR_SUB:-}
- MASTER=${MASTER:-y}
- REDIS_SLAVEOF_IP=${REDIS_SLAVEOF_IP:-}
- REDIS_SLAVEOF_PORT=${REDIS_SLAVEOF_PORT:-}
- REDISPASS=${REDISPASS}
- COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME:-mailcow-dockerized}
ports:
- "${DOVEADM_PORT:-127.0.0.1:19991}:12345"
- "${IMAP_PORT:-143}:143"
- "${IMAPS_PORT:-993}:993"
- "${POP_PORT:-110}:110"
- "${POPS_PORT:-995}:995"
- "${SIEVE_PORT:-4190}:4190"
restart: always
tty: true
labels:
ofelia.enabled: "true"
ofelia.job-exec.dovecot_imapsync_runner.schedule: "@every 1m"
ofelia.job-exec.dovecot_imapsync_runner.no-overlap: "true"
ofelia.job-exec.dovecot_imapsync_runner.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu nobody /usr/local/bin/imapsync_runner.pl || exit 0\""
ofelia.job-exec.dovecot_trim_logs.schedule: "@every 1m"
ofelia.job-exec.dovecot_trim_logs.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu vmail /usr/local/bin/trim_logs.sh || exit 0\""
ofelia.job-exec.dovecot_quarantine.schedule: "@every 20m"
ofelia.job-exec.dovecot_quarantine.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu vmail /usr/local/bin/quarantine_notify.py || exit 0\""
ofelia.job-exec.dovecot_clean_q_aged.schedule: "@every 24h"
ofelia.job-exec.dovecot_clean_q_aged.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu vmail /usr/local/bin/clean_q_aged.sh || exit 0\""
ofelia.job-exec.dovecot_maildir_gc.schedule: "@every 30m"
ofelia.job-exec.dovecot_maildir_gc.command: "/bin/bash -c \"source /source_env.sh ; /usr/local/bin/gosu vmail /usr/local/bin/maildir_gc.sh\""
ofelia.job-exec.dovecot_sarules.schedule: "@every 24h"
ofelia.job-exec.dovecot_sarules.command: "/bin/bash -c \"/usr/local/bin/sa-rules.sh\""
ofelia.job-exec.dovecot_fts.schedule: "@every 24h"
ofelia.job-exec.dovecot_fts.command: "/bin/bash -c \"/usr/local/bin/gosu vmail /usr/local/bin/optimize-fts.sh\""
ofelia.job-exec.dovecot_repl_health.schedule: "@every 5m"
ofelia.job-exec.dovecot_repl_health.command: "/bin/bash -c \"/usr/local/bin/gosu vmail /usr/local/bin/repl_health.sh\""
ulimits:
nproc: 65535
nofile:
soft: 20000
hard: 40000
networks:
mailcow-network:
ipv4_address: ${IPV4_NETWORK:-172.22.1}.250
aliases:
- dovecot
postfix-mailcow:
image: ghcr.io/mailcow/postfix:1.80
depends_on:
mysql-mailcow:
condition: service_started
unbound-mailcow:
condition: service_healthy
volumes:
- ./data/hooks/postfix:/hooks:Z
- ./data/conf/postfix:/opt/postfix/conf:z
- ./data/assets/ssl:/etc/ssl/mail/:ro,z
- postfix-vol-1:/var/spool/postfix
- crypt-vol-1:/var/lib/zeyple
- rspamd-vol-1:/var/lib/rspamd
- mysql-socket-vol-1:/var/run/mysqld/
environment:
- LOG_LINES=${LOG_LINES:-9999}
- TZ=${TZ}
- DBNAME=${DBNAME}
- DBUSER=${DBUSER}
- DBPASS=${DBPASS}
- REDIS_SLAVEOF_IP=${REDIS_SLAVEOF_IP:-}
- REDIS_SLAVEOF_PORT=${REDIS_SLAVEOF_PORT:-}
- REDISPASS=${REDISPASS}
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
- SPAMHAUS_DQS_KEY=${SPAMHAUS_DQS_KEY:-}
cap_add:
- NET_BIND_SERVICE
ports:
- "${SMTP_PORT:-25}:25"
- "${SMTPS_PORT:-465}:465"
- "${SUBMISSION_PORT:-587}:587"
restart: always
dns:
- ${IPV4_NETWORK:-172.22.1}.254
networks:
mailcow-network:
ipv4_address: ${IPV4_NETWORK:-172.22.1}.253
aliases:
- postfix
memcached-mailcow:
image: memcached:alpine
restart: always
environment:
- TZ=${TZ}
networks:
mailcow-network:
aliases:
- memcached
nginx-mailcow:
depends_on:
- redis-mailcow
- php-fpm-mailcow
- sogo-mailcow
- rspamd-mailcow
image: ghcr.io/mailcow/nginx:1.03
dns:
- ${IPV4_NETWORK:-172.22.1}.254
environment:
- HTTPS_PORT=${HTTPS_PORT:-8443}
- HTTP_PORT=${HTTP_PORT:-8080}
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
- ADDITIONAL_SERVER_NAMES=${ADDITIONAL_SERVER_NAMES:-}
- TZ=${TZ}
- SKIP_SOGO=${SKIP_SOGO:-n}
- SKIP_RSPAMD=${SKIP_RSPAMD:-n}
- DISABLE_IPv6=${DISABLE_IPv6:-n}
- HTTP_REDIRECT=${HTTP_REDIRECT:-n}
- PHPFPMHOST=${PHPFPMHOST:-}
- SOGOHOST=${SOGOHOST:-}
- RSPAMDHOST=${RSPAMDHOST:-}
- REDISHOST=${REDISHOST:-}
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
- NGINX_USE_PROXY_PROTOCOL=${NGINX_USE_PROXY_PROTOCOL:-n}
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
volumes:
- ./data/web:/web:ro,z
- ./data/conf/rspamd/dynmaps:/dynmaps:ro,z
- ./data/assets/ssl/:/etc/ssl/mail/:ro,z
- ./data/conf/nginx/:/etc/nginx/conf.d/:z
- ./data/conf/rspamd/meta_exporter:/meta_exporter:ro,z
- ./data/conf/dovecot/auth/mailcowauth.php:/mailcowauth/mailcowauth.php:z
- ./data/web/inc/functions.inc.php:/mailcowauth/functions.inc.php:z
- ./data/web/inc/functions.auth.inc.php:/mailcowauth/functions.auth.inc.php:z
- ./data/web/inc/sessions.inc.php:/mailcowauth/sessions.inc.php:z
- sogo-web-vol-1:/usr/lib/GNUstep/SOGo/
ports:
- "${HTTPS_BIND:-}:${HTTPS_PORT:-8443}:${HTTPS_PORT:-8443}"
- "${HTTP_BIND:-}:${HTTP_PORT:-8080}:${HTTP_PORT:-8080}"
restart: always
networks:
mailcow-network:
aliases:
- nginx
mail_network: {}
acme-mailcow:
depends_on:
nginx-mailcow:
condition: service_started
unbound-mailcow:
condition: service_healthy
image: ghcr.io/mailcow/acme:1.92
dns:
- ${IPV4_NETWORK:-172.22.1}.254
environment:
- LOG_LINES=${LOG_LINES:-9999}
- ACME_CONTACT=${ACME_CONTACT:-}
- ADDITIONAL_SAN=${ADDITIONAL_SAN}
- AUTODISCOVER_SAN=${AUTODISCOVER_SAN:-y}
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
- DBNAME=${DBNAME}
- DBUSER=${DBUSER}
- DBPASS=${DBPASS}
- SKIP_LETS_ENCRYPT=${SKIP_LETS_ENCRYPT:-n}
- COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME:-mailcow-dockerized}
- DIRECTORY_URL=${DIRECTORY_URL:-}
- ENABLE_SSL_SNI=${ENABLE_SSL_SNI:-n}
- SKIP_IP_CHECK=${SKIP_IP_CHECK:-n}
- SKIP_HTTP_VERIFICATION=${SKIP_HTTP_VERIFICATION:-n}
- ONLY_MAILCOW_HOSTNAME=${ONLY_MAILCOW_HOSTNAME:-n}
- LE_STAGING=${LE_STAGING:-n}
- TZ=${TZ}
- REDIS_SLAVEOF_IP=${REDIS_SLAVEOF_IP:-}
- REDIS_SLAVEOF_PORT=${REDIS_SLAVEOF_PORT:-}
- REDISPASS=${REDISPASS}
- SNAT_TO_SOURCE=${SNAT_TO_SOURCE:-n}
- SNAT6_TO_SOURCE=${SNAT6_TO_SOURCE:-n}
volumes:
- ./data/web/.well-known/acme-challenge:/var/www/acme:z
- ./data/assets/ssl:/var/lib/acme/:z
- ./data/assets/ssl-example:/var/lib/ssl-example/:ro,Z
- mysql-socket-vol-1:/var/run/mysqld/
restart: always
networks:
mailcow-network:
aliases:
- acme
netfilter-mailcow:
image: ghcr.io/mailcow/netfilter:1.61
stop_grace_period: 30s
restart: always
privileged: true
environment:
- TZ=${TZ}
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
- IPV6_NETWORK=${IPV6_NETWORK:-fd4d:6169:6c63:6f77::/64}
- SNAT_TO_SOURCE=${SNAT_TO_SOURCE:-n}
- SNAT6_TO_SOURCE=${SNAT6_TO_SOURCE:-n}
- REDIS_SLAVEOF_IP=${REDIS_SLAVEOF_IP:-}
- REDIS_SLAVEOF_PORT=${REDIS_SLAVEOF_PORT:-}
- REDISPASS=${REDISPASS}
- MAILCOW_REPLICA_IP=${MAILCOW_REPLICA_IP:-}
- DISABLE_NETFILTER_ISOLATION_RULE=${DISABLE_NETFILTER_ISOLATION_RULE:-n}
network_mode: "host"
volumes:
- /lib/modules:/lib/modules:ro
watchdog-mailcow:
image: ghcr.io/mailcow/watchdog:2.08
dns:
- ${IPV4_NETWORK:-172.22.1}.254
tmpfs:
- /tmp
volumes:
- rspamd-vol-1:/var/lib/rspamd
- mysql-socket-vol-1:/var/run/mysqld/
- postfix-vol-1:/var/spool/postfix
- ./data/assets/ssl:/etc/ssl/mail/:ro,z
restart: always
depends_on:
- postfix-mailcow
- dovecot-mailcow
- mysql-mailcow
- acme-mailcow
- redis-mailcow
environment:
- IPV6_NETWORK=${IPV6_NETWORK:-fd4d:6169:6c63:6f77::/64}
- LOG_LINES=${LOG_LINES:-9999}
- TZ=${TZ}
- DBNAME=${DBNAME}
- DBUSER=${DBUSER}
- DBPASS=${DBPASS}
- DBROOT=${DBROOT}
- USE_WATCHDOG=${USE_WATCHDOG:-n}
- WATCHDOG_NOTIFY_EMAIL=${WATCHDOG_NOTIFY_EMAIL:-}
- WATCHDOG_NOTIFY_BAN=${WATCHDOG_NOTIFY_BAN:-y}
- WATCHDOG_NOTIFY_START=${WATCHDOG_NOTIFY_START:-y}
- WATCHDOG_SUBJECT=${WATCHDOG_SUBJECT:-Watchdog ALERT}
- WATCHDOG_NOTIFY_WEBHOOK=${WATCHDOG_NOTIFY_WEBHOOK:-}
- WATCHDOG_NOTIFY_WEBHOOK_BODY=${WATCHDOG_NOTIFY_WEBHOOK_BODY:-}
- WATCHDOG_EXTERNAL_CHECKS=${WATCHDOG_EXTERNAL_CHECKS:-n}
- WATCHDOG_MYSQL_REPLICATION_CHECKS=${WATCHDOG_MYSQL_REPLICATION_CHECKS:-n}
- WATCHDOG_VERBOSE=${WATCHDOG_VERBOSE:-n}
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
- COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME:-mailcow-dockerized}
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
- IP_BY_DOCKER_API=${IP_BY_DOCKER_API:-0}
- CHECK_UNBOUND=${CHECK_UNBOUND:-1}
- SKIP_CLAMD=${SKIP_CLAMD:-n}
- SKIP_OLEFY=${SKIP_OLEFY:-n}
- SKIP_LETS_ENCRYPT=${SKIP_LETS_ENCRYPT:-n}
- SKIP_SOGO=${SKIP_SOGO:-n}
- HTTPS_PORT=${HTTPS_PORT:-8443}
- REDIS_SLAVEOF_IP=${REDIS_SLAVEOF_IP:-}
- REDIS_SLAVEOF_PORT=${REDIS_SLAVEOF_PORT:-}
- REDISPASS=${REDISPASS}
- EXTERNAL_CHECKS_THRESHOLD=${EXTERNAL_CHECKS_THRESHOLD:-1}
- NGINX_THRESHOLD=${NGINX_THRESHOLD:-5}
- UNBOUND_THRESHOLD=${UNBOUND_THRESHOLD:-5}
- REDIS_THRESHOLD=${REDIS_THRESHOLD:-5}
- MYSQL_THRESHOLD=${MYSQL_THRESHOLD:-5}
- MYSQL_REPLICATION_THRESHOLD=${MYSQL_REPLICATION_THRESHOLD:-1}
- SOGO_THRESHOLD=${SOGO_THRESHOLD:-3}
- POSTFIX_THRESHOLD=${POSTFIX_THRESHOLD:-8}
- CLAMD_THRESHOLD=${CLAMD_THRESHOLD:-15}
- DOVECOT_THRESHOLD=${DOVECOT_THRESHOLD:-12}
- DOVECOT_REPL_THRESHOLD=${DOVECOT_REPL_THRESHOLD:-20}
- PHPFPM_THRESHOLD=${PHPFPM_THRESHOLD:-5}
- RATELIMIT_THRESHOLD=${RATELIMIT_THRESHOLD:-1}
- FAIL2BAN_THRESHOLD=${FAIL2BAN_THRESHOLD:-1}
- ACME_THRESHOLD=${ACME_THRESHOLD:-1}
- RSPAMD_THRESHOLD=${RSPAMD_THRESHOLD:-5}
- OLEFY_THRESHOLD=${OLEFY_THRESHOLD:-5}
- MAILQ_THRESHOLD=${MAILQ_THRESHOLD:-20}
- MAILQ_CRIT=${MAILQ_CRIT:-30}
networks:
mailcow-network:
aliases:
- watchdog
dockerapi-mailcow:
image: ghcr.io/mailcow/dockerapi:2.11
security_opt:
- label=disable
restart: always
dns:
- ${IPV4_NETWORK:-172.22.1}.254
environment:
- DBROOT=${DBROOT}
- TZ=${TZ}
- REDIS_SLAVEOF_IP=${REDIS_SLAVEOF_IP:-}
- REDIS_SLAVEOF_PORT=${REDIS_SLAVEOF_PORT:-}
- REDISPASS=${REDISPASS}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
mailcow-network:
aliases:
- dockerapi
olefy-mailcow:
image: ghcr.io/mailcow/olefy:1.15
restart: always
environment:
- TZ=${TZ}
- OLEFY_BINDADDRESS=0.0.0.0
- OLEFY_BINDPORT=10055
- OLEFY_TMPDIR=/tmp
- OLEFY_PYTHON_PATH=/usr/bin/python3
- OLEFY_OLEVBA_PATH=/usr/bin/olevba
- OLEFY_LOGLVL=20
- OLEFY_MINLENGTH=500
- OLEFY_DEL_TMP=1
- SKIP_OLEFY=${SKIP_OLEFY:-n}
networks:
mailcow-network:
aliases:
- olefy
ofelia-mailcow:
image: mcuadros/ofelia:latest
restart: always
command: daemon --docker -f label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}
environment:
- TZ=${TZ}
- COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}
depends_on:
- sogo-mailcow
- dovecot-mailcow
labels:
ofelia.enabled: "true"
security_opt:
- label=disable
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
mailcow-network:
aliases:
- ofelia
ipv6nat-mailcow:
depends_on:
- unbound-mailcow
- mysql-mailcow
- redis-mailcow
- clamd-mailcow
- rspamd-mailcow
- php-fpm-mailcow
- sogo-mailcow
- dovecot-mailcow
- postfix-mailcow
- memcached-mailcow
- nginx-mailcow
- acme-mailcow
- netfilter-mailcow
- watchdog-mailcow
- dockerapi-mailcow
environment:
- TZ=${TZ}
image: robbertkl/ipv6nat
security_opt:
- label=disable
restart: always
privileged: true
network_mode: "host"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- /lib/modules:/lib/modules:ro
networks:
mailcow-network:
driver: bridge
driver_opts:
com.docker.network.bridge.name: br-mailcow
enable_ipv6: true
ipam:
driver: default
config:
- subnet: ${IPV4_NETWORK:-172.22.1}.0/24
- subnet: ${IPV6_NETWORK:-fd4d:6169:6c63:6f77::/64}
mail_network:
external: true
volumes:
vmail-vol-1:
vmail-index-vol-1:
mysql-vol-1:
mysql-socket-vol-1:
redis-vol-1:
rspamd-vol-1:
postfix-vol-1:
crypt-vol-1:
sogo-web-vol-1:
sogo-userdata-backup-vol-1:
clamd-db-vol-1:

View File

@@ -0,0 +1,300 @@
# ------------------------------
# mailcow web ui configuration
# ------------------------------
# example.org is _not_ a valid hostname, use a fqdn here.
# Default admin user is "admin"
# Default password is "moohoo"
MAILCOW_HOSTNAME=mail.andreasknuth.de
# Password hash algorithm
# Only certain password hash algorithm are supported. For a fully list of supported schemes,
# see https://docs.mailcow.email/models/model-passwd/
MAILCOW_PASS_SCHEME=BLF-CRYPT
# ------------------------------
# SQL database configuration
# ------------------------------
DBNAME=mailcow
DBUSER=mailcow
# Please use long, random alphanumeric strings (A-Za-z0-9)
DBPASS=KGekNNga7WLZvNwr2eAYiMhU7aUG
DBROOT=gSeRDgCUmndjb38kpEf919naoklx
# ------------------------------
# REDIS configuration
# ------------------------------
REDISPASS=LsamNIsi3taCxMgOva0iVfcXOV5O
# ------------------------------
# HTTP/S Bindings
# ------------------------------
# You should use HTTPS, but in case of SSL offloaded reverse proxies:
# Might be important: This will also change the binding within the container.
# If you use a proxy within Docker, point it to the ports you set below.
# Do _not_ use IP:PORT in HTTP(S)_BIND or HTTP(S)_PORT
# IMPORTANT: Do not use port 8081, 9081, 9082 or 65510!
# Example: HTTP_BIND=1.2.3.4
# For IPv4 leave it as it is: HTTP_BIND= & HTTPS_PORT=
# For IPv6 see https://docs.mailcow.email/post_installation/firststeps-ip_bindings/
HTTP_PORT=80
HTTP_BIND=127.0.0.1
HTTPS_PORT=8443
HTTPS_BIND=127.0.0.1
# Redirect HTTP connections to HTTPS - y/n
HTTP_REDIRECT=n
# ------------------------------
# Other bindings
# ------------------------------
# You should leave that alone
# Format: 11.22.33.44:25 or 12.34.56.78:465 etc.
SMTP_PORT=25
SMTPS_PORT=465
SUBMISSION_PORT=587
IMAP_PORT=143
IMAPS_PORT=993
POP_PORT=110
POPS_PORT=995
SIEVE_PORT=4190
DOVEADM_PORT=127.0.0.1:19991
SQL_PORT=127.0.0.1:13306
REDIS_PORT=127.0.0.1:7654
# Your timezone
# See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a list of timezones
# Use the column named 'TZ identifier' + pay attention for the column named 'Notes'
TZ=America/Chicago
# Fixed project name
# Please use lowercase letters only
COMPOSE_PROJECT_NAME=mailcowdockerized
# Used Docker Compose version
# Switch here between native (compose plugin) and standalone
# For more informations take a look at the mailcow docs regarding the configuration options.
# Normally this should be untouched but if you decided to use either of those you can switch it manually here.
# Please be aware that at least one of those variants should be installed on your machine or mailcow will fail.
DOCKER_COMPOSE_VERSION=native
# Set this to "allow" to enable the anyone pseudo user. Disabled by default.
# When enabled, ACL can be created, that apply to "All authenticated users"
# This should probably only be activated on mail hosts, that are used exclusivly by one organisation.
# Otherwise a user might share data with too many other users.
ACL_ANYONE=disallow
# Garbage collector cleanup
# Deleted domains and mailboxes are moved to /var/vmail/_garbage/timestamp_sanitizedstring
# How long should objects remain in the garbage until they are being deleted? (value in minutes)
# Check interval is hourly
MAILDIR_GC_TIME=7200
# Additional SAN for the certificate
#
# You can use wildcard records to create specific names for every domain you add to mailcow.
# Example: Add domains "example.com" and "example.net" to mailcow, change ADDITIONAL_SAN to a value like:
#ADDITIONAL_SAN=imap.*,smtp.*
# This will expand the certificate to "imap.example.com", "smtp.example.com", "imap.example.net", "smtp.example.net"
# plus every domain you add in the future.
#
# You can also just add static names...
#ADDITIONAL_SAN=srv1.example.net
# ...or combine wildcard and static names:
#ADDITIONAL_SAN=imap.*,srv1.example.com
#
ADDITIONAL_SAN=
# Obtain certificates for autodiscover.* and autoconfig.* domains.
# This can be useful to switch off in case you are in a scenario where a reverse proxy already handles those.
# There are mixed scenarios where ports 80,443 are occupied and you do not want to share certs
# between services. So acme-mailcow obtains for maildomains and all web-things get handled
# in the reverse proxy.
AUTODISCOVER_SAN=y
# Additional server names for mailcow UI
#
# Specify alternative addresses for the mailcow UI to respond to
# This is useful when you set mail.* as ADDITIONAL_SAN and want to make sure mail.maildomain.com will always point to the mailcow UI.
# If the server name does not match a known site, Nginx decides by best-guess and may redirect users to the wrong web root.
# You can understand this as server_name directive in Nginx.
# Comma separated list without spaces! Example: ADDITIONAL_SERVER_NAMES=a.b.c,d.e.f
ADDITIONAL_SERVER_NAMES=autoconfig.mail.andreasknuth.de autodiscover.mail.andreasknuth.de
# Skip running ACME (acme-mailcow, Let's Encrypt certs) - y/n
SKIP_LETS_ENCRYPT=y
# Create seperate certificates for all domains - y/n
# this will allow adding more than 100 domains, but some email clients will not be able to connect with alternative hostnames
# see https://doc.dovecot.org/admin_manual/ssl/sni_support
ENABLE_SSL_SNI=n
# Skip IPv4 check in ACME container - y/n
SKIP_IP_CHECK=n
# Skip HTTP verification in ACME container - y/n
SKIP_HTTP_VERIFICATION=n
# Skip Unbound (DNS Resolver) Healthchecks (NOT Recommended!) - y/n
SKIP_UNBOUND_HEALTHCHECK=n
# Skip ClamAV (clamd-mailcow) anti-virus (Rspamd will auto-detect a missing ClamAV container) - y/n
SKIP_CLAMD=n
# Skip Olefy (olefy-mailcow) anti-virus for Office documents (Rspamd will auto-detect a missing Olefy container) - y/n
SKIP_OLEFY=n
# Skip SOGo: Will disable SOGo integration and therefore webmail, DAV protocols and ActiveSync support (experimental, unsupported, not fully implemented) - y/n
SKIP_SOGO=n
# Skip FTS (Fulltext Search) for Dovecot on low-memory, low-threaded systems or if you simply want to disable it.
# Dovecot inside mailcow use Flatcurve as FTS Backend.
SKIP_FTS=n
# Dovecot Indexing (FTS) Process maximum heap size in MB, there is no recommendation, please see Dovecot docs.
# Flatcurve (Xapian backend) is used as the FTS Indexer. It is supposed to be efficient in CPU and RAM consumption.
# However: Please always monitor your Resource consumption!
FTS_HEAP=128
# Controls how many processes the Dovecot indexing process can spawn at max.
# Too many indexing processes can use a lot of CPU and Disk I/O.
# Please visit: https://doc.dovecot.org/configuration_manual/service_configuration/#indexer-worker for more informations
FTS_PROCS=1
# Allow admins to log into SOGo as email user (without any password)
ALLOW_ADMIN_EMAIL_LOGIN=n
# Enable watchdog (watchdog-mailcow) to restart unhealthy containers
USE_WATCHDOG=y
# Send watchdog notifications by mail (sent from watchdog@MAILCOW_HOSTNAME)
# CAUTION:
# 1. You should use external recipients
# 2. Mails are sent unsigned (no DKIM)
# 3. If you use DMARC, create a separate DMARC policy ("v=DMARC1; p=none;" in _dmarc.MAILCOW_HOSTNAME)
# Multiple rcpts allowed, NO quotation marks, NO spaces
#WATCHDOG_NOTIFY_EMAIL=a@example.com,b@example.com,c@example.com
#WATCHDOG_NOTIFY_EMAIL=
# Send notifications to a webhook URL that receives a POST request with the content type "application/json".
# You can use this to send notifications to services like Discord, Slack and others.
#WATCHDOG_NOTIFY_WEBHOOK=https://discord.com/api/webhooks/XXXXXXXXXXXXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# JSON body included in the webhook POST request. Needs to be in single quotes.
# Following variables are available: SUBJECT, BODY
#WATCHDOG_NOTIFY_WEBHOOK_BODY='{"username": "mailcow Watchdog", "content": "****\n"}'
# Notify about banned IP (includes whois lookup)
WATCHDOG_NOTIFY_BAN=n
# Send a notification when the watchdog is started.
WATCHDOG_NOTIFY_START=y
# Subject for watchdog mails. Defaults to "Watchdog ALERT" followed by the error message.
#WATCHDOG_SUBJECT=
# Checks if mailcow is an open relay. Requires a SAL. More checks will follow.
# https://www.servercow.de/mailcow?lang=en
# https://www.servercow.de/mailcow?lang=de
# No data is collected. Opt-in and anonymous.
# Will only work with unmodified mailcow setups.
WATCHDOG_EXTERNAL_CHECKS=n
# Enable watchdog verbose logging
WATCHDOG_VERBOSE=n
# Max log lines per service to keep in Redis logs
LOG_LINES=9999
# Internal IPv4 /24 subnet, format n.n.n (expands to n.n.n.0/24)
# Use private IPv4 addresses only, see https://en.wikipedia.org/wiki/Private_network#Private_IPv4_addresses
IPV4_NETWORK=172.22.1
# Internal IPv6 subnet in fc00::/7
# Use private IPv6 addresses only, see https://en.wikipedia.org/wiki/Private_network#Private_IPv6_addresses
IPV6_NETWORK=fd4d:6169:6c63:6f77::/64
# Use this IPv4 for outgoing connections (SNAT)
#SNAT_TO_SOURCE=
# Use this IPv6 for outgoing connections (SNAT)
#SNAT6_TO_SOURCE=
# Create or override an API key for the web UI
# You _must_ define API_ALLOW_FROM, which is a comma separated list of IPs
# An API key defined as API_KEY has read-write access
# An API key defined as API_KEY_READ_ONLY has read-only access
# Allowed chars for API_KEY and API_KEY_READ_ONLY: a-z, A-Z, 0-9, -
# You can define API_KEY and/or API_KEY_READ_ONLY
#API_KEY=
#API_KEY_READ_ONLY=
#API_ALLOW_FROM=172.22.1.1,127.0.0.1
# mail_home is ~/Maildir
MAILDIR_SUB=Maildir
# SOGo session timeout in minutes
SOGO_EXPIRE_SESSION=480
# DOVECOT_MASTER_USER and DOVECOT_MASTER_PASS must both be provided. No special chars.
# Empty by default to auto-generate master user and password on start.
# User expands to DOVECOT_MASTER_USER@mailcow.local
# LEAVE EMPTY IF UNSURE
DOVECOT_MASTER_USER=
# LEAVE EMPTY IF UNSURE
DOVECOT_MASTER_PASS=
# Let's Encrypt registration contact information
# Optional: Leave empty for none
# This value is only used on first order!
# Setting it at a later point will require the following steps:
# https://docs.mailcow.email/troubleshooting/debug-reset_tls/
ACME_CONTACT=
# WebAuthn device manufacturer verification
# After setting WEBAUTHN_ONLY_TRUSTED_VENDORS=y only devices from trusted manufacturers are allowed
# root certificates can be placed for validation under mailcow-dockerized/data/web/inc/lib/WebAuthn/rootCertificates
WEBAUTHN_ONLY_TRUSTED_VENDORS=n
# Spamhaus Data Query Service Key
# Optional: Leave empty for none
# Enter your key here if you are using a blocked ASN (OVH, AWS, Cloudflare e.g) for the unregistered Spamhaus Blocklist.
# If empty, it will completely disable Spamhaus blocklists if it detects that you are running on a server using a blocked AS.
# Otherwise it will work normally.
SPAMHAUS_DQS_KEY=
# Prevent netfilter from setting an iptables/nftables rule to isolate the mailcow docker network - y/n
# CAUTION: Disabling this may expose container ports to other neighbors on the same subnet, even if the ports are bound to localhost
DISABLE_NETFILTER_ISOLATION_RULE=n

View File

@@ -0,0 +1,387 @@
import os
import boto3
import json
import time
from email.parser import BytesParser
from email.policy import SMTP as SMTPPolicy
s3 = boto3.client('s3')
sqs = boto3.client('sqs', region_name='us-east-2')
# AWS Region
AWS_REGION = 'us-east-2'
# Metadata Keys
PROCESSED_KEY = 'processed'
PROCESSED_VALUE = 'true'
def domain_to_bucket(domain: str) -> str:
"""Konvertiert Domain zu S3 Bucket Namen"""
domain = domain.lower()
return domain.replace('.', '-') + '-emails'
def domain_to_queue_name(domain: str) -> str:
"""Konvertiert Domain zu SQS Queue Namen"""
domain = domain.lower()
return domain.replace('.', '-') + '-queue'
def get_queue_url_for_domain(domain: str) -> str:
"""Ermittelt SQS Queue URL für Domain"""
queue_name = domain_to_queue_name(domain)
try:
response = sqs.get_queue_url(QueueName=queue_name)
queue_url = response['QueueUrl']
print(f"✓ Found queue: {queue_name}")
return queue_url
except sqs.exceptions.QueueDoesNotExist:
raise Exception(
f"Queue does not exist: {queue_name} "
f"(for domain: {domain.lower()})"
)
except Exception as e:
raise Exception(f"Error getting queue URL for {domain.lower()}: {e}")
def is_already_processed(bucket: str, key: str) -> bool:
"""Prüft ob E-Mail bereits verarbeitet wurde"""
try:
head = s3.head_object(Bucket=bucket, Key=key)
metadata = head.get('Metadata', {}) or {}
if metadata.get(PROCESSED_KEY) == PROCESSED_VALUE:
processed_at = metadata.get('processed_at', 'unknown')
print(f"✓ Already processed at {processed_at}")
return True
return False
except s3.exceptions.NoSuchKey:
print(f"⚠ Object {key} not found in {bucket}")
return True
except Exception as e:
print(f"⚠ Error checking processed status: {e}")
return False
def set_processing_lock(bucket: str, key: str) -> bool:
"""
Setzt Processing Lock um Duplicate Processing zu verhindern
Returns: True wenn Lock erfolgreich gesetzt, False wenn bereits locked
"""
try:
head = s3.head_object(Bucket=bucket, Key=key)
metadata = head.get('Metadata', {}) or {}
# Prüfe auf existierenden Lock
processing_started = metadata.get('processing_started')
if processing_started:
lock_age = time.time() - float(processing_started)
if lock_age < 300: # 5 Minuten Lock
print(f"⚠ Processing lock active (age: {lock_age:.0f}s)")
return False
else:
print(f"⚠ Stale lock detected ({lock_age:.0f}s old), overriding")
# Setze neuen Lock
new_meta = metadata.copy()
new_meta['processing_started'] = str(int(time.time()))
s3.copy_object(
Bucket=bucket,
Key=key,
CopySource={'Bucket': bucket, 'Key': key},
Metadata=new_meta,
MetadataDirective='REPLACE'
)
print(f"✓ Processing lock set")
return True
except Exception as e:
print(f"⚠ Error setting processing lock: {e}")
return True
def mark_as_queued(bucket: str, key: str, queue_name: str):
"""Markiert E-Mail als in Queue eingereiht"""
try:
head = s3.head_object(Bucket=bucket, Key=key)
metadata = head.get('Metadata', {}) or {}
metadata['queued_at'] = str(int(time.time()))
metadata['queued_to'] = queue_name
metadata['status'] = 'queued'
metadata.pop('processing_started', None)
s3.copy_object(
Bucket=bucket,
Key=key,
CopySource={'Bucket': bucket, 'Key': key},
Metadata=metadata,
MetadataDirective='REPLACE'
)
print(f"✓ Marked as queued to {queue_name}")
except Exception as e:
print(f"⚠ Failed to mark as queued: {e}")
def send_to_queue(queue_url: str, bucket: str, key: str,
from_addr: str, recipients: list, domain: str,
subject: str, message_id: str):
"""
Sendet E-Mail-Job in domain-spezifische SQS Queue
EINE Message mit ALLEN Recipients für diese Domain
"""
queue_name = queue_url.split('/')[-1]
message = {
'bucket': bucket,
'key': key,
'from': from_addr,
'recipients': recipients, # Liste aller Empfänger
'domain': domain,
'subject': subject,
'message_id': message_id,
'timestamp': int(time.time())
}
try:
response = sqs.send_message(
QueueUrl=queue_url,
MessageBody=json.dumps(message, ensure_ascii=False),
MessageAttributes={
'domain': {
'StringValue': domain,
'DataType': 'String'
},
'bucket': {
'StringValue': bucket,
'DataType': 'String'
},
'recipient_count': {
'StringValue': str(len(recipients)),
'DataType': 'Number'
},
'message_id': {
'StringValue': message_id,
'DataType': 'String'
}
}
)
sqs_message_id = response['MessageId']
print(f"✓ Queued to {queue_name}: SQS MessageId={sqs_message_id}")
print(f" Recipients: {len(recipients)} - {', '.join(recipients)}")
# Als queued markieren
mark_as_queued(bucket, key, queue_name)
return sqs_message_id
except Exception as e:
print(f"✗ Failed to queue message: {e}")
raise
def lambda_handler(event, context):
"""
Lambda Handler für SES Events
Eine Domain pro Event = eine Queue Message mit allen Recipients
"""
print(f"{'='*70}")
print(f"Lambda invoked: {context.aws_request_id}")
print(f"Region: {AWS_REGION}")
print(f"{'='*70}")
# SES Event parsen
try:
record = event['Records'][0]
ses = record['ses']
except (KeyError, IndexError) as e:
print(f"✗ Invalid event structure: {e}")
return {
'statusCode': 400,
'body': json.dumps({'error': 'Invalid SES event'})
}
mail = ses['mail']
receipt = ses['receipt']
message_id = mail['messageId']
source = mail['source']
timestamp = mail.get('timestamp', '')
recipients = receipt.get('recipients', [])
# FRÜHES LOGGING: S3 Key und Recipients
print(f"\n🔑 S3 Key: {message_id}")
print(f"👥 Recipients ({len(recipients)}): {', '.join(recipients)}")
if not recipients:
print(f"✗ No recipients found in event")
return {
'statusCode': 400,
'body': json.dumps({
'error': 'No recipients in event',
'message_id': message_id
})
}
# Domain extrahieren (alle Recipients haben gleiche Domain!)
domain = recipients[0].split('@')[1].lower()
bucket = domain_to_bucket(domain)
print(f"\n📧 Email Event:")
print(f" MessageId: {message_id}")
print(f" From: {source}")
print(f" Domain: {domain}")
print(f" Bucket: {bucket}")
print(f" Timestamp: {timestamp}")
print(f" Recipients: {len(recipients)}")
# Queue für Domain ermitteln
try:
queue_url = get_queue_url_for_domain(domain)
queue_name = queue_url.split('/')[-1]
print(f" Queue: {queue_name}")
except Exception as e:
print(f"\n✗ ERROR: {e}")
return {
'statusCode': 500,
'body': json.dumps({
'error': 'queue_not_configured',
'domain': domain,
'recipients': recipients,
'message': str(e)
})
}
# S3 Object finden
try:
print(f"\n📦 Searching S3...")
response = s3.list_objects_v2(
Bucket=bucket,
Prefix=message_id,
MaxKeys=1
)
if 'Contents' not in response or not response['Contents']:
raise Exception(f"No S3 object found for message {message_id}")
key = response['Contents'][0]['Key']
size = response['Contents'][0]['Size']
print(f" Found: s3://{bucket}/{key}")
print(f" Size: {size:,} bytes ({size/1024:.1f} KB)")
except Exception as e:
print(f"\n✗ S3 ERROR: {e}")
return {
'statusCode': 404,
'body': json.dumps({
'error': 's3_object_not_found',
'message_id': message_id,
'bucket': bucket,
'details': str(e)
})
}
# Duplicate Check
print(f"\n🔍 Checking for duplicates...")
if is_already_processed(bucket, key):
print(f" Already processed, skipping")
return {
'statusCode': 200,
'body': json.dumps({
'status': 'already_processed',
'message_id': message_id,
'recipients': recipients
})
}
# Processing Lock setzen
print(f"\n🔒 Setting processing lock...")
if not set_processing_lock(bucket, key):
print(f" Already being processed by another instance")
return {
'statusCode': 200,
'body': json.dumps({
'status': 'already_processing',
'message_id': message_id,
'recipients': recipients
})
}
# E-Mail laden um Subject zu extrahieren
subject = '(unknown)'
try:
print(f"\n📖 Reading email for metadata...")
obj = s3.get_object(Bucket=bucket, Key=key)
raw_bytes = obj['Body'].read()
# Nur Headers parsen (schneller)
parsed = BytesParser(policy=SMTPPolicy).parsebytes(raw_bytes)
subject = parsed.get('subject', '(no subject)')
print(f" Subject: {subject}")
except Exception as e:
print(f" ⚠ Could not parse email (continuing): {e}")
# In Queue einreihen (EINE Message mit ALLEN Recipients)
try:
print(f"\n📤 Queuing to {queue_name}...")
sqs_message_id = send_to_queue(
queue_url=queue_url,
bucket=bucket,
key=key,
from_addr=source,
recipients=recipients, # ALLE Recipients
domain=domain,
subject=subject,
message_id=message_id
)
print(f"\n{'='*70}")
print(f"✅ SUCCESS - Email queued for delivery")
print(f"{'='*70}\n")
return {
'statusCode': 200,
'body': json.dumps({
'status': 'queued',
'message_id': message_id,
'sqs_message_id': sqs_message_id,
'queue': queue_name,
'domain': domain,
'recipients': recipients,
'recipient_count': len(recipients),
'subject': subject
})
}
except Exception as e:
print(f"\n{'='*70}")
print(f"✗ FAILED TO QUEUE")
print(f"{'='*70}")
print(f"Error: {e}")
return {
'statusCode': 500,
'body': json.dumps({
'error': 'failed_to_queue',
'message': str(e),
'message_id': message_id,
'recipients': recipients
})
}

View File

@@ -0,0 +1,44 @@
'use strict';
if (process.argv.length < 3) {
process.stdout.write('USAGE: nodejs emailtojson.js filename');
process.stdout.write('Example: nodejs emailtojson.js emailfile.eml');
return;
}
const fs = require('fs');
const { MailParser } = require("mailparser");
const mailpath = process.argv[2];
let parser = new MailParser();
let input = fs.createReadStream(mailpath);
let mailobj = {
attachments: [],
text: {}
};
parser.on('headers', headers => {
let headerObj = {};
for (let [k, v] of headers) {
// We dont escape the key '__proto__'
// which can cause problems on older engines
headerObj[k] = v;
}
mailobj.headers = headerObj;
});
parser.on('data', data => {
if (data.type === 'attachment') {
mailobj.attachments.push(data);
data.content.on('readable', () => data.content.read());
data.content.on('end', () => data.release());
} else {
mailobj.text = data;
}
});
parser.on('end', () => {
process.stdout.write(JSON.stringify(mailobj, (k, v) => (k === 'content' || k === 'release' ? undefined : v), 3));
});
input.pipe(parser);

View File

@@ -0,0 +1,30 @@
// simple-emailtojson.mjs
import fs from 'fs/promises';
import { simpleParser } from 'mailparser';
if (process.argv.length < 3) {
console.error('USAGE: node simple-emailtojson.mjs <emailfile.eml>');
process.exit(1);
}
const mailpath = process.argv[2];
(async () => {
const emlBuffer = await fs.readFile(mailpath);
const mail = await simpleParser(emlBuffer);
// Optional: entferne Buffers, die du nicht serialisieren willst
if (mail.attachments) {
mail.attachments = mail.attachments.map(att => ({
filename: att.filename,
contentType: att.contentType,
size: att.size,
// evtl. att.content.toString('base64') oder weglassen
}));
}
console.log(JSON.stringify(mail, null, 2));
})().catch(err => {
console.error('Fehler beim Parsen:', err);
process.exit(1);
});

View File

@@ -0,0 +1,68 @@
Return-Path: <andreas.knuth@gmail.com>
Received: from mail-lf1-f54.google.com (mail-lf1-f54.google.com [209.85.167.54])
by inbound-smtp.us-east-2.amazonaws.com with SMTP id tl8bodt75rl99agvurj9pt06aaphgs5pj3l7ci01
for test@bizmatch.net;
Mon, 07 Jul 2025 22:29:30 +0000 (UTC)
X-SES-Spam-Verdict: PASS
X-SES-Virus-Verdict: PASS
Received-SPF: pass (spfCheck: domain of _spf.google.com designates 209.85.167.54 as permitted sender) client-ip=209.85.167.54; envelope-from=andreas.knuth@gmail.com; helo=mail-lf1-f54.google.com;
Authentication-Results: amazonses.com;
spf=pass (spfCheck: domain of _spf.google.com designates 209.85.167.54 as permitted sender) client-ip=209.85.167.54; envelope-from=andreas.knuth@gmail.com; helo=mail-lf1-f54.google.com;
dkim=pass header.i=@gmail.com;
dmarc=pass header.from=gmail.com;
X-SES-RECEIPT: AEFBQUFBQUFBQUFHZ2VxMTdrTDl5UCtYZjRQUHNhL3YwRWo4YXNNbEVYdGdqUTducmt1L25UY0pMNFNqMitXQWZCbnVsYW1seVdseFQzT1lZT2VUVEtCUWl0b2VDVk94SU5xN3p1K1R3d2lOT0hkb2ZIclEvS0JqNVdtRzAvNnJtejlsOE42dTU3ZTV5K2NIQ0lvOEJtQ0hBSkhrZ2JURHJjWXpVYU5EOEZnMnc0SU8xeS9TUVR6OXZxdmt4WVdCMzNuaUJ2TE9xRzN1WHdZM3VFdUcwYzBrZm9OV3BFMEwrZURnb25PY2h2dVExRXV1Q0ZCSzhIeGRsSTZFdXZwUUVzQ2JQUFVzUjFvZnI0U2g4aXBFZDQxQVNFanJLYXdNS2crKzZPanJySHJWckdXQ21hZ2NOQWc9PQ==
X-SES-DKIM-SIGNATURE: a=rsa-sha256; q=dns/txt; b=A7ZG4osvHSz8Grirn5FNbtnZtZoxA4SwzM4NX2SD3xlmdGZ9gEs7o5QAaexpqFo+tVHGze6kCXShR/m5e+Ccoelv+pYGuQsM0UQukPH567mOTd6DBsUnwgGoWyzkR4LyBMSGKX50m3plpMr7OsfydgTtSgmNqx6TaW2uTqAmHG4=; c=relaxed/simple; s=ndjes4mrtuzus6qxu3frw3ubo3gpjndv; d=amazonses.com; t=1751927370; v=1; bh=kl0ZVgKAgL2tPEaQmtmEdFkMF0Wkh08RlXtja41/naQ=; h=From:To:Cc:Bcc:Subject:Date:Message-ID:MIME-Version:Content-Type:X-SES-RECEIPT;
Received: by mail-lf1-f54.google.com with SMTP id 2adb3069b0e04-553aba2f99eso547669e87.3
for <test@bizmatch.net>; Mon, 07 Jul 2025 15:29:29 -0700 (PDT)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=gmail.com; s=20230601; t=1751927368; x=1752532168; darn=bizmatch.net;
h=to:subject:message-id:date:from:mime-version:from:to:cc:subject
:date:message-id:reply-to;
bh=kl0ZVgKAgL2tPEaQmtmEdFkMF0Wkh08RlXtja41/naQ=;
b=Dv7XQW93T4nV5kY0HB5qVq0H1iB0cYfdQMzSGyu+chsPKK5N+8INipWr1bulAYA4OM
UKP7EiY4j3zzrxVLFMjboztDfI4PG2oAYSdxIah+jTdgpliVhIeGqvM87SH4pfSVPnOB
JygDwwhB25s9wfwM7XDQ+uaAg/Fdwc6kgXf1d2k28gdnV9cuhToWMBAdCZG+0pic969P
HEJlLY+KJBVIvzl8JcVZ6ReT8FeQWGwKfzdrpG8PXyYO8MH4FtAmfji4Av4PO/Q2Ky/u
3Razz1QTf8R7dHCndAdXCa5INrMaCQOvXRWMMc22sIfMTtM0RKieL7jfp+T4kzcWd8bp
F3BA==
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=1e100.net; s=20230601; t=1751927368; x=1752532168;
h=to:subject:message-id:date:from:mime-version:x-gm-message-state
:from:to:cc:subject:date:message-id:reply-to;
bh=kl0ZVgKAgL2tPEaQmtmEdFkMF0Wkh08RlXtja41/naQ=;
b=wDGrMTBQxC0PHTqXvyy2DVWa4au/7y1hd7NkSgRoVX/vVKp1ArewmkY1xWPEG4qp6S
X6B9q/qimOqNHs/me0gke2XOeVfgT0Pw+NMSJMf7mCGLZ2+y6sxRttgrh4u2FTxeY0K1
RKYdwG7rUcqBYoyU/1h6nJYrotuCs7VYBmWbglChhTJoysmFdnR7eAsD2GnxVM1CDZbI
XdVsK/+vOhUHw8uyVB8sILrEtpM4+ETz0BnIveqyldnfXTKj1v1gnXUNi2XgaK+K126b
DsXGAP4SwLXUeCHnwGvEfpqTvdVhhOalwR0uCNFWMSOIOuxJbm6hPdU82oz1G6yEUip1
pSyw==
X-Gm-Message-State: AOJu0YwHBQTUiVzyF4Z+W9Nn+X1DjRnb+ExbYEHAl2nHyJxuSHCcO+92
BQdv1ZRanXsQ1Lb4d3pzXr5AoeyNsoAyT3H9Xnu0bZO+zSNpvJ44dQY0WwJc1RKk3WFm8C2xxjl
FNPLCFUIKOYoBKSue/IhK5RuJEorabq6yCy11zJUvVQ==
X-Gm-Gg: ASbGnctmha0Sl+6s3+7aqdJp4XfRfVYWw1ijYcCHalIyyYoLNA/scbpX0Eqz6/xkLKz
Zk8kZ1s2cvvs0Li8JDtKWndBEfOlH2vObiTf1nOjfUXArElHNcXTLauyTSsQhhnX98yufY/FlMM
gBVMpCLdinwI7W73wct+qp6JNzoPTJjMqxxr460ujtFDG0M5f6/edKdGc=
X-Google-Smtp-Source: AGHT+IGKQO5agz3saT3mvRcQjADlp5mR3Ss7bUoX6CzSwr9FNqw5AekIbPUiMQx0QQJz5SZAtSywG7pqy3jzwJU7gFI=
X-Received: by 2002:a05:6512:e90:b0:553:29cc:c47a with SMTP id
2adb3069b0e04-556e76ea8b6mr1397840e87.6.1751927367907; Mon, 07 Jul 2025
15:29:27 -0700 (PDT)
MIME-Version: 1.0
From: Andreas Knuth <andreas.knuth@gmail.com>
Date: Mon, 7 Jul 2025 17:29:22 -0500
X-Gm-Features: Ac12FXylATeuoXeS0LgUwAAC4rygTYy_KTtNVnLhQ8Pv-KiTkX5e5F1AlsvpAY8
Message-ID: <CADfCGtb_G+9W11EgfeQhp+V5vb1_gkeq9ZsfqgvsxC9hMNEfJQ@mail.gmail.com>
Subject: dsfsd
To: test@bizmatch.net
Content-Type: multipart/alternative; boundary="0000000000006fc0ff06395e6090"
--0000000000006fc0ff06395e6090
Content-Type: text/plain; charset="UTF-8"
sdfsdf
--0000000000006fc0ff06395e6090
Content-Type: text/html; charset="UTF-8"
<div dir="ltr">sdfsdf</div>
--0000000000006fc0ff06395e6090--

View File

@@ -0,0 +1,68 @@
Return-Path: <andreas.knuth@gmail.com>
Received: from mail-lj1-f181.google.com (mail-lj1-f181.google.com [209.85.208.181])
by inbound-smtp.us-east-2.amazonaws.com with SMTP id uiead4igi9ee1cijaffb2dsd0bhkg8ksjr35kp01;
Mon, 07 Jul 2025 15:15:44 +0000 (UTC)
X-SES-Spam-Verdict: PASS
X-SES-Virus-Verdict: PASS
Received-SPF: pass (spfCheck: domain of _spf.google.com designates 209.85.208.181 as permitted sender) client-ip=209.85.208.181; envelope-from=andreas.knuth@gmail.com; helo=mail-lj1-f181.google.com;
Authentication-Results: amazonses.com;
spf=pass (spfCheck: domain of _spf.google.com designates 209.85.208.181 as permitted sender) client-ip=209.85.208.181; envelope-from=andreas.knuth@gmail.com; helo=mail-lj1-f181.google.com;
dkim=pass header.i=@gmail.com;
dmarc=pass header.from=gmail.com;
X-SES-RECEIPT: AEFBQUFBQUFBQUFFVnVnY0k5TFVXcDdiK0FZbmNLbnRMc3d5c2hrWitkN1l4bEhZdnRvQ0NJUGVPcm5qV1hIblA0WDh0ZERsT2xFS01HUFZFOEtPZ29tdW8yQWtkYzlMckxESzR0d1VjMVF0dWw0bXlBVzkrM3BrVCtJZ2xTdm5nU3VMcEgzcVNqRXp0aEZxdks0NzBpN2s3d25jcnJEUi9PL2ZzbGQ3Z1JrRFZTNGp6bDRnS2JZb3EwQXB2bThlMTE2SFBWZitNSlVUSzloRkZyREJRQVdoQmVWMGtXSWZFbndrT0g5cUtnajhQUTA4ZTlQaGdKWXNJbnFRSXVrNkMzenB4Mm1xWXVvVFRHUDdlU2N0cm5XaGtzV1ZaTDVnZG1aVVIyWjB2MkZ6M3dNNlNDWmNDeXc9PQ==
X-SES-DKIM-SIGNATURE: a=rsa-sha256; q=dns/txt; b=DqDTEo6krNCKrTt6VBWj6WkxPHgAqraQunr2h6nI/95ooZ2H4qZ/4Ts5uMU13PJV449VQYWMUL1qX5qbjq0UqKHGjry7RlMmOWxxJY0SRl8Eye6HN0UMAwJibEb0K6piljG9oYAbIigE5e8D63ESnPkuiEeIqztkUg7ngVvDFiE=; c=relaxed/simple; s=ndjes4mrtuzus6qxu3frw3ubo3gpjndv; d=amazonses.com; t=1751901344; v=1; bh=+4eu3AqIZQTs7Asy6nycJyqaDVkIYEQ7Wt4fZW12bas=; h=From:To:Cc:Bcc:Subject:Date:Message-ID:MIME-Version:Content-Type:X-SES-RECEIPT;
Received: by mail-lj1-f181.google.com with SMTP id 38308e7fff4ca-32b4483cd3cso1203521fa.3;
Mon, 07 Jul 2025 08:15:43 -0700 (PDT)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=gmail.com; s=20230601; t=1751901342; x=1752506142; darn=bizmatch.net;
h=to:subject:message-id:date:from:mime-version:from:to:cc:subject
:date:message-id:reply-to;
bh=+4eu3AqIZQTs7Asy6nycJyqaDVkIYEQ7Wt4fZW12bas=;
b=LgkJUkx/JjaJyQr6qBsVw28Vwcr8g27WAFJXWlIPJ5CEewRfkIT337505lkC3BD85J
OlgjVzXj7MjD3bF64ltxJOKRWoXWzk9F9eMQYHYpfkAk5iAoVHQutXw4u1wYZBQH2iCc
BH2YapjsD4vO0exwYlJBbMP4Tq6N1Wu1XTdTtJTuPpynNnwB0hjnnJdlNj5jisLRtJe/
RSUPTpAQU9Hqxgt7R/1yNUjG5I807hzceJuSs0LW7BQPJwZIK6ZI+EvX88FKo4wjKrDf
pWoVTu3iwszFCDFBHZ2LGF4cXggnyiRS/5bZot9WhU59zOmgYJHfhjfZokfihsPqdWdl
EHzQ==
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=1e100.net; s=20230601; t=1751901342; x=1752506142;
h=to:subject:message-id:date:from:mime-version:x-gm-message-state
:from:to:cc:subject:date:message-id:reply-to;
bh=+4eu3AqIZQTs7Asy6nycJyqaDVkIYEQ7Wt4fZW12bas=;
b=m2xoHX0bVq6CGVKxnGFuWZy9tEjQ/ONvZhhl7uOVZQ7vTaS67YXsLydSk2F8c0c8vY
P/s6wy4sDmTOpWISBM04zRWqRvsaHQonyualsQd7U02Zsp/tI0mqdIJ/ni3MsC8G03GL
wWWN2e6V6QBG+rAhau2BXGBao+baQSTjSvgefvYDzuWg5ugfdexXhJ7efRcWHQqPb5KU
mEjmkLDt5DTBhR091i01YxrrC7Ny3RlEPMqbyq+nwvmDFm5ACNdq9aMaEujVRB1+a3ei
BhLrtMRybBlGJPgpCV7IOdA9WRyJyNMR7qbeREz3joz1Errsab/Oa9VYhWqFl5dPLEt8
hZzw==
X-Forwarded-Encrypted: i=1; AJvYcCXI0RBO9KQUyKzy0MqDnS9enil+Kp0jSrcSTlRViDgsyZLMSUi6TJ5BX9yrcJmHLdEB069pDCoD@bizmatch.net
X-Gm-Message-State: AOJu0YysUzHz3l+r4XYKaoIqIKrS6dUxH8itmcz0lOP+MF+FcieWd0V4
D3/aLVsWV48ZOfaA0uoEw/lJ1AQC81NVUTAAu29+H/WmFIAD5qOqhAQ2JkiffPJe26VJ4eDTz9e
LZoi5HSO+CmLcSML1iUvE0Rd03QjVG85uwNJ8cPQAhpZK
X-Gm-Gg: ASbGncurPvR6Brs7OGpPmZ3/vpAbCMXuujgejGh/xAmeSHObnMZSBQnW6wbOzHhmjUn
mv90GEruVA4Ru81mpcqOCAjUD7wut8PtZwtavp4RGbRpTEMtetsFEuHxSULHc7fvCdqMHxbDhtK
U+JDIDpBXBf9nLjX/9Ia1MHszsdoxH6r2MmQpZpiWk++VE
X-Google-Smtp-Source: AGHT+IE+PQnE0dCTFWNExgYOZKVZ7/J425p7hNVhw1g9pL6JkbRpiUfxAw0iO0Y0Bf90ZvQ1b9I9rJjrkK0b/NkUYs8=
X-Received: by 2002:a05:6512:6cd:b0:553:2bf7:77bf with SMTP id
2adb3069b0e04-556e7bc6d1dmr1353763e87.8.1751901341859; Mon, 07 Jul 2025
08:15:41 -0700 (PDT)
MIME-Version: 1.0
From: Andreas Knuth <andreas.knuth@gmail.com>
Date: Mon, 7 Jul 2025 10:15:33 -0500
X-Gm-Features: Ac12FXwNFaB1vrcboxVGo5_EQvpg1Kc37eKOdlZdqKdeMb2z20e0CP6o69Pq8k4
Message-ID: <CADfCGtYpG78F7DKCWj8DQXsqTteOyg3=Jqw9rgwE2AXZ4YEJ3Q@mail.gmail.com>
Subject: info,support - knuth
To: info@bizmatch.net, support@bizmatch.net, knuth@andreasknuth.de
Content-Type: multipart/alternative; boundary="00000000000029c44406395851f8"
--00000000000029c44406395851f8
Content-Type: text/plain; charset="UTF-8"
info,support - knuth
--00000000000029c44406395851f8
Content-Type: text/html; charset="UTF-8"
<div dir="ltr">info,support - knuth</div>
--00000000000029c44406395851f8--

182
ses-lambda-nodejs/eml/3.eml Normal file
View File

@@ -0,0 +1,182 @@
Return-Path: <pradeepkumar200w@outlook.com>
Received: from SEYPR02CU001.outbound.protection.outlook.com (mail-koreacentralazolkn19013080.outbound.protection.outlook.com [52.103.74.80])
by inbound-smtp.us-east-2.amazonaws.com with SMTP id qchs4km8p1vevgfk9l2j0hh04706mhi4jd2tjpo1
for support@bizmatch.net;
Tue, 24 Jun 2025 16:33:44 +0000 (UTC)
X-SES-Spam-Verdict: PASS
X-SES-Virus-Verdict: PASS
Received-SPF: pass (spfCheck: domain of outlook.com designates 52.103.74.80 as permitted sender) client-ip=52.103.74.80; envelope-from=pradeepkumar200w@outlook.com; helo=SEYPR02CU001.outbound.protection.outlook.com;
Authentication-Results: amazonses.com;
spf=pass (spfCheck: domain of outlook.com designates 52.103.74.80 as permitted sender) client-ip=52.103.74.80; envelope-from=pradeepkumar200w@outlook.com; helo=SEYPR02CU001.outbound.protection.outlook.com;
dkim=pass header.i=@outlook.com;
dmarc=pass header.from=outlook.com;
X-SES-RECEIPT: AEFBQUFBQUFBQUFFMlhQMUNyaFlTZkpkTFZLRVhscVZwVjlXOWZ2L3hWOHU0UXJDbE1YRWlkbWZPWXR2b2dxWmpjL1VPUTVEc05qVHpxbFpFZmlDcU1iNC9mU0dwNVl0dDFuSVlFTGk2UmdTUUt3bGNvdmI1QUFoTndzd1pSdVJ1ZjAzbithRm9mcDM3eWRadUJidjdGaVVmeEZ2RVZiajRhUlk1MUhua3MvL2plKytCUjdUY0hEMEd1QjFJd2hDMzRZcmRRY1pEZHVtMzFaNUIzVUM0MGVUSk80dmJxZWhSbUdaYWI5bnRIYktLMndSUzdaVjh4Z3U0Z1ZybldXWGxYeEx2YnBXd21NVGNFTGI3REFEbTFyTzA0YW9DS215SXJJL0Z4Y0VVVGw5MURCNEs0QUJDTWc9PQ==
X-SES-DKIM-SIGNATURE: a=rsa-sha256; q=dns/txt; b=3vPX1a0N+3QUAXXTHFMuvn/FNmc7EPbUvsTd/22aVOpM1vTUq5iHJsB/xlkw3ZArE5CZv4BMEX8vo02wDt9BUdtoLX0+7Yg6KFHTod5sZtota+retIgCAJsL4ZsbVsrsuJe//T6crx7y6e8GR0yCOnJI7W6skPgBubEqoAYxUL4=; c=relaxed/simple; s=ndjes4mrtuzus6qxu3frw3ubo3gpjndv; d=amazonses.com; t=1750782825; v=1; bh=g+9PG3WYMx3Sc8zRsKmZM6AKdWg1fMBV5IpRVhFdquA=; h=From:To:Cc:Bcc:Subject:Date:Message-ID:MIME-Version:Content-Type:X-SES-RECEIPT;
ARC-Seal: i=1; a=rsa-sha256; s=arcselector10001; d=microsoft.com; cv=none;
b=BTWCUxEqn+to6x1qM6mq3CVd3ujLguc64BtxCZlNFSfq0/WGv2BWX+LJ9JJ1edebSdewRyDEEdIZ+jTfdk74K1ArW4y29EkzVkihr5B5/tPTqt+Aa4ledzchK/M8DI937Bs68r1UKY7RdgzhfIIiCC8X0r1a+deRIfKCUMqkvJygpKG1qh3OPbAQksZnaI9yBcZF51ddkPoHyErNqeKBpufRE8O1EF5JUiaWfX/TRkmSAxG9hfUOQXzpBnrq1zCIPlUxME5DNWRaJgiJMjMzcfgdGg5SY/gzqTgA63x0DlK7L1LF1EOTBDjjCMAIEYPnLRuIcAb2f7m1RWeQWEa0xg==
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=microsoft.com;
s=arcselector10001;
h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-AntiSpam-MessageData-ChunkCount:X-MS-Exchange-AntiSpam-MessageData-0:X-MS-Exchange-AntiSpam-MessageData-1;
bh=g+9PG3WYMx3Sc8zRsKmZM6AKdWg1fMBV5IpRVhFdquA=;
b=J6RpWVzs/O2uHbi6iYdAtURortYRRdAQE0FgtVPBgDs1TEwgllTnDHnZ3Ee9gqkCl2N5zrNBxPp1Tr0giZe09QhZy77fDATXPV/VWOBikGX5Y7Udyixdr2H4xnVTs24qVz0t1EueJwK8mPxc0XN2PJqgEFzaJNmOXhXe27b0lJ9OYE0RIVX4Sov3mQeUziTFJnmGfmMm/IdUNhimeZZN1xkx2y3wNQYvvJkOmHE2QVltH92gWWmyokW0ETvArswPlrkGE32lOYhZyfzBHMAEmOheiU78MhDbQVKtavt3MedkkgtQiaf5TjUtEuEVkb53YvAZ3WMk5PY6XrWSyTu5vw==
ARC-Authentication-Results: i=1; mx.microsoft.com 1; spf=none; dmarc=none;
dkim=none; arc=none
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=outlook.com;
s=selector1;
h=From:Date:Subject:Message-ID:Content-Type:MIME-Version:X-MS-Exchange-SenderADCheck;
bh=g+9PG3WYMx3Sc8zRsKmZM6AKdWg1fMBV5IpRVhFdquA=;
b=d1/pYYSuAkgUYS0TLWHcVe3omzNmJjGQCkpBZfAmvA8MVfnE7aFlk748X6sZtRhTbpBT0Dcr/dvSkhJurZIxaqJlhYyQwrBsJENi2FyfeZBsrdNAuRyrAIFr75aOIVE5ij512zr9Gr6CBqx6F1AzNiYDirgqR8vCxg3f+PGtpqim3V0kIXPy3dvqtm0cEdPoC15Pojkot1eW+xZ0pdHXfQcQMyw7KGJxioGrl4U9gYO8auOw8elO0zdOmlefWWXUz42tJZ/OpyfcOaVfgz+QLW/nutZ0ldwjH/Jwzf4RauWEtMVwrWjJK4vcr/ckg0MHGsA9UmLMvVgSasEDB9ZvzA==
Received: from TYZPR03MB6645.apcprd03.prod.outlook.com (2603:1096:400:1ff::12)
by SI6PR03MB8610.apcprd03.prod.outlook.com (2603:1096:4:244::5) with
Microsoft SMTP Server (version=TLS1_2,
cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.8857.21; Tue, 24 Jun
2025 16:32:54 +0000
Received: from TYZPR03MB6645.apcprd03.prod.outlook.com
([fe80::2ede:ba06:80a0:91e8]) by TYZPR03MB6645.apcprd03.prod.outlook.com
([fe80::2ede:ba06:80a0:91e8%4]) with mapi id 15.20.8857.025; Tue, 24 Jun 2025
16:32:54 +0000
From: pradeep kumar <pradeepkumar200w@outlook.com>
Subject: Re: looking for SEO
Thread-Topic: looking for SEO
Thread-Index: AQHbzu/TjqB39zoRVEylLcucXwKa6LQSrIR3
Date: Tue, 24 Jun 2025 16:32:53 +0000
Message-ID:
<TYZPR03MB664557FE3274D64FE1ECD7E68E78A@TYZPR03MB6645.apcprd03.prod.outlook.com>
References:
<TYZPR03MB664536B9DBBAFC2DA2CE219D8E64A@TYZPR03MB6645.apcprd03.prod.outlook.com>
In-Reply-To:
<TYZPR03MB664536B9DBBAFC2DA2CE219D8E64A@TYZPR03MB6645.apcprd03.prod.outlook.com>
Accept-Language: en-US
Content-Language: en-US
X-MS-Has-Attach:
X-MS-TNEF-Correlator:
msip_labels:
x-ms-publictraffictype: Email
x-ms-traffictypediagnostic: TYZPR03MB6645:EE_|SI6PR03MB8610:EE_
x-ms-office365-filtering-correlation-id: 5e83163c-354d-4afc-94d8-08ddb33cc499
x-ms-exchange-slblob-mailprops:
quCBMN2EvO+3eCjVWNsab2y9cp2aqTLRnXY+dn1RYtLfTylnz7C2Qkw6tbRplokq3/dPa42yatAHZ9P7kIhqgd/w/mEuMzD0r1gJWelOszPqMWzVG8xJMrFua0N6edu9IX//6js6WX+P6WGC1rN926Tnqcmu3Fdq3UobezgXrE8O+jcUWw7f+kGwg7pldDihFU17job4OTxnOUZ+rc5K3hLhfREcgIqGmIsSp2OKcpFRwTQRtwehiye7s3ltvPz2/sk+qcZ9zVtdzBDG3exz8q3yENEoFRBD96woBm338Lo3tsYNxBW8iC49fqrzGGFoU2SbRJ6o/WXvBl4BFCdmdMdb4on5q/7pW+lFjwe3qBisFGtQus+Iir4T/wIXtXwqPjVDjBPcPRNh0PkUesuvF+rpmylqmwOg9uyRRRH8lZt42w3dT/ex1c+SIEZgc1MkYrR/Xkb38lObSg48puyTiFPq71Y7QgDevTyinebLAT0zxH5f9G42gSlTVjHW67HZp0vFxJRfbRNMYdFrSRf9XWCLgP76ZKAB9h1rfRNNbAAWbSCBZ1DfTgqWh7L2Hx4pEooyqVUWr2sS/r+ME0p+YBqpVGPozRTWQ488yZB7nLSAfwJh4U9zshPL4ZzKX++H5wzrCpy1HxeBks2JdrG66ZyGqeaCMSNJs29RGGQlH0nJI63ERVYDM2EZN5a9gioUk93HpJvcELXh2ttXhxeeug==
x-microsoft-antispam:
BCL:0;ARA:14566002|19110799006|15030799003|461199028|8060799009|8062599006|15080799009|36102599003|56899033|102099032|51005399003|39105399003|39145399003|40105399003|440099028|41105399003|3412199025|12091999003;
x-microsoft-antispam-message-info:
=?Windows-1252?Q?6J4jUzURB6tFqlRVNH94rwJMw2iJ1lkKRN7XCPMOHnTDiUwW7gR2PKoS?=
=?Windows-1252?Q?t+OSqOP7fg7W8pKb0xgzBKMnj+OU0WmGw+ckciCo7XvoRkjTI/zFdWrB?=
=?Windows-1252?Q?i7svyWUJQX7d8gfW1eJn4TvGwS6XpIx+ByvDoXXU5EkDS0ziAtDVyQk6?=
=?Windows-1252?Q?Om9gS+ygZAIgj6XUEl82JXJB1M94Lr83KTfnCZtJOuOpFedx0BYpkRK+?=
=?Windows-1252?Q?k/g12DCoQPKI9tTiDGY1a4yzmUt5u8UREH8nnXkMnGnztPV5Dm4aOhL9?=
=?Windows-1252?Q?SSH5GuzhISqUpubAAg97sBMRHYn/mvdgall7S3te6NmAo2qnfxGLHmdi?=
=?Windows-1252?Q?mOc9g61j67VJjEr19oqg0eFLyymsLHVZD2EodZ21fPvjyX3G0YfAMAZs?=
=?Windows-1252?Q?oLfZLbnZxnI2ZSrS8VhNwaUIn5X+Xh8Ij0qHG7mogT4kW69/trteRK2I?=
=?Windows-1252?Q?U14mJi4rSXGQSg4Mywt0XTj8NGDDCBqVy8OKhmt3BTW5L0//cxi8T5gk?=
=?Windows-1252?Q?wkkdakJThVu71Ome6CceNGcvVEvKpHXNv6LVpdpjhSliLgyFvJ+Ds1bB?=
=?Windows-1252?Q?PstQ+nrQ+dhNcLU2bQFQ2WlrC1uZpkzmRwZM6d3PYuiE8ULmQwROLwDc?=
=?Windows-1252?Q?retFeJPpbsnM4wF0bUWHUxnPwc2ZuCbJK0RsW5UpiHljWBrZC53Z6I7v?=
=?Windows-1252?Q?f/MU8ksCYqhtM87PLgI93sh9RqYiy1mlTnLZrVeqrTcoch/lJDIn7clE?=
=?Windows-1252?Q?DBsq85olQsdTXWydHoRpMT2yXfEmusPnsT095UjTac4sPrmmOvkWMHtH?=
=?Windows-1252?Q?3iZc7rGkEPwS5xZzc1SEjhhq1bcDr3PumMj4iwMGwi7yDySg43+P9KAo?=
=?Windows-1252?Q?kxl4q/hJzKFzYKjd+Dj9BTCYgSXKFPVs8lBQCqhGc2G9i/KJ2fz23FzV?=
=?Windows-1252?Q?5xRQfvqY8559BtNsw9nNEYXf/UifdiXk9JCHinJBnP4N7SYUD1CnSsIV?=
=?Windows-1252?Q?xNi0UnC708APiOcnZtBRmQlSYA2swpRmEwwifP50p5TOSNlyY9RsPWkd?=
=?Windows-1252?Q?CddIMxRNkRrr1qaxjGSAmq5D4JXg+vMK1Pul3yMak3nKIa/do9KFTJUT?=
=?Windows-1252?Q?m4yZyhP0OFQaoAr+Fqjt6pstOCnI3PUCKJjjeWYonAOOpYs3b3KuVHMu?=
=?Windows-1252?Q?UcOSYfbs9j1RM2em9dQeBTrxwJT2FLgCGSk/UF8sRx0ci0Xp1tFwNf13?=
=?Windows-1252?Q?gA9pSGd13kW131UgDOxVskJe6s2G+hyYP1oWllkcXsW96r97nBzITeXd?=
=?Windows-1252?Q?10M6LX2/ry6EJLzdOFgDyukxIB7CiwOeALMUWLSmXrQLG79DAEWASGQS?=
=?Windows-1252?Q?22fQCAiLKSxr/U75SPJgTtmyuLWruNhHkcztfmC55ufVT74e1dkPv4+6?=
=?Windows-1252?Q?54+uJ8OM3VTw2Ap64gpvrg=3D=3D?=
x-ms-exchange-antispam-messagedata-chunkcount: 1
x-ms-exchange-antispam-messagedata-0:
=?Windows-1252?Q?HvICyUpSWzy/QQbj/+YiOsKmtmSeA6C1F2LfQHbLpAkHXo1Fi3V0s/FD?=
=?Windows-1252?Q?jixCBxRrR4Gh1GTFIXOG44mplT+iFXvsWxQkZSOcQqFRTuWaA8WHK56q?=
=?Windows-1252?Q?aKupnpXB5oUCQ2F6h4Sy0sHj+RrLqO8vP3AUV1QIWM+qHEYp7oNbR6ss?=
=?Windows-1252?Q?ZrBV+O6lgiFw/3TLHsJbcwg/uP7Fkyb/37YpYAQPyO3H8RA39KL2468L?=
=?Windows-1252?Q?mRL+MZuzOKNINqdeGycHVRuZcQJ2mdwtNB0gvkMiDFm/1F4Dgpwi2y0H?=
=?Windows-1252?Q?f5v/mqpRkrQMdJQfQxDBigbT59Mkl+tbiGgALNXgr0uTpzKxf1juxbqD?=
=?Windows-1252?Q?7oKa0/uyDqu+ukCd5r78iYOi0KcDpYQvYuqtgU65+Vzk2vzEaKRXVbRK?=
=?Windows-1252?Q?aSDs+VGHfhVXi5bpT4BtedYpW9Hr17EooUqbIVhz1t/jLJmE6CJIY5/r?=
=?Windows-1252?Q?rzarSaeCppNqyCs23W/ujPAFTWIxkSvs3EfDGfY6rJIg58cVjfQbvDJP?=
=?Windows-1252?Q?I43PgcmkkKZ6vMcFvrXyHPMI2g7QN74GaavTRXmWb6JB3zUeSgLcMjZC?=
=?Windows-1252?Q?SB60vJ2/RvMb0PXPtF8pFGE4kqkkzN1krIpabjEYzEp+lxd+dSzpiww+?=
=?Windows-1252?Q?UW64TNQX/tPHUehtPFg1UqMzJhY+eQHftbkuwS6GNioYXIRU7cvMzTxB?=
=?Windows-1252?Q?VAeLVN2YlJZUyT7pHAZ/5DnlWVv9AD4aqAFAjyuUi9rpHDb1kL6VkTcU?=
=?Windows-1252?Q?XgftUf798WAtZ9WlEuu+dBMY7P7fPpc9ysZ5jqfIq20Sb85pOA576qz5?=
=?Windows-1252?Q?rZXlfw9Oh6a7pfblNtY+aZWZJuVBTcIJUk8T7QenkPpLkyoNVXX9+5Bk?=
=?Windows-1252?Q?Ypsle/vbfGvMRwy9l+kP22eF0B+Q8qfp4hdKzEeflQ0j/8ceKZwwMcbp?=
=?Windows-1252?Q?YKzkn+JD2EiH6gDVWS31fj8D7qAo6/W6tx7X3P8RtQPRLcnyqtBz5bT3?=
=?Windows-1252?Q?EK1KPhICBx8X9fXeXnk3/SCpjYWorfsEN3Q2JM6mEwdI1mLdRSKCjBXs?=
=?Windows-1252?Q?EZn5k6tEQFa8iswi13FVVq3HJzZ0BfugfPyYeTLHcs6gLY3D8dXYGSdm?=
=?Windows-1252?Q?Ba5XjxVPILungkIbzkay46gJwmdPn1cBbODEX2PgupopjBtJM6pOFR6J?=
=?Windows-1252?Q?nItgP849IbQwRBYFlmnHT3X+xsj3Y6kC68GSlsQZ6DcpQNTxRTR22I6w?=
=?Windows-1252?Q?bQI+g3OJy4+9deM+KmDEAenkjnxV9dDqSjnqldF0hjsxJtBu9fcdUmqZ?=
=?Windows-1252?Q?mHkUh86zS+A5kscX6tVEwF8s3xsCyPp5cT0LVId339F/dazlH0nxsaxx?=
=?Windows-1252?Q?hEpnXiVCjHFS6LUePkgeZHc4KFDQUeZEXeKqZaPZRhc/U7lKrtLC1B5a?=
Content-Type: multipart/alternative;
boundary="_000_TYZPR03MB664557FE3274D64FE1ECD7E68E78ATYZPR03MB6645apcp_"
MIME-Version: 1.0
X-OriginatorOrg: outlook.com
X-MS-Exchange-CrossTenant-AuthAs: Internal
X-MS-Exchange-CrossTenant-AuthSource: TYZPR03MB6645.apcprd03.prod.outlook.com
X-MS-Exchange-CrossTenant-RMS-PersistedConsumerOrg: 00000000-0000-0000-0000-000000000000
X-MS-Exchange-CrossTenant-Network-Message-Id: 5e83163c-354d-4afc-94d8-08ddb33cc499
X-MS-Exchange-CrossTenant-originalarrivaltime: 24 Jun 2025 16:32:53.3821
(UTC)
X-MS-Exchange-CrossTenant-fromentityheader: Hosted
X-MS-Exchange-CrossTenant-id: 84df9e7f-e9f6-40af-b435-aaaaaaaaaaaa
X-MS-Exchange-CrossTenant-rms-persistedconsumerorg: 00000000-0000-0000-0000-000000000000
X-MS-Exchange-Transport-CrossTenantHeadersStamped: SI6PR03MB8610
--_000_TYZPR03MB664557FE3274D64FE1ECD7E68E78ATYZPR03MB6645apcp_
Content-Type: text/plain; charset="Windows-1252"
Content-Transfer-Encoding: quoted-printable
Hi,
Want to rank higher on Google and get more traffic? I offer budget-friendly=
SEO services including keyword optimization and content strategy.
Let=92s grow your online presence. May I send you a quote& price? If intere=
sted.
Reply if you'd like to see recent results.
Best,
Pradeep Kumar,
--_000_TYZPR03MB664557FE3274D64FE1ECD7E68E78ATYZPR03MB6645apcp_
Content-Type: text/html; charset="Windows-1252"
Content-Transfer-Encoding: quoted-printable
<html>
<head>
<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3DWindows-1=
252">
<style type=3D"text/css" style=3D"display:none;"> P {margin-top:0;margin-bo=
ttom:0;} </style>
</head>
<body dir=3D"ltr">
<div class=3D"elementToProof" style=3D"font-family: Aptos, Aptos_EmbeddedFo=
nt, Aptos_MSFontService, Calibri, Helvetica, sans-serif; font-size: 12pt; c=
olor: rgb(0, 0, 0);">
Hi,<br>
Want to rank higher on Google and get more traffic? I offer budget-friendly=
SEO services including keyword optimization and content strategy.</div>
<div id=3D"divRplyFwdMsg"></div>
<div style=3D"direction: ltr; text-align: left; text-indent: 0px; line-heig=
ht: 1.8; margin: 0cm 0cm 0.0001pt; font-family: Aptos, Aptos_EmbeddedFont, =
Aptos_MSFontService, Calibri, Helvetica, sans-serif; font-size: 12pt; color=
: rgb(0, 0, 0);">
Let=92s&nbsp;grow your online presence. May I send you a quote&amp; price? =
If interested.<br>
Reply if you'd like to see recent results.<br>
Best,</div>
<div style=3D"direction: ltr; text-align: left; text-indent: 0px; line-heig=
ht: normal; margin: 0cm 0cm 5pt; font-family: Aptos, Aptos_EmbeddedFont, Ap=
tos_MSFontService, Calibri, Helvetica, sans-serif; font-size: 12pt; color: =
rgb(0, 0, 0);">
Pradeep Kumar,</div>
<div style=3D"direction: ltr; font-family: Aptos, Aptos_EmbeddedFont, Aptos=
_MSFontService, Calibri, Helvetica, sans-serif; font-size: 12pt; color: rgb=
(0, 0, 0);">
<br>
</div>
</body>
</html>
--_000_TYZPR03MB664557FE3274D64FE1ECD7E68E78ATYZPR03MB6645apcp_--

View File

@@ -0,0 +1,68 @@
Return-Path: <andreas.knuth@gmail.com>
Received: from mail-lj1-f178.google.com (mail-lj1-f178.google.com [209.85.208.178])
by inbound-smtp.us-east-2.amazonaws.com with SMTP id 36n4254ephirfcpq0addqo7tfme3fgond15tfq81
for info1@bizmatch.net;
Mon, 16 Jun 2025 23:42:27 +0000 (UTC)
X-SES-Spam-Verdict: PASS
X-SES-Virus-Verdict: PASS
Received-SPF: pass (spfCheck: domain of _spf.google.com designates 209.85.208.178 as permitted sender) client-ip=209.85.208.178; envelope-from=andreas.knuth@gmail.com; helo=mail-lj1-f178.google.com;
Authentication-Results: amazonses.com;
spf=pass (spfCheck: domain of _spf.google.com designates 209.85.208.178 as permitted sender) client-ip=209.85.208.178; envelope-from=andreas.knuth@gmail.com; helo=mail-lj1-f178.google.com;
dkim=pass header.i=@gmail.com;
dmarc=pass header.from=gmail.com;
X-SES-RECEIPT: AEFBQUFBQUFBQUFIK25KdWc1UzBCdy9QZGZFOVhYSjZMcGF4THdYenQzSjdWV0V4ODRPTGpkRjVmMXhPeS9YSjQvNmY3UjJ2T1A5blEyVURLQy82aGFycXBXdVRtOXJLdzFRajZPNzloRHFUdzZGVWtocXB6aHBXZG0vQVQza0lLbVhZZjJ6c01tdWg5cTZyRWgwQ0tJSS9hV2lBOHhvUWZONDM4emVkcldnampNSStya3pIS2VUK2g5QW4vK1p2ZXFmSnAxK0M4SU55bkMwajg4Q2RHVDk5a0hnUjFKRFNoQkQ3WGNLMVMrMjdBTTcyZW9mN1RSbEx5cmQ4Zjgzd2ZtWVc2UVNwSEt5UzlNbGtKTUhiYUlUWlNaQ2d0MlFhWGtkOTlLQnUvTGJ4QzhpUGFXeFI0cUE9PQ==
X-SES-DKIM-SIGNATURE: a=rsa-sha256; q=dns/txt; b=2C2LtDXnd2Rgahacofi6r/dkp+j6+wUNdDaHVQkdrDl8fLJdyOWE7ouFdpinT5Yj4Zqn1C7CXc4x6CfVd1iGzGA4crjxp/Saqdnl1yAmqRR7CYhnLqN5JYRU+s2PLeq2aGHyhqMKsExzKEwP6TKGZ8z+8j3o17zKDzP2frrjExo=; c=relaxed/simple; s=ndjes4mrtuzus6qxu3frw3ubo3gpjndv; d=amazonses.com; t=1750117347; v=1; bh=GhseSXuGN9tgXhkzyME5Vy3B+ORZlqe0/mkrwVjb1gs=; h=From:To:Cc:Bcc:Subject:Date:Message-ID:MIME-Version:Content-Type:X-SES-RECEIPT;
Received: by mail-lj1-f178.google.com with SMTP id 38308e7fff4ca-32b3c889cddso4475251fa.1
for <info1@bizmatch.net>; Mon, 16 Jun 2025 16:42:26 -0700 (PDT)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=gmail.com; s=20230601; t=1750117345; x=1750722145; darn=bizmatch.net;
h=to:subject:message-id:date:from:mime-version:from:to:cc:subject
:date:message-id:reply-to;
bh=GhseSXuGN9tgXhkzyME5Vy3B+ORZlqe0/mkrwVjb1gs=;
b=RKJNRI5vZpIscEL5+UwfmGKNRxnvkZtv6IJzX0Y0wTSCm8DFbyGoJ+S5HbaVf8ll6Z
f2Oe1EfC1EJe/Sdw6JqtNh1L7xPtCEWhjf8eKvrYfp9OssXa7sejFW8ya4GT7SD1V/xV
bTaeZ0r1yU6ST43JuaH+cua3Peyf0AWTkB22bsllbgmlLcRCNTfx5lcMDIPTRaCvYLqK
bQxdMlCIydODUeteHgGNcj/oUXgCvbcQgFT59eX8Su7IILe2NxqlhCaKo0GTG2RNWHNY
fuEE9j0VBSvRXlKYqOY9f+IcMHvo9W7byT6voqF5EwVY6gXbAHIjc1mRu88goAJVgspv
FB5g==
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=1e100.net; s=20230601; t=1750117345; x=1750722145;
h=to:subject:message-id:date:from:mime-version:x-gm-message-state
:from:to:cc:subject:date:message-id:reply-to;
bh=GhseSXuGN9tgXhkzyME5Vy3B+ORZlqe0/mkrwVjb1gs=;
b=Ym5RD768CA5BxwJTdC1ML2gfY7n9j8QzCt6B/N6Aln9/RWhvldkXoiaxswQM9MyMd6
cOluMW41iLzgnB/taM1IQy+VLy4aQw8k2vfDCEo3NPS5/jFZNM9NiqXPnA0umVoHy6x/
T1HK/+f4y6hpzjS782Zl1NnNXD/EjRHvHdhe0ThisJXFsA/P4JsQNbOSSc+inou7jTOs
24pKfvdVFXByJB+YJwvt05J16W96ADjYf641C5Zxbw0jASxXZ7wS09bGBKNAwn6/NGR4
ekWyOVQHPd7fd9S4RzuFS4Xi0HSS74pAqESgMbHdfMVr5vFBOw1IRRZsaIUy1l+r0OSR
LAKw==
X-Gm-Message-State: AOJu0Yw5wp5E4tGz30/zaiSpSJ3YuK0o/gb8o/pewmIm/qmBB2rF1G0J
9VQQ5HBTezSfU1WZluorBrtWKMtP+JPti+UzsRyEvFh5eFS/zU568gNp1dtY1M0TCDlbYysk+wC
kvMu3zTkk7WNUVeLp4NTdmQF/USFdvgDt1FHWhAs=
X-Gm-Gg: ASbGnctCvyrglWpXoxufwhI/5JUawGElxa4V19xcjZZ6iMb+bkbvYeFtm4jFpa5wahx
RRIGY/LgO5e5DUn7E9TbJI0zFaIO1WEq03SFWKettycYPg4XUt/v0QQOHQBYG0r+JMQAJRK49wV
FrEpwioG5krLs5B3q2eozzGS9eu+nZ2owEcsrT3ozeYw5+
X-Google-Smtp-Source: AGHT+IE7UBkVfVljOKTDQeI6kUuT8CjiU45xCwI19ARKkhrlSiOJIb5u8llGPnqA29t1O6znR2vjpcOQjb8amyn/YNI=
X-Received: by 2002:a05:6512:23a1:b0:54f:c5e7:8f7c with SMTP id
2adb3069b0e04-553c95f649emr84676e87.16.1750117345191; Mon, 16 Jun 2025
16:42:25 -0700 (PDT)
MIME-Version: 1.0
From: Andreas Knuth <andreas.knuth@gmail.com>
Date: Mon, 16 Jun 2025 18:42:14 -0500
X-Gm-Features: AX0GCFtidkvZnLhFoACx19SzatSelvT3f9BT79l90NZuttwS1SlmZtkY52q7Pcw
Message-ID: <CADfCGta+hvpvnmUXKibbMstBOM1WJWh1WBKtM4Gf6-no5Hcn2Q@mail.gmail.com>
Subject: asd
To: info1@bizmatch.net
Content-Type: multipart/alternative; boundary="000000000000aceb630637b8f21a"
--000000000000aceb630637b8f21a
Content-Type: text/plain; charset="UTF-8"
asda
--000000000000aceb630637b8f21a
Content-Type: text/html; charset="UTF-8"
<div dir="ltr">asda</div>
--000000000000aceb630637b8f21a--

4573
ses-lambda-nodejs/eml/5.eml Normal file

File diff suppressed because it is too large Load Diff

4573
ses-lambda-nodejs/eml/6.eml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
Return-Path: <andreas.knuth@gmail.com>
Received: from mail-lj1-f174.google.com (mail-lj1-f174.google.com [209.85.208.174])
by inbound-smtp.us-east-2.amazonaws.com with SMTP id m3eg6h7v3pepnvk6g3mnamc5534m41rd9smb13g1;
Tue, 08 Jul 2025 15:22:02 +0000 (UTC)
X-SES-Spam-Verdict: PASS
X-SES-Virus-Verdict: PASS
Received-SPF: pass (spfCheck: domain of _spf.google.com designates 209.85.208.174 as permitted sender) client-ip=209.85.208.174; envelope-from=andreas.knuth@gmail.com; helo=mail-lj1-f174.google.com;
Authentication-Results: amazonses.com;
spf=pass (spfCheck: domain of _spf.google.com designates 209.85.208.174 as permitted sender) client-ip=209.85.208.174; envelope-from=andreas.knuth@gmail.com; helo=mail-lj1-f174.google.com;
dkim=pass header.i=@gmail.com;
dmarc=pass header.from=gmail.com;
X-SES-RECEIPT: AEFBQUFBQUFBQUFFZmNMTjFMdDNIZmluRHB4RlBVb2RrQmo1bGp4aDFRR1FlMHZ2TmVuMDRuZVJlUE9zb243MVpYU1NUSlNyeC9INld5TGRzbzcwWUI2TXFyYWZzNXlqUHRPVXdCZHowUE01cXg2ZEk0b2c4NzEvbWt5ajFjSHpzR0VCeXFWYm45ZGJLdkVkelZJUnBXc0lWeWpGYzZQcWhic3c5UE9nRmd4V0RwRlEvdVQ4M3lyMW94QkJJRS8yWVFQYUt2aG1sUFFqWmRacyt5YUJnZlcrbTBvb0tYZ21vamRka2YvVzNwVTgxU3dJVDI2VzByUWQydENHRzY2cUtlTXRsOC9maWxqRFdPUHAyQWJNbWtaWC9kRjhET0Z1d2QrVVU4NWFsbUNVb3I1RkVZVjlORnc9PQ==
X-SES-DKIM-SIGNATURE: a=rsa-sha256; q=dns/txt; b=Ns7PD33ZcMOuMImq8RydmnQQiPQOcMqRNA6+WBVEqOzrnT8Fr/6ovI6LcNjuLMaNlTyzNW6redDOvY1Fnw9d8PS+R7nDf6/0TVG2sMVeAD0BAfFRbIvFxa1ptoIC/A5DwuS1LrezTIBf2eqYvUaT8ezhh0RFhHPlwNfhMBT28ng=; c=relaxed/simple; s=ndjes4mrtuzus6qxu3frw3ubo3gpjndv; d=amazonses.com; t=1751988122; v=1; bh=eKB/IyUxfA241zhlmu1r99BcuvAQxKeIb9t6YTUDyU4=; h=From:To:Cc:Bcc:Subject:Date:Message-ID:MIME-Version:Content-Type:X-SES-RECEIPT;
Received: by mail-lj1-f174.google.com with SMTP id 38308e7fff4ca-32eaaa0e501so896071fa.1;
Tue, 08 Jul 2025 08:22:01 -0700 (PDT)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=gmail.com; s=20230601; t=1751988120; x=1752592920; darn=bizmatch.net;
h=cc:to:subject:message-id:date:from:mime-version:from:to:cc:subject
:date:message-id:reply-to;
bh=eKB/IyUxfA241zhlmu1r99BcuvAQxKeIb9t6YTUDyU4=;
b=hvsD5eoGlDNXtRwmmBnxsoBSom1j9nCrIed0UHM+KzN0PhnpithS65y5TmC9Uhqq6z
e18tVyrn7pSNkDhg5an3t0lRYtqH/9THaIr/a6iKUsU1cBLY0YnnMx2pNphCEDlJD/2N
IpGp5B+/ufie5DVpBZM6cKaH9yhnsM90jzos9/epxfG3uewYbqmWxpl9WSae9PHIkrkq
PCsQLYmrU6VlkkvZMxAAg1Czls20bknmjYmB4xgwFhYaYtYW1++TFsCe8F+OXjwa57eY
RxhHoYNWl1FVGrN8+eIvCFpdDqDJU/LBoOAk3DJVUj6yEOFQ75fivYd+Ed2HB/Kyl1W7
EIRA==
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=1e100.net; s=20230601; t=1751988120; x=1752592920;
h=cc:to:subject:message-id:date:from:mime-version:x-gm-message-state
:from:to:cc:subject:date:message-id:reply-to;
bh=eKB/IyUxfA241zhlmu1r99BcuvAQxKeIb9t6YTUDyU4=;
b=qpc7/ecpUw57eIdLO2aVmUr5zxZ5RGQb3pqwoiNjUYJQrlDSiYYuhl7o3KjwKcQHTw
TBJ2AjAx/K5hpaRAcXdcHSSHwva/oM1OZtFKjPkwEkselRnumSV1x+tqrDcwP4x+Fkwi
Wi4Fg5fIXpuqDTvzFBCpaypQUszQFbKIdiNaLUpR2i5uMzeaPWvHXbzQ9Cd5dW+A0gaX
Ep2U+DqipArnieyA2osXjYqO6Sm9T7/qsI7SzqjmEM68FgVarKbACFMBRckR2fcH4otl
+4EO2NxDmOJFVYsdAlGZn6RUYrNBjGsg+fFsInwJSnYmAbcZpsxFQid8iW219vvAqOwj
yA6w==
X-Forwarded-Encrypted: i=1; AJvYcCUgL0vwGzGasuMiUeV58XUd9D4p/+SA8gUHmcJBGicjs4HKE0oXxha/ZpAaHekZMhGJ9D18@bizmatch.net
X-Gm-Message-State: AOJu0YzQdxzi0FXHJZ5NKpf/4xZmJQ67KmS9/HXwRrA7GaTn4Z6mPlmd
YcqUn88N0jED8g0HP9et9mGxd6JVpyl3VhHO9900xd/cqdGrAHoRr+Tz2CwiTXRV7PqY8DInS8A
rVnHukdGlNItxRA2zX9HqRpusEfjceDnlaBev53Uj+w==
X-Gm-Gg: ASbGncuDfEE0HlBTh/2RaT7usIau+7Mp8M5Kbyn2LH1jUA9C1q95IxyMto5pvkcB64c
CjTP8ehNu2dFwnO3fO9m4q5T6i2mP57aW9+MHLZ/AS+UzjUfo/2BMDBn6+rkKLycfFBgxMagGJ0
Kv/XyhtuVMPEPYuLIh2PIB+J3rHuDCWDsnRutU1RD6pYLo
X-Google-Smtp-Source: AGHT+IEwHFLwKEd/mVb0yu/gvz3kxVGxEfuH91RsHqyFGR23Pb/RqmQNz0/9QGS4A9tGFPXpXUiJ3k99rBUg8n3wjz8=
X-Received: by 2002:a2e:bb81:0:b0:32f:3e83:4389 with SMTP id
38308e7fff4ca-32f3e834936mr1827791fa.7.1751988119805; Tue, 08 Jul 2025
08:21:59 -0700 (PDT)
MIME-Version: 1.0
From: Andreas Knuth <andreas.knuth@gmail.com>
Date: Tue, 8 Jul 2025 10:21:51 -0500
X-Gm-Features: Ac12FXy8RY9akza8W7SQgEoROiDhMjmnSP8GeIafKiGU1XrBvx8uVbFKIO6dq08
Message-ID: <CADfCGtbc-MqHYF=_1BihROBqd07wjnWKBRaR=9v0AODyCUPnwg@mail.gmail.com>
Subject: test
To: info1@bizmatch.net
Cc: support@bizmatch.net, info@bizmatch.net
Content-Type: multipart/alternative; boundary="00000000000088252706396c85b3"
--00000000000088252706396c85b3
Content-Type: text/plain; charset="UTF-8"
werewr
--00000000000088252706396c85b3
Content-Type: text/html; charset="UTF-8"
<div dir="ltr">werewr</div>
--00000000000088252706396c85b3--

View File

@@ -0,0 +1,616 @@
Delivered-To: andris.reinman@gmail.com
Received: by 10.28.50.2 with SMTP id y2csp233403wmy;
Thu, 13 Oct 2016 04:39:49 -0700 (PDT)
X-Received: by 10.25.37.18 with SMTP id l18mr9511740lfl.88.1476358789184;
Thu, 13 Oct 2016 04:39:49 -0700 (PDT)
Return-Path: <SRS0=63fc=V7=kreata.ee=andris@srs1.zonevs.eu>
Received: from srs1.zonevs.eu (srs1.zonevs.eu. [217.146.68.191])
by mx.google.com with ESMTPS id l202si1012799lfg.293.2016.10.13.04.39.49
for <andris.reinman@gmail.com>
(version=TLS1_2 cipher=ECDHE-RSA-AES128-GCM-SHA256 bits=128/128);
Thu, 13 Oct 2016 04:39:49 -0700 (PDT)
Received-SPF: pass (google.com: best guess record for domain of srs0=63fc=v7=kreata.ee=andris@srs1.zonevs.eu designates 217.146.68.191 as permitted sender) client-ip=217.146.68.191;
Authentication-Results: mx.google.com;
dkim=pass header.i=@srs1.zonevs.eu;
spf=pass (google.com: best guess record for domain of srs0=63fc=v7=kreata.ee=andris@srs1.zonevs.eu designates 217.146.68.191 as permitted sender) smtp.mailfrom=SRS0=63fc=V7=kreata.ee=andris@srs1.zonevs.eu;
dmarc=fail (p=NONE dis=NONE) header.from=kreata.ee
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=srs1.zonevs.eu;
q=dns/txt; s=oct2016; bh=xKHKChGY0vTH8NsecmXwA0OqbinKOXeQbaC2UYp2BAM=;
h=from:subject:date:message-id:to:mime-version:content-type;
b=Ve18ogdCAG+7WZYkJPOewe1hKjhN4k9unz7bVHMXd6+1CQDRUkLCArQZJzSKxkM481nzXfjFn
bI8qOuQL8mRk/8fAjYhxLgnr/3SyVIOhCnXxjdQkRzgouZyl42hqD0gIaCxu9uodtQrp2pbKvyl
e+3sG+LhcdJmsPguOfILn14j+irinPSWrospC8PBIDTsUwO8DCyPqSlOADbW0B6TRUHWMf4XUX4
W8TH61H1ZI3Xu3k0bvX7rsGHZjsy8dcshcnfYENLCLep8fsQMaB15EErc3RXycBX7CBd0iU1l50
pYpUFd6bZehCF0ipTOgA7IJ7ZPafaH0YTU8wRntXOwbg==
Received: from host29.guest.zone.eu [217.146.66.6]
by srs1.zonevs.eu (ZoneMTA Forwarder) with ESMTP id 157bdd754f70005750.002
for <andris.reinman@gmail.com>;
Thu, 13 Oct 2016 11:39:48 +0000
Content-Type: multipart/mixed;
boundary="----sinikael-?=_1-14763587882000.8241290969717285"
X-Laziness-Level: 1000
From: Andris Kreata <andris@kreata.ee>
To: Andris Reinman <andris+123@kreata.ee>, andris.reinman@gmail.com
Subject: Nodemailer is unicode friendly =?UTF-8?Q?=E2=9C=94?=
(1476358788189)
Message-ID: <012d606e-3550-2d94-b566-6cd996de88e3@kreata.ee>
X-Mailer: nodemailer (2.6.0; +http://nodemailer.com/;
SMTP/2.7.2[client:2.12.0])
Date: Thu, 13 Oct 2016 11:39:48 +0000
MIME-Version: 1.0
X-Zone-Spam-Resolution: no action
X-Zone-Spam-Status: No, score=0.408099, required=15, tests=[MIME_GOOD=-0.1,
R_MISSING_CHARSET=2.5, DMARC_POLICY_SOFTFAIL=0.1, MIME_UNKNOWN=0.1,
R_DKIM_NA=0, BAYES_HAM=-2.1919]
X-Original-Sender: andris@kreata.ee
X-Zone-Forwarded-For: andris@kreata.ee
X-Zone-Forwarded-To: andris.reinman@gmail.com
------sinikael-?=_1-14763587882000.8241290969717285
Content-Type: multipart/alternative;
boundary="----sinikael-?=_2-14763587882000.8241290969717285"
------sinikael-?=_2-14763587882000.8241290969717285
Content-Type: text/plain
Content-Transfer-Encoding: 7bit
Hello to myself! http://www.nodemailer.com/
------sinikael-?=_2-14763587882000.8241290969717285
Content-Type: text/watch-html
Content-Transfer-Encoding: 7bit
<b>Hello</b> to myself
------sinikael-?=_2-14763587882000.8241290969717285
Content-Type: multipart/related; type="text/html";
boundary="----sinikael-?=_5-14763587882000.8241290969717285"
------sinikael-?=_5-14763587882000.8241290969717285
Content-Type: text/html
Content-Transfer-Encoding: quoted-printable
<p><b>Hello</b> to myself <img src=3D"cid:note@example.com"/></p><p>Here's =
a nyan cat for you as an embedded attachment:<br/><img =
src=3D"cid:nyan@example.com"/></p>
------sinikael-?=_5-14763587882000.8241290969717285
Content-Type: image/png; name=image.png
Content-ID: <note@example.com>
Content-Disposition: attachment; filename=image.png
Content-Transfer-Encoding: base64
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABlBMVEUAAAD///+l2Z/dAAAAM0lE
QVR4nGP4/5/h/1+G/58ZDrAz3D/McH8yw83NDDeNGe4Ug9C9zwz3gVLMDA/A6P9/AFGGFyjOXZtQ
AAAAAElFTkSuQmCC
------sinikael-?=_5-14763587882000.8241290969717285
Content-Type: image/gif; name="nyan cat =?UTF-8?Q?=E2=9C=94=2Egif?="
Content-ID: <nyan@example.com>
Content-Disposition: attachment;
filename*0*=utf-8''nyan%20cat%20%E2%9C%94.gif
Content-Transfer-Encoding: base64
R0lGODlh9AFeAaIHAAAAAP+Z/5mZmf/Mmf8zmf+Zmf///wAAACH/C05FVFNDQVBFMi4wAwEAAAAh
+QQJBwAHACwAAAAA9AFeAUAD/3i63P4wykmrvTjrzbv/YCiOZGmeaKqubOu+cCzPdG3fQK7vfO//
wKBwSCwaj8ikcsks3p7QqFTSrFqv2Kx2yxVOv+AwiTgom8/otHrNbrvf8Lh8Tq/b7/j4UMzv04x5
eAGDhIWGh4iJiouMjY6PkJGSk4+BdkZ+mZocgJZ1lKChoqOkpaaGnnSYm6ytVEWpZaeztAEEt7i5
urW6vQS1wI2xq67Fxp2pwcqgvs28zbjL0oXDTsbXrchtz9C509/g4eKLb8TY533abNzdt+Pv8PG1
5dbo9mHqa/L7/IhZs+280aJH5IKWewhfkXnTr6EyIwIiSpxIsWJFIw4TEcxisf+jx4nmErLKpyaj
yVMQP6qkiPEkNTdcVsqUGHJKlywjSKZxqSzgL1opZwpVSUTeRiFDk36sKeUmlpywGPIE5rNWUKVY
IxaNdzRI1q9a64EhArYjTgs60UxdK+lq2bcgnVaBS9ci0yhk64a9YjCqG7aAHbnVC1buXMJ670LR
gtiwDzjyAgYWXASx5cuYMy8V+4UxYcc9IMeTPJnRYM2oU6tWqlhTTLpcRJee5vPW6dW4c+u23DrT
a7hpz0Cq/XM2qNuZDShfzrx53t11m0tn/rxubz+/3wY3M7y28VDIMU8fXx362/HTywPnLJIB6CWy
v/MLf9nz7iFC7Vtn317BeyX/8cknD328cQQdfjPpBxt//UGlRYACEjcLgRIVYOGFGGaooYbmdZja
hiCGqOF1DXYQm1QCEiIhSpXNJOKLHHoo42Uw1pghiSWqsB0d3VWVYgAURmRjjfkhmFiL66ln0ZBD
4pgjCjvO0SNpKQYpAJMvFonUZ0hq16VHWNbo5JMmRBkLdz9a9WVq/7XJxJEFkTmDmzmcaeedeFbj
1Yx83segnHjRmeeghBYK05Z9JqramICWYCRudEYq6aSUVupUo9ghCqmlnHbq6aecYpqOpgs+hdaa
XzHaGaqp/umAlSupKqpNpCbJ16lKZiVrU6zq6moDsBL166yjglqmm8Qq1Oax/7cm6+yz0EYr7bTU
Vmvttdhmq+223Hbr7bfghivuuOSWa+656Kar7rrstuvuu/DGK68roNZr771nzesuvvz2668X+oa7
kKEEF2zwwQh31UPA1Q6c8MMQRyxxHnsw/KSZdqapMVcI72oxDn4dvPHI4iTs8cc1YAwhyYasCFA7
I+tZMcojhbwOLS6zrPM+Cv9AczY264OzdzsXzXEbJ/8cQ5RGy/fPKTmb0rMPfZmqdAZMN23c06ZE
XcrUC+Pa7NVVD7Gy1u8Eu9lWU4G9BJwzX5012vuo7VFLbR+qYFlJ64ivg2ajSLctVLKYa2Zsv+P2
Dn7Gic/fIsw9uI8T9opZ4v/jLK5D43GvWquXY08g+eBpW24evoqyNGzKBsL93tnfFP6j3anXrlvf
KewN+n+w0wbzxrTbLjybq/ORHd9b9E56y7UFP/zz0O/VeaC6+/qg4MsvQpzz0XdfO+6sV4/V6NoT
TTf3WKEn3eHeq+/co6VOD7T4rAVd0iNeG42+Uu5TB7/3E+nfcthnPfn1B1/Ky15bTFcgLJzuc2Zp
3X4c960T/UVj+QMPAysUJhABEIAdfBH4GmXBbWDQfKWwUgg39EHvrTBEI7SWyrBXPsoJSIUvxJCW
9uQ6Hu4OghTJ4YZi2DD7CQJ/KHTaBoUkRAvtEAiNWaJMKNTEGxVvXDNMhgL/TSPFCf7LMT0EAtn8
Y8RCbZGLBGzgFw0TRp/BK1ITi6McB/C/FtoxLhRUGhznyMeH1fGOdyTitf6omTUa8pCIpNMY3QPE
VlmNAvuTHsCuEUlJBqFsjUyKIOVFSEeGTlmZHMomX1BJATipkqN8Y72Y9R8ZKtJRj1wk4DzFyve4
Elmw/KQsd8nLXvryl8AMpjCHScxiGvOYyEymMpfJzGY685nQjKY0p0nNalqTmonMpjZxec0cbfOb
4LxJNy8WznKaswnj9OY518nOPKZzEw7rozznSU+ZufGdtBJCPffJz37KwYD4DF8Q/EnQghYUoAEd
QxkJdcaGvuRgqZRmFj3h/9CKmuyKCX3VQgdVUYde1J0ZFZs+E9bRhn4UoSGNwERvRrcMikJ2KbLn
JVOKtY3yaHJJLAVMBSRTMdIUAysVWktzSoqdyqen9/xpBYJ6v6HasGu/0xhSqaZUkQ6UhkUlakm3
OgjN5aCqVgVCAifhUq521KsAAOtSbToAdkTVrHBFq1ohyVa3dgOueA2AXOcKyqteMK+lu8LLnvo1
vaGUkbEcJvkA+w2u6VSrotjrWhMrzMUyVhqOzSphSSFZulI2mJa97CRQWQS2oLWUEfVWaEUbCdJi
7iSn7WKsMKrKeAqVtRpM4xODYFrDOtCLkxwL5EKw2p1tdhSlTNAQjOJbH/+uJrUe4Ncs/WpC0h1X
FMmd4nKPxobsahek1LPXdMWK1aZdN7edrM924YFWzgXXc+KNXF2Xd97jyNZWQGAu0u6L35k+LpT1
0yUEiovbRnj3h58aHnRNlN7xffYBBC7wIg6MPHspmLYw0K0mH6xR2zZVwqPlL2pQJ7wFc0KC8Xtd
eWP31tmJGJAwFhZ4F4Pi/jpmrMEw6ncoHOMWmngD9Cugiv/6Dh1v7cU9TjIeDytQK0SxTTimSour
hGQlK/nHJzheYZK34oZur8pWDnOK/ZvPGnsSC1G2bvPALOY2n5nMvAqyKLlM5K1+WcNuzjNw4Rze
39q4CmnGqU94rOdC0wT/w2X2M4LJW+caGpllhLaIAJWDZ+FN2gCVVu6MXbOFPTO6uo0oK6TZnJRL
Z7p2pm5wgJlsvE6P+dMsDTVkdRbpiqQawM+7tXP/TFVKuprXoeky8+pLslpTRNdQjDGyf/BkVqMD
gcK+rLGX7OQD4dqUZq6ws88B7UZLeNqHVnRuVG3JJjT7vSXqNqhBDCRSz1ncm9r13bK95U1jq4Sx
TpGoF4jnKhbA0Obx94WwbA9831bfsw5xv6sIcOgI3EIEfzad1x2hhPOb3FdieMN18/ACRBxQTN0J
EonNFhw2cbc+OLe8hbzyID7843IKuVpG/mjAmFyIKO+BypMNbB7IpOMw/yeTzIVD8ykfeeEn13TL
38zzRTN7JUBHtGrZKocpGX02N89hzn3OpVPPW7dRt7e5hv4JuIK73O10N7bFri+y39SsZ1972pHQ
xl7LjeoZM7valT531Eq9W3s0qODngPGN3+7v6wr84BffXJ0b/nuIV5fiGU/5NBT+8c+NfLomX/nO
0/HamM882891+Vf3/fSolxQvS9/z1Lv+9V1YPeg9Dfva257DKGO902/P+94fQfZLZzk6w/r01uuA
22o/pbuDjsXZf1fAEF6+5m3g99EfwLXbXqTu1wb9Dm//ItNvcvA3bH3so1uxzp8t7hH7fdVZ/7/t
d7+zzc9n9Bsrl60sIv83x7uEbsZXocuifwEIgFbgf6uEf7YkgPlHgFXAVw74gBAYgRI4gRRYgRZ4
gRiYgRq4gRzYgR74gSAYgiI4giRYgiZ4giiYgiq4gizYgi74gjAYgzI4gzRYgzZ4g8bkezp4LzgY
XTv4g7TUgycGhERIKUI4hEWYhPt3hJikhE5oGEyoAU84hY4RhTVFhVjIBVa4AB7meV74hRRzfi7Y
hWBYhma4ODVIhme4hmxYBtlHgmrYhnIIhm84gnE4h3hYeXUogneYh34oeHsYgn34h4TYT4EYgW4n
JewWMx0TfuOUiFW3iBtzUmL4gJAYB5I4iY34fj91iYGWiS5BifUHgZ7/GG2g2FsQ5YgGOIh5d4o8
tYmHGFCl6G2uOBmi6FMVmIhqRnKSUHO2mArMV0y6KGi+2ItX9x1TFTa5iHefWHG8GAnFCBjJyAMX
OIxOFY1W5wuM6AnBSEzWiDb7RlbHaBzTuAPVyIymuDHhaIx3pYncqIrX9I1as47QOI6zUY7HZ4Hy
aF4WJ47tKFXACI/WFGHsiI21eFSNl1QTSJD1+IwHiZD7xYk0xZDZCA0P2TSdtZDzNVgGeZGlkZES
SJFF948eyTIgiYgbCTX9WJLkmJB2p5F9aFcWyZIm6ZLKSIEiSZOEkFmjQI+QcJIDli/CmJI6CQk8
+VIrGQlAGX3rB3yB/0OLRTlhWMCR9kgJS+l9BXhMOVmURxkKPlkJNkmNxNd/WkmUUWlgU6mSDgmW
ETl/QumNZnmWUilYatmRwhCW5jiWSoBMW8lu9JdfaxFbXvd1sbh5cVmSf/kDqNhdv2Z8XzVGfQli
iekDi7kGWlZvhYkukflt0vdaJiGY8Udto/gxm1lgk9kDlakGl8l0CvkzpYlbp8kDqWl5jbl7N6lH
h+mRsbkDs4kGqyl8rUkzr8lau6kDvXkGv+lgEklKwwUCw1kaa0kZg4mZgMleeLk5h7ecLiBd8hWT
9GWXaBSa5Mdb3GWZe8d3o/kE3ElcuVk00YmW08maslmeqnmez1eJ6v/ZnB/wnJPxnuGZftpWnYpz
nTngXukJMjzYnU9JcfMInnMpnu9GntbZlgAaoAdKffrpg965iyRpCnHnmeDQXtmZmVCSoQy2ocQ4
k5UTn8A5nxPKmCzaoreZaMVHe3uplzzQjH5pn7ZZKRemnS0Qo+DXfcDSng8Zd/eZYCUmkPzHdXWX
BE0IawdHk0iqfvXyoySaZRAablk5WQuab1TKoxbqKViKnwg6fqvWgDi6AzoqmWJKnUpqO92oUluK
djfqpSMFlRdZpTJ2pUsKpCwgpPKnpnhKXWDKknzKfaBSphcqfubWdbyTjlLWoVQmqKGXOnMalPDW
o13QppqlommSqJf/ejlMqqDV9qQ34ak9WZWTIaqjqkZmiqGbOqaGoapISak39KavaqC4KFyzCqc3
JqkyuQvAo6u7Om6lyp6/Kp9OYateyaqB4arHaqMzGmdXsHO1KqxDg6tKZKnT6iGZioTLOp5opq3X
2A7S+q2YmqwaSm9pegXOyjJ3VqfqmmThyn7jGqHlqqdnNK8VWq95dq9cWJvACmjmCo5r5q0A22MC
S0ZytnVMEK8k469ourAb17DXR7DMCh8H26CDZqwW2z0Ym5zkCq8dy48fq7AhG0jseqLuWrJWILEj
Q7FNt7KjirFRehiOCQBR9pWVSq8rsWwpp2yTprI4u6ZJQK05Kqk+/5urKhu0RQu0uSG0joeqeelr
D4ueUvphjNC03Sq1H0G1TgpIYss4kBqrxfKy+mqoU6oIXrtjIHtsUfuvfFK22Gm1+Yi1aguxS8uv
hfC2Rwe2HmG3BUq0AmS0LbsCJLu2WytyjgC4WBe3ATS3FTs8hAsA2PqS9KKxMtq3DJoIkFsacXe5
PUa6Z9uoBXcvMps9H7q36lW5Wcu4wXlAqnuyHdW6+UqqsOu6squ5DaJuh2qaksulpzqiNdqnxWt6
vku79rK6y4O712ptu5u7MDu77QG8bSu8T4u8Omu8Q5ukj4q3jyl0teu3jAW9yRtvNauo3au81bot
Bse1zuig/iBF/v9ms2URdlkKchMXvMYRuoWQdS+Ev2Chv2irgCZrvoABwDtpvxpHwFhhwKhLLfHr
uD/CwIMgwCsEwVkhwb06ddejwGyBwe2GdDjHwUrhwdabLRU8c2lCwhocQiicwi+XuCLRwkT3wkkp
nRh3vzM8FCq8vNqCw2iiw/6ZCDHcQT8MxDUMqPCLjiJMwoHVww9spevLqXeLxYWrEkH8vobJij27
wy6RxGHCt1ustGa7sz/XxPuLwI17BxXpDC5mwlqntVWLxllMq9/7EV0slu+yj24rxidBxlhixph7
ulfcuWnMx2x8wM0HxkwryCZByExiyJl7x3qMyR3Rx1e7L1D8uYj/IMXjQMlDYsmIfLwFu8dg0sgT
LC6ADLqSnBGkbCOmLL6HrMZQx8ofzC6zCMoNhb46aMva98mWgFfA7HvCDJnEHAjGPLx2envJTDa9
7L+/7Mxyt4PRfHeQrEVwZ81FmM24uc0UpXfby77IjMfjq81fWjDNXM6Eic3onFa1BWWFWE+Cy8FH
y79uUs/2TLdLbBc2/MT7zM/zdM8QnM8xJygELU8GTcAITb4DvdB81ND4+9DkFNESLUcUbbMWrU4Y
ndETs9Er29F6i8rEk4UoPXdOadIjltIuzU4rrcqL8tI0XU4xrcknXdM6nU03Pbait9NAbUg9vcg/
HdRG3S9Dnccz/33UTG2i4ZzIhdTUUn2AskSvB4G0Z5zJfnwMyXdFxZnOQ1m5hqx8lkrSTLmlZA2h
Zg0tVv2WfQXV77rL84PWXt2ZbTwvbd2UDqvWAV2i3prW/ky8rTzMYm3HZFmocF29Qsxpf13XZd3X
X5zYykmk+FrY5rzCvtHV5WfXjlzVgT2ohOpZfO3EZ/rZz7zVosPZg63M98eAoHFLA9ikUBqPVC3b
4uTGVYiAwzeQtW2qr43bUKjbTLCKn1JLv03Br+Taw03brW3bsQfccmHcy83bze3buY3cS6isXbqF
3N3d3v3d4B3e4j3e5F3e5n3e6J3e6r3e7N3e7v3e8B3f8j3f9Ctd3/Z93/id3/q93/zd3/793wAe
4AI+4ARe4AZ+4Aie4Aq+4Aze4A7u4AkAACH5BAkHAAcALAAAAAD0AV4BQAP/eLrc/jDKSau9OOvN
u/9gKI5kaZ5oqq5s675wLM90bd9Aru987//AoHBILBqPyKRyySzentCoVNKsWq/YrHbLFU6/4DCJ
OCibz+i0es1uu9/wuHxOr9vv+PhQzO/TjHl4AYOEhYaHiImKi4yNjo+QkZKTj4F2Rn6ZmhyAlnWU
oKGio6SlpoaedJibrK1URallp7O0AQS3uLm6tbq9BLXAjbGrrsXGnanByqC+zbzNuMvShcNOxtet
yG3P0LnT3+Dh4otvxNjnfdps3N234++0XPCQ5dZjWejo6mvz/f6i8hq180arHhETWvKd26fmn8OH
AYYImEixosWLGCcagWdw/48FIxlDihRgTmEUhmkgqpwncaTLixvfdfTyscjLmyTtmTwJ681KaQN/
sRSCs+hNIrVmXjHKNGPJnTh6uvm5LGi/lk2zVkRa0A0XrWCfSkkYAyUaqminYQXLVmQXLG3jhhTL
E4sMs2fS6g22Vq7fnG+r/B0M2CMMkH7pbsBrpt/AvbX6Ep5MubJlpzpdIJarWANjWfMeQ54l+bLp
06i1dkaxOW5gK3BGiwvq7lHr1Lhz67Zs+G7p3Rg/D4BEW6jsb7dPG1jOvLlzIsD/Op/eHPrg3mV/
R98qdduj4sfBJTdNvbz17W3LUz+fmKaYr5y7rwtPf9D4y1p03w+ehTD2L//wuSYfP/XRt19l+eV2
oEUJ/vUfVA689kNsBRoC3jsLClDAhhx26OGHIG6I3oiVhWjiiRw+CCEDEvpAYYWEXDhOhijWCCJO
7MUXhH82uWTjjym6t6IKwslBHG0wSkIjkD/iqJ1qRF3X40hM/qjikPeQEcuRViXJ5UBP4tfimHC1
dxCWNhQZC2hewhMmb2TG2YSDmaGZHRZr5qnnnnyqkSGJgFK2mp1QaNHnoYgmesmUgTZ62qCEssZo
W3JWaumlmGaqaZ2RTvGnk5uGKuqopJZ6RKd8fHoUpxC8ydSZneYoIKwRqPoSpKimY6lvleZaQaYj
XOorNsLeKeewEwArQrH/yDbr7LPQRivttNRWa+212Gar7bbcduvtt+CGK+645JZr7rnopqvuuuy2
6+678Ma7k6n01msvPvLCe+++/PYrZL7faqnowAQXbPDBaVwJcLMCI+zwwxBHrMq/Cw+p5pptZiyT
wbhWDMbFW2os8jcHd+yxpwPmMfIiMp4iWpvV0HryJsKx087KOP+jFBAzu1IzLS3nLHQ4O//QczYp
pzT0ygExEnQpRfuAEL5HL/Dz0iI3zTKSXbVhck12VW110mdhfZyrRsU0TtQ9XGCrS1+De7XZkKE9
GVezsL3EbnEHTHZedA/SpZtR8j1EUl5tYbjM7M5t9uAYFu6oRvZOzh3j/2NR/YLjgaNl9+L0Wk5R
3x+8PRLpD3AezsuBfy7668Ch3oHpbrG62N+NhXZz5xFJDvvvjcrOyaRs3fsi71tDzgjtwDcPvMLD
yxqWvccjn8jTijDv/PaWQ0+k67ipfj3X1mdPPHnqPQf+5Omr7/usPP+xPmriI4J9+YRoD2WZ4Z+P
Gf9mit97FKejIVQPf8vQX1YalBpVMbCARksVAeEnhAMW6H7SWFKVUMQ97m2wRt6DiqUsWB8MJtB/
FfkgBzvoPBWeKITYAtlUvkM+rGnQhR8C1Y6ktEM6Se8iOAwRDK8lQ+84woQju2EQOaRDIPCohwF8
H0aW+KEhWquIlkDgKP/mR0F/vcaHVgwXFgOhRYBIkX5eHBMYKbYuQ0nsjXBkgwJZODnhpcuNccxj
HudIx+DZDl18HF0aB0nIQtpLbGP74QL/2AAuziWMC3Hk/7wXSMphDpGvUGRTOiZJ/rARS5pc5CVZ
hMJNMhKTjdyVseJ0RUwFS5Wo/BUsNzdLaSkrBMyKpS53ycte+vKXwAymMIdJzGIa85jITKYyl8nM
ZjrzmdCMpjSnSc1q6tKQ2Mxmr6xpJ21685tv4WY3wUnOcjZBnGgypzrXeUp0hqFheoynPOcZCEi6
kwXwpKc+98nPhH3ynjXIZz8HSlA92hOgnsEdn8qIv5K1E6GtUuieGFr/PoeOEqLJkqieKGo9ix4U
omNUGUd559F/YjSTBgzZ42poCtYlKWYfRWhIlbZS5ZXCpTCCqUlPGlGBCoJuSKQETiuk0yDwNAMz
LVtNhzoKptanqAI8qix9qlSXsXSkHNXbDqTqNonarBtYDWsAtKoDroItpTOcRVDFStLEXdSsiUSr
Ea1qU7YWQmtUIWsOphY2RNbPrlfZwhGvSgq9AoCvV0DlXwFLOC0Mtq6jMCxiraBYrzJWJXgdH2RF
IdkSkMWvlr0scs44PbyJo7NT7eTlYrqtxYr2FKplUBE44tYhVrIwOzWXa19bitiuVgi09dpDD3Bb
O2ZrtyLbrFpIaxrT/5rCsLdtolH1FVqgOnW5UFTQ4bomxwn27626re5SoRHY7H43CIgT7gPRCN5y
IVdjys0gc8UEXO6uIUDnzW1ANacZ8fIWGL69W+VeZ1xJ9ZWWVAXcf5UR4CeGjsDD5VViVxkEEi7Y
FA3m4YNFV+ATfBbBcp0PPK6bswz38cQwifCySvkqFaO0gj7RHVh5Z2IU2xi30z0Mi9Pm4lr5d3W7
61yNb3ziDnc1lDxu71lhnNZ3kBhnQyYyHY28ZPOWll4Wxt9a7bNjKXu5gfpFapelS6osl2/LvUPy
l9dM36iuWM1kHpWZrYfm6LL5zqvK8SvhnGcsx3ikdR6zdNrHHD6/jv/Qhc4waz1gaO0mOHePfTLy
7OwSRC+n0ZaztAEwDbcwezjKxfuxZiVNY0H7RdOcdhSqFe3pyf4Awo9mk0AIq0VKj2TV820erq0c
aj1T2AewDjGBIh1kjtq6dgBkr93WS6lWSzjZV2byXC9sG1NDUDCOXnZ/NBzBATJblMJuCLUjcexH
Qru5gv52tKUmQXW3ONbDGTckyj1JK+gn3dteI7tRNcI/JwnNpNkxFT2E5xENvEOLRpqc5jwagMNW
4AcXUcG3E/ENJZwV/W4yjByOYYhHfOIUr/jFh5VUBc86vlnz+MHjzAMHv5rbvLZIxQswcl+VHNIn
J3WblEhFlu/A5cD/hrkTfSRyZ1Pr5rJ2Gq2hrPKB+1wHQO9B1F9OpaL7Wm4ajcOXir00ni/x6TmY
+s+FTnWRzLzmuUL6HOQNamSzMwn6dnPjso6xcbfd3G9HQty77S61G8nuuUZQ3uEexau3MQsFTfya
6A3yLsq9Z3hUvOTxwPjG9xrt44r85Dc/h8pbfn+YF5fmOU/62gb+87HrsbeKO/jWu56QlU11vV9P
+9qXKvYBtr3udx8q3J++z7wP/r7gCgLWK5m4vwf7YfnNavAaP/TUlb0nbZt84POdUNKf/eNTmX3Z
Ht/3MTfl8ZtveFCSP7fPN7rYctnfbR7dlW8+ljHZ3wL6Q+uWxa+l/zDtvwL+Owv/paN/weR/KUCA
yAKAjCaAxLeADNiADviAEBiBEjiBFFiBFniBGJiBGriBHNiBHviBIBiCIjiCJFiCJniCKJiCKriC
LNiCLviCMBiDMjiDNNhMwneDw1eDCYiDPHh7Ojg7PRiEovKDQCiERgh/RJhQR7iE8peEYsaEUNgi
TqiEUViFXTCFpCRtpbeFXJgH0DeB8NaFYjiGZ/CFEhiGZJiGXGiGEYiGaviGm8eGEOiGcFiHBSWH
D0iHdriH+4SHDqiHfBiI8eSH9+R3WidvL8UxqgdSdKdSiFggJVV+DWiIDPeIaBGJ2zeJjZgMlgiJ
ivh9R0WJ/taJx/+Bidc3h5voCaT4VJ9IiMskihq3ipBhivt2hqkoYljDcTnnDBkDVaeYh7c4bLm4
dKGgc3vhi7XYhsEobuM1Y2rFdTmVClRWTWoXOLqodM7oJcjYNhRYjdaFcpNgjHqxjTxQgd7YjOQF
NNBIVNK4iDK1jDQ1jOAoCeKYFuS4Vd0Ij1W1NNeYPOkIM+0Iijz1XluXjbIoMqhli/D2Vf94kAhp
epLIgARJQ/PokKUIkZkokaImCv1okXmFkb/4hxtZjMTokfSRkMq4kOpYkSZ5jCCZjKioks/IkhSV
WSuBkrjEX0czkS3ZCDZ5CB2ZCDiZfwdWNTzZk4vwkxZSkpQwlAH/WJQ7OZJIqSSCtYsGCTUvyY1Z
MmGgJZNTuUVViY31KAxZWY6epZOQJ5VfWW2OZZUN+VxliY9bSVmYdJRraQhKWQhBiQhOuYNcuX5q
uZbnh14bo17U132/FZHnYpcWOZhAEFzdNX7Wdjru2FqB+ZWO+QOQeV/DlX6KGV5eeZeIkJk+sJl+
0pmT6Xbqt3qXOZWk2QOm6U+SiZiCJJB+E5qiiZfVdyuzVZiReZi5V5nawpgO+Zo8EJtowEmpiXer
2S3EeZB3p32waV+n6W6CZ5usiZv8OJYdF35t9pjUKZv5ll+f6V6tmTE0GXDeCSf1lTdxSXjZ1pzc
8pz0kZ4Pt57X/0mY7mmY54Zu2Omc59km9tmdQwc64Lmfv9mf31me5EKf4TGgvbWbhVea4Zmc3gVm
/zmfAeolEEoK0Tl9B3oK0LWclyeflqmdQ9OhYImf0XEvsDONe/aX7Rdu8ZibusmiqVcvLyqcBYiW
/behJvmh4rdh3cOjBiaj9QekHimk70akdWSknwaV+KSkjSmhGOqkjgKj8UeXOoaiNsqkSYalfpSh
PyqlZUqj+2ij+WOlymYqO0qm32OmcYqmJqema4qjBloqb+qKT4ikU+ql08CdMAKmqJejfIp8tGlJ
I+egzLCOXcemhco9WvpieNppcBpXWiiM4yCoFUKokRqfDDqXlf9KmZeqAIwqVI46NJ76qVdqoreT
qDiWkX2aqczoZKkqNKvKqo8CpUcWnKWKqHSKc5t6qyUGqbr6O5PqY7DaYacajsTKdKN6rM6TrD3l
q34Wi2UUaLAqrWyGeZ5ZZqPIUNqaq9yKrK4aISQKoqVSiZ0zrsZargXnremaYvXCrtbIlHdKrvDK
YefKfdZqKvb6jTrnefsqqf2ahf+6ruGarfjKZdtasDcmr8tKPQurRe4arWyxawV6YhpbdtemlTH6
rokJrthqsQ2bZmDasUGHYiordWQnl8/msWMarEnnj886afPaFC3bcja2s2O3dzD7ay5bpIC6lCoK
rSlraQ+rGz7/C3UvW1byI7KC0pp7yTQ5yxRNG3Y9q7TGuXwzsLQfOyEVaz8n2zpXaxRZCwA3lrZi
B7Vfq6/MWWFjC5RlSzcESxFsu7WI9rCHiqkyGyjvVbVJdLZFkbcsy7VSO7Jm+baJ27Y8wK6Cm3Jg
q5rYRp4bS6pL8bR71W7jWaJyW7KyeLeKam+germUOyea67XvdKHrBgQBS22iG6tMcG98Zp1DGpJ1
0bmtK7agu4qxa7uO+7OWmrlA67arC7zW57pzS4q/q7v+qW0KCnq4WyisK72fO21VOrlxi7qW+7fS
uTepqyvIy5tF24nNG71TW7vO67kg+zHVC260WqMVErnk1nQ9/wexfnF2Bxul63u714uLG1e3BJpr
M4e/+Wt1sgohGYe9FyTAEYpkBWzAcaG/oaoQCwzA8+vAHmq/XyfBbUHBCSxClfK6VEG/88bBQeTB
H4zA07siF6ypGXy0lOB1KazCYAHCLazAI7y8smHCbPkmEWzDWYHDMDlOC8fDDafBKwrEFad8wRu8
Tlt1Ede3+XCObsmLZkPDOOTEqQvFWivFB0fF+qCPdWqzV4mrKLzFyTu0E8qzxevGZsfCRZydNLt2
FMmp4aHFLsTFb6y2qRvFcTzF+5t2ZCysZvyWqprGe7zGcBy2wuvIbBwSRNy+WAeIGKwIPuw5iqxC
fNzGj+x43v8rc3JMybdZx39HbGdcrBDcxIz8yc2Gsacbyik0yosreoVcs5isxPVJG6tqhH1svO0C
iwz8Wr0shL+8ue8izJdMzI0bpjx4zKobzLdsx7DbzEXhy55MyuqizDDMW8UchNAcL9xcqwv2zT0Y
ztFnynnCdtasfLWHzsk8zad8Yeb8zNlcy9KMJ4I4ebGLv9QKoPq8z4rXzxD7zxoa0AJ9h4QrxNub
wwAzegnNTwRdsAZ9olcQ0Yk30fta0cOJeBhNUBoNrxx9XB790f0U0uU60oQ8sVbY0kEIfqa7uy49
07YH07LcpDSd0zVdlwut0z69012ZsD891Otk0ysLykSd1N//ZNSRzL5KrdQh+K1PndRR3dOoCcsN
jc9GjNXfq80I27gqDZpC7dCmas2/mgl8e0pSPce/tNZeja5cra5s7cJdC8ypY9VnnS9urdXKGtNO
zdfmZ9ZqjddibMssDZw37b9zrcOC7XyEPcjystdBS6lN/coVPMaNTUmPfdlBzUpCKyGtZIAYINq7
RNpn6X7TgoBF2ITEZNqiOiahrYCvytrD5NohSyaxjdpPSdv7J9s9qtvRotrRw9sD6NtH6tnvZ9sZ
BdzFzdxnitypjYRESdxYWN3Wfd3Ynd3avd3c3d3e/d3gHd7iPd7kXd7mfd7ond7qvd7s3d7u/d7w
Hd/yPd/0Il3f9n3f+J3f+r3f/N3f/v3fAB7gAj7gBF7gBn7gCM4BCQAAIfkECQcABwAsAAAAAPQB
XgFAA/94utz+MMpJq7046827/2AojmRpnmiqrmzrvnAsz3Rt33iu73zv/8CgcEgsGo9IHGDJbDqf
0Kh0Sq1ar9isdsvteq/JsJhmHZjP6LR6zW673/C4fE6v2+/4vLw67vs/ZXqCg4SFhoeIiW58f41/
WIpwAZOUlZaXmJmam5ydnp+goaKjn5GLYI6pPJCmbaSvsLGys7S1lq1sWKq7OayFtsDBAQTExcbH
wsfKBMLNnYa6vNJkV4fO16/L2snaxdjfldCo0+Qvvm7c3cbg7O3u75tw0eX0K+euwerI8PzAX1H9
QMkbF+IflHpD7rEJyLBhLINPPOlbF2ygFRIQnSAUonD/jcOPIANUEUCypMmTKFOStMLQIqMKVlTK
nFly3kZz1SSF3NlvJM2fKFkGdEnlQkygSFcSvNmioxqe3yYyC+gzqdWZWIQRhXi1q0qbTFk4TQMV
m1SGVb2qFZC14puMTdbKBRtWxVg0ZfOCSyu3r0y4gJf4Hfx3ad0Ud8/oXeyML+HHgQE/nlzT8OET
ic0wnMhYmGPKoEOLHv2T7mUNcON0hieVGCgspGPLnk1b7cvTEFLrXN2u9VRPsGsLH0589m0fkZNT
Uf3JN293wYUbmE69OvXoxQlb3z4dO+HjPZSLh8JcYuvn7bzP5r5dfXa57K277wsehOT3NDMPQM+f
0nzS//cJ919QcIFWHyAF4odVTm/019+AogVYG4QmSfhdUSVQGNp4UZTnICXO8UNhASSWaOKJKKZI
ooIsjqbiizCWeKAHGhrI4RMefjjMeSJe8VOMQKbY4pCUBWmkjBjOcOMXFOg3Bygh6viajzQdeSRQ
NVr1GX1bpmSlkTPiBhODuJAlJVpUFLfkmlxRZpqYIjiJyJloTqEmm3h2YeNFcGKmW5mABipoGlkS
aehgb/bpAmCDNupoK4UeKqltlim6KJX55anpppx26umnT1iaEKYLgmrqqaimquoUonJEql+JOtDl
WnxudNRksSoQaaa1toqcpjJs2mqnIwjr6w/GwpCsov/ExgnssdBGK+201FZr7bXYZqvtttx26+23
4IYr7rjklmvuueimq+667Lbr7rvwxrvBqvTWa+8/8n5777789ntQvtAG8ujABBdssB1hAlyOwAc3
7PDDAyesMBJyWkPnxfA4muvESpA5CMadRGkLZxeL0yvHvzL8McibiFwLyXSaLDHKNeiXDsws59zQ
VlLQDITN+fCo89D98ByFz8h6vBDROkdm3llunXIygoBxDDTTLDsdstC2GP2vs1VPfDXWvM1KKRUt
vVXpA7dCtra+SntE9nNmeyVU0WpPHUHbF+od7thzQ91TmsS1BYzXXtzpN7iAz71Y3fjZO2lli8Ot
8tL/jncG+XuST87W29427g7Ojm/u+enZbcyt6L3pk7l/hKMuu6GqH/snOq8/Tfomu87uO+ozH3Y7
Prlz4jLvr/6u/PKU9xyEv1Pk2DLXxWfSe1/xVXe9odlflzysSRab4KSsY3J89ZZsT+v4E37/Fft9
O48R/LTH/RT60LkPIP2yZWkhouEDm+lwlSfpOeh8e9FfSb4UJOb5joFACh4G1Lc+PBmwPwj8xogg
CCMHzo6DMJKgCe7VJPvhAUrUY9oGQSik0ijwbFLYk52QwkIViTBD9irh5fSAQsGRbYU1NBGWXmi3
2LnNiFUK4oluaKmKGQJ/sxjgEaE3HhkGcFxO/AUU/x+CRNpQ8UZWZNW5GAWxMj6Mgh6sX+XapaEv
uvGNcDQV0ibQxjja8Y54VM4cJYDGCoGOAVIc4hUXFkgXrvEAfWzeIPeoKyJq6Y8LKCSvxGgrSZZK
YolUyiF9tqxL5WlYnBLfJxnJtmcpy5TMCqUA2UTKVrrylbCMpSxnScta2vKWuMylLnfJy1768pfA
DKYwh0nMYhrzmMhMpjKXycxm2ieP0Iwmk5zpB2la85qQpObzsMnNbjZBm33wpji5Cc6kLceM6Eyn
OvXAxHI+85zrjKc850moRboTMVWgpz73mc523lNWJmzFFqGosWxqM4uEGCj+CrpJcCJ0ZQotHkP9
qf/MhwoiotWbqD3/aVG5BS6Fs9jdh2S20Xt29H4f9eHLXFeyQtTuoAG1Q+YyCAuROoiklPwnQHeY
h5mCVBY27Q9O5afTBpSvpj/FqEIRp5Gi7jSfuxlZUpW6RaZ+06lGjalmgqZSqkbUqkzAalZ5aiav
5kVrxpvqK8C6hPmFTWFHNWtD0Dq9rs6CrQBwK1ysptX9yLUsdNUETdeaN4qW8q0Ai+tfm2HJwqBt
KIUtKSAb+76Gakuxiw0GZVNyN37g1SibJZBls4XZzNoitCfpbMYim1M6otaPo8VWaU1Li9cqEiCQ
lZoE+QbA2F5rtheza3q6aJwraIW1BlGcYaUFXDr/CZcdtk2K4bqG3H8oV7Kr62vunpvAGQ5nurXA
ayYf6VtrNfdM3NUgcYnUucm9lLTapS101zuk9pLPoPAlK17k290Yes6+knqvbOPLX2xE1031Op2A
f0tgfgSVaAdOo4Rhu9xonVeq6ihehCc84QWbt8GsYenrNszhNHq4WhdeaYZzR+ISO/DEcBoe5jA6
2EqM18U45lJrm5iRC0KxxrDjbY6H/F3sCq/HUY0okCdxYyI7GSkVnoaMPUpjtdrYkU/OcvyOtk07
+vgSSx4xlifTve6M2XNlNkCTo4xPLye5rg9GX5N/kuY5D6fOZ4aykanh5gY1x8oazvNg8Czk5RH6
/8BsnmxG/gviAk+p0PtbdJEhLdo2EXDH77R0gBvtaOAIGnySFtCY/wdqoq7Suu7ldKc5YedLJnfS
piO1jk1dkE+Xek1ffk6Y/UFEJZ5Iy/jxtYkSHUlbzxrXbz4goD3Ta2GvCNjZcTaJiN1ISo9GU7nm
za5P22xnQzva0qY2Io09lwImG4PL1my3hf3t4ki7AOIGbb10CFUt6k7EP1y3rwWJaPpW0Ls/Cvee
R5hD1+pXpn9OL8iAuG9D9hvgW45CUt4dbwuQ0OD1TmjC47xwfSuR3/6GocQRHHKTUHzgOJw3xuGp
8XuvOKUTabFjx2ntIpYXxX1NxKqZXPIN0Zzclf+teIxzPqedyzzoP4d4b4UuppOe0Og9D2PSoSB1
LpuLjPzM+iBa3e5bYxpeWNe62O/A9a6X++ZYnPLY115dqpt902gXV9jZTnfdRv3too773xw59b77
HVSzrOPfB0/4JQWe74VPvOIDc/iaf27xkI88Fxpv25c+/OtSvrxkxwvjeHHeoJq3OkIcf5WNfV7v
7zr9bu8uXaaHJ/S0firrHY5yuKLSk3gCZSdpdHtX7l4svW+6Kms9ylj+3h7Bx02ziJ97sTr/+dCP
vvSnT/3qW//62M++9rfP/e57//vgD7/4x0/+8pv//OhPv/rXz/72u//98I+//OdP//rb//74z7//
/vfP//77v5aSF4DD93+yJ4AGyCEEmBsHuIAImIAFyIAQmBEO+IARWIFeMIFjZYEaeIEYWGwb+IFb
0IEeCIIkiHrjd3B1l4IqaFXvh4Ir+IIwaAauh1UuGIM2WHcz6FQ1eIM8KHY5WFQ72INCqE8/qFNB
OIRIuE5FaExOdwc7FzON0nnI1IQI94Q6olGYx1FEZzFWOFJRiF/ORIV10IVX+IUmWExiSAdk6IWD
IoXHlIZPsoZCZYZLaEtwKAdyOIdtCIbNdIfZloc8gYWxB4RbeG46s20upwwYM1SiJ1Zp6FMKNwoc
xxuM+DXO94iOg4hb83JnUomhAn2YCHP4pmLd/7CILsWHzBSKZKOJacWJUuKJTfV8qog1rAhn29BS
hOCGTFiIfiaKrkgLk7gasHhVsqhqsVCLgLgYnwWKxohUkZiM/LGMxeiCNzOK0AiFdpeFJtWM2ZBu
1yiMbfeJ05hxxINhwfiN4JiNg2iE3EgKyIiOOyGNl9iOkuiNERVYUCGPmSaBYkOPovCO1YOPgRiO
sch8/Gh71AiPvAYYiXiLh0OQxGiQEMFXCamQtSCQYGaPnqCPVLNX/ViRFhlFgdGQy3Bc6tiIHXmQ
ieWPIdkJGGkJAHkJHMl7iJUvKdaS6TOSm3iOAgGRYSVKHomQ5DhjOPkKL1kJMXkLPtlWQKmSNv/J
khYJexGRW21geVJpieRyk9B4lU2QNieJlXzElQWZlVCpkGK5BF5ZlaB3lj95dWUJj2yZlrmwlrMH
ctrYLVqZjHFJlXNpWaRHXnV4E3kJiHuJN18pjvTmX5cWmJUEkkWJCYXpWUuZVxbHlkzplo65ijyp
bkpXXKpFC+IFdCJ3l9mVmbS4mQupmHlXBSaplrIWG7poO28pJc/IWHW5dI/1kIepJ4WDijhnmkxT
m8zWmf1jXFHjmvxTnGfoK4OJHsLJmarZPp95V5NZdkhHmtvSnLqGmtxGnLBpnLqJnKG2mrX3YcBJ
NM+ZmiMHa7lJXbvJBdeFnZc1mzqSnt0ZnYf/AmD56ZvUop1reHRntyoKxp/T4p9kCKD/pioDupy6
N5RU9piXgKCjiSoLypijR5/XKKE2J6CMxqCiYqBdqKFdoZ9qZKH1AKJWKKKll2AdaqL0gKI9VIos
dpt4pzyxyZwYGlLWmG/eWaMldqMNynLlGGK/yKP46aM4BqQfmqNAtaNYo6JI6kUEylxMeoxOqkI0
GqXAM6UWVqXOKKNi1qNa6kFKymPnCQ7cKSVQOqaR5qKEdKZRcaUQlqVsel8eKphI1otVVpvWWafM
46Y/k6e4o1Rh1qd+aqPleaEQ8YfblW6Geqi+A6jmZBCM+jqFKpqQmqSJeqKCOqQKdal/manA/yap
KUOphog/oLqmorqf8hkWaldWe3qOj7qqLYqSq3BHlZqUHReq2lFmsyoah0an14mYpQpHuaqRRiqs
XhGsYjo7zHqkxwaWt9png7qTcipnmHoVz7qeabStbreYtroDeHSs9vmk2WoV3voEHJauTlB1EVms
b0SuaYqlvDpovnqu78GucUFy69gLuHqqMIms5lqvfqGvTLCu90qwkxSuNKlprOqgKGWtRbpFv/p4
DuuZsZacAcqwHfCaLDJbuooxFeuxETJqGpug0pqSqGanEAur3ziyJ1uyf0myG8qxHECznOOlmQWz
4ymlMxuzNZuyDftqLCukRJmh+KpnPYuxd/+HsyvaqpW5tOyls4vFsxernBkrtdFKrBL5BbUaPQD7
n0lrl17Lns16W2ULrkLbsUCbs3Aqtgq7sSsrnVl7tVs7ll3LrXSLbHpanwIrCwz3cbQqFycHtWMS
t3J7I5W6GCH7aHXzboNLuAJnuIl5tu4qHourF437CYEbRJG7FoXbrzc7tmTLIZmbF5vraTUHuZ/r
FaFrs/NCurS3JKdbFqnrkh7nua3bFa+7tqOLuCiruGG7GrfLarlbQ7vLu5MrurELvBNqusPbGcWL
PKsrbcl7Fb3LtTihcmF5hH1rixPbNMfLQqX7rVOkt7iJvjKRvXi7vfRSuVLAhRILpsnarKz/u7CW
C5jqe7ftSkPLC7tt9r4rB7b2Nr8OWb/QanLWO7sJHLz9q7bmm0TOZqIX170t64Qbd627+rgLjL8N
/Lz7CsHq6r8TvKn7qCrw2yFPlMHhmzOdi7wMvL+J+8DnK8Mowb7vekrcuze8WK2tOK/88cLkG8MR
nL4jLMI0LMHCRsEFZ8FGy0MsTL8Dy8HeRsRHXMNXHHFFvL7/67t20cQ87L0+DL5SHJytAaWSd7nt
u3diDFGdhsaRp8Y5LHc97MaOBseQJ8dtWS5+GL1mhceLp8eXycd1fFFQl79I/HeCTJmYecGm8ISA
rHiLjC59/L38FcmJN8lj9KpJOE8VC6ll/5pfi9rJPii7yRvKA9appEyEpry7qMxgqrzKntzKrfvK
5jnKsrxPn3yotvybuJzL9LTLftrL/cnJwGxGwlynxFygxnzMEJPMbLrMVOp4JVjN3kR5PWfN2nxN
2Hy22/zN0NTNH6xJ4FzObyTONoy25rzO+4LOW1xp7BzPFQxLgifP9izAxoevVonISku5Y+C88HyX
qmfCYKfPdDnOI0qqTWGZjBzGlcel7jLQ8smWTAHQqfVHEs28nGTQfqmsAe3FqmDRFLZ5HK3QzCzS
FrtnFI2nHn3Rh5TRAEwzMA3S1YbQT+vPYoDS5EzSKC3NjJN8X1x8qXR8zdt8+SzUuMdKS4hK1KgB
1HvE1CgA1XWxfCdseLIk1QSH1EOH1VFr1PTs1FEN1lM9gCpr1Uft1Um9JkGK1kOr1lct1lnN1sLH
1Ycr16RE103p1mYK112t1CL414Ad2II92IRd2IZ92Iid2Iq92Izd2I792JAd2ZI92ZRd2ZZ92Zid
2Zq92Zzd2Z792aAd2qJNAgkAACH5BAkHAAcALAAAAAD0AV4BQAP/eLrc/jDKSau9OOvNu/9gKI5k
aZ5oqq5s675wLM90bd94ru987//AoHBILBqPSBxgyWw6n9CodEqtWq/YrHbL7XqvybCYZh2Yz+i0
es1uu9/wuHxOr9vv+Ly8Ou77P2V6goOEhYaHiIlufH+Nf1iKcAGTlJWWl5iZmpucnZ6foKGio5+R
i2COqTyQpm2kr7CxsrO0tZatbFiquzmshbbAwQEExMXGx8LHygTCzZ2GurzSZFeHztevy9rJ2sXY
35XQqNPkL75u3N3G4Ozt7u+bcNHl9CvnrsHqyPD8wF9R/UDJGzfin5N6Q+6xCciwYSyDTzzpWxds
oBUTEJkgFKJw/43DjyADVBFAsqTJkyhTksTSzyKjClhUypwpYN5Gc9UkhdzZbyTNnyhZ8nNJ5UJM
oEht3mzRUQ3PbxOZBfSJtGrSKsKIQrTKdabSpfZyvnmKLSpDql3TnrSS9U3GJmrjriQINmygsWTz
fkMrt6/Mt4CX+B38l27dFE3T6F3sjC/hx4EBP55c8uvhE4nRMJzIWJhjyqBDix790/JlBZGrxOkM
LyoxUEdJy55Nu3bXlxtTU1nN2p1rqZ5i2x5OvLhs3D4iQxaL7tPv3u6E1zZAvbr161aMU77O3Xp2
ysh7KCec+Qyo59DZSafdvf137YTbd3+/fEoN+vBVljeTvj+l9f+zSUYcgPq9BVp4OFGRH037DeCf
fwSSJuBwEQZlIHhFlVDhbboZxNuDlaAHz4YCFGDiiSimqOKKJi7o4mgsxijjiQgCcsVkHXqoE4iU
iPgOiTMGuWJV+A32WV8klijkkibW6EGSRBrWQYNznOcaj6IAyeSSUSqI4RQH3vjTlks6yQJgMlCJ
CJYNHUlbjnCmhqOUOqAZg5rWsDmVl8bF6eeF5NF5WkFv4WLooYi6JeaLjBZn2qB3FpropJQaCmWj
mAZ6EaQoXArUn6CGKuqopJZqJ6f3LWqkqay26uqrUtRnJqpIeFqaoA7Y6hWuqui666Yw8SmrFLQS
QWqqoRaL2qj/hIqqLEfMUpOssseKUO2z2Gar7bbcduvtt+CGK+645JZr7rnopqvuuuy26+678MYr
77z01mvvvfjmqy2s/PbrL6j6rvvvwAQX/E/A3d5V6cIMN+ywHrMiTI/CD1ds8cUPRyxxEngaoufH
Q1H66MarMDcIyJ34WAtnH4sDLMlBdIwXypqoTAvLerqsMcw2NJgOzjQH3ZBWxPIcs8kL5XOl0EwH
RHQURh9NcdJNBx2ZREsD8zQUGL3Fs89VWx0Y1mZVpOjL1noNM9hhQ+dmXELBs/UTRqnq18jost02
a28fh5XZp0xIIa/n6r132T0JO+DfWp8N6OBot2v43ov1vWDB/5muRbi5k1Oel+X5YZ55ZZuX23k7
QFMO+uisv4h34Uh7FFDqe6/e+u3avQ7WqB96nnLWnfiK+/DE15ThTbzv6DsnNmsivFzycVdk8SZF
j53tXRYNxHirTi072bQvj8nzcVnvHfasm1/d9Egenyb6kKumPPPAi58J+WoJXtul+nev/fuKw9Tp
7HcN/KWlfwGy26+28qX/pY19+SNY73jUPHBoiUwzop4GlYRBGe0MAwY0ScEmCKIK7kWBKOlgBjdY
PBV60H0PhN8CYfik2OXBSohr2wVdqKLsxaqBUQgTBE3CQxZ9sG5D5FDkpmRDPOAwfEzbYRFP5MMg
AhEKQgTdFP9VdEQNqQ0GMiMEAWkhQ00ZDFRzWmKdvpgg+VFqjLMoo//O6Kc0djFcgMGYHvc4gBCy
UIClI5cf50LHQhrykIjUyNpQCLdEOvKRkHTV1xgZQTU+YJC6a8QgjWfJXAXwbnfc2CYzeQBMBtIP
owykHAtDw6hdMlozgCWtrgUCWroSArYEo7OoJUsb7fKWwAymMIdJzGIa85jITKYyl8nMZjrzmdCM
pjSnSc1qWvOa2MymNrfJzW5685vg1GYkx0lOOIWzV+VMpzohcs5UrPOd8NRCOx0Rz3raE2rzhNZu
+MjPfvoTYq3MZxul8M+CGvSgawilQGNIUIQ69KH8VOhCIxD/xpPBEY6VIqVAKyqIi2JUZKfMJ0f1
4NExZjSk8xzpDUtqv5N2cqILUKliKGdCWEDRPzoLKEwZIFPN0LR+N9MHyHJqn51KoKfm+WkOVybU
lhVCoyJt4h08V9NsNDVnT0VpOwdoU6Cy9KsBmNtBjEpRqSYVGFUFq0kdp1OyctWqS1UrS8XaBLKW
1XtOkaterva7uM6CrookwalEaVb+6PVzY+vrTWEB2CV0LSOTxOtMD/sUvtLPr7JoLAAey85FStan
lP3RJ9vHlpAF7oOpfCnsPnvW0LZjlQUqrdzYWtRgJZErUMVjYR3k2uiMVi5xe4dmkQhbzamWc7vt
rW/BdEUp/7SEtg48KiUPqNVvvTVomFXPbxNIhbacNiN9qq63rkuz7Fpwu37rLuDawL34tTVvyfWd
eU/I3PCqt3HfZeDijmu6+FJ1sfT9IetEN2DxJsy/yg2wFQs8sNvlFlzkTXAc0Xs5gjnYwNyKsIQf
QuHQWbh1D7YugltzVc8V948ovgp/BTnidwBYaCdOsYxZKdEDs9aw/Xix2Oo74x4Pq7bv0vDPtrG8
GPv4yCEmR/Jm9tW0/me6R47yfqM7MVGR0KNOnsQmpcxl6gIZIUtuDlizLBIod/nMoqlxGMKMjzF7
9X5mBo36qHPb4c3ZAHXG7Xtv0F7SulHMii0xS7cMlDvn+f92hjYy6b4snsD8uKFMvqyOixzn7cz5
0K1LdIf1zOiSIbCSf25zoNUhV0L/RNM8RjGqBfzoscZS0c2NwpU3HJxK29EgjrL1p4G7Z8TA+tZT
mDWtOWFqBj1uf7o+9hy5Jq1UA/LGvB12KIo9w3/k+tC7bmSnfanoEc6vhG9WsLNTuEUUodlF5UaR
ml+J6VsNTNjpIbM/zJxuKp47P/VuUq8zkNp/wRs68raFFLd4b3zne92ebLex3/3tBwW8FgOfYsHh
k+8CILwB/d63bUPdUeeEe8eWq3gVsRhruJT8CUCp+MV5autPYZjl0K6Sx+eLsogXceQoP7lgdG5y
mqhc48T/3bSKV45UHI+6Gyamd75x7oQsLrjVJPf5wYFugYxvewNFj/bRiXw41/ya6fcct7ZXzlmD
AJDjh5K2loXu9LArvNpUXiNkIxXzNan960N3+9tpTHVdzt3vaDeU2sss9jfp3epxV1ceIcp4QlB7
4mbse5Al1fjK4+HxkAfliuW1eMt7ng6YzzyvNx+vzn/+9NB9uuifTXZe7j22h4+97P8V2W7P/va4
b1Xt2e7u3Pv+93HafeG9DPzi+wvqdLMm4vG5cd7DnflVfn1KSPlr0pNs+cxu/vA53frttbz3Gq9+
9zn//YWjtvx8v/ousJ987ecc2Nk/Zi4H6qdnzZ8D9xdm//7P9MtZ9rKG/WdM+2cXAON60xICA2hX
CriADNiADviAEBiBEjiBFFiBFniBGJiBGriBHNiBHviBIBiCIjiCJFiCJniCKJiCKriCLNiCLviC
MBiDMjiDNFiDNniDOJiDOriDkmN8PsgvPEgBPziEkhSE0kWESPh/RghzSdiE9beEuOSEUmhOUMhu
U3iFgVGFVoiFXGh2WohxXRiGB6ODdYd6ZniGuSB5HFiGaNiGaDh+EMiGbjiHnweHDyiHdJiHjGeH
DoiHeviHB8WHDeiHgFiI/SSI4ZR1Mjd4POJSiBhNiigHjIgljqiGibhbiTCJjQhS1rdVmGh3mohT
nPiI0P8Uif8WinlRiepnVKbYcKjIGKqYeG71iXnyitARi9A3iLToMbZ4i6NoieCUdf9Fc6MwaZ1B
VLLIirsYaVXzcFtHEVjleC/XTcKoVMb4RFzHJsiYiwxYjV1HjKJwjYuxjfGni4T4PW3jjJKWjZSY
VZ14Tt6Yjh8HV0jnVNL4jpd4jnn1jeIIPuy4ifdIis8kZDPXj714jKlXjt3YYrOgjgepF8MVhwwp
Cw75kGQRkXc4kbFQkRbJExjZhxrZVeDYkQiZX8m4UwTpj6RGktHIXtPITSn5jMowbJaVignZfs3S
WdcXkvQoaMpVkxd5k66Wk164k9DGkvMGGCpZj/jlkvj/eFc6SVhHiZQQl1jruJJNmYZPGYV/J5WB
N1lUKQtAmQkciQkfyVBjaJRfCVphyWFKKZP7kJUJ9ZJMWJReuU/M2JajMJaYUJaXcJYIyEYSE5N6
WQl8eQl+eQtCWVde1JWDyZN6KX6yJVyLGVhCiH7pd5LINZWFCWfbR3z3RZkmyY1Q6XzPp5DsQpgP
KZmMI5pOeX7SZyFbuS2qeZCsGZruAJil+ZlWkWQ2tpat1ZmWcJtT8FyjiZpcaXuzuS+Q2ZbE6Vym
9Zrhh5mwB4wZ1pxhiXcuh5u0oFmhN310aX/YCTIj6RmmGXkAsV5aqWyGt5zZUpv+UZ7BoJ3gl55y
qQZ9/4Zs7okt8Nkf8pmUrDZl0HmfaZCf7SmQuzOeH/OfAneeywYF3iWd+uVeq7hawGl0/MiU/ECf
5jegtuCd1Dl6CLoU/RlvBlmVvJlmrVkLIBqboGadtKmgesKgKBqgFAqh6jmX2TYavnmdnGmNGjoi
Dnqgxwdi4WmAeAlowmmYQ8pd/3Jh+ymeP7qkT5aiufNhDDaiyCOjJMmhflakWQqjzDmlVEp4Nsp6
YDo6PRqjZEqlXiqiaZo5azqmF6p1ZWqmqpcpBKamR1osJdqTyyA+b7p6LjKn78mltnCiPDKohOph
UYqkweaK7KCoIMKojXqlj+qniMpUWJl0Vnqpf2So/P+5qUHVqarTpKAKpVqaG6TakD6pQ6iaqmFa
oZtZp5vxqmFjqbJqG6IqpbY6O7haNbq6q0RKqyRqZZI6RmT2ncQaqmJ6BGxGNU02j0zqos16ZquK
LKByiuKzrCF6rWiWrc32J9y6PN5qreAqZeL6aqFSrvJFrVU6rOmqqqRZDtGKjix1rvI6r7OKk2CG
rHl5Ufoaq1yxannKQgYbdcjnWFLDni8aqQHbl/BKaehqFQn7fil2sU3Hc/rksKAJsUq6CYlZc9+a
Fhrbcxl7ac85lMnhaOgJaSFbMxPrO8xaPSpLsPlxskzQdv7aaDvam+M5siBTsyWhszsnY0YLADzL
sj7/67FK9KtL+Y8eRbQkkbQ9ZrUc27Dg9bKylqyWILQfQ7UCgLVIe7OfmnfIyRT7up1QC5fQ+FVi
+7NLq7Adam1Zy65ne6NdG7GIObOeOqxyy3M7i7ZfMLeWSXd5y6utmmBx67Qqim2O+7E9S390i6Yg
K2oW2bhbq7eVm5leYLgMi7dnyih/GlqaO6H6aTuBC7TGihlrW58wi7mrWbJPi2sCaqWrC3ahe3aj
6zqL+5O0y322y7kYW7eFe7e8e7Ck+7u9dbrDq7iQu7kLu1mNqZz+5rW9AbafYHM8xK+E8XOtG3SJ
67mv4q6Lob21dlsi571+Ab6ayW/BS0jXy7cA57cN/6q+S8e+feG+9QqA1usv5qsX6Bs8Sldv+ru/
Uxe+VRe/nDS/MRuf9lujFLa+B6wW/Ju2TFSxDQzA2MsaA0xsBZxuFRwXFzy53IazG9wvAZwXH7wJ
3OtCunu00wu6KCsTJcy0tcTAohqPVxmkwhrC5RbDSstzNDy4Uldv3cd+OIx/y/jAEkujEALEBEe4
xfugGwt/ynsSN8yYgqXDdMnDIhvBlSrFEkfFVzzDgivDNpzA7wtCXryfYCyzUNwfL6xCQkzDRazG
KrHFhxuYGrzDTSy7ckyp6VHHHXTHRJzGQ3zE6ZbEb0x0gSytPSy1UUTGN2fGNWzFmax5WsTG/ZvB
//+rwEdYp4vott5wqvhrwJhsxGiMxZ1Lbkj8rHU5vtUpyrsZux0XtYEKpOqwtkToyibcC4Kptvro
RHeHwsJbfMC8xMLsmMRMyqBIk8jMuj64zFzcss7Mf8U8VcdMy5xsfNbcxzswWC7QivRrutMsxLIX
zrvbtHZZzpFMUt3cu04KztMrznIXlfC8zXYweL48hOxMvfBieobYeGI7r73aXxlR0J530Oma0Cy2
0AxdeQ4NrhA9LgQ90Q5V0dd60eKS0RodiAw8wuCZqRhNeSG90SNN0sa1rqgC0in9TxzdrB6tWxId
0wg108Ra05CixGL40/UkfPRcu0Bd1Pck1FksuUb/vdTrhNSv/LBMHdXl5NRVDKdSfdWRRNVn/M1Y
3dWJpNWbPHZevdT33M7S5NP4vIXe3NKy3LEoTH3TbNKl98jTWbE8Pc503bory8zBhNZmfctJncxt
jEp53cZ7fc0CWNifDIZ23ac/4NcCfZlrXdK2/Jh/rErxe9f5/NaYPdmyWdkIA9niO9TU3NZa69mL
BpukTdQYDEwJ6GsH6H8B6L8FiEyv3Smz3dNKyMS5PUy37bqx/dK7jXXD3dfFTYB/4qu1ncO9rX/H
rQLPnaDNrQG/Ddapoa3JDalP6MfBXUzVXb3ZranTDb/jfUvf3cXdrdvlPdrB94Xu/d7wHd/yPd/0
L13f9n3f+J3f+r3f/N3f/v3fAB7gAj7gBF7gBn7gCJ7gCr7gDN7gDv7gEB7hyJQAACH5BAkHAAcA
LAAAAAD0AV4BQAP/eLrc/jDKSau9OOvNu/9gKI5kaZ5oqq5s675wLM90bd94ru987//AoHBILBqP
SBxgyWw6n9CodEqtWq/YrHbL7XqvybCYZh2Yz+i0es1uu9/wuHxOr9vv+Ly8Ou77P2V6goOEhYaH
iIlufH+Nf1iKcAGTlJWWl5iZmpucnZ6foKGio5+Ri2COqTyQpm2kr7CxsrO0tZatbFiquzmshbbA
wQEExMXGx8LHygTCzZ2GurzSZFeHztevy9rJ2sXY35XQqNPkL75u3N3G4Ozt7u+bcNHl9CvnrsHq
yPD8nl9R/YTJG0fhn5N6qe6xCciwISaDT4DpWxdsoJULEJkgdKRw/43Dj9+qCBhJsqTJkyhHYgGp
yWJGACljyhQwb6OPjmpY6gwmcqbPkyt33nrzksnPozQJ2lxVTZLQaxOZ8aSCtOpRKwxdUrXKFWXN
pTtwpnkKdaKwnl3TlsQaUOsUtXC/Mi1adIRYNGTzkkILt69MukX9Co4pNyzgl3abvtHLGBTfwZCT
HjYYubJkRjCwWP45GWIchmYb+9u6ubTp06iBKm0ROHVawJ9Fu4tKTLPr27hz615LxUTr3ZzpxpbN
jjYB28CTK1/+uneQzpSvKkb3yThxWchxG9jOvbt3K8wFex/fHXxlzD+g/0N69wwo69dhZb9Nvr75
8HDrk78PGT3YCf/qQTFcfB/N59pvuBlI2EuW+fdfBAE+MSCBDSmIGoK3WegVg+c596AL7ZlB4SXw
1aIhSQWkqOKKLLboYor4xVjZizTWqKKDH6YQ4gAjWlIiLSeOZOOQLrL3mGBH+hWkkEQ2mSKOOZ6w
4xzv0dbjJEs66aSRpPXX5WBLCqBlk1BGaQFsuHh05Vlf5hbhmxBFVpiZIqCZ5lhrTvVWcnD26YWc
q9F5pp+B3GnooYWEKeOiXl4kqI6EVoHopJTeoSijmMYV6KMAXrFbpKCGKuqopPbJKQqXNlrqqqy2
6uqpNqU605wNyBrcFGAl2ZejFdi6IK+wpufnDITCGqlvwwYLRLH/MjDL6bElOKvstNRWa+212Gar
7bbcduvtt+CGK+645JZr7rnopqvuuuy26+678MYr77z0EuHqvfjmi1i94err778Ae8hvsIVWavDB
CCdcR5kD01OwwhBHLHGlDDecxJSI5KkxP5TSarEOGE+4MSY/1hJanuIA+/FN0w0yMicl03LymilX
vLINO6Yz88s8Z0XUpjcr0fJC+VjZ89Ft/axy0CAPrSbSsgEmkdHAuCUFRvsyDaHTOUEdNV1TRyWQ
0jYvUJfWWz9MtNcO6aopW3lZvZ6qAvObM9ttt5lgFT6fwt/eS9N7N96TiK2nFMrBzTHZeqfm8Q3+
Jqb204T34zZ+//pmStLjOOsruaROVW554zFmrvlldbPm6af5ijybPhtffvrs4XGO7N8Zth46Pzv3
KDvtwANne7QcZmondaJ3YpyvwTc/e9k1mH4r6IslrzxtzOen33e/A78996SrBT01+HJJPfLXG453
9mp9X173tLvPHe5Kpv4ooa5nEjPS7DeXEev0SwmGkGQ/QeFvd9bDzurcVDzAwQ91cwMUrto1uCvt
Tz4LjMmYiOS8DoppgzYa37cq2KMLviJLIKyRB52XwhqJ0FshQ+AmTPi1AJakhTQyH+I6tCcJdg+H
L3pht2JYPU/QUDQoBCKLdBiFBoXPfz9UIouEeD/hUCqBnHigE/8DFiEfFnBcxzsUFjehRR5yUT1e
nOAQ7zWxNt6pfyvE1PAIlsHcnfGOeMzjqyhYxwPp8Y+ADOSb3AXHzQGNAYU0CRX7UMbpXW1QNuTK
HFeWSAg+slORhGIUcvXErgQOApWc5MekZY5kPYuUIUAl2hBpysy00oCqBMQrV0nLWtrylrjMpS53
ycte+vKXwAymMIdJzGIa85jITKYyl8nMZjrzmdCMpjSnSU1YCvKa2MxaNRmZzW56kwvbfMQ3x0lO
NYZTDOVMpzqXcM5lnc+N8IynPC31xXaqYHLzzKc+97lIe3oAn/sMqEAl1k9/1opruBijQifRsUMa
lIiEWKhCG/r/SYMqAKIukygWKVpQaGJUEBrd6KREWc2PUs5rRyRF70ZUs3o+FKF3qFxKR7FSCrXU
nBY9KEBjSriZiqKmBLrpJXOq03eCtKdUswVQ4yPUTRLVASSUWVJDSlVKyA0KT4UqTN1TNPVV9atX
fUJWi0qF/MXCp19NXlgPMlazbVVEaYWH1JQ6VVqstQlYy8guoxrXkICNrl6txV01AkmI7PWtPOqr
O+ZqsrrOYrDsLKxBDrtTrioWSJ2U5BUYA9kspHGodkPsZU2UWasEJW6MixPdcFovvo52FI30yWnJ
0tlKWtKpDXPta0MR21ltVi+17ePbOgpG0YousJjtIQCn0Lc2/9iWiaBtrXFlutQTlnYzioNHZ4Xn
0Hfp9mjInUVvCUiF5uZCuI7rbvQ8V6fpIhV2h2vi6aRnPPWSD1+fK6sMdyuK8fqxfPO1L7HYm0r3
8he21+UugDVH0lLmK79TMOuBO+Hf9C64vhWFVCZLoy8Jf6O6xKlwHEcMXdw6WMSe1F0ReQffPKGY
xDD+FXE18Fzp4MvD2ABxDXcY4x5/1sQgaiCjwrg2Fqsjdgn2sZL/ImAIfyHARcGx9ZaH3iVbWTcz
JmsE5WjFFYeUyhu+sphNk2VW/o/BXUbfl7FX5TG7mcMuhVy+Siyg/S4UzC+uivy2E+b47bnPVSmz
lOZsY6OedP+Gjj1ajX2yZwMA+nSNfnShgdwD+vq2snA1YqJ7tuiZRDrPyvl0kh2J1ecQmtRSkHLh
Ns2zTstE1ModMax5vFqxKuuAXp6wq2UcHQYCeoD1Y20V+6TquO5agEL+7wOBvas4fwjXatZ1m+F8
Zl8vO9nBji66vksctI5mw1Jc4puXE+4VCfrWBh6Rtyk87Q+WuwDjJve7C3BuOmI6sWtadxbbPW94
xzs5/a63sdJNIX2Tkd/z/jfA5y3wU5m0a5oO73WSWG46N2GLtAZTu91d7oaf8t5Uqg6rkYjwd1vc
KGaU74+h8JOAO7tfiA15xHWsF4qH++RLwLjKa71zDTL85fb/fok1RC5xTo+a5+vMeLMzDPMoX/Gr
oDZt0m3bYGwR+U5pjXqgp75xnBM2XVdPU9aPrnGug7rqdLrxQNe+XYVD2ePDbhXb5+7crrtdwXC3
ptzpznc0HPvu4msyDNnY98L/HfApZjq1Dq9Iszv+8aQipN2RDvnKW/4wkpc0eS/P+c6fjV2M543n
R3/GcIZSvTXOe6XJzmSgn17xTHt92VIPdI6wnteUTlue0S5dzeNe21r1/aWBTw6tw/6ikx9+7Uc5
S9WZyuGx/Gfzaxn9e04/R9AiQfXRtn1UXf/Z3afx99tK/vKb//zoT7/618/+9rv//fCPv/znT//6
2//++M+///73z//++///ABiAAjiABFiABniACJiACriADNiADviAEBiBEjiBFEhJpHeBo1KBoISB
HJh9GmhmHRiCEfKBwSeCJtgZJKhlJ7iCepWCbsWCMNiCLngAMViDkzWDNGiDOghODwhyhfeDQDgH
qvdUPhiERniEaTCERFWESNiEQaiEOcWETjiFfAeFFiWFVJiFA2WFL6VfWviFR8iFzPRweDBhL8NR
y1dSMZcxZrgxaChsS7iGQ9eGKDNSgqeGWJhRdEgzdnh850SGPLWHV/KGxHeFcmgIgsiHiMJ7HnWI
v5CIPUKIuReHeXhUkGhTfSiGvASIlsU2BgczLaaIg8CIz//EiZnmiSOnUqE4iIlyh9RkiviGikUH
CzTXGE1VauQHi9Q1i9mwipHYin5oeo4YbUjziYh2ZBpzi7aWi8OID3hjjJpQi5wFjJpIWYaWB7so
jUTXDW5IjWn4igQnC9B4iaLRWebHbarIi+QoiucVjNuEjjSViuv4i35TjboEjz8lj/OIifX4jdOE
j6EwjvtIW6lViF14jXgSNto4kOVYkJMYheF4Vvq4Pn9Vh/0Ihw/webgEkFWijpXDWOy4BnOkkbfE
kQyJJRUpVR4pCuYoWf9gjV5IjCd5cEWhkL74WA6JiwWhTSUZkTP5ECk5CwJ5CS3ZKzxpSyZ5kiAp
lBMJCkX/uZMyuJE++ZOWsJTi2JSlkJPLCJWGdY9TSZWUYJUSuZKh8JSY1JW5lJRtaHy/hVoX2Wub
54/mopZmyJbZJRTBJXzI5o5g95WXaJd8A1xa2QUrt5WhdW9gGQCAWV6C+ZZblm1yWS50KW2xFpfM
1Zh1x2yahJHxMpkHtpiX6ZaZiW2Bx5fb5peQqHVeZ14imXxlZ49xF5PO+F7ICAyqOWkAkTSOyXLL
FZnk4pkEQpaOcXtLF5qLs5tPgHe+WVyIeVwLyW6V6UBSwJpqEHqiB5t6J5tF9ozPuW/RaUfGqV2D
eXG9yZnwApzxIZyfcJuoFhG6OZrEuZkG6V2ouTHq+W1K/yedudkPbYdlrngu6Hkd9wmd+Qme0/me
7cierYedEhA57dWc2ciN8cWbaHZhXGaaQfZgD4qQeAGWCgqZrfI8/+l8+LWh2nloDPmhxekqIoqh
JHovThYFxSaIKjpcLPp2y6lhJVpgEPqTNVqa99KiDKp7MGqiEWZn+/ij8rkqQpqj3qehPMqhnTiT
Spp4QYqj5jloemmje5drr1Oba1KliMcnI9oB1mlIauel7dCdQiGmY1qe83k7bnpbpTKjXSWhLhaf
b9qkWSqneqpZaSqTxXGTFDKne2qgfUo8WwqkrmKnNrkMSPadh9pjpCh+i7qkdYqk4MCmO2Gok3oh
ZcoBmv9ZO2k2m/DAqTrhqZ9KZqG6AaPKHGGXkEaGp2H6p6sqI5WaAa+6HLHaoVWFZ7Z6q1ZWb7ua
OKW6nRoFrJIqrIdKrKSJOceKonfGZpfKrFfmrNV2oUKnqVikrAVqrXuKraqFYduqpgrlrT0HrpMq
rnA5ZNEKcVSFrhSqrp86pEQaoriZatyaQPKanLoxa+m6QgA7r6/5kL1warJVn0NJctVqFQPrrzD2
sE6gczrJMha6oCcKr6A4oIXqmn4hseQZsX8Gmoa5ehf7ezK6r5SwsI1xpgIAsignsvLTsNdpsEJz
snsppae4sagaYh7bFzCbczEWtDCRchVrslfannVmriT/gpW+87PaM7OqOhhES7ElOxdJm7A9yrOE
anQ0ixRVO7QjG6wq4aS8AG2meow9u2Nkq7RcAKcBm7PtaplXm50RUmwsyxguW6xG+62qka0Fe7Sx
ebcq21d7+6ys+muIi6lfN3DEVrjGBrWM+pjKdl18u3WJ+h9oi6yfKbmMuwVwS7AYS7kgWrdp5yeO
SlWHC7iVu6yNx7qly1ZB9yapG1KrO6766bevi7uBa7pmsrnSulu3O7cWdm2wu6KCC6D1qRd5a3NS
RK+C4XKZO0LLmxfNW3IVB719Ib1x2nQ6G4sl5LQ0+Tv9pr3b+3PTO3hbq27iqwnOq0TmCxfca7PM
+b0a/3O94JZw8ZsW85u8c1m9ZIG/5Ku/+8sV/eu7vwnATyHAnVS++QqxlCe0hTmxPnHAsrsuuriN
Xcu2rstxN/fAFDzBRSvCMSsTFoxXfFSJwdu0HMsS7wtEXme1IRzBIWvC6Nu94JLBM7fBDDvAJgfC
Ndy7MyzEoosSJ9y46qLD6bO2DPHCOBTDfTvEdBu3JnHEkQV6zci5+tO+Loy9H+y2JUzEYTzFRXwS
VgwAmfe9MrfEPNyyXvy8QDzGsRvEZAzBPvdu9lp8WbzCPsLFIOHELQTFIizDZVzFN0y/09KrdtCR
TOzGHZy4o0fCV3yaTjcpY/fI1BbJNIzE/1vJiHLJuv97GhgoyWicxO+aUFDXtl5ndqSMwafcCqBM
xcXrea1syp4sRqmMydh1gbXcl7dsKLFcyLPceb3MLYEKhm3ksuCaq4tHeMjsRspsrcycyM78zBMT
zcw6zejWqNaczJ5bwMqXvmvEzd1MUN8Mzijrv5JZzeWsMNgsrNo8u6vSzub8tehcs+JszOxMzwfz
zrcazxsxvDs40OSUxqoczgSd0Nlk0Lq8yQr90KWXwm4K0RR9TQwdyoRc0Rr9Lxcty5m80SANMB0t
zB8d0iYtjDRrO7SXz9zU0HE8yWdpqwDtyimNeue8u4isCiSLwCAo060qL7LnbCuNw7bn0mAM0w16
03TQytOxp9QqrdRoStSNsNMXHNNGnc5MHTRB3adDndMJ0bZ+uNVSbTFijchdrc7SQNUobJQ17aK9
BycD9nwfJ9cxCh2bOH5PCtfQh9eWqtdeSdcnNkh7DdhRKth/7deuRNi/G366ytda7diKith2O4KR
bdhpCdnah9kI4YFGGiB3rdgswNixItp5Jdk9Cdr2oNn1wNmFTdk4+NqwHduyPdu0Xdu2fdu4ndu6
vdu83du+/dvAHdzCPdzEXdzGfdzIndzKvdzM3dzO/dzQHd3SrQIJAAAh+QQJBwAHACwAAAAA9AFe
AUAD/3i63P4wykmrvTjrzbv/YCiOZGmeaKqubOu+cCzPdG3feK7vfO//wKBwSCwaj0gcYMlsOp/Q
qHRKrVqv2Kx2y+16r8mwmGYdmM/otHrNbrvf8Lh8Tq/b7/i8vDru+z9leoKDhIWGh4iJbnx/jX9Y
inABk5SVlpeYmZqbnJ2en6ChoqOfkYtgjqk8kKZtpK+wsbKztLWWrWxYqrs5rIW2wMEBBMTFxsfC
x8oEws2dhrq80mRXh87Xr8vatl9R2LPQqBXdTtPSvm7J2srf7bXkT+7tcNHj8EvmvOiuwevs8gAx
VRFAsKDBgwgTEsQScBS9e0wUSpwooF4+IfvYNNzIsf/SQIogETLs5A9ZsIcQAYRcWVHcxSAZ13Sc
2fAjy5sgrcxEaROnz4MWX/6IqYamUXc9fypdWGXnGyxLowYVk1IG0TRHAZYkJixp1K8KU4oFS1bi
1DBVY1xFk1XeVmbBvJYtKzbl3LsFz+qAOlfvhrVn2gqOSwWv4cOIE/v0q+TKXcYZAJsZTJmWXMWY
M2sGC9kGX7p178WZubWyR8ebU6tevZoRxsusE0oeYJr0WwKfUxvYzbu3byux8foe3hs4XtcwYQc3
OLt2x9u4UasmTt34crLUiVvvS8VRXe6B3oCC7hxY7s12W0vPCdEw8j7fQYdP94l8+Xfr0bdXv51i
+sf/3QmlQWhUjHafaeclVMCCDDbo4IMQLnjdhJhFaOGFDL4noD0ERmHggZQliBCGJELIUn9kKaeU
iCOW6OKCGm7oQnMgbmIffii2+GKJJ6r4k4+L5afQji7GKCMLNNaYyY0BAYlYh1B2CKAVR/ow2xzj
3aZkLE4eFuWXY4FnZJUm1IWLRlsaxSKFbOpHJZk7mHlmUWnStGabeCbWGZwjdJnSnIAGKugZYBZq
qFh89uAnRIM26qgih0YqaReJtnDnk5NmqummnBLI3puVGnGpl52WauqpqELx6ZihWinkUnuOGuQU
R3bZY4Ac2uqfS62+digJmva6gKQzECvsEMaKEOyx/8nC0Oyx0EYr7bTUVmvttdhmq+223Hbr7bfg
hivuuOSWa+656Kar7rrstuvuu/DGK2+ZqdZr7733zEsuvvz26y+u+k4736MEF2zwwU8BHHCVAyPs
8MMQA8rqwlRV02idGA/W6J4UN9awHhmLwmQtpYVMSTigdpzcx3mYnOVb6vjj8iQoT6yyx1V8aMvI
M79SV888KSxBWjfnWqAk/WjZsyw/zxw0rRYQXfQESS4dYmHHXeEJz+Ak/F+KvAZctdWC6TrlFFzL
8rQUsXHM7thkZ2W2mFI4dUqOmbm9Ltxxq4l1nszdCzhQYaMFkVUWi9f3kiWb9/fgLdkLeV6F7/Uq
rP+V/5U4fYvL/fjkoE+ut1qXr5j5gJvz0/lRc4fuumajO1v6j/fq3FDjCM7++u68yyb0C7LeWq/t
AeFeWfC9J++6zTizLXrqaK4u8m3Iz5XdcHgnf/1vrU/E/A3ZU8i39JxAV7188LCG/NfoR+FdmGCz
jFV9SpN/fvzp87foPe79bjj7UoGeTOgHM/IFYFRE4pHyeJdAEn2PYZEingEtozsBNBBDC2TgBS30
wG+Nb0tp81kFN2gh4TnvbFHoX/gKQsIIddBbH1RSCEmBwBY+yIQpzNoUVAgkGz7ohd26khxeZrzO
dY8z/yoUCqHWMSFKkHEFtN/nVJNEJdKNieiS06D/JsiNCmYwebHLolgcxUUcHfGLgAvjtvYHj4i5
0WFVjGMW3HU/JMrxjnjk16r8NzUH1PEreQykIE+1Ryz2MQJ/9F3KEOlFnADRD2dUpCGH1siQqHFh
iRTJ6YZVSRw+oVZTtOMkGbnCm1xSbJECVqag9awZpfKQVHulslbJLFnK7lewzKUud8nLXvryl8AM
pjCHScxiGvOYyEymMpfJzGY685nQjKY0p0nNalrzmtjMZjMHyc1uIkqb7/OmOMe5BXCGk5zoTKf7
zPkIdbrznQBgp6+m8MZ62vOed3ikPGd5NHz6858AHYA+9wkC+QX0oAh91EAJ+gAnGqKMEN3ExjbJ
/9ADOPQXEc3oLQZ1ymtelBAaDSnNOEpRhn50ECIN6UQXWVE/CrBlBpxhNkoCtEJ01JonjZ70ZEqK
ImKsZnxsqUVfiocJ8nQUPq0TUEcpVAXkdIDkO+onmlabpUrhAlLzZQxnGsWUUtVka1un0bohzK32
tH5eFUtN7/bArPbSrEhFq0i/GrKwQgGrhwsmXFPKtFACUmskkSst7OqFJV5VZXvlKywiqUmdBLar
tSBsFwwrVoolVrE09GsAHVs+wXaNrfy74mGbSFS2YNYZjDUdFezWhkxijqX6uuxpQ5Fa2q22I5Jt
QttK2i7Zznaqms1bU3DrtdqakrdvK21gfkuYHf8Gh7MbyW1E1IfclcEDcQZdrlGTyqXgik9woLtp
88hKuuxOhouQpaBznye58FYXCG5FknLPu12aNveEkMNX6MQLvrzeMmdIq6/M7ptDNBoYU7C1XCmP
m2DNmZc2zHXceg9MYdFWVlGdLORoCzpfCEfYjAWusIj/2uDx4leUQY1Mhz/cxQmP+MWzWugKXEs4
ez3RHdz13IJhzGMEb7i8xsXXjduRY9ZluMdIRvFdqbHjlQg5wBspst+anOQqb/bH//WuI2sH5dvZ
tzY09sn2imPc4IyZN1R2cop7UWbKQmHIGTXfkcFy5t2kGXJ1NsCdNVwOZLXZwm/uMl/lvOev5Ln/
0Hk6dJtlbKk/t4+egk4poR09EUVr+XWWdjH+LjwUSitZCnCO6KQvrcP8UbGSAPx0n9uZatUCWHFb
82zfwrzl0J66ya2+MqcrlusY95Nzj5Vyz2jNYFOnZn37cTMTzmlrVQca1sH+svSI7cnC6o/UkTP2
oz8pLUmF+sM19CGDrLwccTeI0dPwdqRZfJpSmnvc5I7Nu2G0ZjipG9rsvkS4zR1vec8b3dXybWWk
Clx3z7vaTuBhiLe98CH9u97hEjhlCO6JfYsb4botdcOd/YSQzLsAAKeWxAdD8U5Y3IcYn66yl6Bw
TSvo4Uw118gFU3JOnNyGKWe5xlW18xM7/N0h/xdYh7FEQGHPzNO7gie1+Quup9Ip1uldHNK9p/Sl
vzddTp8f1I3usqmbpepz/nqJIz7GhxZd2qvbCrX73fNdz0uLgsq3vsPOduGO/V1wD5TcLbH2ujN8
yaT908X2Tom++53jq94btgWf0MbrAez46iMbyeH4yuMB8veS/OIZZfnOzwHz9tK8y8PCec+b/vSD
CLrovV5j0Lt+nHwGPDANn/TX256bsee2Xun+2tv7Po+5T/wvaU/13xtfjsFvwjCJHSvekz7m6cZ2
zuMZNecH7u6HZH5J+656P49e125vqPUph/3VS7/29eY+xFOx6LaOnynln1orPbAsYc1fvrjcff/+
Q1D/Xt1/xrY0fAEICLRkfwPYaPvXVAq4gAzYgA74gBAYgRI4gRRYgRZ4gRiYgRq4gRzYgR74gSAY
giI4giRYgiZ4giiYgiq4gizYgi74gjAYgzI4gzRYgzZ4gziYgzq4g2V1fD5YKjxIAT84hJsShLFE
hEiYgEbYAEnYhIWyhKTkhFIYGlAIAVN4hXVRheKHhVxIDlroUl0YhpSigw+GemZ4hmjQfRFYhmjY
hqenhhDIhm44h5UHhw8oh3SYhwdlhw6Ih3r4h/jEhw3oh4BYiG4kiNmUdXdAeGSzUoiYTIpoB4xo
NY64fvIUiXUwiUtTidDXUphIB5q4VoHCdNL/9IlEF4pgRVLxd4lDhwio6DKciGULaIpD9Ip1pYqP
iEy0+G22WFW4aIns9IkChna0wHXlYVXhJ1TCGFOyFgvG6BzIKHuD2Iq86DI1F23LIIqCQIrRtIxR
1Yyw8Iy+SAjcCE3euFPgyFXroI2Pd3XgdI5pl45nNWCwaFPuqE3w2DnXmAl0RRnRqHtC6F8C+GAx
I45Sp1ZOU1zrF1+7NHNQZJCzhpD1CFqdyAAMqUsOiQn7GDf9+FMKWZGclC/692rARjLyOEEdqVQf
KYtWKJBatWJJE3UZlZJpIl34UH0iOXswCQwbSTY0uSU2SX1j9QU9SJAxCZFp0n7QVRlBaXWr/6gu
GXkJPakkSjlc49ha8LNpINlbO9mLm1CVt9VZMhkLTfl+8JeLIteVXsmP52dJgCWWSKkJZYlokgSM
WKeWazl339d7YWkjJykKcxlk9yhzeJmX7eZziNcEW0eMn4WVveZrW5lcRmmYAtGWyccEi0mPthCY
lil2aCl0k0mZfNeZWgkFU4kJQblbT3mXoSmahUeaiQkArJULZllsdilGremaBwSbJDYFs7kGxFd8
n9lthSmarFeX3kBcFImYdjec0RKV7HacjeWbyumY0kl+zslKxUmZ13l91MkRqUldq4mbJKk6ujkJ
3YmddVOdtEmXfBmZipebrpme05cp+zWYcf/ikghYnjr1jYzZV3v5Xe3FXtlZAheZAtAJImO5WLyJ
GfrlXuOJYTkJPNvZNwsqQgE6IQ9KoLcpodcFZL9mnv6pmSDGcxyKKvcZoaugn/jHn1DFjP/ZXRl6
HRuaX/ipYB+aZZCGb+gYoww6o8tRo4NTjtiVoxQqn/oYl2wJpM8FXifKkkhwoCiQoAdyoZnFpIf3
RUTKT4KpomDook93nruJpVm6QFvKf7XJdFQaYfRZprBzoyrglCG3pszVpm7qoHCKoGmap05VoXlp
p3eqJ3x6AnLaoVsIplonpoAaqKTipQDonq1nqF8aov2pm4vKqG0njSsKqd6ZKtVYkNvgHMH/ialo
VKBDxanqiSqfepShCma1SaoiZqqFqqrrhmM+amSoCqsjJqt7amO1SmS3OmWXqqt4wqu5mm2e+qvf
oKTfMKrECkaSSoDH+mQ8qhXBaiev+qylGq3016vDo6zYwKzY4KwIkWnMqT11dqxnmYyP2qXfWq1u
ca0zQa4HYa4bpzz2aqKAtmzeR6a3hqimdVqj5q90lq7DGhX52nGZyq/zdK4CCrDahVkD67CIkbAJ
d2AWm3ErJ5TWRbE06qeiRj3ZChIZq3JoVLI6t7FEoK4bywSrOqIlQa8GgbIqgbEG26DTya5shrM+
RqkvqlgTe6+KQbMURrQLe5MNK7QP67Nh/wq0IsuyJHuzBAs4Rquy/eqxQQqyEBW0+uom2vamuJZs
+4q0kJSVvcm0iQqX8tp1I4t+3SCek/e2R8ux8GG24Adq4EoJpwkiMpuqXwC3m9dsf8ewZfuYtrmj
JemXVjpsbSuc5AC4TGq4kAmQdSu5aoakGvmXbAu1bvu31xa5Yju4ZFu5oXu3HpK3k7C3B9K36yq3
/xq4X5uYzBa7roa2AYuN62hArIusrntsqFa6stsI8VGapwuvmbu4RzdCB6etYPFxpqpKluuWmCuV
mmsyN9dCzNu8MAellXJviUt410tC2fsVzsut0XcoL4tZ4btB4xsV5QufMuK9IsqI63tB7f+7FO/L
vYkiv5UKvsr7bverFPmrs/YWQahbp//LbwHsEwOsqf5nwMabb/XbQNPXcgrbsik7EQ1MuawJsfQV
MqqLngl8cZc7tY7btcSLwi8HdOYbcFp7HyE8pj7ycRU8txastCy0vQRMdtNbIzE8wQlUwxt7wyqs
I+b2vEJBp1FWvSY3wihXwlhba1F8uFi7wcLXwbYbsRnzw06Mc1CMw7Y1xfVZsxRhxcrHlT2soExs
c12MvV9cxKarsWMrxxJhxoQLlS9cHjEMCm06hBhMt6hEiPOrCXvsqiYcm5D3xzeTj4S8xhjTxz+o
yIhFjQdcyLlzyGf7e5IceB4MimdHokb/xLNhbHybbFmUHMGWYMnHI8qTe3uljEmn/L0PubbWy8pS
TMpzfMeB3MmniLutOm22TMWunMujS5iMB1KfnLswuwy7S6xnmi15Byia2My6+szYEs1zMs2Nu8DW
fC3YfCbazLkLzLv6i3dlt0WTSM2w2s3W8s24EM4HW6bs7MLHrHfpvM0BPM98ErdfYIh1KIbfVDT8
7AX+7HgAHdCLDLuUV9AJddBSKi8D3QUM3dAOPaGTDLqiMdF7WNFGetFTXM8aHYgc7YXyp9DdENIo
XU9I7NFg7LcEndIw/TArjcWYPMojfdMVpM/jsrs43dOjotPiwtM+PdQZCtQ83J1EndRw/+zSDsxL
Qq3UUE3GntnC8PLUUZ3Ul1nMb4XPyHnVPZ3VgLzVudp8LDvTRyrGb8zBUSjKRt10ZknWYNnU5hDX
cj2pNc3Uau3Ub7192WrWrnTXkbrDp1pbbe1Be4196ge/rIbW0huZ2ueob3fYzJPY5bwLdJ3Xh8rW
g2rOY83XZU3VRXDZV7zWgE3Ogi3QB9gB/dcq/xenqd2Qr+1grU0msz2lsZ1LtY0Bqx0quU0vSijW
hgK9vb0hwy3cT1iUwd0nBfjAv92ixz2Sz42mk6Kdzd2u0T2Qyc2ltw1B1e3a3Y3b263by83a4e3b
2f2F6J3e6r3e7N3e7v3e8B3f8j3f9CZd3/Z93/id3/q93/zd3/793wAe4AI+4ARe4AZ+4Aie4Aq+
4NqUAAA7
------sinikael-?=_5-14763587882000.8241290969717285--
------sinikael-?=_2-14763587882000.8241290969717285--
------sinikael-?=_1-14763587882000.8241290969717285
Content-Type: text/plain; name=notes.txt
Content-Disposition: attachment; filename=notes.txt
Content-Transfer-Encoding: 7bit
Some notes about this e-mail
------sinikael-?=_1-14763587882000.8241290969717285--

167
ses-lambda-nodejs/index.js Normal file
View File

@@ -0,0 +1,167 @@
import { S3Client, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { simpleParser } from 'mailparser';
import { gzipSync } from 'zlib';
import { Base64 } from 'js-base64';
import { createLogger, format, transports } from 'winston';
import { config } from 'dotenv';
// Load environment variables
config();
// Logger setup
const logger = createLogger({
level: 'info',
format: format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
format.printf(({ timestamp, level, message }) => `${timestamp} ${level.toUpperCase()} ${message}`)
),
transports: [new transports.Console()]
});
// Environment variables
const API_BASE_URL = process.env.API_BASE_URL;
const API_TOKEN = process.env.API_TOKEN;
const MAX_EMAIL_SIZE = parseInt(process.env.MAX_EMAIL_SIZE || '10485760', 10);
const AWS_REGION = process.env.AWS_REGION || 'us-east-1';
// Log environment variables (omit sensitive values like API_TOKEN)
logger.info(`Environment: API_BASE_URL=${API_BASE_URL}, AWS_REGION=${AWS_REGION}, MAX_EMAIL_SIZE=${MAX_EMAIL_SIZE}`);
// Validate environment variables
if (!API_BASE_URL || !API_TOKEN) {
logger.error('Missing required environment variables: API_BASE_URL or API_TOKEN');
throw new Error('Missing required environment variables');
}
// S3 client
const s3Client = new S3Client({ region: AWS_REGION });
// Utility to convert stream to buffer
async function streamToBuffer(stream) {
try {
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
} catch (error) {
throw new Error(`Failed to convert stream to buffer: ${error.message}`);
}
}
// Utility to call the REST API
async function callApiOnce(payload, domain, requestId) {
const url = `${API_BASE_URL}/process/${domain}`;
logger.info(
`[${requestId}] Preparing POST to ${url}: ` +
`domain=${domain}, key=${payload.s3_key}, bucket=${payload.s3_bucket}, ` +
`orig_size=${payload.original_size}, comp_size=${payload.compressed_size}`
);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json',
'X-Request-ID': requestId
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(25000)
});
const responseBody = await response.text();
logger.info(`[${requestId}] API response: status=${response.status}, body=${responseBody}`);
if (response.ok) {
logger.info(`[${requestId}] API call successful`);
return true;
} else {
logger.error(`[${requestId}] API returned ${response.status}: ${responseBody}`);
return false;
}
} catch (error) {
logger.error(`[${requestId}] API call failed: ${error.message}`);
return false;
}
}
// Lambda handler
export const handler = async (event, context) => {
const reqId = context.awsRequestId;
logger.info(`[${reqId}] Starting Lambda execution`);
logger.info(`[${reqId}] Event: ${JSON.stringify(event)}`);
try {
const rec = event.Records[0].s3;
const bucket = rec.bucket.name;
const key = decodeURIComponent(rec.object.key.replace(/\+/g, ' '));
logger.info(`[${reqId}] Processing ${bucket}/${key}`);
// Check email size
logger.info(`[${reqId}] Fetching object metadata for ${bucket}/${key}`);
const headCommand = new HeadObjectCommand({ Bucket: bucket, Key: key });
const head = await s3Client.send(headCommand);
const size = head.ContentLength;
logger.info(`[${reqId}] Object size: ${size} bytes`);
if (size > MAX_EMAIL_SIZE) {
logger.warning(`[${reqId}] Email too large: ${size} bytes (max: ${MAX_EMAIL_SIZE})`);
return { statusCode: 413, body: JSON.stringify({ error: 'Email too large' }) };
}
// Load email content
logger.info(`[${reqId}] Fetching object content from ${bucket}/${key}`);
const getObjectCommand = new GetObjectCommand({ Bucket: bucket, Key: key });
const { Body } = await s3Client.send(getObjectCommand);
logger.info(`[${reqId}] Object content retrieved, converting to buffer`);
const body = await streamToBuffer(Body);
logger.info(`[${reqId}] Buffer size: ${body.length} bytes`);
// Parse and log from/to
let fromAddr = '';
let toAddrs = [];
try {
logger.info(`[${reqId}] Parsing email content`);
const parser = await simpleParser(body);
fromAddr = parser.from?.value[0]?.address || '';
toAddrs = [
...(parser.to?.value || []),
...(parser.cc?.value || []),
...(parser.bcc?.value || [])
].map(addr => addr.address).filter(Boolean);
logger.info(`[${reqId}] Parsed email: from=${fromAddr}, to=${toAddrs}`);
} catch (error) {
logger.error(`[${reqId}] Error parsing email: ${error.message}`);
}
// Compress and build payload
logger.info(`[${reqId}] Compressing email content`);
const compressed = gzipSync(body);
const payload = {
s3_bucket: bucket,
s3_key: key,
domain: bucket.replace(/-/g, '.').replace('.emails', ''),
email_content: Base64.encode(compressed.toString('binary')),
compressed: true,
etag: head.ETag.replace(/"/g, ''),
request_id: reqId,
original_size: body.length,
compressed_size: compressed.length
};
logger.info(`[${reqId}] Payload prepared: domain=${payload.domain}, compressed_size=${payload.compressed_size}`);
// Send to REST API
logger.info(`[${reqId}] Sending payload to REST API`);
const success = await callApiOnce(payload, payload.domain, reqId);
// Log result
if (success) {
logger.info(`[${reqId}] Email processed successfully`);
} else {
logger.info(`[${reqId}] Email processing failed, status handled by REST API`);
}
logger.info(`[${reqId}] Lambda execution completed`);
return { statusCode: 200, body: JSON.stringify({ message: 'Done' }) };
} catch (error) {
logger.error(`[${reqId}] Error processing event: ${error.message}, stack: ${error.stack}`);
return { statusCode: 500, body: JSON.stringify({ error: 'Internal server error' }) };
}
};

2270
ses-lambda-nodejs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"name": "email-lambda",
"version": "1.0.0",
"type": "module",
"dependencies": {
"@aws-sdk/client-s3": "^3.658.1",
"nodemailer": "^6.9.14",
"mailparser": "^3.7.1",
"js-base64": "^3.7.7",
"winston": "^3.13.1",
"dotenv": "^16.4.5"
}
}

Binary file not shown.

View File

@@ -0,0 +1,101 @@
/* import { MailParser } from 'mailparser';
import { createLogger, format, transports } from 'winston';
const logger = createLogger({
level: 'info',
format: format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
format.printf(({ timestamp, level, message }) => `${timestamp} ${level.toUpperCase()} ${message}`)
),
transports: [new transports.Console()]
}); */
const emailContent = `
Return-Path: <andreas.knuth@gmail.com>
Received: from mail-lf1-f54.google.com (mail-lf1-f54.google.com [209.85.167.54])
by inbound-smtp.us-east-2.amazonaws.com with SMTP id tl8bodt75rl99agvurj9pt06aaphgs5pj3l7ci01
for test@bizmatch.net;
Mon, 07 Jul 2025 22:29:30 +0000 (UTC)
X-SES-Spam-Verdict: PASS
X-SES-Virus-Verdict: PASS
Received-SPF: pass (spfCheck: domain of _spf.google.com designates 209.85.167.54 as permitted sender) client-ip=209.85.167.54; envelope-from=andreas.knuth@gmail.com; helo=mail-lf1-f54.google.com;
Authentication-Results: amazonses.com;
spf=pass (spfCheck: domain of _spf.google.com designates 209.85.167.54 as permitted sender) client-ip=209.85.167.54; envelope-from=andreas.knuth@gmail.com; helo=mail-lf1-f54.google.com;
dkim=pass header.i=@gmail.com;
dmarc=pass header.from=gmail.com;
X-SES-RECEIPT: AEFBQUFBQUFBQUFHZ2VxMTdrTDl5UCtYZjRQUHNhL3YwRWo4YXNNbEVYdGdqUTducmt1L25UY0pMNFNqMitXQWZCbnVsYW1seVdseFQzT1lZT2VUVEtCUWl0b2VDVk94SU5xN3p1K1R3d2lOT0hkb2ZIclEvS0JqNVdtRzAvNnJtejlsOE42dTU3ZTV5K2NIQ0lvOEJtQ0hBSkhrZ2JURHJjWXpVYU5EOEZnMnc0SU8xeS9TUVR6OXZxdmt4WVdCMzNuaUJ2TE9xRzN1WHdZM3VFdUcwYzBrZm9OV3BFMEwrZURnb25PY2h2dVExRXV1Q0ZCSzhIeGRsSTZFdXZwUUVzQ2JQUFVzUjFvZnI0U2g4aXBFZDQxQVNFanJLYXdNS2crKzZPanJySHJWckdXQ21hZ2NOQWc9PQ==
X-SES-DKIM-SIGNATURE: a=rsa-sha256; q=dns/txt; b=A7ZG4osvHSz8Grirn5FNbtnZtZoxA4SwzM4NX2SD3xlmdGZ9gEs7o5QAaexpqFo+tVHGze6kCXShR/m5e+Ccoelv+pYGuQsM0UQukPH567mOTd6DBsUnwgGoWyzkR4LyBMSGKX50m3plpMr7OsfydgTtSgmNqx6TaW2uTqAmHG4=; c=relaxed/simple; s=ndjes4mrtuzus6qxu3frw3ubo3gpjndv; d=amazonses.com; t=1751927370; v=1; bh=kl0ZVgKAgL2tPEaQmtmEdFkMF0Wkh08RlXtja41/naQ=; h=From:To:Cc:Bcc:Subject:Date:Message-ID:MIME-Version:Content-Type:X-SES-RECEIPT;
Received: by mail-lf1-f54.google.com with SMTP id 2adb3069b0e04-553aba2f99eso547669e87.3
for <test@bizmatch.net>; Mon, 07 Jul 2025 15:29:29 -0700 (PDT)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=gmail.com; s=20230601; t=1751927368; x=1752532168; darn=bizmatch.net;
h=to:subject:message-id:date:from:mime-version:from:to:cc:subject
:date:message-id:reply-to;
bh=kl0ZVgKAgL2tPEaQmtmEdFkMF0Wkh08RlXtja41/naQ=;
b=Dv7XQW93T4nV5kY0HB5qVq0H1iB0cYfdQMzSGyu+chsPKK5N+8INipWr1bulAYA4OM
UKP7EiY4j3zzrxVLFMjboztDfI4PG2oAYSdxIah+jTdgpliVhIeGqvM87SH4pfSVPnOB
JygDwwhB25s9wfwM7XDQ+uaAg/Fdwc6kgXf1d2k28gdnV9cuhToWMBAdCZG+0pic969P
HEJlLY+KJBVIvzl8JcVZ6ReT8FeQWGwKfzdrpG8PXyYO8MH4FtAmfji4Av4PO/Q2Ky/u
3Razz1QTf8R7dHCndAdXCa5INrMaCQOvXRWMMc22sIfMTtM0RKieL7jfp+T4kzcWd8bp
F3BA==
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=1e100.net; s=20230601; t=1751927368; x=1752532168;
h=to:subject:message-id:date:from:mime-version:x-gm-message-state
:from:to:cc:subject:date:message-id:reply-to;
bh=kl0ZVgKAgL2tPEaQmtmEdFkMF0Wkh08RlXtja41/naQ=;
b=wDGrMTBQxC0PHTqXvyy2DVWa4au/7y1hd7NkSgRoVX/vVKp1ArewmkY1xWPEG4qp6S
X6B9q/qimOqNHs/me0gke2XOeVfgT0Pw+NMSJMf7mCGLZ2+y6sxRttgrh4u2FTxeY0K1
RKYdwG7rUcqBYoyU/1h6nJYrotuCs7VYBmWbglChhTJoysmFdnR7eAsD2GnxVM1CDZbI
XdVsK/+vOhUHw8uyVB8sILrEtpM4+ETz0BnIveqyldnfXTKj1v1gnXUNi2XgaK+K126b
DsXGAP4SwLXUeCHnwGvEfpqTvdVhhOalwR0uCNFWMSOIOuxJbm6hPdU82oz1G6yEUip1
pSyw==
X-Gm-Message-State: AOJu0YwHBQTUiVzyF4Z+W9Nn+X1DjRnb+ExbYEHAl2nHyJxuSHCcO+92
BQdv1ZRanXsQ1Lb4d3pzXr5AoeyNsoAyT3H9Xnu0bZO+zSNpvJ44dQY0WwJc1RKk3WFm8C2xxjl
FNPLCFUIKOYoBKSue/IhK5RuJEorabq6yCy11zJUvVQ==
X-Gm-Gg: ASbGnctmha0Sl+6s3+7aqdJp4XfRfVYWw1ijYcCHalIyyYoLNA/scbpX0Eqz6/xkLKz
Zk8kZ1s2cvvs0Li8JDtKWndBEfOlH2vObiTf1nOjfUXArElHNcXTLauyTSsQhhnX98yufY/FlMM
gBVMpCLdinwI7W73wct+qp6JNzoPTJjMqxxr460ujtFDG0M5f6/edKdGc=
X-Google-Smtp-Source: AGHT+IGKQO5agz3saT3mvRcQjADlp5mR3Ss7bUoX6CzSwr9FNqw5AekIbPUiMQx0QQJz5SZAtSywG7pqy3jzwJU7gFI=
X-Received: by 2002:a05:6512:e90:b0:553:29cc:c47a with SMTP id
2adb3069b0e04-556e76ea8b6mr1397840e87.6.1751927367907; Mon, 07 Jul 2025
15:29:27 -0700 (PDT)
MIME-Version: 1.0
From: Andreas Knuth <andreas.knuth@gmail.com>
Date: Mon, 7 Jul 2025 17:29:22 -0500
X-Gm-Features: Ac12FXylATeuoXeS0LgUwAAC4rygTYy_KTtNVnLhQ8Pv-KiTkX5e5F1AlsvpAY8
Message-ID: <CADfCGtb_G+9W11EgfeQhp+V5vb1_gkeq9ZsfqgvsxC9hMNEfJQ@mail.gmail.com>
Subject: dsfsd
To: test@bizmatch.net
Content-Type: multipart/alternative; boundary="0000000000006fc0ff06395e6090"
--0000000000006fc0ff06395e6090
Content-Type: text/plain; charset="UTF-8"
sdfsdf
--0000000000006fc0ff06395e6090
Content-Type: text/html; charset="UTF-8"
<div dir="ltr">sdfsdf</div>
--0000000000006fc0ff06395e6090--
`;
const { simpleParser } = require("mailparser");
// Callback style
simpleParser(source, options, (err, mail) => {
if (err) throw err;
console.log(mail.subject);
});
// Promise style
simpleParser(source, options)
.then((mail) => console.log(mail.subject))
.catch(console.error);
// async/await
//const mail = await simpleParser(source, options);
(async () => {
await simpleParser(source, options);
})

View File

@@ -0,0 +1,152 @@
import os
import boto3
import smtplib
import time
import requests
from email.parser import BytesParser
from email.policy import default
from email.utils import getaddresses
s3 = boto3.client('s3')
MAILCOW_HOST = os.environ['MAILCOW_SMTP_HOST']
MAILCOW_PORT = int(os.environ.get('MAILCOW_SMTP_PORT', 587))
SMTP_USER = os.environ.get('MAILCOW_SMTP_USER')
SMTP_PASS = os.environ.get('MAILCOW_SMTP_PASS')
MAILCOW_API_KEY = os.environ.get('MAILCOW_API_KEY')
def domain_to_bucket(domain):
return domain.replace('.', '-') + '-emails'
def bucket_to_domain(bucket):
return bucket.replace('-emails', '').replace('-', '.')
def get_valid_inboxes():
url = 'https://mail.email-srvr.com/api/v1/get/mailbox/all'
headers = {'X-API-Key': MAILCOW_API_KEY}
try:
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
mailboxes = response.json()
return {mb['username'].lower() for mb in mailboxes if mb['active_int'] == 1}
except requests.RequestException as e:
print(f"Fehler beim Abrufen der Postfächer: {e}")
raise Exception("Konnte gültige Postfächer nicht abrufen")
def lambda_handler(event, context):
rec = event['Records'][0]
if 'ses' in rec:
ses = rec['ses']
msg_id = ses['mail']['messageId']
recipients = ses['receipt']['recipients']
first_recipient = recipients[0]
domain = first_recipient.split('@')[1]
bucket = domain_to_bucket(domain)
prefix = f"emails/{msg_id}"
print(f"SES-Receipt erkannt, domain={domain}, bucket={bucket}, prefix={prefix}")
resp_list = s3.list_objects_v2(Bucket=bucket, Prefix=prefix)
if 'Contents' not in resp_list or not resp_list['Contents']:
raise Exception(f"Kein Objekt unter Prefix {prefix} in Bucket {bucket} gefunden")
key = resp_list['Contents'][0]['Key']
elif 's3' in rec:
s3info = rec['s3']
bucket = s3info['bucket']['name']
key = s3info['object']['key']
print("S3-Put erkannt, bucket =", bucket, "key =", key)
recipients = []
else:
raise Exception("Unbekannter Event-Typ")
# Prüfen, ob das Objekt bereits verarbeitet wurde
try:
resp = s3.head_object(Bucket=bucket, Key=key)
if resp.get('Metadata', {}).get('processed') == 'true':
print(f"Objekt {key} bereits verarbeitet (processed=true), überspringe Verarbeitung")
return {
'statusCode': 200,
'body': f"Objekt {key} bereits verarbeitet, keine erneute Weiterleitung"
}
except Exception as e:
print(f"Fehler beim Prüfen der Metadaten: {e}")
# Raw-Mail aus S3 holen
resp = s3.get_object(Bucket=bucket, Key=key)
raw_bytes = resp['Body'].read()
print(f"E-Mail geladen: {len(raw_bytes)} Bytes")
# Parsen für Logging
parsed = BytesParser(policy=default).parsebytes(raw_bytes)
subj = parsed.get('subject', '(kein Subject)')
frm_addr = getaddresses(parsed.get_all('from', []))[0][1]
print(f"Parsed: From={frm_addr} Subject={subj}")
# Empfänger aus Headern ziehen, falls nicht aus SES
if not recipients:
to_addrs = [addr for _name, addr in getaddresses(parsed.get_all('to', []))]
cc_addrs = [addr for _name, addr in getaddresses(parsed.get_all('cc', []))]
bcc_addrs = [addr for _name, addr in getaddresses(parsed.get_all('bcc', []))]
recipients = to_addrs + cc_addrs + bcc_addrs
print("Empfänger aus Headern:", recipients)
# Im S3-Flow nur Empfänger mit passender Domain behalten
expected_domain = bucket_to_domain(bucket)
recipients = [rcpt for rcpt in recipients if rcpt.lower().split('@')[1] == expected_domain]
print(f"Empfänger nach Domain-Filter ({expected_domain}): {recipients}")
if not recipients:
print("Keine Empfänger gefunden, setze Metadatum und überspringe SMTP")
else:
# Gültige Postfächer abrufen und Empfänger filtern
valid_inboxes = get_valid_inboxes()
valid_recipients = [rcpt for rcpt in recipients if rcpt.lower() in valid_inboxes]
print(f"Gültige Empfänger: {valid_recipients}")
if valid_recipients:
# SMTP-Verbindung und Envelope
start = time.time()
print("=== SMTP: Verbinde zu", MAILCOW_HOST, "Port", MAILCOW_PORT)
with smtplib.SMTP(MAILCOW_HOST, MAILCOW_PORT, timeout=30) as smtp:
smtp.ehlo()
smtp.starttls()
smtp.ehlo()
if SMTP_USER and SMTP_PASS:
smtp.login(SMTP_USER, SMTP_PASS)
print("=== SMTP: MAIL FROM", frm_addr)
smtp.mail(frm_addr)
for rcpt in valid_recipients:
print("=== SMTP: RCPT TO", rcpt)
smtp.rcpt(rcpt)
smtp.data(raw_bytes)
print(f"SMTP-Transfer in {time.time()-start:.2f}s abgeschlossen ...")
else:
print("Keine gültigen Postfächer für die Empfänger gefunden, setze Metadatum und überspringe SMTP")
# Metadatum "processed": "true" hinzufügen
try:
resp = s3.head_object(Bucket=bucket, Key=key)
current_metadata = resp.get('Metadata', {})
new_metadata = current_metadata.copy()
new_metadata['processed'] = 'true'
s3.copy_object(
Bucket=bucket,
Key=key,
CopySource={'Bucket': bucket, 'Key': key},
Metadata=new_metadata,
MetadataDirective='REPLACE'
)
print("Metadatum 'processed:true' hinzugefügt.")
except Exception as e:
print(f"Fehler beim Schreiben des Metadatums: {e}")
raise
return {
'statusCode': 200,
'body': f"E-Mail verarbeitet für {bucket}, SMTP-Weiterleitung: {bool(valid_recipients)}"
}