mirror of
https://github.com/chatmail/relay.git
synced 2026-05-20 04:48:06 +00:00
Compare commits
8 Commits
lmtp_heade
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5b9a98baa | ||
|
|
ab2d807084 | ||
|
|
ce05b26c77 | ||
|
|
77ed93fb7a | ||
|
|
39d1ecaa03 | ||
|
|
a266ffd060 | ||
|
|
a47bb94143 | ||
|
|
43ae9fee5c |
1
.github/workflows/docker-dispatch.yaml
vendored
1
.github/workflows/docker-dispatch.yaml
vendored
@@ -9,6 +9,7 @@ name: Trigger Docker build
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
tags: ['[0-9]+.[0-9]+.[0-9]+']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|||||||
42
CHANGELOG.md
42
CHANGELOG.md
@@ -1,5 +1,47 @@
|
|||||||
# Changelog for chatmail deployment
|
# Changelog for chatmail deployment
|
||||||
|
|
||||||
|
## [1.11.0] - 2026-05-15
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- [**breaking**] Drop passthrough_sender and passthrough_recipients chatmail.ini options to eliminate one more source of unencrypted messages
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Use filtermail for delivery to remote MTAs
|
||||||
|
- Expose metadata "maxsmtprecipients" value
|
||||||
|
- Support setup without domain, with only an IPv4 address (#963)
|
||||||
|
- *(doc/docker)* Introduce docker images in documentation
|
||||||
|
- DKIM-sign bounce messages (mainly "user does not exist")
|
||||||
|
- *(config)* Load default values from Config(), not chatmail.ini.f (#853)
|
||||||
|
- Make turn_socket_path configurable, and cleanup tests and turnserver code.
|
||||||
|
- Warn about any unused chatmail.ini parameter at the end of "cmdeploy run"
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Make www tests work with editable instead of just plain installs
|
||||||
|
- Use path with no leading slash for mxdeliv
|
||||||
|
- Increase filtermail-transport concurrency limit
|
||||||
|
- Fix #972 by increasing file descriptors for filtermail
|
||||||
|
- *(mtail)* Correct boot ordering and deploy restart logic
|
||||||
|
- *(cmdeploy)* Stop and disable unbound-resolvconf
|
||||||
|
- *(nginx)* Properly redirect www to mail_domain
|
||||||
|
- *(dns)* Query correct NS if MNAME server is hidden (#954)
|
||||||
|
- Legacy token metadata storage used list type, but if no new setmetadata happened, the user would not be notified at all.
|
||||||
|
- *(logging)* Log all http requests to syslog
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Document how to upgrade to new version (#965)
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- *(deps)* Upgrade to filtermail v0.6.4
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Introduce automated change-tracking across deployers
|
||||||
|
|
||||||
## 1.10.0 2026-04-30
|
## 1.10.0 2026-04-30
|
||||||
|
|
||||||
* start mtail after networking is fully up <https://github.com/chatmail/relay/pull/942>
|
* start mtail after networking is fully up <https://github.com/chatmail/relay/pull/942>
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ def read_config(inipath):
|
|||||||
class Config:
|
class Config:
|
||||||
def __init__(self, inipath, params):
|
def __init__(self, inipath, params):
|
||||||
self._inipath = inipath
|
self._inipath = inipath
|
||||||
raw_domain = params["mail_domain"]
|
params = dict(params)
|
||||||
|
raw_domain = params.pop("mail_domain")
|
||||||
self.mail_domain_bare = raw_domain
|
self.mail_domain_bare = raw_domain
|
||||||
|
|
||||||
if is_valid_ipv4(raw_domain):
|
if is_valid_ipv4(raw_domain):
|
||||||
@@ -29,60 +30,59 @@ class Config:
|
|||||||
self.mail_domain = raw_domain
|
self.mail_domain = raw_domain
|
||||||
self.postfix_myhostname = 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.pop("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.pop("max_user_send_burst_size", 10))
|
||||||
self.max_mailbox_size = params.get("max_mailbox_size", "500M")
|
self.max_mailbox_size = params.pop("max_mailbox_size", "500M")
|
||||||
self.max_message_size = int(params.get("max_message_size", 31457280))
|
self.max_message_size = int(params.pop("max_message_size", 31457280))
|
||||||
self.delete_mails_after = params.get("delete_mails_after", "20")
|
self.delete_mails_after = params.pop("delete_mails_after", "20")
|
||||||
self.delete_large_after = params.get("delete_large_after", "7")
|
self.delete_large_after = params.pop("delete_large_after", "7")
|
||||||
self.delete_inactive_users_after = int(
|
self.delete_inactive_users_after = int(
|
||||||
params.get("delete_inactive_users_after", 90)
|
params.pop("delete_inactive_users_after", 90)
|
||||||
)
|
)
|
||||||
self.username_min_length = int(params.get("username_min_length", 9))
|
self.username_min_length = int(params.pop("username_min_length", 9))
|
||||||
self.username_max_length = int(params.get("username_max_length", 9))
|
self.username_max_length = int(params.pop("username_max_length", 9))
|
||||||
self.password_min_length = int(params.get("password_min_length", 9))
|
self.password_min_length = int(params.pop("password_min_length", 9))
|
||||||
self.passthrough_senders = params.get("passthrough_senders", "").split()
|
self.www_folder = params.pop("www_folder", "")
|
||||||
self.passthrough_recipients = params.get("passthrough_recipients", "").split()
|
self.filtermail_smtp_port = int(params.pop("filtermail_smtp_port", "10080"))
|
||||||
self.www_folder = params.get("www_folder", "")
|
|
||||||
self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080"))
|
|
||||||
self.filtermail_smtp_port_incoming = int(
|
self.filtermail_smtp_port_incoming = int(
|
||||||
params.get("filtermail_smtp_port_incoming", "10081")
|
params.pop("filtermail_smtp_port_incoming", "10081")
|
||||||
)
|
)
|
||||||
self.filtermail_http_port_incoming = int(
|
self.filtermail_http_port_incoming = int(
|
||||||
params.get("filtermail_http_port_incoming", "10082")
|
params.pop("filtermail_http_port_incoming", "10082")
|
||||||
)
|
)
|
||||||
self.filtermail_lmtp_port_transport = int(
|
self.filtermail_lmtp_port_transport = int(
|
||||||
params.get("filtermail_lmtp_port_transport", "10083")
|
params.pop("filtermail_lmtp_port_transport", "10083")
|
||||||
)
|
)
|
||||||
self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
|
self.postfix_reinject_port = int(params.pop("postfix_reinject_port", "10025"))
|
||||||
self.postfix_reinject_port_incoming = int(
|
self.postfix_reinject_port_incoming = int(
|
||||||
params.get("postfix_reinject_port_incoming", "10026")
|
params.pop("postfix_reinject_port_incoming", "10026")
|
||||||
)
|
)
|
||||||
self.mtail_address = params.get("mtail_address")
|
self.mtail_address = params.pop("mtail_address", None)
|
||||||
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
|
self.disable_ipv6 = params.pop("disable_ipv6", "false").lower() == "true"
|
||||||
self.acme_email = params.get("acme_email", "")
|
self.acme_email = params.pop("acme_email", "")
|
||||||
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
|
self.imap_rawlog = params.pop("imap_rawlog", "false").lower() == "true"
|
||||||
self.imap_compress = params.get("imap_compress", "false").lower() == "true"
|
self.imap_compress = params.pop("imap_compress", "false").lower() == "true"
|
||||||
self.turn_socket_path = params.get(
|
self.turn_socket_path = params.pop(
|
||||||
"turn_socket_path", "/run/chatmail-turn/turn.socket"
|
"turn_socket_path", "/run/chatmail-turn/turn.socket"
|
||||||
)
|
)
|
||||||
if "iroh_relay" not in params:
|
iroh_relay = params.pop("iroh_relay", None)
|
||||||
|
if iroh_relay is None:
|
||||||
self.iroh_relay = "https://" + raw_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 = iroh_relay.strip()
|
||||||
self.enable_iroh_relay = False
|
self.enable_iroh_relay = False
|
||||||
self.privacy_postal = params.get("privacy_postal")
|
self.privacy_postal = params.pop("privacy_postal", None)
|
||||||
self.privacy_mail = params.get("privacy_mail")
|
self.privacy_mail = params.pop("privacy_mail", None)
|
||||||
self.privacy_pdo = params.get("privacy_pdo")
|
self.privacy_pdo = params.pop("privacy_pdo", None)
|
||||||
self.privacy_supervisor = params.get("privacy_supervisor")
|
self.privacy_supervisor = params.pop("privacy_supervisor", None)
|
||||||
|
|
||||||
# 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:
|
||||||
# - Domains starting with "_" use self-signed certificates
|
# - Domains starting with "_" use self-signed certificates
|
||||||
# - All other domains use ACME.
|
# - All other domains use ACME.
|
||||||
external = params.get("tls_external_cert_and_key", "").strip()
|
external = params.pop("tls_external_cert_and_key", "").strip()
|
||||||
|
|
||||||
if external:
|
if external:
|
||||||
parts = external.split()
|
parts = external.split()
|
||||||
@@ -103,11 +103,12 @@ class Config:
|
|||||||
self.tls_key_path = f"/var/lib/acme/live/{raw_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/{raw_domain}")
|
mbdir = params.pop("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)
|
||||||
self.passdb_path = Path(params.get("passdb_path", "/home/vmail/passdb.sqlite"))
|
self.passdb_path = Path(params.pop("passdb_path", "/home/vmail/passdb.sqlite"))
|
||||||
|
self._unused_keys = list(params)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max_mailbox_size_mb(self):
|
def max_mailbox_size_mb(self):
|
||||||
|
|||||||
@@ -42,13 +42,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
|
||||||
|
|
||||||
# list of chatmail addresses which can send outbound un-encrypted mail
|
|
||||||
#passthrough_senders =
|
|
||||||
|
|
||||||
# list of e-mail recipients for which to accept outbound un-encrypted mails
|
|
||||||
# (space-separated, item may start with "@" to whitelist whole recipient domains)
|
|
||||||
#passthrough_recipients =
|
|
||||||
|
|
||||||
# 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.
|
||||||
|
|||||||
@@ -45,8 +45,12 @@ 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.passthrough_recipients == []
|
assert example_config._unused_keys == []
|
||||||
assert example_config.passthrough_senders == []
|
|
||||||
|
|
||||||
|
def test_config_unused_keys(make_config):
|
||||||
|
config = make_config("chat.example.org", {"passthrough_senders": "x@y.org"})
|
||||||
|
assert config._unused_keys == ["passthrough_senders"]
|
||||||
|
|
||||||
|
|
||||||
def test_config_userstate_paths(make_config, tmp_path):
|
def test_config_userstate_paths(make_config, tmp_path):
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ def run_cmd_options(parser):
|
|||||||
add_ssh_host_option(parser)
|
add_ssh_host_option(parser)
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_unused_settings(unused_keys, out):
|
||||||
|
if unused_keys:
|
||||||
|
names = ", ".join(unused_keys)
|
||||||
|
out.red(
|
||||||
|
f"WARNING: chatmail.ini contains settings that have no effect: {names}\n"
|
||||||
|
"Please remove them from chatmail.ini."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(args, out):
|
def run_cmd(args, out):
|
||||||
"""Deploy chatmail services on the remote server."""
|
"""Deploy chatmail services on the remote server."""
|
||||||
|
|
||||||
@@ -125,6 +134,7 @@ def run_cmd(args, out):
|
|||||||
out.green("Deploy completed.")
|
out.green("Deploy completed.")
|
||||||
else:
|
else:
|
||||||
out.green("Deploy completed, call `cmdeploy dns` next.")
|
out.green("Deploy completed, call `cmdeploy dns` next.")
|
||||||
|
_warn_unused_settings(args.config._unused_keys, out)
|
||||||
return 0
|
return 0
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
out.red("Deploy failed")
|
out.red("Deploy failed")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ ExecStart={{ bin_path }} {{ config_path }} transport
|
|||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=30
|
RestartSec=30
|
||||||
User=vmail
|
User=vmail
|
||||||
|
LimitNOFILE=524288
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -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,6 +102,18 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -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 - 1000 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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user