Compare commits

..

7 Commits

Author SHA1 Message Date
Jagoda Estera Ślązak
c0b207c320 chore(deps): Upgrade filtermail to v0.6.5 (#966) 2026-05-12 10:37:28 +02:00
holger krekel
4ebde2825d feat: support setup without domain, with only an IPv4 address (#963)
* dovecot: enable login names with square brackets

* config: make IPv4-only relays use self-signed TLS certs

* postfix: make delivery for IP-only relays work

* cmdeploy: skip DNS checks for IPv4 only relays

* www: generate dclogin codes for IPv4-only relays

* opendkim: disable DKIM signing on ipv4-only relays

* get delivery working

* get tests working on IPv4 only machine

* doc: document IPv4-only relays

* dns: warn if mail_domain is an IP, instead of checking DNS

* config: validate domains when formatting them

* ci: add cmlxc testing for no-DNS relays

* ci: run no-dns and normal CI in parallel

* retain "config.mail_domain" as the domain part of @ email addresses, so for ipv4 relays  "[1.2.3.4]" and introduce config.ipv4_relay and config.mail_domain_bare helpers.

* ci: migrate from --no-dns to --type ipv4 for cmlxc compatibility

* cleanup dead code, fix docs, fixate cmlxc version

---------

Co-authored-by: missytake <missytake@systemli.org>
2026-05-11 21:52:33 +02:00
holger krekel
6a7e6ce9e7 feat: expose metadata "maxsmtprecipients" value
also add metadata tests and make metadata lookup method more readable by using structural match/case syntax
2026-05-11 20:08:38 +02:00
holger krekel
8db668c037 fix(logging): log all http requests to syslog 2026-05-10 23:32:42 +02:00
holger krekel
45fafa10a9 fix: legacy token metadata storage used list type, but if no new setmetadata happened, the user would not be notified at all. 2026-05-08 21:39:40 +02:00
missytake
ee435a7ef7 fix(dns): query correct NS if MNAME server is hidden (#954)
replaces #870
fix #851

* fix(dns): address possible IndexError
* fix(dns): remove redundant docstring
* fix(dns): don't make NS explicit if None
* bump cmlxc to 0.13.5 which fixes a powerdns config issue
* remove the unneccessary SOA mocks, simplify mock tests, and run ruff format

Co-authored-by: holger krekel <holger@merlinux.eu>
2026-05-08 19:34:42 +02:00
missytake
8fafd4e79f fix(nginx): properly redirect www to mail_domain 2026-05-07 23:00:02 +02:00
32 changed files with 365 additions and 148 deletions

40
.github/workflows/ci-no-dns.yaml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: No-DNS
on:
# Triggers when a PR is merged into main or a direct push occurs
push:
branches: [ "main" ]
# Triggers for any PR (and its subsequent commits) targeting the main branch
pull_request:
branches: [ "main" ]
permissions: {}
# Newest push wins: Prevents multiple runs from clashing and wasting runner efforts
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
no-dns:
name: LXC deploy and test
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.6
with:
cmlxc_version: v0.14.6
cmlxc_commands: |
cmlxc init
# single cmdeploy relay test
cmlxc -v deploy-cmdeploy --source ./repo --type ipv4 cm0
cmlxc -v test-cmdeploy cm0
# cross cmdeploy relay test (two ipv4 relays)
cmlxc -v deploy-cmdeploy --source ./repo --ipv4-only --type ipv4 cm1
cmlxc -v test-cmdeploy cm0 cm1
# cross cmdeploy/madmail relay tests
cmlxc -v deploy-madmail mad0
cmlxc -v test-cmdeploy cm0 mad0
cmlxc -v test-mini mad0 cm0
cmlxc -v test-mini cm0 mad0

View File

@@ -1,4 +1,4 @@
name: Run unit-tests and container-based deploy+test verification name: CI
on: on:
# Triggers when a PR is merged into main or a direct push occurs # Triggers when a PR is merged into main or a direct push occurs
@@ -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.6.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.6.5/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
@@ -57,8 +57,9 @@ jobs:
lxc-test: lxc-test:
name: LXC deploy and test name: LXC deploy and test
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.10.0 uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.6
with: with:
cmlxc_version: v0.14.6
cmlxc_commands: | cmlxc_commands: |
cmlxc init cmlxc init
# single cmdeploy relay test # single cmdeploy relay test
@@ -75,3 +76,4 @@ jobs:
cmlxc -v test-cmdeploy cm0 mad0 cmlxc -v test-cmdeploy cm0 mad0
cmlxc -v test-mini cm0 mad0 cmlxc -v test-mini cm0 mad0
cmlxc -v test-mini mad0 cm0 cmlxc -v test-mini mad0 cm0

View File

@@ -10,6 +10,7 @@ dependencies = [
"filelock", "filelock",
"requests", "requests",
"crypt-r >= 3.13.1 ; python_version >= '3.11'", "crypt-r >= 3.13.1 ; python_version >= '3.11'",
"domain-validator",
] ]
[tool.setuptools] [tool.setuptools]

View File

@@ -1,6 +1,8 @@
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
@@ -19,7 +21,19 @@ def read_config(inipath):
class Config: class Config:
def __init__(self, inipath, params): def __init__(self, inipath, params):
self._inipath = inipath self._inipath = inipath
self.mail_domain = params["mail_domain"] raw_domain = params["mail_domain"]
self.mail_domain_bare = raw_domain
if is_valid_ipv4(raw_domain):
self.ipv4_relay = raw_domain
self.mail_domain = f"[{raw_domain}]"
self.postfix_myhostname = ipaddress.IPv4Address(raw_domain).reverse_pointer
else:
DomainValidator().validate_domain_re(raw_domain)
self.ipv4_relay = None
self.mail_domain = raw_domain
self.postfix_myhostname = raw_domain
self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60)) self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60))
self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10)) self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10))
self.max_mailbox_size = params["max_mailbox_size"] self.max_mailbox_size = params["max_mailbox_size"]
@@ -53,7 +67,7 @@ class Config:
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true" self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
self.imap_compress = params.get("imap_compress", "false").lower() == "true" self.imap_compress = params.get("imap_compress", "false").lower() == "true"
if "iroh_relay" not in params: if "iroh_relay" not in params:
self.iroh_relay = "https://" + params["mail_domain"] self.iroh_relay = "https://" + raw_domain
self.enable_iroh_relay = True self.enable_iroh_relay = True
else: else:
self.iroh_relay = params["iroh_relay"].strip() self.iroh_relay = params["iroh_relay"].strip()
@@ -79,17 +93,17 @@ class Config:
) )
self.tls_cert_mode = "external" self.tls_cert_mode = "external"
self.tls_cert_path, self.tls_key_path = parts self.tls_cert_path, self.tls_key_path = parts
elif self.mail_domain.startswith("_"): elif raw_domain.startswith("_") or self.ipv4_relay:
self.tls_cert_mode = "self" self.tls_cert_mode = "self"
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem" self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
self.tls_key_path = "/etc/ssl/private/mailserver.key" self.tls_key_path = "/etc/ssl/private/mailserver.key"
else: else:
self.tls_cert_mode = "acme" self.tls_cert_mode = "acme"
self.tls_cert_path = f"/var/lib/acme/live/{self.mail_domain}/fullchain" self.tls_cert_path = f"/var/lib/acme/live/{raw_domain}/fullchain"
self.tls_key_path = f"/var/lib/acme/live/{self.mail_domain}/privkey" self.tls_key_path = f"/var/lib/acme/live/{raw_domain}/privkey"
# deprecated option # deprecated option
mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}") mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{raw_domain}")
self.mailboxes_dir = Path(mbdir.strip()) self.mailboxes_dir = Path(mbdir.strip())
# old unused option (except for first migration from sqlite to maildir store) # old unused option (except for first migration from sqlite to maildir store)
@@ -175,3 +189,12 @@ def get_default_config_content(mail_domain, **overrides):
lines.append(line) lines.append(line)
content = "\n".join(lines) content = "\n".join(lines)
return content return content
def is_valid_ipv4(address: str) -> bool:
"""Check if a mail_domain is an IPv4 address."""
try:
ipaddress.IPv4Address(address)
return True
except ValueError:
return False

