diff --git a/CHANGELOG.md b/CHANGELOG.md index dad307e2..a50ad2d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ * Fix: catch case where getpwuid is not returning a username * Fix: add support for query without comp-type * Fix: expanded event with dates are missing VALUE=DATE +* Add: [hook] dryrun: option to disable real hook action for testing, add tests for email+rabbitmq ## 3.5.4 * Improve: item filter enhanced for 3rd level supporting VALARM and honoring TRIGGER (offset or absolute) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index eb2ec99a..87293425 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -1529,6 +1529,14 @@ Available types: Default: `none` +##### dryrun + +_(> 3.5.4)_ + +Dry-Run (do not really trigger hook action) + +Default: `False` + ##### rabbitmq_endpoint _(>= 3.2.0)_ diff --git a/config b/config index 51e5324c..9b6c577b 100644 --- a/config +++ b/config @@ -313,9 +313,16 @@ # Hook types # Value: none | rabbitmq | email #type = none + +# dry-run (do not really trigger hook action) +#dryrun = False + +# hook: rabbitmq #rabbitmq_endpoint = #rabbitmq_topic = #rabbitmq_queue_type = classic + +# hook: email #smtp_server = localhost #smtp_port = 25 #smtp_security = starttls diff --git a/radicale/config.py b/radicale/config.py index 7deee5ca..63f627b8 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -427,6 +427,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "help": "hook backend", "type": str, "internal": hook.INTERNAL_TYPES}), + ("dryrun", { + "value": "False", + "help": "dry-run (do not really trigger hook action)", + "type": bool}), ("rabbitmq_endpoint", { "value": "", "help": "endpoint where rabbitmq server is running", diff --git a/radicale/hook/email/__init__.py b/radicale/hook/email/__init__.py index 28885e50..75d043aa 100644 --- a/radicale/hook/email/__init__.py +++ b/radicale/hook/email/__init__.py @@ -1,6 +1,20 @@ # This file is related to Radicale - CalDAV and CardDAV server # for email notifications # Copyright © 2025-2025 Nate Harris +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + import enum import re import smtplib @@ -596,6 +610,7 @@ class EmailConfig: password: str, from_email: str, send_mass_emails: bool, + dryrun: bool, added_template: MessageTemplate, removed_template: MessageTemplate): self.host = host @@ -606,6 +621,7 @@ class EmailConfig: self.password = password self.from_email = from_email self.send_mass_emails = send_mass_emails + self.dryrun = dryrun self.added_template = added_template self.removed_template = removed_template self.updated_template = added_template # Reuse added template for updated events @@ -616,7 +632,7 @@ class EmailConfig: Return a string representation of the EmailConfig. """ return f"EmailConfig(host={self.host}, port={self.port}, username={self.username}, " \ - f"from_email={self.from_email}, send_mass_emails={self.send_mass_emails})" + f"from_email={self.from_email}, send_mass_emails={self.send_mass_emails}, dryrun={self.dryrun})" def __repr__(self): return self.__str__() @@ -733,6 +749,10 @@ class EmailConfig: logger.warning("No valid email addresses found in attendees. Cannot send email.") return False + if self.dryrun is True: + logger.warning("Hook 'email': DRY-RUN _send_email / to_addresses=%r", to_addresses) + return True + # Add headers message = MIMEMultipart("mixed") message["From"] = self.from_email @@ -805,6 +825,7 @@ def _read_event(vobject_data: str) -> EmailEvent: class Hook(BaseHook): def __init__(self, configuration): super().__init__(configuration) + self.dryrun = self.configuration.get("hook", "dryrun") self.email_config = EmailConfig( host=self.configuration.get("hook", "smtp_server"), port=self.configuration.get("hook", "smtp_port"), @@ -814,6 +835,7 @@ class Hook(BaseHook): password=self.configuration.get("hook", "smtp_password"), from_email=self.configuration.get("hook", "from_email"), send_mass_emails=self.configuration.get("hook", "mass_email"), + dryrun=self.configuration.get("hook", "dryrun"), added_template=MessageTemplate( subject="You have been added to an event", body=self.configuration.get("hook", "added_template") @@ -844,7 +866,10 @@ class Hook(BaseHook): :type notification_item: HookNotificationItem :return: None """ - logger.debug("Received notification item: %s", notification_item) + if self.dryrun: + logger.warning("Hook 'email': DRY-RUN received notification_item: %r", vars(notification_item)) + else: + logger.debug("Received notification_item: %r", vars(notification_item)) try: notification_type = HookNotificationItemTypes(value=notification_item.type) except ValueError: diff --git a/radicale/hook/rabbitmq/__init__.py b/radicale/hook/rabbitmq/__init__.py index 2323ed43..12d521b4 100644 --- a/radicale/hook/rabbitmq/__init__.py +++ b/radicale/hook/rabbitmq/__init__.py @@ -1,3 +1,20 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2020-2024 Tuna Celik +# Copyright © 2025-2025 Peter Bieringer +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + import pika from pika.exceptions import ChannelWrongStateError, StreamLostError @@ -14,16 +31,26 @@ class Hook(hook.BaseHook): self._topic = configuration.get("hook", "rabbitmq_topic") self._queue_type = configuration.get("hook", "rabbitmq_queue_type") self._encoding = configuration.get("encoding", "stock") + self._dryrun = configuration.get("hook", "dryrun") + logger.info("Hook 'rabbitmq': endpoint=%r topic=%r queue_type=%r dryrun=%s", self._endpoint, self._topic, self._queue_type, self._dryrun) self._make_connection_synced() self._make_declare_queue_synced() def _make_connection_synced(self): parameters = pika.URLParameters(self._endpoint) + if self._dryrun is True: + logger.warning("Hook 'rabbitmq': DRY-RUN _make_connection_synced / parameters=%r", parameters) + return + logger.debug("Hook 'rabbitmq': _make_connection_synced / parameters=%r", parameters) connection = pika.BlockingConnection(parameters) self._channel = connection.channel() def _make_declare_queue_synced(self): + if self._dryrun is True: + logger.warning("Hook 'rabbitmq': DRY-RUN _make_declare_queue_synced") + return + logger.debug("Hook 'rabbitmq': _make_declare_queue_synced") self._channel.queue_declare(queue=self._topic, durable=True, arguments={"x-queue-type": self._queue_type}) def notify(self, notification_item): @@ -31,6 +58,9 @@ class Hook(hook.BaseHook): self._notify(notification_item, True) def _notify(self, notification_item, recall): + if self._dryrun is True: + logger.warning("Hook 'rabbitmq': DRY-RUN _notify / notification_item: %r", vars(notification_item)) + return try: self._channel.basic_publish( exchange='', diff --git a/radicale/tests/test_hook_email.py b/radicale/tests/test_hook_email.py new file mode 100644 index 00000000..74674589 --- /dev/null +++ b/radicale/tests/test_hook_email.py @@ -0,0 +1,110 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2025-2025 Peter Bieringer +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +Radicale tests related to hook 'email' + +""" + +import logging +import os + +from radicale.tests import BaseTest +from radicale.tests.helpers import get_file_content + + +class TestHooks(BaseTest): + """Tests with hooks.""" + + def setup_method(self) -> None: + BaseTest.setup_method(self) + rights_file_path = os.path.join(self.colpath, "rights") + with open(rights_file_path, "w") as f: + f.write("""\ +[permit delete collection] +user: .* +collection: test-permit-delete +permissions: RrWwD + +[forbid delete collection] +user: .* +collection: test-forbid-delete +permissions: RrWwd + +[permit overwrite collection] +user: .* +collection: test-permit-overwrite +permissions: RrWwO + +[forbid overwrite collection] +user: .* +collection: test-forbid-overwrite +permissions: RrWwo + +[allow all] +user: .* +collection: .* +permissions: RrWw""") + self.configure({"rights": {"file": rights_file_path, + "type": "from_file"}}) + self.configure({"hook": {"type": "email", + "dryrun": "True"}}) + + def test_add_event(self, caplog) -> None: + caplog.set_level(logging.WARNING) + """Add an event.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + path = "/calendar.ics/event1.ics" + self.put(path, event) + _, headers, answer = self.request("GET", path, check=200) + assert "ETag" in headers + assert headers["Content-Type"] == "text/calendar; charset=utf-8" + assert "VEVENT" in answer + assert "Event" in answer + assert "UID:event" in answer + found = 0 + for line in caplog.messages: + if line.find("notification_item: {'type': 'upsert'") != -1: + found = found | 1 + if line.find("to_addresses=['janedoe@example.com']") != -1: + found = found | 2 + if line.find("to_addresses=['johndoe@example.com']") != -1: + found = found | 4 + if (found != 7): + raise ValueError("Logging misses expected log lines, found=%d", found) + + def test_delete_event(self, caplog) -> None: + caplog.set_level(logging.WARNING) + """Delete an event.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + path = "/calendar.ics/event1.ics" + self.put(path, event) + _, responses = self.delete(path) + assert responses[path] == 200 + _, answer = self.get("/calendar.ics/") + assert "VEVENT" not in answer + found = 0 + for line in caplog.messages: + if line.find("notification_item: {'type': 'delete'") != -1: + found = found | 1 + if line.find("to_addresses=['janedoe@example.com']") != -1: + found = found | 2 + if line.find("to_addresses=['johndoe@example.com']") != -1: + found = found | 4 + if (found != 7): + raise ValueError("Logging misses expected log lines, found=%d", found) diff --git a/radicale/tests/test_hook_rabbitmq.py b/radicale/tests/test_hook_rabbitmq.py new file mode 100644 index 00000000..42cedfce --- /dev/null +++ b/radicale/tests/test_hook_rabbitmq.py @@ -0,0 +1,102 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright © 2025-2025 Peter Bieringer +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +Radicale tests related to hook 'rabbitmq' + +""" + +import logging +import os + +from radicale.tests import BaseTest +from radicale.tests.helpers import get_file_content + + +class TestHooks(BaseTest): + """Tests with hooks.""" + + def setup_method(self) -> None: + BaseTest.setup_method(self) + rights_file_path = os.path.join(self.colpath, "rights") + with open(rights_file_path, "w") as f: + f.write("""\ +[permit delete collection] +user: .* +collection: test-permit-delete +permissions: RrWwD + +[forbid delete collection] +user: .* +collection: test-forbid-delete +permissions: RrWwd + +[permit overwrite collection] +user: .* +collection: test-permit-overwrite +permissions: RrWwO + +[forbid overwrite collection] +user: .* +collection: test-forbid-overwrite +permissions: RrWwo + +[allow all] +user: .* +collection: .* +permissions: RrWw""") + self.configure({"rights": {"file": rights_file_path, + "type": "from_file"}}) + self.configure({"hook": {"type": "rabbitmq", + "dryrun": "True"}}) + + def test_add_event(self, caplog) -> None: + caplog.set_level(logging.WARNING) + """Add an event.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + path = "/calendar.ics/event1.ics" + self.put(path, event) + _, headers, answer = self.request("GET", path, check=200) + assert "ETag" in headers + assert headers["Content-Type"] == "text/calendar; charset=utf-8" + assert "VEVENT" in answer + assert "Event" in answer + assert "UID:event" in answer + found = False + for line in caplog.messages: + if line.find("notification_item: {'type': 'upsert'") != -1: + found = True + if (found is False): + raise ValueError("Logging misses expected log line") + + def test_delete_event(self, caplog) -> None: + caplog.set_level(logging.WARNING) + """Delete an event.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + path = "/calendar.ics/event1.ics" + self.put(path, event) + _, responses = self.delete(path) + assert responses[path] == 200 + _, answer = self.get("/calendar.ics/") + assert "VEVENT" not in answer + found = False + for line in caplog.messages: + if line.find("notification_item: {'type': 'delete'") != -1: + found = True + if (found is False): + raise ValueError("Logging misses expected log line")