mirror of
https://github.com/chatmail/relay.git
synced 2026-05-17 22:48:58 +00:00
Compare commits
32 Commits
link2xt/re
...
hpk/fixup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb1424f944 | ||
|
|
0931da21b8 | ||
|
|
11a8f8cf9e | ||
|
|
0aa255e3f1 | ||
|
|
6c4764b452 | ||
|
|
c1f08a9afe | ||
|
|
5c8afb377e | ||
|
|
8225a9f398 | ||
|
|
eb221ca1af | ||
|
|
93421b317b | ||
|
|
777be107f3 | ||
|
|
8b81d5b5d6 | ||
|
|
e6a2906e82 | ||
|
|
67ba4ac99e | ||
|
|
8cadf51387 | ||
|
|
ce4bb97294 | ||
|
|
3a0c629f3b | ||
|
|
8df53c2655 | ||
|
|
3fd3ab1a68 | ||
|
|
d74f792787 | ||
|
|
1135372b81 | ||
|
|
c9f80bffd8 | ||
|
|
10e53d17e8 | ||
|
|
01ca2a8b91 | ||
|
|
fb01944f0d | ||
|
|
a90a651ba0 | ||
|
|
7d74b46502 | ||
|
|
6d3e690653 | ||
|
|
ed7a70ba31 | ||
|
|
023116bc91 | ||
|
|
b13929119b | ||
|
|
a4152140ca |
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1 +1,5 @@
|
|||||||
blank_issues_enabled: true
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: Mutual Help Chat Group
|
||||||
|
url: https://i.delta.chat/#6CBFF8FFD505C0FDEA20A66674F2916EA8FBEE99&a=invitebot%40nine.testrun.org&g=Chatmail%20Mutual%20Help&x=7sFF7Ik50pWv6J1z7RVC5527&i=X69wTFfvCfs3d-JzqP0kVA3i&s=ibp-447dU-wUq-52QanwAtWc
|
||||||
|
about: If you have troubles setting up the relay server, feel free to ask here.
|
||||||
|
|||||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -2,15 +2,6 @@
|
|||||||
|
|
||||||
## untagged
|
## untagged
|
||||||
|
|
||||||
- acmetool: use ECDSA keys instead of RSA
|
|
||||||
([#689](https://github.com/chatmail/relay/pull/689))
|
|
||||||
|
|
||||||
- Require TLS 1.2 for outgoing SMTP connections
|
|
||||||
([#685](https://github.com/chatmail/relay/pull/685))
|
|
||||||
|
|
||||||
- require STARTTLS for incoming port 25 connections
|
|
||||||
([#684](https://github.com/chatmail/relay/pull/684))
|
|
||||||
|
|
||||||
- filtermail: run CPU-intensive handle_DATA in a thread pool executor
|
- filtermail: run CPU-intensive handle_DATA in a thread pool executor
|
||||||
([#676](https://github.com/chatmail/relay/pull/676))
|
([#676](https://github.com/chatmail/relay/pull/676))
|
||||||
|
|
||||||
@@ -30,7 +21,7 @@
|
|||||||
([#650](https://github.com/chatmail/relay/pull/650))
|
([#650](https://github.com/chatmail/relay/pull/650))
|
||||||
|
|
||||||
- filtermail: accept mails from Protonmail
|
- filtermail: accept mails from Protonmail
|
||||||
([#616](https://github.com/chatmail/relay/pull/616))
|
([#616](https://github.com/chatmail/relay/pull/655))
|
||||||
|
|
||||||
- Ignore all RCPT TO: parameters
|
- Ignore all RCPT TO: parameters
|
||||||
([#651](https://github.com/chatmail/relay/pull/651))
|
([#651](https://github.com/chatmail/relay/pull/651))
|
||||||
@@ -63,7 +54,7 @@
|
|||||||
to only do a single iteration over sometimes millions of messages
|
to only do a single iteration over sometimes millions of messages
|
||||||
instead of doing "find" commands that iterate 9 times over the messages.
|
instead of doing "find" commands that iterate 9 times over the messages.
|
||||||
Provide an "fsreport" CLI for more fine grained analysis of message files.
|
Provide an "fsreport" CLI for more fine grained analysis of message files.
|
||||||
([#637](https://github.com/chatmail/relay/pull/637))
|
([#637](https://github.com/chatmail/relay/pull/632))
|
||||||
|
|
||||||
|
|
||||||
## 1.7.0 2025-09-11
|
## 1.7.0 2025-09-11
|
||||||
|
|||||||
@@ -180,10 +180,6 @@ The components of chatmail are:
|
|||||||
- [Iroh relay](https://www.iroh.computer/docs/concepts/relay)
|
- [Iroh relay](https://www.iroh.computer/docs/concepts/relay)
|
||||||
which helps client devices to establish Peer-to-Peer connections
|
which helps client devices to establish Peer-to-Peer connections
|
||||||
|
|
||||||
- [TURN](https://github.com/chatmail/chatmail-turn)
|
|
||||||
to enable relay users to start webRTC calls
|
|
||||||
even if a p2p connection can't be established
|
|
||||||
|
|
||||||
- and the chatmaild services, explained in the next section:
|
- and the chatmaild services, explained in the next section:
|
||||||
|
|
||||||
### chatmaild
|
### chatmaild
|
||||||
@@ -308,8 +304,6 @@ Chatmail address creation will be denied while this file is present.
|
|||||||
[Nginx](https://www.nginx.com/) listens on port 8443 (HTTPS-ALT) and 443 (HTTPS).
|
[Nginx](https://www.nginx.com/) listens on port 8443 (HTTPS-ALT) and 443 (HTTPS).
|
||||||
Port 443 multiplexes HTTPS, IMAP and SMTP using ALPN to redirect connections to ports 8443, 465 or 993.
|
Port 443 multiplexes HTTPS, IMAP and SMTP using ALPN to redirect connections to ports 8443, 465 or 993.
|
||||||
[acmetool](https://hlandau.github.io/acmetool/) listens on port 80 (HTTP).
|
[acmetool](https://hlandau.github.io/acmetool/) listens on port 80 (HTTP).
|
||||||
[chatmail-turn](https://github.com/chatmail/chatmail-turn) listens on UDP port 3478 (STUN/TURN),
|
|
||||||
and temporarily opens UDP ports when users request them. UDP port range is not restricted, any free port may be allocated.
|
|
||||||
|
|
||||||
chatmail-core based apps will, however, discover all ports and configurations
|
chatmail-core based apps will, however, discover all ports and configurations
|
||||||
automatically by reading the [autoconfig XML file](https://www.ietf.org/archive/id/draft-bucksch-autoconfig-00.html) from the chatmail relay server.
|
automatically by reading the [autoconfig XML file](https://www.ietf.org/archive/id/draft-bucksch-autoconfig-00.html) from the chatmail relay server.
|
||||||
|
|||||||
@@ -22,30 +22,11 @@ def iter_mailboxes(basedir, maxnum):
|
|||||||
print_info(f"no mailboxes found at: {basedir}")
|
print_info(f"no mailboxes found at: {basedir}")
|
||||||
return
|
return
|
||||||
|
|
||||||
for name in os_listdir_if_exists(basedir)[:maxnum]:
|
for name in os.listdir(basedir)[:maxnum]:
|
||||||
if "@" in name:
|
if "@" in name:
|
||||||
yield MailboxStat(basedir + "/" + name)
|
yield MailboxStat(basedir + "/" + name)
|
||||||
|
|
||||||
|
|
||||||
def get_file_entry(path):
|
|
||||||
"""return a FileEntry or None if the path does not exist or is not a regular file."""
|
|
||||||
try:
|
|
||||||
st = os.stat(path)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
if not S_ISREG(st.st_mode):
|
|
||||||
return None
|
|
||||||
return FileEntry(path, st.st_mtime, st.st_size)
|
|
||||||
|
|
||||||
|
|
||||||
def os_listdir_if_exists(path):
|
|
||||||
"""return a list of names obtained from os.listdir or an empty list if the path does not exist."""
|
|
||||||
try:
|
|
||||||
return os.listdir(path)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
class MailboxStat:
|
class MailboxStat:
|
||||||
last_login = None
|
last_login = None
|
||||||
|
|
||||||
@@ -59,23 +40,19 @@ class MailboxStat:
|
|||||||
|
|
||||||
# scan all relevant files (without recursion)
|
# scan all relevant files (without recursion)
|
||||||
old_cwd = os.getcwd()
|
old_cwd = os.getcwd()
|
||||||
try:
|
os.chdir(self.basedir)
|
||||||
os.chdir(self.basedir)
|
for name in os.listdir("."):
|
||||||
except FileNotFoundError:
|
|
||||||
return
|
|
||||||
for name in os_listdir_if_exists("."):
|
|
||||||
if name in ("cur", "new", "tmp"):
|
if name in ("cur", "new", "tmp"):
|
||||||
for msg_name in os_listdir_if_exists(name):
|
for msg_name in os.listdir(name):
|
||||||
entry = get_file_entry(f"{name}/{msg_name}")
|
relpath = name + "/" + msg_name
|
||||||
if entry is not None:
|
st = os.stat(relpath)
|
||||||
self.messages.append(entry)
|
self.messages.append(FileEntry(relpath, st.st_mtime, st.st_size))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
entry = get_file_entry(name)
|
st = os.stat(name)
|
||||||
if entry is not None:
|
if S_ISREG(st.st_mode):
|
||||||
self.extrafiles.append(entry)
|
self.extrafiles.append(FileEntry(name, st.st_mtime, st.st_size))
|
||||||
if name == "password":
|
if name == "password":
|
||||||
self.last_login = entry.mtime
|
self.last_login = st.st_mtime
|
||||||
self.extrafiles.sort(key=lambda x: -x.size)
|
self.extrafiles.sort(key=lambda x: -x.size)
|
||||||
os.chdir(old_cwd)
|
os.chdir(old_cwd)
|
||||||
|
|
||||||
@@ -103,13 +80,9 @@ class Expiry:
|
|||||||
shutil.rmtree(mboxdir)
|
shutil.rmtree(mboxdir)
|
||||||
self.del_mboxes += 1
|
self.del_mboxes += 1
|
||||||
|
|
||||||
def remove_file(self, path, mtime=None):
|
def remove_file(self, path):
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
if mtime is not None:
|
print_info(f"removing {path}")
|
||||||
date = datetime.fromtimestamp(mtime).strftime("%b %d")
|
|
||||||
print_info(f"removing {date} {path}")
|
|
||||||
else:
|
|
||||||
print_info(f"removing {path}")
|
|
||||||
if not self.dry:
|
if not self.dry:
|
||||||
try:
|
try:
|
||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
@@ -131,27 +104,18 @@ class Expiry:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# all to-be-removed files are relative to the mailbox basedir
|
# all to-be-removed files are relative to the mailbox basedir
|
||||||
try:
|
os.chdir(mbox.basedir)
|
||||||
os.chdir(mbox.basedir)
|
|
||||||
except FileNotFoundError:
|
|
||||||
print_info(f"mailbox not found/vanished {mbox.basedir}")
|
|
||||||
return
|
|
||||||
|
|
||||||
mboxname = os.path.basename(mbox.basedir)
|
mboxname = os.path.basename(mbox.basedir)
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
date = datetime.fromtimestamp(mbox.last_login) if mbox.last_login else None
|
print_info(f"checking for mailbox messages in: {mboxname}")
|
||||||
if date:
|
|
||||||
print_info(f"checking mailbox {date.strftime('%b %d')} {mboxname}")
|
|
||||||
else:
|
|
||||||
print_info(f"checking mailbox (no last_login) {mboxname}")
|
|
||||||
self.all_files += len(mbox.messages)
|
self.all_files += len(mbox.messages)
|
||||||
for message in mbox.messages:
|
for message in mbox.messages:
|
||||||
if message.mtime < cutoff_mails:
|
if message.mtime < cutoff_mails:
|
||||||
self.remove_file(message.relpath, mtime=message.mtime)
|
self.remove_file(message.relpath)
|
||||||
elif message.size > 200000 and message.mtime < cutoff_large_mails:
|
elif message.size > 200000 and message.mtime < cutoff_large_mails:
|
||||||
# we only remove noticed large files (not unnoticed ones in new/)
|
# we only remove noticed large files (not unnoticed ones in new/)
|
||||||
if message.relpath.startswith("cur/"):
|
if message.relpath.startswith("cur/"):
|
||||||
self.remove_file(message.relpath, mtime=message.mtime)
|
self.remove_file(message.relpath)
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
changed = True
|
changed = True
|
||||||
|
|||||||
@@ -6,13 +6,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from chatmaild.expire import (
|
from chatmaild.expire import FileEntry, MailboxStat, iter_mailboxes
|
||||||
FileEntry,
|
|
||||||
MailboxStat,
|
|
||||||
get_file_entry,
|
|
||||||
iter_mailboxes,
|
|
||||||
os_listdir_if_exists,
|
|
||||||
)
|
|
||||||
from chatmaild.expire import main as expiry_main
|
from chatmaild.expire import main as expiry_main
|
||||||
from chatmaild.fsreport import main as report_main
|
from chatmaild.fsreport import main as report_main
|
||||||
|
|
||||||
@@ -133,18 +127,3 @@ def test_expiry_cli_old_files(capsys, example_config, mbox1):
|
|||||||
pytest.fail(f"failed to remove {path}\n{err}")
|
pytest.fail(f"failed to remove {path}\n{err}")
|
||||||
|
|
||||||
assert "shouldstay" not in err
|
assert "shouldstay" not in err
|
||||||
|
|
||||||
|
|
||||||
def test_get_file_entry(tmp_path):
|
|
||||||
assert get_file_entry(str(tmp_path.joinpath("123123"))) is None
|
|
||||||
p = tmp_path.joinpath("x")
|
|
||||||
p.write_text("hello")
|
|
||||||
entry = get_file_entry(str(p))
|
|
||||||
assert entry.size == 5
|
|
||||||
assert entry.mtime
|
|
||||||
|
|
||||||
|
|
||||||
def test_os_listdir_if_exists(tmp_path):
|
|
||||||
tmp_path.joinpath("x").write_text("hello")
|
|
||||||
assert len(os_listdir_if_exists(str(tmp_path))) == 1
|
|
||||||
assert len(os_listdir_if_exists(str(tmp_path.joinpath("123123")))) == 0
|
|
||||||
|
|||||||
@@ -338,9 +338,9 @@ def _install_dovecot_package(package: str, arch: str):
|
|||||||
|
|
||||||
match (package, arch):
|
match (package, arch):
|
||||||
case ("core", "amd64"):
|
case ("core", "amd64"):
|
||||||
sha256 = "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d"
|
sha256 = "43f593332e22ac7701c62d58b575d2ca409e0f64857a2803be886c22860f5587"
|
||||||
case ("core", "arm64"):
|
case ("core", "arm64"):
|
||||||
sha256 = "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9"
|
sha256 = "4d21eba1a83f51c100f08f2e49f0c9f8f52f721ebc34f75018e043306da993a7"
|
||||||
case ("imapd", "amd64"):
|
case ("imapd", "amd64"):
|
||||||
sha256 = "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86"
|
sha256 = "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86"
|
||||||
case ("imapd", "arm64"):
|
case ("imapd", "arm64"):
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
request:
|
request:
|
||||||
provider: https://acme-v02.api.letsencrypt.org/directory
|
provider: https://acme-v02.api.letsencrypt.org/directory
|
||||||
key:
|
key:
|
||||||
type: ecdsa
|
type: rsa
|
||||||
ecdsa-curve: nistp256
|
|
||||||
challenge:
|
challenge:
|
||||||
webroot-paths:
|
webroot-paths:
|
||||||
- /var/www/html/.well-known/acme-challenge
|
- /var/www/html/.well-known/acme-challenge
|
||||||
|
|||||||
@@ -70,12 +70,6 @@ userdb {
|
|||||||
# Mailboxes are stored in the "mail" directory of the vmail user home.
|
# Mailboxes are stored in the "mail" directory of the vmail user home.
|
||||||
mail_location = maildir:{{ config.mailboxes_dir }}/%u
|
mail_location = maildir:{{ config.mailboxes_dir }}/%u
|
||||||
|
|
||||||
# index/cache files are not very useful for chatmail relay operations
|
|
||||||
# but it's not clear how to disable them completely.
|
|
||||||
# According to https://doc.dovecot.org/2.3/settings/advanced/#core_setting-mail_cache_max_size
|
|
||||||
# if the cache file becomes larger than the specified size, it is truncated by dovecot
|
|
||||||
mail_cache_max_size = 500K
|
|
||||||
|
|
||||||
namespace inbox {
|
namespace inbox {
|
||||||
inbox = yes
|
inbox = yes
|
||||||
|
|
||||||
|
|||||||
@@ -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_protocols = >=TLSv1.2
|
smtp_tls_policy_maps = inline:{nauta.cu=may}
|
||||||
smtpd_tls_protocols = >=TLSv1.2
|
smtpd_tls_protocols = >=TLSv1.2
|
||||||
|
|
||||||
# Disable anonymous cipher suites
|
# Disable anonymous cipher suites
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ smtp inet n - y - - smtpd -v
|
|||||||
{%- else %}
|
{%- else %}
|
||||||
smtp inet n - y - - smtpd
|
smtp inet n - y - - smtpd
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
-o smtpd_tls_security_level=encrypt
|
|
||||||
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }}
|
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }}
|
||||||
submission inet n - y - 5000 smtpd
|
submission inet n - y - 5000 smtpd
|
||||||
-o syslog_name=postfix/submission
|
-o syslog_name=postfix/submission
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import queue
|
import queue
|
||||||
import smtplib
|
import socket
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -91,23 +91,25 @@ def test_concurrent_logins_same_account(
|
|||||||
|
|
||||||
def test_no_vrfy(chatmail_config):
|
def test_no_vrfy(chatmail_config):
|
||||||
domain = chatmail_config.mail_domain
|
domain = chatmail_config.mail_domain
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
s = smtplib.SMTP(domain)
|
sock.settimeout(10)
|
||||||
s.starttls()
|
try:
|
||||||
|
sock.connect((domain, 25))
|
||||||
s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}")
|
except socket.timeout:
|
||||||
result = s.getreply()
|
pytest.skip(f"port 25 not reachable for {domain}")
|
||||||
|
banner = sock.recv(1024)
|
||||||
|
print(banner)
|
||||||
|
sock.send(b"VRFY wrongaddress@%s\r\n" % (chatmail_config.mail_domain.encode(),))
|
||||||
|
result = sock.recv(1024)
|
||||||
print(result)
|
print(result)
|
||||||
s.putcmd("vrfy", f"echo@{chatmail_config.mail_domain}")
|
sock.send(b"VRFY echo@%s\r\n" % (chatmail_config.mail_domain.encode(),))
|
||||||
result2 = s.getreply()
|
result2 = sock.recv(1024)
|
||||||
print(result2)
|
print(result2)
|
||||||
assert result[0] == result2[0] == 252
|
assert result[0:10] == result2[0:10]
|
||||||
assert result[1][0:6] == result2[1][0:6] == b"2.0.0 "
|
sock.send(b"VRFY wrongaddress\r\n")
|
||||||
s.putcmd("vrfy", "wrongaddress")
|
result = sock.recv(1024)
|
||||||
result = s.getreply()
|
|
||||||
print(result)
|
print(result)
|
||||||
s.putcmd("vrfy", "echo")
|
sock.send(b"VRFY echo\r\n")
|
||||||
result2 = s.getreply()
|
result2 = sock.recv(1024)
|
||||||
print(result2)
|
print(result2)
|
||||||
assert result[0] == result2[0] == 252
|
assert result[0:10] == result2[0:10] == b"252 2.0.0 "
|
||||||
assert result[1][0:6] == result2[1][0:6] == b"2.0.0 "
|
|
||||||
|
|||||||
@@ -143,7 +143,6 @@ def test_reject_missing_dkim(cmsetup, maildata, from_addr):
|
|||||||
"encrypted.eml", from_addr=from_addr, to_addr=recipient.addr
|
"encrypted.eml", from_addr=from_addr, to_addr=recipient.addr
|
||||||
).as_string()
|
).as_string()
|
||||||
conn = smtplib.SMTP(cmsetup.maildomain, 25, timeout=10)
|
conn = smtplib.SMTP(cmsetup.maildomain, 25, timeout=10)
|
||||||
conn.starttls()
|
|
||||||
|
|
||||||
with conn as s:
|
with conn as s:
|
||||||
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
|
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
|
||||||
|
|||||||
Reference in New Issue
Block a user