View File

@@ -70,6 +70,9 @@ class Metadata:
# Some tokens have expired, remove them. # Some tokens have expired, remove them.
with self._modify_tokens(addr) as _tokens: with self._modify_tokens(addr) as _tokens:
pass pass
elif isinstance(tokens, list):
with self._modify_tokens(addr) as tokens:
token_list = list(tokens.keys())
else: else:
token_list = [] token_list = []
return token_list return token_list
@@ -85,29 +88,27 @@ class MetadataDictProxy(DictProxy):
def handle_lookup(self, parts): def handle_lookup(self, parts):
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org # Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
keyparts = parts[0].split("/", 2) match parts[0].split("/", 2):
if keyparts[0] == "priv": case ["priv", _, keyname] if keyname == self.metadata.DEVICETOKEN_KEY:
keyname = keyparts[2] addr = parts[1]
addr = parts[1]
if keyname == self.metadata.DEVICETOKEN_KEY:
res = " ".join(self.metadata.get_tokens_for_addr(addr)) res = " ".join(self.metadata.get_tokens_for_addr(addr))
return f"O{res}\n" return f"O{res}\n"
elif keyparts[0] == "shared": case ["shared", _, keyname]:
keyname = keyparts[2] prefix = "vendor/vendor.dovecot/pvt/server/vendor/deltachat/"
if ( if keyname.startswith(prefix):
keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay" match keyname[len(prefix) :]:
and self.iroh_relay case "irohrelay" if self.iroh_relay:
): return f"O{self.iroh_relay}\n"
# Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay` case "turn":
return f"O{self.iroh_relay}\n" try:
elif keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn": res = turn_credentials()
try: except Exception:
res = turn_credentials() logging.exception("failed to get TURN credentials")
except Exception: return "N\n"
logging.exception("failed to get TURN credentials") return f"O{self.turn_hostname}:3478:{res}\n"
return "N\n" case "maxsmtprecipients":
port = 3478 # postfix default (see "postconf smtpd_recipient_limit")
return f"O{self.turn_hostname}:{port}:{res}\n" return "O1000\n"
logging.warning(f"lookup ignored: {parts!r}") logging.warning(f"lookup ignored: {parts!r}")
return "N\n" return "N\n"
@@ -117,12 +118,13 @@ class MetadataDictProxy(DictProxy):
# https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h # https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h
keyname = parts[1].split("/") keyname = parts[1].split("/")
value = parts[2] if len(parts) > 2 else "" value = parts[2] if len(parts) > 2 else ""
if keyname[0] == "priv" and keyname[2] == self.metadata.DEVICETOKEN_KEY: match keyname:
self.metadata.add_token_to_addr(addr, value) case ["priv", _, key] if key == self.metadata.DEVICETOKEN_KEY:
return True self.metadata.add_token_to_addr(addr, value)
elif keyname[0] == "priv" and keyname[2] == "messagenew": return True
self.notifier.new_message_for_addr(addr, self.metadata) case ["priv", _, "messagenew"]:
return True self.notifier.new_message_for_addr(addr, self.metadata)
return True
return False return False

