Compare commits

..

5 Commits

Author SHA1 Message Date
missytake b8dcdcba91 docs: add paragraph on how to migrate your local repository to the server 2026-06-03 17:09:54 +02:00
missytake 6a0bf5abe7 docs: webdev needs to be exposed via nginx if run on the relay 2026-06-03 17:09:54 +02:00
missytake e7829672f8 docs: use ssh_host = localhost in getting started docs
remove mentions of the build machine / deployment server separation
2026-06-03 17:09:54 +02:00
missytake 62d19fd910 docs: cmdeploy dns + test are kind of necessary 2026-06-03 17:09:54 +02:00
missytake e1a8d798d3 cmdeploy: add ssh_host chatmail.ini option to deploy remotely 2026-06-03 17:09:54 +02:00
40 changed files with 136 additions and 664 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false persist-credentials: false
- name: download filtermail - name: download filtermail
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.7.4/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.7.0/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
- name: run chatmaild tests - name: run chatmaild tests
working-directory: chatmaild working-directory: chatmaild
run: pipx run tox run: pipx run tox
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
mkdir -p "$HOME/.ssh" mkdir -p "$HOME/.ssh"
echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key" echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key" chmod 600 "$HOME/.ssh/key"
rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:${STEPS_PREPARE_OUTPUTS_PRID}/" rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:/var/www/html/staging.chatmail.at/doc/relay/${STEPS_PREPARE_OUTPUTS_PRID}/"
env: env:
STEPS_PREPARE_OUTPUTS_PRID: ${{ steps.prepare.outputs.prid }} STEPS_PREPARE_OUTPUTS_PRID: ${{ steps.prepare.outputs.prid }}
+1 -1
View File
@@ -47,5 +47,5 @@ jobs:
mkdir -p "$HOME/.ssh" mkdir -p "$HOME/.ssh"
echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key" echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key" chmod 600 "$HOME/.ssh/key"
rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:" rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:/var/www/html/chatmail.at/doc/relay/"
-55
View File
@@ -1,60 +1,5 @@
# Changelog for chatmail deployment # Changelog for chatmail deployment
## [1.12.0] - 2026-07-31
### Breaking Changes
- [**breaking**] Introduce configurable system limits to reject new address creation and limit imap/smtp connections.
Dovecot default connection limit lowered from 50k to 10k,
Postfix default connection limit lowered from 5k to 1k,
larger relays need to adjust their settings.
### Features
- Reduce maximal_queue_lifetime from 5d to 2d
- Disable negative cache in unbound (#992)
- *(mtail)* Add incoming_mailer_daemon_mail_count
- *(postfix)* Disable processing of MIME headers
- *(dovecot)* Advertise privacy_mail as admin contact, drop server comment
### Bug Fixes
- Set relay restrictions per smtpd service with default reject
- Reduce maxproc for filtermail-transport LMTP client to 500
- Core 2.50.0 does not have delete_server_after config anymore.
- Check if all required ports are available for filtermail (#983)
- Always deploy unbound.conf.d/chatmail.conf (#993)
- Expire empty directories (#994)
- Crypt-r dependency was declared for wrong Python version
- Always overwrite /etc/resolv.conf, even if it is a symbolic link
- Pass kwargs to files.put()
- List Iroh proxy endpoints used by 0.35 and 1.0, drop stale /relay/probe from earlier versions
- Fix port discovery when ss -tulpn shows dovecot before stats
### Documentation
- Add scripts/initenv.sh to upgrade instructions
- Update overview diagrams (#995)
- *(overview)* Remove mermaid styles from 'Accepting and delivering mail' (#1009)
- *(README.md)* Clarify security enforcement (#1011)
### Miscellaneous Tasks
- *(ci)* Auto-trigger docker build on release tag push
- *(acmetool)* Update let's encrypt ToS link to 1.8
- *(ci)* Update doc staging upload path
- *(ci)* Fix docs upload path
### Refactor
- *(postfix)* Remove unused "filter" lmtp service
- Install dns-root-data instead of using unbound-anchor
- *(deps)* Remove domain-validator dependency
### Testing
- Set socket security for IMAP and SMTP to "TLS" in "dclogin"
## [1.11.0] - 2026-05-15 ## [1.11.0] - 2026-05-15
### Breaking Changes ### Breaking Changes
-3
View File
@@ -5,6 +5,3 @@ We use [git-cliff] to generate the changelog from commit messages before the rel
[Conventional Commits]: https://www.conventionalcommits.org/ [Conventional Commits]: https://www.conventionalcommits.org/
[git-cliff]: https://git-cliff.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 -6
View File
@@ -8,12 +8,7 @@ Chatmail relay servers are interoperable Mail Transport Agents (MTAs) designed f
- **Instant/Realtime:** sub-second message delivery, realtime P2P - **Instant/Realtime:** sub-second message delivery, realtime P2P
streaming, privacy-preserving Push Notifications for Apple, Google, and Huawei; streaming, privacy-preserving Push Notifications for Apple, Google, and Huawei;
- **Security Enforcement**: Only connections with strict TLS are accepted; - **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted
all messages must be correctly signed with DKIM and OpenPGP-encrypted with minimized metadata.
There are experimental exceptions for no-DNS relays,
which are allowed use self-signed TLS certificates
and which do not need to DKIM-sign their messages.
Unencrypted messages are allowed in neither case.
- **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating - **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating
depends on established IETF standards and protocols. depends on established IETF standards and protocols.
+6 -4
View File
@@ -1,13 +1,15 @@
# Releasing a new version of chatmail relay # Releasing a new version of chatmail relay
For example, to release version 1.13.0 of chatmail relay, do the following steps. For example, to release version 1.9.0 of chatmail relay, do the following steps.
1. Update the changelog: `git cliff --unreleased --tag 1.13.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.13.0 -p CHANGELOG.md`. 1. Update the changelog: `git cliff --unreleased --tag 1.9.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.9.0 -p CHANGELOG.md`.
2. Open the changelog in the editor, edit it if required. 2. Open the changelog in the editor, edit it if required.
3. Commit the changes to the changelog with a commit message `chore(release): prepare for 1.9.0`. 3. Commit the changes to the changelog with a commit message `chore(release): prepare for 1.9.0`.
4. Open a PR with the new commit, merge it to main after review. 3. Tag the release: `git tag --annotate 1.9.0`.
5. In the web interface, create a GitHub release, tell it to create a new tag. 4. Push the release tag: `git push origin 1.9.0`.
5. Create a GitHub release: `gh release create 1.9.0`.
-1
View File
@@ -1,4 +1,3 @@
include src/chatmaild/defaults/*.json
include src/chatmaild/ini/*.ini.f include src/chatmaild/ini/*.ini.f
include src/chatmaild/ini/*.ini include src/chatmaild/ini/*.ini
include src/chatmaild/tests/mail-data/* include src/chatmaild/tests/mail-data/*
+2 -2
View File
@@ -8,9 +8,9 @@ version = "0.3"
dependencies = [ dependencies = [
"iniconfig", "iniconfig",
"filelock", "filelock",
"psutil",
"requests", "requests",
"crypt-r >= 3.13.1 ; python_version >= '3.13'", "crypt-r >= 3.13.1 ; python_version >= '3.11'",
"domain-validator",
] ]
[tool.setuptools] [tool.setuptools]
+3 -10
View File
@@ -2,6 +2,7 @@ import ipaddress
from pathlib import Path from pathlib import Path
import iniconfig import iniconfig
from domain_validator import DomainValidator
from chatmaild.user import User from chatmaild.user import User
@@ -18,12 +19,14 @@ class Config:
params = dict(params) params = dict(params)
raw_domain = params.pop("mail_domain") raw_domain = params.pop("mail_domain")
self.mail_domain_bare = raw_domain self.mail_domain_bare = raw_domain
self.ssh_host = params.pop("ssh_host", raw_domain)
if is_valid_ipv4(raw_domain): if is_valid_ipv4(raw_domain):
self.ipv4_relay = raw_domain self.ipv4_relay = raw_domain
self.mail_domain = f"[{raw_domain}]" self.mail_domain = f"[{raw_domain}]"
self.postfix_myhostname = ipaddress.IPv4Address(raw_domain).reverse_pointer self.postfix_myhostname = ipaddress.IPv4Address(raw_domain).reverse_pointer
else: else:
DomainValidator().validate_domain_re(raw_domain)
self.ipv4_relay = None self.ipv4_relay = None
self.mail_domain = raw_domain self.mail_domain = raw_domain
self.postfix_myhostname = raw_domain self.postfix_myhostname = raw_domain
@@ -75,16 +78,6 @@ class Config:
self.privacy_pdo = params.pop("privacy_pdo", None) self.privacy_pdo = params.pop("privacy_pdo", None)
self.privacy_supervisor = params.pop("privacy_supervisor", None) self.privacy_supervisor = params.pop("privacy_supervisor", None)
self.max_load_1m = float(params.pop("max_load_1m", 5))
self.min_available_memory_mb = parse_size_mb(
params.pop("min_available_memory", "200M")
)
self.min_free_disk_space_mb = parse_size_mb(
params.pop("min_free_disk_space", "1G")
)
self.max_imap_connections = int(params.pop("max_imap_connections", 10000))
self.max_smtp_connections = int(params.pop("max_smtp_connections", 1000))
# TLS certificate management. # TLS certificate management.
# If tls_external_cert_and_key is set, use externally managed certs. # If tls_external_cert_and_key is set, use externally managed certs.
# Otherwise derived from the domain name: # Otherwise derived from the domain name:
@@ -1,15 +0,0 @@
{
"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
@@ -14,7 +14,6 @@ except ImportError:
from .config import Config, read_config from .config import Config, read_config
from .dictproxy import DictProxy from .dictproxy import DictProxy
from .migrate_db import migrate_from_db_to_maildir from .migrate_db import migrate_from_db_to_maildir
from .syslimits import has_sufficient_resources
NOCREATE_FILE = "/etc/chatmail-nocreate" NOCREATE_FILE = "/etc/chatmail-nocreate"
VALID_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$") VALID_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$")
@@ -148,8 +147,6 @@ class AuthDictProxy(DictProxy):
return userdata return userdata
if not is_allowed_to_create(self.config, addr, cleartext_password): if not is_allowed_to_create(self.config, addr, cleartext_password):
return return
if not has_sufficient_resources(self.config):
return
lock = filelock.FileLock(str(user.password_path) + ".lock", timeout=5) lock = filelock.FileLock(str(user.password_path) + ".lock", timeout=5)
with lock: with lock:
+3 -27
View File
@@ -3,6 +3,9 @@
# mail domain (MUST be set to fully qualified chat mail domain) # mail domain (MUST be set to fully qualified chat mail domain)
mail_domain = {mail_domain} mail_domain = {mail_domain}
# Where to deploy the relay - if unspecified, mail_domain will be used.
ssh_host = localhost
# #
# If you only do private test deploys, you don't need to modify any settings below # If you only do private test deploys, you don't need to modify any settings below
# #
@@ -42,33 +45,6 @@ mail_domain = {mail_domain}
# minimum length a password must have # minimum length a password must have
#password_min_length = 9 #password_min_length = 9
#
# System resource limits
#
# The following three limits refuse creation of new addresses
# while existing addresses keep working.
# Rejections are logged by the doveauth service.
# Maximum 1-minute load average, as reported by "uptime";
# it counts processes waiting for disk I/O as well as for CPU.
#max_load_1m = 5
# Minimum memory available without swapping.
#min_available_memory = 200M
# Minimum free disk space on the file system holding the mailboxes.
#min_free_disk_space = 1G
# Maximum number of concurrent IMAP connections
# (the Dovecot imap process limit).
#max_imap_connections = 10000
# Maximum number of concurrent SMTP connections
# on each of the submission and smtps ports (the Postfix process limit).
# A single client IP may use up to a fifth of this.
#max_smtp_connections = 1000
# Use externally managed TLS certificates instead of built-in acmetool. # Use externally managed TLS certificates instead of built-in acmetool.
# Paths refer to files on the deployment server (not the build machine). # Paths refer to files on the deployment server (not the build machine).
# Both files must already exist before running cmdeploy. # Both files must already exist before running cmdeploy.
-18
View File
@@ -1,10 +1,8 @@
import json
import logging import logging
import socket import socket
import sys import sys
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from importlib.resources import files
from .config import read_config from .config import read_config
from .dictproxy import DictProxy from .dictproxy import DictProxy
@@ -20,18 +18,6 @@ def turn_credentials(turn_socket_path):
return file.readline().decode("utf-8").strip() 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): def _is_valid_token_timestamp(timestamp, now):
# Token if invalid after 90 days # Token if invalid after 90 days
# or if the timestamp is in the future. # or if the timestamp is in the future.
@@ -115,7 +101,6 @@ class MetadataDictProxy(DictProxy):
self.iroh_relay = iroh_relay self.iroh_relay = iroh_relay
self.turn_hostname = turn_hostname self.turn_hostname = turn_hostname
self.turn_socket_path = turn_socket_path self.turn_socket_path = turn_socket_path
self.appversions_path = files(__package__).joinpath("defaults/appversions.json")
def handle_lookup(self, parts): def handle_lookup(self, parts):
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org # Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
@@ -140,9 +125,6 @@ class MetadataDictProxy(DictProxy):
case "maxsmtprecipients": case "maxsmtprecipients":
# postfix default (see "postconf smtpd_recipient_limit") # postfix default (see "postconf smtpd_recipient_limit")
return "O1000\n" 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}") logging.warning(f"lookup ignored: {parts!r}")
return "N\n" return "N\n"
-32
View File
@@ -1,32 +0,0 @@
"""Detect whether the system is at its limits."""
import logging
import psutil
MB = 1024 * 1024
def read_value(getter):
try:
return getter()
except Exception as e:
logging.warning("ignoring unreadable system limit: %s", e)
return None
def has_sufficient_resources(config):
"""Return False if load, memory or disk exceeds a configured limit."""
load = read_value(lambda: psutil.getloadavg()[0])
mem = read_value(lambda: psutil.virtual_memory().available // MB)
disk = read_value(lambda: psutil.disk_usage(str(config.mailboxes_dir)).free // MB)
if load is not None and load > config.max_load_1m:
msg = f"load avg {load:.2f} > {config.max_load_1m:.2f}"
elif mem is not None and mem < config.min_available_memory_mb:
msg = f"available memory {mem}MB < {config.min_available_memory_mb}MB"
elif disk is not None and disk < config.min_free_disk_space_mb:
msg = f"free disk {disk}MB < {config.min_free_disk_space_mb}MB"
else:
return True
logging.warning("registration rejected: %s", msg)
return False
-4
View File
@@ -20,10 +20,6 @@ def make_config(tmp_path):
basedir.mkdir(parents=True, exist_ok=True) basedir.mkdir(parents=True, exist_ok=True)
overrides = settings.copy() if settings else {} overrides = settings.copy() if settings else {}
overrides["mailboxes_dir"] = str(basedir) overrides["mailboxes_dir"] = str(basedir)
# permissive resource limits so tests never depend on host load/memory/disk
overrides.setdefault("max_load_1m", "99999")
overrides.setdefault("min_available_memory", "0")
overrides.setdefault("min_free_disk_space", "0")
write_initial_config(inipath, mail_domain, overrides=overrides) write_initial_config(inipath, mail_domain, overrides=overrides)
return read_config(inipath) return read_config(inipath)
@@ -1,96 +0,0 @@
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)
@@ -45,8 +45,6 @@ def test_read_config_basic_using_defaults(tmp_path, maildomain):
assert example_config.username_min_length == 9 assert example_config.username_min_length == 9
assert example_config.username_max_length == 9 assert example_config.username_max_length == 9
assert example_config.password_min_length == 9 assert example_config.password_min_length == 9
assert example_config.max_imap_connections == 10000
assert example_config.max_smtp_connections == 1000
assert example_config._unused_keys == [] assert example_config._unused_keys == []
@@ -202,17 +202,3 @@ def test_50_concurrent_lookups_different_accounts(gencreds, dictproxy):
res = results.get() res = results.get()
if res is not None: if res is not None:
pytest.fail(f"concurrent lookup failed\n{res}") pytest.fail(f"concurrent lookup failed\n{res}")
def test_insufficient_resources_block_creation_not_existing_logins(
dictproxy, gencreds, monkeypatch
):
addr, password = gencreds()
assert dictproxy.lookup_passdb(addr, password)
monkeypatch.setattr(
chatmaild.doveauth, "has_sufficient_resources", lambda config: False
)
newaddr, newpassword = gencreds()
assert not dictproxy.lookup_passdb(newaddr, newpassword)
assert dictproxy.lookup_passdb(addr, password)
@@ -1,5 +1,4 @@
import io import io
import json
import time import time
import pytest import pytest
@@ -8,7 +7,6 @@ import requests
from chatmaild.metadata import ( from chatmaild.metadata import (
Metadata, Metadata,
MetadataDictProxy, MetadataDictProxy,
read_appversions,
) )
from chatmaild.notifier import ( from chatmaild.notifier import (
Notifier, Notifier,
@@ -371,32 +369,6 @@ def test_iroh_relay(dictproxy):
assert wfile.getvalue() == b"Ohttps://example.org/\n" 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): def test_legacy_token_migration(metadata, testaddr):
with metadata.get_metadata_dict(testaddr).modify() as data: with metadata.get_metadata_dict(testaddr).modify() as data:
data[metadata.DEVICETOKEN_KEY] = ["oldtoken1", "oldtoken2"] data[metadata.DEVICETOKEN_KEY] = ["oldtoken1", "oldtoken2"]
@@ -1,45 +0,0 @@
import shutil
import psutil
from chatmaild.syslimits import has_sufficient_resources
PERMISSIVE = {
"max_load_1m": "99999",
"min_available_memory": "0",
"min_free_disk_space": "0",
}
def test_rejects_constrained_system(make_config, caplog):
assert has_sufficient_resources(make_config("chat.example.org", PERMISSIVE))
for settings in (
{"max_load_1m": "-1.0"},
{"min_available_memory": "99999999G"},
{"min_free_disk_space": "99999999G"},
):
config = make_config("chat.example.org", PERMISSIVE | settings)
caplog.clear()
assert not has_sufficient_resources(config), settings
assert "registration rejected" in caplog.text
def test_unreadable_disk_does_not_reject(make_config, caplog):
config = make_config(
"chat.example.org", PERMISSIVE | {"min_free_disk_space": "99999999G"}
)
shutil.rmtree(config.mailboxes_dir)
assert has_sufficient_resources(config)
assert "ignoring" in caplog.text
def test_one_unreadable_value_keeps_other_checks(make_config, monkeypatch, caplog):
def raise_error(*args):
raise psutil.Error("dud")
monkeypatch.setattr(psutil, "getloadavg", raise_error)
config = make_config(
"chat.example.org", PERMISSIVE | {"min_free_disk_space": "99999999G"}
)
assert not has_sufficient_resources(config)
assert "ignoring" in caplog.text
@@ -1,2 +1,2 @@
"acme-enter-email": "{{ email }}" "acme-enter-email": "{{ email }}"
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.8-July-06-2026.pdf": true "acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.6-August-18-2025.pdf": true
+1 -2
View File
@@ -166,7 +166,7 @@ class Deployer:
return self.put_template(src, dest, **kwargs) return self.put_template(src, dest, **kwargs)
return self.put_file(src, dest) return self.put_file(src, dest)
def put_file(self, src, dest, mode="644", **kwargs): def put_file(self, src, dest, mode="644"):
if isinstance(src, str): if isinstance(src, str):
src = get_resource(src) src = get_resource(src)
res = files.put( res = files.put(
@@ -176,7 +176,6 @@ class Deployer:
user="root", user="root",
group="root", group="root",
mode=mode, mode=mode,
**kwargs,
) )
return self._update_restart_signals(dest, res) return self._update_restart_signals(dest, res)
+4 -4
View File
@@ -96,7 +96,7 @@ def _warn_unused_settings(unused_keys, out):
def run_cmd(args, out): def run_cmd(args, out):
"""Deploy chatmail services on the remote server.""" """Deploy chatmail services on the remote server."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare ssh_host = args.ssh_host if args.ssh_host else args.config.ssh_host
sshexec = get_sshexec(ssh_host) sshexec = get_sshexec(ssh_host)
require_iroh = args.config.enable_iroh_relay require_iroh = args.config.enable_iroh_relay
strict_tls = args.config.tls_cert_mode == "acme" strict_tls = args.config.tls_cert_mode == "acme"
@@ -116,7 +116,7 @@ def run_cmd(args, out):
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra" pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y" cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
if ssh_host == "localhost": if ssh_host in ["localhost", "@local"]:
cmd = f"{pyinf} @local {deploy_path} -y" cmd = f"{pyinf} @local {deploy_path} -y"
if version.parse(pyinfra.__version__) < version.parse("3"): if version.parse(pyinfra.__version__) < version.parse("3"):
@@ -158,7 +158,7 @@ def dns_cmd(args, out):
ipv4 = args.config.ipv4_relay ipv4 = args.config.ipv4_relay
print(f"[WARNING] {ipv4} is not a domain, skipping DNS checks.") print(f"[WARNING] {ipv4} is not a domain, skipping DNS checks.")
return 0 return 0
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain ssh_host = args.ssh_host if args.ssh_host else args.config.ssh_host
sshexec = get_sshexec(ssh_host, verbose=args.verbose) sshexec = get_sshexec(ssh_host, verbose=args.verbose)
tls_cert_mode = args.config.tls_cert_mode tls_cert_mode = args.config.tls_cert_mode
strict_tls = tls_cert_mode == "acme" strict_tls = tls_cert_mode == "acme"
@@ -195,7 +195,7 @@ def status_cmd_options(parser):
def status_cmd(args, out): def status_cmd(args, out):
"""Display status for online chatmail instance.""" """Display status for online chatmail instance."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare ssh_host = args.ssh_host if args.ssh_host else args.config.ssh_host
sshexec = get_sshexec(ssh_host, verbose=args.verbose) sshexec = get_sshexec(ssh_host, verbose=args.verbose)
out.green(f"chatmail domain: {args.config.mail_domain}") out.green(f"chatmail domain: {args.config.mail_domain}")
+9 -26
View File
@@ -138,22 +138,9 @@ class UnboundDeployer(Deployer):
# On an IPv4-only system, if unbound is started but not configured, # On an IPv4-only system, if unbound is started but not configured,
# it causes subsequent steps to fail to resolve hosts. # it causes subsequent steps to fail to resolve hosts.
with blocked_service_startup(): with blocked_service_startup():
# dns-root-data is an optional package
# that contains /usr/share/dns/root.key
#
# This file is copied into /var/lib/unbound/root.key
# at the start of "unbound" systemd unit
# by /usr/libexec/unbound-helper shell script
# from the "unbound" package as of version 1.17.1-2+deb12u4
#
# The same /var/lib/unbound/root.key can be retrieved directly
# following the procedure from
# <https://www.rfc-editor.org/info/rfc7958/#section-3.1>
# with "unbound-anchor -a /var/lib/unbound/root.key"
# We don't install and use "unbound-anchor".
apt.packages( apt.packages(
name="Install unbound", name="Install unbound",
packages=["unbound", "dns-root-data", "dnsutils"], packages=["unbound", "unbound-anchor", "dnsutils"],
) )
def configure(self): def configure(self):
@@ -177,7 +164,12 @@ class UnboundDeployer(Deployer):
self.put_file( self.put_file(
src=BytesIO(b"nameserver 127.0.0.1\nnameserver 9.9.9.9\n"), src=BytesIO(b"nameserver 127.0.0.1\nnameserver 9.9.9.9\n"),
dest="/etc/resolv.conf", dest="/etc/resolv.conf",
force=True, )
server.shell(
name="Generate root keys for validating DNSSEC",
commands=[
"unbound-anchor -a /var/lib/unbound/root.key || true",
],
) )
self.ensure_directory( self.ensure_directory(
path="/etc/unbound/unbound.conf.d", path="/etc/unbound/unbound.conf.d",
@@ -253,13 +245,6 @@ class LegacyRemoveDeployer(Deployer):
def install(self): def install(self):
apt.packages(name="Remove rspamd", packages="rspamd", present=False) apt.packages(name="Remove rspamd", packages="rspamd", present=False)
# unbound-anchor was used to download /var/lib/unbound/root.key
# It is replaced by dns-root-data which contains /usr/share/dns/root.key.
# unbound systemd unit copies /usr/share/dns/root.key
# into /var/lib/unbound/root.key automatically on start
# as long as /usr/share/dns/root.key is present.
apt.packages(name="Remove unbound-anchor", packages="unbound-anchor", present=False)
# remove historic expunge script # remove historic expunge script
# which is now implemented through a systemd timer (chatmail-expire) # which is now implemented through a systemd timer (chatmail-expire)
self.remove_file("/etc/cron.d/expunge") self.remove_file("/etc/cron.d/expunge")
@@ -370,8 +355,6 @@ class ChatmailVenvDeployer(Deployer):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.units = ( self.units = (
# doveauth must restart when chatmaild/ini file changes
"doveauth",
"chatmail-metadata", "chatmail-metadata",
"lastlogin", "lastlogin",
"chatmail-expire", "chatmail-expire",
@@ -520,10 +503,10 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
("nginx", 443), ("nginx", 443),
(["master", "smtpd"], 465), (["master", "smtpd"], 465),
(["master", "smtpd"], 587), (["master", "smtpd"], 587),
(["dovecot", "imap-login"], 993), (["imap-login", "dovecot"], 993),
("iroh-relay", 3340), ("iroh-relay", 3340),
("mtail", 3903), ("mtail", 3903),
(["dovecot", "stats"], 3904), ("stats", 3904),
("nginx", 8443), ("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port), (["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming), (["master", "smtpd"], config.postfix_reinject_port_incoming),
+1 -1
View File
@@ -34,7 +34,7 @@ class DovecotDeployer(Deployer):
def __init__(self, config, disable_mail): def __init__(self, config, disable_mail):
self.config = config self.config = config
self.disable_mail = disable_mail self.disable_mail = disable_mail
self.units = [] self.units = ["doveauth"]
def install(self): def install(self):
arch = host.get_fact(Arch) arch = host.get_fact(Arch)
@@ -37,15 +37,11 @@ default_client_limit = 20000
# the following warning will be logged: # the following warning will be logged:
# Warning: service(imap): process_limit (1024) reached, client connections are being dropped # Warning: service(imap): process_limit (1024) reached, client connections are being dropped
service imap { service imap {
process_limit = {{ config.max_imap_connections }} process_limit = 50000
} }
{% if config.privacy_mail %} mail_server_admin = mailto:root@{{ config.mail_domain }}
# Advertised to clients as IMAP METADATA /shared/admin (RFC 5464). mail_server_comment = Chatmail server
# The privacy_mail contact from chatmail.ini is used because it is
# the only address set by an operator.
mail_server_admin = mailto:{{ config.privacy_mail }}
{% endif %}
# `zlib` enables compressing messages stored in the maildir. # `zlib` enables compressing messages stored in the maildir.
# See # See
+3 -3
View File
@@ -20,10 +20,10 @@ class FiltermailDeployer(Deployer):
return return
arch = host.get_fact(facts.server.Arch) arch = host.get_fact(facts.server.Arch)
url = f"https://github.com/chatmail/filtermail/releases/download/v0.7.4/filtermail-{arch}" url = f"https://github.com/chatmail/filtermail/releases/download/v0.7.0/filtermail-{arch}"
sha256sum = { sha256sum = {
"x86_64": "484cb8dff083134aefba9fce4a6b7ef4784a0f0e28e5108ecf8bb9e58a44fd2c", "x86_64": "451f295a85b3b12dbb0f89e18ec319f742ee46dec218f20f7923bfb017a248bd",
"aarch64": "66aa0ca2ca9add7a12d92883d76f8786384092adfde24a3d3a1d0b1f30d23a9e", "aarch64": "6833061b2a2028264fdeb32f0a6123e1ff73de57dace125364016300b748452e",
}[arch] }[arch]
self.download_executable(url, self.bin_path, sha256sum) self.download_executable(url, self.bin_path, sha256sum)
@@ -28,13 +28,6 @@ counter created_nonci_accounts
} }
} }
# doveauth refusing new addresses because a chatmail.ini
# system resource limit is exceeded.
counter rejected_registrations
/registration rejected: / {
rejected_registrations++
}
counter postfix_timeouts counter postfix_timeouts
/timeout after DATA/ { /timeout after DATA/ {
postfix_timeouts++ postfix_timeouts++
@@ -80,11 +73,6 @@ counter incoming_unencrypted_mail_count
filtered_incoming_mail_count++ filtered_incoming_mail_count++
} }
counter incoming_mailer_daemon_mail_count
/Incoming: Filtering mailer-daemon message from/ {
incoming_mailer_daemon_mail_count++
filtered_incoming_mail_count++
}
counter rejected_unencrypted_mail_count counter rejected_unencrypted_mail_count
/Rejected unencrypted mail/ { /Rejected unencrypted mail/ {
+1 -4
View File
@@ -128,10 +128,7 @@ http {
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
} }
# Endpoints Iroh uses for net_report probes and that clients location /relay/probe {
# probe to tell whether this relay works. Both paths are served
# by iroh-relay 0.35 and by the 1.0 line.
location /ping {
proxy_pass http://127.0.0.1:3340; proxy_pass http://127.0.0.1:3340;
proxy_http_version 1.1; proxy_http_version 1.1;
} }
@@ -1,3 +1,23 @@
/^DKIM-Signature:/ IGNORE # List of headers for incoming messages
/^Authentication-Results:/ IGNORE # that must be retained for functionality and compatibility reasons
/^Received:/ IGNORE /^From:/ DUNNO
/^Message-Id:/ DUNNO
/^Chat-/ DUNNO
/^Content-Type:/ DUNNO
# For receiving clear-text messages (still supported in May 2026)
/^Subject:/ DUNNO
/^Date:/ DUNNO
/^To:/ DUNNO
/^CC:/ DUNNO
/^References:/ DUNNO
/^In-Reply-To:/ DUNNO
# Senders might support Autocrypt 1 but not RFC9788 (Header Protection)
/^Autocrypt:/ DUNNO
# SecureJoin V2 protocol headers (for backward compatibility)
/^Secure-Join/ DUNNO
# Ignore all other headers
/.*/ IGNORE
-8
View File
@@ -85,14 +85,6 @@ inet_protocols = all
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
# Do not apply header checks to MIME headers
# and other headers that are actually part of the message body.
# Reference:
# <https://www.postfix.org/postconf.5.html#disable_mime_input_processing>
# <https://www.postfix.org/header_checks.5.html>
# <https://stump.io/blog/2020/11/29/a-little-gotcha-with-postfixs-header_checks/>
disable_mime_input_processing = yes
mua_client_restrictions = permit_sasl_authenticated, reject mua_client_restrictions = permit_sasl_authenticated, reject
mua_sender_restrictions = reject_sender_login_mismatch, permit_sasl_authenticated, reject mua_sender_restrictions = reject_sender_login_mismatch, permit_sasl_authenticated, reject
mua_helo_restrictions = permit_mynetworks, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, permit mua_helo_restrictions = permit_mynetworks, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, permit
+5 -4
View File
@@ -18,7 +18,7 @@ smtp inet n - y - - smtpd
-o smtpd_tls_mandatory_protocols=>=TLSv1.2 -o smtpd_tls_mandatory_protocols=>=TLSv1.2
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }}
-o smtpd_relay_restrictions=reject_unauth_destination -o smtpd_relay_restrictions=reject_unauth_destination
submission inet n - y - {{ config.max_smtp_connections }} smtpd submission inet n - y - 5000 smtpd
-o syslog_name=postfix/submission -o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
-o smtpd_tls_mandatory_protocols=>=TLSv1.3 -o smtpd_tls_mandatory_protocols=>=TLSv1.3
@@ -32,9 +32,9 @@ submission inet n - y - {{ config.max_smtp_connections }
-o smtpd_sender_restrictions=$mua_sender_restrictions -o smtpd_sender_restrictions=$mua_sender_restrictions
-o smtpd_recipient_restrictions= -o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o smtpd_client_connection_count_limit={{ config.max_smtp_connections // 5 }} -o smtpd_client_connection_count_limit=1000
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
smtps inet n - y - {{ config.max_smtp_connections }} smtpd smtps inet n - y - 5000 smtpd
-o syslog_name=postfix/smtps -o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes -o smtpd_tls_wrappermode=yes
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
@@ -48,7 +48,7 @@ smtps inet n - y - {{ config.max_smtp_connections }
-o smtpd_sender_restrictions=$mua_sender_restrictions -o smtpd_sender_restrictions=$mua_sender_restrictions
-o smtpd_recipient_restrictions= -o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o smtpd_client_connection_count_limit={{ config.max_smtp_connections // 5 }} -o smtpd_client_connection_count_limit=1000
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
#628 inet n - y - - qmqpd #628 inet n - y - - qmqpd
pickup unix n - y 60 1 pickup pickup unix n - y 60 1 pickup
@@ -76,6 +76,7 @@ lmtp unix - - y - - lmtp
anvil unix - - y - 1 anvil anvil unix - - y - 1 anvil
scache unix - - y - 1 scache scache unix - - y - 1 scache
postlog unix-dgram n - n - 1 postlogd postlog unix-dgram n - n - 1 postlogd
filter unix - n n - - lmtp
# Local SMTP server for reinjecting outgoing filtered mail. # Local SMTP server for reinjecting outgoing filtered mail.
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd 127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject -o syslog_name=postfix/reinject
@@ -1,12 +1,10 @@
import ipaddress import ipaddress
import json
import re import re
import time import time
import imap_tools import imap_tools
import pytest import pytest
import requests import requests
from chatmaild.tests.test_appversions import check_appversions
from cmdeploy.cmdeploy import get_sshexec from cmdeploy.cmdeploy import get_sshexec
from cmdeploy.remote import rshell from cmdeploy.remote import rshell
@@ -53,17 +51,6 @@ class TestMetadataTokens:
assert res == b"1111 2222" assert res == b"1111 2222"
assert b"Getmetadata completed" in client.readline() 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: class TestEndToEndDeltaChat:
"Tests that use Delta Chat accounts on the chat mail instance." "Tests that use Delta Chat accounts on the chat mail instance."
+4 -2
View File
@@ -10,11 +10,13 @@ from pathlib import Path
import pytest import pytest
from chatmaild.config import is_valid_ipv4, read_config from chatmaild.config import is_valid_ipv4, read_config
from domain_validator import DomainValidator
def format_mail_domain(raw_domain: str) -> str: def format_mail_domain(raw_domain: str) -> str:
if is_valid_ipv4(raw_domain): if is_valid_ipv4(raw_domain):
return f"[{raw_domain}]" return f"[{raw_domain}]"
DomainValidator().validate_domain_re(raw_domain)
return raw_domain return raw_domain
@@ -60,8 +62,8 @@ def maildomain(chatmail_config):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def sshdomain(maildomain): def sshdomain(chatmail_config):
return os.environ.get("CHATMAIL_SSH", maildomain) return os.environ.get("CHATMAIL_SSH", chatmail_config.ssh_host)
@pytest.fixture @pytest.fixture
-6
View File
@@ -22,12 +22,6 @@ extensions = [
templates_path = ['_templates'] templates_path = ['_templates']
exclude_patterns = [] 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 ------------------------------------------------- # -- Options for HTML output -------------------------------------------------
+30 -143
View File
@@ -14,21 +14,14 @@ Minimal requirements and prerequisites
You will need the following: You will need the following:
- A Debian 12 **deployment server** with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports. - Control over a domain through a DNS provider of your choice.
(there is experimental support for :ref:`IP-only relays <iponly>`).
- A Debian 12 server with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
IPv6 is encouraged if available. Chatmail relay servers only require IPv6 is encouraged if available. Chatmail relay servers only require
1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active 1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active
chatmail addresses. chatmail addresses.
- A Linux or Unix **build machine** with key-based SSH access to the root
user of the deployment server.
You must add a passphrase-protected private key to your local ssh-agent because you
cant type in your passphrase during deployment.
(An ed25519 private key is required due to an `upstream bug in
paramiko <https://github.com/paramiko/paramiko/issues/2191>`_)
- Control over a domain through a DNS provider of your choice
(there is experimental support for :ref:`IP-only relays <iponly>`).
.. _setup: .. _setup:
@@ -38,7 +31,7 @@ Setup with ``scripts/cmdeploy``
We use ``chat.example.org`` as the chatmail domain in the following We use ``chat.example.org`` as the chatmail domain in the following
steps. Please substitute it with your own domain. steps. Please substitute it with your own domain.
1. Setup the initial DNS records for your deployment server. 1. Setup the initial DNS records for your relay.
The following is an example in the The following is an example in the
familiar BIND zone file format with a TTL of 1 hour (3600 seconds). familiar BIND zone file format with a TTL of 1 hour (3600 seconds).
Please substitute your domain and IP addresses. Please substitute your domain and IP addresses.
@@ -58,22 +51,25 @@ steps. Please substitute it with your own domain.
The ``mta-sts`` CNAME and ``_mta-sts`` TXT records The ``mta-sts`` CNAME and ``_mta-sts`` TXT records
are not needed for such domains. are not needed for such domains.
2. On your local PC, clone the repository and bootstrap the Python 2. Login to the server with SSH, clone the repository and bootstrap the Python
virtualenv. virtualenv.
:: ::
ssh root@chat.example.org
git clone https://github.com/chatmail/relay git clone https://github.com/chatmail/relay
cd relay cd relay
scripts/initenv.sh scripts/initenv.sh
3. On your local build machine (PC), create a chatmail configuration file 3. Then, create a chatmail configuration file
``chatmail.ini``: ``chatmail.ini``:
:: ::
scripts/cmdeploy init chat.example.org # <-- use your domain scripts/cmdeploy init chat.example.org # <-- use your domain
.. note::
To use self-signed TLS certificates To use self-signed TLS certificates
instead of Let's Encrypt, instead of Let's Encrypt,
use a domain name starting with ``_`` use a domain name starting with ``_``
@@ -84,13 +80,7 @@ steps. Please substitute it with your own domain.
See the :doc:`overview` See the :doc:`overview`
for details on certificate provisioning. for details on certificate provisioning.
4. Verify that SSH root login to the deployment server server works: 4. Now run the deployment script to install the relay to the server:
::
ssh root@chat.example.org # <-- use your domain
5. From your local build machine, setup and configure the remote deployment server:
:: ::
@@ -102,7 +92,6 @@ steps. Please substitute it with your own domain.
public). public).
Docker installation Docker installation
------------------- -------------------
@@ -110,27 +99,33 @@ There is experimental support for running chatmail via Docker.
A monolithic image based on the above cmdeploy method is available `through a separate repository <https://github.com/chatmail/docker/pkgs/container/docker>`_. A monolithic image based on the above cmdeploy method is available `through a separate repository <https://github.com/chatmail/docker/pkgs/container/docker>`_.
See the `chatmail/docker README <https://github.com/chatmail/docker>`_ for full setup instructions. See the `chatmail/docker README <https://github.com/chatmail/docker>`_ for full setup instructions.
Other helpful commands
----------------------
To check the status of your deployment server running the chatmail service: Next Steps
----------
:: Now you should display and check all recommended DNS records
to enable federation with other relays:
scripts/cmdeploy status
To display and check all recommended DNS records:
:: ::
scripts/cmdeploy dns scripts/cmdeploy dns
To test whether your chatmail service is working correctly: You should also test whether your chatmail service is working correctly:
:: ::
scripts/cmdeploy test scripts/cmdeploy test
Other Helpful Commands
----------------------
To check the status of your chatmail relay:
::
scripts/cmdeploy status
To measure the performance of your chatmail service: To measure the performance of your chatmail service:
:: ::
@@ -171,8 +166,9 @@ This starts a local live development cycle for chatmail web pages:
directory and generating HTML files and copying assets to the directory and generating HTML files and copying assets to the
``www/build`` directory. ``www/build`` directory.
- Starts a browser window automatically where you can “refresh” as - if you are running scripts/cmdeploy webdev on the relay itself,
needed. you need to configure a route in /etc/nginx/nginx.conf
to expose the build directory.
Custom web pages Custom web pages
---------------- ----------------
@@ -190,7 +186,7 @@ Disable automatic address creation
-------------------------------------------------------- --------------------------------------------------------
If you need to stop address creation, e.g. because some script is wildly If you need to stop address creation, e.g. because some script is wildly
creating addresses, login with ssh to the deployment machine and run: creating addresses, login with ssh to the relay and run:
:: ::
@@ -199,93 +195,6 @@ creating addresses, login with ssh to the deployment machine and run:
Chatmail address creation will be denied while this file is present. Chatmail address creation will be denied while this file is present.
.. _system-limits:
Configurable System Limits
--------------------------
Limits for auto-rejecting address creation
..........................................
A relay refuses creation of new addresses
when the machine runs low on resources,
but existing addresses keep working.
Three ``chatmail.ini`` settings control this,
shown here with their defaults::
max_load_1m = 5
min_available_memory = 200M
min_free_disk_space = 1G
- ``max_load_1m`` is the maximum 1-minute load average,
as reported by ``uptime``;
it counts processes waiting for disk I/O as well as for CPU.
It is deliberately not scaled by the number of CPUs
because I/O rather than CPU is what typically limits a relay.
- ``min_available_memory`` is the minimum memory available without swapping.
- ``min_free_disk_space`` is the minimum free disk space
on the file system holding the mailboxes.
The defaults suit the small machine described in
`Minimal requirements and prerequisites`_.
.. note::
If you run a bigger machine,
raise ``max_load_1m`` after watching ``uptime`` under typical load.
Rejections are logged by the ``doveauth`` service,
so you can check whether a limit is set too tightly::
journalctl -u doveauth --grep 'registration rejected'
If ``mtail_address`` is set, rejections are also counted
in the ``rejected_registrations`` metric.
Overall IMAP and SMTP connection limits
.......................................
Two further settings bound how many connections
the relay accepts at all, again shown with their defaults::
max_imap_connections = 10000
max_smtp_connections = 1000
``max_imap_connections`` becomes the Dovecot imap process limit,
and ``max_smtp_connections`` the Postfix process limit
on each of the submission and smtps ports.
A single client IP may use up to a fifth of ``max_smtp_connections``.
Each connection costs memory,
so these limits defend the relay against running out of RAM.
Unless ``imap_compress`` is enabled,
an IMAP connection that is idle for ``imap_hibernate_timeout``
is handed over to the ``imap-hibernate`` process
and does not count towards ``max_imap_connections``,
which is why a relay can serve far more IMAP clients
than this setting suggests.
If you run a large relay with 10k or 100k's of addresses,
check current connection counts before upgrading
and set the limits accordingly.
To see how close a running relay is to these two limits,
copy ``scripts/check-connections.sh`` from the relay repository
onto the relay and run it there::
imap 5 ports 143,993 (max_imap_connections)
5 dovecot sessions, 0 of them in an active imap process
submission 0 ports 465,587 (max_smtp_connections per port)
incoming 0 port 25 (from other relays, no chatmail.ini limit)
It counts established sockets with ``ss``
and cross-checks the IMAP number against ``doveadm who``.
Running a relay with self-signed certificates Running a relay with self-signed certificates
---------------------------------------------- ----------------------------------------------
@@ -333,25 +242,3 @@ The deploy will verify that both files exist on the server.
If you use such a setup, you must trigger the reload explicitly after renewal:: If you use such a setup, you must trigger the reload explicitly after renewal::
systemctl start tls-cert-reload.service systemctl start tls-cert-reload.service
Migrating to a new build machine
----------------------------------
To move or add a build machine,
clone the relay repository on the new build machine, and copy the ``chatmail.ini`` file from the old build machine.
Make sure ``rsync`` is installed, then initialize the environment:
::
./scripts/initenv.sh
Run safety checks before a new deployment:
::
./scripts/cmdeploy dns
./scripts/cmdeploy status
If you keep multiple build machines (ie laptop and desktop), keep ``chatmail.ini`` in sync between
them.
+16 -2
View File
@@ -1,6 +1,6 @@
Migrating to a new machine Migrating the relay to a new server
=========================== ===================================
This migration tutorial provides a step-wise approach This migration tutorial provides a step-wise approach
to safely migrate a chatmail relay from one remote machine to another. to safely migrate a chatmail relay from one remote machine to another.
@@ -96,3 +96,17 @@ in this case, just run ``ssh-keygen -R "mail.example.org"`` as recommended.
If you have lowered the Time-to-Live for DNS records in step 1, If you have lowered the Time-to-Live for DNS records in step 1,
better use a higher value again (between 14400 and 86400 seconds) once you are sure everything works. better use a higher value again (between 14400 and 86400 seconds) once you are sure everything works.
Migrating a local chatmail/relay repository to the server
=========================================================
To move the directory with your local chatmail/relay repository and ``chatmail.ini`` file,
clone the `relay repository <https://github.com/chatmail/relay/>` to the server where the relay is running,
and copy the ``chatmail.ini`` file from your local chatmail/relay repository there.
If you made local changes to the repository,
you can store them in a file with ``git diff origin/main > local-changes.patch``,
copy the file to the repository on the server,
and run ``git apply local-changes.patch``.
Then you can proceed with the `installation steps <setup>`.
+15 -27
View File
@@ -225,6 +225,21 @@ Accepting and delivering mail
nginx -.SMTP inet:465.-> smtpd-smtps nginx -.SMTP inet:465.-> smtpd-smtps
mta2[Remote relay] -.SMTP inet:25.-> smtpd-smtp mta2[Remote relay] -.SMTP inet:25.-> smtpd-smtp
mta2 -.HTTPS /mxdeliv.-> nginx mta2 -.HTTPS /mxdeliv.-> nginx
style postfix fill:#363
style qmgr fill:#252
style authclean fill:#252
style cleanup fill:#252
style lmtp-filtermail fill:#252
style lmtp fill:#252
style bounce fill:#252
style smtpd-submission fill:#252
style smtpd-smtps fill:#252
style smtpd-reinject-outgoing fill:#252
style smtpd-reinject-incoming fill:#252
style smtpd-smtp fill:#252
style filtermail-outgoing fill:#225
style filtermail-incoming fill:#225
style filtermail-transport fill:#225
Operational details of a chatmail relay Operational details of a chatmail relay
---------------------------------------- ----------------------------------------
@@ -249,33 +264,6 @@ Fresh chatmail addresses have a mailbox directory that contains:
directories will typically be empty unless the user of that address directories will typically be empty unless the user of that address
hasnt been online for a while. hasnt 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 Active ports
~~~~~~~~~~~~ ~~~~~~~~~~~~
-26
View File
@@ -1,26 +0,0 @@
#!/bin/bash
#
# Show current IMAP and SMTP connections on a chatmail relay,
# to compare against the max_imap_connections and max_smtp_connections
# settings in chatmail.ini. Run this on the relay itself.
set -e
# Count established TCP connections whose local port is one of the arguments.
established() {
filter=$(printf 'sport = :%s or ' "$@")
ss -Htn state established "( ${filter% or } )" | wc -l
}
# doveadm prints a header line and then one line per logged-in user,
# with that user's number of connections in the second column.
sessions=$(doveadm who | awk 'NR > 1 { n += $2 } END { print n + 0 }')
# Unless imap_compress is enabled, connections idle for
# imap_hibernate_timeout are handed over to the imap-hibernate
# process, so they cost no imap process while idle.
active=$(pgrep -x imap | wc -l)
printf 'imap %6d ports 143,993 (max_imap_connections)\n' "$(established 143 993)"
printf ' %6d dovecot sessions, %d of them in an active imap process\n' "$sessions" "$active"
printf 'submission %6d ports 465,587 (max_smtp_connections per port)\n' "$(established 465 587)"
printf 'incoming %6d port 25 (from other relays, no chatmail.ini limit)\n' "$(established 25)"