Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
1 change: 1 addition & 0 deletions chatmaild/MANIFEST.in
Original file line number Diff line number Diff line change
@@ -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/*
15 changes: 15 additions & 0 deletions chatmaild/src/chatmaild/defaults/appversions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this file maybe have a timestamp?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it wouldn't help much currently with freshness of app versions to users. Freshness pipe from relay-repo -> relay-deployment -> core reading new metadata -> UI showing "update available" rather depends on core currently. it's not clear what checks/automatizations we want to do, so let's stick with the minimum data for now.

"clients": [
{
"clientId": "deltachat",
Comment thread
hpk42 marked this conversation as resolved.
"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"
}
]
}
]
}
18 changes: 18 additions & 0 deletions chatmaild/src/chatmaild/metadata.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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=(",", ":"))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the separators are just to minimize the generated string. they would be ", " and ": " otherwise :)



def _is_valid_token_timestamp(timestamp, now):
# Token if invalid after 90 days
# or if the timestamp is in the future.
Expand Down Expand Up @@ -101,6 +115,7 @@ def __init__(
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
Expand All @@ -125,6 +140,9 @@ def handle_lookup(self, parts):
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"
Expand Down
96 changes: 96 additions & 0 deletions chatmaild/src/chatmaild/tests/test_appversions.py
Original file line number Diff line number Diff line change
@@ -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)
28 changes: 28 additions & 0 deletions chatmaild/src/chatmaild/tests/test_metadata.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import io
import json
import time

import pytest
Expand All @@ -7,6 +8,7 @@
from chatmaild.metadata import (
Metadata,
MetadataDictProxy,
read_appversions,
)
from chatmaild.notifier import (
Notifier,
Expand Down Expand Up @@ -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"]
Expand Down
13 changes: 13 additions & 0 deletions cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -51,6 +53,17 @@ def test_set_get_metadata(self, imap_mailbox):
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."
Expand Down
6 changes: 6 additions & 0 deletions doc/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
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 -------------------------------------------------
Expand Down
27 changes: 27 additions & 0 deletions doc/source/overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/defaults/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
~~~~~~~~~~~~

Expand Down
Loading