mirror of
https://github.com/chatmail/relay.git
synced 2026-06-10 13:41:08 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a8272a05b | |||
| 0a95e1ceeb | |||
| f496871863 | |||
| 6def189d16 | |||
| 24612e9121 | |||
| a9dd9fe3e0 | |||
| aa846c3478 | |||
| 921080125f | |||
| d898f41064 | |||
| e9e012234b | |||
| bb40c5bb21 | |||
| a229f1bc45 | |||
| 4ba19b0031 | |||
| 5eab3a5a25 | |||
| 30729d9be0 | |||
| 4b04aae83b | |||
| 0eed92171c | |||
| a5b9a98baa | |||
| ab2d807084 | |||
| ce05b26c77 | |||
| 77ed93fb7a | |||
| 39d1ecaa03 |
@@ -20,9 +20,9 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
no-dns:
|
no-dns:
|
||||||
name: LXC deploy and test
|
name: LXC deploy and test
|
||||||
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.6
|
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@main
|
||||||
with:
|
with:
|
||||||
cmlxc_version: v0.14.6
|
cmlxc_version: main
|
||||||
cmlxc_commands: |
|
cmlxc_commands: |
|
||||||
cmlxc init
|
cmlxc init
|
||||||
# single cmdeploy relay test
|
# single cmdeploy relay test
|
||||||
|
|||||||
@@ -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.6/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
|
||||||
@@ -57,9 +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.14.6
|
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@main
|
||||||
with:
|
with:
|
||||||
cmlxc_version: v0.14.6
|
cmlxc_version: main
|
||||||
cmlxc_commands: |
|
cmlxc_commands: |
|
||||||
cmlxc init
|
cmlxc init
|
||||||
# single cmdeploy relay test
|
# single cmdeploy relay test
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ name: Trigger Docker build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, j4n/dovecot-multidist]
|
||||||
|
tags: ['[0-9]+.[0-9]+.[0-9]+']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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]
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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
|
||||||
|
|
||||||
@@ -25,7 +24,6 @@ class Config:
|
|||||||
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
|
||||||
|
|||||||
@@ -168,6 +168,16 @@ class Expiry:
|
|||||||
if mbox.last_login and mbox.last_login < cutoff_without_login:
|
if mbox.last_login and mbox.last_login < cutoff_without_login:
|
||||||
self.remove_mailbox(mbox.basedir)
|
self.remove_mailbox(mbox.basedir)
|
||||||
return
|
return
|
||||||
|
elif mbox.last_login is None:
|
||||||
|
try:
|
||||||
|
if not self.dry:
|
||||||
|
os.rmdir(mbox.basedir)
|
||||||
|
self.del_mboxes += 1
|
||||||
|
except OSError:
|
||||||
|
print_info(
|
||||||
|
f"Skipped deleting {mbox.basedir}, doesn't have last_login but isn't empty"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
mboxname = os.path.basename(mbox.basedir)
|
mboxname = os.path.basename(mbox.basedir)
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import itertools
|
import itertools
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import shutil
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fnmatch import fnmatch
|
from fnmatch import fnmatch
|
||||||
@@ -9,6 +10,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from chatmaild.expire import (
|
from chatmaild.expire import (
|
||||||
|
Expiry,
|
||||||
FileEntry,
|
FileEntry,
|
||||||
MailboxStat,
|
MailboxStat,
|
||||||
expire_to_target,
|
expire_to_target,
|
||||||
@@ -104,6 +106,32 @@ def test_stats_mailbox(mbox1):
|
|||||||
assert mbox3.last_login is None
|
assert mbox3.last_login is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mbox_without_password(mbox1, example_config, capsys):
|
||||||
|
password = Path(mbox1.basedir).joinpath("password")
|
||||||
|
os.remove(password)
|
||||||
|
mbox_rescan = MailboxStat(mbox1.basedir)
|
||||||
|
assert mbox_rescan.last_login is None
|
||||||
|
|
||||||
|
exp = Expiry(
|
||||||
|
example_config, dry=False, now=datetime.now().timestamp(), verbose=False
|
||||||
|
)
|
||||||
|
exp.process_mailbox_stat(mbox_rescan)
|
||||||
|
out, err = capsys.readouterr()
|
||||||
|
assert "doesn't have last_login but isn't empty" in err
|
||||||
|
assert os.path.isdir(mbox_rescan.basedir)
|
||||||
|
|
||||||
|
for entry in os.scandir(mbox_rescan.basedir):
|
||||||
|
if os.path.isdir(entry):
|
||||||
|
shutil.rmtree(entry)
|
||||||
|
else:
|
||||||
|
os.remove(entry)
|
||||||
|
|
||||||
|
exp.process_mailbox_stat(mbox_rescan)
|
||||||
|
out, err = capsys.readouterr()
|
||||||
|
assert "doesn't have last_login but isn't empty" not in err
|
||||||
|
assert not os.path.isdir(mbox_rescan.basedir)
|
||||||
|
|
||||||
|
|
||||||
def test_report_no_mailboxes(example_config):
|
def test_report_no_mailboxes(example_config):
|
||||||
args = (str(example_config._inipath),)
|
args = (str(example_config._inipath),)
|
||||||
report_main(args)
|
report_main(args)
|
||||||
|
|||||||
@@ -171,16 +171,14 @@ class UnboundDeployer(Deployer):
|
|||||||
"unbound-anchor -a /var/lib/unbound/root.key || true",
|
"unbound-anchor -a /var/lib/unbound/root.key || true",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
if self.config.disable_ipv6:
|
self.ensure_directory(
|
||||||
self.ensure_directory(
|
path="/etc/unbound/unbound.conf.d",
|
||||||
path="/etc/unbound/unbound.conf.d",
|
)
|
||||||
)
|
self.put_template(
|
||||||
self.put_template(
|
"unbound/unbound.conf.j2",
|
||||||
"unbound/unbound.conf.j2",
|
"/etc/unbound/unbound.conf.d/chatmail.conf",
|
||||||
"/etc/unbound/unbound.conf.d/chatmail.conf",
|
disable_ipv6=self.config.disable_ipv6,
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
self.remove_file("/etc/unbound/unbound.conf.d/chatmail.conf")
|
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
server.shell(
|
server.shell(
|
||||||
@@ -391,6 +389,12 @@ class ChatmailDeployer(Deployer):
|
|||||||
src=BytesIO(b'APT::Install-Recommends "false";\n'),
|
src=BytesIO(b'APT::Install-Recommends "false";\n'),
|
||||||
dest="/etc/apt/apt.conf.d/00InstallRecommends",
|
dest="/etc/apt/apt.conf.d/00InstallRecommends",
|
||||||
)
|
)
|
||||||
|
# Pin dovecot-* to priority -1 before any apt operation, apt should
|
||||||
|
# never manage dovecot as our version might be lower than the distro's.
|
||||||
|
self.put_file(
|
||||||
|
src=StringIO("Package: dovecot-*\nPin: version *\nPin-Priority: -1\n"),
|
||||||
|
dest="/etc/apt/preferences.d/pin-dovecot",
|
||||||
|
)
|
||||||
apt.update(name="apt update", cache_time=24 * 3600)
|
apt.update(name="apt update", cache_time=24 * 3600)
|
||||||
apt.upgrade(name="upgrade apt packages", auto_remove=True)
|
apt.upgrade(name="upgrade apt packages", auto_remove=True)
|
||||||
|
|
||||||
@@ -514,6 +518,8 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
|
|||||||
(["master", "smtpd"], config.postfix_reinject_port_incoming),
|
(["master", "smtpd"], config.postfix_reinject_port_incoming),
|
||||||
("filtermail", config.filtermail_smtp_port),
|
("filtermail", config.filtermail_smtp_port),
|
||||||
("filtermail", config.filtermail_smtp_port_incoming),
|
("filtermail", config.filtermail_smtp_port_incoming),
|
||||||
|
("filtermail", config.filtermail_http_port_incoming),
|
||||||
|
("filtermail", config.filtermail_lmtp_port_transport),
|
||||||
]
|
]
|
||||||
for service, port in port_services:
|
for service, port in port_services:
|
||||||
print(f"Checking if port {port} is available for {service}...")
|
print(f"Checking if port {port} is available for {service}...")
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import io
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from chatmaild.config import Config
|
from chatmaild.config import Config
|
||||||
@@ -19,12 +18,18 @@ DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3"
|
|||||||
DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}"
|
DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}"
|
||||||
|
|
||||||
DOVECOT_SHA256 = {
|
DOVECOT_SHA256 = {
|
||||||
("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
|
("amd64", "bookworm", "core"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
|
||||||
("core", "arm64"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9",
|
("arm64", "bookworm", "core"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9",
|
||||||
("imapd", "amd64"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
|
("amd64", "bookworm", "imapd"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
|
||||||
("imapd", "arm64"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
|
("arm64", "bookworm", "imapd"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
|
||||||
("lmtpd", "amd64"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
|
("amd64", "bookworm", "lmtpd"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
|
||||||
("lmtpd", "arm64"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
|
("arm64", "bookworm", "lmtpd"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
|
||||||
|
("amd64", "trixie", "core"): "406d3781ed81e0913c472077dcf62cb1106e3855983efa6e44ddf43b4b0c9be1",
|
||||||
|
("arm64", "trixie", "core"): "c75b0d9df11a77d07ebd8522920380c167fa47330ddefebe10575d99d0ecdf7f",
|
||||||
|
("amd64", "trixie", "imapd"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
|
||||||
|
("arm64", "trixie", "imapd"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
|
||||||
|
("amd64", "trixie", "lmtpd"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
|
||||||
|
("arm64", "trixie", "lmtpd"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -38,34 +43,32 @@ class DovecotDeployer(Deployer):
|
|||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
arch = host.get_fact(Arch)
|
arch = host.get_fact(Arch)
|
||||||
|
codename = (host.get_fact(Command, "grep '^VERSION_CODENAME=' /etc/os-release | cut -d= -f2") or "").strip()
|
||||||
|
if codename not in {key[1] for key in DOVECOT_SHA256}:
|
||||||
|
raise ValueError(f"Unsupported Debian codename: {codename!r}")
|
||||||
with blocked_service_startup():
|
with blocked_service_startup():
|
||||||
debs = []
|
debs = []
|
||||||
for pkg in ("core", "imapd", "lmtpd"):
|
for pkg in ("core", "imapd", "lmtpd"):
|
||||||
deb, changed = _download_dovecot_package(pkg, arch)
|
deb, changed = _download_dovecot_package(pkg, arch, codename)
|
||||||
self.need_restart |= changed
|
self.need_restart |= changed
|
||||||
if deb:
|
if deb:
|
||||||
debs.append(deb)
|
debs.append(deb)
|
||||||
if debs:
|
if debs:
|
||||||
deb_list = " ".join(debs)
|
deb_list = " ".join(debs)
|
||||||
# First dpkg may fail on missing dependencies (stderr suppressed);
|
# apt-get install with local .deb paths resolves depends
|
||||||
# apt-get --fix-broken pulls them in, then dpkg retries cleanly.
|
# against the configured repos (e.g. pulls libwrap0),
|
||||||
|
# The pin file written earlier by ChatmailDeployer prevents apt
|
||||||
|
# from installing a 'wrong' version
|
||||||
server.shell(
|
server.shell(
|
||||||
name="Install dovecot packages",
|
name="Install dovecot packages",
|
||||||
commands=[
|
commands=[
|
||||||
f"dpkg --force-confdef --force-confold -i {deb_list} 2> /dev/null || true",
|
"DEBIAN_FRONTEND=noninteractive apt-get install -y "
|
||||||
"DEBIAN_FRONTEND=noninteractive apt-get -y --fix-broken install",
|
'-o Dpkg::Options::="--force-confdef" '
|
||||||
f"dpkg --force-confdef --force-confold -i {deb_list}",
|
'-o Dpkg::Options::="--force-confold" '
|
||||||
|
f"--allow-downgrades {deb_list}",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.need_restart = True
|
self.need_restart = True
|
||||||
self.put_file(
|
|
||||||
src=io.StringIO(
|
|
||||||
"Package: dovecot-*\n"
|
|
||||||
"Pin: version *\n"
|
|
||||||
"Pin-Priority: -1\n"
|
|
||||||
),
|
|
||||||
dest="/etc/apt/preferences.d/pin-dovecot",
|
|
||||||
)
|
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
configure_remote_units(self, self.config.mail_domain_bare, self.units)
|
configure_remote_units(self, self.config.mail_domain_bare, self.units)
|
||||||
@@ -78,7 +81,7 @@ class DovecotDeployer(Deployer):
|
|||||||
if not self.disable_mail and not self.need_restart:
|
if not self.disable_mail and not self.need_restart:
|
||||||
stale = host.get_fact(
|
stale = host.get_fact(
|
||||||
Command,
|
Command,
|
||||||
'pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);'
|
"pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);"
|
||||||
' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"'
|
' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"'
|
||||||
" && echo STALE || true",
|
" && echo STALE || true",
|
||||||
)
|
)
|
||||||
@@ -102,13 +105,13 @@ def _pick_url(primary, fallback):
|
|||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool]:
|
def _download_dovecot_package(package: str, arch: str, codename: str) -> tuple[str | None, bool]:
|
||||||
"""Download a dovecot .deb if needed, return (path, changed)."""
|
"""Download a dovecot .deb if needed, return (path, changed)."""
|
||||||
arch = "amd64" if arch == "x86_64" else arch
|
arch = "amd64" if arch == "x86_64" else arch
|
||||||
arch = "arm64" if arch == "aarch64" else arch
|
arch = "arm64" if arch == "aarch64" else arch
|
||||||
|
|
||||||
pkg_name = f"dovecot-{package}"
|
pkg_name = f"dovecot-{package}"
|
||||||
sha256 = DOVECOT_SHA256.get((package, arch))
|
sha256 = DOVECOT_SHA256.get((arch, codename, package))
|
||||||
if sha256 is None:
|
if sha256 is None:
|
||||||
op = apt.packages(packages=[pkg_name])
|
op = apt.packages(packages=[pkg_name])
|
||||||
return None, bool(getattr(op, "changed", False))
|
return None, bool(getattr(op, "changed", False))
|
||||||
@@ -119,8 +122,10 @@ def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool
|
|||||||
|
|
||||||
url_version = DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
|
url_version = DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
|
||||||
deb_base = f"{pkg_name}_{url_version}_{arch}.deb"
|
deb_base = f"{pkg_name}_{url_version}_{arch}.deb"
|
||||||
primary_url = f"https://download.delta.chat/dovecot/{deb_base}"
|
primary_url = f"https://download.delta.chat/dovecot/{codename}/{url_version}/{deb_base}"
|
||||||
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version}/{deb_base}"
|
upstream_version = DOVECOT_ARCHIVE_VERSION.rsplit("-", 1)[0].replace("+", "%2B")
|
||||||
|
fallback_deb = f"{pkg_name}_{url_version}_{arch}_{codename}.deb"
|
||||||
|
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{upstream_version}/{fallback_deb}"
|
||||||
url = _pick_url(primary_url, fallback_url)
|
url = _pick_url(primary_url, fallback_url)
|
||||||
deb_filename = f"/root/{deb_base}"
|
deb_filename = f"/root/{deb_base}"
|
||||||
|
|
||||||
@@ -134,6 +139,7 @@ def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool
|
|||||||
|
|
||||||
return deb_filename, True
|
return deb_filename, True
|
||||||
|
|
||||||
|
|
||||||
def _configure_dovecot(deployer, config: Config, debug: bool = False):
|
def _configure_dovecot(deployer, config: Config, debug: bool = False):
|
||||||
"""Configures Dovecot IMAP server."""
|
"""Configures Dovecot IMAP server."""
|
||||||
deployer.put_template(
|
deployer.put_template(
|
||||||
@@ -144,9 +150,7 @@ def _configure_dovecot(deployer, config: Config, debug: bool = False):
|
|||||||
disable_ipv6=config.disable_ipv6,
|
disable_ipv6=config.disable_ipv6,
|
||||||
)
|
)
|
||||||
deployer.put_file("dovecot/auth.conf", "/etc/dovecot/auth.conf")
|
deployer.put_file("dovecot/auth.conf", "/etc/dovecot/auth.conf")
|
||||||
deployer.put_file(
|
deployer.put_file("dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua")
|
||||||
"dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua"
|
|
||||||
)
|
|
||||||
|
|
||||||
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
|
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
|
||||||
# it is recommended to set the following inotify limits
|
# it is recommended to set the following inotify limits
|
||||||
|
|||||||
@@ -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.6/filtermail-{arch}"
|
url = f"https://github.com/chatmail/filtermail/releases/download/v0.7.0/filtermail-{arch}"
|
||||||
sha256sum = {
|
sha256sum = {
|
||||||
"x86_64": "05c7e7ac244606c2eeb275f2d282ffdbc2403e0169f1cdd3110ffcebdb994a92",
|
"x86_64": "451f295a85b3b12dbb0f89e18ec319f742ee46dec218f20f7923bfb017a248bd",
|
||||||
"aarch64": "8cf8bbda4d907beca547b365cc7e6753532a74b1712492d0d2f3d2d8a553fb3d",
|
"aarch64": "6833061b2a2028264fdeb32f0a6123e1ff73de57dace125364016300b748452e",
|
||||||
}[arch]
|
}[arch]
|
||||||
self.download_executable(url, self.bin_path, sha256sum)
|
self.download_executable(url, self.bin_path, sha256sum)
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ smtpd_tls_exclude_ciphers = aNULL, RC4, MD5, DES
|
|||||||
# See <https://www.postfix.org/FORWARD_SECRECY_README.html#server_fs>.
|
# See <https://www.postfix.org/FORWARD_SECRECY_README.html#server_fs>.
|
||||||
tls_preempt_cipherlist = yes
|
tls_preempt_cipherlist = yes
|
||||||
|
|
||||||
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
|
# Reject by default, override per smtpd in master.cf
|
||||||
|
smtpd_relay_restrictions = reject
|
||||||
myhostname = {{ config.postfix_myhostname }}
|
myhostname = {{ config.postfix_myhostname }}
|
||||||
alias_maps = hash:/etc/aliases
|
alias_maps = hash:/etc/aliases
|
||||||
alias_database = hash:/etc/aliases
|
alias_database = hash:/etc/aliases
|
||||||
@@ -101,9 +102,24 @@ smtpd_peername_lookup = no
|
|||||||
# so instead this is handled in filtermail.
|
# so instead this is handled in filtermail.
|
||||||
# We use LMTP instead SMTP so we can communicate per-recipient errors back to postfix.
|
# We use LMTP instead SMTP so we can communicate per-recipient errors back to postfix.
|
||||||
default_transport = lmtp-filtermail:inet:[127.0.0.1]:{{ config.filtermail_lmtp_port_transport }}
|
default_transport = lmtp-filtermail:inet:[127.0.0.1]:{{ config.filtermail_lmtp_port_transport }}
|
||||||
|
|
||||||
|
# All deliveries over lmtp-filtermail are treated
|
||||||
|
# as having the same destination [127.0.0.1],
|
||||||
|
# so it is not possible to limit per-destination concurrency here,
|
||||||
|
# it is a job for filtermail-transport.
|
||||||
|
# Total number of parallel deliveries is limited
|
||||||
|
# by "maxproc" column in /etc/postfix/master.cf for lmtp-filtermail.
|
||||||
|
# Settings below are to prevent Postfix queue manager
|
||||||
|
# from limiting the number of LMTP connections to filtermail-transport.
|
||||||
|
# Read <https://www.postfix.org/TUNING_README.html#rope> and
|
||||||
|
# <https://www.postfix.org/SCHEDULER_README.html> for the details
|
||||||
|
# of the Postfix algorithm that we effectively disable here.
|
||||||
lmtp-filtermail_initial_destination_concurrency=10000
|
lmtp-filtermail_initial_destination_concurrency=10000
|
||||||
lmtp-filtermail_destination_concurrency_limit=10000
|
lmtp-filtermail_destination_concurrency_limit=10000
|
||||||
|
|
||||||
|
# Do not try to deliver messages for more than 2 days.
|
||||||
|
maximal_queue_lifetime = 2d
|
||||||
|
|
||||||
{% if not config.ipv4_relay %}
|
{% if not config.ipv4_relay %}
|
||||||
# DKIM-sign locally generated mail (bounces, DSNs).
|
# DKIM-sign locally generated mail (bounces, DSNs).
|
||||||
# These bypass smtpd, so they need explicit milter configuration.
|
# These bypass smtpd, so they need explicit milter configuration.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ smtp inet n - y - - smtpd
|
|||||||
-o smtpd_tls_security_level=encrypt
|
-o smtpd_tls_security_level=encrypt
|
||||||
-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
|
||||||
submission inet n - y - 5000 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
|
||||||
@@ -81,12 +82,14 @@ filter unix - n n - - lmtp
|
|||||||
-o syslog_name=postfix/reinject
|
-o syslog_name=postfix/reinject
|
||||||
-o milter_macro_daemon_name=ORIGINATING
|
-o milter_macro_daemon_name=ORIGINATING
|
||||||
-o cleanup_service_name=authclean
|
-o cleanup_service_name=authclean
|
||||||
|
-o smtpd_relay_restrictions=permit_mynetworks,reject
|
||||||
{% if not config.ipv4_relay %} -o smtpd_milters=unix:opendkim/opendkim.sock
|
{% if not config.ipv4_relay %} -o smtpd_milters=unix:opendkim/opendkim.sock
|
||||||
{% endif %}
|
{% 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
|
||||||
-o syslog_name=postfix/reinject_incoming
|
-o syslog_name=postfix/reinject_incoming
|
||||||
|
-o smtpd_relay_restrictions=reject_unauth_destination
|
||||||
|
|
||||||
# Cleanup `Received` headers for authenticated mail
|
# Cleanup `Received` headers for authenticated mail
|
||||||
# to avoid leaking client IP.
|
# to avoid leaking client IP.
|
||||||
@@ -102,7 +105,15 @@ filter unix - n n - - lmtp
|
|||||||
authclean unix n - - - 0 cleanup
|
authclean unix n - - - 0 cleanup
|
||||||
-o header_checks=regexp:/etc/postfix/submission_header_cleanup
|
-o header_checks=regexp:/etc/postfix/submission_header_cleanup
|
||||||
|
|
||||||
lmtp-filtermail unix - - y - 10000 lmtp
|
# Reducing `maxproc` here may result in a head of line blocking
|
||||||
|
# when there are many messages sent to unreachable destinations
|
||||||
|
# at the same time.
|
||||||
|
# LMTP clients here talk to filtermail-transport.
|
||||||
|
# LMTP has no pipelining,
|
||||||
|
# so while filtermail-transport tries to deliver the message,
|
||||||
|
# possibly waiting for a long connection timeout
|
||||||
|
# or talking to a slow server, LMTP client cannot be reused.
|
||||||
|
lmtp-filtermail unix - - y - 500 lmtp
|
||||||
-o syslog_name=postfix/lmtp-filtermail
|
-o syslog_name=postfix/lmtp-filtermail
|
||||||
-o lmtp_header_checks=
|
-o lmtp_header_checks=
|
||||||
-o lmtp_tls_security_level=none
|
-o lmtp_tls_security_level=none
|
||||||
|
|||||||
@@ -10,13 +10,11 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -349,9 +347,9 @@ class ChatmailACFactory:
|
|||||||
qr = (
|
qr = (
|
||||||
f"dclogin:{addr}"
|
f"dclogin:{addr}"
|
||||||
f"?p={password}&v=1"
|
f"?p={password}&v=1"
|
||||||
f"&ih={domain}&ip=993"
|
f"&ih={domain}&ip=993&is=ssl"
|
||||||
f"&sh={domain}&sp=465"
|
f"&sh={domain}&sp=465&ss=ssl"
|
||||||
f"&ic=3&ss=default"
|
f"&ic=3"
|
||||||
)
|
)
|
||||||
future = account.add_transport_from_qr.future(qr)
|
future = account.add_transport_from_qr.future(qr)
|
||||||
else:
|
else:
|
||||||
@@ -362,7 +360,7 @@ class ChatmailACFactory:
|
|||||||
|
|
||||||
# ensure messages stay in INBOX so that they can be
|
# ensure messages stay in INBOX so that they can be
|
||||||
# concurrently fetched via extra IMAP connections during tests
|
# concurrently fetched via extra IMAP connections during tests
|
||||||
account.set_config("delete_server_after", "10")
|
account.set_config("bcc_self", "1")
|
||||||
accounts.append(account)
|
accounts.append(account)
|
||||||
|
|
||||||
for future in futures:
|
for future in futures:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pyinfra.facts.deb import DebPackages
|
from pyinfra.facts.deb import DebPackages
|
||||||
|
from pyinfra.facts.server import Command
|
||||||
|
|
||||||
from cmdeploy.dovecot import deployer as dovecot_deployer
|
from cmdeploy.dovecot import deployer as dovecot_deployer
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ def make_host(*fact_pairs):
|
|||||||
"""
|
"""
|
||||||
facts = dict(fact_pairs)
|
facts = dict(fact_pairs)
|
||||||
|
|
||||||
def get_fact(cls):
|
def get_fact(cls, *args):
|
||||||
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(
|
||||||
@@ -82,7 +83,9 @@ def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
|
|||||||
lambda **kwargs: downloads.append(kwargs),
|
lambda **kwargs: downloads.append(kwargs),
|
||||||
)
|
)
|
||||||
|
|
||||||
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64")
|
deb, changed = dovecot_deployer._download_dovecot_package(
|
||||||
|
"core", "amd64", codename="bookworm"
|
||||||
|
)
|
||||||
|
|
||||||
assert deb is None, f"expected no deb path when version matches, got {deb!r}"
|
assert deb is None, f"expected no deb path when version matches, got {deb!r}"
|
||||||
assert changed is False, "should not flag changed when version already installed"
|
assert changed is False, "should not flag changed when version already installed"
|
||||||
@@ -109,7 +112,9 @@ def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
|
|||||||
lambda **kwargs: downloads.append(kwargs),
|
lambda **kwargs: downloads.append(kwargs),
|
||||||
)
|
)
|
||||||
|
|
||||||
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64")
|
deb, changed = dovecot_deployer._download_dovecot_package(
|
||||||
|
"core", "amd64", codename="bookworm"
|
||||||
|
)
|
||||||
|
|
||||||
archive_version = dovecot_deployer.DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
|
archive_version = dovecot_deployer.DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
|
||||||
expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb"
|
expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb"
|
||||||
@@ -139,6 +144,7 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
(dovecot_deployer.Arch, "x86_64"),
|
(dovecot_deployer.Arch, "x86_64"),
|
||||||
|
(Command, "bookworm"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
downloads = []
|
downloads = []
|
||||||
@@ -160,11 +166,13 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
|
|||||||
def test_install_unsupported_arch_falls_back_to_apt(
|
def test_install_unsupported_arch_falls_back_to_apt(
|
||||||
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
|
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
|
||||||
):
|
):
|
||||||
# For unsupported architectures, all fact lookups return the arch string.
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
dovecot_deployer,
|
dovecot_deployer,
|
||||||
"host",
|
"host",
|
||||||
SimpleNamespace(get_fact=lambda cls: "riscv64"),
|
make_host(
|
||||||
|
(dovecot_deployer.Arch, "riscv64"),
|
||||||
|
(Command, "bookworm"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
apt_calls = []
|
apt_calls = []
|
||||||
|
|
||||||
@@ -198,6 +206,7 @@ def test_install_runs_dpkg_when_packages_need_download(
|
|||||||
make_host(
|
make_host(
|
||||||
(dovecot_deployer.DebPackages, {}),
|
(dovecot_deployer.DebPackages, {}),
|
||||||
(dovecot_deployer.Arch, "x86_64"),
|
(dovecot_deployer.Arch, "x86_64"),
|
||||||
|
(Command, "bookworm"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -217,10 +226,12 @@ def test_install_runs_dpkg_when_packages_need_download(
|
|||||||
f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
|
f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
|
||||||
)
|
)
|
||||||
cmds = track_shell[0]["commands"]
|
cmds = track_shell[0]["commands"]
|
||||||
assert len(cmds) == 3, f"expected 3 dpkg/apt commands, got: {cmds}"
|
assert len(cmds) == 1, f"expected single apt-get install command, got: {cmds}"
|
||||||
assert cmds[0].startswith("dpkg --force-confdef --force-confold -i ")
|
assert "apt-get install -y" in cmds[0]
|
||||||
assert "apt-get -y --fix-broken install" in cmds[1]
|
assert '-o Dpkg::Options::="--force-confdef"' in cmds[0]
|
||||||
assert cmds[2].startswith("dpkg --force-confdef --force-confold -i ")
|
assert '-o Dpkg::Options::="--force-confold"' in cmds[0]
|
||||||
|
assert "--allow-downgrades" in cmds[0]
|
||||||
|
assert ".deb" in cmds[0]
|
||||||
assert deployer.need_restart is True, (
|
assert deployer.need_restart is True, (
|
||||||
"need_restart should be True after dpkg install"
|
"need_restart should be True after dpkg install"
|
||||||
)
|
)
|
||||||
@@ -235,3 +246,19 @@ def test_pick_url_falls_back_on_primary_error(monkeypatch):
|
|||||||
assert result == "http://fallback", (
|
assert result == "http://fallback", (
|
||||||
f"should fall back when primary fails, got {result!r}"
|
f"should fall back when primary fails, got {result!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_fails_on_unsupported_debian_version(
|
||||||
|
deployer, patch_blocked, monkeypatch
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dovecot_deployer,
|
||||||
|
"host",
|
||||||
|
make_host(
|
||||||
|
(dovecot_deployer.Arch, "x86_64"),
|
||||||
|
(Command, "sid"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Unsupported Debian codename"):
|
||||||
|
deployer.install()
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# Managed by cmdeploy: disable IPv6 in unbound.
|
# Managed by cmdeploy
|
||||||
server:
|
server:
|
||||||
|
{% if disable_ipv6 %}
|
||||||
interface: 127.0.0.1
|
interface: 127.0.0.1
|
||||||
do-ip6: no
|
do-ip6: no
|
||||||
|
{% endif %}
|
||||||
|
cache-max-negative-ttl: 0
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ and run the following commands:
|
|||||||
::
|
::
|
||||||
|
|
||||||
git pull origin main --rebase --autostash
|
git pull origin main --rebase --autostash
|
||||||
|
scripts/initenv.sh
|
||||||
scripts/cmdeploy run
|
scripts/cmdeploy run
|
||||||
|
|
||||||
If you don't want the latest development version,
|
If you don't want the latest development version,
|
||||||
|
|||||||
+56
-15
@@ -156,6 +156,7 @@ Chatmail relay dependency diagram
|
|||||||
postfix --- |10083|filtermail-transport;
|
postfix --- |10083|filtermail-transport;
|
||||||
filtermail-outgoing --- |10025 reinject|postfix;
|
filtermail-outgoing --- |10025 reinject|postfix;
|
||||||
filtermail-incoming --- |10026 reinject|postfix;
|
filtermail-incoming --- |10026 reinject|postfix;
|
||||||
|
postfix --- |milter opendkim.sock|OpenDKIM
|
||||||
dovecot --- |doveauth.socket|doveauth;
|
dovecot --- |doveauth.socket|doveauth;
|
||||||
dovecot --- |message delivery|maildir["maildir
|
dovecot --- |message delivery|maildir["maildir
|
||||||
/home/vmail/.../user"];
|
/home/vmail/.../user"];
|
||||||
@@ -179,26 +180,66 @@ Chatmail relay dependency diagram
|
|||||||
style nginx-right fill:#f66;
|
style nginx-right fill:#f66;
|
||||||
style postfix fill:#f66;
|
style postfix fill:#f66;
|
||||||
style dovecot fill:#f66;
|
style dovecot fill:#f66;
|
||||||
|
style OpenDKIM fill:#f66;
|
||||||
style notification-proxy fill:#f66;
|
style notification-proxy fill:#f66;
|
||||||
|
|
||||||
Message between users on the same relay
|
Accepting and delivering mail
|
||||||
---------------------------------------
|
-----------------------------
|
||||||
|
|
||||||
.. mermaid::
|
.. mermaid::
|
||||||
:caption: This diagram shows the path a non-federated message takes.
|
:caption: This diagram shows all the paths a message can take.
|
||||||
|
|
||||||
graph LR;
|
flowchart LR
|
||||||
sender --> |465|smtps/smtpd;
|
subgraph chatmail relay
|
||||||
sender --> |587|submission/smtpd;
|
subgraph postfix
|
||||||
smtps/smtpd --> |10080|filtermail;
|
qmgr .-> lmtp-filtermail["lmtp/lmtp-filtermail (default_transport)"]
|
||||||
submission/smtpd --> |10080|filtermail;
|
qmgr .-> lmtp["lmtp (local_transport)"]
|
||||||
filtermail --> |10025|smtpd_reinject;
|
lmtp --> cleanup["cleanup (lmtp_header_cleanup)"]
|
||||||
smtpd_reinject --> cleanup;
|
bounce
|
||||||
cleanup --> qmgr;
|
smtpd-submission["smtpd/submission"]
|
||||||
qmgr --> smtpd_accepts_message;
|
smtpd-smtps["smtpd/smtps"]
|
||||||
qmgr --> |lmtp|dovecot;
|
smtpd-reinject-outgoing["smtpd/reinject-outgoing"] --> authclean["cleanup/authclean (submission_header_cleanup)"]
|
||||||
dovecot --> recipient;
|
authclean --> qmgr
|
||||||
dovecot --> sender's_other_devices;
|
smtpd-smtp["smtpd/smtp"]
|
||||||
|
smtpd-reinject-incoming["smtpd/reinject-incoming"] --> qmgr
|
||||||
|
end
|
||||||
|
lmtp-filtermail --LMTP inet:10083--> filtermail-transport
|
||||||
|
cleanup --LMTP unix:private/dovecot-lmtp --> dovecot
|
||||||
|
dovecot --> maildir
|
||||||
|
smtpd-submission --SMTP inet:10080--> filtermail-outgoing
|
||||||
|
smtpd-smtps --SMTP inet:10080--> filtermail-outgoing
|
||||||
|
filtermail-outgoing --SMTP inet:10025--> smtpd-reinject-outgoing
|
||||||
|
open-dkim["OpenDKIM (signing only)"] <--milter unix:opendkim/opendkim.sock--> smtpd-reinject-outgoing
|
||||||
|
bounce <--milter unix:opendkim/opendkim.sock--> open-dkim
|
||||||
|
bounce --> qmgr
|
||||||
|
nginx
|
||||||
|
smtpd-smtp -.SMTP inet:10081.-> filtermail-incoming
|
||||||
|
nginx -.HTTP inet:10082.-> filtermail-incoming
|
||||||
|
filtermail-incoming --SMTP inet:10026--> smtpd-reinject-incoming
|
||||||
|
end
|
||||||
|
filtermail-transport -.SMTP inet:25.-> mta1[Remote relay]
|
||||||
|
filtermail-transport -.HTTPS /mxdeliv.-> mta1
|
||||||
|
client[Client] -.SMTP inet:587.-> smtpd-submission
|
||||||
|
client -.SMTP inet:465.-> smtpd-smtps
|
||||||
|
client -.SMTP inet:443.-> nginx
|
||||||
|
nginx -.SMTP inet:465.-> smtpd-smtps
|
||||||
|
mta2[Remote relay] -.SMTP inet:25.-> smtpd-smtp
|
||||||
|
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
|
||||||
----------------------------------------
|
----------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user