Compare commits

..

32 Commits

Author SHA1 Message Date
missytake
4b9b0f5f44 lint: fix issues 2024-01-18 17:26:24 +01:00
link2xt
10c671ebda Reject on DKIM PERMFAIL and SPF PERMFAIL as well 2024-01-18 17:24:36 +01:00
link2xt
f2be32ac6f Fixup rspamd disabled.conf deployment message 2024-01-18 17:24:36 +01:00
link2xt
b702848c33 Replace rspamd rule weights with a strict rule 2024-01-18 17:24:36 +01:00
link2xt
a6f2f74520 Remove unused _configure_opendkim 2024-01-18 17:24:36 +01:00
link2xt
01ec341364 Disable ratelimit module like other modules 2024-01-18 17:24:36 +01:00
link2xt
998799fe3f Do not return anything from remove_opendkim() 2024-01-18 17:24:36 +01:00
link2xt
6186dc5259 Actually disable phising, rbl and hfilter 2024-01-18 17:24:36 +01:00
missytake
5880133b5b rspamd: remove redis (not needed) 2024-01-18 17:24:36 +01:00
missytake
6772bfe630 lint fixes, final touch 2024-01-18 17:24:36 +01:00
missytake
101c3a6b47 rspamd: reject emails with invalid SPF, DKIM, DMARC 2024-01-18 17:24:36 +01:00
missytake
5ef2100765 tests: use generic recipient for DKIM testing 2024-01-18 17:24:36 +01:00
missytake
d49aae365c revert "Significantly lower ratelimit" 2024-01-18 17:24:36 +01:00
missytake
998a185332 rspamd: generate DKIM keys with rspamadm 2024-01-18 17:24:36 +01:00
missytake
3e78555ca1 rspamd: install rspamd + redis 2024-01-18 17:24:36 +01:00
missytake
01cfd0be19 tests: add test for rejecting SPF & DMARC fails 2024-01-18 17:24:36 +01:00
missytake
1bdc547479 lint: fix 3 issues 2024-01-18 17:24:36 +01:00
missytake
c0b8ba816d rspamd: Significantly lower ratelimit; without read receipts this should be more than fine 2024-01-18 17:24:36 +01:00
missytake
118ae49674 rspamd: add redis-server for caching 2024-01-18 17:24:36 +01:00
missytake
a47df20e22 rspamd: disable RBL checks 2024-01-18 17:24:36 +01:00
missytake
a1d8881887 rspamd: add rate limiting 2024-01-18 17:24:36 +01:00
missytake
cd7416a0dd disable some unnecessary rspamd modules 2024-01-18 17:24:36 +01:00
missytake
173e3f6390 do DKIM signing with rspamd instead of openDKIM 2024-01-18 17:24:36 +01:00
missytake
b8d53242cf DNS: added www subdomain to zonefile 2024-01-18 17:24:36 +01:00
link2xt
c65f618fb1 nginx: redirect www. to non-www 2024-01-18 17:24:36 +01:00
link2xt
42afad0852 Fix indentation in nginx.conf.j2 2024-01-18 17:24:36 +01:00
link2xt
8bc19439a9 dns: require www. subdomain and request TLS certificate for it 2024-01-18 17:24:36 +01:00
link2xt
cdaddb9b0f dns: check mta-sts CNAME directly without resolving to IP 2024-01-18 17:24:36 +01:00
missytake
768bf2b22c greeterbot: better comparison method
Co-authored-by: holger krekel  <holger@merlinux.eu>
2024-01-18 17:09:23 +01:00
missytake
185e6f7d2a greeterbot: address hpk's comments 2024-01-13 17:37:22 +01:00
missytake
90e7169eef lint: fix issues 2024-01-12 16:24:45 +01:00
missytake
3db7933d8b greeterbot: port to chatmail 2024-01-12 16:20:39 +01:00
45 changed files with 413 additions and 905 deletions

View File

@@ -1,20 +0,0 @@
;; Zone file for staging.testrun.org
$ORIGIN staging.testrun.org.
$TTL 300
@ IN SOA ns.testrun.org. root.nine.testrun.org (
2023010101 ; Serial
7200 ; Refresh
3600 ; Retry
1209600 ; Expire
3600 ; Negative response caching TTL
)
;; Nameservers.
@ IN NS ns.testrun.org.
;; DNS records.
@ IN A 37.27.37.98
mta-sts.staging.testrun.org. CNAME staging.testrun.org.
www.staging.testrun.org. CNAME staging.testrun.org.

View File

@@ -1,72 +0,0 @@
name: deploy on staging.testrun.org, and run tests
on:
push:
branches:
- main
- staging-ci
jobs:
deploy:
name: deploy on staging.testrun.org, and run tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: prepare SSH
run: |
mkdir ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan staging.testrun.org > ~/.ssh/known_hosts
# rsync -avz root@staging.testrun.org:/var/lib/acme . || true
# rsync -avz root@staging.testrun.org:/var/lib/rspamd/dkim . || true
#- name: rebuild staging.testrun.org to have a clean VPS
# run: |
# curl -X POST \
# -H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
# -H "Content-Type: application/json" \
# -d '{"image":"debian-12"}' \
# "https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_SERVER_ID }}/actions/rebuild"
- run: scripts/initenv.sh
- name: append venv/bin to PATH
run: echo venv/bin >>$GITHUB_PATH
- name: run formatting checks
run: cmdeploy fmt -v
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
#- name: upload TLS cert after rebuilding
# run: |
# echo " --- wait until staging.testrun.org VPS is rebuilt --- "
# rm ~/.ssh/known_hosts
# while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org id -u ; do sleep 1 ; done
# ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org id -u
# rsync -avz acme root@staging.testrun.org:/var/lib/ || true
# rsync -avz dkim root@staging.testrun.org:/var/lib/rspamd/ || true
- run: cmdeploy init staging.testrun.org
- run: cmdeploy run
- name: set DNS entries
run: |
#ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org chown _rspamd:_rspamd -R /var/lib/rspamd/dkim
cmdeploy dns --zonefile staging-generated.zone
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
cat .github/workflows/staging.testrun.org-default.zone
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging.testrun.org /etc/nsd/staging.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test
run: CHATMAIL_DOMAIN2=nine.testrun.org cmdeploy test --slow
- name: cmdeploy dns (try 3 times)
run: cmdeploy dns || cmdeploy dns || cmdeploy dns

View File

@@ -10,7 +10,10 @@ dependencies = [
"iniconfig", "iniconfig",
"deltachat-rpc-server", "deltachat-rpc-server",
"deltachat-rpc-client", "deltachat-rpc-client",
"requests", "ConfigArgParse",
"deltachat",
"setuptools>=60",
"setuptools-scm>=8",
] ]
[tool.setuptools] [tool.setuptools]
@@ -21,9 +24,9 @@ where = ['src']
[project.scripts] [project.scripts]
doveauth = "chatmaild.doveauth:main" doveauth = "chatmaild.doveauth:main"
chatmail-metadata = "chatmaild.metadata:main"
filtermail = "chatmaild.filtermail:main" filtermail = "chatmaild.filtermail:main"
echobot = "chatmaild.echo:main" echobot = "chatmaild.echo:main"
greeterbot = "chatmaild.greeterbot:main"
chatmail-metrics = "chatmaild.metrics:main" chatmail-metrics = "chatmaild.metrics:main"
[project.entry-points.pytest11] [project.entry-points.pytest11]

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -1,10 +0,0 @@
[Unit]
Description=Chatmail dict proxy for IMAP METADATA
[Service]
ExecStart={execpath} /run/dovecot/metadata.socket vmail {config_path}
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -46,11 +46,17 @@ class Connection:
) )
return result return result
def get_user_list(self) -> set[str]:
"""Get a set of all users."""
q = "SELECT addr from users"
return set([tup[0] for tup in self._sqlconn.execute(q).fetchall()])
class Database: class Database:
def __init__(self, path: str): def __init__(self, path: str, read_only=False):
self.path = Path(path) self.path = Path(path)
self.ensure_tables() if not read_only:
self.ensure_tables()
def _get_connection( def _get_connection(
self, write=False, transaction=False, closing=False self, write=False, transaction=False, closing=False

View File

@@ -46,7 +46,7 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
len(localpart) > config.username_max_length len(localpart) > config.username_max_length
or len(localpart) < config.username_min_length or len(localpart) < config.username_min_length
): ):
if localpart != "echo": if localpart not in ("echo", "hello"):
logging.warning( logging.warning(
"localpart %s has to be between %s and %s chars long", "localpart %s has to be between %s and %s chars long",
localpart, localpart,
@@ -58,18 +58,17 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
return True return True
def get_user_data(db, config: Config, user): def get_user_data(db, user):
with db.read_connection() as conn: with db.read_connection() as conn:
result = conn.get_user(user) result = conn.get_user(user)
if result: if result:
result["home"] = f"/home/vmail/mail/{config.mail_domain}/{user}"
result["uid"] = "vmail" result["uid"] = "vmail"
result["gid"] = "vmail" result["gid"] = "vmail"
return result return result
def lookup_userdb(db, config: Config, user): def lookup_userdb(db, user):
return get_user_data(db, config, user) return get_user_data(db, user)
def lookup_passdb(db, config: Config, user, cleartext_password): def lookup_passdb(db, config: Config, user, cleartext_password):
@@ -81,7 +80,6 @@ def lookup_passdb(db, config: Config, user, cleartext_password):
"UPDATE users SET last_login=? WHERE addr=?", (int(time.time()), user) "UPDATE users SET last_login=? WHERE addr=?", (int(time.time()), user)
) )
userdata["home"] = f"/home/vmail/mail/{config.mail_domain}/{user}"
userdata["uid"] = "vmail" userdata["uid"] = "vmail"
userdata["gid"] = "vmail" userdata["gid"] = "vmail"
return userdata return userdata
@@ -144,7 +142,7 @@ def handle_dovecot_request(msg, db, config: Config):
if type == "userdb": if type == "userdb":
user = args[0] user = args[0]
if user.endswith(f"@{config.mail_domain}"): if user.endswith(f"@{config.mail_domain}"):
res = lookup_userdb(db, config, user) res = lookup_userdb(db, user)
if res: if res:
reply_command = "O" reply_command = "O"
else: else:
@@ -162,19 +160,6 @@ def handle_dovecot_request(msg, db, config: Config):
return None return None
def handle_dovecot_protocol(rfile, wfile, db: Database, config: Config):
while True:
msg = rfile.readline().strip().decode()
if not msg:
break
res = handle_dovecot_request(msg, db, config)
if res:
wfile.write(res.encode("ascii"))
wfile.flush()
else:
logging.warning("request had no answer: %r", msg)
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer): class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
request_queue_size = 100 request_queue_size = 100
@@ -188,7 +173,16 @@ def main():
class Handler(StreamRequestHandler): class Handler(StreamRequestHandler):
def handle(self): def handle(self):
try: try:
handle_dovecot_protocol(self.rfile, self.wfile, db, config) while True:
msg = self.rfile.readline().strip().decode()
if not msg:
break
res = handle_dovecot_request(msg, db, config)
if res:
self.wfile.write(res.encode("ascii"))
self.wfile.flush()
else:
logging.warn("request had no answer: %r", msg)
except Exception: except Exception:
logging.exception("Exception in the handler") logging.exception("Exception in the handler")
raise raise