View File

@@ -2,7 +2,6 @@
"""CGI script for creating new accounts.""" """CGI script for creating new accounts."""
import ipaddress
import json import json
import secrets import secrets
import string import string
@@ -15,16 +14,6 @@ ALPHANUMERIC = string.ascii_lowercase + string.digits
ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation
def wrap_ip(host):
if host.startswith("[") and host.endswith("]"):
return host
try:
ipaddress.ip_address(host)
return f"[{host}]"
except ValueError:
return host
def create_newemail_dict(config: Config): def create_newemail_dict(config: Config):
user = "".join( user = "".join(
secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length) secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length)
@@ -33,16 +22,22 @@ def create_newemail_dict(config: Config):
secrets.choice(ALPHANUMERIC_PUNCT) secrets.choice(ALPHANUMERIC_PUNCT)
for _ in range(config.password_min_length + 3) for _ in range(config.password_min_length + 3)
) )
return dict(email=f"{user}@{wrap_ip(config.mail_domain)}", password=f"{password}") return dict(email=f"{user}@{config.mail_domain}", password=f"{password}")
def create_dclogin_url(email, password): def create_dclogin_url(config, email, password):
"""Build a dclogin: URL with credentials and self-signed cert acceptance. """Build a dclogin: URL with credentials and self-signed cert acceptance.
Uses ic=3 (AcceptInvalidCertificates) so chatmail clients Uses ic=3 (AcceptInvalidCertificates) so chatmail clients
can connect to servers with self-signed TLS certificates. can connect to servers with self-signed TLS certificates.
""" """
return f"dclogin:{quote(email, safe='@')}?p={quote(password, safe='')}&v=1&ic=3" if config.ipv4_relay:
imap_host = "&ih=" + config.ipv4_relay
smtp_host = "&sh=" + config.ipv4_relay
else:
imap_host = ""
smtp_host = ""
return f"dclogin:{quote(email, safe='@[]')}?p={quote(password, safe='')}&v=1{imap_host}{smtp_host}&ic=3"
def print_new_account(): def print_new_account():
@@ -51,7 +46,9 @@ def print_new_account():
result = dict(email=creds["email"], password=creds["password"]) result = dict(email=creds["email"], password=creds["password"])
if config.tls_cert_mode == "self": if config.tls_cert_mode == "self":
result["dclogin_url"] = create_dclogin_url(creds["email"], creds["password"]) result["dclogin_url"] = create_dclogin_url(
config, creds["email"], creds["password"]
)
print("Content-Type: application/json") print("Content-Type: application/json")
print("") print("")

View File

@@ -31,6 +31,11 @@ def example_config(make_config):
return make_config("chat.example.org") return make_config("chat.example.org")
@pytest.fixture
def ipv4_config(make_config):
return make_config("1.3.3.7")
@pytest.fixture @pytest.fixture
def maildomain(example_config): def maildomain(example_config):
return example_config.mail_domain return example_config.mail_domain

View File

@@ -1,6 +1,10 @@
import pytest import pytest
from chatmaild.config import parse_size_mb, read_config from chatmaild.config import (
is_valid_ipv4,
parse_size_mb,
read_config,
)
def test_read_config_basic(example_config): def test_read_config_basic(example_config):
@@ -13,6 +17,12 @@ def test_read_config_basic(example_config):
example_config = read_config(inipath) example_config = read_config(inipath)
assert example_config.max_user_send_per_minute == 37 assert example_config.max_user_send_per_minute == 37
assert example_config.mail_domain == "chat.example.org" assert example_config.mail_domain == "chat.example.org"
assert example_config.ipv4_relay is None
def test_read_config_ipv4(ipv4_config):
assert ipv4_config.ipv4_relay == "1.3.3.7"
assert ipv4_config.mail_domain == "[1.3.3.7]"
def test_read_config_basic_using_defaults(tmp_path, maildomain): def test_read_config_basic_using_defaults(tmp_path, maildomain):
@@ -135,3 +145,17 @@ def test_max_mailbox_size_mb(make_config):
config = make_config("chat.example.org") config = make_config("chat.example.org")
assert config.max_mailbox_size == "500M" assert config.max_mailbox_size == "500M"
assert config.max_mailbox_size_mb == 500 assert config.max_mailbox_size_mb == 500
@pytest.mark.parametrize(
["input", "result"],
[
("example.org", False),
("1.3.3.7", True),
("fe::1", False),
("ad.1e.dag.adf", False),
("12394142", False),
],
)
def test_is_valid_ipv4(input, result):
assert result == is_valid_ipv4(input)

View File

@@ -360,15 +360,39 @@ def test_turn_credentials_success(notifier, metadata, monkeypatch):
def test_iroh_relay(dictproxy): def test_iroh_relay(dictproxy):
rfile = io.BytesIO( key = b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay\tuser@example.org"
b"\n".join( rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO()
[
b"H",
b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay\tuser@example.org",
]
)
)
wfile = io.BytesIO()
dictproxy.iroh_relay = "https://example.org/" dictproxy.iroh_relay = "https://example.org/"
dictproxy.loop_forever(rfile, wfile) dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == b"Ohttps://example.org/\n" assert wfile.getvalue() == b"Ohttps://example.org/\n"
def test_legacy_token_migration(metadata, testaddr):
with metadata.get_metadata_dict(testaddr).modify() as data:
data[metadata.DEVICETOKEN_KEY] = ["oldtoken1", "oldtoken2"]
assert metadata.get_tokens_for_addr(testaddr) == ["oldtoken1", "oldtoken2"]
mdict = metadata.get_metadata_dict(testaddr).read()
tokens = mdict[metadata.DEVICETOKEN_KEY]
assert isinstance(tokens, dict)
assert "oldtoken1" in tokens and "oldtoken2" in tokens
@pytest.mark.parametrize(
"suffix, expected",
[
(b"vendor/deltachat/maxsmtprecipients", b"O1000\n"),
(b"wrong/prefix/key", b"N\n"),
(b"vendor/deltachat/unknown", b"N\n"),
],
ids=["maxsmtprecipients", "prefix_mismatch", "unknown_name"],
)
def test_shared_lookup(dictproxy, suffix, expected):
key = (
b"Lshared/0123/vendor/vendor.dovecot/pvt/server/"
+ suffix
+ b"\tuser@example.org"
)
rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == expected

