Compare commits

...

36 Commits

Author SHA1 Message Date
missytake
fcdef806d7 ci: deploy with ssh_host=localhost on staging-ipv4 2026-02-18 01:54:15 +01:00
missytake
e024d837dc tests: make tests work with --ssh-host localhost 2026-02-18 01:54:15 +01:00
missytake
6981331466 docs: webdev needs to be exposed via nginx if run on the relay 2026-02-18 01:54:15 +01:00
missytake
4d8aacae6b docs: cmdeploy dns + test are kind of necessary 2026-02-18 01:54:15 +01:00
missytake
a6ddb4fe92 docs: remove mentions of the build machine / deployment server separation 2026-02-18 01:53:56 +01:00
missytake
2042c94bb6 cmdeploy: add ssh_host chatmail.ini option to deploy remotely 2026-02-18 01:51:29 +01:00
missytake
6d28cf0f15 config: load default values from Config(), not chatmail.ini.f 2026-02-18 00:41:36 +01:00
Mark Felder
36eb63faa1 feat: Strip Received headers before delivery 2026-02-17 21:16:11 +01:00
Jagoda Estera Ślązak
91df11015e chore(deps): upgrade to filtermail v0.3 (#850)
## 0.3.0 - 2026-02-14

### Features

- Support legacy, pre-OpenPGP packet format

### Miscellaneous Tasks

- *(dist)* Switch to musl targets

### Refactor

- Remove unnecessary Arc
- Use a custom, minimal SMTP client instead of lettre

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-14 18:02:05 +01:00
link2xt
d4f8a29243 docs: fix link to Maddy and update madmail URL 2026-02-13 09:49:29 +00:00
missytake
0144fc3ea8 postfix: only look for square brackets, they are only allowed for address literals 2026-02-12 10:45:15 +01:00
missytake
e7ce6679b9 postfix: IPv6 literals have a prefix 2026-02-12 10:45:15 +01:00
missytake
d1adf52f89 postfix: also accept self-signed for IPv6-only 2026-02-12 10:45:15 +01:00
missytake
56d0e2ca27 postfix: be more exact with nauta.cu 2026-02-12 10:45:15 +01:00
missytake
2613558db6 postfix uses POSIX EREs, not PCRE, so some stuff doesn't work 2026-02-12 10:45:15 +01:00
missytake
6843fcb1a0 postfix: fix tls policy regexp map 2026-02-12 10:45:15 +01:00
missytake
ff54ad88d8 postfix: use regexp to match IPv4 addresses 2026-02-12 10:45:15 +01:00
missytake
cce2b27ae7 postfix: accept self-signed certificates for IP-only relays 2026-02-12 10:45:15 +01:00
j4n
87022e3681 fix(cmdeploy): check if dns_check_disabled before trying to warn about LE
If --skip-dns-check is used and retcode != 0, remote_data is undefined.
2026-02-11 12:13:24 +01:00
j4n
06560dd071 feat(postfix): bind to mail_domain's A/AAAA addresses for outbound mail
Carry forward A/AAAA address from the DNS check to the postfix deploy
stage and set accordingly in main.cf.
2026-02-11 12:13:24 +01:00
j4n
1b0337a5f7 fix(cmdeploy): port check: check addresses, fix single services
Ensure that the interface for mtail_address is available and fix a bug
in port checking where single services were always passing regardless of
the specified service name.
2026-02-11 09:36:04 +01:00
373[Ø]™
dfcaf415b1 Merge pull request #834 from chatmail/373/fix-dns-resolver-injection
fix: remediates issue with improper concat on resolver injection
2026-01-30 23:36:46 +00:00
ccclxxiii
c0718325ef fix: simplify resolver fix 2026-01-30 22:17:53 +00:00
ccclxxiii
7d72b0e592 fix:[wip] fix concact issue which causes dns failure 2026-01-30 21:10:19 +00:00
373[Ø]™
8f1e23d98e Merge pull request #832 from chatmail/373/respect-ipv4-ipv6-boolean-config
remediates ipv6 boolean not being respected during operations
2026-01-30 17:53:36 +00:00
ccclxxiii
56aaf2649b chore: fixes bug in dovecot template 2026-01-30 15:52:32 +00:00
ccclxxiii
2660b4d24c feat: updates postfix for ipv4/v6 2026-01-30 15:27:02 +00:00
ccclxxiii
ea60ecfb57 feat: updates deployers for ipv4/v6 bool 2026-01-30 15:26:45 +00:00
ccclxxiii
2a3a224cc2 feat: adds template for unbound v4/v6 2026-01-30 15:24:26 +00:00
Jagoda Ślązak
e42139e97b chore(deps): upgrade to filtermail v0.2
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-28 20:46:02 +00:00
Jagoda Estera Ślązak
65b660c413 docs: update information about filtermail (#824)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-27 13:20:09 +01:00
link2xt
dd2beb226a test(test_exceed_rate_limit): print timestamps when sending messages 2026-01-26 14:25:06 +00:00
link2xt
9c7508cc33 test: fix flaky test_exceed_rate_limit
filtermail rate limiter is using leaky bucket
algorithm (GCRA).
Exceeting the limit requires sending
at least max_user_send_per_minute
messages to exhaust allowed burst,
and then sending messages faster
than the leak rate.
As we don't know how fast is the network
between the server and test runner,
try to send 3 times max_user_send_per_minute
messages to ensure the test does not
fail randomly.
2026-01-26 12:07:10 +00:00
Jagoda Estera Ślązak
ab3492d9a1 feat(filtermail): Replace filtermail with rust reimplementation (#808)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-23 16:31:45 +01:00
Jagoda Estera Ślązak
032faf0a94 feat(config): Set default internal SMTP ports in Config (#819)
Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-01-23 09:34:16 +01:00
link2xt
c45fe03652 fix(mtail): separate metrics for incoming and outgoing messages 2026-01-22 14:45:33 +00:00
29 changed files with 303 additions and 877 deletions

View File

@@ -14,7 +14,8 @@ jobs:
# Otherwise `test_deployed_state` will be unhappy. # Otherwise `test_deployed_state` will be unhappy.
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: download filtermail
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.3.0/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

View File

@@ -71,26 +71,35 @@ jobs:
- name: run deploy-chatmail offline tests - name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy run: pytest --pyargs cmdeploy
- run: | - name: setup dependencies
cmdeploy init staging-ipv4.testrun.org run: |
sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini ssh root@staging-ipv4.testrun.org apt update
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini ssh root@staging-ipv4.testrun.org apt install -y git python3.11-venv python3-dev gcc
ssh root@staging-ipv4.testrun.org git clone https://github.com/chatmail/relay
ssh root@staging-ipv4.testrun.org "cd relay && git checkout " ${{ github.head_ref }}
ssh root@staging-ipv4.testrun.org "cd relay && scripts/initenv.sh"
- run: cmdeploy run --verbose --skip-dns-check - name: initialize config
run: |
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy init staging-ipv4.testrun.org"
ssh root@staging-ipv4.testrun.org "sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' relay/chatmail.ini"
ssh root@staging-ipv4.testrun.org "sed -i 's/#\s*mtail_address/mtail_address/' relay/chatmail.ini"
- run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy run --verbose --skip-dns-check"
- name: set DNS entries - name: set DNS entries
run: | run: |
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown opendkim:opendkim -R /etc/dkimkeys ssh root@staging-ipv4.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
cmdeploy dns --zonefile staging-generated.zone ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns --zonefile staging-generated.zone"
cat staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone ssh root@staging-ipv4.testrun.org cat relay/staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone
cat .github/workflows/staging-ipv4.testrun.org-default.zone cat .github/workflows/staging-ipv4.testrun.org-default.zone
scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test - name: cmdeploy test
run: CHATMAIL_DOMAIN2=ci-chatmail.testrun.org cmdeploy test --slow run: ssh root@staging-ipv4.testrun.org "cd relay && CHATMAIL_DOMAIN2=ci-chatmail.testrun.org scripts/cmdeploy test --slow"
- name: cmdeploy dns - name: cmdeploy dns
run: cmdeploy dns -v run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v"

View File

@@ -76,6 +76,7 @@ jobs:
- run: | - run: |
cmdeploy init staging2.testrun.org cmdeploy init staging2.testrun.org
sed -i 's/^ssh_host/#ssh_host/' chatmail.ini
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
- run: cmdeploy run --verbose --skip-dns-check - run: cmdeploy run --verbose --skip-dns-check

View File

@@ -24,7 +24,6 @@ where = ['src']
[project.scripts] [project.scripts]
doveauth = "chatmaild.doveauth:main" doveauth = "chatmaild.doveauth:main"
chatmail-metadata = "chatmaild.metadata:main" chatmail-metadata = "chatmaild.metadata:main"
filtermail = "chatmaild.filtermail:main"
chatmail-metrics = "chatmaild.metrics:main" chatmail-metrics = "chatmaild.metrics:main"
chatmail-expire = "chatmaild.expire:main" chatmail-expire = "chatmaild.expire:main"
chatmail-fsreport = "chatmaild.fsreport:main" chatmail-fsreport = "chatmaild.fsreport:main"

View File

@@ -1,3 +1,4 @@
import os
from pathlib import Path from pathlib import Path
import iniconfig import iniconfig
@@ -8,40 +9,41 @@ from chatmaild.user import User
def read_config(inipath): def read_config(inipath):
assert Path(inipath).exists(), inipath assert Path(inipath).exists(), inipath
cfg = iniconfig.IniConfig(inipath) cfg = iniconfig.IniConfig(inipath)
params = cfg.sections["params"] return Config(inipath, params=cfg.sections["params"])
default_config_content = get_default_config_content(params["mail_domain"])
df_params = iniconfig.IniConfig("ini", data=default_config_content)["params"]
new_params = dict(df_params.items())
new_params.update(params)
return Config(inipath, params=new_params)
class Config: class Config:
def __init__(self, inipath, params): def __init__(self, inipath, params):
self._inipath = inipath self._inipath = inipath
self.mail_domain = params["mail_domain"] self.mail_domain = params["mail_domain"]
self.max_user_send_per_minute = int(params["max_user_send_per_minute"]) self.ssh_host = params.get("ssh_host", self.mail_domain)
self.max_mailbox_size = params["max_mailbox_size"] self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60))
self.max_message_size = int(params.get("max_message_size", "31457280")) self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10))
self.delete_mails_after = params["delete_mails_after"] self.max_mailbox_size = params.get("max_mailbox_size", "500M")
self.delete_large_after = params["delete_large_after"] self.max_message_size = int(params.get("max_message_size", 31457280))
self.delete_inactive_users_after = int(params["delete_inactive_users_after"]) self.delete_mails_after = params.get("delete_mails_after", "20")
self.username_min_length = int(params["username_min_length"]) self.delete_large_after = params.get("delete_large_after", "7")
self.username_max_length = int(params["username_max_length"]) self.delete_inactive_users_after = int(
self.password_min_length = int(params["password_min_length"]) params.get("delete_inactive_users_after", 100)
self.passthrough_senders = params["passthrough_senders"].split()
self.passthrough_recipients = params["passthrough_recipients"].split()
self.www_folder = params.get("www_folder", "")
self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
self.filtermail_smtp_port_incoming = int(
params["filtermail_smtp_port_incoming"]
) )
self.postfix_reinject_port = int(params["postfix_reinject_port"]) self.username_min_length = int(params.get("username_min_length", 9))
self.username_max_length = int(params.get("username_max_length", 9))
self.password_min_length = int(params.get("password_min_length", 9))
self.passthrough_senders = params.get("passthrough_senders", "").split()
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(
params.get("filtermail_smtp_port_incoming", "10081")
)
self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
self.postfix_reinject_port_incoming = int( self.postfix_reinject_port_incoming = int(
params["postfix_reinject_port_incoming"] params.get("postfix_reinject_port_incoming", "10026")
) )
self.mtail_address = params.get("mtail_address") self.mtail_address = params.get("mtail_address")
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true" self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
self.addr_v4 = os.environ.get("CHATMAIL_ADDR_V4", "")
self.addr_v6 = os.environ.get("CHATMAIL_ADDR_V6", "")
self.acme_email = params.get("acme_email", "") self.acme_email = params.get("acme_email", "")
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true" self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
self.imap_compress = params.get("imap_compress", "false").lower() == "true" self.imap_compress = params.get("imap_compress", "false").lower() == "true"

