Compare commits

...

14 Commits

Author SHA1 Message Date
holger krekel
141bfbf2ea create build venv in chatmaild/venv 2023-10-17 00:33:07 +02:00
holger krekel
6fa5ec86f0 move deploy.py file and revamp README 2023-10-17 00:22:41 +02:00
holger krekel
b74fde2a9f add postfix instrumented debugging 2023-10-16 23:38:02 +02:00
holger krekel
e176595f1f add global debug flag and instrument dovecot with it 2023-10-16 23:38:02 +02:00
link2xt
179c79a052 Allow to send securejoin 2023-10-16 21:15:56 +00:00
link2xt
408da296f1 test.sh: do not run slow tests by default 2023-10-16 20:13:41 +00:00
missytake
192238567b add some initial benchmarks
Co-Authored-By: holger krekel <holger@merlinux.eu>
2023-10-16 21:51:53 +02:00
holger krekel
c35e485510 an empty message in the handler means EOF 2023-10-16 21:49:56 +02:00
holger krekel
1bac4b5b46 generalize remotelog to "remote" and offer remote.iter_output method 2023-10-16 20:49:30 +02:00
holger krekel
63a7ad82ff fix capturing of logging to capture postfix better 2023-10-16 20:49:30 +02:00
holger krekel
37ef3f13b4 fix bugs 2023-10-16 20:49:30 +02:00
holger krekel
9dfd0ceb5a simplify and speedup multi-chatmail instance support 2023-10-16 20:49:30 +02:00
holger krekel
55c58e3c7a add support for using a second chatmail server 2023-10-16 20:49:30 +02:00
holger krekel
c2692c7e92 introduce remotelog fixture for capturing systemd-unit logs 2023-10-16 20:49:30 +02:00
19 changed files with 192 additions and 103 deletions

View File