View File

@@ -19,24 +19,35 @@ def test_create_newemail_dict(example_config):
assert ac1["password"] != ac2["password"] assert ac1["password"] != ac2["password"]
def test_create_newemail_dict_ip(make_config): def test_create_newemail_dict_ip(ipv4_config):
config = make_config("1.2.3.4") ac = create_newemail_dict(ipv4_config)
ac = create_newemail_dict(config) assert ac["email"].endswith("@[1.3.3.7]")
assert ac["email"].endswith("@[1.2.3.4]")
def test_create_dclogin_url(): def test_create_dclogin_url(example_config):
url = create_dclogin_url("user@example.org", "p@ss w+rd") addr = "user@example.org"
password = "p@ss w+rd"
url = create_dclogin_url(example_config, addr, password)
assert url.startswith("dclogin:") assert url.startswith("dclogin:")
assert "v=1" in url assert "v=1" in url
assert "ic=3" in url assert "ic=3" in url
assert "user@example.org" in url assert addr in url
# password special chars must be encoded # password special chars must be encoded
assert "p%40ss" in url assert "p%40ss" in url
assert "w%2Brd" in url assert "w%2Brd" in url
def test_create_dclogin_url_ipv4(ipv4_config):
addr = "user@[1.3.3.7]"
password = "p@ss w+rd"
url = create_dclogin_url(ipv4_config, addr, password)
assert url.startswith("dclogin:")
assert "v=1" in url
assert "ic=3" in url
assert addr in url
def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config): def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config):
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath)) monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath))
print_new_account() print_new_account()

View File

@@ -87,10 +87,12 @@ def run_cmd_options(parser):
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 ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare
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"
if args.config.ipv4_relay:
args.dns_check_disabled = True
if not args.dns_check_disabled: if not args.dns_check_disabled:
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red): if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red):
@@ -119,6 +121,8 @@ def run_cmd(args, out):
elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]: elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]:
out.red("Deploy completed but letsencrypt not configured") out.red("Deploy completed but letsencrypt not configured")
out.red("Run 'cmdeploy run' again") out.red("Run 'cmdeploy run' again")
elif args.config.ipv4_relay:
out.green("Deploy completed.")
else: else:
out.green("Deploy completed, call `cmdeploy dns` next.") out.green("Deploy completed, call `cmdeploy dns` next.")
return 0 return 0
@@ -140,6 +144,10 @@ def dns_cmd_options(parser):
def dns_cmd(args, out): def dns_cmd(args, out):
"""Check DNS entries and optionally generate dns zone file.""" """Check DNS entries and optionally generate dns zone file."""
if args.config.ipv4_relay:
ipv4 = args.config.ipv4_relay
print(f"[WARNING] {ipv4} is not a domain, skipping DNS checks.")
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.mail_domain
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
@@ -177,7 +185,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 ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare
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}")

View File

@@ -370,7 +370,7 @@ class ChatmailVenvDeployer(Deployer):
def configure(self): def configure(self):
_configure_remote_venv_with_chatmaild(self, self.config) _configure_remote_venv_with_chatmaild(self, self.config)
configure_remote_units(self, self.config.mail_domain, self.units) configure_remote_units(self, self.config.mail_domain_bare, self.units)
def activate(self): def activate(self):
activate_remote_units(self, self.units) activate_remote_units(self, self.units)
@@ -469,7 +469,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
""" """
config = read_config(config_path) config = read_config(config_path)
check_config(config) check_config(config)
mail_domain = config.mail_domain bare_host = config.mail_domain_bare
if website_only: if website_only:
Deployment().perform_stages([WebsiteDeployer(config)]) Deployment().perform_stages([WebsiteDeployer(config)])
@@ -526,7 +526,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
) )
exit(1) exit(1)
tls_deployer = get_tls_deployer(config, mail_domain) tls_deployer = get_tls_deployer(config, bare_host)
all_deployers = [ all_deployers = [
ChatmailDeployer(config), ChatmailDeployer(config),
@@ -534,13 +534,13 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
FiltermailDeployer(), FiltermailDeployer(),
JournaldDeployer(), JournaldDeployer(),
UnboundDeployer(config), UnboundDeployer(config),
TurnDeployer(mail_domain), TurnDeployer(bare_host),
IrohDeployer(config.enable_iroh_relay), IrohDeployer(config.enable_iroh_relay),
tls_deployer, tls_deployer,
WebsiteDeployer(config), WebsiteDeployer(config),
ChatmailVenvDeployer(config), ChatmailVenvDeployer(config),
MtastsDeployer(), MtastsDeployer(),
OpendkimDeployer(mail_domain), *([] if config.ipv4_relay else [OpendkimDeployer(bare_host)]),
# Dovecot should be started before Postfix # Dovecot should be started before Postfix
# because it creates authentication socket # because it creates authentication socket
# required by Postfix. # required by Postfix.

View File

@@ -68,7 +68,7 @@ class DovecotDeployer(Deployer):
) )
def configure(self): def configure(self):
configure_remote_units(self, self.config.mail_domain, self.units) configure_remote_units(self, self.config.mail_domain_bare, self.units)
_configure_dovecot(self, self.config) _configure_dovecot(self, self.config)
def activate(self): def activate(self):