View File

@@ -1,378 +0,0 @@
#!/usr/bin/env python3
import asyncio
import base64
import binascii
import sys
import time
from email import policy
from email.parser import BytesParser
from email.utils import parseaddr
from smtplib import SMTP as SMTPClient
from aiosmtpd.controller import Controller
from aiosmtpd.smtp import SMTP
from .config import read_config
ENCRYPTION_NEEDED_523 = "523 Encryption Needed: Invalid Unencrypted Mail"
def check_openpgp_payload(payload: bytes):
"""Checks the OpenPGP payload.
OpenPGP payload must consist only of PKESK and SKESK packets
terminated by a single SEIPD packet.
Returns True if OpenPGP payload is correct,
False otherwise.
May raise IndexError while trying to read OpenPGP packet header
if it is truncated.
"""
i = 0
while i < len(payload):
# Only OpenPGP format is allowed.
if payload[i] & 0xC0 != 0xC0:
return False
packet_type_id = payload[i] & 0x3F
i += 1
while payload[i] >= 224 and payload[i] < 255:
# Partial body length.
partial_length = 1 << (payload[i] & 0x1F)
i += 1 + partial_length
if payload[i] < 192:
# One-octet length.
body_len = payload[i]
i += 1
elif payload[i] < 224:
# Two-octet length.
body_len = ((payload[i] - 192) << 8) + payload[i + 1] + 192
i += 2
elif payload[i] == 255:
# Five-octet length.
body_len = (
(payload[i + 1] << 24)
| (payload[i + 2] << 16)
| (payload[i + 3] << 8)
| payload[i + 4]
)
i += 5
else:
# Impossible, partial body length was processed above.
return False
i += body_len
if i == len(payload):
# Last packet should be
# Symmetrically Encrypted and Integrity Protected Data Packet (SEIPD)
#
# This is the only place where this function may return `True`.
return packet_type_id == 18
elif packet_type_id not in [1, 3]:
# All packets except the last one must be either
# Public-Key Encrypted Session Key Packet (PKESK)
# or
# Symmetric-Key Encrypted Session Key Packet (SKESK)
return False
return False
def check_armored_payload(payload: str, outgoing: bool):
"""Check the armored PGP message for invalid content.
:param payload: the armored PGP message
:param outgoing: whether the message is outgoing or incoming
:return: whether the message is a valid PGP message
"""
prefix = "-----BEGIN PGP MESSAGE-----\r\n"
if not payload.startswith(prefix):
return False
payload = payload.removeprefix(prefix)
while payload.endswith("\r\n"):
payload = payload.removesuffix("\r\n")
suffix = "-----END PGP MESSAGE-----"
if not payload.endswith(suffix):
return False
payload = payload.removesuffix(suffix)
version_comment = "Version: "
if payload.startswith(version_comment):
if outgoing: # Disallow comments in outgoing messages
return False
# Remove comments from incoming messages
payload = payload.partition("\r\n")[2]
while payload.startswith("\r\n"):
payload = payload.removeprefix("\r\n")
# Remove CRC24.
payload = payload.rpartition("=")[0]
try:
payload = base64.b64decode(payload)
except binascii.Error:
return False
try:
return check_openpgp_payload(payload)
except IndexError:
return False
def is_securejoin(message):
if message.get("secure-join") not in ["vc-request", "vg-request"]:
return False
if not message.is_multipart():
return False
parts_count = 0
for part in message.iter_parts():
parts_count += 1
if parts_count > 1:
return False
if part.is_multipart():
return False
if part.get_content_type() != "text/plain":
return False
payload = part.get_payload().strip().lower()
if payload not in ("secure-join: vc-request", "secure-join: vg-request"):
return False
return True
def check_encrypted(message, outgoing=True):
"""Check that the message is an OpenPGP-encrypted message.
MIME structure of the message must correspond to <https://www.rfc-editor.org/rfc/rfc3156>.
"""
if not message.is_multipart():
return False
if message.get_content_type() != "multipart/encrypted":
return False
parts_count = 0
for part in message.iter_parts():
# We explicitly check Content-Type of each part later,
# but this is to be absolutely sure `get_payload()` returns string and not list.
if part.is_multipart():
return False
if parts_count == 0:
if part.get_content_type() != "application/pgp-encrypted":
return False
payload = part.get_payload()
if payload.strip() != "Version: 1":
return False
elif parts_count == 1:
if part.get_content_type() != "application/octet-stream":
return False
if not check_armored_payload(part.get_payload(), outgoing=outgoing):
return False
else:
return False
parts_count += 1
return True
async def asyncmain_beforequeue(config, mode):
if mode == "outgoing":
port = config.filtermail_smtp_port
handler = OutgoingBeforeQueueHandler(config)
else:
port = config.filtermail_smtp_port_incoming
handler = IncomingBeforeQueueHandler(config)
HackedController(
handler,
hostname="127.0.0.1",
port=port,
data_size_limit=config.max_message_size,
).start()
def recipient_matches_passthrough(recipient, passthrough_recipients):
for addr in passthrough_recipients:
if recipient == addr:
return True
if addr[0] == "@" and recipient.endswith(addr):
return True
return False
class HackedController(Controller):
def factory(self):
return SMTPDiscardRCPTO_options(self.handler, **self.SMTP_kwargs)
class SMTPDiscardRCPTO_options(SMTP):
def _getparams(self, params):
# Ignore RCPT TO parameters.
#
# Otherwise parameters such as `ORCPT=...`
# or `NOTIFY=DELAY,FAILURE` (generated by Stalwart)
# make aiosmtpd reject the message here:
# <https://github.com/aio-libs/aiosmtpd/blob/98f578389ae86e5345cc343fa4e5a17b21d9c96d/aiosmtpd/smtp.py#L1379-L1384>
return {}
class OutgoingBeforeQueueHandler:
def __init__(self, config):
self.config = config
self.send_rate_limiter = SendRateLimiter()
async def handle_MAIL(self, server, session, envelope, address, mail_options):
log_info(f"handle_MAIL from {address}")
envelope.mail_from = address
max_sent = self.config.max_user_send_per_minute
if not self.send_rate_limiter.is_sending_allowed(address, max_sent):
return f"450 4.7.1: Too much mail from {address}"
parts = envelope.mail_from.split("@")
if len(parts) != 2:
return f"500 Invalid from address <{envelope.mail_from!r}>"
return "250 OK"
async def handle_DATA(self, server, session, envelope):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self.sync_handle_DATA, envelope)
def sync_handle_DATA(self, envelope):
log_info("handle_DATA before-queue")
error = self.check_DATA(envelope)
if error:
return error
log_info("re-injecting the mail that passed checks")
client = SMTPClient("localhost", self.config.postfix_reinject_port)
client.sendmail(
envelope.mail_from, envelope.rcpt_tos, envelope.original_content
)
return "250 OK"
def check_DATA(self, envelope):
"""the central filtering function for e-mails."""
log_info(f"Processing DATA message from {envelope.mail_from}")
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message, outgoing=True)
_, from_addr = parseaddr(message.get("from").strip())
if envelope.mail_from.lower() != from_addr.lower():
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
if mail_encrypted or is_securejoin(message):
print("Outgoing: Filtering encrypted mail.", file=sys.stderr)
return
print("Outgoing: Filtering unencrypted mail.", file=sys.stderr)
if envelope.mail_from in self.config.passthrough_senders:
return
# allow self-sent Autocrypt Setup Message
if envelope.rcpt_tos == [from_addr]:
if message.get("subject") == "Autocrypt Setup Message":
if message.get_content_type() == "multipart/mixed":
return
passthrough_recipients = self.config.passthrough_recipients
for recipient in envelope.rcpt_tos:
if recipient_matches_passthrough(recipient, passthrough_recipients):
continue
print("Rejected unencrypted mail.", file=sys.stderr)
return ENCRYPTION_NEEDED_523
class IncomingBeforeQueueHandler:
def __init__(self, config):
self.config = config
async def handle_DATA(self, server, session, envelope):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self.sync_handle_DATA, envelope)
def sync_handle_DATA(self, envelope):
log_info("handle_DATA before-queue")
error = self.check_DATA(envelope)
if error:
return error
log_info("re-injecting the mail that passed checks")
client = SMTPClient(
"localhost",
self.config.postfix_reinject_port_incoming,
)
client.sendmail(
envelope.mail_from, envelope.rcpt_tos, envelope.original_content
)
return "250 OK"
def check_DATA(self, envelope):
"""the central filtering function for e-mails."""
log_info(f"Processing DATA message from {envelope.mail_from}")
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message, outgoing=False)
if mail_encrypted or is_securejoin(message):
print("Incoming: Filtering encrypted mail.", file=sys.stderr)
return
print("Incoming: Filtering unencrypted mail.", file=sys.stderr)
# we want cleartext mailer-daemon messages to pass through
# chatmail core will typically not display them as normal messages
if message.get("auto-submitted"):
_, from_addr = parseaddr(message.get("from").strip())
if from_addr.lower().startswith("mailer-daemon@"):
if message.get_content_type() == "multipart/report":
return
for recipient in envelope.rcpt_tos:
user = self.config.get_user(recipient)
if user is None or user.is_incoming_cleartext_ok():
continue
print("Rejected unencrypted mail.", file=sys.stderr)
return ENCRYPTION_NEEDED_523
class SendRateLimiter:
def __init__(self):
self.addr2timestamps = {}
def is_sending_allowed(self, mail_from, max_send_per_minute):
last = self.addr2timestamps.setdefault(mail_from, [])
now = time.time()
last[:] = [ts for ts in last if ts >= (now - 60)]
if len(last) <= max_send_per_minute:
last.append(now)
return True
return False
def log_info(msg):
print(msg, file=sys.stderr)
def main():
args = sys.argv[1:]
assert len(args) == 2
config = read_config(args[0])
mode = args[1]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
assert mode in ["incoming", "outgoing"]
task = asyncmain_beforequeue(config, mode)
loop.create_task(task)
log_info("entering serving loop")
loop.run_forever()