Binary file not shown.

View File

@@ -0,0 +1,132 @@
import time
import deltachat
from deltachat.tracker import ConfigureFailed
from time import sleep
import tempfile
import os
import configargparse
import pkg_resources
import secrets
from chatmaild.database import Database
from chatmaild.config import read_config
from chatmaild.newemail import ALPHANUMERIC_PUNCT, CONFIG_PATH
PASSDB_PATH = "/home/vmail/passdb.sqlite"
def setup_account(data_dir: str, debug: bool) -> deltachat.Account:
"""Create a deltachat account with a given addr/password combination.
:param data_dir: the directory where the data(base) is stored.
:param debug: whether to show log messages for the account.
:return: the deltachat account object.
"""
chatmail_config = read_config(CONFIG_PATH)
addr = "hello@" + chatmail_config.mail_domain
try:
os.mkdir(os.path.join(data_dir, addr))
except FileExistsError:
pass
db_path = os.path.join(data_dir, addr, "db.sqlite")
ac = deltachat.Account(db_path)
if debug:
ac.add_account_plugin(deltachat.events.FFIEventLogger(ac))
ac.set_config("mvbox_move", "0")
ac.set_config("sentbox_watch", "0")
ac.set_config("bot", "1")
ac.set_config("mdns_enabled", "0")
if not ac.is_configured():
cleartext_password = "".join(
secrets.choice(ALPHANUMERIC_PUNCT)
for _ in range(chatmail_config.password_min_length + 3)
)
ac.set_config("mail_pw", cleartext_password)
ac.set_config("addr", addr)
configtracker = ac.configure()
try:
configtracker.wait_finish()
except ConfigureFailed:
print(
"configuration setup failed for %s with password:\n%s"
% (ac.get_config("addr"), ac.get_config("mail_pw"))
)
raise
ac.start_io()
avatar = pkg_resources.resource_filename(__name__, "avatar.jpg")
ac.set_avatar(avatar)
ac.set_config("displayname", f"Hello at {chatmail_config.mail_domain}!")
return ac
class GreetBot:
def __init__(self, passdb, account):
self.db = Database(passdb, read_only=True)
self.account = account
self.domain = account.get_config("addr").split("@")[1]
with self.db.read_connection() as conn:
self.existing_users = conn.get_user_list()
def greet_users(self):
with self.db.read_connection() as conn:
users = conn.get_user_list()
new_users = users.difference(self.existing_users)
self.existing_users = users
time.sleep(20) # wait until Delta is configured on the user side
for user in new_users:
for ci_prefix in ["ac1_", "ac2_", "ac3_", "ac4_", "ac5_", "ci-"]:
if user.startswith(ci_prefix):
continue
if user not in [c.addr for c in self.account.get_contacts()]:
print("Inviting", user)
contact = self.account.create_contact(user)
chat = contact.create_chat()
chat.send_text(
"Welcome to %s! Here you can try out Delta Chat." % (self.domain,)
)
chat.send_text(
"I prepared some webxdc apps for you, if you are interested:"
)
chat.send_file(pkg_resources.resource_filename(__name__, "editor.xdc"))
chat.send_file(
pkg_resources.resource_filename(__name__, "tower-builder.xdc")
)
chat.send_text(
"You can visit https://webxdc.org/apps to discover more apps! "
"Some of these games you can also play with friends, directly in the chat."
)
def main():
args = configargparse.ArgumentParser()
args.add_argument("--db_path", help="location of the Delta Chat database")
args.add_argument(
"--passdb", default=PASSDB_PATH, help="location of the chatmail passdb"
)
args.add_argument("--show-ffi", action="store_true", help="print Delta Chat log")
ops = args.parse_args()
# ensuring account data directory
if ops.db_path is None:
tempdir = tempfile.TemporaryDirectory(prefix="hellobot")
ops.db_path = tempdir.name
elif not os.path.exists(ops.db_path):
os.mkdir(ops.db_path)
ac = setup_account(ops.db_path, ops.show_ffi)
greeter = GreetBot(ops.passdb, ac)
print("waiting for new chatmail users...")
while 1:
greeter.greet_users()
sleep(5)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Chatmail greeterbot, a Delta Chat bot to greet new users
[Service]
ExecStart={execpath} --passdb {passdb_path} --db_path /home/vmail/greeterbot/ --show-ffi
User=vmail
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -33,7 +33,7 @@ password_min_length = 9
passthrough_senders = passthrough_senders =
# list of e-mail recipients for which to accept outbound un-encrypted mails # list of e-mail recipients for which to accept outbound un-encrypted mails
passthrough_recipients = xstore@testrun.org groupsbot@hispanilandia.net passthrough_recipients =
# #
# Deployment Details # Deployment Details

View File

@@ -1,7 +1,7 @@
[privacy] [privacy]
passthrough_recipients = privacy@testrun.org xstore@testrun.org groupsbot@hispanilandia.net passthrough_recipients = privacy@testrun.org
privacy_postal = privacy_postal =
Merlinux GmbH, Represented by the managing director H. Krekel, Merlinux GmbH, Represented by the managing director H. Krekel,

View File

