diff --git a/README.md b/README.md index 044d14ba..168e3eaa 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ DNS domain name (FQDN), for example `chat.example.org`. 3. Create chatmail configuration file `chatmail.ini`: ``` - cmdeploy genconfig chatmail.ini CHATMAIL_DOMAIN + cmdeploy init chatmail.ini CHATMAIL_DOMAIN ``` 4. Deploy to the remote chatmail server, pointing to the chatmail config file: diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index 94232d97..5e8ac411 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -1,41 +1,25 @@ - - from pathlib import Path from fnmatch import fnmatch import iniconfig -system_mailname_path = Path("/etc/mailname") - -def read_config(inipath, mailname=None): - if mailname is None: - with open(system_mailname_path) as f: - mailname = f.read().strip() - - ini = iniconfig.IniConfig(inipath) - privacy = {} - for section in ini: - if section.name.startswith("privacy:"): - domain = section["domain"] - if fnmatch(mailname, domain): - privacy = section - break - - return Config(inipath, mailname, privacy, params=ini.sections["params"]) +def read_config(inipath): + cfg = iniconfig.IniConfig(inipath) + return Config(inipath, params=cfg.sections["params"]) class Config: - def __init__(self, inipath, mailname, privacy, params): + def __init__(self, inipath, params): self._inipath = inipath - self.mailname = mailname - self.privacy_postal = privacy.get("privacy_postal") - self.privacy_mail = privacy.get("privacy_mail") - self.privacy_pdo = privacy.get("privacy_pdo") - self.privacy_supervisor = privacy.get("privacy_supervisor") + self.mailname = params["mailname"] self.max_user_send_per_minute = int(params["max_user_send_per_minute"]) self.filtermail_smtp_port = int(params["filtermail_smtp_port"]) self.postfix_reinject_port = int(params["postfix_reinject_port"]) self.passthrough_recipients = params["passthrough_recipients"].split() + self.privacy_postal = params.get("privacy_postal") + self.privacy_mail = params.get("privacy_mail") + self.privacy_pdo = params.get("privacy_pdo") + self.privacy_supervisor = params.get("privacy_supervisor") def _getbytefile(self): return open(self._inipath, "rb") diff --git a/chatmaild/src/chatmaild/filtermail.py b/chatmaild/src/chatmaild/filtermail.py index c9e00f7d..c0f318f6 100644 --- a/chatmaild/src/chatmaild/filtermail.py +++ b/chatmaild/src/chatmaild/filtermail.py @@ -127,7 +127,10 @@ class BeforeQueueHandler: is_outgoing = recipient_domain != envelope_from_domain if is_outgoing and not mail_encrypted: - is_securejoin = message.get("secure-join") in ["vc-request", "vg-request"] + is_securejoin = message.get("secure-join") in [ + "vc-request", + "vg-request", + ] if not is_securejoin: return f"500 Invalid unencrypted mail to <{recipient}>" diff --git a/deploy-chatmail/pyproject.toml b/deploy-chatmail/pyproject.toml index 649e7973..7b6b243a 100644 --- a/deploy-chatmail/pyproject.toml +++ b/deploy-chatmail/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=45"] +requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" [project] @@ -12,9 +12,13 @@ dependencies = [ "markdown", "pytest", "setuptools>=68", + "termcolor", "tox", ] +[project.scripts] +cmdeploy = "deploy_chatmail.cmdeploy:main" + [tool.pytest.ini_options] addopts = "-v -ra --strict-markers" diff --git a/deploy-chatmail/src/deploy_chatmail/cmdeploy.py b/deploy-chatmail/src/deploy_chatmail/cmdeploy.py new file mode 100644 index 00000000..e5f70c92 --- /dev/null +++ b/deploy-chatmail/src/deploy_chatmail/cmdeploy.py @@ -0,0 +1,132 @@ +""" +Provides the `cmdeploy` entry point function, +along with command line option and subcommand parsing. +""" +import importlib.resources +import argparse +from pathlib import Path + +import iniconfig + +from termcolor import colored +from chatmaild.config import read_config + + +class Out: + """Convenience print output printer providing coloring.""" + + def red(self, msg): + print(colored(msg, "red")) + + def green(self, msg): + print(colored(msg, "green")) + + def __call__(self, msg, red=False, green=False): + color = "red" if red else ("green" if green else None) + print(colored(msg, color)) + + +description = """\ +Setup your chatmail server configuration and +deploy it via SSH to your remote location. +""" + + +def add_config_option(parser): + parser.add_argument( + "--config", + dest="chatmail_ini", + action="store", + default=Path("chatmail.ini"), + type=Path, + help="path to the chatmail.ini file", + ) + + +def add_subcommand(subparsers, func): + name = func.__name__ + assert name.endswith("_cmd") + name = name[:-4] + doc = func.__doc__.strip() + p = subparsers.add_parser(name, description=doc, help=doc) + p.set_defaults(func=func) + return p + + +def get_parser(): + """Return an ArgumentParser for the 'cmdeploy' CLI.""" + parser = argparse.ArgumentParser(description=description) + subparsers = parser.add_subparsers( + title="subcommands", + ) + + init_parser = add_subcommand(subparsers, init_cmd) + add_config_option(init_parser) + init_parser.add_argument( + "chatmail_domain", + action="store", + help="fully qualified DNS domain name for your chatmail instance", + ) + + install_parser = add_subcommand(subparsers, install_cmd) + add_config_option(install_parser) + return parser + +def write_initial_config(inipath, mailname, out): + inidir = importlib.resources.files(__package__).joinpath("ini") + content = inidir.joinpath("chatmail.ini.f").read_text().format(mailname=mailname) + if mailname.endswith(".testrun.org"): + override_inipath = inidir.joinpath("override-testrun.ini") + privacy = iniconfig.IniConfig(override_inipath)["privacy"] + lines = [] + for line in content.split("\n"): + for key, value in privacy.items(): + value_lines = value.strip().split("\n") + if not line.startswith(f"{key} =") or not value_lines: + continue + if len(value_lines) == 1: + lines.append(f"{key} = {value}") + else: + lines.append(f"{key} =") + for vl in value_lines: + lines.append(f" {vl}") + break + else: + lines.append(line) + content = "\n".join(lines) + + inipath.write_text(content) + out(f"written {inipath} for chatmail domain {mailname}") + + +def init_cmd(args, out): + """Initialize chatmail config file.""" + if args.chatmail_ini.exists(): + out.red(f"Path exists, not modifying: {args.xdcget_ini}") + raise SystemExit(1) + write_initial_config(args.chatmail_ini, args.chatmail_domain, out) + + +def install_cmd(args, out): + """Install or update chatmail services on the remote server. """ + try: + config = read_config(args.chatmail_ini) + except Exception as ex: + out.red(ex) + raise SystemExit(1) + + XXX + + +def main(args=None): + """Provide main entry point for 'xdcget' CLI invocation.""" + parser = get_parser() + args = parser.parse_args(args=args) + if not hasattr(args, "func"): + return parser.parse_args(["-h"]) + out = Out() + args.func(args, out) + + +if __name__ == "__main__": + main() diff --git a/defaults/chatmail.ini b/deploy-chatmail/src/deploy_chatmail/ini/chatmail.ini.f similarity index 95% rename from defaults/chatmail.ini rename to deploy-chatmail/src/deploy_chatmail/ini/chatmail.ini.f index 25adaefa..c9a90755 100644 --- a/defaults/chatmail.ini +++ b/deploy-chatmail/src/deploy_chatmail/ini/chatmail.ini.f @@ -1,6 +1,8 @@ +[params] + # mail domain (MUST be set to fully qualified chat mail domain) -domain = {mailname} +mailname = {mailname} # # If you only do private test deploys, you don't need to modify any settings below diff --git a/defaults/chatmail-testrun.org.ini b/deploy-chatmail/src/deploy_chatmail/ini/override-testrun.ini similarity index 67% rename from defaults/chatmail-testrun.org.ini rename to deploy-chatmail/src/deploy_chatmail/ini/override-testrun.ini index 5d422faa..d0b19b27 100644 --- a/defaults/chatmail-testrun.org.ini +++ b/deploy-chatmail/src/deploy_chatmail/ini/override-testrun.ini @@ -1,16 +1,13 @@ -mailname = {mailname} -max_user_send_per_minute = 60 -passthrough_recipients = privacy@testrun.org xstore@testrun.org +[privacy] -filtermail_smtp_port = 10080 -postfix_reinject_port = 10025 +passthrough_recipients = privacy@testrun.org privacy_postal = Merlinux GmbH, Represented by the managing director H. Krekel, Reichgrafen Str. 20, 79102 Freiburg, Germany -privacy_mail = delta-privacy@merlinux.eu +privacy_mail = privacy@testrun.org privacy_pdo = Prof. Dr. Fabian Schmieder, lexICT UG (limited), Ostfeldstr. 49, 30559 Hannover. You can contact him at *delta-privacy@merlinux.eu* (Keyword: DPO) diff --git a/tests/chatmaild/test_config.py b/tests/chatmaild/test_config.py index 1f5e139c..a5ed8d23 100644 --- a/tests/chatmaild/test_config.py +++ b/tests/chatmaild/test_config.py @@ -2,87 +2,27 @@ from chatmaild.config import read_config import chatmaild.config -def test_read_config_without_mailname(tmp_path, create_ini, monkeypatch): - mailname_path = tmp_path.joinpath("mailname") - mailname_path.write_text("something.example.org") - monkeypatch.setattr(chatmaild.config, "system_mailname_path", mailname_path) +def test_read_config_basic(make_config): + config = make_config("chat.example.org") + assert config.mailname == "chat.example.org" + assert not config.privacy_supervisor and not config.privacy_mail + assert not config.privacy_pdo and not config.privacy_postal - inipath = create_ini( - """ - [params] - max_user_send_per_minute = 40 - filtermail_smtp_port = 9875 - postfix_reinject_port = 9999 - passthrough_recipients = - """ - ) + inipath = config._inipath + inipath.write_text(inipath.read_text().replace("60", "37")) config = read_config(inipath) - assert config.mailname == "something.example.org" + assert config.max_user_send_per_minute == 37 + assert config.mailname == "chat.example.org" -def test_read_config_without_privacy_policy(tmp_path, create_ini): - inipath = create_ini( - """ - [params] - max_user_send_per_minute = 40 - filtermail_smtp_port = 9875 - postfix_reinject_port = 9999 - passthrough_recipients = - - [privacy:testrun] - domain = *.example.org - """ - ) - config = read_config(inipath, "something.example.org") - assert config.mailname == "something.example.org" - assert config.max_user_send_per_minute == 40 - assert config.filtermail_smtp_port == 9875 - assert config.postfix_reinject_port == 9999 - assert config.passthrough_recipients == [] - assert not config.privacy_postal - assert not config.privacy_mail - assert not config.privacy_pdo - assert not config.privacy_supervisor - - -def test_read_config(create_ini): - inipath = create_ini( - """ - [params] - max_user_send_per_minute = 40 - filtermail_smtp_port = 10080 - postfix_reinject_port = 10025 - passthrough_recipients = x@example.org y@example.org - - [privacy:testrun] - domain = *.testrun.org - - privacy_postal = - Postal Ltd - - privacy_mail = privacy@merlinux.eu - - privacy_pdo = - Postal PDO - You can contact him at *delta-privacy@merlinux.eu* (Keyword: DPO) - - privacy_supervisor = - line1 - line2 with space - """ - ) - - config = read_config(inipath, "something.testrun.org") - +def test_read_config_testrun(make_config): + config = make_config("something.testrun.org") assert config.mailname == "something.testrun.org" + assert len(config.privacy_postal.split("\n")) > 1 + assert len(config.privacy_supervisor.split("\n")) > 1 + assert len(config.privacy_pdo.split("\n")) > 1 + assert config.privacy_mail == "privacy@testrun.org" assert config.filtermail_smtp_port == 10080 assert config.postfix_reinject_port == 10025 - assert config.passthrough_recipients == ["x@example.org", "y@example.org"] - assert config.privacy_postal == "Postal Ltd" - assert config.privacy_mail == "privacy@merlinux.eu" - lines = config.privacy_pdo.split("\n") - assert lines[0] == "Postal PDO" - assert lines[1].startswith("You can ") - lines = config.privacy_supervisor.split("\n") - assert lines[0] == "line1" - assert lines[1] == "line2 with space" + assert config.max_user_send_per_minute == 60 + assert config.passthrough_recipients diff --git a/tests/conftest.py b/tests/conftest.py index 88c08346..686f72dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,13 +39,6 @@ def pytest_runtest_setup(item): pytest.skip("skipping slow test, use --slow to run") -@pytest.fixture -def inipath(): - dpath = importlib.resources.files("chatmaild") - inipath = dpath.joinpath("../../../chatmail.ini").resolve() - assert inipath.exists() - return inipath - @pytest.fixture def maildomain(): @@ -415,13 +408,13 @@ class CMUser: @pytest.fixture -def create_ini(tmp_path, inipath): - def create_ini_func(source=None): - if source is None: - source = inipath.read_text() - p = tmp_path.joinpath("chatmail.ini") - assert not p.exists(), p - p.write_text(textwrap.dedent(source)) - return p +def make_config(tmp_path): + from deploy_chatmail.cmdeploy import main + from chatmaild.config import read_config + inipath = tmp_path.joinpath("chatmail.ini") - return create_ini_func + def make_conf(mailname): + main(["init", "--config", str(inipath), mailname]) + return read_config(inipath) + + return make_conf diff --git a/tests/test_cmdeploy.py b/tests/test_cmdeploy.py new file mode 100644 index 00000000..9f7291c7 --- /dev/null +++ b/tests/test_cmdeploy.py @@ -0,0 +1,104 @@ + +import os +import sys +import pytest +from deploy_chatmail.cmdeploy import get_parser, main +from chatmaild.config import read_config + + +class TestCmdline: + def test_parser(self, capsys): + parser = get_parser() + parser.parse_args([]) + init = parser.parse_args(["init", "chat.example.org"]) + update = parser.parse_args(["install"]) + assert init and update + + def test_init(self, tmpdir): + tmpdir.chdir() + main(["init", "chat.example.org"]) + inipath = tmpdir.join("chatmail.ini") + config = read_config(inipath.strpath) + assert config.mailname == "chat.example.org" + + def test_no_args_description(self, capsys): + with pytest.raises(SystemExit) as excinfo: + main([]) + assert excinfo.value.code == 0 + out, err = capsys.readouterr() + assert "Collect webxdc" in out + assert " init " in out and "Initialize config" in out + + def test_version(self, capsys): + with pytest.raises(SystemExit) as excinfo: + main(["--version"]) + assert excinfo.value.code == 0 + out, err = capsys.readouterr() + assert out.strip() == xdcget.__version__ + + def test_init_not_overwrite(self, tmpdir): + tmpdir.chdir() + main(["init"]) + with pytest.raises(SystemExit): + main(["init"]) + + def test_update_from_different_dir(self, config_example1, tmp_path): + p = tmp_path.joinpath("somewhere") + p.mkdir() + os.chdir(p) + main(["--config", "../xdcget.ini", "update"]) + + def test_prune_index(self, iniconfig): + iniconfig.add_source( + app_id="webxdc-poll", + source_code_url="https://codeberg.org/webxdc/poll", + ) + iniconfig.add_lock_entry( + app_id="webxdc-poll", + name="Poll", + tag_name="v1.0.1", + url="https://codeberg.org/attachments/d53543bd-d805-4aba-926d-88eefc7a9eef", + date="2023-07-05T20:30:48Z", + cache_relname="webxdc-poll-v1.0.1.xdc", + ) + iniconfig.add_lock_entry( + app_id="webxdc-checklist", + name="Checklist", + tag_name="v0.0.2", + url="https://codeberg.org/attachments/65d05b8d-a97c-4fb6-a534-e308c382f874", + date="2023-07-07T18:05:19Z", + cache_relname="webxdc-checklist-v0.0.2.xdc", + ) + + config = iniconfig.create() + assert "webxdc-poll" in config.index_path.read_text() + assert "webxdc-checklist" in config.index_path.read_text() + main(["update"]) + assert "webxdc-poll" in config.index_path.read_text() + assert "webxdc-checklist" not in config.index_path.read_text() + + def test_update_empty(self, iniconfig): + iniconfig.create() + with pytest.raises(SystemExit): + main(["update"]) + + def test_update_no_network(self, capfd, config_example1, monkeypatch): + main(["update"]) + p = config_example1.export_dir.joinpath("xdcget.lock") + assert p.exists() + assert len(p.read_text()) > 50 + monkeypatch.delattr(sys.modules["requests"], "get") + main(["update", "--offline"]) + + def test_export_json(self, capfd, config_example1, monkeypatch): + main(["update"]) + p = config_example1.export_dir.joinpath("xdcget-lock.json") + assert p.exists() + with p.open() as f: + app_list = json.load(f) + assert len(app_list) == 2 + checklist, poll = app_list + p = config_example1.export_dir.joinpath(checklist["icon_relname"]) + assert p.exists() and "icon" in p.name + p = config_example1.export_dir.joinpath(poll["icon_relname"]) + assert p.exists() and "icon" in p.name