View File

@@ -3,6 +3,9 @@
# mail domain (MUST be set to fully qualified chat mail domain) # mail domain (MUST be set to fully qualified chat mail domain)
mail_domain = {mail_domain} mail_domain = {mail_domain}
# Where to deploy the relay - if unspecified, mail_domain will be used.
ssh_host = localhost
# #
# If you only do private test deploys, you don't need to modify any settings below # If you only do private test deploys, you don't need to modify any settings below
# #
@@ -11,9 +14,12 @@ mail_domain = {mail_domain}
# Restrictions on user addresses # Restrictions on user addresses
# #
# how many mails a user can send out per minute # email sending rate per user and minute
max_user_send_per_minute = 60 max_user_send_per_minute = 60
# per-user max burst size for sending rate limiting (GCRA bucket capacity)
max_user_send_burst_size = 10
# maximum mailbox size of a chatmail address # maximum mailbox size of a chatmail address
max_mailbox_size = 500M max_mailbox_size = 500M

View File

@@ -1,361 +0,0 @@
import pytest
from chatmaild.filtermail import (
IncomingBeforeQueueHandler,
OutgoingBeforeQueueHandler,
SendRateLimiter,
check_armored_payload,
check_encrypted,
is_securejoin,
)
@pytest.fixture
def maildomain():
# let's not depend on a real chatmail instance for the offline tests below
return "chatmail.example.org"
@pytest.fixture
def handler(make_config, maildomain):
config = make_config(maildomain)
return OutgoingBeforeQueueHandler(config)
@pytest.fixture
def inhandler(make_config, maildomain):
config = make_config(maildomain)
return IncomingBeforeQueueHandler(config)
def test_reject_forged_from(maildata, gencreds, handler):
class env:
mail_from = gencreds()[0]
rcpt_tos = [gencreds()[0]]
# test that the filter lets good mail through
to_addr = gencreds()[0]
env.content = maildata(
"encrypted.eml", from_addr=env.mail_from, to_addr=to_addr
).as_bytes()
assert not handler.check_DATA(envelope=env)
# test that the filter rejects forged mail
env.content = maildata(
"encrypted.eml", from_addr="forged@c3.testrun.org", to_addr=to_addr
).as_bytes()
error = handler.check_DATA(envelope=env)
assert "500" in error
def test_filtermail_no_encryption_detection(maildata):
msg = maildata(
"plain.eml", from_addr="some@example.org", to_addr="other@example.org"
)
assert not check_encrypted(msg)
# https://xkcd.com/1181/
msg = maildata(
"fake-encrypted.eml", from_addr="some@example.org", to_addr="other@example.org"
)
assert not check_encrypted(msg)
def test_filtermail_securejoin_detection(maildata):
msg = maildata(
"securejoin-vc.eml", from_addr="some@example.org", to_addr="other@example.org"
)
assert is_securejoin(msg)
msg = maildata(
"securejoin-vc-fake.eml",
from_addr="some@example.org",
to_addr="other@example.org",
)
assert not is_securejoin(msg)
def test_filtermail_encryption_detection(maildata):
msg = maildata(
"encrypted.eml",
from_addr="1@example.org",
to_addr="2@example.org",
subject="Subject does not matter, will be replaced anyway",
)
assert check_encrypted(msg)
def test_filtermail_no_literal_packets(maildata):
"""Test that literal OpenPGP packet is not considered an encrypted mail."""
msg = maildata("literal.eml", from_addr="1@example.org", to_addr="2@example.org")
assert not check_encrypted(msg)
def test_filtermail_unencrypted_mdn(maildata, gencreds):
"""Unencrypted MDNs should not pass."""
from_addr = gencreds()[0]
to_addr = gencreds()[0] + ".other"
msg = maildata("mdn.eml", from_addr=from_addr, to_addr=to_addr)
assert not check_encrypted(msg)
def test_send_rate_limiter():
limiter = SendRateLimiter()
for i in range(100):
if limiter.is_sending_allowed("some@example.org", 10):
if i <= 10:
continue
pytest.fail("limiter didn't work")
else:
assert i == 11
break
def test_cleartext_excempt_privacy(maildata, gencreds, handler):
from_addr = gencreds()[0]
to_addr = "privacy@testrun.org"
handler.config.passthrough_recipients = [to_addr]
false_to = "privacy@something.org"
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
# assert that None/no error is returned
assert not handler.check_DATA(envelope=env)
class env2:
mail_from = from_addr
rcpt_tos = [to_addr, false_to]
content = msg.as_bytes()
assert "523" in handler.check_DATA(envelope=env2)
def test_cleartext_self_send_autocrypt_setup_message(maildata, gencreds, handler):
from_addr = gencreds()[0]
to_addr = from_addr
msg = maildata("asm.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
assert not handler.check_DATA(envelope=env)
def test_cleartext_send_fails(maildata, gencreds, handler):
from_addr = gencreds()[0]
to_addr = gencreds()[0]
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
res = handler.check_DATA(envelope=env)
assert "523 Encryption Needed" in res
def test_cleartext_incoming_fails(maildata, gencreds, inhandler):
from_addr = gencreds()[0]
to_addr, password = gencreds()
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
user = inhandler.config.get_user(to_addr)
user.set_password(password)
res = inhandler.check_DATA(envelope=env)
assert "523 Encryption Needed" in res
user.allow_incoming_cleartext()
assert not inhandler.check_DATA(envelope=env)
def test_cleartext_incoming_mailer_daemon(maildata, gencreds, inhandler):
from_addr = "mailer-daemon@example.org"
to_addr = gencreds()[0]
msg = maildata("mailer-daemon.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
assert not inhandler.check_DATA(envelope=env)
def test_cleartext_passthrough_domains(maildata, gencreds, handler):
from_addr = gencreds()[0]
to_addr = "privacy@x.y.z"
handler.config.passthrough_recipients = ["@x.y.z"]
false_to = "something@x.y"
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
# assert that None/no error is returned
assert not handler.check_DATA(envelope=env)
class env2:
mail_from = from_addr
rcpt_tos = [to_addr, false_to]
content = msg.as_bytes()
assert "523" in handler.check_DATA(envelope=env2)
def test_cleartext_passthrough_senders(gencreds, handler, maildata):
acc1 = gencreds()[0]
to_addr = "recipient@something.org"
handler.config.passthrough_senders = [acc1]
msg = maildata("plain.eml", from_addr=acc1, to_addr=to_addr)
class env:
mail_from = acc1
rcpt_tos = to_addr
content = msg.as_bytes()
# assert that None/no error is returned
assert not handler.check_DATA(envelope=env)
def test_check_armored_payload():
prefix = "-----BEGIN PGP MESSAGE-----\r\n"
comment = "Version: ProtonMail\r\n"
payload = """\r
wU4DSqFx0d1yqAoSAQdAYkX/ZN/Az4B0k7X47zKyWrXxlDEdS3WOy0Yf2+GJTFgg\r
Zk5ql0mLG8Ze+ZifCS0XMO4otlemSyJ0K1ZPdFMGzUDBTgNqzkFabxXoXRIBB0AM\r
755wlX41X6Ay3KhnwBq7yEqSykVH6F3x11iHPKraLCAGZoaS8bKKNy/zg5slda1X\r
pt14b4aC1VwtSnYhcRRELNLD/wE2TFif+g7poMmFY50VyMPLYjVP96Z5QCT4+z4H\r
Ikh/pRRN8S3JNMrRJHc6prooSJmLcx47Y5un7VFy390MsJ+LiUJuQMDdYWRAinfs\r
Ebm89Ezjm7F03qbFPXE0X4ZNzVXS/eKO0uhJQdiov/vmbn41rNtHmNpqjaO0vi5+\r
sS9tR7yDUrIXiCUCN78eBLVioxtktsPZm5cDORbQWzv+7nmCEz9/JowCUcBVdCGn\r
1ofOaH82JCAX/cRx08pLaDNj6iolVBsi56Dd+2bGxJOZOG2AMcEyz0pXY0dOAJCD\r
iUThcQeGIdRnU3j8UBcnIEsjLu2+C+rrwMZQESMWKnJ0rnqTk0pK5kXScr6F/L0L\r
UE49ccIexNm3xZvYr5drszr6wz3Tv5fdue87P4etBt90gF/Vzknck+g1LLlkzZkp\r
d8dI0k2tOSPjUbDPnSy1x+X73WGpPZmj0kWT+RGvq0nH6UkJj3AQTG2qf1T8jK+3\r
rTp3LR9vDkMwDjX4R8SA9c0wdnUzzr79OYQC9lTnzcx+fM6BBmgQ2GrS33jaFLp7\r
L6/DFpCl5zhnPjM/2dKvMkw/Kd6XS/vjwsO405FQdjSDiQEEAZA+ZvAfcjdccbbU\r
yCO+x0QNdeBsufDVnh3xvzuWy4CICdTQT4s1AWRPCzjOj+SGmx5WqCLWfsd8Ma0+\r
w/C7SfTYu1FDQILLM+llpq1M/9GPley4QZ8JQjo262AyPXsPF/OW48uuZz0Db1xT\r
Yh4iHBztj4VSdy7l2+IyaIf7cnL4EEBFxv/MwmVDXvDlxyvfAfIsd3D9SvJESzKZ\r
VWDYwaocgeCN+ojKu1p885lu1EfRbX3fr3YO02K5/c2JYDkc0Py0W3wUP/J1XUax\r
pbKpzwlkxEgtmzsGqsOfMJqBV3TNDrOA2uBsa+uBqP5MGYLZ49S/4v/bW9I01Cr1\r
D2ZkV510Y1Vgo66WlP8mRqOTyt/5WRhPD+MxXdk67BNN/PmO6tMlVoJDuk+XwWPR\r
t2TvNaND/yabT9eYI55Og4fzKD6RIjouUX8DvKLkm+7aXxVs2uuLQ3Jco3O82z55\r
dbShU1jYsrw9oouXUz06MHPbkdhNbF/2hfhZ2qA31sNeovJw65iUv7sDKX3LVWgJ\r
10jlywcDwqlU8CO7WC9lGixYTbnOkYZpXCGEl8e6Jbs79l42YFo4ogYpFK1NXFhV\r
kOXRmDf/wmfj+c/ld3L2PkvwlgofhCudOQknZbo3ub1gjiTn7L+lMGHIj/3suMIl\r
ID4EUxAXScIM1ZEz2fjtW5jATlqYcLjLTbf/olw6HFyPNH+9IssqXeZNKnGwPUB9\r
3lTXsg0tpzl+x7F/2WjEw1DSNhjC0KnHt1vEYNMkUGDGFdN9y3ERLqX/FIgiASUb\r
bTvAVupnAK3raBezGmhrs6LsQtLS9P0VvQiLU3uDhMqw8Z4SISLpcD+NnVBHzQqm\r
6W5Qn/8xsCL6av18yUVTi2G3igt3QCNoYx9evt2ZcIkNoyyagUVjfZe5GHXh8Dnz\r
GaBXW/hg3HlXLRGaQu4RYCzBMJILcO25OhZOg6jbkCLiEexQlm2e9krB5cXR49Al\r
UN4fiB0KR9JyG2ayUdNJVkXZSZLnHyRgiaadlpUo16LVvw==\r
=b5Kp\r
-----END PGP MESSAGE-----\r
\r
\r
"""
commented_payload = prefix + comment + payload
assert check_armored_payload(commented_payload, outgoing=False) == True
assert check_armored_payload(commented_payload, outgoing=True) == False
payload = prefix + payload
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True
payload = payload.removesuffix("\r\n")
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True
payload = payload.removesuffix("\r\n")
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True
payload = payload.removesuffix("\r\n")
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True
payload = """-----BEGIN PGP MESSAGE-----\r
\r
HELLOWORLD
-----END PGP MESSAGE-----\r
\r
"""
assert check_armored_payload(payload, outgoing=False) == False
assert check_armored_payload(payload, outgoing=True) == False
payload = """-----BEGIN PGP MESSAGE-----\r
\r
=njUN
-----END PGP MESSAGE-----\r
\r
"""
assert check_armored_payload(payload, outgoing=False) == False
assert check_armored_payload(payload, outgoing=True) == False
# Test payload using partial body length
# as generated by GopenPGP.
payload = """-----BEGIN PGP MESSAGE-----\r
\r
wV4DdCVjRfOT3TQSAQdAY5+pjT6mlCxPGdR3be4w7oJJRUGIPI/Vnh+mJxGSm34w\r
LNlVc89S1g22uQYFif2sUJsQWbpoHpNkuWpkSgOaHmNvrZiY/YU5iv+cZ3LbmtUG\r
0uoBisSHh9O1c+5sYZSbrvYZ1NOwlD7Fv/U5/Mw4E5+CjxfdgNGp5o3DDddzPK78\r
jseDhdSXxnaiIJC93hxNX6R1RPt3G2gukyzx69wciPQShcF8zf3W3o75Ed7B8etV\r
QEeB16xzdFhKa9JxdjTu3osgCs21IO7wpcFkjc7nZzlW6jPnELJJaNmv4yOOCjMp\r
6YAkaN/BkL+jHTznHDuDsT5ilnTXpwHDU1Cm9PIx/KFcNCQnIB+2DcdIHPHUH1ci\r
jvqoeXAVWjKXEjS7PqPFuP/xGbrWG2ugs+toXJOKbgRkExvKs1dwPFKrgghvCVbW\r
AcKejQKAPArLwpkA7aD875TZQShvGt74fNs45XBlGOYOnNOAJ1KAmzrXLIDViyyB\r
kDsmTBk785xofuCkjBpXSe6vsMprPzCteDfaUibh8FHeJjucxPerwuOPEmnogNaf\r
YyL4+iy8H8I9/p7pmUqILprxTG0jTOtlk0bTVzeiF56W1xbtSEMuOo4oFbQTyOM2\r
bKXaYo774Jm+rRtKAnnI2dtf9RpK19cog6YNzfYjesLKbXDsPZbN5rmwyFiCvvxC\r
kQ6JLob+B2fPdY2gzy7LypxktS8Zi1HJcWDHJGVmQodaDLqKUObb4M26bXDe6oxI\r
NS8PJz5exVbM3KhZnUOEn6PJRBBf5a/ZqxlhZPcQo/oBuhKpBRpO5kSDwPIUByu3\r
UlXLSkpMqe9pUarAOEuQjfl2RVY7U+RrQYp4YP5keMO+i8NCefAFbowTTufO1JIq\r
2nVgCi/QVnxZyEc9OYt/8AE3g4cdojE+vsSDifZLSWYIetpfrohHv3dT3StD1QRG\r
0QE6qq6oKpg/IL0cjvuX4c7a7bslv2fXp8t75y37RU6253qdIebhxc/cRhPbc/yu\r
p0YLyD4SrvKTLP2ZV95jT4IPEpqm4AN3QmiOzdtqR2gLyb62L8QfqI/FdwsIiRiM\r
hqydwoqt/lfSqG1WKPh+6EkMkH+TDiCC1BQdbN1MNcyUtcjb35PR2c8Ld2TF3guA\r
jLIqMt/Vb7hBoMb2FcsOYY25ka9oV62OwgKWLXnFzk+modMR5fzb4kxVVAYEqP+D\r
T5KO1Vs76v1fyPGOq6BbBCvLwTqe/e6IZInJles4v5jrhnLcGKmNGivCUDe6X6NY\r
UKNt5RsZllwDQpaAb5dMNhyrk8SgIE7TBI7rvqIdUCE52Vy+0JDxFg5olRpFUfO6\r
/MyTW3Yo/ekk/npHr7iYYqJTCc21bDGLWQcIo/XO7WPxrKNWGBNPFnkRdw0MaKr4\r
+cEM3V8NFnSEpC12xA+RX/CezuJtwXZK5MpG76eYqMO6qyC+c25YcFecEufDZDxx\r
ZLqRszVRyxyWPtk/oIeQK2v9wOqY6N9/ff01gHz69vqYqN5bUw/QKZsmx1zW+gPw\r
6x2tDK2BHeYl182gCbhlKISRFwCtbjqZSkiKWao/VtygHkw0fK34avJuyQ/X9YaN\r
BRy+7Lf3VA53pnB5WJ1xwRXN8VDvmZeXzv2krHveCMemj0OjnRoCLu117xN0A5m9\r
Fm/RoDix5PolDHtWTtr2m1n2hp2LHnj8at9lFEd0SKhAYHVL9KjzycwWODZRXt+x\r
zGDDuooEeTvdY5NLyKcl4gETz1ZP4Ez5jGGjhPSwSpq1mU7UaJ9ZXXdr4KHyifW6\r
ggNzNsGhXTap7IWZpTtqXABydfiBshmH2NjqtNDwBweJVSgP10+r0WhMWlaZs6xl\r
V3o5yskJt6GlkwpJxZrTvN6Tiww/eW7HFV6NGf7IRSWY5tJc/iA7/92tOmkdvJ1q\r
myLbG7cJB787QjplEyVe2P/JBO6xYvbkJLf9Q+HaviTO25rugRSrYsoKMDfO8VlQ\r
1CcnTPVtApPZJEQzAWJEgVAM8uIlkqWJJMgyWT34sTkdBeCUFGloXQFs9Yxd0AGf\r
/zHEkYZSTKpVSvAIGu4=\r
=6iHb\r
-----END PGP MESSAGE-----\r
"""
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True

View File

@@ -17,9 +17,8 @@ def configure_remote_units(mail_domain, units) -> None:
# install systemd units # install systemd units
for fn in units: for fn in units:
execpath = fn if fn != "filtermail-incoming" else "filtermail"
params = dict( params = dict(
execpath=f"{remote_venv_dir}/bin/{execpath}", execpath=f"{remote_venv_dir}/bin/{fn}",
config_path=remote_chatmail_inipath, config_path=remote_chatmail_inipath,
remote_venv_dir=remote_venv_dir, remote_venv_dir=remote_venv_dir,
mail_domain=mail_domain, mail_domain=mail_domain,

View File

@@ -88,7 +88,7 @@ def run_cmd_options(parser):
def run_cmd(args, out): def run_cmd(args, out):
"""Deploy chatmail services on the remote server.""" """Deploy chatmail services on the remote server."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain ssh_host = args.ssh_host if args.ssh_host else args.config.ssh_host
sshexec = get_sshexec(ssh_host) sshexec = get_sshexec(ssh_host)
require_iroh = args.config.enable_iroh_relay require_iroh = args.config.enable_iroh_relay
if not args.dns_check_disabled: if not args.dns_check_disabled:
@@ -101,11 +101,14 @@ def run_cmd(args, out):
env["CHATMAIL_WEBSITE_ONLY"] = "True" if args.website_only else "" env["CHATMAIL_WEBSITE_ONLY"] = "True" if args.website_only else ""
env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail else "" env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail else ""
env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else "" env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else ""
if not args.dns_check_disabled:
env["CHATMAIL_ADDR_V4"] = remote_data.get("A") or ""
env["CHATMAIL_ADDR_V6"] = remote_data.get("AAAA") or ""
deploy_path = importlib.resources.files(__package__).joinpath("run.py").resolve() deploy_path = importlib.resources.files(__package__).joinpath("run.py").resolve()
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra" pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y" cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
if ssh_host in ["localhost", "@docker"]: if ssh_host in ["localhost", "@local", "@docker"]:
cmd = f"{pyinf} @local {deploy_path} -y" cmd = f"{pyinf} @local {deploy_path} -y"
if version.parse(pyinfra.__version__) < version.parse("3"): if version.parse(pyinfra.__version__) < version.parse("3"):
@@ -121,7 +124,7 @@ def run_cmd(args, out):
out.red("Website deployment failed.") out.red("Website deployment failed.")
elif retcode == 0: elif retcode == 0:
out.green("Deploy completed, call `cmdeploy dns` next.") out.green("Deploy completed, call `cmdeploy dns` next.")
elif not remote_data["acme_account_url"]: elif not args.dns_check_disabled and not remote_data["acme_account_url"]:
out.red("Deploy completed but letsencrypt not configured") out.red("Deploy completed but letsencrypt not configured")
out.red("Run 'cmdeploy run' again") out.red("Run 'cmdeploy run' again")
retcode = 0 retcode = 0
@@ -146,7 +149,7 @@ def dns_cmd_options(parser):
def dns_cmd(args, out): def dns_cmd(args, out):
"""Check DNS entries and optionally generate dns zone file.""" """Check DNS entries and optionally generate dns zone file."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain ssh_host = args.ssh_host if args.ssh_host else args.config.ssh_host
sshexec = get_sshexec(ssh_host, verbose=args.verbose) sshexec = get_sshexec(ssh_host, verbose=args.verbose)
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not remote_data: if not remote_data:
@@ -180,7 +183,7 @@ def status_cmd_options(parser):
def status_cmd(args, out): def status_cmd(args, out):
"""Display status for online chatmail instance.""" """Display status for online chatmail instance."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain ssh_host = args.ssh_host if args.ssh_host else args.config.ssh_host
sshexec = get_sshexec(ssh_host, verbose=args.verbose) sshexec = get_sshexec(ssh_host, verbose=args.verbose)
out.green(f"chatmail domain: {args.config.mail_domain}") out.green(f"chatmail domain: {args.config.mail_domain}")
@@ -200,6 +203,7 @@ def test_cmd_options(parser):
action="store_true", action="store_true",
help="also run slow tests", help="also run slow tests",
) )
add_ssh_host_option(parser)
def test_cmd(args, out): def test_cmd(args, out):
@@ -211,6 +215,9 @@ def test_cmd(args, out):
x = importlib.util.find_spec("deltachat") x = importlib.util.find_spec("deltachat")
if x is None: if x is None:
out.check_call(f"{sys.executable} -m pip install deltachat") out.check_call(f"{sys.executable} -m pip install deltachat")
env = os.environ.copy()
if args.ssh_host:
env["CHATMAIL_SSH"] = args.ssh_host
pytest_path = shutil.which("pytest") pytest_path = shutil.which("pytest")
pytest_args = [ pytest_args = [
@@ -224,7 +231,7 @@ def test_cmd(args, out):
] ]
if args.slow: if args.slow:
pytest_args.append("--slow") pytest_args.append("--slow")
ret = out.run_ret(pytest_args) ret = out.run_ret(pytest_args, env=env)
return ret return ret

View File

@@ -10,6 +10,7 @@ from pathlib import Path
from chatmaild.config import read_config from chatmaild.config import read_config
from pyinfra import facts, host, logger from pyinfra import facts, host, logger
from pyinfra.facts import hardware
from pyinfra.api import FactBase from pyinfra.api import FactBase
from pyinfra.facts.files import Sha256File from pyinfra.facts.files import Sha256File
from pyinfra.facts.systemd import SystemdEnabled from pyinfra.facts.systemd import SystemdEnabled
@@ -26,6 +27,7 @@ from .basedeploy import (
get_resource, get_resource,
) )
from .dovecot.deployer import DovecotDeployer from .dovecot.deployer import DovecotDeployer
from .filtermail.deployer import FiltermailDeployer
from .mtail.deployer import MtailDeployer from .mtail.deployer import MtailDeployer
from .nginx.deployer import NginxDeployer from .nginx.deployer import NginxDeployer
from .opendkim.deployer import OpendkimDeployer from .opendkim.deployer import OpendkimDeployer
@@ -35,7 +37,7 @@ from .www import build_webpages, find_merge_conflict, get_paths
class Port(FactBase): class Port(FactBase):
""" """
Returns the process occuping a port. Returns the process occupying a port.
""" """
def command(self, port: int) -> str: def command(self, port: int) -> str:
@@ -140,6 +142,10 @@ def _configure_remote_venv_with_chatmaild(config) -> None:
class UnboundDeployer(Deployer): class UnboundDeployer(Deployer):
def __init__(self, config):
self.config = config
self.need_restart = False
def install(self): def install(self):
# Run local DNS resolver `unbound`. # Run local DNS resolver `unbound`.
# `resolvconf` takes care of setting up /etc/resolv.conf # `resolvconf` takes care of setting up /etc/resolv.conf
@@ -176,6 +182,27 @@ class UnboundDeployer(Deployer):
"unbound-anchor -a /var/lib/unbound/root.key || true", "unbound-anchor -a /var/lib/unbound/root.key || true",
], ],
) )
if self.config.disable_ipv6:
files.directory(
path="/etc/unbound/unbound.conf.d",
present=True,
user="root",
group="root",
mode="755",
)
conf = files.put(
src=get_resource("unbound/unbound.conf.j2"),
dest="/etc/unbound/unbound.conf.d/chatmail.conf",
user="root",
group="root",
mode="644",
)
else:
conf = files.file(
path="/etc/unbound/unbound.conf.d/chatmail.conf",
present=False,
)
self.need_restart |= conf.changed
def activate(self): def activate(self):
server.shell( server.shell(
@@ -190,6 +217,7 @@ class UnboundDeployer(Deployer):
service="unbound.service", service="unbound.service",
running=True, running=True,
enabled=True, enabled=True,
restarted=self.need_restart,
) )
@@ -416,8 +444,6 @@ class ChatmailVenvDeployer(Deployer):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.units = ( self.units = (
"filtermail",
"filtermail-incoming",
"chatmail-metadata", "chatmail-metadata",
"lastlogin", "lastlogin",
"chatmail-expire", "chatmail-expire",
@@ -528,9 +554,18 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
files.line( files.line(
name="Add 9.9.9.9 to resolv.conf", name="Add 9.9.9.9 to resolv.conf",
path="/etc/resolv.conf", path="/etc/resolv.conf",
line="nameserver 9.9.9.9", # Guard against resolv.conf missing a trailing newline (SolusVM bug).
line="\nnameserver 9.9.9.9",
) )
# Check if mtail_address interface is available (if configured)
if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'):
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs]
if config.mtail_address not in all_addresses:
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
exit(1)
port_services = [ port_services = [
(["master", "smtpd"], 25), (["master", "smtpd"], 25),
("unbound", 53), ("unbound", 53),
@@ -542,7 +577,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
(["imap-login", "dovecot"], 993), (["imap-login", "dovecot"], 993),
("iroh-relay", 3340), ("iroh-relay", 3340),
("mtail", 3903), ("mtail", 3903),
("dovecot-stats", 3904), ("stats", 3904),
("nginx", 8443), ("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port), (["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming), (["master", "smtpd"], config.postfix_reinject_port_incoming),
@@ -552,8 +587,9 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
for service, port in port_services: for service, port in port_services:
print(f"Checking if port {port} is available for {service}...") print(f"Checking if port {port} is available for {service}...")
running_service = host.get_fact(Port, port=port) running_service = host.get_fact(Port, port=port)
services = [service] if isinstance(service, str) else service
if running_service: if running_service:
if running_service not in service: if running_service not in services:
Out().red( Out().red(
f"Deploy failed: port {port} is occupied by: {running_service}" f"Deploy failed: port {port} is occupied by: {running_service}"
) )
@@ -564,8 +600,9 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
all_deployers = [ all_deployers = [
ChatmailDeployer(mail_domain), ChatmailDeployer(mail_domain),
LegacyRemoveDeployer(), LegacyRemoveDeployer(),
FiltermailDeployer(),
JournaldDeployer(), JournaldDeployer(),
UnboundDeployer(), UnboundDeployer(config),
TurnDeployer(mail_domain), TurnDeployer(mail_domain),
IrohDeployer(config.enable_iroh_relay), IrohDeployer(config.enable_iroh_relay),
AcmetoolDeployer(config.acme_email, tls_domains), AcmetoolDeployer(config.acme_email, tls_domains),

View File

@@ -1,7 +1,7 @@
## Dovecot configuration file ## Dovecot configuration file
{% if disable_ipv6 %} {% if disable_ipv6 %}
listen = * listen = 0.0.0.0
{% endif %} {% endif %}
protocols = imap lmtp protocols = imap lmtp

View File

@@ -0,0 +1,52 @@
from pyinfra import facts, host
from pyinfra.operations import files, systemd
from cmdeploy.basedeploy import Deployer, get_resource
class FiltermailDeployer(Deployer):
services = ["filtermail", "filtermail-incoming"]
bin_path = "/usr/local/bin/filtermail"
config_path = "/usr/local/lib/chatmaild/chatmail.ini"
def __init__(self):
self.need_restart = False
def install(self):
arch = host.get_fact(facts.server.Arch)
url = f"https://github.com/chatmail/filtermail/releases/download/v0.3.0/filtermail-{arch}"
sha256sum = {
"x86_64": "f14a31323ae2dad3b59d3fdafcde507521da2f951a9478cd1f2fe2b4463df71d",
"aarch64": "933770d75046c4fd7084ce8d43f905f8748333426ad839154f0fc654755ef09f",
}[arch]
self.need_restart |= files.download(
name="Download filtermail",
src=url,
sha256sum=sha256sum,
dest=self.bin_path,
mode="755",
).changed
def configure(self):
for service in self.services:
self.need_restart |= files.template(
src=get_resource(f"filtermail/{service}.service.j2"),
dest=f"/etc/systemd/system/{service}.service",
user="root",
group="root",
mode="644",
bin_path=self.bin_path,
config_path=self.config_path,
).changed
def activate(self):
for service in self.services:
systemd.service(
name=f"Start and enable {service}",
service=f"{service}.service",
running=True,
enabled=True,
restarted=self.need_restart,
daemon_reload=True,
)
self.need_restart = False

View File

@@ -2,11 +2,10 @@
Description=Incoming Chatmail Postfix before queue filter Description=Incoming Chatmail Postfix before queue filter
[Service] [Service]
ExecStart={execpath} {config_path} incoming ExecStart={{ bin_path }} {{ config_path }} incoming
Restart=always Restart=always
RestartSec=30 RestartSec=30
User=vmail User=vmail
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -2,7 +2,7 @@
Description=Outgoing Chatmail Postfix before queue filter Description=Outgoing Chatmail Postfix before queue filter
[Service] [Service]
ExecStart={execpath} {config_path} outgoing ExecStart={{ bin_path }} {{ config_path }} outgoing
Restart=always Restart=always
RestartSec=30 RestartSec=30
User=vmail User=vmail

View File

@@ -44,21 +44,37 @@ counter warning_count
} }
counter filtered_mail_count counter filtered_outgoing_mail_count
counter encrypted_mail_count counter outgoing_encrypted_mail_count
/Filtering encrypted mail\./ { /Outgoing: Filtering encrypted mail\./ {
encrypted_mail_count++ outgoing_encrypted_mail_count++
filtered_mail_count++ filtered_outgoing_mail_count++
} }
counter unencrypted_mail_count counter outgoing_unencrypted_mail_count
/Filtering unencrypted mail\./ { /Outgoing: Filtering unencrypted mail\./ {
unencrypted_mail_count++ outgoing_unencrypted_mail_count++
filtered_mail_count++ filtered_outgoing_mail_count++
} }
counter filtered_incoming_mail_count
counter incoming_encrypted_mail_count
/Incoming: Filtering encrypted mail\./ {
incoming_encrypted_mail_count++
filtered_incoming_mail_count++
}
counter incoming_unencrypted_mail_count
/Incoming: Filtering unencrypted mail\./ {
incoming_unencrypted_mail_count++
filtered_incoming_mail_count++
}
counter rejected_unencrypted_mail_count counter rejected_unencrypted_mail_count
/Rejected unencrypted mail\./ { /Rejected unencrypted mail/ {
rejected_unencrypted_mail_count++ rejected_unencrypted_mail_count++
} }

View File

@@ -61,6 +61,20 @@ class PostfixDeployer(Deployer):
) )
need_restart |= lmtp_header_cleanup.changed need_restart |= lmtp_header_cleanup.changed
tls_policy_map = files.put(
name="Upload SMTP TLS Policy that accepts self-signed certificates for IP-only hosts",
src=get_resource("postfix/smtp_tls_policy_map"),
dest="/etc/postfix/smtp_tls_policy_map",
user="root",
group="root",
mode="644",
)
need_restart |= tls_policy_map.changed
if tls_policy_map.changed:
server.shell(
commands=["postmap /etc/postfix/smtp_tls_policy_map"],
)
# Login map that 1:1 maps email address to login. # Login map that 1:1 maps email address to login.
login_map = files.put( login_map = files.put(
src=get_resource("postfix/login_map"), src=get_resource("postfix/login_map"),

View File

@@ -1,2 +1,3 @@
/^DKIM-Signature:/ IGNORE /^DKIM-Signature:/ IGNORE
/^Authentication-Results:/ IGNORE /^Authentication-Results:/ IGNORE
/^Received:/ IGNORE

View File

@@ -25,7 +25,7 @@ smtp_tls_security_level=verify
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername> # <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
smtp_tls_servername = hostname smtp_tls_servername = hostname
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_tls_policy_maps = inline:{nauta.cu=may} smtp_tls_policy_maps = regexp:/etc/postfix/smtp_tls_policy_map
smtp_tls_protocols = >=TLSv1.2 smtp_tls_protocols = >=TLSv1.2
smtp_tls_mandatory_protocols = >=TLSv1.2 smtp_tls_mandatory_protocols = >=TLSv1.2
@@ -64,7 +64,20 @@ alias_database = hash:/etc/aliases
mydestination = mydestination =
relayhost = relayhost =
{% if disable_ipv6 %}
mynetworks = 127.0.0.0/8
{% else %}
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128 mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
{% endif %}
{% if config.addr_v4 %}
smtp_bind_address = {{ config.addr_v4 }}
{% endif %}
{% if config.addr_v6 %}
smtp_bind_address6 = {{ config.addr_v6 }}
{% endif %}
{% if config.addr_v4 or config.addr_v6 %}
smtp_bind_address_enforce = yes
{% endif %}
mailbox_size_limit = 0 mailbox_size_limit = 0
message_size_limit = {{config.max_message_size}} message_size_limit = {{config.max_message_size}}
recipient_delimiter = + recipient_delimiter = +

View File

@@ -0,0 +1,2 @@
/^\[[^]]+\]$/ encrypt
/^nauta\.cu$/ may

View File

@@ -85,16 +85,31 @@ class SSHExec:
class LocalExec: class LocalExec:
FuncError = FuncError
def __init__(self, verbose=False, docker=False): def __init__(self, verbose=False, docker=False):
self.verbose = verbose self.verbose = verbose
self.docker = docker self.docker = docker
def __call__(self, call, kwargs=None, log_callback=None):
if kwargs is None:
kwargs = {}
return call(**kwargs)
def logged(self, call, kwargs: dict): def logged(self, call, kwargs: dict):
title = call.__doc__
if not title:
title = call.__name__
where = "locally" where = "locally"
if self.docker: if self.docker:
if call == remote.rdns.perform_initial_checks: if call == remote.rdns.perform_initial_checks:
kwargs["pre_command"] = "docker exec chatmail " kwargs["pre_command"] = "docker exec chatmail "
where = "in docker" where = "in docker"
if self.verbose: if self.verbose:
print(f"Running {where}: {call.__name__}(**{kwargs})") print_stderr(f"Running {where}: {title}(**{kwargs})")
return call(**kwargs) return self(call, kwargs, log_callback=print_stderr)
else:
print_stderr(title, end="")
res = self(call, kwargs, log_callback=remote.rshell.log_progress)
print_stderr()
return res

View File

@@ -7,13 +7,13 @@ import time
import pytest import pytest
from cmdeploy import remote from cmdeploy import remote
from cmdeploy.sshexec import SSHExec from cmdeploy.cmdeploy import get_sshexec
class TestSSHExecutor: class TestSSHExecutor:
@pytest.fixture(scope="class") @pytest.fixture(scope="class")
def sshexec(self, sshdomain): def sshexec(self, sshdomain):
return SSHExec(sshdomain) return get_sshexec(sshdomain)
def test_ls(self, sshexec): def test_ls(self, sshexec):
out = sshexec(call=remote.rdns.shell, kwargs=dict(command="ls")) out = sshexec(call=remote.rdns.shell, kwargs=dict(command="ls"))
@@ -27,6 +27,7 @@ class TestSSHExecutor:
assert res["A"] or res["AAAA"] assert res["A"] or res["AAAA"]
def test_logged(self, sshexec, maildomain, capsys): def test_logged(self, sshexec, maildomain, capsys):
sshexec.verbose = False
sshexec.logged( sshexec.logged(
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain) remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
) )
@@ -52,6 +53,8 @@ class TestSSHExecutor:
remote.rdns.perform_initial_checks, remote.rdns.perform_initial_checks,
kwargs=dict(mail_domain=None), kwargs=dict(mail_domain=None),
) )
except AssertionError:
pass
except sshexec.FuncError as e: except sshexec.FuncError as e:
assert "rdns.py" in str(e) assert "rdns.py" in str(e)
assert "AssertionError" in str(e) assert "AssertionError" in str(e)
@@ -189,12 +192,14 @@ def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
mail = maildata( mail = maildata(
"encrypted.eml", from_addr=user1.addr, to_addr=user2.addr "encrypted.eml", from_addr=user1.addr, to_addr=user2.addr
).as_string() ).as_string()
for i in range(chatmail_config.max_user_send_per_minute + 5):
print("Sending mail", str(i)) start = time.time()
for i in range(chatmail_config.max_user_send_per_minute * 3):
print("Sending mail", str(i + 1), "at", time.time() - start, "s.")
try: try:
user1.smtp.sendmail(user1.addr, [user2.addr], mail) user1.smtp.sendmail(user1.addr, [user2.addr], mail)
except smtplib.SMTPException as e: except smtplib.SMTPException as e:
if i < chatmail_config.max_user_send_per_minute: if i < chatmail_config.max_user_send_burst_size:
pytest.fail(f"rate limit was exceeded too early with msg {i}") pytest.fail(f"rate limit was exceeded too early with msg {i}")
outcome = e.recipients[user2.addr] outcome = e.recipients[user2.addr]
assert outcome[0] == 450 assert outcome[0] == 450

View File

@@ -7,7 +7,7 @@ import pytest
import requests import requests
from cmdeploy.remote import rshell from cmdeploy.remote import rshell
from cmdeploy.sshexec import SSHExec from cmdeploy.cmdeploy import get_sshexec
@pytest.fixture @pytest.fixture
@@ -90,7 +90,7 @@ class TestEndToEndDeltaChat:
lp.sec(f"filling remote inbox for {user}") lp.sec(f"filling remote inbox for {user}")
fn = f"7743102289.M843172P2484002.c20,S={quota},W=2398:2," fn = f"7743102289.M843172P2484002.c20,S={quota},W=2398:2,"
path = chatmail_config.mailboxes_dir.joinpath(user, "cur", fn) path = chatmail_config.mailboxes_dir.joinpath(user, "cur", fn)
sshexec = SSHExec(sshdomain) sshexec = get_sshexec(sshdomain)
sshexec(call=rshell.write_numbytes, kwargs=dict(path=str(path), num=120)) sshexec(call=rshell.write_numbytes, kwargs=dict(path=str(path), num=120))
res = sshexec(call=rshell.dovecot_recalc_quota, kwargs=dict(user=user)) res = sshexec(call=rshell.dovecot_recalc_quota, kwargs=dict(user=user))
assert res["percent"] >= 100 assert res["percent"] >= 100

View File

@@ -5,7 +5,11 @@ from cmdeploy.cmdeploy import main
def test_status_cmd(chatmail_config, capsys, request): def test_status_cmd(chatmail_config, capsys, request):
os.chdir(request.config.invocation_params.dir) os.chdir(request.config.invocation_params.dir)
assert main(["status"]) == 0 command = ["status"]
if os.getenv("CHATMAIL_SSH"):
command.append("--ssh-host")
command.append(os.getenv("CHATMAIL_SSH"))
assert main(command) == 0
status_out = capsys.readouterr() status_out = capsys.readouterr()
print(status_out.out) print(status_out.out)

View File

@@ -54,8 +54,8 @@ def maildomain(chatmail_config):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def sshdomain(maildomain): def sshdomain(chatmail_config):
return os.environ.get("CHATMAIL_SSH", maildomain) return os.environ.get("CHATMAIL_SSH", chatmail_config.ssh_host)
@pytest.fixture @pytest.fixture
@@ -337,8 +337,14 @@ class Remote:
def iter_output(self, logcmd=""): def iter_output(self, logcmd=""):
getjournal = "journalctl -f" if not logcmd else logcmd getjournal = "journalctl -f" if not logcmd else logcmd
print(self.sshdomain)
match self.sshdomain:
case "@local": command = []
case "localhost": command = []
case _: command = ["ssh", f"root@{self.sshdomain}"]
[command.append(arg) for arg in getjournal.split()]
self.popen = subprocess.Popen( self.popen = subprocess.Popen(
["ssh", f"root@{self.sshdomain}", getjournal], command,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
) )
while 1: while 1:

View File

@@ -0,0 +1,4 @@
# Managed by cmdeploy: disable IPv6 in unbound.
server:
interface: 127.0.0.1
do-ip6: no

View File

@@ -16,18 +16,11 @@ You will need the following:
- Control over a domain through a DNS provider of your choice. - Control over a domain through a DNS provider of your choice.
- A Debian 12 **deployment server** with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports. - A Debian 12 server with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
IPv6 is encouraged if available. Chatmail relay servers only require IPv6 is encouraged if available. Chatmail relay servers only require
1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active 1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active
chatmail addresses. chatmail addresses.
- A Linux or Unix **build machine** with key-based SSH access to the root
user of the deployment server.
You must add a passphrase-protected private key to your local ssh-agent because you
cant type in your passphrase during deployment.
(An ed25519 private key is required due to an `upstream bug in
paramiko <https://github.com/paramiko/paramiko/issues/2191>`_)
Setup with ``scripts/cmdeploy`` Setup with ``scripts/cmdeploy``
------------------------------------- -------------------------------------
@@ -35,7 +28,7 @@ Setup with ``scripts/cmdeploy``
We use ``chat.example.org`` as the chatmail domain in the following We use ``chat.example.org`` as the chatmail domain in the following
steps. Please substitute it with your own domain. steps. Please substitute it with your own domain.
1. Setup the initial DNS records for your deployment server. 1. Setup the initial DNS records for your relay.
The following is an example in the The following is an example in the
familiar BIND zone file format with a TTL of 1 hour (3600 seconds). familiar BIND zone file format with a TTL of 1 hour (3600 seconds).
Please substitute your domain and IP addresses. Please substitute your domain and IP addresses.
@@ -47,29 +40,24 @@ steps. Please substitute it with your own domain.
www.chat.example.org. 3600 IN CNAME chat.example.org. www.chat.example.org. 3600 IN CNAME chat.example.org.
mta-sts.chat.example.org. 3600 IN CNAME chat.example.org. mta-sts.chat.example.org. 3600 IN CNAME chat.example.org.
2. On your local PC, clone the repository and bootstrap the Python 2. Login to the server with SSH, clone the repository and bootstrap the Python
virtualenv. virtualenv.
:: ::
ssh root@chat.example.org
git clone https://github.com/chatmail/relay git clone https://github.com/chatmail/relay
cd relay cd relay
scripts/initenv.sh scripts/initenv.sh
3. On your local build machine (PC), create a chatmail configuration file 3. Then, create a chatmail configuration file
``chatmail.ini``: ``chatmail.ini``:
:: ::
scripts/cmdeploy init chat.example.org # <-- use your domain scripts/cmdeploy init chat.example.org # <-- use your domain
4. Verify that SSH root login to the deployment server server works: 4. Now run the deployment script to install the relay to the server:
::
ssh root@chat.example.org # <-- use your domain
5. From your local build machine, setup and configure the remote deployment server:
:: ::
@@ -80,27 +68,32 @@ steps. Please substitute it with your own domain.
configure at your DNS provider (it can take some time until they are configure at your DNS provider (it can take some time until they are
public). public).
Other helpful commands Next Steps
---------------------- ----------
To check the status of your deployment server running the chatmail service: Now you should display and check all recommended DNS records
to enable federation with other relays:
::
scripts/cmdeploy status
To display and check all recommended DNS records:
:: ::
scripts/cmdeploy dns scripts/cmdeploy dns
To test whether your chatmail service is working correctly: You should also test whether your chatmail service is working correctly:
:: ::
scripts/cmdeploy test scripts/cmdeploy test
Other Helpful Commands
----------------------
To check the status of your chatmail relay:
::
scripts/cmdeploy status
To measure the performance of your chatmail service: To measure the performance of your chatmail service:
:: ::
@@ -141,8 +134,9 @@ This starts a local live development cycle for chatmail web pages:
directory and generating HTML files and copying assets to the directory and generating HTML files and copying assets to the
``www/build`` directory. ``www/build`` directory.
- Starts a browser window automatically where you can “refresh” as - if you are running scripts/cmdeploy webdev on the relay itself,
needed. you need to configure a route in /etc/nginx/nginx.conf
to expose the build directory.
Custom web pages Custom web pages
---------------- ----------------
@@ -160,7 +154,7 @@ Disable automatic address creation
-------------------------------------------------------- --------------------------------------------------------
If you need to stop address creation, e.g. because some script is wildly If you need to stop address creation, e.g. because some script is wildly
creating addresses, login with ssh to the deployment machine and run: creating addresses, login with ssh to the relay and run:
:: ::
@@ -168,24 +162,3 @@ creating addresses, login with ssh to the deployment machine and run:
Chatmail address creation will be denied while this file is present. Chatmail address creation will be denied while this file is present.
Migrating to a new build machine
----------------------------------
To move or add a build machine,
clone the relay repository on the new build machine, and copy the ``chatmail.ini`` file from the old build machine.
Make sure ``rsync`` is installed, then initialize the environment:
::
./scripts/initenv.sh
Run safety checks before a new deployment:
::
./scripts/cmdeploy dns
./scripts/cmdeploy status
If you keep multiple build machines (ie laptop and desktop), keep ``chatmail.ini`` in sync between
them.

View File

@@ -42,6 +42,11 @@ The deployed system components of a chatmail relay are:
- Dovecot_ is the Mail Delivery Agent (MDA) and - Dovecot_ is the Mail Delivery Agent (MDA) and
stores messages for users until they download them stores messages for users until they download them
- `filtermail <https://github.com/chatmail/filtermail>`_
prevents unencrypted email from leaving or entering the chatmail
service and is integrated into Postfixs outbound and inbound mail
pipelines.
- Nginx_ shows the web page with privacy policy and additional information - Nginx_ shows the web page with privacy policy and additional information
- `acmetool <https://hlandau.github.io/acmetool/>`_ manages TLS - `acmetool <https://hlandau.github.io/acmetool/>`_ manages TLS
@@ -85,11 +90,6 @@ short overview of ``chatmaild`` services:
<https://doc.dovecot.org/2.3/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket>`_ <https://doc.dovecot.org/2.3/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket>`_
to authenticate logins. to authenticate logins.
- `filtermail <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/filtermail.py>`_
prevents unencrypted email from leaving or entering the chatmail
service and is integrated into Postfixs outbound and inbound mail
pipelines.
- `chatmail-metadata <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/metadata.py>`_ - `chatmail-metadata <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/metadata.py>`_
is contacted by a `Dovecot lua is contacted by a `Dovecot lua
script <https://github.com/chatmail/relay/blob/main/cmdeploy/src/cmdeploy/dovecot/push_notification.lua>`_ script <https://github.com/chatmail/relay/blob/main/cmdeploy/src/cmdeploy/dovecot/push_notification.lua>`_

View File

@@ -14,8 +14,8 @@ We know of three work-in-progress alternative implementation efforts:
it to support all of the features and configuration settings required it to support all of the features and configuration settings required
to operate as a chatmail relay. to operate as a chatmail relay.
- `Madmail <https://github.com/omidz4t/madmail>`_: an - `Madmail <https://github.com/themadorg/madmail>`_: an
experimental fork of Maddy Mail Server <https://maddy.email/>`_ optimized experimental fork of `Maddy Mail Server <https://maddy.email/>`_, modified
for chatmail deployments. It provides a single binary solution for chatmail deployments. It provides a single binary solution
for running a chatmail relay. for running a chatmail relay.