Compare commits

..

3 Commits

Author SHA1 Message Date
Mark Felder
605f5060da More headers preserved
For cleartext: To, CC, In-Reply-To, References

For Chatmail future expansion, allow Chat-*
2026-05-14 15:30:01 -07:00
feld
056be51560 Update cmdeploy/src/cmdeploy/postfix/lmtp_header_cleanup
Co-authored-by: holger krekel  <holger@merlinux.eu>
2026-05-13 17:07:08 -07:00
Mark Felder
a4bc665f59 Aggressive LMTP header cleanup
This will remove all headers possible during LMTP delivery.

From: required or core does not process the message correctly.
Also required for cleartext compatibility.

Message-Id: required for clients to know which messages have been
downloaded

Chat-Is-Post-Message: is required for our attachment previews

Content-Type: required

Subject and Date: retained for cleartext compatibility for now
2026-05-13 15:24:25 -07:00
6 changed files with 71 additions and 56 deletions

View File

@@ -16,8 +16,7 @@ def read_config(inipath):
class Config: class Config:
def __init__(self, inipath, params): def __init__(self, inipath, params):
self._inipath = inipath self._inipath = inipath
params = dict(params) raw_domain = params["mail_domain"]
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):
@@ -30,59 +29,60 @@ 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.pop("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.pop("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.pop("max_mailbox_size", "500M") self.max_mailbox_size = params.get("max_mailbox_size", "500M")
self.max_message_size = int(params.pop("max_message_size", 31457280)) self.max_message_size = int(params.get("max_message_size", 31457280))
self.delete_mails_after = params.pop("delete_mails_after", "20") self.delete_mails_after = params.get("delete_mails_after", "20")
self.delete_large_after = params.pop("delete_large_after", "7") self.delete_large_after = params.get("delete_large_after", "7")
self.delete_inactive_users_after = int( self.delete_inactive_users_after = int(
params.pop("delete_inactive_users_after", 90) params.get("delete_inactive_users_after", 90)
) )
self.username_min_length = int(params.pop("username_min_length", 9)) self.username_min_length = int(params.get("username_min_length", 9))
self.username_max_length = int(params.pop("username_max_length", 9)) self.username_max_length = int(params.get("username_max_length", 9))
self.password_min_length = int(params.pop("password_min_length", 9)) self.password_min_length = int(params.get("password_min_length", 9))
self.www_folder = params.pop("www_folder", "") self.passthrough_senders = params.get("passthrough_senders", "").split()
self.filtermail_smtp_port = int(params.pop("filtermail_smtp_port", "10080")) self.passthrough_recipients = params.get("passthrough_recipients", "").split()
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.pop("filtermail_smtp_port_incoming", "10081") params.get("filtermail_smtp_port_incoming", "10081")
) )
self.filtermail_http_port_incoming = int( self.filtermail_http_port_incoming = int(
params.pop("filtermail_http_port_incoming", "10082") params.get("filtermail_http_port_incoming", "10082")
) )
self.filtermail_lmtp_port_transport = int( self.filtermail_lmtp_port_transport = int(
params.pop("filtermail_lmtp_port_transport", "10083") params.get("filtermail_lmtp_port_transport", "10083")
) )
self.postfix_reinject_port = int(params.pop("postfix_reinject_port", "10025")) self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
self.postfix_reinject_port_incoming = int( self.postfix_reinject_port_incoming = int(
params.pop("postfix_reinject_port_incoming", "10026") params.get("postfix_reinject_port_incoming", "10026")
) )
self.mtail_address = params.pop("mtail_address", None) self.mtail_address = params.get("mtail_address")
self.disable_ipv6 = params.pop("disable_ipv6", "false").lower() == "true" self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
self.acme_email = params.pop("acme_email", "") self.acme_email = params.get("acme_email", "")
self.imap_rawlog = params.pop("imap_rawlog", "false").lower() == "true" self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
self.imap_compress = params.pop("imap_compress", "false").lower() == "true" self.imap_compress = params.get("imap_compress", "false").lower() == "true"
self.turn_socket_path = params.pop( self.turn_socket_path = params.get(
"turn_socket_path", "/run/chatmail-turn/turn.socket" "turn_socket_path", "/run/chatmail-turn/turn.socket"
) )
iroh_relay = params.pop("iroh_relay", None) if "iroh_relay" not in params:
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 = iroh_relay.strip() self.iroh_relay = params["iroh_relay"].strip()
self.enable_iroh_relay = False self.enable_iroh_relay = False
self.privacy_postal = params.pop("privacy_postal", None) self.privacy_postal = params.get("privacy_postal")
self.privacy_mail = params.pop("privacy_mail", None) self.privacy_mail = params.get("privacy_mail")
self.privacy_pdo = params.pop("privacy_pdo", None) self.privacy_pdo = params.get("privacy_pdo")
self.privacy_supervisor = params.pop("privacy_supervisor", None) self.privacy_supervisor = params.get("privacy_supervisor")
# 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.pop("tls_external_cert_and_key", "").strip() external = params.get("tls_external_cert_and_key", "").strip()
if external: if external:
parts = external.split() parts = external.split()
@@ -103,12 +103,11 @@ 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.pop("mailboxes_dir", f"/home/vmail/mail/{raw_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)
self.passdb_path = Path(params.pop("passdb_path", "/home/vmail/passdb.sqlite")) self.passdb_path = Path(params.get("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):

View File

@@ -42,6 +42,13 @@ 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.

View File

@@ -45,12 +45,8 @@ 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._unused_keys == [] assert example_config.passthrough_recipients == []
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):

View File

@@ -84,15 +84,6 @@ 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."""
@@ -134,7 +125,6 @@ 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")

View File

@@ -6,7 +6,6 @@ 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

View File

@@ -1,3 +1,27 @@
/^DKIM-Signature:/ IGNORE # List of headers for incoming messages
/^Authentication-Results:/ IGNORE # that must be retained for functionality and compatibility reasons
/^Received:/ IGNORE /^From:/ DUNNO
/^Message-Id:/ DUNNO
/^Chat-/ DUNNO
/^Content-Type:/ DUNNO
# For receiving clear-text messages (still supported in May 2026)
/^Subject:/ DUNNO
/^Date:/ DUNNO
/^To:/ DUNNO
/^CC:/ DUNNO
/^References:/ DUNNO
/^In-Reply-To:/ DUNNO
# Senders might support Autocrypt 1 but not RFC9788 (Header Protection)
/^Autocrypt:/ DUNNO
# SecureJoin V2 protocol headers (for backward compatibility)
/^Secure-Join:/ DUNNO
/^Secure-Join-Invitenumber:/ DUNNO
/^Secure-Join-Auth:/ DUNNO
/^Secure-Join-Fingerprint:/ DUNNO
/^Secure-Join-Group:/ DUNNO
# Ignore all other headers
/.*/ IGNORE