diff options
author | Jannis M. Hoffmann <jannis@fehcom.de> | 2024-11-19 23:15:55 +0100 |
---|---|---|
committer | Jannis M. Hoffmann <jannis@fehcom.de> | 2024-11-19 23:15:55 +0100 |
commit | 5324a38f8fbd41391741317f7e7ab2d69ec30623 (patch) | |
tree | 7a6ff48bb8dece79ac782977e510bb5bd048fc21 | |
parent | ae117909d9f39103e32296325e93fa3c22749350 (diff) |
switch from protobuf based protocol to varlink
-rw-r--r-- | .gitignore | 5 | ||||
-rw-r--r-- | README_IPC.md | 49 | ||||
-rw-r--r-- | depoly.yml | 6 | ||||
-rw-r--r-- | dev-requirements.txt | 26 | ||||
-rw-r--r-- | pyproject.toml | 3 | ||||
-rw-r--r-- | requirements.txt | 207 | ||||
-rwxr-xr-x | script/extract.py | 805 | ||||
-rwxr-xr-x | script/moveto3.py | 28 | ||||
-rw-r--r-- | src/jwebmail/__init__.py | 14 | ||||
-rw-r--r-- | src/jwebmail/model/de.jmhoffmann.jwebmail.mail-storage.varlink | 85 | ||||
-rw-r--r-- | src/jwebmail/model/jwebmail.proto | 165 | ||||
-rw-r--r-- | src/jwebmail/model/read_mails.py | 322 | ||||
-rw-r--r-- | src/jwebmail/read_mails.py | 15 | ||||
-rw-r--r-- | src/jwebmail/render_mail.py | 4 |
14 files changed, 784 insertions, 950 deletions
@@ -4,14 +4,11 @@ tests/testdata/ __pycache__/ .vscode/ jwebmail.toml +jwebmail.prod.toml messages.pot -user_sessions *.mo src/jwebmail/static/css/ dist/ -jwebmail.prod.toml -jwebmail_pb2.py -jwebmail_pb2.pyi inventory.ini script/extract script/extract-release diff --git a/README_IPC.md b/README_IPC.md index b9d754a..26100d0 100644 --- a/README_IPC.md +++ b/README_IPC.md @@ -2,25 +2,25 @@ About the Inter Process Communication Protocol ============================================== JWebmail needs to read a users mail box in maildir format. -JWebmail runs under its own user also called jwebmail. -The mailbox has rights of its own user or there is -a special user for all mailboxes. To gain access there is a setuid program -called qmail-authuser that checks acces rights and starts another program. +JWebmail runs under its own user commonly called jwebmail. +The mailbox has rights of its own user or there is a special user for all +mailboxes under a domain. To gain access there is a setuid program called +qmail-authuser that checks acces rights and starts another program. This program is called `extractor`. Version 1 --------- -Previously all arguments where passed by command line arguments and json -was returned. +In this version all arguments where passed by command line arguments and +json was returned. Binary data was sent after a json header and a newline. Version 2 --------- -Now protobuf is used for message interchange. +Here protobuf is used for message interchange. -Currently a process in spawned for every command. +A process in spawned for every command. The method is given as a process argument and the arguments are a single message on stdin which is close afterwards. The response is a single protobuf message on stdout after which the process exits. @@ -28,32 +28,7 @@ message on stdout after which the process exits. Version 3 --------- -In the future one process is spawned for every jwebmail request. -For this the method name must be dynamic. - -I propose the following protocol: - - request - 32 bit big endian unsigned integer - ascii string (method name) - 32 bit big endian unsigned integer - protobuf message - - response - 32 bit big endian unsigned integer - protobuf message - -On end the request file descriptor is closed. -An error may close stdout early. - -Exit codes ----------- - -- 1 qma -- 2 qma -- 3 err response -- 4 user error -- 5 issue switching to user -- 6 operation not supported -- 110 qma -- 111 qma +In this version one process is spawned for every jwebmail request. +This is done by switching to varlink a json based rpc protocol. +A small disadvantage is that all binary data needs to be base64 encoded +and passed inside of a json string. @@ -21,12 +21,6 @@ - "toml" virtualenv: "/usr/local/jwebmail" become: true - - name: Scripts - ansible.builtin.copy: - src: "script/moveto3.py" - dest: "/usr/local/jwebmail/bin/moveto3.py" - mode: "0755" - become: true - name: Extract ansible.builtin.copy: src: "script/extract-release" diff --git a/dev-requirements.txt b/dev-requirements.txt index 40f7b02..246b043 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile --extra=dev --generate-hashes --output-file=dev-requirements.txt --strip-extras pyproject.toml @@ -130,26 +130,13 @@ markupsafe==2.1.3 \ # jinja2 # werkzeug # wtforms -protobuf==4.25.3 \ - --hash=sha256:19b270aeaa0099f16d3ca02628546b8baefe2955bbe23224aaf856134eccf1e4 \ - --hash=sha256:209ba4cc916bab46f64e56b85b090607a676f66b473e6b762e6f1d9d591eb2e8 \ - --hash=sha256:25b5d0b42fd000320bd7830b349e3b696435f3b329810427a6bcce6a5492cc5c \ - --hash=sha256:7c8daa26095f82482307bc717364e7c13f4f1c99659be82890dcfc215194554d \ - --hash=sha256:c053062984e61144385022e53678fbded7aea14ebb3e0305ae3592fb219ccfa4 \ - --hash=sha256:d4198877797a83cbfe9bffa3803602bbe1625dc30d8a097365dbc762e5790faa \ - --hash=sha256:e3c97a1555fd6388f857770ff8b9703083de6bf1f9274a002a332d65fbb56c8c \ - --hash=sha256:e7cb0ae90dd83727f0c0718634ed56837bfeeee29a5f82a7514c03ee1364c019 \ - --hash=sha256:f0700d54bcf45424477e46a9f0944155b46fb0639d69728739c0e47bab83f2b9 \ - --hash=sha256:f1279ab38ecbfae7e456a108c5c0681e4956d5b1090027c1de0f934dfdb4b35c \ - --hash=sha256:f4f118245c4a087776e0a8408be33cf09f6c547442c00395fbfb116fac2f8ac2 - # via jwebmail (pyproject.toml) pytz==2023.3.post1 \ --hash=sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b \ --hash=sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7 # via flask-babel -redis==5.0.3 \ - --hash=sha256:4973bae7444c0fbed64a06b87446f79361cb7e4ec1538c022d696ed7a5015580 \ - --hash=sha256:5da9b8fe9e1254293756c16c008e8620b3d15fcc6dde6babde9541850e72a32d +varlink==31.0.0 \ + --hash=sha256:0d0629e5ca7e629f79ed84dc5a4f29e04f3cc83b24641528e91a41fa158e58e9 \ + --hash=sha256:4070cb2c250f459bcea88c99bb08203d65e4f41241a4f68942969f25123b0a31 # via jwebmail (pyproject.toml) werkzeug==3.0.1 \ --hash=sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc \ @@ -161,3 +148,8 @@ wtforms==3.1.1 \ --hash=sha256:5e51df8af9a60f6beead75efa10975e97768825a82146a65c7cbf5b915990620 \ --hash=sha256:ae7c54b29806c70f7bce8eb9f24afceb10ca5c32af3d9f04f74d2f66ccc5c7e0 # via flask-wtf + +# WARNING: The following packages were not pinned, but pip requires them to be +# pinned when the requirements file includes hashes and the requirement is not +# satisfied by a package already installed. Consider using the --allow-unsafe flag. +# setuptools diff --git a/pyproject.toml b/pyproject.toml index 98f94cb..a3f3d37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "Flask-WTF", "flask-paginate", "email-validator", - "protobuf", + "varlink", ] [project.optional-dependencies] @@ -40,7 +40,6 @@ serve = 'env PATH="$PATH:$PWD/script/" flask --app src/jwebmail --debug run --ex server = "env JWEBMAIL_CONFIG=../../jwebmail.toml flask --app src/jwebmail run" tr-compile = "pybabel compile -d src/jwebmail/translations/" tr-extract = "pybabel extract -F babel.cfg -o messages.pot -k lazy_gettext src/ && pybabel update -i messages.pot -d src/jwebmail/translations/" -pb-generate = "protoc -I src/jwebmail/model --python_out=src/jwebmail/model/ jwebmail.proto" [tool.hatch.build.targets.wheel] ignore-vcs = true diff --git a/requirements.txt b/requirements.txt index 072f105..7dbc749 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,32 +1,32 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile --generate-hashes --strip-extras # -babel==2.13.1 \ - --hash=sha256:33e0952d7dd6374af8dbf6768cc4ddf3ccfefc244f9986d4074704f2fbd18900 \ - --hash=sha256:7077a4984b02b6727ac10f1f7294484f737443d7e2e66c5e4380e41a3ae0b4ed +babel==2.16.0 \ + --hash=sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b \ + --hash=sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316 # via flask-babel -blinker==1.7.0 \ - --hash=sha256:c3f865d4d54db7abc53758a01601cf343fe55b84c1de4e3fa910e420b438d5b9 \ - --hash=sha256:e6820ff6fa4e4d1d8e2747c2283749c3f547e4fee112b98555cdcdae32996182 +blinker==1.9.0 \ + --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ + --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc # via flask click==8.1.7 \ --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 \ --hash=sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de # via flask -dnspython==2.4.2 \ - --hash=sha256:57c6fbaaeaaf39c891292012060beb141791735dbb4004798328fc2c467402d8 \ - --hash=sha256:8dcfae8c7460a2f84b4072e26f1c9f4101ca20c071649cb7c34e8b6a93d58984 +dnspython==2.7.0 \ + --hash=sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86 \ + --hash=sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1 # via email-validator -email-validator==2.1.0.post1 \ - --hash=sha256:a4b0bd1cf55f073b924258d19321b1f3aa74b4b5a71a42c305575dba920e1a44 \ - --hash=sha256:c973053efbeddfef924dc0bd93f6e77a1ea7ee0fce935aea7103c7a3d6d2d637 +email-validator==2.2.0 \ + --hash=sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631 \ + --hash=sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7 # via jwebmail (pyproject.toml) -flask==3.0.0 \ - --hash=sha256:21128f47e4e3b9d597a3e8521a329bf56909b690fcc3fa3e477725aa81367638 \ - --hash=sha256:cfadcdb638b609361d29ec22360d6070a77d7463dcb3ab08d2c2f2f168845f58 +flask==3.1.0 \ + --hash=sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac \ + --hash=sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136 # via # flask-babel # flask-login @@ -42,109 +42,110 @@ flask-login==0.6.3 \ --hash=sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d # via jwebmail (pyproject.toml) flask-paginate==2024.4.12 \ - --hash=sha256:48cc477fd64c95b6c7be980bc96198a8eadeca863484f78c1e1fe22608fc3b28 \ - --hash=sha256:57790fd4c543c802511ade71b6a9d9654e7295fcde39eda00d3cd4aa3bd68859 + --hash=sha256:2c5a19c851b07456b2bb354d2f5dc9d59fbad0b7241599f3450c86c2427e4da1 \ + --hash=sha256:2de04606b061736f0fc8fbe73d9d4d6fc03664638eca70a57728b03b3e2c9577 # via jwebmail (pyproject.toml) -flask-wtf==1.2.1 \ - --hash=sha256:8bb269eb9bb46b87e7c8233d7e7debdf1f8b74bf90cc1789988c29b37a97b695 \ - --hash=sha256:fa6793f2fb7e812e0fe9743b282118e581fb1b6c45d414b8af05e659bd653287 +flask-wtf==1.2.2 \ + --hash=sha256:79d2ee1e436cf570bccb7d916533fa18757a2f18c290accffab1b9a0b684666b \ + --hash=sha256:e93160c5c5b6b571cf99300b6e01b72f9a101027cab1579901f8b10c5daf0b70 # via jwebmail (pyproject.toml) -idna==3.6 \ - --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ - --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f +idna==3.10 \ + --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ + --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 # via email-validator -itsdangerous==2.1.2 \ - --hash=sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44 \ - --hash=sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a +itsdangerous==2.2.0 \ + --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \ + --hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173 # via # flask # flask-wtf -jinja2==3.1.2 \ - --hash=sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852 \ - --hash=sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61 +jinja2==3.1.4 \ + --hash=sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369 \ + --hash=sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d # via # flask # flask-babel -markupsafe==2.1.3 \ - --hash=sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e \ - --hash=sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e \ - --hash=sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431 \ - --hash=sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686 \ - --hash=sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c \ - --hash=sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559 \ - --hash=sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc \ - --hash=sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb \ - --hash=sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939 \ - --hash=sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c \ - --hash=sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0 \ - --hash=sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4 \ - --hash=sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9 \ - --hash=sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575 \ - --hash=sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba \ - --hash=sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d \ - --hash=sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd \ - --hash=sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3 \ - --hash=sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00 \ - --hash=sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155 \ - --hash=sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac \ - --hash=sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52 \ - --hash=sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f \ - --hash=sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8 \ - --hash=sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b \ - --hash=sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007 \ - --hash=sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24 \ - --hash=sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea \ - --hash=sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198 \ - --hash=sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0 \ - --hash=sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee \ - --hash=sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be \ - --hash=sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2 \ - --hash=sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1 \ - --hash=sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707 \ - --hash=sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6 \ - --hash=sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c \ - --hash=sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58 \ - --hash=sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823 \ - --hash=sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779 \ - --hash=sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636 \ - --hash=sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c \ - --hash=sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad \ - --hash=sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee \ - --hash=sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc \ - --hash=sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2 \ - --hash=sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48 \ - --hash=sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7 \ - --hash=sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e \ - --hash=sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b \ - --hash=sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa \ - --hash=sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5 \ - --hash=sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e \ - --hash=sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb \ - --hash=sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9 \ - --hash=sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57 \ - --hash=sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc \ - --hash=sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc \ - --hash=sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2 \ - --hash=sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11 +markupsafe==3.0.2 \ + --hash=sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4 \ + --hash=sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30 \ + --hash=sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0 \ + --hash=sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9 \ + --hash=sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396 \ + --hash=sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13 \ + --hash=sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028 \ + --hash=sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca \ + --hash=sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557 \ + --hash=sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832 \ + --hash=sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0 \ + --hash=sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b \ + --hash=sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579 \ + --hash=sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a \ + --hash=sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c \ + --hash=sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff \ + --hash=sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c \ + --hash=sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22 \ + --hash=sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094 \ + --hash=sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb \ + --hash=sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e \ + --hash=sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5 \ + --hash=sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a \ + --hash=sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d \ + --hash=sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a \ + --hash=sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b \ + --hash=sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8 \ + --hash=sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225 \ + --hash=sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c \ + --hash=sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144 \ + --hash=sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f \ + --hash=sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87 \ + --hash=sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d \ + --hash=sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93 \ + --hash=sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf \ + --hash=sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158 \ + --hash=sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84 \ + --hash=sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb \ + --hash=sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48 \ + --hash=sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171 \ + --hash=sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c \ + --hash=sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6 \ + --hash=sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd \ + --hash=sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d \ + --hash=sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1 \ + --hash=sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d \ + --hash=sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca \ + --hash=sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a \ + --hash=sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29 \ + --hash=sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe \ + --hash=sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798 \ + --hash=sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c \ + --hash=sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8 \ + --hash=sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f \ + --hash=sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f \ + --hash=sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a \ + --hash=sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178 \ + --hash=sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0 \ + --hash=sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79 \ + --hash=sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430 \ + --hash=sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50 # via # jinja2 # werkzeug # wtforms -pytz==2023.3.post1 \ - --hash=sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b \ - --hash=sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7 +pytz==2024.2 \ + --hash=sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a \ + --hash=sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725 # via flask-babel -redis==5.0.1 \ - --hash=sha256:0dab495cd5753069d3bc650a0dde8a8f9edde16fc5691b689a566eda58100d0f \ - --hash=sha256:ed4802971884ae19d640775ba3b03aa2e7bd5e8fb8dfaed2decce4d0fc48391f +varlink==31.0.0 \ + --hash=sha256:0d0629e5ca7e629f79ed84dc5a4f29e04f3cc83b24641528e91a41fa158e58e9 \ + --hash=sha256:4070cb2c250f459bcea88c99bb08203d65e4f41241a4f68942969f25123b0a31 # via jwebmail (pyproject.toml) -werkzeug==3.0.1 \ - --hash=sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc \ - --hash=sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10 +werkzeug==3.1.3 \ + --hash=sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e \ + --hash=sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746 # via # flask # flask-login -wtforms==3.1.1 \ - --hash=sha256:5e51df8af9a60f6beead75efa10975e97768825a82146a65c7cbf5b915990620 \ - --hash=sha256:ae7c54b29806c70f7bce8eb9f24afceb10ca5c32af3d9f04f74d2f66ccc5c7e0 +wtforms==3.2.1 \ + --hash=sha256:583bad77ba1dd7286463f21e11aa3043ca4869d03575921d1a1698d0715e0fd4 \ + --hash=sha256:df3e6b70f3192e92623128123ec8dca3067df9cfadd43d59681e210cfb8d4682 # via flask-wtf diff --git a/script/extract.py b/script/extract.py index c670bef..f311fba 100755 --- a/script/extract.py +++ b/script/extract.py @@ -5,47 +5,31 @@ Extract delivers information about emails from a maildir. Runs with elevated privileges. -This program is started by qmail-authuser with elevated privileges after -a successful login. - -The run method is provided by a command line argument. -Additional data is read from STDIN as protobuf. -Output is delivered via STDOUT as protobuf and log information via STDERR. - -Exit codes:: - - 1 reserved - 2 reserved - 3 operational error (error message in output) - 4 user error (no output) - 5 issue switching to user (no output) - 110 reserved - 111 reserved +This program is started by qmail-authuser with elevated privileges after a +successful login. """ import email.parser import email.policy import logging import re -from argparse import ArgumentParser +import socket from base64 import b64encode from datetime import datetime from email.message import EmailMessage from itertools import islice -from mailbox import Maildir, MaildirMessage -from os import environ, getpid, mkdir, path, setuid +from mailbox import Maildir, MaildirMessage, NoSuchMailboxError +from os import environ, getpid, mkdir, path, setuid, stat from pathlib import Path from pwd import getpwnam from sys import exit as sysexit -from sys import stdin, stdout -import jwebmail.model.jwebmail_pb2 as jwebmail +import varlink class MyMaildir(Maildir): def __init__(self, dirname, parent=None, *args, **kwargs): self.__path = Path(dirname) - self.__parent = parent self.set_msgtype("MaildirMessage") super().__init__(dirname, *args, **kwargs) @@ -96,16 +80,9 @@ class MyMaildir(Maildir): """ return type(self)( path.join(self._path, "." + folder), - parent=self, create=False, ) - def list_folders(self): - if self.__parent is not None: - return self.__parent.list_folders() - else: - return super().list_folders() - def _refresh(self): """ This override of internal method _refresh that strips out 'hidden' files. @@ -116,427 +93,462 @@ class MyMaildir(Maildir): del self._toc[r] -class QMAuthError(Exception): - def __init__(self, msg, **args): - self.msg = msg - self.info = args +service = varlink.Service( + vendor="JWebmail", + product="Mail Storage Interface", + version="1", + url="https://www.fehcom.de/cgit/jwebmail2", + interface_dir=path.join(path.dirname(__file__), "../src/jwebmail/model/"), +) -def _adr(addrs): - if addrs is None: - return [] - return [ - jwebmail.MailHeader.MailAddr( - address=addr.addr_spec, - name=addr.display_name, +class InvalidUserError(varlink.VarlinkError): + + def __init__(self, unix_user): + super().__init__( + { + "error": "de.jmhoffmann.jwebmail.mail-storage.InvalidUser", + "parameters": { + "unix_user": unix_user, + }, + } ) - for addr in addrs.addresses - ] -def _get_rcv_time(mid): - idx = mid.find(".") - assert idx > 0 - return float(mid[:idx]) +class NotInitializedError(varlink.VarlinkError): + def __init__(self): + super().__init__( + {"error": "de.jmhoffmann.jwebmail.mail-storage.NotInitialized"} + ) -def startup(maildir, su, user, mode): - del environ["PATH"] - netfehcom_uid = getpwnam(su).pw_uid - if not netfehcom_uid: - logging.error("user must not be root") - sysexit(5) - try: - setuid(netfehcom_uid) - except OSError: - logging.exception("error setting uid") - sysexit(5) - - return MyMaildir(maildir / user, create=False) - - -def _sort_by_sender(midmsg): - _, msg = midmsg - - if len(addrs := msg["from"].addresses) == 1: - return addrs[0].addr_spec - else: - return msg["sender"].address.addr_spec - - -def _sort_mails(f, sort): - reverse = False - if sort.startswith("!"): - reverse = True - sort = sort[1:] - - def by_rec_date(midmsg): - return float(re.match(r"\d+\.\d+", midmsg[0], re.ASCII)[0]) - - if sort == "date": - keyfn = by_rec_date - elif sort == "sender": - keyfn = _sort_by_sender - elif sort == "subject": - # fmt: off - def keyfn(midmsg): return midmsg[1]["subject"] - # fmt: on - elif sort == "size": - # fmt: off - def keyfn(midmsg): return path.getsize(f.get_filename(midmsg[0])) - # fmt: on - elif sort == "": - keyfn = by_rec_date - else: - logging.warning("unknown sort-verb %r", sort) - reverse = False - keyfn = by_rec_date - - return keyfn, reverse - - -def _get_mime_head_info(msg): - mh = jwebmail.MIMEHeader( - maintype=msg.get_content_maintype(), - subtype=msg.get_content_subtype(), - ) - if (cd := msg.get_content_disposition()) == "inline": - mh.contentdispo = ( - jwebmail.MIMEHeader.ContentDisposition.CONTENT_DISPOSITION_INLINE - ) - elif cd == "attachment": - mh.contentdispo = ( - jwebmail.MIMEHeader.ContentDisposition.CONTENT_DISPOSITION_ATTACHMENT - ) - elif cd is None: - mh.contentdispo = ( - jwebmail.MIMEHeader.ContentDisposition.CONTENT_DISPOSITION_NONE +class InvalidMailboxError(varlink.VarlinkError): + + def __init__(self, path, not_a_mailbox, user_mismatch): + super().__init__( + { + "error": "de.jmhoffmann.jwebmail.mail-storage.InvalidMailbox", + "parameters": { + "path": path, + "not_a_mailbox": not_a_mailbox, + "user_mismatch": user_mismatch, + }, + } ) - else: - assert False - if fn := msg.get_filename(): - mh.file_name = fn - return mh +class InvalidMIDError(varlink.VarlinkError): + def __init__(self, folder, mid): + super().__init__( + { + "error": "de.jmhoffmann.jwebmail.mail-storage.InvalidMID", + "parameters": { + "folder": folder, + "mid": mid, + }, + } + ) -def _get_head_info(msg): - mh = jwebmail.MailHeader( - send_date=msg["date"].datetime.isoformat(), - written_from=_adr(msg["from"]), - reply_to=_adr(msg["reply-to"]), - send_to=_adr(msg["to"]), - cc=_adr(msg["cc"]), - bcc=_adr(msg["bcc"]), - subject=msg["subject"], - comments=msg["comments"], - keywords=msg["keywords"], - mime=_get_mime_head_info(msg), - ) - if s := _adr(msg["sender"]): - mh.sender = s[0] +class InvalidPathInMailError(varlink.VarlinkError): - return mh + def __init__(self, folder, mid, path): + super().__init__( + { + "error": "de.jmhoffmann.jwebmail.mail-storage.InvalidPathInMail", + "parameters": { + "folder": folder, + "mid": mid, + "path": path, + }, + } + ) -def list_mails(f, req): - r = jwebmail.ListReq() - r.ParseFromString(req) +@service.interface("de.jmhoffmann.jwebmail.mail-storage") +class MailStorage: - assert 0 <= r.start <= r.end + def __init__(self): + self.maildir = None - if r.folder: - f = f.get_folder(r.folder) + def Init(self, su, maildir): + del environ["PATH"] - f.set_msgtype("Maildir(EmailMessageHeader)") + try: + uid = getpwnam(su).pw_uid + if not uid: + raise InvalidUserError(su) - if r.start == r.end: - return [] + setuid(uid) - kfn, reverse = _sort_mails(f, r.sort) - msgs = list(f.items()) - msgs.sort(key=kfn, reverse=reverse) - msgs = msgs[r.start : min(len(msgs), r.end)] + if stat(maildir).st_uid != uid: + raise InvalidMailboxError( + maildir, not_a_mailbox=False, user_mismatch=True + ) - items = [ - jwebmail.ListMailHeader( - mid=mid, - byte_size=path.getsize(f.get_filename(mid)), - unread="S" not in msg.get_flags(), - rec_date=datetime.fromtimestamp(_get_rcv_time(mid)).isoformat(), - header=_get_head_info(msg), + self.maildir = MyMaildir(maildir, create=False) + except KeyError: + raise InvalidUserError(su) + except (FileNotFoundError, NoSuchMailboxError): + raise InvalidMailboxError(maildir, not_a_mailbox=True, user_mismatch=False) + + def List(self, folder, start, end, sort): + if self.maildir is None: + raise NotInitializedError() + + assert 0 <= start <= end + + maildir = self.maildir + if folder: + maildir = maildir.get_folder(folder) + + maildir.set_msgtype("Maildir(EmailMessageHeader)") + + if start == end: + return [] + + kfn, reverse = self._sort_mails(maildir, sort) + msgs = list(maildir.items()) + msgs.sort(key=kfn, reverse=reverse) + msgs = msgs[start : min(len(msgs), end)] + + def _get_rcv_time(mid): + return float(mid[: mid.index(".")]) + + items = [ + { + "mid": mid, + "byte_size": path.getsize(maildir.get_filename(mid)), + "unread": "S" not in msg.get_flags(), + "rec_date": datetime.fromtimestamp(_get_rcv_time(mid)).isoformat(), + "header": self._get_head_info(msg), + } + for mid, msg in msgs + ] + return {"mail_heads": items} + + def Stats(self, folder): + if self.maildir is None: + raise NotInitializedError() + + maildir = self.maildir + if folder: + maildir = maildir.get_folder(folder) + + maildir.set_msgtype("None") + + return dict( + mail_count=len(maildir), + unread_count=len([1 for m in maildir if "S" in m.get_flags()]), + byte_size=sum( + path.getsize(maildir.get_filename(mid)) for mid in maildir.keys() + ), ) - for mid, msg in msgs - ] - return jwebmail.ListResp(mail_heads=items).SerializeToString() + def Show(self, folder, mid): + if self.maildir is None: + raise NotInitializedError() -def count_mails(f, req): - r = jwebmail.StatsReq() - r.ParseFromString(req) - if r.folder: - f = f.get_folder(r.folder) + maildir = self.maildir + if folder: + maildir = maildir.get_folder(folder) - f.set_msgtype("None") + maildir.set_msgtype("EmailMessage") - resp = jwebmail.StatsResp( - mail_count=len(f), - unread_count=len([1 for m in f if "S" in m.get_flags()]), - byte_size=sum(path.getsize(f.get_filename(mid)) for mid in f.keys()), - ) - return resp.SerializeToString() + msg = maildir.get(mid, None) + if not msg: + raise InvalidMIDError(folder, mid) + maildir.set_msgtype("MaildirMessage") -def _get_body(mail): - if not mail.is_multipart(): - if mail.get_content_maintype() == "text": - return jwebmail.MailBody(discrete=mail.get_content()) - else: - ret = mail.get_content() - if ret.isascii(): - return jwebmail.MailBody(discrete=ret.decode(encoding="ascii")) - elif len(ret) <= 128 * 1024: - return jwebmail.MailBody( - discrete=b64encode(ret).decode(encoding="ascii") - ) - else: - raise QMAuthError( - "non attachment part too large (>512kB)", size=len(ret) - ) + maildir[mid].add_flag("S") - if (mctype := mail.get_content_maintype()) == "message": - msg = mail.get_content() - return jwebmail.MailBody( - mail=jwebmail.Mail(head=_get_head_info(msg), body=_get_body(msg)) - ) - elif mctype == "multipart": - ret = jwebmail.MailBody.Multipart( - preamble=mail.preamble, - epilogue=mail.epilogue, - ) - for part in mail.iter_parts(): - head = _get_mime_head_info(part) - if ( - head.contentdispo - != jwebmail.MIMEHeader.ContentDisposition.CONTENT_DISPOSITION_ATTACHMENT - ): - body = _get_body(part) + return { + "mail": dict( + head=self._get_head_info(msg), + body=self._get_body(msg), + ) + } + + def Raw(self, folder, mid, path): + if self.maildir is None: + raise NotInitializedError() + + maildir = self.maildir + if folder: + maildir = maildir.get_folder(folder) + + maildir.set_msgtype("EmailMessage") + + msg = maildir.get(mid, None) + if not msg: + raise InvalidMIDError(folder, mid) + + pth = [int(seg) for seg in path.split(".")] if path else [] + h = dict(mime_type={"main_type": "message", "sub_type": "rfc822"}) + b = msg + + for n in pth: + mctype = h["mime_type"]["main_type"] + + if mctype == "multipart": + try: + res = next(islice(b, n, None)) + except StopIteration: + raise InvalidPathInMailError(folder, mid, pth) + (h, b) = self._descent(res) + elif mctype == "message": + assert n == 0 + (h, b) = self._descent(b) else: - body = None - ret.parts.append( - jwebmail.MIMEPart( - mime_header=head, - body=body, + raise InvalidPathInMailError( + f"can not descent into non multipart content type {mctype}" ) - ) - return jwebmail.MailBody(multipart=ret) - else: - raise ValueError(f"unknown major content-type {mctype!r}") - - -def read_mail(f, req): - r = jwebmail.ShowReq() - r.ParseFromString(req) - if r.folder: - f = f.get_folder(r.folder) + if hasattr(b, "__next__"): + # can not stop at multipart section + raise InvalidPathInMailError(folder=folder, mid=mid, path=pth) + elif isinstance(b, str): + b = b.encode() + elif isinstance(b, EmailMessage): + b = b.as_bytes() - f.set_msgtype("EmailMessage") + return dict(header=h, body=b64encode(b).decode("ASCII")) - msg = f.get(r.mid, None) - if not msg: - raise QMAuthError("no such message", mid=r.mid) + def Search(self, folder, pattern): + if self.maildir is None: + raise NotInitializedError() - f.set_msgtype("MaildirMessage") + maildir = self.maildir + if folder: + maildir = maildir.get_folder(folder) - f[r.mid].add_flag("S") + maildir.set_msgtype("EmailMessage") - res = jwebmail.Mail( - head=_get_head_info(msg), - body=_get_body(msg), - ) - return jwebmail.ShowResp(mail=res).SerializeToString() + res = [ + dict(header=self._get_head_info(msg)) + for msg in maildir.values() + if self._matches(msg, pattern) + ] + return {"found": res} + def Folders(self): + if self.maildir is None: + raise NotInitializedError() -def _descent(xx): - head = _get_mime_head_info(xx) - if (mctype := head.maintype) == "message": - body = xx.get_content() - elif mctype == "multipart": - body = xx.iter_parts() - else: - body = xx.get_content() - return head, body + return {"folders": self.maildir.list_folders()} + def Move(self, mid, from_folder, to_folder): + if self.maildir is None: + raise NotInitializedError() -def raw_mail(f, req): - r = jwebmail.RawReq() - r.ParseFromString(req) + maildir = self.maildir + if from_folder: + maildir = maildir.get_folder(from_folder) - if r.folder: - f = f.get_folder(r.folder) + fname = Path(maildir.get_filename(mid)) - f.set_msgtype("EmailMessage") + assert to_folder in self.maildir.list_folders() or to_folder == "" - msg = f.get(r.mid, None) - if not msg: - raise QMAuthError("no such message", mid=r.mid) + sep = -2 if not from_folder else -3 - pth = [int(seg) for seg in r.path.split(".")] if r.path else [] - h = jwebmail.MIMEHeader(maintype="message", subtype="rfc822") - b = msg - - for n in pth: - mctype = h.maintype - - if mctype == "multipart": - try: - res = next(islice(b, n, None)) - except StopIteration: - raise QMAuthError("out of bounds path for mail", path=pth) - (h, b) = _descent(res) - elif mctype == "message": - assert n == 0 - (h, b) = _descent(b) + if to_folder: + res = fname.parts[:sep] + ("." + to_folder,) + fname.parts[-2:] else: - raise QMAuthError( - f"can not descent into non multipart content type {mctype}" - ) + res = fname.parts[:sep] + fname.parts[-2:] - if hasattr(b, "__next__"): - raise QMAuthError("can not stop at multipart section", path=pth) - elif isinstance(b, str): - b = b.encode() - elif isinstance(b, EmailMessage): - b = b.as_bytes() + fname.rename(Path(*res)) - return jwebmail.RawResp(header=h, body=b).SerializeToString() + def Remove(self, folder, mid): + if self.maildir is None: + raise NotInitializedError() + maildir = self.maildir + if folder: + maildir = maildir.get_folder(folder) -def _matches(m, pattern): - if m.is_multipart(): - return any( - 1 - for part in m.body.parts - if re.search(pattern, part.decoded()) or re.search(pattern, part.subject) - ) - return re.search(pattern, m.body.decoded()) or re.search(pattern, m.subject) + maildir.set_msgtype("MaildirMessage") + maildir[mid].add_flag("T") -def search_mails(f, req): - r = jwebmail.SearchReq() - r.ParseFromString(req) + def AddFolder(self, name): + if self.maildir is None: + raise NotInitializedError() - if r.folder: - f = f.get_folder(r.folder) - - f.set_msgtype("EmailMessage") - - res = [ - jwebmail.ListMailHeader( - header=_get_head_info(msg), + name = path.join( + self.maildir._path, "." + name.translate(str.maketrans("/", ".")) ) - for msg in f.values() - if _matches(msg, r.pattern) - ] - return jwebmail.SearchResp(found=res).SerializeToString() - -def folders(f, req): - r = jwebmail.FoldersReq() - r.ParseFromString(req) - return jwebmail.FoldersResp(folders=f.list_folders()).SerializeToString() - - -def move_mail(f, req): - r = jwebmail.MoveReq() - r.ParseFromString(req) - - if r.from_f: - f = f.get_folder(r.from_f) - - fname = Path(f.get_filename(r.mid)) - - assert r.to_f in f.list_folders() or r.to_f == "" - - sep = -2 if not r.from_f else -3 - - if r.to_f: - res = fname.parts[:sep] + ("." + r.to_f,) + fname.parts[-2:] - else: - res = fname.parts[:sep] + fname.parts[-2:] - - fname.rename(Path(*res)) - - return jwebmail.MoveResp().SerializeToString() - - -def remove_mail(f, req): - r = jwebmail.RemoveReq() - r.ParseFromString(req) - - if r.folder: - f = f.get_folder(r.folder) - - f.set_msgtype("MaildirMessage") + if path.isdir(name): + return {"status": "skipped"} + + mkdir(name) + mkdir(path.join(name, "cur")) + mkdir(path.join(name, "new")) + mkdir(path.join(name, "tmp")) + + return {"status": "created"} + + @staticmethod + def _adr(addrs): + if addrs is None: + return [] + + ret = [] + for addr in addrs.addresses: + struct = {"address": addr.addr_spec} + if addr.display_name: + struct["name"] = addr.display_name + ret.append(struct) + return ret + + @staticmethod + def _get_mime_head_info(msg): + mh = dict( + mime_type={ + "main_type": msg.get_content_maintype(), + "sub_type": msg.get_content_subtype(), + } + ) + if (cd := msg.get_content_disposition()) == "inline": + mh["content_dispo"] = "inline" + elif cd == "attachment": + mh["content_dispo"] = "attachment" + elif cd is None: + mh["content_dispo"] = "none" + else: + assert False + + if fn := msg.get_filename(): + mh["file_name"] = fn + + return mh + + def _get_head_info(self, msg): + mh = dict( + send_date=msg["date"].datetime.isoformat(), + written_from=self._adr(msg["from"]), + reply_to=self._adr(msg["reply-to"]), + send_to=self._adr(msg["to"]), + cc=self._adr(msg["cc"]), + bcc=self._adr(msg["bcc"]), + subject=msg["subject"], + comments=msg["comments"], + keywords=msg["keywords"], + mime=self._get_mime_head_info(msg), + ) - f[r.mid].add_flag("T") + if s := self._adr(msg["sender"]): + mh.sender = s[0] - return jwebmail.RemoveResp().SerializeToString() + return mh + def _get_body(self, mail): + if not mail.is_multipart(): + if mail.get_content_maintype() == "text": + return dict(discrete=mail.get_content()) + else: + ret = mail.get_content() + if ret.isascii(): + return dict(discrete=ret.decode(encoding="ASCII")) + elif len(ret) <= 128 * 1024: + return dict(discrete=b64encode(ret).decode(encoding="ASCII")) + else: + raise ValueError( + "non attachment part too large (>512kB)", size=len(ret) + ) -def add_folder(f, req): - r = jwebmail.AddFolderReq() - r.ParseFromString(req) + if (mctype := mail.get_content_maintype()) == "message": + msg = mail.get_content() + return dict( + mail=dict(head=self._get_head_info(msg), body=self._get_body(msg)) + ) + elif mctype == "multipart": + ret = dict( + preamble=mail.preamble, + parts=[], + epilogue=mail.epilogue, + ) + for part in mail.iter_parts(): + head = self._get_mime_head_info(part) + if head["content_dispo"] != "attachment": + body = self._get_body(part) + else: + body = None + ret["parts"].append(dict(mime_header=head, body=body)) + return dict(multipart=ret) + else: + raise ValueError(f"unknown major content-type {mctype!r}") + + def _descent(self, xx): + head = self._get_mime_head_info(xx) + if (mctype := head["mime_type"]["main_type"]) == "message": + body = xx.get_content() + elif mctype == "multipart": + body = xx.iter_parts() + else: + body = xx.get_content() + return head, body + + @staticmethod + def _matches(m, pattern): + if m.is_multipart(): + return any( + 1 + for part in m.body.parts + if re.search(pattern, part.decoded()) + or re.search(pattern, part.subject) + ) + return re.search(pattern, m.body.decoded()) or re.search(pattern, m.subject) - name = path.join(f._path, "." + r.name.translate(str.maketrans("/", "."))) + @staticmethod + def _sort_mails(f, sort): - if path.isdir(name): - return jwebmail.AddFolderResp(status=1).SerializeToString() + match sort["direction"]: + case "asc": + reverse = False + case "desc": + pass + reverse = True + case sort_direct: + raise ValueError(f"unknown sort direction {sort_direct!r}") - mkdir(name) - mkdir(path.join(name, "cur")) - mkdir(path.join(name, "new")) - mkdir(path.join(name, "tmp")) + def _sort_by_sender(midmsg): + _, msg = midmsg - return jwebmail.AddFolderResp(status=0).SerializeToString() + if len(addrs := msg["from"].addresses) == 1: + return addrs[0].addr_spec + else: + return msg["sender"].address.addr_spec + def by_rec_date(midmsg): + return float(re.match(r"\d+\.\d+", midmsg[0], re.ASCII)[0]) -def method_to_run(value): - match value: - case "list": - return list_mails - case "count": - return count_mails - case "read": - return read_mail - case "raw": - return raw_mail - case "folders": - return folders - case "move": - return move_mail - case "remove": - return remove_mail - case "search": - return search_mails - case "add-folder": - return add_folder - case _: - raise ValueError(value) + match sort["parameter"]: + case "date": + keyfn = by_rec_date + case "sender": + keyfn = _sort_by_sender + case "subject": + # fmt: off + def keyfn(midmsg): return midmsg[1]["subject"] + # fmt: on + case "size": + # fmt: off + def keyfn(midmsg): return path.getsize(f.get_filename(midmsg[0])) + # fmt: on + case sort_param: + logging.warning("unknown sort-verb %r", sort_param) + reverse = False + keyfn = by_rec_date + return keyfn, reverse -def parse_arguments(): - ap = ArgumentParser(allow_abbrev=False) - ap.add_argument("maildir_path", type=Path) - ap.add_argument("os_user") - ap.add_argument("mail_user") - ap.add_argument("method", type=method_to_run) - return vars(ap.parse_args()) +class RequestHandler(varlink.RequestHandler): + service = service def main(): @@ -545,25 +557,20 @@ def main(): level="INFO", format="%(levelname)s:" + str(getpid()) + ":%(message)s", ) - args = parse_arguments() - logging.debug("started with %s", args) - s = startup( - args["maildir_path"], - args["os_user"], - args["mail_user"], - args["method"], - ) - logging.debug("setuid successful") - stdout.write("OPEN\n") - stdout.flush() - val = stdin.buffer.read() - run = args["method"] - reply = run(s, val) - logging.debug("pb method(%s) size(%d)", args["method"], len(reply)) - stdout.buffer.write(reply) - # except QMAuthError as qerr: - # errmsg = dict(error=qerr.msg, **qerr.info) - # sysexit(3) + + if environ["LISTEN_FDS"] == "1": + sok = socket.fromfd(3, socket.AF_UNIX, socket.SOCK_STREAM) + RequestHandler(sok, None, None) + return + + types = environ["LISTEN_FDNAMES"].split(":") + assert len(types) == int(environ["LISTEN_FDS"]) + + for i, typ in enumerate(types): + if typ == "varlink": + sok = socket.fromfd(3 + i, socket.AF_UNIX, socket.SOCK_STREAM) + RequestHandler(sok, None, None) + sok.close() except Exception: logging.exception("qmauth.py error") sysexit(4) diff --git a/script/moveto3.py b/script/moveto3.py deleted file mode 100755 index 408ff1a..0000000 --- a/script/moveto3.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import os - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("-a", default="qmail-authuser", dest="pam") - ap.add_argument("fd", type=int) - ap.add_argument("prog") - ap.add_argument("args", nargs="*") - - vals = ap.parse_args() - - if vals.fd < 3: - raise ValueError(f"fd({vals.fd}) must be 3 or greater") - - if vals.fd != 3: - os.dup2(vals.fd, 3) - os.close(vals.fd) - - os.execvp(vals.pam, [vals.pam, vals.prog] + vals.args) - - raise ValueError("should not be reachable") - - -if __name__ == "__main__": - main() diff --git a/src/jwebmail/__init__.py b/src/jwebmail/__init__.py index 67018ce..f48db48 100644 --- a/src/jwebmail/__init__.py +++ b/src/jwebmail/__init__.py @@ -4,7 +4,7 @@ from os import environ from shutil import which from babel import parse_locale -from flask import Flask, abort, g, redirect, url_for +from flask import Flask, abort, g, redirect, request_finished, url_for from flask_babel import Babel, get_locale from flask_login import LoginManager, login_required from flask_wtf.csrf import CSRFProtect @@ -36,7 +36,7 @@ else: toml_read_file = dict(load=toml_load, text=True) -__version__ = "2.6.0.dev5" +__version__ = "2.7.0.dev0" csrf = CSRFProtect() @@ -63,7 +63,9 @@ def create_app(): app.config.from_file(environ["JWEBMAIL_CONFIG"], **toml_read_file) if app.config["JWEBMAIL"].get("PROXY"): - app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) + app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=True, x_proto=True, x_host=True, x_prefix=True + ) validate_config(app) @@ -120,6 +122,12 @@ def create_app(): except ValueError: abort(404) + @request_finished.connect_via(app) + def close_qma(_app, **_): + if "read_mails" in g: + g.read_mails.close() + g.read_mails = None + return app diff --git a/src/jwebmail/model/de.jmhoffmann.jwebmail.mail-storage.varlink b/src/jwebmail/model/de.jmhoffmann.jwebmail.mail-storage.varlink new file mode 100644 index 0000000..64a4073 --- /dev/null +++ b/src/jwebmail/model/de.jmhoffmann.jwebmail.mail-storage.varlink @@ -0,0 +1,85 @@ +interface de.jmhoffmann.jwebmail.mail-storage + + +type MIMEHeader ( + mime_type: (main_type: string, sub_type: string), + content_dispo: (none, inline, attachment), + file_name: ?string +) + +type MailAddr ( + name: ?string, + address: string +) + +# send_date is of ISO 8601 date-time format +type MailHeader ( + send_date: string, + written_from: []MailAddr, + sender: ?MailAddr, + reply_to: []MailAddr, + send_to: []MailAddr, + cc: []MailAddr, + bcc: []MailAddr, + subject: string, + comments: []string, + keywords: []string, + mime: MIMEHeader +) + +type ListMailHeader ( + byte_size: int, + unread: bool, + rec_date: string, + mid: string, + header: MailHeader +) + +type Multipart ( + preamble: ?string, + parts: []MIMEPart, + epilogue: ?string +) + +# exactly one of these fileds must be present +type MailBody ( + discrete: ?string, + multipart: ?Multipart, + mail: ?Mail +) + +type Mail ( + head: MailHeader, + body: MailBody +) + +type MIMEPart ( + mime_header: MIMEHeader, + body: MailBody +) + +type Sort ( + direction: (asc, desc), + parameter: (date, size, sender) +) + + +method Init(unix_user: string, mailbox_path: string) -> () +method List(folder: string, start: int, end: int, sort: Sort) -> (mail_heads: []ListMailHeader) +method Stats(folder: string) -> (mail_count: int, unread_count: int, byte_size: int) +method Show(folder: string, mid: string) -> (mail: Mail) +method Raw(folder: string, mid: string, path: ?string) -> (header: MIMEHeader, body: string) # body is base64 encoded +method Search(folder: string, pattern: string) -> (found: []ListMailHeader) +method Folders() -> (folders: []string) +method Move(mid: string, from_folder: string, to_folder: string) -> () +method Remove(folder: string, mid: string) -> () +method AddFolder(name: string) -> (status: (created, skiped)) + + +error NotIninitialized() +error InvalidFolder(folder: string) +error InvalidMID(folder: string, mid: string) +error InvalidPathInMail(folder: string, mid: string, path: string) +error InvalidSearchPattern(pattern: string) +error InvalidUser(unix_user: string) +error InvalidMailbox(path: string, not_a_mailbox: bool, user_mismatch: bool) diff --git a/src/jwebmail/model/jwebmail.proto b/src/jwebmail/model/jwebmail.proto deleted file mode 100644 index e4cba3b..0000000 --- a/src/jwebmail/model/jwebmail.proto +++ /dev/null @@ -1,165 +0,0 @@ -syntax = "proto3"; - -package jwebmail; - -message MIMEHeader { - - enum ContentDisposition { - CONTENT_DISPOSITION_NONE = 0; - CONTENT_DISPOSITION_INLINE = 1; - CONTENT_DISPOSITION_ATTACHMENT = 2; - } - - string maintype = 1; - string subtype = 2; - ContentDisposition contentdispo = 3; - optional string file_name = 4; -} - -message MailHeader { - - message MailAddr { - optional string name = 1; - string address = 2; - } - - string send_date = 1; - repeated MailAddr written_from = 2; - optional MailAddr sender = 3; - repeated MailAddr reply_to = 4; - repeated MailAddr send_to = 5; - repeated MailAddr cc = 6; - repeated MailAddr bcc = 7; - string subject = 8; - repeated string comments = 9; - repeated string keywords = 10; - MIMEHeader mime = 11; -} - -message ListMailHeader { - uint64 byte_size = 1; - bool unread = 2; - string rec_date = 3; - string mid = 4; - MailHeader header = 5; -} - -message MailBody { - message Multipart { - optional string preamble = 1; - repeated MIMEPart parts = 2; - optional string epilogue = 3; - } - - oneof Body { - string discrete = 1; - Multipart multipart = 2; - Mail mail = 3; - } -} - -message Mail { - MailHeader head = 1; - MailBody body = 2; -} - -message MIMEPart { - MIMEHeader mime_header = 1; - MailBody body = 2; -} - -// Request-Response pairs - -message ListReq { - string folder = 1; - int32 start = 2; - int32 end = 3; - string sort = 4; -} - -message ListResp { - repeated ListMailHeader mail_heads = 1; -} - -message StatsReq { - string folder = 1; -} - -message StatsResp { - uint32 mail_count = 1; - uint32 unread_count = 2; - uint64 byte_size = 3; -} - -message ShowReq { - string folder = 1; - string mid = 2; -} - -message ShowResp { - Mail mail = 1; -} - -message RawReq { - string folder = 1; - string mid = 2; - optional string path = 3; -} - -message RawResp { - MIMEHeader header = 1; - bytes body = 2; -} - -message SearchReq { - string folder = 1; - string pattern = 2; -} - -message SearchResp { - repeated ListMailHeader found = 1; -} - -message FoldersReq { -} - -message FoldersResp { - repeated string folders = 1; -} - -message MoveReq { - string mid = 1; - string from_f = 2; - string to_f = 3; -} - -message MoveResp { -} - -message RemoveReq { - string folder = 1; - string mid = 2; -} - -message RemoveResp { -} - -message AddFolderReq { - string name = 1; -} - -message AddFolderResp { - int32 status = 1; -} - -service MailService { - rpc List(ListReq) returns (ListResp); - rpc Stats(StatsReq) returns (StatsResp); - rpc Show(ShowReq) returns (ShowResp); - rpc Raw(RawReq) returns (RawResp); - rpc Search(SearchReq) returns (SearchResp); - rpc Folders(FoldersReq) returns (FoldersResp); - rpc Move(MoveReq) returns (MoveResp); - rpc Remove(RemoveReq) returns (RemoveResp); - rpc AddFolder(AddFolderReq) returns (AddFolderResp); -} diff --git a/src/jwebmail/model/read_mails.py b/src/jwebmail/model/read_mails.py index 5c63bdd..8a19224 100644 --- a/src/jwebmail/model/read_mails.py +++ b/src/jwebmail/model/read_mails.py @@ -1,16 +1,14 @@ import os -from subprocess import PIPE, Popen, TimeoutExpired -from subprocess import run as subprocess_run +from base64 import b64decode +from socket import socketpair -import jwebmail.model.jwebmail_pb2 as pb2 +import varlink class QMAuthError(Exception): - def __init__(self, msg, rc, response=None): - super().__init__(msg, rc, response) - self.msg = msg + def __init__(self, rc): + super().__init__(rc) self.rc = rc - self.response = response class QMailAuthuser: @@ -24,235 +22,199 @@ class QMailAuthuser: self._virtual_user = virtual_user self._authenticator = authenticator - def verify_user(self): - try: - completed_proc = subprocess_run( - f"{self._authenticator} true 3<&0", - input=f"{self._username}\0{self._password}\0\0".encode(), - shell=True, - timeout=2, - check=False, - ) - if completed_proc.returncode == 0: - return True - elif completed_proc.returncode == 1: - return False - else: - raise QMAuthError("authentication error", completed_proc.returncode) - except TimeoutExpired: - return False + self._pid = None + self._socket = None + self._client = None + self._connection = None def read_headers_for(self, folder, start, end, sort): - req = pb2.ListReq(folder=folder, start=start, end=end, sort=sort) - resp = self.build_and_run("list", req.SerializeToString()) - r = pb2.ListResp() - r.ParseFromString(resp) + sort_val = dict() + if sort[0] == "!": + sort = sort[1:] + sort_val["direction"] = "desc" + else: + sort_val["direction"] = "asc" + + match sort: + case "date" | "subject" | "size": + sort_val["parameter"] = sort + case _: + raise ValueError(f"invalid sort parameter {sort!r}") + + req = self._connection.List(folder=folder, start=start, end=end, sort=sort_val) return [ { - "message_handle": lmh.mid, - "byte_size": lmh.byte_size, - "unread": lmh.unread, - "date_received": lmh.rec_date, - "head": self._mail_header(lmh.header), + "message_handle": lmh["mid"], + "byte_size": lmh["byte_size"], + "unread": lmh["unread"], + "date_received": lmh["rec_date"], + "head": self._mail_header(lmh["header"]), } - for lmh in r.mail_heads + for lmh in req["mail_heads"] ] def count(self, folder): - req = pb2.StatsReq(folder=folder) - resp = self.build_and_run("count", req.SerializeToString()) - r = pb2.StatsResp() - r.ParseFromString(resp) + resp = self._connection.Stats(folder=folder) return { - "byte_size": r.byte_size, - "total_mails": r.mail_count, - "unread_mails": r.unread_count, + "byte_size": resp["byte_size"], + "total_mails": resp["mail_count"], + "unread_mails": resp["unread_count"], } def show(self, folder, msgid): - req = pb2.ShowReq(folder=folder, mid=msgid) - resp = self.build_and_run("read", req.SerializeToString()) - r = pb2.ShowResp() - r.ParseFromString(resp) + resp = self._connection.Show(folder=folder, mid=msgid) return { - "head": self._mail_header(r.mail.head), - "body": self._mail_body(r.mail.body), + "head": self._mail_header(resp["mail"]["head"]), + "body": self._mail_body(resp["mail"]["body"]), } def raw(self, folder, mid, path): - req = pb2.RawReq(folder=folder, mid=mid, path=path) - resp = self.build_and_run("raw", req.SerializeToString()) - r = pb2.RawResp() - r.ParseFromString(resp) - return {"head": self._mime_header(r.header), "body": r.body} + resp = self._connection.Raw(folder=folder, mid=mid, path=path) + return { + "head": self._mime_header(resp["header"]), + "body": b64decode(resp["body"]), + } - # def search(self, pattern, folder): - req = pb2.SearchReq(folder=folder, pattern=pattern) - resp = self.build_and_run("search", req.SerializeToString()) - r = pb2.SearchResp() - r.ParseFromString(resp) - return r + resp = self._connection.Search(folder=folder, pattern=pattern) + return resp def folders(self): - resp = self.build_and_run("folders", pb2.FoldersReq().SerializeToString()) - result = pb2.FoldersResp() - result.ParseFromString(resp) - return list(result.folders) + [""] + resp = self._connection.Folders() + return list(resp["folders"]) + [""] def move(self, mid, from_f, to_f): - req = pb2.MoveReq(mid=mid, from_f=from_f, to_f=to_f) - resp = self.build_and_run("move", req.SerializeToString()) - r = pb2.MoveResp() - r.ParseFromString(resp) + self._connection.Move(mid=mid, from_folder=from_f, to_folder=to_f) return True def remove(self, folder, msgid): - req = pb2.RemoveReq(folder=folder, mid=msgid) - resp = self.build_and_run("remove", req.SerializeToString()) - r = pb2.RemoveResp() - r.ParseFromString(resp) + self._connection.Remove(folder=folder, mid=msgid) return True def add_folder(self, name): - req = pb2.AddFolderReq(name=name) - resp = self.build_and_run("add-folder", req.SerializeToString()) - r = pb2.AddFolderResp() - r.ParseFromString(resp) - return r.status + resp = self._connection.AddFolder(name=name) + return resp["status"] @staticmethod def _address(addr): if not addr: return None - a = {"address": addr.address} - if addr.HasField("name"): - a["display_name"] = addr.name + a = {"address": addr["address"]} + if addr.get("name"): + a["display_name"] = addr["name"] return a - @classmethod - def _mail_header(cls, mail_head): + def _mail_header(self, mail_head): h = mail_head return { - "date": h.send_date, - "sender": cls._address(h.sender), - "cc": [cls._address(x) for x in h.cc if x], - "bcc": [cls._address(x) for x in h.bcc if x], - "from": [cls._address(x) for x in h.written_from if x], - "reply_to": [cls._address(x) for x in h.reply_to if x], - "to": [cls._address(x) for x in h.send_to if x], - "subject": h.subject, - "comments": list(h.comments), - "keywords": list(h.keywords), - "mime": cls._mime_header(h.mime), + "date": h["send_date"], + "sender": self._address(h.get("sender")), + "cc": [self._address(x) for x in h["cc"] if x], + "bcc": [self._address(x) for x in h["bcc"] if x], + "from": [self._address(x) for x in h["written_from"] if x], + "reply_to": [self._address(x) for x in h["reply_to"] if x], + "to": [self._address(x) for x in h["send_to"] if x], + "subject": h["subject"], + "comments": list(h["comments"]), + "keywords": list(h["keywords"]), + "mime": self._mime_header(h["mime"]), } @staticmethod def _mime_header(mime_head): mh = { - "content_maintype": mime_head.maintype, - "content_subtype": mime_head.subtype, + "content_maintype": mime_head["mime_type"]["main_type"], + "content_subtype": mime_head["mime_type"]["sub_type"], } - if ( - mime_head.contentdispo - == pb2.MIMEHeader.ContentDisposition.CONTENT_DISPOSITION_NONE - ): - mh["content_disposition"] = None - elif ( - mime_head.contentdispo - == pb2.MIMEHeader.ContentDisposition.CONTENT_DISPOSITION_INLINE - ): - mh["content_disposition"] = "inline" - elif ( - mime_head.contentdispo - == pb2.MIMEHeader.ContentDisposition.CONTENT_DISPOSITION_ATTACHMENT - ): - mh["content_disposition"] = "attachment" - - if mime_head.HasField("file_name"): - mh["filename"] = mime_head.file_name + match mime_head["content_dispo"]: + case "inline": + mh["content_disposition"] = "inline" + case "attachment": + mh["content_disposition"] = "attachment" + case "none": + mh["content_disposition"] = None + case invalid: + raise ValueError(f"invalid content disposition {invalid!r}") + + if "file_name" in mime_head: + mh["filename"] = mime_head["file_name"] return mh - @classmethod - def _mail_body(cls, body): - if body.WhichOneof("Body") == "discrete": - return body.discrete - elif body.WhichOneof("Body") == "multipart": + def _mail_body(self, body): + assert len(body) == 1 + + if "discrete" in body: + return body["discrete"] + elif "multipart" in body: res = {"parts": []} - for mp in body.multipart.parts: - r = {"head": cls._mime_header(mp.mime_header)} + for mp in body["multipart"]["parts"]: + r = {"head": self._mime_header(mp["mime_header"])} - if ( - mp.mime_header.contentdispo - != pb2.MIMEHeader.ContentDisposition.CONTENT_DISPOSITION_ATTACHMENT - ): - r["body"] = cls._mail_body(mp.body) + if mp["mime_header"]["content_dispo"] != "attachment": + r["body"] = self._mail_body(mp["body"]) res["parts"].append(r) - if body.multipart.HasField("preamble"): - res["preamble"] = body.multipart.preamble - if body.multipart.HasField("epilogue"): - res["epilogue"] = body.multipart.epilogue + if pr := body["multipart"].get("preamble"): + res["preamble"] = pr + if ep := body["multipart"].get("epilogue"): + res["epilogue"] = ep return res - elif body.WhichOneof("Body") == "mail": + elif "mail" in body: return { - "head": cls._mail_header(body.mail.head), - "body": cls._mail_body(body.mail.body), + "head": self._mail_header(body["mail"]["head"]), + "body": self._mail_body(body["mail"]["body"]), } else: assert False - def _build_arg(self, user_mail_addr, mode, rp): - idx = user_mail_addr.find("@") - user_name = user_mail_addr[:idx] - - cmdline = [ - "moveto3.py", - "-a", - self._authenticator, - str(rp), - self._prog, - self._mailbox_path, - self._virtual_user, - user_name, - mode, - ] - - return cmdline - - def _read_qmauth(self, cmd, args, rp, wp): - - with Popen(cmd, stdin=PIPE, stdout=PIPE, pass_fds=[rp], bufsize=0) as popen: - os.close(rp) - os.write(wp, f"{self._username}\0{self._password}\0\0".encode()) + def open(self): + (rp, wp) = os.pipe() + (sp, sc) = socketpair() + cmdline = [self._authenticator, self._prog] + if (pid := os.fork()) == 0: + # child os.close(wp) - r = popen.stdout.read(10) - if popen.poll(): - raise QMAuthError( - "qmail-authuser unexpectedly exited", popen.returncode, r - ) - assert r == b"OPEN\n" - popen.stdin.write(args) - popen.stdin.close() - inp = popen.stdout.readall() - - try: - popen.wait(timeout=2) - except TimeoutExpired: - popen.kill() - popen.poll() - - rc = popen.returncode + sp.close() + os.dup2(rp, 3) + os.dup2(sc.fileno(), 4) + os.environ["LISTEN_FDS"] = "2" + os.environ["LISTEN_FDNAMES"] = "auth:varlink" + os.environ["LISTEN_PID"] = str(os.getpid()) + os.execvp(self._authenticator, cmdline) + assert False + sc.close() + os.close(rp) + os.write(wp, f"{self._username}\0{self._password}\0\0".encode()) + os.close(wp) - if rc == 0: - return inp - elif rc == 3: - raise QMAuthError("error reported by extractor", 3, inp) - else: - raise QMAuthError("got unsuccessful return code by qmail-authuser", rc, inp) + self._pid = pid + self._socket = sp + self._client = varlink.Client() - def build_and_run(self, mode, args): - (rp, wp) = os.pipe() - cmd = self._build_arg(self._username, mode, rp) - return self._read_qmauth(cmd, args, rp, wp) + try: + self._connection = self._client.open( + "de.jmhoffmann.jwebmail.mail-storage", connection=sp + ) + except ConnectionResetError: + (pid, status) = os.waitpid(pid, os.WNOHANG) + if pid != 0: + raise QMAuthError(os.waitstatus_to_exitcode(status)) + else: + raise + + user = self._username[: self._username.index("@")] + self._connection.Init( + unix_user=self._virtual_user, + mailbox_path=os.path.join(self._mailbox_path, user), + ) + return self + + def close(self): + self._connection.close() + self._socket.close() + (pid, status) = os.waitpid(self._pid, 0) + assert pid == self._pid + rc = os.waitstatus_to_exitcode(status) + if rc != 0: + raise QMAuthError(rc) diff --git a/src/jwebmail/read_mails.py b/src/jwebmail/read_mails.py index 54bd139..7775b12 100644 --- a/src/jwebmail/read_mails.py +++ b/src/jwebmail/read_mails.py @@ -5,7 +5,7 @@ from os.path import join as path_join from flask import current_app, g from flask_login import UserMixin, current_user, login_user -from .model.read_mails import QMailAuthuser +from .model.read_mails import QMailAuthuser, QMAuthError EXPIRATION_SEC = 60 * 60 * 25 @@ -163,8 +163,15 @@ def _build_qma(username, password): def login(username, password): - if not _build_qma(username, password).verify_user(): - return False + try: + qma = _build_qma(username, password).open() + except QMAuthError as err: + if err.rc == 1: + return False + else: + raise + finally: + qma.close() r = _select_timeout_session() r.set(username, password) r.close() @@ -186,6 +193,6 @@ def get_read_mails_logged_in(): if "read_mails" in g: return g.read_mails - qma = _build_qma(current_user.get_id(), current_user.password) + qma = _build_qma(current_user.get_id(), current_user.password).open() g.read_mails = qma return qma diff --git a/src/jwebmail/render_mail.py b/src/jwebmail/render_mail.py index 7dc0824..f76c40a 100644 --- a/src/jwebmail/render_mail.py +++ b/src/jwebmail/render_mail.py @@ -66,7 +66,7 @@ def render_multipart(_subtype, content, path): for i, p in enumerate(parts): R += "<div class=media><div class=media-content>\n" if ( - not p["head"]["content_disposition"] + not p["head"].get("content_disposition") or p["head"]["content_disposition"].lower() == "none" or p["head"]["content_disposition"].lower() == "inline" ): @@ -107,7 +107,7 @@ def _format_header(category, value): for v in value: value = ( f'"{v["display_name"]}" <{v["address"]}>' - if v["display_name"] + if v.get("display_name") else v["address"] ) R += f"<dd>{escape(value)}<dd>\n" |