feat: serve an APPVERSIONS.json index file to clients via IMAP metadata

This is designed to help implement self-updating APKs (and later other clients),
see counterpart https://github.com/chatmail/core/pull/8557
This commit is contained in:
holger krekel
2026-08-12 10:21:56 +02:00
parent dc8e0a34a2
commit 381a1e30e0
11 changed files with 118 additions and 2 deletions
+3
View File
@@ -63,6 +63,9 @@ class Config:
self.turn_socket_path = params.pop(
"turn_socket_path", "/run/chatmail-turn/turn.socket"
)
self.appversions_path = Path(
params.pop("appversions_path", "/usr/local/lib/chatmaild/appversions.json")
)
iroh_relay = params.pop("iroh_relay", None)
if iroh_relay is None:
self.iroh_relay = "https://" + raw_domain
+19
View File
@@ -1,3 +1,4 @@
import json
import logging
import socket
import sys
@@ -18,6 +19,18 @@ def turn_credentials(turn_socket_path):
return file.readline().decode("utf-8").strip()
def read_appversions(path):
try:
data = json.loads(path.read_bytes())
except FileNotFoundError:
return None
except (OSError, ValueError):
logging.exception(f"failed to read {path}")
return None
# the dict protocol is line-based, keep the value single-line
return json.dumps(data, separators=(",", ":"))
def _is_valid_token_timestamp(timestamp, now):
# Token if invalid after 90 days
# or if the timestamp is in the future.
@@ -94,6 +107,7 @@ class MetadataDictProxy(DictProxy):
iroh_relay=None,
turn_hostname=None,
turn_socket_path=None,
appversions_path=None,
):
super().__init__()
self.notifier = notifier
@@ -101,6 +115,7 @@ class MetadataDictProxy(DictProxy):
self.iroh_relay = iroh_relay
self.turn_hostname = turn_hostname
self.turn_socket_path = turn_socket_path
self.appversions_path = appversions_path
def handle_lookup(self, parts):
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
@@ -125,6 +140,9 @@ class MetadataDictProxy(DictProxy):
case "maxsmtprecipients":
# postfix default (see "postconf smtpd_recipient_limit")
return "O1000\n"
case "appversions" if self.appversions_path:
value = read_appversions(self.appversions_path)
return f"O{value}\n" if value else "N\n"
logging.warning(f"lookup ignored: {parts!r}")
return "N\n"
@@ -170,6 +188,7 @@ def main():
iroh_relay=iroh_relay,
turn_hostname=mail_domain,
turn_socket_path=socket_path,
appversions_path=config.appversions_path,
)
dictproxy.serve_forever_from_socket(socket)
@@ -47,6 +47,9 @@ def test_read_config_basic_using_defaults(tmp_path, maildomain):
assert example_config.password_min_length == 9
assert example_config.max_imap_connections == 10000
assert example_config.max_smtp_connections == 1000
assert str(example_config.appversions_path) == (
"/usr/local/lib/chatmaild/appversions.json"
)
assert example_config._unused_keys == []
@@ -7,6 +7,7 @@ import requests
from chatmaild.metadata import (
Metadata,
MetadataDictProxy,
read_appversions,
)
from chatmaild.notifier import (
Notifier,
@@ -369,6 +370,17 @@ def test_iroh_relay(dictproxy):
assert wfile.getvalue() == b"Ohttps://example.org/\n"
def test_read_appversions(tmp_path):
path = tmp_path.joinpath("appversions.json")
assert read_appversions(path) is None
path.write_text('{\n "clients": []\n}')
assert read_appversions(path) == '{"clients":[]}'
path.write_text("bad json")
assert read_appversions(path) is None
def test_legacy_token_migration(metadata, testaddr):
with metadata.get_metadata_dict(testaddr).modify() as data:
data[metadata.DEVICETOKEN_KEY] = ["oldtoken1", "oldtoken2"]