View File

@@ -7,6 +7,7 @@ listen = 0.0.0.0
protocols = imap lmtp protocols = imap lmtp
auth_mechanisms = plain auth_mechanisms = plain
auth_username_chars = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.-_@[]
{% if debug == true %} {% if debug == true %}
auth_verbose = yes auth_verbose = yes

View File

@@ -1,4 +1,3 @@
from pyinfra import host from pyinfra import host
from pyinfra.facts.files import File from pyinfra.facts.files import File
@@ -21,8 +20,8 @@ class ExternalTlsDeployer(Deployer):
def configure(self): def configure(self):
# Verify cert and key exist on the remote host using pyinfra facts. # Verify cert and key exist on the remote host using pyinfra facts.
for path in (self.cert_path, self.key_path): for path in (self.cert_path, self.key_path):
if host.get_fact(File, path=path) is None: if host.get_fact(File, path=path) is None:
raise Exception(f"External TLS file not found on server: {path}") raise Exception(f"External TLS file not found on server: {path}")
self.ensure_systemd_unit( self.ensure_systemd_unit(
"external/tls-cert-reload.path.j2", "external/tls-cert-reload.path.j2",
@@ -40,5 +39,3 @@ class ExternalTlsDeployer(Deployer):
running=True, running=True,
enabled=True, enabled=True,
) )

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.6.4/filtermail-{arch}" url = f"https://github.com/chatmail/filtermail/releases/download/v0.6.5/filtermail-{arch}"
sha256sum = { sha256sum = {
"x86_64": "5295115952c72e4c4ec3c85546e094b4155a4c702c82bd71fcdcb744dc73adf6", "x86_64": "32be37d631520f0246cda61fa20994d6299d2e144a9a37099d50434c0eb13d83",
"aarch64": "6892244f17b8f26ccb465766e96028e7222b3c8adefca9fc6bfe9ff332ca8dff", "aarch64": "f1dadffcc2377ecad16a6090f139d4af5ddfe504b32e05d2dfacfdcaab8652c0",
}[arch] }[arch]
self.download_executable(url, self.bin_path, sha256sum) self.download_executable(url, self.bin_path, sha256sum)

View File

@@ -42,6 +42,9 @@ stream {
} }
http { http {
# access_log setting is inherited by all server sections
access_log syslog:server=unix:/dev/log,facility=local7;
{% if config.tls_cert_mode == "self" %} {% if config.tls_cert_mode == "self" %}
limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s; limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s;
{% endif %} {% endif %}
@@ -71,8 +74,6 @@ http {
server_name {{ config.mail_domain }} mta-sts.{{ config.mail_domain }}; server_name {{ config.mail_domain }} mta-sts.{{ config.mail_domain }};
access_log syslog:server=unix:/dev/log,facility=local7;
location /mxdeliv { location /mxdeliv {
proxy_pass http://127.0.0.1:{{ config.filtermail_http_port_incoming }}; proxy_pass http://127.0.0.1:{{ config.filtermail_http_port_incoming }};
} }
@@ -143,7 +144,6 @@ http {
listen 127.0.0.1:8443 ssl; listen 127.0.0.1:8443 ssl;
server_name www.{{ config.mail_domain }}; server_name www.{{ config.mail_domain }};
return 301 $scheme://{{ config.mail_domain }}$request_uri; return 301 $scheme://{{ config.mail_domain }}$request_uri;
access_log syslog:server=unix:/dev/log,facility=local7;
} }
server { server {

View File

@@ -54,14 +54,17 @@ smtpd_tls_exclude_ciphers = aNULL, RC4, MD5, DES
tls_preempt_cipherlist = yes tls_preempt_cipherlist = yes
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = {{ config.mail_domain }} myhostname = {{ config.postfix_myhostname }}
alias_maps = hash:/etc/aliases alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases alias_database = hash:/etc/aliases
# Postfix does not deliver mail for any domain by itself. # When postfix receives mail for $mydestination,
# Primary domain is listed in `virtual_mailbox_domains` instead # it hands it over to dovecot via $local_transport.
# and handed over to Dovecot. # Note: IP literals must be handled via local delivery / mydestination.
mydestination = mydestination = {{ config.mail_domain }}
local_transport = lmtp:unix:private/dovecot-lmtp
# postfix doesn't check whether local users exist or not:
local_recipient_maps =
relayhost = relayhost =
{% if disable_ipv6 %} {% if disable_ipv6 %}
@@ -79,8 +82,6 @@ inet_protocols = ipv4
inet_protocols = all inet_protocols = all
{% endif %} {% endif %}
virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = {{ config.mail_domain }}
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
mua_client_restrictions = permit_sasl_authenticated, reject mua_client_restrictions = permit_sasl_authenticated, reject

View File

@@ -80,8 +80,9 @@ filter unix - n n - - lmtp
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
-o milter_macro_daemon_name=ORIGINATING -o milter_macro_daemon_name=ORIGINATING
-o smtpd_milters=unix:opendkim/opendkim.sock
-o cleanup_service_name=authclean -o cleanup_service_name=authclean
{% if not config.ipv4_relay %} -o smtpd_milters=unix:opendkim/opendkim.sock
{% endif %}
# Local SMTP server for reinjecting incoming filtered mail # Local SMTP server for reinjecting incoming filtered mail
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd 127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd

View File

@@ -64,21 +64,25 @@ def get_dkim_entry(mail_domain, pre_command, dkim_selector):
) )
def query_dns(typ, domain): def get_authoritative_ns(domain):
# Get autoritative nameserver from the SOA record. ns_replies = [
soa_answers = [
x.split() x.split()
for x in shell( for x in shell(
f"dig -r -q {domain} -t SOA +noall +authority +answer", print=log_progress f"dig -r -q {domain} -t NS +noall +authority +answer", print=log_progress
).split("\n") ).split("\n")
] ]
soa = [a for a in soa_answers if len(a) >= 3 and a[3] == "SOA"] filtered_replies = [a for a in ns_replies if len(a) >= 5 and a[3] == "NS"]
if not soa: if not filtered_replies:
return return
ns = soa[0][4] return filtered_replies[0][4]
def query_dns(typ, domain):
ns = get_authoritative_ns(domain)
# Query authoritative nameserver directly to bypass DNS cache. # Query authoritative nameserver directly to bypass DNS cache.
res = shell(f"dig @{ns} -r -q {domain} -t {typ} +short", print=log_progress) direct_ns = f"@{ns}" if ns else ""
res = shell(f"dig {direct_ns} -r -q {domain} -t {typ} +short", print=log_progress)
return next((line for line in res.split("\n") if not line.startswith(";")), "") return next((line for line in res.split("\n") if not line.startswith(";")), "")

View File

@@ -12,7 +12,7 @@ def test_init(tmp_path, maildomain):
inipath = tmp_path.joinpath("chatmail.ini") inipath = tmp_path.joinpath("chatmail.ini")
main(["init", "--config", str(inipath), maildomain]) main(["init", "--config", str(inipath), maildomain])
config = read_config(inipath) config = read_config(inipath)
assert config.mail_domain == maildomain assert config.mail_domain_bare == maildomain
def test_capabilities(imap): def test_capabilities(imap):
@@ -89,12 +89,11 @@ def test_concurrent_logins_same_account(
assert login_results.get() assert login_results.get()
def test_no_vrfy(cmfactory, chatmail_config): def test_no_vrfy(cmfactory, chatmail_config, maildomain):
ac = cmfactory.get_online_account() ac = cmfactory.get_online_account()
addr = ac.get_config("addr") addr = ac.get_config("addr")
domain = chatmail_config.mail_domain
s = smtplib.SMTP(domain) s = smtplib.SMTP(maildomain)
s.starttls() s.starttls()
s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}") s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}")