@@ -1,149 +0,0 @@
import pwd
from queue import Queue
from threading import Thread
from socketserver import (
UnixStreamServer,
StreamRequestHandler,
ThreadingMixIn,
)
from .config import read_config
import sys
import logging
import os
import requests
DICTPROXY_LOOKUP_CHAR = "L"
DICTPROXY_SET_CHAR = "S"
DICTPROXY_BEGIN_TRANSACTION_CHAR = "B"
DICTPROXY_COMMIT_TRANSACTION_CHAR = "C"
DICTPROXY_TRANSACTION_CHARS = "SBC"
class Notifier:
def __init__(self):
self.guid2token = {}
self.to_notify_queue = Queue()
def set_token(self, guid, token):
self.guid2token[guid] = token
def new_message_for_guid(self, guid):
self.to_notify_queue.put(guid)
def thread_run_loop(self):
requests_session = requests.Session()
while 1:
self.thread_run_one(requests_session)
def thread_run_one(self, requests_session):
guid = self.to_notify_queue.get()
token = self.guid2token.get(guid)
if token:
response = requests_session.post(
"https://notifications.delta.chat/notify",
data=token,
timeout=60,
)
if response.status_code == 410:
# 410 Gone status code
# means the token is no longer valid.
del self.guid2token[guid]
def handle_dovecot_protocol(rfile, wfile, notifier):
# HELLO message, ignored.
msg = rfile.readline().strip().decode()
transactions = {}
while True:
msg = rfile.readline().strip().decode()
if not msg:
break
res = handle_dovecot_request(msg, transactions, notifier)
if res:
wfile.write(res.encode("ascii"))
wfile.flush()
def handle_dovecot_request(msg, transactions, notifier):
# see https://doc.dovecot.org/3.0/developer_manual/design/dict_protocol/
short_command = msg[0]
parts = msg[1:].split("\t")
if short_command == DICTPROXY_LOOKUP_CHAR:
return "N\n"
if short_command not in (DICTPROXY_TRANSACTION_CHARS):
return
transaction_id = parts[0]
if short_command == DICTPROXY_BEGIN_TRANSACTION_CHAR:
transactions[transaction_id] = "O\n"
elif short_command == DICTPROXY_COMMIT_TRANSACTION_CHAR:
# returns whether it failed or succeeded.
return transactions.pop(transaction_id, "N\n")
elif short_command == DICTPROXY_SET_CHAR:
# See header of
# <https://github.com/dovecot/core/blob/5e7965632395793d9355eb906b173bf28d2a10ca/src/lib-storage/mailbox-attribute.h>
# for the documentation on the structure of the key.
# Request GETMETADATA "INBOX" /private/chatmail
# results in a query for
# priv/dd72550f05eadc65542a1200cac67ad7/chatmail
#
# Request GETMETADATA "" /private/chatmail
# results in
# priv/dd72550f05eadc65542a1200cac67ad7/vendor/vendor.dovecot/pvt/server/chatmail
keyname = parts[1].split("/")
value = parts[2] if len(parts) > 2 else ""
if keyname[0] == "priv" and keyname[2] == "devicetoken":
notifier.set_token(keyname[1], value)
elif keyname[0] == "priv" and keyname[2] == "messagenew":
notifier.new_message_for_guid(keyname[1])
else:
# Transaction failed.
transactions[transaction_id] = "F\n"
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
request_queue_size = 100
def main():
socket, username, config = sys.argv[1:]
passwd_entry = pwd.getpwnam(username)
# XXX config is not currently used
config = read_config(config)
notifier = Notifier()
class Handler(StreamRequestHandler):
def handle(self):
try:
handle_dovecot_protocol(self.rfile, self.wfile, notifier)
except Exception:
logging.exception("Exception in the handler")
raise
try:
os.unlink(socket)
except FileNotFoundError:
pass
# start notifier thread for signalling new messages to
# Delta Chat notification server
t = Thread(target=notifier.thread_run_loop)
t.setDaemon(True)
t.start()
with ThreadedUnixStreamServer(socket, Handler) as server:
os.chown(socket, uid=passwd_entry.pw_uid, gid=passwd_entry.pw_gid)
try:
server.serve_forever()
except KeyboardInterrupt:
pass

View File

@@ -1,6 +1,4 @@
import random import random
from pathlib import Path
import os
import importlib.resources import importlib.resources
import itertools import itertools
from email.parser import BytesParser from email.parser import BytesParser
@@ -59,12 +57,7 @@ def db(tmpdir):
@pytest.fixture @pytest.fixture
def maildata(request): def maildata(request):
try: datadir = importlib.resources.files(__package__).joinpath("mail-data")
datadir = importlib.resources.files(__package__).joinpath("mail-data")
except TypeError:
# in python3.9 or lower, the above doesn't work, so we get datadir this way:
datadir = Path(os.getcwd()).joinpath("chatmaild/src/chatmaild/tests/mail-data")
assert datadir.exists(), datadir assert datadir.exists(), datadir
def maildata(name, from_addr, to_addr): def maildata(name, from_addr, to_addr):

View File

@@ -28,5 +28,5 @@ def test_read_config_testrun(make_config):
assert config.username_min_length == 9 assert config.username_min_length == 9
assert config.username_max_length == 9 assert config.username_max_length == 9
assert config.password_min_length == 9 assert config.password_min_length == 9
assert "privacy@testrun.org" in config.passthrough_recipients assert config.passthrough_recipients == ["privacy@testrun.org"]
assert config.passthrough_senders == [] assert config.passthrough_senders == []

View File

