Compare commits

...

1 Commits

Author SHA1 Message Date
holger krekel 381a1e30e0 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
2026-08-12 10:21:56 +02:00
11 changed files with 118 additions and 2 deletions
+15
View File
@@ -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"
}
]
}
]
}
+3
View File
@@ -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).
+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"]
+6 -1
View File
@@ -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")
@@ -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."
@@ -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)
+5 -1
View File
@@ -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 == "":
+22
View File
@@ -249,6 +249,28 @@ Fresh chatmail addresses have a mailbox directory that contains:
directories will typically be empty unless the user of that address
hasnt been online for a while.
App version information (experimental)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A chatmail relay ships the repository's
`APPVERSIONS.json <https://github.com/chatmail/relay/blob/main/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
~~~~~~~~~~~~