diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f92ce25..32bdc81f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,3 +5,6 @@ We use [git-cliff] to generate the changelog from commit messages before the rel [Conventional Commits]: https://www.conventionalcommits.org/ [git-cliff]: https://git-cliff.org/ + +To update client app version information, +edit [chatmaild/src/chatmaild/defaults/appversions.json](chatmaild/src/chatmaild/defaults/appversions.json). diff --git a/chatmaild/MANIFEST.in b/chatmaild/MANIFEST.in index d5b2f2ff..594c5f12 100644 --- a/chatmaild/MANIFEST.in +++ b/chatmaild/MANIFEST.in @@ -1,3 +1,4 @@ +include src/chatmaild/defaults/*.json include src/chatmaild/ini/*.ini.f include src/chatmaild/ini/*.ini include src/chatmaild/tests/mail-data/* diff --git a/chatmaild/src/chatmaild/defaults/appversions.json b/chatmaild/src/chatmaild/defaults/appversions.json new file mode 100644 index 00000000..eff5a5c4 --- /dev/null +++ b/chatmaild/src/chatmaild/defaults/appversions.json @@ -0,0 +1,15 @@ +{ + "clients": [ + { + "clientId": "deltachat", + "sources": [ + { + "sourceId": "gplay", + "versionInteger": 754, + "versionString": "2.57.0", + "downloadUrl": "https://github.com/deltachat/deltachat-android/releases/download/v2.57.0/deltachat-gplay-release-2.57.0.apk" + } + ] + } + ] +} diff --git a/chatmaild/src/chatmaild/metadata.py b/chatmaild/src/chatmaild/metadata.py index 5ca52169..3452ced5 100644 --- a/chatmaild/src/chatmaild/metadata.py +++ b/chatmaild/src/chatmaild/metadata.py @@ -1,8 +1,10 @@ +import json import logging import socket import sys import time from contextlib import contextmanager +from importlib.resources import files from .config import read_config from .dictproxy import DictProxy @@ -18,6 +20,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. @@ -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 = files(__package__).joinpath("defaults/appversions.json") 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": + 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" diff --git a/chatmaild/src/chatmaild/tests/test_appversions.py b/chatmaild/src/chatmaild/tests/test_appversions.py new file mode 100644 index 00000000..572534f6 --- /dev/null +++ b/chatmaild/src/chatmaild/tests/test_appversions.py @@ -0,0 +1,96 @@ +import json + +import pytest + +from chatmaild.metadata import MetadataDictProxy + +ALLOWED_URL_PREFIXES = ( + "https://github.com/deltachat/", + "https://download.delta.chat/", +) + + +def check_string(value): + assert isinstance(value, str), value + assert value + + +def check_version_integer(value): + # core parses this as u32, see https://github.com/chatmail/core/pull/8557 + assert isinstance(value, int) and not isinstance(value, bool), value + assert 0 <= value < 2**32, value + + +def check_appversions(data): + """Verifies the file the way core parses it. + + core deserializes into typed structs and drops the whole payload + of a relay if a single value has an unexpected type, + while missing or misspelled keys silently turn into defaults. + """ + assert set(data) == {"clients"}, data + assert isinstance(data["clients"], list) + assert data["clients"] + client_ids = [] + for client in data["clients"]: + assert set(client) == {"clientId", "sources"}, client + check_string(client["clientId"]) + client_ids.append(client["clientId"]) + assert isinstance(client["sources"], list) + assert client["sources"] + source_ids = [] + for source in client["sources"]: + assert set(source) == { + "sourceId", + "versionInteger", + "versionString", + "downloadUrl", + }, source + check_string(source["sourceId"]) + source_ids.append(source["sourceId"]) + check_version_integer(source["versionInteger"]) + check_string(source["versionString"]) + check_string(source["downloadUrl"]) + assert source["downloadUrl"].startswith(ALLOWED_URL_PREFIXES) + # core takes the first matching source, later duplicates never surface + assert len(set(source_ids)) == len(source_ids), source_ids + assert len(set(client_ids)) == len(client_ids), client_ids + + +@pytest.fixture +def appversions(): + # check the file which chatmail-metadata actually serves + path = MetadataDictProxy(notifier=None, metadata=None).appversions_path + return json.loads(path.read_text()) + + +def test_appversions_schema(appversions): + check_appversions(appversions) + + +@pytest.mark.parametrize("value", [True, -1, 2**32, "754", 754.0, None]) +def test_version_integer_rejected(appversions, value): + appversions["clients"][0]["sources"][0]["versionInteger"] = value + with pytest.raises(AssertionError): + check_appversions(appversions) + + +@pytest.mark.parametrize("key", ["clientId", "sources"]) +def test_misspelled_client_key_rejected(appversions, key): + client = appversions["clients"][0] + client[key + "s"] = client.pop(key) + with pytest.raises(AssertionError): + check_appversions(appversions) + + +def test_duplicate_source_id_rejected(appversions): + sources = appversions["clients"][0]["sources"] + sources.append(dict(sources[0])) + with pytest.raises(AssertionError): + check_appversions(appversions) + + +def test_foreign_download_url_rejected(appversions): + appversions["clients"][0]["sources"][0]["downloadUrl"] = "https://example.org/x.apk" + with pytest.raises(AssertionError): + check_appversions(appversions) diff --git a/chatmaild/src/chatmaild/tests/test_metadata.py b/chatmaild/src/chatmaild/tests/test_metadata.py index a56852ed..6ac4a7b4 100644 --- a/chatmaild/src/chatmaild/tests/test_metadata.py +++ b/chatmaild/src/chatmaild/tests/test_metadata.py @@ -1,4 +1,5 @@ import io +import json import time import pytest @@ -7,6 +8,7 @@ import requests from chatmaild.metadata import ( Metadata, MetadataDictProxy, + read_appversions, ) from chatmaild.notifier import ( Notifier, @@ -369,6 +371,32 @@ 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":[]}' + + # the value travels as a single dict protocol line + path.write_text('{"clients": [{"clientId": "one\\ntwo"}]}') + assert read_appversions(path) == '{"clients":[{"clientId":"one\\ntwo"}]}' + + path.write_text("bad json") + assert read_appversions(path) is None + + +def test_appversions_lookup(dictproxy): + # the version information shipped with chatmaild is served as a single line + key = b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/appversions" + key += b"\tuser@example.org" + rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO() + dictproxy.loop_forever(rfile, wfile) + value = wfile.getvalue() + assert value.startswith(b"O") and value.endswith(b"\n") + assert json.loads(value[1:])["clients"] + + def test_legacy_token_migration(metadata, testaddr): with metadata.get_metadata_dict(testaddr).modify() as data: data[metadata.DEVICETOKEN_KEY] = ["oldtoken1", "oldtoken2"] diff --git a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py index 9cf79ffc..f15f006a 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py @@ -1,10 +1,12 @@ import ipaddress +import json import re import time import imap_tools import pytest import requests +from chatmaild.tests.test_appversions import check_appversions from cmdeploy.cmdeploy import get_sshexec from cmdeploy.remote import rshell @@ -51,6 +53,17 @@ class TestMetadataTokens: assert res == b"1111 2222" assert b"Getmetadata completed" in client.readline() + def test_get_appversions(self, imap_mailbox): + "get app version information shipped with the relay" + client = imap_mailbox.client + client.send(b'a01 GETMETADATA "" /shared/vendor/deltachat/appversions\n') + res = client.readline() + assert res[:1] == b"*" + res = client.readline().strip().rstrip(b")") + # the served value is a single line and passes the shipped file's schema + check_appversions(json.loads(res)) + assert b"Getmetadata completed" in client.readline() + class TestEndToEndDeltaChat: "Tests that use Delta Chat accounts on the chat mail instance." diff --git a/doc/source/conf.py b/doc/source/conf.py index 24422df4..5523440d 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -22,6 +22,12 @@ extensions = [ templates_path = ['_templates'] exclude_patterns = [] +linkcheck_ignore = [ + # only resolves once the file is merged to main + r"https://github\.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/defaults/appversions\.json", +] + + # -- Options for HTML output ------------------------------------------------- diff --git a/doc/source/overview.rst b/doc/source/overview.rst index 0678e312..9c2dd4cf 100644 --- a/doc/source/overview.rst +++ b/doc/source/overview.rst @@ -249,6 +249,33 @@ Fresh chatmail addresses have a mailbox directory that contains: directories will typically be empty unless the user of that address hasn’t been online for a while. +App version information (experimental) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A chatmail relay ships the +`appversions.json `_ +file of the ``chatmaild`` package +and serves its content under the IMAP METADATA key +``/shared/vendor/deltachat/appversions``. +Chat apps installed outside of app stores read this key +to learn about updates and where to download them. +The mechanism is experimental and may change. + +The file travels with the normal deploy: +update the repository checkout and run ``cmdeploy run``. +Local modifications of ``appversions.json`` are deployed as-is, +so you can serve your own app version information, +including links to app downloads. +There is no automatic refresh: +version information changes only when you deploy again. + +.. note:: + + Note that as of August 2026, only Delta Chat Android Google Play version + is beginning to support discovering app versions from relays. + Generally, consumers of relay-provided app version information + need to verify themselves that downloaded app files are valid. + Active ports ~~~~~~~~~~~~