summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--README_IPC.md49
-rw-r--r--depoly.yml6
-rw-r--r--dev-requirements.txt26
-rw-r--r--pyproject.toml3
-rw-r--r--requirements.txt207
-rwxr-xr-xscript/extract.py805
-rwxr-xr-xscript/moveto3.py28
-rw-r--r--src/jwebmail/__init__.py14
-rw-r--r--src/jwebmail/model/de.jmhoffmann.jwebmail.mail-storage.varlink85
-rw-r--r--src/jwebmail/model/jwebmail.proto165
-rw-r--r--src/jwebmail/model/read_mails.py322
-rw-r--r--src/jwebmail/read_mails.py15
-rw-r--r--src/jwebmail/render_mail.py4
14 files changed, 784 insertions, 950 deletions
diff --git a/.gitignore b/.gitignore
index 7dcb5dc..17ee4e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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.
diff --git a/depoly.yml b/depoly.yml
index 684f593..369ba6d 100644
--- a/depoly.yml
+++ b/depoly.yml
@@ -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"