mirror of
https://github.com/chatmail/relay.git
synced 2026-08-11 11:00:52 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9652c30f5 | |||
| 4b2d8d832a | |||
| 16434d8698 | |||
| deca1ef2a3 | |||
| 0323e757a9 | |||
| 752957dae8 | |||
| 3a553f2286 | |||
| cb1e4ff5bb | |||
| 8d7870db55 | |||
| 5a31ff475f | |||
| 8c18aea18e | |||
| ebf5a51964 | |||
| f596d4b56d | |||
| 8e3c18019b | |||
| 9da3f5c235 | |||
| 6def189d16 | |||
| 24612e9121 |
@@ -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.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
|
- name: run chatmaild tests
|
||||||
working-directory: chatmaild
|
working-directory: chatmaild
|
||||||
run: pipx run tox
|
run: pipx run tox
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ Chatmail relay servers are interoperable Mail Transport Agents (MTAs) designed f
|
|||||||
- **Instant/Realtime:** sub-second message delivery, realtime P2P
|
- **Instant/Realtime:** sub-second message delivery, realtime P2P
|
||||||
streaming, privacy-preserving Push Notifications for Apple, Google, and Huawei;
|
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
|
- **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.
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ dependencies = [
|
|||||||
"iniconfig",
|
"iniconfig",
|
||||||
"filelock",
|
"filelock",
|
||||||
"requests",
|
"requests",
|
||||||
"crypt-r >= 3.13.1 ; python_version >= '3.11'",
|
"crypt-r >= 3.13.1 ; python_version >= '3.13'",
|
||||||
"domain-validator",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import ipaddress
|
import ipaddress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from random import randint
|
||||||
|
|
||||||
import iniconfig
|
import iniconfig
|
||||||
from domain_validator import DomainValidator
|
|
||||||
|
|
||||||
from chatmaild.user import User
|
from chatmaild.user import User
|
||||||
|
|
||||||
@@ -25,7 +25,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
|
||||||
@@ -43,6 +42,11 @@ class Config:
|
|||||||
self.username_max_length = int(params.pop("username_max_length", 9))
|
self.username_max_length = int(params.pop("username_max_length", 9))
|
||||||
self.password_min_length = int(params.pop("password_min_length", 9))
|
self.password_min_length = int(params.pop("password_min_length", 9))
|
||||||
self.www_folder = params.pop("www_folder", "")
|
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 = int(params.pop("filtermail_smtp_port", "10080"))
|
||||||
self.filtermail_smtp_port_incoming = int(
|
self.filtermail_smtp_port_incoming = int(
|
||||||
params.pop("filtermail_smtp_port_incoming", "10081")
|
params.pop("filtermail_smtp_port_incoming", "10081")
|
||||||
@@ -140,8 +144,15 @@ def parse_size_mb(limit):
|
|||||||
|
|
||||||
def write_initial_config(inipath, mail_domain, overrides):
|
def write_initial_config(inipath, mail_domain, overrides):
|
||||||
"""Write out default config file, using the specified config value overrides."""
|
"""Write out default config file, using the specified config value overrides."""
|
||||||
content = get_default_config_content(mail_domain, **overrides)
|
content = get_default_config_content(mail_domain, **overrides).splitlines()
|
||||||
inipath.write_text(content)
|
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):
|
def get_default_config_content(mail_domain, **overrides):
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
"acme-enter-email": "{{ email }}"
|
"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
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ class Deployer:
|
|||||||
return self.put_template(src, dest, **kwargs)
|
return self.put_template(src, dest, **kwargs)
|
||||||
return self.put_file(src, dest)
|
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):
|
if isinstance(src, str):
|
||||||
src = get_resource(src)
|
src = get_resource(src)
|
||||||
res = files.put(
|
res = files.put(
|
||||||
@@ -176,6 +176,7 @@ class Deployer:
|
|||||||
user="root",
|
user="root",
|
||||||
group="root",
|
group="root",
|
||||||
mode=mode,
|
mode=mode,
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
return self._update_restart_signals(dest, res)
|
return self._update_restart_signals(dest, res)
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ class UnboundDeployer(Deployer):
|
|||||||
self.put_file(
|
self.put_file(
|
||||||
src=BytesIO(b"nameserver 127.0.0.1\nnameserver 9.9.9.9\n"),
|
src=BytesIO(b"nameserver 127.0.0.1\nnameserver 9.9.9.9\n"),
|
||||||
dest="/etc/resolv.conf",
|
dest="/etc/resolv.conf",
|
||||||
|
force=True,
|
||||||
)
|
)
|
||||||
server.shell(
|
server.shell(
|
||||||
name="Generate root keys for validating DNSSEC",
|
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":
|
if config.tls_cert_mode == "acme":
|
||||||
port_services.append(("acmetool", 402))
|
port_services.append(("acmetool", 402))
|
||||||
port_services += [
|
port_services += [
|
||||||
(["imap-login", "dovecot"], 143),
|
(["imap-login", "dovecot", "nginx"], config.imap_port),
|
||||||
# acmetool previously listened on port 80,
|
# acmetool previously listened on port 80,
|
||||||
# so don't complain during upgrade that moved it to port 402
|
# so don't complain during upgrade that moved it to port 402
|
||||||
# and gave the port to nginx.
|
# and gave the port to nginx.
|
||||||
(["acmetool", "nginx"], 80),
|
(["acmetool", "nginx"], 80),
|
||||||
("nginx", 443),
|
("nginx", 443),
|
||||||
(["master", "smtpd"], 465),
|
(["master", "smtpd", "nginx"], config.smtp_port),
|
||||||
(["master", "smtpd"], 587),
|
(["master", "smtpd", "nginx"], config.smtps_port),
|
||||||
(["imap-login", "dovecot"], 993),
|
(["imap-login", "dovecot", "nginx"], config.imaps_port),
|
||||||
("iroh-relay", 3340),
|
("iroh-relay", 3340),
|
||||||
("mtail", 3903),
|
("mtail", 3903),
|
||||||
("stats", 3904),
|
("stats", 3904),
|
||||||
|
|||||||
@@ -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.7.0/filtermail-{arch}"
|
url = f"https://github.com/chatmail/filtermail/releases/download/v0.7.4/filtermail-{arch}"
|
||||||
sha256sum = {
|
sha256sum = {
|
||||||
"x86_64": "451f295a85b3b12dbb0f89e18ec319f742ee46dec218f20f7923bfb017a248bd",
|
"x86_64": "484cb8dff083134aefba9fce4a6b7ef4784a0f0e28e5108ecf8bb9e58a44fd2c",
|
||||||
"aarch64": "6833061b2a2028264fdeb32f0a6123e1ff73de57dace125364016300b748452e",
|
"aarch64": "66aa0ca2ca9add7a12d92883d76f8786384092adfde24a3d3a1d0b1f30d23a9e",
|
||||||
}[arch]
|
}[arch]
|
||||||
self.download_executable(url, self.bin_path, sha256sum)
|
self.download_executable(url, self.bin_path, sha256sum)
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ counter incoming_unencrypted_mail_count
|
|||||||
filtered_incoming_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
|
counter rejected_unencrypted_mail_count
|
||||||
/Rejected unencrypted mail/ {
|
/Rejected unencrypted mail/ {
|
||||||
|
|||||||
@@ -7,14 +7,14 @@
|
|||||||
<displayShortName>{{ config.mail_domain }}</displayShortName>
|
<displayShortName>{{ config.mail_domain }}</displayShortName>
|
||||||
<incomingServer type="imap">
|
<incomingServer type="imap">
|
||||||
<hostname>{{ config.mail_domain }}</hostname>
|
<hostname>{{ config.mail_domain }}</hostname>
|
||||||
<port>993</port>
|
<port>{{ config.imaps_port }}</port>
|
||||||
<socketType>SSL</socketType>
|
<socketType>SSL</socketType>
|
||||||
<authentication>password-cleartext</authentication>
|
<authentication>password-cleartext</authentication>
|
||||||
<username>%EMAILADDRESS%</username>
|
<username>%EMAILADDRESS%</username>
|
||||||
</incomingServer>
|
</incomingServer>
|
||||||
<incomingServer type="imap">
|
<incomingServer type="imap">
|
||||||
<hostname>{{ config.mail_domain }}</hostname>
|
<hostname>{{ config.mail_domain }}</hostname>
|
||||||
<port>143</port>
|
<port>{{ config.imap_port }}</port>
|
||||||
<socketType>STARTTLS</socketType>
|
<socketType>STARTTLS</socketType>
|
||||||
<authentication>password-cleartext</authentication>
|
<authentication>password-cleartext</authentication>
|
||||||
<username>%EMAILADDRESS%</username>
|
<username>%EMAILADDRESS%</username>
|
||||||
@@ -28,14 +28,14 @@
|
|||||||
</incomingServer>
|
</incomingServer>
|
||||||
<outgoingServer type="smtp">
|
<outgoingServer type="smtp">
|
||||||
<hostname>{{ config.mail_domain }}</hostname>
|
<hostname>{{ config.mail_domain }}</hostname>
|
||||||
<port>465</port>
|
<port>{{ config.smtps_port }}</port>
|
||||||
<socketType>SSL</socketType>
|
<socketType>SSL</socketType>
|
||||||
<authentication>password-cleartext</authentication>
|
<authentication>password-cleartext</authentication>
|
||||||
<username>%EMAILADDRESS%</username>
|
<username>%EMAILADDRESS%</username>
|
||||||
</outgoingServer>
|
</outgoingServer>
|
||||||
<outgoingServer type="smtp">
|
<outgoingServer type="smtp">
|
||||||
<hostname>{{ config.mail_domain }}</hostname>
|
<hostname>{{ config.mail_domain }}</hostname>
|
||||||
<port>587</port>
|
<port>{{ config.smtp_port }}</port>
|
||||||
<socketType>STARTTLS</socketType>
|
<socketType>STARTTLS</socketType>
|
||||||
<authentication>password-cleartext</authentication>
|
<authentication>password-cleartext</authentication>
|
||||||
<username>%EMAILADDRESS%</username>
|
<username>%EMAILADDRESS%</username>
|
||||||
|
|||||||
@@ -31,6 +31,26 @@ stream {
|
|||||||
~\bimap\b 127.0.0.1:993;
|
~\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 {
|
server {
|
||||||
listen 443;
|
listen 443;
|
||||||
{% if not disable_ipv6 %}
|
{% if not disable_ipv6 %}
|
||||||
|
|||||||
@@ -1,23 +1,3 @@
|
|||||||
# List of headers for incoming messages
|
/^DKIM-Signature:/ IGNORE
|
||||||
# that must be retained for functionality and compatibility reasons
|
/^Authentication-Results:/ IGNORE
|
||||||
/^From:/ DUNNO
|
/^Received:/ IGNORE
|
||||||
/^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
|
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ inet_protocols = all
|
|||||||
|
|
||||||
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
|
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_client_restrictions = permit_sasl_authenticated, reject
|
||||||
mua_sender_restrictions = reject_sender_login_mismatch, 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
|
mua_helo_restrictions = permit_mynetworks, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, permit
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -225,21 +225,6 @@ Accepting and delivering mail
|
|||||||
nginx -.SMTP inet:465.-> smtpd-smtps
|
nginx -.SMTP inet:465.-> smtpd-smtps
|
||||||
mta2[Remote relay] -.SMTP inet:25.-> smtpd-smtp
|
mta2[Remote relay] -.SMTP inet:25.-> smtpd-smtp
|
||||||
mta2 -.HTTPS /mxdeliv.-> nginx
|
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