Compare commits

..

1 Commits

Author SHA1 Message Date
link2xt 02c7d3da58 feat: setup websockify
This change makes it possible
to connect the client over WebSocket.
Running `websocat wss://example.org/imap`
connects to the IMAP
and running `websocat wss://example.org/smtp`
connects to SMTP (submission service, not port 25).

Connecting from a web client will still need a CORS policy
for clients not running on the same domain,
but for non-web clients this is already usable.
2026-07-29 22:10:14 +00:00
12 changed files with 74 additions and 178 deletions
+2
View File
@@ -20,6 +20,8 @@ concurrency:
jobs:
no-dns:
name: LXC deploy and test
permissions:
contents: read
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@main
with:
cmlxc_version: main
+2
View File
@@ -57,6 +57,8 @@ jobs:
lxc-test:
name: LXC deploy and test
permissions:
contents: read
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@main
with:
cmlxc_version: main
+1 -1
View File
@@ -47,5 +47,5 @@ jobs:
mkdir -p "$HOME/.ssh"
echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key"
rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:"
rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:/var/www/html/chatmail.at/doc/relay/"
-55
View File
@@ -1,60 +1,5 @@
# Changelog for chatmail deployment
## [1.12.0] - 2026-07-31
### Breaking Changes
- [**breaking**] Introduce configurable system limits to reject new address creation and limit imap/smtp connections.
Dovecot default connection limit lowered from 50k to 10k,
Postfix default connection limit lowered from 5k to 1k,
larger relays need to adjust their settings.
### Features
- Reduce maximal_queue_lifetime from 5d to 2d
- Disable negative cache in unbound (#992)
- *(mtail)* Add incoming_mailer_daemon_mail_count
- *(postfix)* Disable processing of MIME headers
- *(dovecot)* Advertise privacy_mail as admin contact, drop server comment
### Bug Fixes
- Set relay restrictions per smtpd service with default reject
- Reduce maxproc for filtermail-transport LMTP client to 500
- Core 2.50.0 does not have delete_server_after config anymore.
- Check if all required ports are available for filtermail (#983)
- Always deploy unbound.conf.d/chatmail.conf (#993)
- Expire empty directories (#994)
- Crypt-r dependency was declared for wrong Python version
- Always overwrite /etc/resolv.conf, even if it is a symbolic link
- Pass kwargs to files.put()
- List Iroh proxy endpoints used by 0.35 and 1.0, drop stale /relay/probe from earlier versions
- Fix port discovery when ss -tulpn shows dovecot before stats
### Documentation
- Add scripts/initenv.sh to upgrade instructions
- Update overview diagrams (#995)
- *(overview)* Remove mermaid styles from 'Accepting and delivering mail' (#1009)
- *(README.md)* Clarify security enforcement (#1011)
### Miscellaneous Tasks
- *(ci)* Auto-trigger docker build on release tag push
- *(acmetool)* Update let's encrypt ToS link to 1.8
- *(ci)* Update doc staging upload path
- *(ci)* Fix docs upload path
### Refactor
- *(postfix)* Remove unused "filter" lmtp service
- Install dns-root-data instead of using unbound-anchor
- *(deps)* Remove domain-validator dependency
### Testing
- Set socket security for IMAP and SMTP to "TLS" in "dclogin"
## [1.11.0] - 2026-05-15
### Breaking Changes
+6 -4
View File
@@ -1,13 +1,15 @@
# Releasing a new version of chatmail relay
For example, to release version 1.13.0 of chatmail relay, do the following steps.
For example, to release version 1.9.0 of chatmail relay, do the following steps.
1. Update the changelog: `git cliff --unreleased --tag 1.13.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.13.0 -p CHANGELOG.md`.
1. Update the changelog: `git cliff --unreleased --tag 1.9.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.9.0 -p CHANGELOG.md`.
2. Open the changelog in the editor, edit it if required.
3. Commit the changes to the changelog with a commit message `chore(release): prepare for 1.9.0`.
4. Open a PR with the new commit, merge it to main after review.
3. Tag the release: `git tag --annotate 1.9.0`.
5. In the web interface, create a GitHub release, tell it to create a new tag.
4. Push the release tag: `git push origin 1.9.0`.
5. Create a GitHub release: `gh release create 1.9.0`.
+4 -2
View File
@@ -32,6 +32,7 @@ from .external.deployer import ExternalTlsDeployer
from .filtermail.deployer import FiltermailDeployer
from .mtail.deployer import MtailDeployer
from .nginx.deployer import NginxDeployer
from .websockify.deployer import WebsockifyDeployer
from .opendkim.deployer import OpendkimDeployer
from .postfix.deployer import PostfixDeployer
from .selfsigned.deployer import SelfSignedTlsDeployer
@@ -520,10 +521,10 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
("nginx", 443),
(["master", "smtpd"], 465),
(["master", "smtpd"], 587),
(["dovecot", "imap-login"], 993),
(["imap-login", "dovecot"], 993),
("iroh-relay", 3340),
("mtail", 3903),
(["dovecot", "stats"], 3904),
("stats", 3904),
("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming),
@@ -565,6 +566,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
PostfixDeployer(config, disable_mail),
FcgiwrapDeployer(),
NginxDeployer(config),
WebsockifyDeployer(config),
MtailDeployer(config.mtail_address),
GithashDeployer(),
]
+2 -15
View File
@@ -1,5 +1,5 @@
from pyinfra import facts, host
from pyinfra.operations import apt, server
from pyinfra.operations import apt
from cmdeploy.basedeploy import Deployer
@@ -37,20 +37,7 @@ class MtailDeployer(Deployer):
address=self.mtail_address or "127.0.0.1",
port=3903,
)
if self.mtail_address:
self.put_file("mtail/delivered_mail.mtail", "/etc/mtail/delivered_mail.mtail")
self.put_file("mtail/filtermail.mtail", "/etc/mtail/filtermail.mtail")
if self.need_restart:
# Check if all installed mtail rules compile or fail early
# --one_shot to exit, --port 0 to not clash with running mtail.
server.shell(
name="Validate mtail programs",
commands=[
"timeout 30 /usr/local/bin/mtail --compile_only --one_shot"
" --progs /etc/mtail --logs /dev/null"
" --address 127.0.0.1 --port 0"
],
)
self.put_file("mtail/delivered_mail.mtail", "/etc/mtail/delivered_mail.mtail")
def activate(self):
active = bool(self.mtail_address)
@@ -1,101 +0,0 @@
# filtermail.mtail: filtermail process event counters
#
# Counters
#
# Connection errors in the transport path
# Error::Io / Error::ConnectionFailed / Error::Tls: the reason string filtermail strips
counter filtermail_transport_error_total by reason
# Unexpected transport errors: DNS failure, HTTP error, config error.
counter filtermail_transport_unexpected_total
# Silent drops: inbound
# "unencrypted" overlaps with rejected_unencrypted_mail_count in delivered_mail.mtail.
counter filtermail_inbound_drop_total by reason
# Silent drops: outbound
# "unencrypted" overlaps with rejected_unencrypted_mail_count in delivered_mail.mtail;
# "sender_disabled" and "all_recipients_disabled" are new.
counter filtermail_outbound_drop_total by reason
# Reinject failures
counter filtermail_reinject_error_total by direction
# SMTP-level connection errors (client dropped mid-session)
counter filtermail_smtp_error_total by reason
#
# filtermail::transport
#
/filtermail\[\d+\]: \[WARN\s+filtermail::transport::worker\] Connection error relaying to mail server \S+: / {
/timed out/ {
filtermail_transport_error_total["connection_timeout"]++
} otherwise {
/Failed to connect to any of the following addresses/ {
filtermail_transport_error_total["connection_refused"]++
} otherwise {
/Connection refused/ {
filtermail_transport_error_total["connection_refused"]++
} otherwise {
/invalid certificate/ {
filtermail_transport_error_total["tls_cert_not_verified"]++
} otherwise {
/peer sent fatal alert|handshake/ {
filtermail_transport_error_total["tls_handshake"]++
} otherwise {
/close_notify|connection closed/ {
filtermail_transport_error_total["lost_connection"]++
} otherwise {
filtermail_transport_error_total["other"]++
}}}}}}}
/filtermail\[\d+\]: \[WARN\s+filtermail::transport::worker\] Unexpected error while delivering/ {
filtermail_transport_unexpected_total++
}
#
# filtermail::smtp_server
#
/filtermail(?:-incoming)?\[\d+\]: \[WARN\s+filtermail::smtp_server\] Unexpected EoF while receiving DATA/ {
filtermail_smtp_error_total["unexpected_eof"]++
}
/filtermail(?:-incoming)?\[\d+\]: \[WARN\s+filtermail::smtp_server\] Malformed DATA line without CRLF/ {
filtermail_smtp_error_total["malformed_data_line"]++
}
#
# filtermail::outbound
#
/filtermail\[\d+\]: \[WARN\s+filtermail::outbound\] Rejected unencrypted mail/ {
filtermail_outbound_drop_total["unencrypted"]++
}
/filtermail\[\d+\]: \[WARN\s+filtermail::outbound\] Dropping mail; Sender .* is disabled/ {
filtermail_outbound_drop_total["sender_disabled"]++
}
/filtermail\[\d+\]: \[WARN\s+filtermail::outbound\] Dropping mail; All recipients disabled/ {
filtermail_outbound_drop_total["all_recipients_disabled"]++
}
/filtermail\[\d+\]: \[WARN\s+filtermail::outbound\] Failed to re.inject mail/ {
filtermail_reinject_error_total["outbound"]++
}
#
# filtermail::inbound
#
/filtermail(?:-incoming)?\[\d+\]: \[WARN\s+filtermail::inbound\] Rejected unencrypted mail/ {
filtermail_inbound_drop_total["unencrypted"]++
}
/filtermail(?:-incoming)?\[\d+\]: \[WARN\s+filtermail::inbound\] Failed to re.inject mail/ {
filtermail_reinject_error_total["inbound"]++
}
+14
View File
@@ -140,6 +140,20 @@ http {
proxy_pass http://127.0.0.1:3340;
proxy_http_version 1.1;
}
location /imap {
proxy_pass http://127.0.0.1:8143;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /smtp {
proxy_pass http://127.0.0.1:8587;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
# Redirect www. to non-www
@@ -0,0 +1,19 @@
from pyinfra.operations import apt, server
from cmdeploy.basedeploy import Deployer
class WebsockifyDeployer(Deployer):
def __init__(self, config):
self.config = config
def install(self):
apt.packages(name="Install websockify", packages=["websockify"])
def configure(self):
self.ensure_systemd_unit("websockify/websockify-imap.service")
self.ensure_systemd_unit("websockify/websockify-submission.service")
def activate(self):
self.ensure_service("websockify-imap.service")
self.ensure_service("websockify-submission.service")
@@ -0,0 +1,12 @@
[Unit]
Description=WebSocket proxy for IMAP
After=network.target
[Service]
Type=simple
Restart=always
ExecStart=/usr/bin/websockify 127.0.0.1:8143 localhost:143
DynamicUser=true
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,12 @@
[Unit]
Description=WebSocket proxy for SMTP
After=network.target
[Service]
Type=simple
Restart=always
ExecStart=/usr/bin/websockify 127.0.0.1:8587 localhost:587
DynamicUser=true
[Install]
WantedBy=multi-user.target