diff --git a/APPVERSIONS.json b/APPVERSIONS.json new file mode 100644 index 00000000..eff5a5c4 --- /dev/null +++ b/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/CONTRIBUTING.md b/CONTRIBUTING.md index 8f92ce25..8ef3cadc 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 [APPVERSIONS.json](APPVERSIONS.json). diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index 21f817e7..0f725a53 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -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 diff --git a/chatmaild/src/chatmaild/metadata.py b/chatmaild/src/chatmaild/metadata.py index 5ca52169..f78e12bc 100644 --- a/chatmaild/src/chatmaild/metadata.py +++ b/chatmaild/src/chatmaild/metadata.py @@ -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) diff --git a/chatmaild/src/chatmaild/tests/test_config.py b/chatmaild/src/chatmaild/tests/test_config.py index 8d9ce4c6..90d5a37f 100644 --- a/chatmaild/src/chatmaild/tests/test_config.py +++ b/chatmaild/src/chatmaild/tests/test_config.py @@ -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 == [] diff --git a/chatmaild/src/chatmaild/tests/test_metadata.py b/chatmaild/src/chatmaild/tests/test_metadata.py index a56852ed..d71b65d8 100644 --- a/chatmaild/src/chatmaild/tests/test_metadata.py +++ b/chatmaild/src/chatmaild/tests/test_metadata.py @@ -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"] diff --git a/cmdeploy/src/cmdeploy/deployers.py b/cmdeploy/src/cmdeploy/deployers.py index b2e8e53c..5028cb08 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -35,7 +35,7 @@ from .nginx.deployer import NginxDeployer from .opendkim.deployer import OpendkimDeployer from .postfix.deployer import PostfixDeployer from .selfsigned.deployer import SelfSignedTlsDeployer -from .www import build_webpages, find_merge_conflict, get_paths +from .www import build_webpages, find_merge_conflict, get_paths, get_reporoot class Port(FactBase): @@ -126,6 +126,11 @@ def _configure_remote_venv_with_chatmaild(deployer, config) -> None: dest=remote_chatmail_inipath, ) + deployer.put_file( + src=get_reporoot().joinpath("APPVERSIONS.json").open("rb"), + dest=str(config.appversions_path), + ) + deployer.remove_file("/etc/cron.d/chatmail-metrics") deployer.remove_file("/var/www/html/metrics") diff --git a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py index 9cf79ffc..aa5abe01 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py @@ -51,6 +51,16 @@ 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")") + assert b'"clients":' in res + assert b"Getmetadata completed" in client.readline() + class TestEndToEndDeltaChat: "Tests that use Delta Chat accounts on the chat mail instance." diff --git a/cmdeploy/src/cmdeploy/tests/test_appversions.py b/cmdeploy/src/cmdeploy/tests/test_appversions.py new file mode 100644 index 00000000..c474a398 --- /dev/null +++ b/cmdeploy/src/cmdeploy/tests/test_appversions.py @@ -0,0 +1,20 @@ +import json + +from cmdeploy.www import get_reporoot + +ALLOWED_URL_PREFIXES = ( + "https://github.com/deltachat/", + "https://download.delta.chat/", +) + + +def test_appversions_schema(): + data = json.loads(get_reporoot().joinpath("APPVERSIONS.json").read_text()) + assert data["clients"] + for client in data["clients"]: + assert isinstance(client["clientId"], str) + for source in client["sources"]: + assert isinstance(source["sourceId"], str) + assert isinstance(source["versionInteger"], int) + assert isinstance(source["versionString"], str) + assert source["downloadUrl"].startswith(ALLOWED_URL_PREFIXES) diff --git a/cmdeploy/src/cmdeploy/www.py b/cmdeploy/src/cmdeploy/www.py index 9b543c6e..8bd3a2ce 100644 --- a/cmdeploy/src/cmdeploy/www.py +++ b/cmdeploy/src/cmdeploy/www.py @@ -35,8 +35,12 @@ def prepare_template(source): return render_vars, page_layout +def get_reporoot() -> Path: + return (Path(__file__).resolve() / "../../../../").resolve() + + def get_paths(config) -> (Path, Path, Path): - reporoot = (Path(__file__).resolve() / "../../../../").resolve() + reporoot = get_reporoot() www_path = Path(config.www_folder) # if www_folder was not set, use default directory if config.www_folder == "": diff --git a/doc/source/overview.rst b/doc/source/overview.rst index 0678e312..8139f813 100644 --- a/doc/source/overview.rst +++ b/doc/source/overview.rst @@ -249,6 +249,28 @@ 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 repository's +`APPVERSIONS.json `_ +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. + +Note that as of August 2026, only Delta Chat Android is beginning +to support discovering app versions from relays. +Consumers of relay-provided app version information +need to verify themselves that downloaded app files are valid. + Active ports ~~~~~~~~~~~~