View File

@@ -5,6 +5,7 @@ import subprocess
import time import time
import pytest import pytest
from chatmaild.config import is_valid_ipv4
from cmdeploy import remote from cmdeploy import remote
from cmdeploy.cmdeploy import get_sshexec from cmdeploy.cmdeploy import get_sshexec
@@ -21,6 +22,8 @@ class TestSSHExecutor:
assert out == out2 assert out == out2
def test_perform_initial(self, sshexec, maildomain): def test_perform_initial(self, sshexec, maildomain):
if is_valid_ipv4(maildomain):
pytest.skip(f"{maildomain} is not a domain")
res = sshexec( res = sshexec(
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain) remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
) )
@@ -61,8 +64,10 @@ class TestSSHExecutor:
else: else:
pytest.fail("didn't raise exception") pytest.fail("didn't raise exception")
def test_opendkim_restarted(self, sshexec): def test_opendkim_restarted(self, sshexec, maildomain):
"""check that opendkim is not running for longer than a day.""" """check that opendkim is not running for longer than a day."""
if is_valid_ipv4(maildomain):
pytest.skip(f"{maildomain} is an IPv4 relay, opendkim is not installed")
cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp" cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp"
out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd)) out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd))
datestring = out.split("=")[1] datestring = out.split("=")[1]
@@ -281,3 +286,15 @@ def test_deployed_state(remote):
# assert len(git_status) == len(remote_version) # for some reason, we only get 11 lines from remote.iter_output() # assert len(git_status) == len(remote_version) # for some reason, we only get 11 lines from remote.iter_output()
for i in range(len(remote_version)): for i in range(len(remote_version)):
assert git_status[i] == remote_version[i], "You have undeployed changes." assert git_status[i] == remote_version[i], "You have undeployed changes."
def test_nginx_access_log_only_defined_once(sshdomain):
sshexec = get_sshexec(sshdomain)
conf = sshexec(
call=remote.rshell.shell,
kwargs=dict(command="nginx -T 2>/dev/null"),
)
access_logs = [l for l in conf.splitlines() if l.strip().startswith("access_log")]
assert len(access_logs) == 1, (
f"expected 1 access_log, found {len(access_logs)}: {access_logs}"
)

View File

@@ -15,7 +15,7 @@ def imap_mailbox(cmfactory, ssl_context):
(ac1,) = cmfactory.get_online_accounts(1) (ac1,) = cmfactory.get_online_accounts(1)
user = ac1.get_config("addr") user = ac1.get_config("addr")
password = ac1.get_config("mail_pw") password = ac1.get_config("mail_pw")
host = user.split("@")[1] host = user.split("@")[1].strip("[").strip("]")
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(user, password) mailbox.login(user, password)
mailbox.dc_ac = ac1 mailbox.dc_ac = ac1
@@ -178,7 +178,7 @@ def test_hide_senders_ip_address(cmfactory, ssl_context):
chat.send_text("testing submission header cleanup") chat.send_text("testing submission header cleanup")
user2.wait_for_incoming_msg() user2.wait_for_incoming_msg()
addr = user2.get_config("addr") addr = user2.get_config("addr")
host = addr.split("@")[1] host = addr.split("@")[1].strip("[").strip("]")
pw = user2.get_config("mail_pw") pw = user2.get_config("mail_pw")
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(addr, pw) mailbox.login(addr, pw)