@@ -1,60 +1,40 @@
# Chat Mail server configuration # Chat Mail server configuration
This package deploys Postfix and Dovecot servers, including OpenDKIM for DKIM signing. This repository setups a ready-to-go chatmail instance
comprised of a minimal setup of the battle-tested
Postfix uses Dovecot for authentication as described in <https://www.postfix.org/SASL_README.html#server_dovecot> [postfix smtp server](https://www.postfix.org) and [dovecot imap server](https://www.dovecot.org).
## Getting started ## Getting started
prepare: 1. prepare your local system:
pip install -e chatmail-infra scripts/init.sh
2. set environment variable to the chatmail domain you want to setup:
export CHATMAIL_DOMAIN=c1.testrun.org # replace with your host
3. run the deploy of the chat mail instance:
scripts/deploy.sh
then run with pyinfra command line tool: ## Running tests and benchmarks (offline and online)
CHATMAIL_DOMAIN=c1.testrun.org pyinfra --ssh-user root c1.testrun.org deploy.py 1. Set `CHATMAIL_SSH` so that `ssh root@$CHATMAIL_SSH` allows
to login to the chatmail instance server.
2. To run local and online tests:
## Structure (wip) scripts/test.sh
```
# package doveauth tool and deploy chatmail server to a envvar-specified ssh-reachable host 3. To run benchmarks against your chatmail instance:
deploy.py
# chatmail pyinfra deploy package scripts/bench.sh
chatmail-pyinfra
pyproject.toml
chatmail/__init__ ...
# doveauth tool used by dovecot's auth mechanism on the host system ## Running tests (offline and online)
doveauth
README.md
pyproject.toml
doveauth.py
test_doveauth.py
# lmtp server to block (outgoing) unencrypted messages
filtermail
README.md
pyproject.toml
....
# online tests (after deploy)
online-tests # runnable via pytest
# scripts for setup/development/deployment
scripts/
init.sh # create venv/other perequires
deploy.sh # run pyinfra based deploy of everything
test.sh # run all local and online tests
``` ```
## Dovecot/Postfix configuration ## Dovecot/Postfix configuration
### Ports ### Ports
@@ -64,4 +44,6 @@ Dovecot listens on ports 143(imap) and 993 (imaps).
## DNS ## DNS
For DKIM you must add a DNS entry as in /etc/opendkim/selector.txt (where selector is the opendkim_selector configured in the chatmail inventory). For DKIM you must add a DNS entry as found in /etc/opendkim/selector.txt on your chatmail instance.
The above `scripts/deploy.sh` prints out the DKIM selector and DNS entry you
need to setup with your DNS provider.

View File

@@ -95,7 +95,7 @@ def main():
while True: while True:
msg = self.rfile.readline().strip().decode() msg = self.rfile.readline().strip().decode()
if not msg: if not msg:
continue break
res = handle_dovecot_request(msg, db) res = handle_dovecot_request(msg, db)
if res: if res:
print(f"sending result: {res!r}", file=sys.stderr) print(f"sending result: {res!r}", file=sys.stderr)

View File

@@ -9,9 +9,8 @@ from aiosmtpd.controller import UnixSocketController
from smtplib import SMTP as SMTPClient from smtplib import SMTP as SMTPClient
def check_encrypted(content): def check_encrypted(message):
"""Check that the message is an OpenPGP-encrypted message.""" """Check that the message is an OpenPGP-encrypted message."""
message = BytesParser(policy=policy.default).parsebytes(content)
if not message.is_multipart(): if not message.is_multipart():
return False return False
if message.get("subject") != "...": if message.get("subject") != "...":
@@ -47,7 +46,8 @@ class ExampleHandler:
valid_recipients = [] valid_recipients = []
mail_encrypted = check_encrypted(envelope.content) message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message)
res = [] res = []
for recipient in envelope.rcpt_tos: for recipient in envelope.rcpt_tos:
@@ -68,7 +68,13 @@ class ExampleHandler:
continue continue
is_outgoing = recipient_local_domain[1] != my_local_domain[1] is_outgoing = recipient_local_domain[1] != my_local_domain[1]
if is_outgoing and not mail_encrypted:
if (
is_outgoing
and not mail_encrypted
and message.get("secure-join") != "vc-request"
and message.get("secure-join") != "vg-request"
):
res += ["500 Outgoing mail must be encrypted"] res += ["500 Outgoing mail must be encrypted"]
continue continue

View File

@@ -1,12 +1,16 @@
import pytest
from .filtermail import check_encrypted from .filtermail import check_encrypted
from email.parser import BytesParser
from email import policy
def test_filtermail(): def test_filtermail():
assert not check_encrypted(b"foo") def check_encrypted_bstr(content):
message = BytesParser(policy=policy.default).parsebytes(content)
return check_encrypted(message)
assert not check_encrypted( assert not check_encrypted_bstr(b"foo")
assert not check_encrypted_bstr(
"\r\n".join( "\r\n".join(
[ [
"Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=", "Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=",
@@ -36,7 +40,7 @@ def test_filtermail():
).encode() ).encode()
) )
assert not check_encrypted( assert not check_encrypted_bstr(
"\r\n".join( "\r\n".join(
[ [
"Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=", "Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=",
@@ -67,7 +71,7 @@ def test_filtermail():
) )
# https://xkcd.com/1181/ # https://xkcd.com/1181/
assert not check_encrypted( assert not check_encrypted_bstr(
"\r\n".join( "\r\n".join(
[ [
"Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=", "Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=",
@@ -99,7 +103,7 @@ def test_filtermail():
).encode() ).encode()
) )
assert check_encrypted( assert check_encrypted_bstr(
"\r\n".join( "\r\n".join(
[ [
"Subject: ...", "Subject: ...",
@@ -172,7 +176,7 @@ def test_filtermail():
).encode() ).encode()
) )
assert not check_encrypted( assert not check_encrypted_bstr(
"\r\n".join( "\r\n".join(
[ [
"Subject: Buy Penis Enlargement at www.malicious-domain.com", "Subject: Buy Penis Enlargement at www.malicious-domain.com",
@@ -245,7 +249,7 @@ def test_filtermail():
).encode() ).encode()
) )
assert not check_encrypted( assert not check_encrypted_bstr(
"\r\n".join( "\r\n".join(
[ [
"Subject: Message opened", "Subject: Message opened",

View File

@@ -110,7 +110,7 @@ def _configure_opendkim(domain: str, dkim_selector: str) -> bool:
return need_restart return need_restart
def _configure_postfix(domain: str) -> bool: def _configure_postfix(domain: str, debug: bool = False) -> bool:
"""Configures Postfix SMTP server.""" """Configures Postfix SMTP server."""
need_restart = False need_restart = False
@@ -124,21 +124,20 @@ def _configure_postfix(domain: str) -> bool:
) )
need_restart |= main_config.changed need_restart |= main_config.changed
master_config = files.put( master_config = files.template(
src=importlib.resources.files(__package__) src=importlib.resources.files(__package__).joinpath("postfix/master.cf.j2"),
.joinpath("postfix/master.cf")
.open("rb"),
dest="/etc/postfix/master.cf", dest="/etc/postfix/master.cf",
user="root", user="root",
group="root", group="root",
mode="644", mode="644",
debug=debug,
) )
need_restart |= master_config.changed need_restart |= master_config.changed
return need_restart return need_restart
def _configure_dovecot(mail_server: str) -> bool: def _configure_dovecot(mail_server: str, debug: bool = False) -> bool:
"""Configures Dovecot IMAP server.""" """Configures Dovecot IMAP server."""
need_restart = False need_restart = False
@@ -149,6 +148,7 @@ def _configure_dovecot(mail_server: str) -> bool:
group="root", group="root",
mode="644", mode="644",
config={"hostname": mail_server}, config={"hostname": mail_server},
debug=debug,
) )
need_restart |= main_config.changed need_restart |= main_config.changed
auth_config = files.put( auth_config = files.put(
@@ -215,8 +215,9 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
) )
_install_chatmaild() _install_chatmaild()
dovecot_need_restart = _configure_dovecot(mail_server) debug = False
postfix_need_restart = _configure_postfix(mail_domain) dovecot_need_restart = _configure_dovecot(mail_server, debug=debug)
postfix_need_restart = _configure_postfix(mail_domain, debug=debug)
opendkim_need_restart = _configure_opendkim(mail_domain, dkim_selector) opendkim_need_restart = _configure_opendkim(mail_domain, dkim_selector)
systemd.service( systemd.service(

View File

@@ -4,13 +4,17 @@ protocols = imap lmtp
auth_mechanisms = plain auth_mechanisms = plain
{% if debug == true %}
auth_verbose = yes auth_verbose = yes
auth_debug = yes auth_debug = yes
auth_debug_passwords = yes auth_debug_passwords = yes
auth_verbose_passwords = plain auth_verbose_passwords = plain
auth_cache_size = 100M auth_cache_size = 100M
mail_plugins = quota
mail_debug = yes mail_debug = yes
{% endif %}
mail_plugins = quota
# Authentication for system users. # Authentication for system users.
passdb { passdb {

View File

@@ -9,7 +9,11 @@
# service type private unpriv chroot wakeup maxproc command + args # service type private unpriv chroot wakeup maxproc command + args
# (yes) (yes) (no) (never) (100) # (yes) (yes) (no) (never) (100)
# ========================================================================== # ==========================================================================
smtp inet n - y - - smtpd -v {% if debug == true %}
smtp inet n - y - - smtpd -v
{% else %}
smtp inet n - y - - smtpd
{% endif %}
#smtp inet n - y - 1 postscreen #smtp inet n - y - 1 postscreen
#smtpd pass - - y - - smtpd #smtpd pass - - y - - smtpd
#dnsblog unix - - y - 0 dnsblog #dnsblog unix - - y - 0 dnsblog

34
online-tests/benchmark.py Normal file
View File

@@ -0,0 +1,34 @@
def test_tls_serialized_connect(benchmark, imap_or_smtp):
def connect():
imap_or_smtp.connect()
benchmark(connect)
def test_login(benchmark, imap_or_smtp, gencreds):
cls = imap_or_smtp.__class__
conns = []
for i in range(20):
conn = cls(imap_or_smtp.host)
conn.connect()
conns.append(conn)
def login():
conn = conns.pop()
conn.login(*gencreds())
benchmark(login)
def test_send_and_receive_10(benchmark, cmfactory, lp):
"""send many messages between two accounts"""
ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2)
def send_10_receive_all():
for i in range(10):
chat.send_text(f"hello {i}")
for i in range(10):
ac2.wait_next_incoming_message()
benchmark(send_10_receive_all)

View File

@@ -30,13 +30,23 @@ def maildomain():
@pytest.fixture @pytest.fixture
def chatmail_ssh(maildomain): def sshdomain(maildomain):
domain = os.environ.get("CHATMAIL_SSH") return os.environ.get("CHATMAIL_SSH", maildomain)
@pytest.fixture
def maildomain2():
domain = os.environ.get("CHATMAIL_DOMAIN2")
if not domain: if not domain:
domain = maildomain pytest.skip("set CHATMAIL_DOMAIN2 to a ssh-reachable chatmail instance")
return domain return domain
@pytest.fixture
def sshdomain2(maildomain2):
return os.environ.get("CHATMAIL_SSH2", maildomain2)
def pytest_report_header(): def pytest_report_header():
domain = os.environ.get("CHATMAIL_DOMAIN") domain = os.environ.get("CHATMAIL_DOMAIN")
if domain: if domain:
@@ -51,6 +61,8 @@ def imap(maildomain):
class ImapConn: class ImapConn:
AuthError = imaplib.IMAP4.error AuthError = imaplib.IMAP4.error
logcmd = "journalctl -f -u dovecot"
name = "dovecot"
def __init__(self, host): def __init__(self, host):
self.host = host self.host = host
@@ -71,6 +83,8 @@ def smtp(maildomain):
class SmtpConn: class SmtpConn:
AuthError = smtplib.SMTPAuthenticationError AuthError = smtplib.SMTPAuthenticationError
logcmd = "journalctl -f -t postfix/smtpd -t postfix/smtp -t postfix/lmtp"
name = "postfix"
def __init__(self, host): def __init__(self, host):
self.host = host self.host = host
@@ -94,16 +108,17 @@ def gencreds(maildomain):
count = itertools.count() count = itertools.count()
next(count) next(count)
def gen(): def gen(domain=None):
domain = domain if domain else maildomain
while 1: while 1:
num = next(count) num = next(count)
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890" alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
user = "".join(random.choices(alphanumeric, k=10)) user = "".join(random.choices(alphanumeric, k=10))
user = f"ac{num}_{user}" user = f"ac{num}_{user}"
password = "".join(random.choices(alphanumeric, k=10)) password = "".join(random.choices(alphanumeric, k=10))
yield f"{user}@{maildomain}", f"{password}" yield f"{user}@{domain}", f"{password}"
return lambda: next(gen()) return lambda domain=None: next(gen(domain))
# #
@@ -118,12 +133,13 @@ class ChatmailTestProcess:
def __init__(self, pytestconfig, maildomain, gencreds): def __init__(self, pytestconfig, maildomain, gencreds):
self.pytestconfig = pytestconfig self.pytestconfig = pytestconfig
self.maildomain = maildomain self.maildomain = maildomain
assert "." in self.maildomain, maildomain
self.gencreds = gencreds self.gencreds = gencreds
self._addr2files = {} self._addr2files = {}
def get_liveconfig_producer(self): def get_liveconfig_producer(self):
while 1: while 1:
user, password = self.gencreds() user, password = self.gencreds(self.maildomain)
config = { config = {
"addr": user, "addr": user,
"mail_pw": password, "mail_pw": password,
@@ -141,13 +157,21 @@ class ChatmailTestProcess:
@pytest.fixture @pytest.fixture
def cmfactory(request, maildomain, gencreds, tmpdir, data): def cmfactory(request, gencreds, tmpdir, data, maildomain):
# cloned from deltachat.testplugin.amfactory # cloned from deltachat.testplugin.amfactory
pytest.importorskip("deltachat") pytest.importorskip("deltachat")
from deltachat.testplugin import ACFactory from deltachat.testplugin import ACFactory
testproc = ChatmailTestProcess(request.config, maildomain, gencreds) testproc = ChatmailTestProcess(request.config, maildomain, gencreds)
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=data) am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=data)
# nb. a bit hacky
# would probably be better if deltachat's test machinery grows native support
def switch_maildomain(maildomain2):
am.testprocess.maildomain = maildomain2
am.switch_maildomain = switch_maildomain
yield am yield am
if hasattr(request.node, "rep_call") and request.node.rep_call.failed: if hasattr(request.node, "rep_call") and request.node.rep_call.failed:
if testproc.pytestconfig.getoption("--extra-info"): if testproc.pytestconfig.getoption("--extra-info"):
@@ -158,13 +182,20 @@ def cmfactory(request, maildomain, gencreds, tmpdir, data):
@pytest.fixture @pytest.fixture
def dovelogreader(chatmail_ssh): def remote(sshdomain):
def remote_reader(): return Remote(sshdomain)
popen = subprocess.Popen(
["ssh", f"root@{chatmail_ssh}", "journalctl -f -u dovecot"],
class Remote:
def __init__(self, sshdomain):
self.sshdomain = sshdomain
def iter_output(self, logcmd=""):
getjournal = f"journalctl -f" if not logcmd else logcmd
self.popen = subprocess.Popen(
["ssh", f"root@{self.sshdomain}", getjournal],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
) )
while 1: while 1:
yield popen.stdout.readline() line = self.popen.stdout.readline()
yield line.decode().strip().lower()
return remote_reader

View File

@@ -0,0 +1,15 @@
def test_remote(remote, imap_or_smtp):
lineproducer = remote.iter_output(imap_or_smtp.logcmd)
imap_or_smtp.connect()
assert imap_or_smtp.name in next(lineproducer)
def test_use_two_chatmailservers(cmfactory, maildomain2):
ac1 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
cmfactory.get_accepted_chat(ac1, ac2)
domain1 = ac1.get_config("addr").split("@")[1]
domain2 = ac2.get_config("addr").split("@")[1]
assert domain1 != domain2

View File

@@ -17,7 +17,7 @@ def test_login_basic_functioning(imap_or_smtp, gencreds, lp):
imap_or_smtp.connect() imap_or_smtp.connect()
lp.sec("success") lp.sec("success")
lp.sec("reconnect and verify wrong password fails {user} ") lp.sec(f"reconnect and verify wrong password fails {user} ")
imap_or_smtp.connect() imap_or_smtp.connect()
with pytest.raises(imap_or_smtp.AuthError): with pytest.raises(imap_or_smtp.AuthError):
imap_or_smtp.login(user, password + "wrong") imap_or_smtp.login(user, password + "wrong")

View File

@@ -19,7 +19,7 @@ class TestEndToEndDeltaChat:
assert msg2.text == "message0" assert msg2.text == "message0"
@pytest.mark.slow @pytest.mark.slow
def test_exceed_quota(self, cmfactory, lp, tmpdir, dovelogreader): def test_exceed_quota(self, cmfactory, lp, tmpdir, remote):
"""This is a very slow test as it needs to upload >100MB of mail data """This is a very slow test as it needs to upload >100MB of mail data
before quota is exceeded, and thus depends on the speed of the upload. before quota is exceeded, and thus depends on the speed of the upload.
""" """
@@ -48,8 +48,7 @@ class TestEndToEndDeltaChat:
addr = ac2.get_config("addr").lower() addr = ac2.get_config("addr").lower()
saved_ok = 0 saved_ok = 0
for line in dovelogreader(): for line in remote.iter_output("journalctl -f -u dovecot"):
line = line.decode().lower().strip()
if addr not in line: if addr not in line:
# print(line) # print(line)
continue continue
@@ -68,3 +67,17 @@ class TestEndToEndDeltaChat:
break break
pytest.fail("sending succeeded although messages should exceed quota") pytest.fail("sending succeeded although messages should exceed quota")
def test_securejoin(self, cmfactory, lp, maildomain2):
ac1 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
lp.sec("ac1: create QR code and let ac2 scan it, starting the securejoin")
qr = ac1.get_setup_contact_qr()
lp.sec("ac2: start QR-code based setup contact protocol")
ch = ac2.qr_setup_contact(qr)
assert ch.id >= 10
ac1._evtracker.wait_securejoin_inviter_progress(1000)

View File

@@ -8,12 +8,6 @@
- limit: configure max-connections per account - limit: configure max-connections per account
## Filtermail
- (alex, Only allow (outgoing) mails if secure-join or autocrypt-pgp-encrypted format.
TODO: mime-parse mails and check/add tests
## nami: send out rate limit / rspamd ## nami: send out rate limit / rspamd
- basic outgoing send rate/limits (depending on "account-rating") - basic outgoing send rate/limits (depending on "account-rating")

4
scripts/bench.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
set -e
online-tests/venv/bin/pytest online-tests/benchmark.py -vrx

View File

@@ -2,8 +2,9 @@
: ${CHATMAIL_DOMAIN:=c1.testrun.org} : ${CHATMAIL_DOMAIN:=c1.testrun.org}
export CHATMAIL_DOMAIN export CHATMAIL_DOMAIN
venv/bin/python3 -m build -n --sdist chatmaild --outdir dist chatmaild/venv/bin/python3 -m build -n --sdist chatmaild --outdir dist
deploy-chatmail/venv/bin/pyinfra --ssh-user root "$CHATMAIL_DOMAIN" deploy.py deploy-chatmail/venv/bin/pyinfra --ssh-user root "$CHATMAIL_DOMAIN" \
deploy-chatmail/src/deploy_chatmail/deploy.py
rm -r dist/ rm -r dist/

View File

@@ -6,12 +6,8 @@ deploy-chatmail/venv/bin/pip install -e deploy-chatmail
deploy-chatmail/venv/bin/pip install -e chatmaild deploy-chatmail/venv/bin/pip install -e chatmaild
python3 -m venv chatmaild/venv python3 -m venv chatmaild/venv
chatmaild/venv/bin/pip install pytest chatmaild/venv/bin/pip install --upgrade pytest build 'setuptools>=68'
chatmaild/venv/bin/pip install -e chatmaild chatmaild/venv/bin/pip install -e chatmaild
python3 -m venv online-tests/venv python3 -m venv online-tests/venv
online-tests/venv/bin/pip install pytest pytest-timeout pdbpp deltachat online-tests/venv/bin/pip install pytest pytest-timeout pdbpp deltachat pytest-benchmark
python3 -m venv venv
venv/bin/pip install build
venv/bin/pip install 'setuptools>=68'

View File

@@ -2,7 +2,7 @@
set -e set -e
: ${CHATMAIL_DOMAIN:=c1.testrun.org} : ${CHATMAIL_DOMAIN:=c1.testrun.org}
: ${CHATMAIL_SSH_HOST:=$CHATMAIL_DOMAIN} : ${CHATMAIL_SSH:=$CHATMAIL_DOMAIN}
rsync -avz . "root@$CHATMAIL_SSH_HOST:/root/chatmail" --exclude='/.git' --filter="dir-merge,- .gitignore" rsync -avz . "root@$CHATMAIL_SSH:/root/chatmail" --exclude='/.git' --filter="dir-merge,- .gitignore"
ssh "root@$CHATMAIL_SSH_HOST" "cd /root/chatmail; apt install -y python3-venv; python3 -m venv venv; venv/bin/pip install pyinfra build; venv/bin/python3 -m build -n --sdist chatmaild --outdir dist; venv/bin/pip install -e ./deploy-chatmail -e ./chatmaild; export CHATMAIL_DOMAIN=$CHATMAIL_DOMAIN; venv/bin/pyinfra @local deploy.py" ssh "root@$CHATMAIL_SSH" "cd /root/chatmail; apt install -y python3-venv; python3 -m venv venv; venv/bin/pip install pyinfra build; venv/bin/python3 -m build -n --sdist chatmaild --outdir dist; venv/bin/pip install -e ./deploy-chatmail -e ./chatmaild; export CHATMAIL_DOMAIN=$CHATMAIL_DOMAIN; venv/bin/pyinfra @local deploy.py"

View File

@@ -4,4 +4,4 @@ pushd chatmaild/src/chatmaild
../../venv/bin/pytest ../../venv/bin/pytest
popd popd
online-tests/venv/bin/pytest online-tests/ -vrx --durations=5 --slow online-tests/venv/bin/pytest online-tests/ -vrx --durations=5