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")