@@ -1,23 +1,17 @@
import io
import json import json
import pytest import pytest
import queue
import threading import threading
import queue
import traceback import traceback
import chatmaild.doveauth import chatmaild.doveauth
from chatmaild.doveauth import ( from chatmaild.doveauth import get_user_data, lookup_passdb, handle_dovecot_request
get_user_data,
lookup_passdb,
handle_dovecot_request,
handle_dovecot_protocol,
)
from chatmaild.database import DBError from chatmaild.database import DBError
def test_basic(db, example_config): def test_basic(db, example_config):
lookup_passdb(db, example_config, "asdf12345@chat.example.org", "q9mr3faue") lookup_passdb(db, example_config, "asdf12345@chat.example.org", "q9mr3faue")
data = get_user_data(db, example_config, "asdf12345@chat.example.org") data = get_user_data(db, "asdf12345@chat.example.org")
assert data assert data
data2 = lookup_passdb( data2 = lookup_passdb(
db, example_config, "asdf12345@chat.example.org", "q9mr3jewvadsfaue" db, example_config, "asdf12345@chat.example.org", "q9mr3jewvadsfaue"
@@ -43,7 +37,7 @@ def test_nocreate_file(db, monkeypatch, tmpdir, example_config):
lookup_passdb( lookup_passdb(
db, example_config, "newuser12@chat.example.org", "zequ0Aimuchoodaechik" db, example_config, "newuser12@chat.example.org", "zequ0Aimuchoodaechik"
) )
assert not get_user_data(db, example_config, "newuser12@chat.example.org") assert not get_user_data(db, "newuser12@chat.example.org")
def test_db_version(db): def test_db_version(db):
@@ -75,15 +69,6 @@ def test_handle_dovecot_request(db, example_config):
assert userdata["password"].startswith("{SHA512-CRYPT}") assert userdata["password"].startswith("{SHA512-CRYPT}")
def test_handle_dovecot_protocol(db, example_config):
rfile = io.BytesIO(
b"H3\t2\t0\t\tauth\nLshared/userdb/foobar@chat.example.org\tfoobar@chat.example.org\n"
)
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, db, example_config)
assert wfile.getvalue() == b"N\n"
def test_50_concurrent_lookups_different_accounts(db, gencreds, example_config): def test_50_concurrent_lookups_different_accounts(db, gencreds, example_config):
num_threads = 50 num_threads = 50
req_per_thread = 5 req_per_thread = 5

View File

@@ -1,132 +0,0 @@
import io
from chatmaild.metadata import (
handle_dovecot_request,
handle_dovecot_protocol,
Notifier,
)
def test_handle_dovecot_request_lookup_fails():
notifier = Notifier()
res = handle_dovecot_request("Lpriv/123/chatmail", {}, notifier)
assert res == "N\n"
def test_handle_dovecot_request_happy_path():
notifier = Notifier()
transactions = {}
# lookups return the same NOTFOUND result
res = handle_dovecot_request("Lpriv/123/chatmail", transactions, notifier)
assert res == "N\n"
assert not notifier.guid2token and not transactions
# set device token in a transaction
tx = "1111"
msg = f"B{tx}\tuser"
res = handle_dovecot_request(msg, transactions, notifier)
assert not res and not notifier.guid2token
assert transactions == {tx: "O\n"}
msg = f"S{tx}\tpriv/guid00/devicetoken\t01234"
res = handle_dovecot_request(msg, transactions, notifier)
assert not res
assert len(transactions) == 1
assert len(notifier.guid2token) == 1
assert notifier.guid2token["guid00"] == "01234"
msg = f"C{tx}"
res = handle_dovecot_request(msg, transactions, notifier)
assert res == "O\n"
assert len(transactions) == 0
assert notifier.guid2token["guid00"] == "01234"
# trigger notification for incoming message
assert handle_dovecot_request(f"B{tx}\tuser", transactions, notifier) is None
msg = f"S{tx}\tpriv/guid00/messagenew"
assert handle_dovecot_request(msg, transactions, notifier) is None
assert notifier.to_notify_queue.get() == "guid00"
assert notifier.to_notify_queue.qsize() == 0
assert handle_dovecot_request(f"C{tx}\tuser", transactions, notifier) == "O\n"
assert not transactions
def test_handle_dovecot_protocol_set_devicetoken():
rfile = io.BytesIO(
b"\n".join(
[
b"HELLO",
b"Btx00\tuser",
b"Stx00\tpriv/guid00/devicetoken\t01234",
b"Ctx00",
]
)
)
wfile = io.BytesIO()
notifier = Notifier()
handle_dovecot_protocol(rfile, wfile, notifier)
assert notifier.guid2token["guid00"] == "01234"
assert wfile.getvalue() == b"O\n"
def test_handle_dovecot_protocol_messagenew():
rfile = io.BytesIO(
b"\n".join(
[
b"HELLO",
b"Btx01\tuser",
b"Stx01\tpriv/guid00/messagenew",
b"Ctx01",
]
)
)
wfile = io.BytesIO()
notifier = Notifier()
handle_dovecot_protocol(rfile, wfile, notifier)
assert wfile.getvalue() == b"O\n"
assert notifier.to_notify_queue.get() == "guid00"
assert notifier.to_notify_queue.qsize() == 0
def test_notifier_thread_run():
requests = []
class ReqMock:
def post(self, url, data, timeout):
requests.append((url, data, timeout))
class Result:
status_code = 200
return Result()
notifier = Notifier()
notifier.set_token("guid00", "01234")
notifier.new_message_for_guid("guid00")
notifier.thread_run_one(ReqMock())
url, data, timeout = requests[0]
assert data == "01234"
assert len(notifier.guid2token) == 1
def test_notifier_thread_run_gone_removes_token():
requests = []
class ReqMock:
def post(self, url, data, timeout):
requests.append((url, data, timeout))
class Result:
status_code = 410
return Result()
notifier = Notifier()
notifier.set_token("guid00", "01234")
notifier.new_message_for_guid("guid00")
assert notifier.guid2token["guid00"] == "01234"
notifier.thread_run_one(ReqMock())
url, data, timeout = requests[0]
assert data == "01234"
assert len(notifier.guid2token) == 0

Binary file not shown.

View File

@@ -1,7 +1,6 @@
""" """
Chat Mail pyinfra deploy. Chat Mail pyinfra deploy.
""" """
import sys import sys
import importlib.resources import importlib.resources
import subprocess import subprocess
@@ -102,12 +101,13 @@ def _install_remote_venv_with_chatmaild(config) -> None:
"doveauth", "doveauth",
"filtermail", "filtermail",
"echobot", "echobot",
"chatmail-metadata", "greeterbot",
): ):
params = dict( params = dict(
execpath=f"{remote_venv_dir}/bin/{fn}", 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,
passdb_path="/home/vmail/passdb.sqlite",
) )
source_path = importlib.resources.files("chatmaild").joinpath(f"{fn}.service.f") source_path = importlib.resources.files("chatmaild").joinpath(f"{fn}.service.f")
content = source_path.read_text().format(**params).encode() content = source_path.read_text().format(**params).encode()
@@ -128,107 +128,6 @@ def _install_remote_venv_with_chatmaild(config) -> None:
) )
def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
"""Configures OpenDKIM"""
need_restart = False
server.group(name="Create opendkim group", group="opendkim", system=True)
server.user(
name="Create opendkim user",
user="opendkim",
groups=["opendkim"],
system=True,
)
server.user(
name="Add postfix user to opendkim group for socket access",
user="postfix",
groups=["opendkim"],
system=True,
)
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("opendkim/opendkim.conf"),
dest="/etc/opendkim.conf",
user="root",
group="root",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= main_config.changed
screen_script = files.put(
src=importlib.resources.files(__package__).joinpath("opendkim/screen.lua"),
dest="/etc/opendkim/screen.lua",
user="root",
group="root",
mode="644",
)
need_restart |= screen_script.changed
final_script = files.put(
src=importlib.resources.files(__package__).joinpath("opendkim/final.lua"),
dest="/etc/opendkim/final.lua",
user="root",
group="root",
mode="644",
)
need_restart |= final_script.changed
files.directory(
name="Add opendkim directory to /etc",
path="/etc/opendkim",
user="opendkim",
group="opendkim",
mode="750",
present=True,
)
keytable = files.template(
src=importlib.resources.files(__package__).joinpath("opendkim/KeyTable"),
dest="/etc/dkimkeys/KeyTable",
user="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= keytable.changed
signing_table = files.template(
src=importlib.resources.files(__package__).joinpath("opendkim/SigningTable"),
dest="/etc/dkimkeys/SigningTable",
user="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= signing_table.changed
files.directory(
name="Add opendkim socket directory to /var/spool/postfix",
path="/var/spool/postfix/opendkim",
user="opendkim",
group="opendkim",
mode="750",
present=True,
)
apt.packages(
name="apt install opendkim opendkim-tools",
packages=["opendkim", "opendkim-tools"],
)
if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"):
server.shell(
name="Generate OpenDKIM domain keys",
commands=[
f"opendkim-genkey -D /etc/dkimkeys -d {domain} -s {dkim_selector}"
],
_sudo=True,
_sudo_user="opendkim",
)
return need_restart
def _install_mta_sts_daemon() -> bool: def _install_mta_sts_daemon() -> bool:
need_restart = False need_restart = False
@@ -303,16 +202,6 @@ def _configure_postfix(config: Config, debug: bool = False) -> bool:
) )
need_restart |= header_cleanup.changed need_restart |= header_cleanup.changed
# Login map that 1:1 maps email address to login.
login_map = files.put(
src=importlib.resources.files(__package__).joinpath("postfix/login_map"),
dest="/etc/postfix/login_map",
user="root",
group="root",
mode="644",
)
need_restart |= login_map.changed
return need_restart return need_restart
@@ -338,27 +227,6 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
mode="644", mode="644",
) )
need_restart |= auth_config.changed need_restart |= auth_config.changed
lua_push_notification_script = files.put(
src=importlib.resources.files(__package__).joinpath(
"dovecot/push_notification.lua"
),
dest="/etc/dovecot/push_notification.lua",
user="root",
group="root",
mode="644",
)
need_restart |= lua_push_notification_script.changed
sieve_script = files.put(
src=importlib.resources.files(__package__).joinpath(
"dovecot/default.sieve"
),
dest="/etc/dovecot/default.sieve",
user="root",
group="root",
mode="644",
)
need_restart |= sieve_script.changed
files.template( files.template(
src=importlib.resources.files(__package__).joinpath("dovecot/expunge.cron.j2"), src=importlib.resources.files(__package__).joinpath("dovecot/expunge.cron.j2"),
@@ -439,18 +307,113 @@ def _configure_nginx(domain: str, debug: bool = False) -> bool:
return need_restart return need_restart
def _remove_rspamd() -> None: def remove_opendkim() -> None:
"""Remove rspamd""" """Remove OpenDKIM, deprecated"""
apt.packages(name="Remove rspamd", packages="rspamd", present=False) files.file(
name="Remove legacy opendkim.conf",
path="/etc/opendkim.conf",
present=False,
)
files.directory(
name="Remove legacy opendkim socket directory from /var/spool/postfix",
path="/var/spool/postfix/opendkim",
present=False,
)
apt.packages(name="Remove openDKIM", packages="opendkim", present=False)
def _configure_rspamd(dkim_selector: str, mail_domain: str) -> bool:
"""Configures rspamd for Rate Limiting."""
need_restart = False
apt.packages(
name="apt install rspamd",
packages="rspamd",
)
for module in ["phishing", "rbl", "hfilter", "ratelimit"]:
disabled_module_conf = files.put(
name=f"disable {module} rspamd plugin",
src=importlib.resources.files(__package__).joinpath("rspamd/disabled.conf"),
dest=f"/etc/rspamd/local.d/{module}.conf",
user="root",
group="root",
mode="644",
)
need_restart |= disabled_module_conf.changed
options_inc = files.put(
name="disable fuzzy checks",
src=importlib.resources.files(__package__).joinpath("rspamd/options.inc"),
dest="/etc/rspamd/local.d/options.inc",
user="root",
group="root",
mode="644",
)
need_restart |= options_inc.changed
# https://rspamd.com/doc/modules/force_actions.html
force_actions_conf = files.put(
name="Set up rules to reject on DKIM, SPF and DMARC fails",
src=importlib.resources.files(__package__).joinpath(
"rspamd/force_actions.conf"
),
dest="/etc/rspamd/local.d/force_actions.conf",
user="root",
group="root",
mode="644",
)
need_restart |= force_actions_conf.changed
dkim_directory = "/var/lib/rspamd/dkim/"
dkim_key_path = f"{dkim_directory}{mail_domain}.{dkim_selector}.key"
dkim_dns_file = f"{dkim_directory}{mail_domain}.{dkim_selector}.zone"
dkim_config = files.template(
src=importlib.resources.files(__package__).joinpath(
"rspamd/dkim_signing.conf.j2"
),
dest="/etc/rspamd/local.d/dkim_signing.conf",
user="root",
group="root",
mode="644",
config={
"dkim_selector": str(dkim_selector),
"mail_domain": mail_domain,
"dkim_key_path": dkim_key_path,
},
)
need_restart |= dkim_config.changed
files.directory(
name="ensure DKIM key directory exists",
path=dkim_directory,
present=True,
user="_rspamd",
group="_rspamd",
)
if not host.get_fact(File, dkim_key_path):
server.shell(
name="Generate DKIM domain keys with rspamd",
commands=[
f"rspamadm dkim_keygen -b 2048 -s {dkim_selector} -d {mail_domain} -k {dkim_key_path} > {dkim_dns_file}"
],
_sudo=True,
_sudo_user="_rspamd",
)
return need_restart
def check_config(config): def check_config(config):
mail_domain = config.mail_domain mail_domain = config.mail_domain
if mail_domain != "testrun.org" and not mail_domain.endswith(".testrun.org"): if mail_domain != "testrun.org" and not mail_domain.endswith(".testrun.org"):
blocked_words = "merlinux schmieder testrun.org".split() blocked_words = "merlinux schmieder testrun.org".split()
for key in config.__dict__: for value in config.__dict__.values():
value = config.__dict__[key] if any(x in str(value) for x in blocked_words):
if key.startswith("privacy") and any(x in str(value) for x in blocked_words):
raise ValueError( raise ValueError(
f"please set your own privacy contacts/addresses in {config._inipath}" f"please set your own privacy contacts/addresses in {config._inipath}"
) )
@@ -481,10 +444,7 @@ def deploy_chatmail(config_path: Path) -> None:
) )
server.shell( server.shell(
name="Generate root keys for validating DNSSEC", name="Generate root keys for validating DNSSEC",
commands=[ commands=["unbound-anchor -a /var/lib/unbound/root.key || true"],
"unbound-anchor -a /var/lib/unbound/root.key || true",
"systemctl reset-failed unbound.service",
],
) )
systemd.service( systemd.service(
name="Start and enable unbound", name="Start and enable unbound",
@@ -506,7 +466,7 @@ def deploy_chatmail(config_path: Path) -> None:
apt.packages( apt.packages(
name="Install Dovecot", name="Install Dovecot",
packages=["dovecot-imapd", "dovecot-lmtpd", "dovecot-sieve"], packages=["dovecot-imapd", "dovecot-lmtpd"],
) )
apt.packages( apt.packages(
@@ -533,15 +493,15 @@ def deploy_chatmail(config_path: Path) -> None:
mta_sts_need_restart = _install_mta_sts_daemon() mta_sts_need_restart = _install_mta_sts_daemon()
nginx_need_restart = _configure_nginx(mail_domain) nginx_need_restart = _configure_nginx(mail_domain)
_remove_rspamd() remove_opendkim()
opendkim_need_restart = _configure_opendkim(mail_domain, "opendkim") rspamd_need_restart = _configure_rspamd("dkim", mail_domain)
systemd.service( systemd.service(
name="Start and enable OpenDKIM", name="Start and enable rspamd",
service="opendkim.service", service="rspamd.service",
running=True, running=True,
enabled=True, enabled=True,
restarted=opendkim_need_restart, restarted=rspamd_need_restart,
) )
systemd.service( systemd.service(

View File

@@ -7,9 +7,9 @@ _imap._tcp.{chatmail_domain}. SRV 0 1 143 {chatmail_domain}.
_imaps._tcp.{chatmail_domain}. SRV 0 1 993 {chatmail_domain}. _imaps._tcp.{chatmail_domain}. SRV 0 1 993 {chatmail_domain}.
{chatmail_domain}. CAA 128 issue "letsencrypt.org;accounturi={acme_account_url}" {chatmail_domain}. CAA 128 issue "letsencrypt.org;accounturi={acme_account_url}"
{chatmail_domain}. TXT "v=spf1 a:{chatmail_domain} -all" {chatmail_domain}. TXT "v=spf1 a:{chatmail_domain} -all"
_dmarc.{chatmail_domain}. TXT "v=DMARC1;p=reject;adkim=s;aspf=s" _dmarc.{chatmail_domain}. TXT "v=DMARC1;p=reject;rua=mailto:{email};ruf=mailto:{email};fo=1;adkim=s;aspf=s"
_mta-sts.{chatmail_domain}. TXT "v=STSv1; id={sts_id}" _mta-sts.{chatmail_domain}. TXT "v=STSv1; id={sts_id}"
mta-sts.{chatmail_domain}. CNAME {chatmail_domain}. mta-sts.{chatmail_domain}. CNAME {chatmail_domain}.
www.{chatmail_domain}. CNAME {chatmail_domain}. www.{chatmail_domain}. CNAME {chatmail_domain}.
_smtp._tls.{chatmail_domain}. TXT "v=TLSRPTv1;rua=mailto:{email}"
{dkim_entry} {dkim_entry}
_adsp._domainkey.{chatmail_domain}. TXT "dkim=discardable"

View File

@@ -2,7 +2,6 @@
Provides the `cmdeploy` entry point function, Provides the `cmdeploy` entry point function,
along with command line option and subcommand parsing. along with command line option and subcommand parsing.
""" """
import argparse import argparse
import shutil import shutil
import subprocess import subprocess
@@ -83,8 +82,7 @@ def dns_cmd_options(parser):
def dns_cmd(args, out): def dns_cmd(args, out):
"""Generate dns zone file.""" """Generate dns zone file."""
exit_code = show_dns(args, out) show_dns(args, out)
exit(exit_code)
def status_cmd(args, out): def status_cmd(args, out):

View File

@@ -5,8 +5,6 @@ import importlib
import subprocess import subprocess
import datetime import datetime
from typing import Optional
class DNS: class DNS:
def __init__(self, out, mail_domain): def __init__(self, out, mail_domain):
@@ -36,11 +34,12 @@ class DNS:
cmd = "ip a | grep inet6 | grep 'scope global' | sed -e 's#/64 scope global##' | sed -e 's#inet6##'" cmd = "ip a | grep inet6 | grep 'scope global' | sed -e 's#/64 scope global##' | sed -e 's#inet6##'"
return self.shell(cmd).strip() return self.shell(cmd).strip()
def get(self, typ: str, domain: str) -> str: def get(self, typ: str, domain: str) -> str | None:
"""Get a DNS entry or empty string if there is none.""" """Get a DNS entry"""
dig_result = self.shell(f"dig -r -q {domain} -t {typ} +short") dig_result = self.shell(f"dig -r -q {domain} -t {typ} +short")
line = dig_result.partition("\n")[0] line = dig_result.partition("\n")[0]
return line if line:
return line
def check_ptr_record(self, ip: str, mail_domain) -> bool: def check_ptr_record(self, ip: str, mail_domain) -> bool:
"""Check the PTR record for an IPv4 or IPv6 address.""" """Check the PTR record for an IPv4 or IPv6 address."""
@@ -48,32 +47,33 @@ class DNS:
return result == f"{mail_domain}." return result == f"{mail_domain}."
def show_dns(args, out) -> int: def show_dns(args, out):
"""Check existing DNS records, optionally write them to zone file, return exit code 0 or 1."""
template = importlib.resources.files(__package__).joinpath("chatmail.zone.f") template = importlib.resources.files(__package__).joinpath("chatmail.zone.f")
mail_domain = args.config.mail_domain mail_domain = args.config.mail_domain
ssh = f"ssh root@{mail_domain}" ssh = f"ssh root@{mail_domain}"
dns = DNS(out, mail_domain) dns = DNS(out, mail_domain)
def read_dkim_entries(entry):
lines = []
for line in entry.split("\n"):
if line.startswith(";") or not line.strip():
continue
line = line.replace("\t", " ")
lines.append(line)
lines[0] = f"dkim._domainkey.{mail_domain}. IN TXT " + lines[0].strip(
"dkim._domainkey IN TXT "
)
return "\n".join(lines)
print("Checking your DKIM keys and DNS entries...") print("Checking your DKIM keys and DNS entries...")
try: try:
acme_account_url = out.shell_output(f"{ssh} -- acmetool account-url") acme_account_url = out.shell_output(f"{ssh} -- acmetool account-url")
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
print("Please run `cmdeploy run` first.") print("Please run `cmdeploy run` first.")
return 1 return
dkim_entry = read_dkim_entries(
dkim_selector = "opendkim" out.shell_output(f"{ssh} -- cat /var/lib/rspamd/dkim/{mail_domain}.dkim.zone")
dkim_pubkey = out.shell_output(
ssh + f" -- openssl rsa -in /etc/dkimkeys/{dkim_selector}.private"
" -pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'"
) )
dkim_entry_value = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s"
dkim_entry_str = ""
while len(dkim_entry_value) >= 255:
dkim_entry_str += '"' + dkim_entry_value[:255] + '" '
dkim_entry_value = dkim_entry_value[255:]
dkim_entry_str += '"' + dkim_entry_value + '"'
dkim_entry = f"{dkim_selector}._domainkey.{mail_domain}. TXT {dkim_entry_str}"
ipv6 = dns.get_ipv6() ipv6 = dns.get_ipv6()
reverse_ipv6 = dns.check_ptr_record(ipv6, mail_domain) reverse_ipv6 = dns.check_ptr_record(ipv6, mail_domain)
@@ -86,6 +86,7 @@ def show_dns(args, out) -> int:
f.read() f.read()
.format( .format(
acme_account_url=acme_account_url, acme_account_url=acme_account_url,
email=f"root@{args.config.mail_domain}",
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"), sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain, chatmail_domain=args.config.mail_domain,
dkim_entry=dkim_entry, dkim_entry=dkim_entry,
@@ -98,12 +99,14 @@ def show_dns(args, out) -> int:
with open(args.zonefile, "w+") as zf: with open(args.zonefile, "w+") as zf:
zf.write(zonefile) zf.write(zonefile)
print(f"DNS records successfully written to: {args.zonefile}") print(f"DNS records successfully written to: {args.zonefile}")
return 0 return
except TypeError: except TypeError:
pass pass
started_dkim_parsing = False
for line in zonefile.splitlines(): for line in zonefile.splitlines():
line = line.format( line = line.format(
acme_account_url=acme_account_url, acme_account_url=acme_account_url,
email=f"root@{args.config.mail_domain}",
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"), sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain, chatmail_domain=args.config.mail_domain,
dkim_entry=dkim_entry, dkim_entry=dkim_entry,
@@ -127,25 +130,29 @@ def show_dns(args, out) -> int:
current = dns.get("SRV", domain[:-1]) current = dns.get("SRV", domain[:-1])
if current != f"{prio} {weight} {port} {value}": if current != f"{prio} {weight} {port} {value}":
to_print.append(line) to_print.append(line)
if " TXT " in line: if " TXT " in line:
domain, value = line.split(" TXT ") domain, value = line.split(" TXT ")
current = dns.get("TXT", domain.strip()[:-1]) current = dns.get("TXT", domain.strip()[:-1])
if domain.startswith("_mta-sts."): if domain.startswith("_mta-sts."):
if current: if current:
if current.split("id=")[0] == value.split("id=")[0]: if current.split("id=")[0] == value.split("id=")[0]:
continue continue
if current != value:
# TXT records longer than 255 bytes
# are split into multiple <character-string>s.
# This typically happens with DKIM record
# which contains long RSA key.
#
# Removing `" "` before comparison
# to get back a single string.
if current.replace('" "', "") != value.replace('" "', ""):
to_print.append(line) to_print.append(line)
if " IN TXT ( " in line:
started_dkim_parsing = True
dkim_lines = [line]
if started_dkim_parsing and line.startswith('"'):
dkim_lines.append(" " + line)
domain, data = "\n".join(dkim_lines).split(" IN TXT ")
current = dns.get("TXT", domain.strip()[:-1])
if current:
current = "( %s" % (current.replace('" "', '"\n "'))
if current != data:
to_print.append(dkim_entry)
else:
to_print.append(dkim_entry)
exit_code = 0
if to_print: if to_print:
to_print.insert( to_print.insert(
0, "You should configure the following DNS entries at your provider:\n" 0, "You should configure the following DNS entries at your provider:\n"
@@ -154,7 +161,6 @@ def show_dns(args, out) -> int:
"\nIf you already configured the DNS entries, wait a bit until the DNS entries propagate to the Internet." "\nIf you already configured the DNS entries, wait a bit until the DNS entries propagate to the Internet."
) )
print("\n".join(to_print)) print("\n".join(to_print))
exit_code = 1
else: else:
out.green("Great! All your DNS entries are correct.") out.green("Great! All your DNS entries are correct.")
@@ -174,8 +180,6 @@ def show_dns(args, out) -> int:
print( print(
"You can do so at your hosting provider (maybe this isn't your DNS provider)." "You can do so at your hosting provider (maybe this isn't your DNS provider)."
) )
exit_code = 1
return exit_code
def check_necessary_dns(out, mail_domain): def check_necessary_dns(out, mail_domain):

View File

@@ -1,5 +0,0 @@
require ["imap4flags"];
if header :is ["Auto-Submitted"] ["auto-replied", "auto-generated"] {
addflag "$Auto";
}

View File

@@ -13,15 +13,13 @@ auth_cache_size = 100M
mail_debug = yes mail_debug = yes
{% endif %} {% endif %}
mail_server_admin = mailto:root@{{ config.mail_domain }}
mail_server_comment = Chatmail server
mail_plugins = quota mail_plugins = quota
# these are the capabilities Delta Chat cares about actually # these are the capabilities Delta Chat cares about actually
# so let's keep the network overhead per login small # so let's keep the network overhead per login small
# https://github.com/deltachat/deltachat-core-rust/blob/master/src/imap/capabilities.rs # https://github.com/deltachat/deltachat-core-rust/blob/master/src/imap/capabilities.rs
imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE NOTIFY METADATA XDELTAPUSH imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE NOTIFY
# Authentication for system users. # Authentication for system users.
@@ -71,32 +69,14 @@ mail_privileged_group = vmail
## Mail processes ## Mail processes
## ##
# Pass all IMAP METADATA requests to the server implementing Dovecot's dict protocol.
mail_attribute_dict = proxy:/run/dovecot/metadata.socket:metadata
# Enable IMAP COMPRESS (RFC 4978). # Enable IMAP COMPRESS (RFC 4978).
# <https://datatracker.ietf.org/doc/html/rfc4978.html> # <https://datatracker.ietf.org/doc/html/rfc4978.html>
protocol imap { protocol imap {
mail_plugins = $mail_plugins imap_zlib imap_quota mail_plugins = $mail_plugins imap_zlib imap_quota
imap_metadata = yes
} }
protocol lmtp { protocol lmtp {
# quota plugin documentation: mail_plugins = $mail_plugins quota
# <https://doc.dovecot.org/configuration_manual/quota_plugin/>
#
# notify plugin is a dependency of push_notification plugin:
# <https://doc.dovecot.org/settings/plugin/notify-plugin/>
#
# push_notification plugin documentation:
# <https://doc.dovecot.org/configuration_manual/push_notification/>
#
# mail_lua and push_notification_lua are needed for Lua push notification handler.
# <https://doc.dovecot.org/configuration_manual/push_notification/#configuration>
#
# Sieve to mark messages that should not be notified as \Seen
# <https://doc.dovecot.org/configuration_manual/sieve/configuration/>
mail_plugins = $mail_plugins quota mail_lua notify push_notification push_notification_lua sieve
} }
plugin { plugin {
@@ -112,15 +92,7 @@ plugin {
# quota_over_flag_value = TRUE # quota_over_flag_value = TRUE
} }
# push_notification configuration
plugin {
# <https://doc.dovecot.org/configuration_manual/push_notification/#lua-lua>
push_notification_driver = lua:file=/etc/dovecot/push_notification.lua
}
plugin {
sieve_default = file:/etc/dovecot/default.sieve
}
service lmtp { service lmtp {
user=vmail user=vmail

View File

@@ -1,10 +1,10 @@
# delete all mails after {{ config.delete_mails_after }} days, in the Inbox # delete all mails after {{ config.delete_mails_after }} days, in the Inbox
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete 2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/cur -mtime +{{ config.delete_mails_after }} -type f -delete
# or in any IMAP subfolder # or in any IMAP subfolder
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/.*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete 2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/.*/cur -mtime +{{ config.delete_mails_after }} -type f -delete
# even if they are unseen # even if they are unseen
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/new/*' -mtime +{{ config.delete_mails_after }} -type f -delete 2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/new -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/.*/new/*' -mtime +{{ config.delete_mails_after }} -type f -delete 2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/.*/new -mtime +{{ config.delete_mails_after }} -type f -delete
# or only temporary (but then they shouldn't be around after {{ config.delete_mails_after }} days anyway). # or only temporary (but then they shouldn't be around after {{ config.delete_mails_after }} days anyway).
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete 2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/tmp -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/.*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete 2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/.*/tmp -mtime +{{ config.delete_mails_after }} -type f -delete

View File

@@ -1,32 +0,0 @@
function dovecot_lua_notify_begin_txn(user)
return user
end
function contains(v, needle)
for _, keyword in ipairs(v) do
if keyword == needle then
return true
end
end
return false
end
function dovecot_lua_notify_event_message_new(user, event)
local mbox = user:mailbox(event.mailbox)
mbox:sync()
if user.username ~= event.from_address then
-- Incoming message
if not contains(event.keywords, "$Auto") then
-- Not an Auto-Submitted message, notifying.
-- Notify METADATA server about new message.
mbox:metadata_set("/private/messagenew", "")
end
end
mbox:free()
end
function dovecot_lua_notify_end_txn(ctx, success)
end

View File

@@ -6,7 +6,7 @@ import io
def gen_qr_png_data(maildomain): def gen_qr_png_data(maildomain):
url = f"DCACCOUNT:https://{maildomain}/new" url = f"DCACCOUNT:https://{maildomain}/cgi-bin/newemail.py"
image = gen_qr(maildomain, url) image = gen_qr(maildomain, url)
temp = io.BytesIO() temp = io.BytesIO()
image.save(temp, format="png") image.save(temp, format="png")

View File

@@ -45,33 +45,8 @@ http {
default_type text/plain; default_type text/plain;
} }
location /new { # add cgi-bin support
if ($request_method = GET) { include /usr/share/doc/fcgiwrap/examples/nginx.conf;
# Redirect to Delta Chat,
# which will in turn do a POST request.
return 301 dcaccount:https://{{ config.domain_name }}/new;
}
fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/lib/cgi-bin/newemail.py;
}
# Old URL for compatibility with e.g. printed QR codes.
#
# Copy-paste instead of redirect to /new
# because Delta Chat core does not follow redirects.
#
# Redirects are only for browsers.
location /cgi-bin/newemail.py {
if ($request_method = GET) {
return 301 dcaccount:https://{{ config.domain_name }}/new;
}
fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/lib/cgi-bin/newemail.py;
}
} }
# Redirect www. to non-www # Redirect www. to non-www

View File

@@ -1 +1 @@
{{ config.opendkim_selector }}._domainkey.{{ config.domain_name }} {{ config.domain_name }}:{{ config.opendkim_selector }}:/etc/dkimkeys/{{ config.opendkim_selector }}.private dkim._domainkey.{{ config.domain_name }} {{ config.domain_name }}:{{ config.opendkim_selector }}:/etc/dkimkeys/dkim.private

View File

@@ -1,28 +0,0 @@
if odkim.internal_ip(ctx) == 1 then
-- Outgoing message will be signed,
-- no need to look for signatures.
return nil
end
nsigs = odkim.get_sigcount(ctx)
if nsigs == nil then
return nil
end
for i = 1, nsigs do
sig = odkim.get_sighandle(ctx, i - 1)
sigres = odkim.sig_result(sig)
-- All signatures that do not correspond to From:
-- were ignored in screen.lua and return sigres -1.
--
-- Any valid signature that was not ignored like this
-- means the message is acceptable.
if sigres == 0 then
return nil
end
end
odkim.set_reply(ctx, "554", "5.7.1", "No valid DKIM signature found")
odkim.set_result(ctx, SMFIS_REJECT)
return nil

View File

@@ -8,12 +8,10 @@ SyslogSuccess yes
# oversigned, because it is often the identity key used by reputation systems # oversigned, because it is often the identity key used by reputation systems
# and thus somewhat security sensitive. # and thus somewhat security sensitive.
Canonicalization relaxed/simple Canonicalization relaxed/simple
#Mode sv
#SubDomains no
OversignHeaders From OversignHeaders From
On-BadSignature reject
On-KeyNotFound reject
On-NoSignature reject
# Signing domain, selector, and key (required). For example, perform signing # Signing domain, selector, and key (required). For example, perform signing
# for domain "example.com" with selector "2020" (2020._domainkey.example.com), # for domain "example.com" with selector "2020" (2020._domainkey.example.com),
# using the private key stored in /etc/dkimkeys/example.private. More granular # using the private key stored in /etc/dkimkeys/example.private. More granular
@@ -24,15 +22,6 @@ KeyFile /etc/dkimkeys/{{ config.opendkim_selector }}.private
KeyTable /etc/dkimkeys/KeyTable KeyTable /etc/dkimkeys/KeyTable
SigningTable refile:/etc/dkimkeys/SigningTable SigningTable refile:/etc/dkimkeys/SigningTable
# Sign Autocrypt header in addition to the default specified in RFC 6376.
SignHeaders *,+autocrypt
# Script to ignore signatures that do not correspond to the From: domain.
ScreenPolicyScript /etc/opendkim/screen.lua
# Script to reject mails without a valid DKIM signature.
FinalPolicyScript /etc/opendkim/final.lua
# In Debian, opendkim runs as user "opendkim". A umask of 007 is required when # In Debian, opendkim runs as user "opendkim". A umask of 007 is required when
# using a local socket with MTAs that access the socket as a non-privileged # using a local socket with MTAs that access the socket as a non-privileged
# user (for example, Postfix). You may need to add user "postfix" to group # user (for example, Postfix). You may need to add user "postfix" to group
@@ -40,10 +29,22 @@ FinalPolicyScript /etc/opendkim/final.lua
UserID opendkim UserID opendkim
UMask 007 UMask 007
# Socket for the MTA connection (required). If the MTA is inside a chroot jail,
# it must be ensured that the socket is accessible. In Debian, Postfix runs in
# a chroot in /var/spool/postfix, therefore a Unix socket would have to be
# configured as shown on the last line below.
#Socket local:/run/opendkim/opendkim.sock
#Socket inet:8891@localhost
#Socket inet:8891
Socket local:/var/spool/postfix/opendkim/opendkim.sock Socket local:/var/spool/postfix/opendkim/opendkim.sock
PidFile /run/opendkim/opendkim.pid PidFile /run/opendkim/opendkim.pid
# Hosts for which to sign rather than verify, default is 127.0.0.1. See the
# OPERATION section of opendkim(8) for more information.
#InternalHosts 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12
# The trust anchor enables DNSSEC. In Debian, the trust anchor file is provided # The trust anchor enables DNSSEC. In Debian, the trust anchor file is provided
# by the package dns-root-data. # by the package dns-root-data.
TrustAnchorFile /usr/share/dns/root.key TrustAnchorFile /usr/share/dns/root.key
#Nameservers 127.0.0.1

View File

@@ -1,21 +0,0 @@
-- Ignore signatures that do not correspond to the From: domain.
from_domain = odkim.get_fromdomain(ctx)
if from_domain == nil then
return nil
end
n = odkim.get_sigcount(ctx)
if n == nil then
return nil
end
for i = 1, n do
sig = odkim.get_sighandle(ctx, i - 1)
sig_domain = odkim.sig_getdomain(sig)
if from_domain ~= sig_domain then
odkim.sig_ignore(sig)
end
end
return nil

View File

@@ -1 +0,0 @@
/^(.*)$/ ${1}

View File

@@ -23,31 +23,6 @@ smtp_tls_CApath=/etc/ssl/certs
smtp_tls_security_level=may smtp_tls_security_level=may
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_tls_policy_maps = socketmap:inet:127.0.0.1:8461:postfix smtp_tls_policy_maps = socketmap:inet:127.0.0.1:8461:postfix
smtpd_tls_protocols = >=TLSv1.2
# Disable anonymous cipher suites
# and known insecure algorithms.
#
# Disabling anonymous ciphers
# does not generally improve security
# because clients that want to verify certificate
# will not select them anyway,
# but makes cipher suite list shorter and security scanners happy.
# See <https://www.postfix.org/TLS_README.html> for discussion.
#
# Only ancient insecure ciphers should be disabled here
# as MTA clients that do not support more secure cipher
# likely do not support MTA-STS either and will
# otherwise fall back to using plaintext connection.
smtpd_tls_exclude_ciphers = aNULL, RC4, MD5, DES
# Override client's preference order.
# <https://www.postfix.org/postconf.5.html#tls_preempt_cipherlist>
#
# This is mostly to ensure cipher suites with forward secrecy
# are preferred over non cipher suites without forward secrecy.
# See <https://www.postfix.org/FORWARD_SECRECY_README.html#server_fs>.
tls_preempt_cipherlist = yes
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = {{ config.mail_domain }} myhostname = {{ config.mail_domain }}
@@ -71,9 +46,7 @@ inet_protocols = all
virtual_transport = lmtp:unix:private/dovecot-lmtp virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = {{ config.mail_domain }} virtual_mailbox_domains = {{ config.mail_domain }}
mua_client_restrictions = permit_sasl_authenticated, reject smtpd_milters = inet:127.0.0.1:11332
mua_sender_restrictions = reject_sender_login_mismatch, permit_sasl_authenticated, reject non_smtpd_milters = $smtpd_milters
mua_helo_restrictions = permit_mynetworks, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, permit
# 1:1 map MAIL FROM to SASL login name. header_checks = regexp:/etc/postfix/submission_header_cleanup
smtpd_sender_login_maps = regexp:/etc/postfix/login_map

View File

@@ -11,10 +11,13 @@
# ========================================================================== # ==========================================================================
{% if debug == true %} {% if debug == true %}
smtp inet n - y - - smtpd -v smtp inet n - y - - smtpd -v
{%- else %} {% else %}
smtp inet n - y - - smtpd smtp inet n - y - - smtpd
{%- endif %} {% endif %}
-o smtpd_milters=unix:opendkim/opendkim.sock #smtp inet n - y - 1 postscreen
#smtpd pass - - y - - smtpd
#dnsblog unix - - y - 0 dnsblog
#tlsproxy unix - - y - 0 tlsproxy
submission inet n - y - - smtpd submission inet n - y - - smtpd
-o syslog_name=postfix/submission -o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
@@ -31,7 +34,6 @@ submission inet n - y - - smtpd
-o milter_macro_daemon_name=ORIGINATING -o milter_macro_daemon_name=ORIGINATING
-o smtpd_client_connection_count_limit=1000 -o smtpd_client_connection_count_limit=1000
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
-o cleanup_service_name=authclean
smtps inet n - y - - smtpd smtps inet n - y - - smtpd
-o syslog_name=postfix/smtps -o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes -o smtpd_tls_wrappermode=yes
@@ -48,7 +50,6 @@ smtps inet n - y - - smtpd
-o smtpd_client_connection_count_limit=1000 -o smtpd_client_connection_count_limit=1000
-o milter_macro_daemon_name=ORIGINATING -o milter_macro_daemon_name=ORIGINATING
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
-o cleanup_service_name=authclean
#628 inet n - y - - qmqpd #628 inet n - y - - qmqpd
pickup unix n - y 60 1 pickup pickup unix n - y 60 1 pickup
cleanup unix n - y - 0 cleanup cleanup unix n - y - 0 cleanup
@@ -79,14 +80,3 @@ filter unix - n n - - lmtp
# Local SMTP server for reinjecting filered mail. # Local SMTP server for reinjecting filered mail.
localhost:{{ config.postfix_reinject_port }} inet n - n - 10 smtpd localhost:{{ config.postfix_reinject_port }} inet n - n - 10 smtpd
-o syslog_name=postfix/reinject -o syslog_name=postfix/reinject
-o smtpd_milters=unix:opendkim/opendkim.sock
-o cleanup_service_name=authclean
# Cleanup `Received` headers for authenticated mail
# to avoid leaking client IP.
#
# We do not do this for received mails
# as this will break DKIM signatures
# if `Received` header is signed.
authclean unix n - - - 0 cleanup
-o header_checks=regexp:/etc/postfix/submission_header_cleanup

View File

@@ -0,0 +1 @@
enabled = false;

View File

@@ -0,0 +1,10 @@
selector = {{ config.dkim_selector }}
use_esld = false # don't cut c1.testrun.org down to testrun.org
domain = {
{{ config.mail_domain }} {
selectors [
selector = {{ config.dkim_selector }}
path = {{ config.dkim_key_path }}
]
}
}

View File

@@ -0,0 +1,30 @@
rules {
REJECT_DKIM_SPF {
action = "reject";
# Reject if
# - R_DKIM_RJECT: DKIM reject inserted by `dkim` module.
# - R_DKIM_PERMFAIL: permanent failure inserted by `dkim` module e.g. no DKIM DNS record found.
# - No DKIM signing (R_DKIM_NA symbol inserted by `dkim` module)
#
# - SPF failure (R_SPF_FAIL)
# - SPF permanent failure, e.g. failed to resolve DNS record referenced from SPF (R_SPF_PERMFAIL)
#
# - DMARC policy failure (DMARC_POLICY_REJECT)
#
# Do not reject if:
# - R_DKIM_TEMPFAIL, it is a DNS resolution failure
# and we do not want to lose messages because of faulty network.
#
# - R_SPF_SOFTFAIL
# - R_SPF_NEUTRAL
# - R_SPF_DNSFAIL
# - R_SPF_NA
#
# - DMARC_DNSFAIL
# - DMARC_NA
# - DMARC_POLICY_SOFTFAIL
# - DMARC_POLICY_QUARANTINE
# - DMARC_BAD_POLICY
expression = "R_DKIM_REJECT | R_DKIM_PERMFAIL | R_DKIM_NA | R_SPF_FAIL | R_SPF_PERMFAIL | DMARC_POLICY_REJECT";
}
}

View File

@@ -0,0 +1 @@
filters = "dkim";

View File

@@ -1,7 +1,6 @@
import pytest import pytest
import threading import threading
import queue import queue
import socket
from chatmaild.config import read_config from chatmaild.config import read_config
from cmdeploy.cmdeploy import main from cmdeploy.cmdeploy import main
@@ -79,24 +78,3 @@ def test_concurrent_logins_same_account(
for _ in conns: for _ in conns:
assert login_results.get() assert login_results.get()
def test_no_vrfy(chatmail_config):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((chatmail_config.mail_domain, 25))
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)
sock.send(b"VRFY echo@%s\r\n" % (chatmail_config.mail_domain.encode(),))
result2 = sock.recv(1024)
print(result2)
assert result[0:10] == result2[0:10]
sock.send(b"VRFY wrongaddress\r\n")
result = sock.recv(1024)
print(result)
sock.send(b"VRFY echo\r\n")
result2 = sock.recv(1024)
print(result2)
assert result[0:10] == result2[0:10] == b"252 2.0.0 "

View File

@@ -9,7 +9,7 @@ def test_gen_qr_png_data(maildomain):
def test_fastcgi_working(maildomain, chatmail_config): def test_fastcgi_working(maildomain, chatmail_config):
url = f"https://{maildomain}/new" url = f"https://{maildomain}/cgi-bin/newemail.py"
print(url) print(url)
res = requests.post(url) res = requests.post(url)
assert maildomain in res.json().get("email") assert maildomain in res.json().get("email")
@@ -18,7 +18,7 @@ def test_fastcgi_working(maildomain, chatmail_config):
def test_newemail_configure(maildomain, rpc): def test_newemail_configure(maildomain, rpc):
"""Test configuring accounts by scanning a QR code works.""" """Test configuring accounts by scanning a QR code works."""
url = f"DCACCOUNT:https://{maildomain}/new" url = f"DCACCOUNT:https://{maildomain}/cgi-bin/newemail.py"
for i in range(3): for i in range(3):
account_id = rpc.add_account() account_id = rpc.add_account()
rpc.set_config_from_qr(account_id, url) rpc.set_config_from_qr(account_id, url)

View File

@@ -42,25 +42,13 @@ def test_reject_forged_from(cmsetup, maildata, gencreds, lp, forgeaddr):
assert "500" in str(e.value) assert "500" in str(e.value)
def test_authenticated_from(cmsetup, maildata):
"""Test that envelope FROM must be the same as login."""
user1, user2, user3 = cmsetup.gen_users(3)
msg = maildata("encrypted.eml", from_addr=user2.addr, to_addr=user3.addr)
with pytest.raises(smtplib.SMTPException) as e:
user1.smtp.sendmail(
from_addr=user2.addr, to_addrs=[user3.addr], msg=msg.as_string()
)
assert e.value.recipients[user3.addr][0] == 553
@pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"]) @pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"])
def test_reject_missing_dkim(cmsetup, maildata, from_addr): def test_reject_missing_dkim(cmsetup, maildata, from_addr):
"""Test that emails with missing or wrong DMARC, DKIM, and SPF entries are rejected.""" """Test that emails with missing or wrong DMARC, DKIM, and SPF entries are rejected."""
recipient = cmsetup.gen_users(1)[0] recipient = cmsetup.gen_users(1)[0]
msg = maildata("plain.eml", from_addr=from_addr, to_addr=recipient.addr).as_string() msg = maildata("plain.eml", from_addr=from_addr, to_addr=recipient.addr).as_string()
with smtplib.SMTP(cmsetup.maildomain, 25) as s: with smtplib.SMTP(cmsetup.maildomain, 25) as s:
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"): with pytest.raises(smtplib.SMTPDataError, match="Spam message rejected"):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg) s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
@@ -83,18 +71,3 @@ def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
assert b"4.7.1: Too much mail from" in outcome[1] assert b"4.7.1: Too much mail from" in outcome[1]
return return
pytest.fail("Rate limit was not exceeded") pytest.fail("Rate limit was not exceeded")
def test_expunged(remote, chatmail_config):
outdated_days = int(chatmail_config.delete_mails_after) + 1
find_cmds = [
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/cur/*' -mtime +{outdated_days} -type f",
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/.*/cur/*' -mtime +{outdated_days} -type f",
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/new/*' -mtime +{outdated_days} -type f",
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/.*/new/*' -mtime +{outdated_days} -type f",
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/tmp/*' -mtime +{outdated_days} -type f",
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/.*/tmp/*' -mtime +{outdated_days} -type f",
]
for cmd in find_cmds:
for line in remote.iter_output(cmd):
assert not line

View File

@@ -136,15 +136,3 @@ def test_hide_senders_ip_address(cmfactory):
user2.direct_imap.select_folder("Inbox") user2.direct_imap.select_folder("Inbox")
msg = user2.direct_imap.get_all_messages()[0] msg = user2.direct_imap.get_all_messages()[0]
assert public_ip not in msg.obj.as_string() assert public_ip not in msg.obj.as_string()
def test_echobot(cmfactory, chatmail_config, lp):
ac = cmfactory.get_online_accounts(1)[0]
lp.sec(f"Send message to echo@{chatmail_config.mail_domain}")
chat = ac.create_chat(f"echo@{chatmail_config.mail_domain}")
text = "hi, I hope you text me back"
chat.send_text(text)
lp.sec("Wait for reply from echobot")
reply = ac.wait_next_incoming_message()
assert reply.text == text

View File

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
set -e set -e
python3 -m venv --upgrade-deps venv python3 -m venv venv
venv/bin/pip install -e chatmaild venv/bin/pip install -e chatmaild
venv/bin/pip install -e cmdeploy venv/bin/pip install -e cmdeploy

View File

@@ -7,7 +7,7 @@ Welcome to instant, interoperable and [privacy-preserving](privacy.html) messagi
👉 **Tap** or scan this QR code to get a random `@{{config.mail_domain}}` e-mail address 👉 **Tap** or scan this QR code to get a random `@{{config.mail_domain}}` e-mail address
<a href="DCACCOUNT:https://{{ config.mail_domain }}/new"> <a href="DCACCOUNT:https://{{ config.mail_domain }}/cgi-bin/newemail.py">
<img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a> <img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a>
🐣 **Choose** your Avatar and Name 🐣 **Choose** your Avatar and Name