mirror of
https://github.com/chatmail/relay.git
synced 2026-05-13 09:24:43 +00:00
Compare commits
21 Commits
docs-inter
...
hpk/tokend
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d51a60be57 | ||
|
|
0938b3a1b5 | ||
|
|
4710e0d734 | ||
|
|
d74b25adea | ||
|
|
917da899c6 | ||
|
|
92dbabc23d | ||
|
|
277465462e | ||
|
|
92b7273c71 | ||
|
|
9c31d0762e | ||
|
|
fab5e8a082 | ||
|
|
1da5d91b71 | ||
|
|
c45e98d1dc | ||
|
|
409b2b6919 | ||
|
|
e2a1ddb987 | ||
|
|
89734d99cf | ||
|
|
193c8b2e85 | ||
|
|
8694dce7ec | ||
|
|
0cf092abd5 | ||
|
|
419de239ac | ||
|
|
00bed66660 | ||
|
|
845ee42f76 |
@@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
### Changes since March 15th, 2024
|
### Changes since March 15th, 2024
|
||||||
|
|
||||||
|
- Persist push tokens and support multiple device per address
|
||||||
|
([#254](https://github.com/deltachat/chatmail/pull/254))
|
||||||
|
|
||||||
- Avoid warning for regular doveauth protocol's hello message.
|
- Avoid warning for regular doveauth protocol's hello message.
|
||||||
([#250](https://github.com/deltachat/chatmail/pull/250))
|
([#250](https://github.com/deltachat/chatmail/pull/250))
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ dependencies = [
|
|||||||
"iniconfig",
|
"iniconfig",
|
||||||
"deltachat-rpc-server",
|
"deltachat-rpc-server",
|
||||||
"deltachat-rpc-client",
|
"deltachat-rpc-client",
|
||||||
|
"filelock",
|
||||||
"requests",
|
"requests",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Description=Chatmail dict proxy for IMAP METADATA
|
Description=Chatmail dict proxy for IMAP METADATA
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart={execpath} /run/dovecot/metadata.socket vmail {config_path} /home/vmail/metadata
|
ExecStart={execpath} /run/dovecot/metadata.socket vmail /home/vmail/mail/{mail_domain}
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=30
|
RestartSec=30
|
||||||
|
|
||||||
|
|||||||
35
chatmaild/src/chatmaild/filedict.py
Normal file
35
chatmaild/src/chatmaild/filedict.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import filelock
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
|
||||||
|
class FileDict:
|
||||||
|
"""Concurrency-safe multi-reader/single-writer persistent dict."""
|
||||||
|
|
||||||
|
def __init__(self, path):
|
||||||
|
self.path = path
|
||||||
|
self.lock_path = path.with_name(path.name + ".lock")
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def modify(self):
|
||||||
|
# the OS will release the lock if the process dies,
|
||||||
|
# and the contextmanager will otherwise guarantee release
|
||||||
|
with filelock.FileLock(self.lock_path):
|
||||||
|
data = self.read()
|
||||||
|
yield data
|
||||||
|
write_path = self.path.with_name(self.path.name + ".tmp")
|
||||||
|
with write_path.open("w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
os.rename(write_path, self.path)
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
try:
|
||||||
|
with self.path.open("r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
except Exception:
|
||||||
|
logging.warning("corrupt serialization state at: %r", self.path)
|
||||||
|
return {}
|
||||||
@@ -1,89 +1,93 @@
|
|||||||
import pwd
|
import pwd
|
||||||
|
|
||||||
import pathlib
|
from pathlib import Path
|
||||||
from queue import Queue
|
from threading import Thread, Event
|
||||||
from threading import Thread
|
|
||||||
from socketserver import (
|
from socketserver import (
|
||||||
UnixStreamServer,
|
UnixStreamServer,
|
||||||
StreamRequestHandler,
|
StreamRequestHandler,
|
||||||
ThreadingMixIn,
|
ThreadingMixIn,
|
||||||
)
|
)
|
||||||
from .config import read_config
|
|
||||||
import sys
|
import sys
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import requests
|
import requests
|
||||||
import marshal
|
|
||||||
|
from .filedict import FileDict
|
||||||
|
|
||||||
|
|
||||||
|
DICTPROXY_HELLO_CHAR = "H"
|
||||||
DICTPROXY_LOOKUP_CHAR = "L"
|
DICTPROXY_LOOKUP_CHAR = "L"
|
||||||
DICTPROXY_ITERATE_CHAR = "I"
|
DICTPROXY_ITERATE_CHAR = "I"
|
||||||
DICTPROXY_SET_CHAR = "S"
|
|
||||||
DICTPROXY_BEGIN_TRANSACTION_CHAR = "B"
|
DICTPROXY_BEGIN_TRANSACTION_CHAR = "B"
|
||||||
|
DICTPROXY_SET_CHAR = "S"
|
||||||
DICTPROXY_COMMIT_TRANSACTION_CHAR = "C"
|
DICTPROXY_COMMIT_TRANSACTION_CHAR = "C"
|
||||||
DICTPROXY_TRANSACTION_CHARS = "SBC"
|
DICTPROXY_TRANSACTION_CHARS = "BSC"
|
||||||
|
|
||||||
|
# each SETMETADATA on this key appends to a list of unique device tokens
|
||||||
|
# which only ever get removed if the upstream indicates the token is invalid
|
||||||
|
METADATA_TOKEN_KEY = "devicetoken"
|
||||||
|
|
||||||
|
|
||||||
class Notifier:
|
class Notifier:
|
||||||
def __init__(self, metadata_dir):
|
def __init__(self, vmail_dir):
|
||||||
self.metadata_dir = metadata_dir
|
self.vmail_dir = vmail_dir
|
||||||
self.to_notify_queue = Queue()
|
self.notification_dir = vmail_dir / "pending_notifications"
|
||||||
|
if not self.notification_dir.exists():
|
||||||
|
self.notification_dir.mkdir()
|
||||||
|
self.message_arrived_event = Event()
|
||||||
|
|
||||||
def get_metadata(self, guid):
|
def get_metadata_dict(self, addr):
|
||||||
guid_path = self.metadata_dir.joinpath(guid)
|
return FileDict(self.vmail_dir / addr / "metadata.json")
|
||||||
if guid_path.exists():
|
|
||||||
with guid_path.open("rb") as f:
|
|
||||||
return marshal.load(f)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def set_metadata(self, guid, guid_data):
|
def add_token(self, addr, token):
|
||||||
guid_path = self.metadata_dir.joinpath(guid)
|
with self.get_metadata_dict(addr).modify() as data:
|
||||||
write_path = guid_path.with_suffix(".tmp")
|
tokens = data.get(METADATA_TOKEN_KEY)
|
||||||
with write_path.open("wb") as f:
|
if tokens is None:
|
||||||
marshal.dump(guid_data, f)
|
data[METADATA_TOKEN_KEY] = [token]
|
||||||
os.rename(write_path, guid_path)
|
elif token not in tokens:
|
||||||
|
tokens.append(token)
|
||||||
|
|
||||||
def set_token(self, guid, token):
|
def remove_token(self, addr, token):
|
||||||
guid_data = self.get_metadata(guid)
|
with self.get_metadata_dict(addr).modify() as data:
|
||||||
guid_data["token"] = token
|
tokens = data.get(METADATA_TOKEN_KEY, [])
|
||||||
self.set_metadata(guid, guid_data)
|
try:
|
||||||
|
tokens.remove(token)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
def del_token(self, guid):
|
def get_tokens(self, addr):
|
||||||
guid_data = self.get_metadata(guid)
|
return self.get_metadata_dict(addr).read().get(METADATA_TOKEN_KEY, [])
|
||||||
if "token" in guid_data:
|
|
||||||
del guid_data["token"]
|
|
||||||
self.set_metadata(guid, guid_data)
|
|
||||||
|
|
||||||
def get_token(self, guid):
|
def new_message_for_addr(self, addr):
|
||||||
return self.get_metadata(guid).get("token")
|
self.notification_dir.joinpath(addr).touch()
|
||||||
|
self.message_arrived_event.set()
|
||||||
def new_message_for_guid(self, guid):
|
|
||||||
self.to_notify_queue.put(guid)
|
|
||||||
|
|
||||||
def thread_run_loop(self):
|
def thread_run_loop(self):
|
||||||
requests_session = requests.Session()
|
requests_session = requests.Session()
|
||||||
while 1:
|
while 1:
|
||||||
|
self.message_arrived_event.wait()
|
||||||
|
self.message_arrived_event.clear()
|
||||||
self.thread_run_one(requests_session)
|
self.thread_run_one(requests_session)
|
||||||
|
|
||||||
def thread_run_one(self, requests_session):
|
def thread_run_one(self, requests_session):
|
||||||
guid = self.to_notify_queue.get()
|
for addr_path in self.notification_dir.iterdir():
|
||||||
token = self.get_token(guid)
|
addr = addr_path.name
|
||||||
if token:
|
if "@" not in addr:
|
||||||
response = requests_session.post(
|
continue
|
||||||
"https://notifications.delta.chat/notify",
|
for token in self.get_tokens(addr):
|
||||||
data=token,
|
response = requests_session.post(
|
||||||
timeout=60,
|
"https://notifications.delta.chat/notify",
|
||||||
)
|
data=token,
|
||||||
if response.status_code == 410:
|
timeout=60,
|
||||||
# 410 Gone status code
|
)
|
||||||
# means the token is no longer valid.
|
if response.status_code == 410:
|
||||||
self.del_token(guid)
|
# 410 Gone status code
|
||||||
|
# means the token is no longer valid.
|
||||||
|
self.remove_token(addr, token)
|
||||||
|
addr_path.unlink()
|
||||||
|
|
||||||
|
|
||||||
def handle_dovecot_protocol(rfile, wfile, notifier):
|
def handle_dovecot_protocol(rfile, wfile, notifier):
|
||||||
# HELLO message, ignored.
|
|
||||||
msg = rfile.readline().strip().decode()
|
|
||||||
|
|
||||||
transactions = {}
|
transactions = {}
|
||||||
while True:
|
while True:
|
||||||
msg = rfile.readline().strip().decode()
|
msg = rfile.readline().strip().decode()
|
||||||
@@ -101,44 +105,52 @@ def handle_dovecot_request(msg, transactions, notifier):
|
|||||||
short_command = msg[0]
|
short_command = msg[0]
|
||||||
parts = msg[1:].split("\t")
|
parts = msg[1:].split("\t")
|
||||||
if short_command == DICTPROXY_LOOKUP_CHAR:
|
if short_command == DICTPROXY_LOOKUP_CHAR:
|
||||||
|
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
|
||||||
|
keyparts = parts[0].split("/")
|
||||||
|
if keyparts[0] == "priv":
|
||||||
|
keyname = keyparts[2]
|
||||||
|
addr = parts[1]
|
||||||
|
if keyname == METADATA_TOKEN_KEY:
|
||||||
|
res = " ".join(notifier.get_tokens(addr))
|
||||||
|
return f"O{res}\n"
|
||||||
|
logging.warning("lookup ignored: %r", msg)
|
||||||
return "N\n"
|
return "N\n"
|
||||||
elif short_command == DICTPROXY_ITERATE_CHAR:
|
elif short_command == DICTPROXY_ITERATE_CHAR:
|
||||||
# Empty line means ITER_FINISHED.
|
# Empty line means ITER_FINISHED.
|
||||||
# If we don't return empty line Dovecot will timeout.
|
# If we don't return empty line Dovecot will timeout.
|
||||||
return "\n"
|
return "\n"
|
||||||
|
elif short_command == DICTPROXY_HELLO_CHAR:
|
||||||
|
return # no version checking
|
||||||
|
|
||||||
if short_command not in (DICTPROXY_TRANSACTION_CHARS):
|
if short_command not in (DICTPROXY_TRANSACTION_CHARS):
|
||||||
|
logging.warning("unknown dictproxy request: %r", msg)
|
||||||
return
|
return
|
||||||
|
|
||||||
transaction_id = parts[0]
|
transaction_id = parts[0]
|
||||||
|
|
||||||
if short_command == DICTPROXY_BEGIN_TRANSACTION_CHAR:
|
if short_command == DICTPROXY_BEGIN_TRANSACTION_CHAR:
|
||||||
transactions[transaction_id] = "O\n"
|
addr = parts[1]
|
||||||
|
transactions[transaction_id] = dict(addr=addr, res="O\n")
|
||||||
elif short_command == DICTPROXY_COMMIT_TRANSACTION_CHAR:
|
elif short_command == DICTPROXY_COMMIT_TRANSACTION_CHAR:
|
||||||
# returns whether it failed or succeeded.
|
# each set devicetoken operation persists directly
|
||||||
return transactions.pop(transaction_id, "N\n")
|
# and does not wait until a "commit" comes
|
||||||
|
# because our dovecot config does not involve
|
||||||
|
# multiple set-operations in a single commit
|
||||||
|
return transactions.pop(transaction_id)["res"]
|
||||||
elif short_command == DICTPROXY_SET_CHAR:
|
elif short_command == DICTPROXY_SET_CHAR:
|
||||||
# See header of
|
# For documentation on key structure see
|
||||||
# <https://github.com/dovecot/core/blob/5e7965632395793d9355eb906b173bf28d2a10ca/src/lib-storage/mailbox-attribute.h>
|
# https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h
|
||||||
# for the documentation on the structure of the key.
|
|
||||||
|
|
||||||
# Request GETMETADATA "INBOX" /private/chatmail
|
|
||||||
# results in a query for
|
|
||||||
# priv/dd72550f05eadc65542a1200cac67ad7/chatmail
|
|
||||||
#
|
|
||||||
# Request GETMETADATA "" /private/chatmail
|
|
||||||
# results in
|
|
||||||
# priv/dd72550f05eadc65542a1200cac67ad7/vendor/vendor.dovecot/pvt/server/chatmail
|
|
||||||
|
|
||||||
keyname = parts[1].split("/")
|
keyname = parts[1].split("/")
|
||||||
value = parts[2] if len(parts) > 2 else ""
|
value = parts[2] if len(parts) > 2 else ""
|
||||||
if keyname[0] == "priv" and keyname[2] == "devicetoken":
|
addr = transactions[transaction_id]["addr"]
|
||||||
notifier.set_token(keyname[1], value)
|
if keyname[0] == "priv" and keyname[2] == METADATA_TOKEN_KEY:
|
||||||
|
notifier.add_token(addr, value)
|
||||||
elif keyname[0] == "priv" and keyname[2] == "messagenew":
|
elif keyname[0] == "priv" and keyname[2] == "messagenew":
|
||||||
notifier.new_message_for_guid(keyname[1])
|
notifier.new_message_for_addr(addr)
|
||||||
else:
|
else:
|
||||||
# Transaction failed.
|
# Transaction failed.
|
||||||
transactions[transaction_id] = "F\n"
|
transactions[transaction_id]["res"] = "F\n"
|
||||||
|
|
||||||
|
|
||||||
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
|
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
|
||||||
@@ -146,22 +158,23 @@ class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
socket, username, config, metadata_dir = sys.argv[1:]
|
socket, username, vmail_dir = sys.argv[1:]
|
||||||
passwd_entry = pwd.getpwnam(username)
|
passwd_entry = pwd.getpwnam(username)
|
||||||
|
|
||||||
# XXX config is not currently used
|
vmail_dir = Path(vmail_dir)
|
||||||
config = read_config(config)
|
|
||||||
metadata_dir = pathlib.Path(metadata_dir)
|
if not vmail_dir.exists():
|
||||||
if not metadata_dir.exists():
|
logging.error("vmail dir does not exist: %r", vmail_dir)
|
||||||
metadata_dir.mkdir()
|
return 1
|
||||||
notifier = Notifier(metadata_dir)
|
|
||||||
|
notifier = Notifier(vmail_dir)
|
||||||
|
|
||||||
class Handler(StreamRequestHandler):
|
class Handler(StreamRequestHandler):
|
||||||
def handle(self):
|
def handle(self):
|
||||||
try:
|
try:
|
||||||
handle_dovecot_protocol(self.rfile, self.wfile, notifier)
|
handle_dovecot_protocol(self.rfile, self.wfile, notifier)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Exception in the handler")
|
logging.exception("Exception in the dovecot dictproxy handler")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -175,6 +188,8 @@ def main():
|
|||||||
t = Thread(target=notifier.thread_run_loop)
|
t = Thread(target=notifier.thread_run_loop)
|
||||||
t.setDaemon(True)
|
t.setDaemon(True)
|
||||||
t.start()
|
t.start()
|
||||||
|
# let notifier thread run once for any pending notifications from last run
|
||||||
|
notifier.message_arrived_event.set()
|
||||||
|
|
||||||
with ThreadedUnixStreamServer(socket, Handler) as server:
|
with ThreadedUnixStreamServer(socket, Handler) as server:
|
||||||
os.chown(socket, uid=passwd_entry.pw_uid, gid=passwd_entry.pw_gid)
|
os.chown(socket, uid=passwd_entry.pw_uid, gid=passwd_entry.pw_gid)
|
||||||
|
|||||||
19
chatmaild/src/chatmaild/tests/test_filedict.py
Normal file
19
chatmaild/src/chatmaild/tests/test_filedict.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from chatmaild.filedict import FileDict
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic(tmp_path):
|
||||||
|
fdict = FileDict(tmp_path.joinpath("metadata"))
|
||||||
|
assert fdict.read() == {}
|
||||||
|
with fdict.modify() as d:
|
||||||
|
d["devicetoken"] = [1, 2, 3]
|
||||||
|
d["456"] = 4.2
|
||||||
|
new = fdict.read()
|
||||||
|
assert new["devicetoken"] == [1, 2, 3]
|
||||||
|
assert new["456"] == 4.2
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_marshal_file(tmp_path, caplog):
|
||||||
|
fdict1 = FileDict(tmp_path.joinpath("metadata"))
|
||||||
|
fdict1.path.write_bytes(b"l12k3l12k3l")
|
||||||
|
assert fdict1.read() == {}
|
||||||
|
assert "corrupt" in caplog.records[0].msg
|
||||||
@@ -10,67 +10,84 @@ from chatmaild.metadata import (
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def notifier(tmp_path):
|
def notifier(tmp_path):
|
||||||
metadata_dir = tmp_path.joinpath("metadata")
|
vmail_dir = tmp_path.joinpath("vmaildir")
|
||||||
metadata_dir.mkdir()
|
vmail_dir.mkdir()
|
||||||
return Notifier(metadata_dir)
|
return Notifier(vmail_dir)
|
||||||
|
|
||||||
|
|
||||||
def test_notifier_persistence(tmp_path):
|
@pytest.fixture
|
||||||
metadata_dir = tmp_path.joinpath("metadata")
|
def testaddr():
|
||||||
metadata_dir.mkdir()
|
return "user.name@example.org"
|
||||||
notifier1 = Notifier(metadata_dir)
|
|
||||||
notifier2 = Notifier(metadata_dir)
|
|
||||||
assert notifier1.get_token(guid="guid00") is None
|
|
||||||
assert notifier2.get_token(guid="guid00") is None
|
|
||||||
|
|
||||||
notifier1.set_token("guid00", "01234")
|
|
||||||
notifier1.set_token("guid03", "456")
|
|
||||||
assert notifier2.get_token("guid00") == "01234"
|
|
||||||
assert notifier2.get_token("guid03") == "456"
|
|
||||||
notifier2.del_token("guid00")
|
|
||||||
assert notifier1.get_token("guid00") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_dovecot_request_lookup_fails(notifier):
|
@pytest.fixture
|
||||||
res = handle_dovecot_request("Lpriv/123/chatmail", {}, notifier)
|
def testaddr2():
|
||||||
|
return "user2@example.org"
|
||||||
|
|
||||||
|
|
||||||
|
def test_notifier_persistence(tmp_path, testaddr, testaddr2):
|
||||||
|
notifier1 = Notifier(tmp_path)
|
||||||
|
notifier2 = Notifier(tmp_path)
|
||||||
|
assert not notifier1.get_tokens(testaddr)
|
||||||
|
assert not notifier2.get_tokens(testaddr)
|
||||||
|
|
||||||
|
notifier1.add_token(testaddr, "01234")
|
||||||
|
notifier1.add_token(testaddr2, "456")
|
||||||
|
assert notifier2.get_tokens(testaddr) == ["01234"]
|
||||||
|
assert notifier2.get_tokens(testaddr2) == ["456"]
|
||||||
|
notifier2.remove_token(testaddr, "01234")
|
||||||
|
assert not notifier1.get_tokens(testaddr)
|
||||||
|
assert notifier1.get_tokens(testaddr2) == ["456"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_nonexisting(tmp_path, testaddr):
|
||||||
|
notifier1 = Notifier(tmp_path)
|
||||||
|
notifier1.add_token(testaddr, "123")
|
||||||
|
notifier1.remove_token(testaddr, "1l23k1l2k3")
|
||||||
|
assert notifier1.get_tokens(testaddr) == ["123"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_notifier_delete_without_set(notifier, testaddr):
|
||||||
|
notifier.remove_token(testaddr, "123")
|
||||||
|
assert not notifier.get_tokens(testaddr)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_dovecot_request_lookup_fails(notifier, testaddr):
|
||||||
|
res = handle_dovecot_request(f"Lpriv/123/chatmail\t{testaddr}", {}, notifier)
|
||||||
assert res == "N\n"
|
assert res == "N\n"
|
||||||
|
|
||||||
|
|
||||||
def test_handle_dovecot_request_happy_path(notifier):
|
def test_handle_dovecot_request_happy_path(notifier, testaddr):
|
||||||
transactions = {}
|
transactions = {}
|
||||||
|
|
||||||
# lookups return the same NOTFOUND result
|
|
||||||
res = handle_dovecot_request("Lpriv/123/chatmail", transactions, notifier)
|
|
||||||
assert res == "N\n"
|
|
||||||
assert notifier.get_token("guid00") is None and not transactions
|
|
||||||
|
|
||||||
# set device token in a transaction
|
# set device token in a transaction
|
||||||
tx = "1111"
|
tx = "1111"
|
||||||
msg = f"B{tx}\tuser"
|
msg = f"B{tx}\t{testaddr}"
|
||||||
res = handle_dovecot_request(msg, transactions, notifier)
|
res = handle_dovecot_request(msg, transactions, notifier)
|
||||||
assert not res and notifier.get_token("guid00") is None
|
assert not res and not notifier.get_tokens(testaddr)
|
||||||
assert transactions == {tx: "O\n"}
|
assert transactions == {tx: dict(addr=testaddr, res="O\n")}
|
||||||
|
|
||||||
msg = f"S{tx}\tpriv/guid00/devicetoken\t01234"
|
msg = f"S{tx}\tpriv/guid00/devicetoken\t01234"
|
||||||
res = handle_dovecot_request(msg, transactions, notifier)
|
res = handle_dovecot_request(msg, transactions, notifier)
|
||||||
assert not res
|
assert not res
|
||||||
assert len(transactions) == 1
|
assert len(transactions) == 1
|
||||||
assert notifier.get_token("guid00") == "01234"
|
assert notifier.get_tokens(testaddr) == ["01234"]
|
||||||
|
|
||||||
msg = f"C{tx}"
|
msg = f"C{tx}"
|
||||||
res = handle_dovecot_request(msg, transactions, notifier)
|
res = handle_dovecot_request(msg, transactions, notifier)
|
||||||
assert res == "O\n"
|
assert res == "O\n"
|
||||||
assert len(transactions) == 0
|
assert len(transactions) == 0
|
||||||
assert notifier.get_token("guid00") == "01234"
|
assert notifier.get_tokens(testaddr) == ["01234"]
|
||||||
|
|
||||||
# trigger notification for incoming message
|
# trigger notification for incoming message
|
||||||
assert handle_dovecot_request(f"B{tx}\tuser", transactions, notifier) is None
|
tx2 = "2222"
|
||||||
msg = f"S{tx}\tpriv/guid00/messagenew"
|
assert handle_dovecot_request(f"B{tx2}\t{testaddr}", transactions, notifier) is None
|
||||||
|
msg = f"S{tx2}\tpriv/guid00/messagenew"
|
||||||
assert handle_dovecot_request(msg, transactions, notifier) is None
|
assert handle_dovecot_request(msg, transactions, notifier) is None
|
||||||
assert notifier.to_notify_queue.get() == "guid00"
|
assert notifier.message_arrived_event.is_set()
|
||||||
assert notifier.to_notify_queue.qsize() == 0
|
assert handle_dovecot_request(f"C{tx2}", transactions, notifier) == "O\n"
|
||||||
assert handle_dovecot_request(f"C{tx}\tuser", transactions, notifier) == "O\n"
|
|
||||||
assert not transactions
|
assert not transactions
|
||||||
|
assert notifier.notification_dir.joinpath(testaddr).exists()
|
||||||
|
|
||||||
|
|
||||||
def test_handle_dovecot_protocol_set_devicetoken(notifier):
|
def test_handle_dovecot_protocol_set_devicetoken(notifier):
|
||||||
@@ -78,7 +95,7 @@ def test_handle_dovecot_protocol_set_devicetoken(notifier):
|
|||||||
b"\n".join(
|
b"\n".join(
|
||||||
[
|
[
|
||||||
b"HELLO",
|
b"HELLO",
|
||||||
b"Btx00\tuser",
|
b"Btx00\tuser@example.org",
|
||||||
b"Stx00\tpriv/guid00/devicetoken\t01234",
|
b"Stx00\tpriv/guid00/devicetoken\t01234",
|
||||||
b"Ctx00",
|
b"Ctx00",
|
||||||
]
|
]
|
||||||
@@ -86,8 +103,32 @@ def test_handle_dovecot_protocol_set_devicetoken(notifier):
|
|||||||
)
|
)
|
||||||
wfile = io.BytesIO()
|
wfile = io.BytesIO()
|
||||||
handle_dovecot_protocol(rfile, wfile, notifier)
|
handle_dovecot_protocol(rfile, wfile, notifier)
|
||||||
assert notifier.get_token("guid00") == "01234"
|
|
||||||
assert wfile.getvalue() == b"O\n"
|
assert wfile.getvalue() == b"O\n"
|
||||||
|
assert notifier.get_tokens("user@example.org") == ["01234"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_dovecot_protocol_set_get_devicetoken(notifier):
|
||||||
|
rfile = io.BytesIO(
|
||||||
|
b"\n".join(
|
||||||
|
[
|
||||||
|
b"HELLO",
|
||||||
|
b"Btx00\tuser@example.org",
|
||||||
|
b"Stx00\tpriv/guid00/devicetoken\t01234",
|
||||||
|
b"Ctx00",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
wfile = io.BytesIO()
|
||||||
|
handle_dovecot_protocol(rfile, wfile, notifier)
|
||||||
|
assert notifier.get_tokens("user@example.org") == ["01234"]
|
||||||
|
assert wfile.getvalue() == b"O\n"
|
||||||
|
|
||||||
|
rfile = io.BytesIO(
|
||||||
|
b"\n".join([b"HELLO", b"Lpriv/0123/devicetoken\tuser@example.org"])
|
||||||
|
)
|
||||||
|
wfile = io.BytesIO()
|
||||||
|
handle_dovecot_protocol(rfile, wfile, notifier)
|
||||||
|
assert wfile.getvalue() == b"O01234\n"
|
||||||
|
|
||||||
|
|
||||||
def test_handle_dovecot_protocol_iterate(notifier):
|
def test_handle_dovecot_protocol_iterate(notifier):
|
||||||
@@ -109,7 +150,7 @@ def test_handle_dovecot_protocol_messagenew(notifier):
|
|||||||
b"\n".join(
|
b"\n".join(
|
||||||
[
|
[
|
||||||
b"HELLO",
|
b"HELLO",
|
||||||
b"Btx01\tuser",
|
b"Btx01\tuser@example.org",
|
||||||
b"Stx01\tpriv/guid00/messagenew",
|
b"Stx01\tpriv/guid00/messagenew",
|
||||||
b"Ctx01",
|
b"Ctx01",
|
||||||
]
|
]
|
||||||
@@ -118,11 +159,11 @@ def test_handle_dovecot_protocol_messagenew(notifier):
|
|||||||
wfile = io.BytesIO()
|
wfile = io.BytesIO()
|
||||||
handle_dovecot_protocol(rfile, wfile, notifier)
|
handle_dovecot_protocol(rfile, wfile, notifier)
|
||||||
assert wfile.getvalue() == b"O\n"
|
assert wfile.getvalue() == b"O\n"
|
||||||
assert notifier.to_notify_queue.get() == "guid00"
|
assert notifier.message_arrived_event.is_set()
|
||||||
assert notifier.to_notify_queue.qsize() == 0
|
assert notifier.notification_dir.joinpath("user@example.org").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_notifier_thread_run(notifier):
|
def test_notifier_thread_run(notifier, testaddr):
|
||||||
requests = []
|
requests = []
|
||||||
|
|
||||||
class ReqMock:
|
class ReqMock:
|
||||||
@@ -134,15 +175,15 @@ def test_notifier_thread_run(notifier):
|
|||||||
|
|
||||||
return Result()
|
return Result()
|
||||||
|
|
||||||
notifier.set_token("guid00", "01234")
|
notifier.add_token(testaddr, "01234")
|
||||||
notifier.new_message_for_guid("guid00")
|
notifier.new_message_for_addr(testaddr)
|
||||||
notifier.thread_run_one(ReqMock())
|
notifier.thread_run_one(ReqMock())
|
||||||
url, data, timeout = requests[0]
|
url, data, timeout = requests[0]
|
||||||
assert data == "01234"
|
assert data == "01234"
|
||||||
assert notifier.get_token("guid00") == "01234"
|
assert notifier.get_tokens(testaddr) == ["01234"]
|
||||||
|
|
||||||
|
|
||||||
def test_notifier_thread_run_gone_removes_token(notifier):
|
def test_multi_device_notifier(notifier, testaddr):
|
||||||
requests = []
|
requests = []
|
||||||
|
|
||||||
class ReqMock:
|
class ReqMock:
|
||||||
@@ -150,14 +191,40 @@ def test_notifier_thread_run_gone_removes_token(notifier):
|
|||||||
requests.append((url, data, timeout))
|
requests.append((url, data, timeout))
|
||||||
|
|
||||||
class Result:
|
class Result:
|
||||||
status_code = 410
|
status_code = 200
|
||||||
|
|
||||||
return Result()
|
return Result()
|
||||||
|
|
||||||
notifier.set_token("guid00", "01234")
|
notifier.add_token(testaddr, "01234")
|
||||||
notifier.new_message_for_guid("guid00")
|
notifier.add_token(testaddr, "56789")
|
||||||
assert notifier.get_token("guid00") == "01234"
|
notifier.new_message_for_addr(testaddr)
|
||||||
notifier.thread_run_one(ReqMock())
|
notifier.thread_run_one(ReqMock())
|
||||||
url, data, timeout = requests[0]
|
url, data, timeout = requests[0]
|
||||||
assert data == "01234"
|
assert data == "01234"
|
||||||
assert notifier.get_token("guid00") is None
|
url, data, timeout = requests[1]
|
||||||
|
assert data == "56789"
|
||||||
|
assert notifier.get_tokens(testaddr) == ["01234", "56789"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_notifier_thread_run_gone_removes_token(notifier, testaddr):
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
class ReqMock:
|
||||||
|
def post(self, url, data, timeout):
|
||||||
|
requests.append((url, data, timeout))
|
||||||
|
|
||||||
|
class Result:
|
||||||
|
status_code = 410 if data == "01234" else 200
|
||||||
|
|
||||||
|
return Result()
|
||||||
|
|
||||||
|
notifier.add_token(testaddr, "01234")
|
||||||
|
notifier.new_message_for_addr(testaddr)
|
||||||
|
assert notifier.get_tokens(testaddr) == ["01234"]
|
||||||
|
notifier.add_token(testaddr, "45678")
|
||||||
|
notifier.thread_run_one(ReqMock())
|
||||||
|
url, data, timeout = requests[0]
|
||||||
|
assert data == "01234"
|
||||||
|
url, data, timeout = requests[1]
|
||||||
|
assert data == "45678"
|
||||||
|
assert notifier.get_tokens(testaddr) == ["45678"]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ dependencies = [
|
|||||||
"black",
|
"black",
|
||||||
"pytest",
|
"pytest",
|
||||||
"pytest-xdist",
|
"pytest-xdist",
|
||||||
|
"imap_tools",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ def _install_remote_venv_with_chatmaild(config) -> None:
|
|||||||
execpath=f"{remote_venv_dir}/bin/{fn}",
|
execpath=f"{remote_venv_dir}/bin/{fn}",
|
||||||
config_path=remote_chatmail_inipath,
|
config_path=remote_chatmail_inipath,
|
||||||
remote_venv_dir=remote_venv_dir,
|
remote_venv_dir=remote_venv_dir,
|
||||||
|
mail_domain=config.mail_domain,
|
||||||
)
|
)
|
||||||
source_path = importlib.resources.files("chatmaild").joinpath(f"{fn}.service.f")
|
source_path = importlib.resources.files("chatmaild").joinpath(f"{fn}.service.f")
|
||||||
content = source_path.read_text().format(**params).encode()
|
content = source_path.read_text().format(**params).encode()
|
||||||
|
|||||||
@@ -5,6 +5,46 @@ import random
|
|||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import imap_tools
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def imap_mailbox(cmfactory):
|
||||||
|
(ac1,) = cmfactory.get_online_accounts(1)
|
||||||
|
user = ac1.get_config("addr")
|
||||||
|
password = ac1.get_config("mail_pw")
|
||||||
|
mailbox = imap_tools.MailBox(user.split("@")[1])
|
||||||
|
mailbox.login(user, password)
|
||||||
|
return mailbox
|
||||||
|
|
||||||
|
|
||||||
|
class TestMetadataTokens:
|
||||||
|
"Tests that use Metadata extension for storing tokens"
|
||||||
|
|
||||||
|
def test_set_get_metadata(self, imap_mailbox):
|
||||||
|
"set and get metadata token for an account"
|
||||||
|
client = imap_mailbox.client
|
||||||
|
client.send(b'a01 SETMETADATA INBOX (/private/devicetoken "1111" )\n')
|
||||||
|
res = client.readline()
|
||||||
|
assert b"OK Setmetadata completed" in res
|
||||||
|
|
||||||
|
client.send(b"a02 GETMETADATA INBOX /private/devicetoken\n")
|
||||||
|
res = client.readline()
|
||||||
|
assert res[:1] == b"*"
|
||||||
|
res = client.readline().strip().rstrip(b")")
|
||||||
|
assert res == b"1111"
|
||||||
|
assert b"Getmetadata completed" in client.readline()
|
||||||
|
|
||||||
|
client.send(b'a01 SETMETADATA INBOX (/private/devicetoken "2222" )\n')
|
||||||
|
res = client.readline()
|
||||||
|
assert b"OK Setmetadata completed" in res
|
||||||
|
|
||||||
|
client.send(b"a02 GETMETADATA INBOX /private/devicetoken\n")
|
||||||
|
res = client.readline()
|
||||||
|
assert res[:1] == b"*"
|
||||||
|
res = client.readline().strip().rstrip(b")")
|
||||||
|
assert res == b"1111 2222"
|
||||||
|
assert b"Getmetadata completed" in client.readline()
|
||||||
|
|
||||||
|
|
||||||
class TestEndToEndDeltaChat:
|
class TestEndToEndDeltaChat:
|
||||||
@@ -75,7 +115,10 @@ class TestEndToEndDeltaChat:
|
|||||||
)
|
)
|
||||||
lp.indent("good, message sending failed because quota was exceeded")
|
lp.indent("good, message sending failed because quota was exceeded")
|
||||||
return
|
return
|
||||||
if "stored mail into mailbox 'inbox'" in line or "saved mail to inbox" in line:
|
if (
|
||||||
|
"stored mail into mailbox 'inbox'" in line
|
||||||
|
or "saved mail to inbox" in line
|
||||||
|
):
|
||||||
saved_ok += 1
|
saved_ok += 1
|
||||||
print(f"{saved_ok}: {line}")
|
print(f"{saved_ok}: {line}")
|
||||||
if saved_ok >= num_to_send:
|
if saved_ok >= num_to_send:
|
||||||
|
|||||||
Reference in New Issue
Block a user