View File

@@ -1,5 +1,4 @@
import imaplib import imaplib
import ipaddress
import itertools import itertools
import os import os
import random import random
@@ -10,19 +9,19 @@ import time
from pathlib import Path from pathlib import Path
import pytest import pytest
from chatmaild.config import read_config from chatmaild.config import is_valid_ipv4, read_config
from domain_validator import DomainValidator
def format_mail_domain(raw_domain: str) -> str:
if is_valid_ipv4(raw_domain):
return f"[{raw_domain}]"
DomainValidator().validate_domain_re(raw_domain)
return raw_domain
conftestdir = Path(__file__).parent conftestdir = Path(__file__).parent
def _is_ip(domain):
try:
ipaddress.ip_address(domain)
return True
except ValueError:
return False
def pytest_configure(config): def pytest_configure(config):
config._benchresults = {} config._benchresults = {}
config.addinivalue_line( config.addinivalue_line(
@@ -58,7 +57,7 @@ def chatmail_config(pytestconfig):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def maildomain(chatmail_config): def maildomain(chatmail_config):
return chatmail_config.mail_domain return chatmail_config.mail_domain_bare
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -278,7 +277,6 @@ def gencreds(chatmail_config):
def gen(domain=None): def gen(domain=None):
domain = domain if domain else chatmail_config.mail_domain domain = domain if domain else chatmail_config.mail_domain
addr_domain = f"[{domain}]" if _is_ip(domain) else domain
while 1: while 1:
num = next(count) num = next(count)
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890" alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
@@ -292,7 +290,7 @@ def gencreds(chatmail_config):
password = "".join( password = "".join(
random.choices(alphanumeric, k=chatmail_config.password_min_length) random.choices(alphanumeric, k=chatmail_config.password_min_length)
) )
yield f"{user}@{addr_domain}", f"{password}" yield f"{user}@{domain}", f"{password}"
return lambda domain=None: next(gen(domain)) return lambda domain=None: next(gen(domain))
@@ -317,7 +315,8 @@ class ChatmailACFactory:
def _make_transport(self, domain): def _make_transport(self, domain):
"""Build a transport config dict for the given domain.""" """Build a transport config dict for the given domain."""
addr, password = self.gencreds(domain) domain_deliverable = format_mail_domain(domain)
addr, password = self.gencreds(domain_deliverable)
transport = { transport = {
"addr": addr, "addr": addr,
"password": password, "password": password,
@@ -326,7 +325,7 @@ class ChatmailACFactory:
"imapServer": domain, "imapServer": domain,
"smtpServer": domain, "smtpServer": domain,
} }
if self.chatmail_config.tls_cert_mode == "self": if domain.startswith("_") or is_valid_ipv4(domain):
transport["certificateChecks"] = "acceptInvalidCertificates" transport["certificateChecks"] = "acceptInvalidCertificates"
return transport return transport
@@ -341,8 +340,9 @@ class ChatmailACFactory:
accounts = [] accounts = []
for _ in range(num): for _ in range(num):
account = self.dc.add_account() account = self.dc.add_account()
addr, password = self.gencreds(domain) domain_deliverable = format_mail_domain(domain)
if _is_ip(domain): addr, password = self.gencreds(domain_deliverable)
if is_valid_ipv4(domain):
# Use DCLOGIN scheme with explicit server hosts, # Use DCLOGIN scheme with explicit server hosts,
# matching how madmail presents its addresses to users. # matching how madmail presents its addresses to users.
qr = ( qr = (
@@ -416,10 +416,10 @@ class Remote:
def iter_output(self, logcmd="", ready=None): def iter_output(self, logcmd="", ready=None):
getjournal = "journalctl -f" if not logcmd else logcmd getjournal = "journalctl -f" if not logcmd else logcmd
print(self.sshdomain) print(self.sshdomain)
match self.sshdomain: if self.sshdomain in ("@local", "localhost"):
case "@local": command = [] command = []
case "localhost": command = [] else:
case _: command = ["ssh", f"root@{self.sshdomain}"] command = ["ssh", f"root@{self.sshdomain}"]
[command.append(arg) for arg in getjournal.split()] [command.append(arg) for arg in getjournal.split()]
popen = subprocess.Popen( popen = subprocess.Popen(
command, command,

View File

@@ -39,6 +39,14 @@ class TestCmdline:
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "deleting config file" in out.lower() assert "deleting config file" in out.lower()
def test_dns_skip_on_ip(self, capsys, tmp_path, monkeypatch):
monkeypatch.delenv("CHATMAIL_INI", raising=False)
inipath = tmp_path / "chatmail.ini"
assert main(["init", "--config", str(inipath), "1.3.3.7"]) == 0
assert main(["dns", "--config", str(inipath)]) == 0
out, err = capsys.readouterr()
assert out == "[WARNING] 1.3.3.7 is not a domain, skipping DNS checks.\n"
def test_www_folder(example_config, tmp_path): def test_www_folder(example_config, tmp_path):
reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve() reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve()

View File

@@ -4,6 +4,7 @@ import pytest
from cmdeploy import remote from cmdeploy import remote
from cmdeploy.dns import check_full_zone, check_initial_remote_data, parse_zone_records from cmdeploy.dns import check_full_zone, check_initial_remote_data, parse_zone_records
from cmdeploy.remote.rdns import get_authoritative_ns
@pytest.fixture @pytest.fixture
@@ -14,11 +15,15 @@ def mockdns_base(monkeypatch):
if command.startswith("dig"): if command.startswith("dig"):
if command == "dig": if command == "dig":
return "." return "."
if "SOA" in command: if "with.public.soa" in command and "NS" in command:
return "domain.with.public.soa. 2419 IN NS ns1.first-ns.de."
if "with.hidden.soa" in command and "NS" in command:
return ( return (
"delta.chat. 21600 IN SOA ns1.first-ns.de. dns.hetzner.com." "domain.with.hidden.soa. 2137 IN NS ns1.desec.io.\n"
" 2025102800 14400 1800 604800 3600" "domain.with.hidden.soa. 2137 IN NS ns2.desec.org."
) )
if "NS" in command:
return "delta.chat. 21600 IN NS ns1.first-ns.de."
command_chunks = command.split() command_chunks = command.split()
domain, typ = command_chunks[4], command_chunks[6] domain, typ = command_chunks[4], command_chunks[6]
try: try:
@@ -125,6 +130,17 @@ class TestPerformInitialChecks:
assert not l assert not l
@pytest.mark.parametrize(
("domain", "ns"),
[
("domain.with.public.soa", "ns1.first-ns.de."),
("domain.with.hidden.soa", "ns1.desec.io."),
],
)
def test_get_authoritative_ns(domain, ns, mockdns):
assert get_authoritative_ns(domain) == ns
def test_parse_zone_records(): def test_parse_zone_records():
text = """ text = """
; This is a comment ; This is a comment

View File

@@ -23,8 +23,7 @@ def make_host(*fact_pairs):
if cls not in facts: if cls not in facts:
registered = ", ".join(c.__name__ for c in facts) registered = ", ".join(c.__name__ for c in facts)
raise LookupError( raise LookupError(
f"unexpected get_fact({cls.__name__}); " f"unexpected get_fact({cls.__name__}); only registered: {registered}"
f"only registered: {registered}"
) )
return facts[cls] return facts[cls]

View File

@@ -15,6 +15,7 @@ goes beyond what classic email servers offer:
streaming, privacy-preserving Push Notifications for Apple, Google, and `Ubuntu Touch <https://docs.ubports.com/en/latest/appdev/guides/pushnotifications.html>`_; streaming, privacy-preserving Push Notifications for Apple, Google, and `Ubuntu Touch <https://docs.ubports.com/en/latest/appdev/guides/pushnotifications.html>`_;
- **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted - **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted
(DKIM is not enforced on :ref:`IP-only relays <iponly>`)
- **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.

View File

@@ -14,8 +14,6 @@ Minimal requirements and prerequisites
You will need the following: You will need the following:
- Control over a domain through a DNS provider of your choice.
- A Debian 12 **deployment server** with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports. - A Debian 12 **deployment 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
@@ -28,6 +26,11 @@ You will need the following:
(An ed25519 private key is required due to an `upstream bug in (An ed25519 private key is required due to an `upstream bug in
paramiko <https://github.com/paramiko/paramiko/issues/2191>`_) 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 with ``scripts/cmdeploy`` Setup with ``scripts/cmdeploy``
------------------------------------- -------------------------------------
@@ -98,15 +101,6 @@ steps. Please substitute it with your own domain.
configure at your DNS provider (it can take some time until they are configure at your DNS provider (it can take some time until they are
public). public).
Docker installation
-------------------
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>`_.
See the `chatmail/docker README <https://github.com/chatmail/docker>`_ for full setup instructions.
Other helpful commands Other helpful commands
---------------------- ----------------------

View File

@@ -19,3 +19,4 @@ Contributions and feedback welcome through the https://github.com/chatmail/relay
reverse_dns reverse_dns
related related
faq faq
iponly

40
doc/source/iponly.rst Normal file
View File

@@ -0,0 +1,40 @@
.. _iponly:
Hosting without DNS records
===========================
.. note::
This option is experimental and might change without notice.
In case you don't have a domain,
for example in a local network,
you can run a chatmail relay with only an IPv4 address as well.
To deploy a relay without a domain,
run ``cmdeploy init`` with only the IPv4 address
during the :ref:`installation steps <setup>`,
for example ``cmdeploy init 13.12.23.42``.
Drawbacks
---------
- your transport encryption will only use self-signed TLS certificates,
which are vulnerable against MITM attacks.
the chatmail core's end-to-end encryption should suffice in most scenarios though.
- your messages will not be DKIM-signed;
experimentally, most chatmail relays accept non-DKIM-signed messages from IP-only relays,
but some relays might not accept messages from yours.
Email addresses
---------------
When running without a domain,
your chatmail addresses will use the IPv4 address
in brackets as the domain part,
for example ``user@[13.12.23.42]``.
This is a valid email address format
according to :rfc:`5321`.

View File

@@ -265,7 +265,8 @@ from the chatmail relay server.
Email domain authentication (DKIM) Email domain authentication (DKIM)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Chatmail relays enforce :rfc:`DKIM <6376>` to authenticate incoming emails. Chatmail relays enforce :rfc:`DKIM <6376>` to authenticate incoming emails
(except for :ref:`IP-only relays <iponly>`).
Incoming emails must have a valid DKIM signature with Incoming emails must have a valid DKIM signature with
Signing Domain Identifier (SDID, ``d=`` parameter in the DKIM-Signature Signing Domain Identifier (SDID, ``d=`` parameter in the DKIM-Signature
header) equal to the ``From:`` header domain. This property is checked header) equal to the ``From:`` header domain. This property is checked