Compare commits

...

17 Commits

Author SHA1 Message Date
missytake c9652c30f5 fix: only allow ports above 1024 for imap + smtp 2026-07-18 21:33:56 +02:00
missytake 4b2d8d832a fix: allow IMAP & SMTP ports to be proxied by nginx 2026-07-18 21:33:56 +02:00
missytake 16434d8698 feat: randomize SMTP + IMAP ports 2026-07-18 21:33:56 +02:00
link2xt deca1ef2a3 feat(postfix): disable processing of MIME headers
Default behavior is processing headers
that are actually part of the message body.
This is unlikely to be needed
as headers such as Received are not added into MIME parts,
but may result in broken DKIM signatures
if the body is modified.
2026-07-13 09:46:32 +00:00
W0rmsy 0323e757a9 chore: update Let's Encrypt Subscriber Agreement to 1.8
The changes are described in the announcement:
https://community.letsencrypt.org/t/updating-the-let-s-encrypt-subscriber-agreement-to-v1-8/248355
2026-07-13 09:45:41 +00:00
j4n 752957dae8 feat(mtail): add incoming_mailer_daemon_mail_count
Track volume of mailer-daemon messages separately from unencrypted.
2026-07-13 11:43:44 +02:00
Jagoda Estera Ślązak 3a553f2286 chore(deps): Upgrade filtermail to v0.7.4 (#1014)
## 0.7.4 - 2026-07-01

### Features

- *(logs)* Log incoming mailer-daemon message sources

### Miscellaneous Tasks

- Update filtermail.mtail

### Testing

- Place #[tokio::test] after rstest case macros

## 0.7.3 - 2026-06-27

### Features

- *(transport)* Worker eviction

## 0.7.2 - 2026-06-26

### Documentation

- *(readme)* Disable colors in mermaid diagrams
- *(readme)* Update Transport mode doc

### Features

- *(transport)* Destination worker pool

### Refactor

- Implement Display for AddressDomain

### Testing

- Test filtermail-transport

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-07-04 15:01:06 +09:00
Hocuri cb1e4ff5bb docs(README.md): Clarify security enforcement (#1011)
Make it clear which security enforcement properties a chatmail server has
2026-06-17 12:38:37 +02:00
Jagoda Ślązak 8d7870db55 chore(deps): Upgrade filtermail to v0.7.1
## 0.7.1 - 2026-06-09

### Bug Fixes

- Switch ratelimiter to MonotonicClock
- *(smtp-server)* Correct error when EOF while reading DATA
- *(resolver)* Disable negative caching
- Ignore CNAME records when resolving TXT records

### Features

- Switch to aws-lc-rs cryptography provider

### Miscellaneous Tasks

- Add filtermail.mtail so filtermail failures can be monitored

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-17 10:16:35 +02:00
Jagoda Estera Ślązak 5a31ff475f docs(overview): Remove mermaid styles from 'Accepting and delivering mail' (#1009)
Previously used colors caused
diagrams to be unreadable in
light mode.
Removing styling completely,
as this was redundant anyway.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-06-10 22:31:26 +02:00
missytake 8c18aea18e fix: pass kwargs to files.put() 2026-06-10 16:49:34 +02:00
missytake ebf5a51964 fix: still overwrite /etc/resolv.conf if it is a symbolic link 2026-06-10 16:49:34 +02:00
feld f596d4b56d Merge pull request #1003 from chatmail/feld/crypt-r
fix: crypt-r dependency was declared for wrong Python version
2026-06-09 12:38:45 -07:00
Mark Felder 8e3c18019b fix: crypt-r dependency was declared for wrong Python version
The original crypt library was last supported in Python 3.12, so it's
not needed until Python 3.13 is default (Trixie)

> The crypt_r module is a renamed copy of the crypt module as it was present in Python 3.12 before it was removed.
2026-06-09 12:38:07 -07:00
missytake 9da3f5c235 fix(acmetool): update let's encrypt ToS link 2026-06-08 16:28:21 +02:00
feld 6def189d16 Revert "Aggressive LMTP header cleanup (#816)"
This reverts commit 921080125f.
2026-06-05 22:28:16 +02:00
Mark Felder 24612e9121 fix(deps): Remove domain-validator dependency
It is broken in multiple ways
2026-06-05 09:39:27 +02:00
15 changed files with 74 additions and 61 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: download 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
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.7.4/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
- name: run chatmaild tests
working-directory: chatmaild
run: pipx run tox
+6 -1
View File
@@ -8,7 +8,12 @@ Chatmail relay servers are interoperable Mail Transport Agents (MTAs) designed f
- **Instant/Realtime:** sub-second message delivery, realtime P2P
streaming, privacy-preserving Push Notifications for Apple, Google, and Huawei;
- **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted
- **Security Enforcement**: Only connections with strict TLS are accepted;
all messages must be correctly signed with DKIM and OpenPGP-encrypted with minimized metadata.
There are experimental exceptions for no-DNS relays,
which are allowed use self-signed TLS certificates
and which do not need to DKIM-sign their messages.
Unencrypted messages are allowed in neither case.
- **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating
depends on established IETF standards and protocols.
+1 -2
View File
@@ -9,8 +9,7 @@ dependencies = [
"iniconfig",
"filelock",
"requests",
"crypt-r >= 3.13.1 ; python_version >= '3.11'",
"domain-validator",
"crypt-r >= 3.13.1 ; python_version >= '3.13'",
]
[tool.setuptools]
+15 -4
View File
@@ -1,8 +1,8 @@
import ipaddress
from pathlib import Path
from random import randint
import iniconfig
from domain_validator import DomainValidator
from chatmaild.user import User
@@ -25,7 +25,6 @@ class Config:
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
@@ -43,6 +42,11 @@ class Config:
self.username_max_length = int(params.pop("username_max_length", 9))
self.password_min_length = int(params.pop("password_min_length", 9))
self.www_folder = params.pop("www_folder", "")
self.imap_port = int(params.pop("imap_port", 143))
self.imaps_port = int(params.pop("imaps_port", 993))
self.smtp_port = int(params.pop("smtp_port", 587))
self.smtps_port = int(params.pop("smtps_port", 465))
self.filtermail_smtp_port = int(params.pop("filtermail_smtp_port", "10080"))
self.filtermail_smtp_port_incoming = int(
params.pop("filtermail_smtp_port_incoming", "10081")
@@ -140,8 +144,15 @@ def parse_size_mb(limit):
def write_initial_config(inipath, mail_domain, overrides):
"""Write out default config file, using the specified config value overrides."""
content = get_default_config_content(mail_domain, **overrides)
inipath.write_text(content)
content = get_default_config_content(mail_domain, **overrides).splitlines()
used_ports = [25, 53, 80, 143, 402, 443, 465, 587, 993, 3340, 3903, 3904, 8443, 10080, 10081, 10082, 10083, 10025, 10026]
for config_key in ["smtp_port", "imap_port", "smtps_port", "imaps_port"]:
value = randint(1024, 65536)
while value in used_ports:
value = randint(65535)
used_ports.append(value)
content.append(f"{config_key} = {value}")
inipath.write_text("\n".join(content))
def get_default_config_content(mail_domain, **overrides):
@@ -1,2 +1,2 @@
"acme-enter-email": "{{ email }}"
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.6-August-18-2025.pdf": true
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.8-July-06-2026.pdf": true
+2 -1
View File
@@ -166,7 +166,7 @@ class Deployer:
return self.put_template(src, dest, **kwargs)
return self.put_file(src, dest)
def put_file(self, src, dest, mode="644"):
def put_file(self, src, dest, mode="644", **kwargs):
if isinstance(src, str):
src = get_resource(src)
res = files.put(
@@ -176,6 +176,7 @@ class Deployer:
user="root",
group="root",
mode=mode,
**kwargs,
)
return self._update_restart_signals(dest, res)
+5 -4
View File
@@ -164,6 +164,7 @@ class UnboundDeployer(Deployer):
self.put_file(
src=BytesIO(b"nameserver 127.0.0.1\nnameserver 9.9.9.9\n"),
dest="/etc/resolv.conf",
force=True,
)
server.shell(
name="Generate root keys for validating DNSSEC",
@@ -495,15 +496,15 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
if config.tls_cert_mode == "acme":
port_services.append(("acmetool", 402))
port_services += [
(["imap-login", "dovecot"], 143),
(["imap-login", "dovecot", "nginx"], config.imap_port),
# acmetool previously listened on port 80,
# so don't complain during upgrade that moved it to port 402
# and gave the port to nginx.
(["acmetool", "nginx"], 80),
("nginx", 443),
(["master", "smtpd"], 465),
(["master", "smtpd"], 587),
(["imap-login", "dovecot"], 993),
(["master", "smtpd", "nginx"], config.smtp_port),
(["master", "smtpd", "nginx"], config.smtps_port),
(["imap-login", "dovecot", "nginx"], config.imaps_port),
("iroh-relay", 3340),
("mtail", 3903),
("stats", 3904),
+3 -3
View File
@@ -20,10 +20,10 @@ class FiltermailDeployer(Deployer):
return
arch = host.get_fact(facts.server.Arch)
url = f"https://github.com/chatmail/filtermail/releases/download/v0.7.0/filtermail-{arch}"
url = f"https://github.com/chatmail/filtermail/releases/download/v0.7.4/filtermail-{arch}"
sha256sum = {
"x86_64": "451f295a85b3b12dbb0f89e18ec319f742ee46dec218f20f7923bfb017a248bd",
"aarch64": "6833061b2a2028264fdeb32f0a6123e1ff73de57dace125364016300b748452e",
"x86_64": "484cb8dff083134aefba9fce4a6b7ef4784a0f0e28e5108ecf8bb9e58a44fd2c",
"aarch64": "66aa0ca2ca9add7a12d92883d76f8786384092adfde24a3d3a1d0b1f30d23a9e",
}[arch]
self.download_executable(url, self.bin_path, sha256sum)
@@ -73,6 +73,11 @@ counter incoming_unencrypted_mail_count
filtered_incoming_mail_count++
}
counter incoming_mailer_daemon_mail_count
/Incoming: Filtering mailer-daemon message from/ {
incoming_mailer_daemon_mail_count++
filtered_incoming_mail_count++
}
counter rejected_unencrypted_mail_count
/Rejected unencrypted mail/ {
@@ -7,14 +7,14 @@
<displayShortName>{{ config.mail_domain }}</displayShortName>
<incomingServer type="imap">
<hostname>{{ config.mail_domain }}</hostname>
<port>993</port>
<port>{{ config.imaps_port }}</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<incomingServer type="imap">
<hostname>{{ config.mail_domain }}</hostname>
<port>143</port>
<port>{{ config.imap_port }}</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
@@ -28,14 +28,14 @@
</incomingServer>
<outgoingServer type="smtp">
<hostname>{{ config.mail_domain }}</hostname>
<port>465</port>
<port>{{ config.smtps_port }}</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
<outgoingServer type="smtp">
<hostname>{{ config.mail_domain }}</hostname>
<port>587</port>
<port>{{ config.smtp_port }}</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
+20
View File
@@ -31,6 +31,26 @@ stream {
~\bimap\b 127.0.0.1:993;
}
server {
listen {{ config.smtp_port }};
proxy_pass 127.0.0.1:587;
}
server {
listen {{ config.imap_port }};
proxy_pass 127.0.0.1:143;
}
server {
listen {{ config.smtps_port }};
proxy_pass 127.0.0.1:465;
}
server {
listen {{ config.imaps_port }};
proxy_pass 127.0.0.1:993;
}
server {
listen 443;
{% if not disable_ipv6 %}
@@ -1,23 +1,3 @@
# List of headers for incoming messages
# that must be retained for functionality and compatibility reasons
/^From:/ DUNNO
/^Message-Id:/ DUNNO
/^Chat-/ DUNNO
/^Content-Type:/ DUNNO
# For receiving clear-text messages (still supported in May 2026)
/^Subject:/ DUNNO
/^Date:/ DUNNO
/^To:/ DUNNO
/^CC:/ DUNNO
/^References:/ DUNNO
/^In-Reply-To:/ DUNNO
# Senders might support Autocrypt 1 but not RFC9788 (Header Protection)
/^Autocrypt:/ DUNNO
# SecureJoin V2 protocol headers (for backward compatibility)
/^Secure-Join/ DUNNO
# Ignore all other headers
/.*/ IGNORE
/^DKIM-Signature:/ IGNORE
/^Authentication-Results:/ IGNORE
/^Received:/ IGNORE
+8
View File
@@ -85,6 +85,14 @@ inet_protocols = all
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
# Do not apply header checks to MIME headers
# and other headers that are actually part of the message body.
# Reference:
# <https://www.postfix.org/postconf.5.html#disable_mime_input_processing>
# <https://www.postfix.org/header_checks.5.html>
# <https://stump.io/blog/2020/11/29/a-little-gotcha-with-postfixs-header_checks/>
disable_mime_input_processing = yes
mua_client_restrictions = permit_sasl_authenticated, reject
mua_sender_restrictions = reject_sender_login_mismatch, permit_sasl_authenticated, reject
mua_helo_restrictions = permit_mynetworks, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, permit
-2
View File
@@ -10,13 +10,11 @@ from pathlib import Path
import pytest
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
-15
View File
@@ -225,21 +225,6 @@ Accepting and delivering mail
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
----------------------------------------