From 717985763c665d30b48eab9d418f49a067b7c47e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 10 Apr 2026 07:46:40 +0200 Subject: [PATCH 01/13] hook/email: add support for date-only DTSTART/DTEND --- radicale/hook/email/__init__.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/radicale/hook/email/__init__.py b/radicale/hook/email/__init__.py index aaf53a98..d46f0e9b 100644 --- a/radicale/hook/email/__init__.py +++ b/radicale/hook/email/__init__.py @@ -21,13 +21,13 @@ import json import re import smtplib import ssl -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from email.encoders import encode_base64 from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import formatdate -from typing import Any, Dict, List, Optional, Sequence, Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union import vobject @@ -991,9 +991,14 @@ class Hook(BaseHook): email_event_end_time = email_event_event.datetime_end # type: ignore # Skip notification if the event end time is more than 1 minute in the past. if email_event_end_time and email_event_end_time.time: - event_end = email_event_end_time.time # type: ignore - now = datetime.now( - event_end.tzinfo) if event_end.tzinfo else datetime.now() # Handle timezone-aware datetime + event_end = email_event_end_time.time + now: Union[datetime, date] + if hasattr(event_end, "tzinfo"): + now = datetime.now( + event_end.tzinfo) if event_end.tzinfo else datetime.now() # Handle timezone-aware datetime + else: + now = date.today() + logger.trace("event_end=%r now=%r", event_end, now) if event_end < (now - timedelta(minutes=1)): logger.warning("Event end time is in the past, skipping notification for event: %s", email_event_event.uid) From 785b19812ee3b41a01eae4c10ba56dfaded07154 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 10 Apr 2026 07:48:10 +0200 Subject: [PATCH 02/13] hook/email: remove/solve some type:ignore --- radicale/hook/email/__init__.py | 50 ++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/radicale/hook/email/__init__.py b/radicale/hook/email/__init__.py index d46f0e9b..e89961db 100644 --- a/radicale/hook/email/__init__.py +++ b/radicale/hook/email/__init__.py @@ -296,7 +296,7 @@ class VComponent: return [None] if not isinstance(sub_vobjects, (list, tuple)): sub_vobjects = [sub_vobjects] - return ([_class(vobject_item=so) for so in sub_vobjects if # type: ignore + return ([_class(vobject_item=so) for so in sub_vobjects if # type: ignore # TODO: Missing positional argument "component_type" in call to "VComponent" isinstance(so, vobject.base.Component)] or [None]) @@ -444,12 +444,12 @@ class Timezone(VComponent): @property def standard(self) -> Optional[StandardTimezone]: """Return the STANDARD subcomponent if it exists.""" - return self._get_sub_vobjects("standard", StandardTimezone)[0] # type: ignore + return self._get_sub_vobjects("standard", StandardTimezone)[0] # type: ignore # TODO: got "VComponent | None", expected "StandardTimezone | None" @property def daylight(self) -> Optional[DaylightTimezone]: """Return the DAYLIGHT subcomponent if it exists.""" - return self._get_sub_vobjects("daylight", DaylightTimezone)[0] # type: ignore + return self._get_sub_vobjects("daylight", DaylightTimezone)[0] # type: ignore # TODO: got "VComponent | None", expected "DaylightTimezone | None" class Event(VComponent): @@ -513,7 +513,7 @@ class Event(VComponent): @property def alarms(self) -> List[Alarm]: """Return a list of VALARM items in the event.""" - return self._get_sub_vobjects("valarm", Alarm) # type: ignore # Can be multiple + return self._get_sub_vobjects("valarm", Alarm) # type: ignore # Can be multiple # TODO: got "list[VComponent | None]", expected "list[Alarm]" @property def attendees(self) -> List[Attendee]: @@ -541,14 +541,14 @@ class Calendar(VComponent): @property def event(self) -> Optional[Event]: """Return the VEVENT item in the calendar.""" - return self._get_sub_vobjects("vevent", Event)[0] # type: ignore + return self._get_sub_vobjects("vevent", Event)[0] # type: ignore # TODO: got "VComponent | None", expected "Event | None" # TODO: Add VTODO and VJOURNAL support if needed @property def timezone(self) -> Optional[Timezone]: """Return the VTIMEZONE item in the calendar.""" - return self._get_sub_vobjects("vtimezone", Timezone)[0] # type: ignore + return self._get_sub_vobjects("vtimezone", Timezone)[0] # type: ignore # TODO: got "VComponent | None", expected "Timezone | None" class EmailEvent: @@ -637,19 +637,20 @@ class MessageTemplate: :param attendee: The specific attendee to include in the message, if not a mass email. :return: The formatted message body. """ + attendee_name: Union[str, None] if mass_email: # If this is a mass email, we do not use individual attendee names attendee_name = "everyone" else: assert attendee is not None, "Attendee must be provided for non-mass emails" - attendee_name = attendee.name if attendee else "Unknown Name" # type: ignore + attendee_name = attendee.name if attendee else "Unknown Name" context = { "attendee_name": attendee_name, "from_email": from_email, "organizer_name": event.event.organizer or "Unknown Organizer", "event_title": event.event.summary or "No Title", - "event_start_time": event.event.datetime_start.time_string(), # type: ignore + "event_start_time": event.event.datetime_start.time_string() if event.event.datetime_start else "No Start Time", "event_end_time": event.event.datetime_end.time_string() if event.event.datetime_end else "No End Time", "event_location": event.event.location or "No Location Specified", } @@ -666,19 +667,20 @@ class MessageTemplate: :param attendee: The specific attendee to include in the message, if not a mass email. :return: The formatted message subject. """ + attendee_name: Union[str, None] if mass_email: # If this is a mass email, we do not use individual attendee names attendee_name = "everyone" else: assert attendee is not None, "Attendee must be provided for non-mass emails" - attendee_name = attendee.name if attendee else "Unknown Name" # type: ignore + attendee_name = attendee.name if attendee else "Unknown Name" context = { "attendee_name": attendee_name, "from_email": from_email, "organizer_name": event.event.organizer or "Unknown Organizer", "event_title": event.event.summary or "No Title", - "event_start_time": event.event.datetime_start.time_string(), # type: ignore + "event_start_time": event.event.datetime_start.time_string() if event.event.datetime_start else "No Start Time", "event_end_time": event.event.datetime_end.time_string() if event.event.datetime_end else "No End Time", "event_location": event.event.location or "No Location Specified", } @@ -890,10 +892,10 @@ def _read_event(vobject_data: str) -> EmailEvent: """ v_cal: vobject.base.Component = vobject.readOne(vobject_data) cal: Calendar = Calendar(vobject_item=v_cal) - event: Event = cal.event # type: ignore + event: Union[Event, None] = cal.event return EmailEvent( - event=event, + event=event, # type: ignore # TODO: Argument "event" to "EmailEvent" has incompatible type "Event | None"; expected "Event" ics_content=vobject_data, ics_file_name="event.ics" ) @@ -955,6 +957,10 @@ class Hook(BaseHook): :type notification_item: HookNotificationItem :return: None """ + email_success: bool + email_event: EmailEvent + new_event: Union[Event, None] + if self.email_config.dryrun: logger.warning("Hook 'email': DRY-RUN received notification_item: %r", vars(notification_item)) else: @@ -972,7 +978,7 @@ class Hook(BaseHook): elif notification_type == HookNotificationItemTypes.UPSERT: # Handle upsert notifications - new_item_str: str = notification_item.new_content # type: ignore # A serialized vobject.base.Component + new_item_str: str = notification_item.new_content # A serialized vobject.base.Component previous_item_str: Optional[str] = notification_item.old_content if not ics_contents_contains_event(contents=new_item_str): @@ -980,15 +986,15 @@ class Hook(BaseHook): logger.debug("No event found in the ICS file, skipping notification.") return - email_event: EmailEvent = _read_event(vobject_data=new_item_str) # type: ignore + email_event = _read_event(vobject_data=new_item_str) if not email_event: logger.error("Failed to read event from new content: %s", new_item_str) return - email_event_event = email_event.event # type: ignore + email_event_event = email_event.event if not email_event_event: logger.error("Event could not be parsed from the new content: %s", new_item_str) return - email_event_end_time = email_event_event.datetime_end # type: ignore + email_event_end_time = email_event_event.datetime_end # Skip notification if the event end time is more than 1 minute in the past. if email_event_end_time and email_event_end_time.time: event_end = email_event_end_time.time @@ -1038,7 +1044,7 @@ class Hook(BaseHook): # Notify added attendees as "event created" if added_attendees: - email_success: bool = self.email_config.send_added_email( # type: ignore + email_success = self.email_config.send_added_email( attendees=added_attendees, event=email_event ) @@ -1048,7 +1054,7 @@ class Hook(BaseHook): # Notify removed attendees as "event deleted" if removed_attendees: - email_success: bool = self.email_config.send_deleted_email( # type: ignore + email_success = self.email_config.send_deleted_email( attendees=removed_attendees, event=email_event ) @@ -1059,7 +1065,7 @@ class Hook(BaseHook): # Notify unaltered attendees as "event updated" if details other than attendees have changed if unaltered_attendees and event_details_other_than_attendees_changed(original_event=previous_event, new_event=new_event): - email_success: bool = self.email_config.send_updated_email( # type: ignore + email_success = self.email_config.send_updated_email( attendees=unaltered_attendees, event=email_event ) @@ -1075,16 +1081,16 @@ class Hook(BaseHook): elif notification_type == HookNotificationItemTypes.DELETE: # Handle delete notifications - deleted_item_str: str = notification_item.old_content # type: ignore # A serialized vobject.base.Component + deleted_item_str: str = notification_item.old_content # A serialized vobject.base.Component if not ics_contents_contains_event(contents=deleted_item_str): # If the ICS file does not contain an event, we do not send any notifications. logger.debug("No event found in the ICS file, skipping notification.") return - email_event: EmailEvent = _read_event(vobject_data=deleted_item_str) # type: ignore + email_event = _read_event(vobject_data=deleted_item_str) - email_success: bool = self.email_config.send_deleted_email( # type: ignore + email_success = self.email_config.send_deleted_email( attendees=email_event.event.attendees, event=email_event ) From 20846d3bd63c5201587be627bacf881284ca5326 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 10 Apr 2026 07:48:40 +0200 Subject: [PATCH 03/13] hook/email: log event.uuid on notification --- radicale/hook/email/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/radicale/hook/email/__init__.py b/radicale/hook/email/__init__.py index e89961db..42d914f9 100644 --- a/radicale/hook/email/__init__.py +++ b/radicale/hook/email/__init__.py @@ -1013,8 +1013,9 @@ class Hook(BaseHook): if not previous_item_str: # Dealing with a completely new event, no previous content to compare against. # Email every attendee about the new event. - logger.debug("New event detected, sending notifications to all attendees.") - email_success: bool = self.email_config.send_added_email( # type: ignore + logger.info("New event detected, sending notifications to all attendees: %s", + email_event.event.uid) + email_success = self.email_config.send_added_email( attendees=email_event.event.attendees, event=email_event ) From 30636b4601951448ab8454f962784f991fc960f8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 10 Apr 2026 07:49:06 +0200 Subject: [PATCH 04/13] hook/email: remove/solve some type:ignore --- radicale/hook/email/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/radicale/hook/email/__init__.py b/radicale/hook/email/__init__.py index 42d914f9..bb098444 100644 --- a/radicale/hook/email/__init__.py +++ b/radicale/hook/email/__init__.py @@ -1025,12 +1025,15 @@ class Hook(BaseHook): return # Dealing with an update to an existing event, compare new and previous content. - new_event: Event = read_ics_event(contents=new_item_str) # type: ignore + new_event = read_ics_event(contents=new_item_str) + if new_event is None: + return + previous_event: Optional[Event] = read_ics_event(contents=previous_item_str) if not previous_event: # If we cannot parse the previous event for some reason, simply treat it as a new event. logger.warning("Previous event content could not be parsed, treating as a new event.") - email_success: bool = self.email_config.send_added_email( # type: ignore + email_success = self.email_config.send_added_email( attendees=email_event.event.attendees, event=email_event ) From 1361638bac094eb09b64870245cd166da41267b1 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 10 Apr 2026 07:49:25 +0200 Subject: [PATCH 05/13] hook/email/tests: extend test cases for date-only --- radicale/tests/test_hook_email.py | 52 +++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/radicale/tests/test_hook_email.py b/radicale/tests/test_hook_email.py index b7fef935..451ee7fa 100644 --- a/radicale/tests/test_hook_email.py +++ b/radicale/tests/test_hook_email.py @@ -70,15 +70,29 @@ permissions: RrWw""") future_date = datetime.now() + timedelta(days=1) return future_date.strftime("%Y%m%dT%H%M%S") + def _future_date(self) -> str: + """Return a date for a future date.""" + future_date = datetime.now() + timedelta(days=1) + return future_date.strftime("%Y%m%d") + def _past_date_timestamp(self) -> str: past_date = datetime.now() - timedelta(days=1) return past_date.strftime("%Y%m%dT%H%M%S") + def _past_date(self) -> str: + past_date = datetime.now() - timedelta(days=1) + return past_date.strftime("%Y%m%d") + def _replace_end_date_in_event(self, event: str, new_date: str) -> str: """Replace the end date in an event string.""" return re.sub(r"DTEND;TZID=Europe/Paris:\d{8}T\d{6}", f"DTEND;TZID=Europe/Paris:{new_date}", event) + def _replace_end_onlydate_in_event(self, event: str, new_date: str) -> str: + """Replace the end date in an event string.""" + return re.sub(r"DTEND;VALUE=DATE:\d{8}", + f"DTEND;VALUE=DATE:{new_date}", event) + def test_add_event_with_future_end_date(self, caplog) -> None: caplog.set_level(logging.WARNING) """Add an event.""" @@ -158,3 +172,41 @@ permissions: RrWw""") assert len([log for log in logs if "received notification_item: {'type': 'delete'," in log]) == 1 # Should have a log saying that no email is sent due to past end date assert len([log for log in logs if "Event end time is in the past, skipping notification for event: event1" in log]) == 1 + + def test_delete_event_with_future_end_onlydate(self, caplog) -> None: + caplog.set_level(logging.INFO) + """Delete an event.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event_issue1917_1.ics") + event = self._replace_end_onlydate_in_event(event, self._future_date()) + 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 + + logs = caplog.messages + # Should have a log saying the notification item was received + assert len([log for log in logs if "received notification_item: {'type': 'delete'," in log]) == 1 + # Should NOT have a log saying that no email is sent (email won't actually be sent due to dryrun) + assert len([log for log in logs if "New event detected, sending notifications to all attendees: event1" in log]) == 1 + + def test_delete_event_with_past_onlyend_date(self, caplog) -> None: + caplog.set_level(logging.WARNING) + """Delete an event.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event_issue1917_1.ics") + event = self._replace_end_onlydate_in_event(event, self._past_date()) + 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 + + logs = caplog.messages + # Should have a log saying the notification item was received + assert len([log for log in logs if "received notification_item: {'type': 'delete'," in log]) == 1 + # Should have a log saying that no email is sent due to past end date + assert len([log for log in logs if "Event end time is in the past, skipping notification for event: event1" in log]) == 1 From 7870d6431a1bb55898b723ffc9c122d7c6dd1487 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 10 Apr 2026 07:49:44 +0200 Subject: [PATCH 06/13] hook/email/test: add new test item --- radicale/tests/static/event_issue1917_1.ics | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 radicale/tests/static/event_issue1917_1.ics diff --git a/radicale/tests/static/event_issue1917_1.ics b/radicale/tests/static/event_issue1917_1.ics new file mode 100644 index 00000000..a78d659c --- /dev/null +++ b/radicale/tests/static/event_issue1917_1.ics @@ -0,0 +1,10 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN +BEGIN:VEVENT +UID:event1 +DTSTART;VALUE=DATE:20250716 +DTEND;VALUE=DATE:20250718 +SUMMARY:test date-only +END:VEVENT +END:VCALENDAR From b8f3ed8c6995e352ee7970a6698d987bf149cc94 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 10 Apr 2026 07:51:56 +0200 Subject: [PATCH 07/13] hook/email/test: improve test case --- radicale/tests/test_hook_email.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/tests/test_hook_email.py b/radicale/tests/test_hook_email.py index 451ee7fa..cf636d0f 100644 --- a/radicale/tests/test_hook_email.py +++ b/radicale/tests/test_hook_email.py @@ -136,7 +136,7 @@ permissions: RrWw""") assert len([log for log in logs if "Event end time is in the past, skipping notification for event: event1" in log]) == 1 def test_delete_event_with_future_end_date(self, caplog) -> None: - caplog.set_level(logging.WARNING) + caplog.set_level(logging.INFO) """Delete an event.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") @@ -152,7 +152,7 @@ permissions: RrWw""") # Should have a log saying the notification item was received assert len([log for log in logs if "received notification_item: {'type': 'delete'," in log]) == 1 # Should NOT have a log saying that no email is sent (email won't actually be sent due to dryrun) - assert len([log for log in logs if "skipping notification for event: event1" in log]) == 0 + assert len([log for log in logs if "New event detected, sending notifications to all attendees: event1" in log]) == 1 def test_delete_event_with_past_end_date(self, caplog) -> None: caplog.set_level(logging.WARNING) From d89d78810f79c5fc0685bdee35178e7d56292006 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 10 Apr 2026 07:56:40 +0200 Subject: [PATCH 08/13] hook/email: fix for flake8 --- radicale/hook/email/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/hook/email/__init__.py b/radicale/hook/email/__init__.py index bb098444..11540e8e 100644 --- a/radicale/hook/email/__init__.py +++ b/radicale/hook/email/__init__.py @@ -895,7 +895,7 @@ def _read_event(vobject_data: str) -> EmailEvent: event: Union[Event, None] = cal.event return EmailEvent( - event=event, # type: ignore # TODO: Argument "event" to "EmailEvent" has incompatible type "Event | None"; expected "Event" + event=event, # type: ignore # TODO: Argument "event" to "EmailEvent" has incompatible type "Event | None"; expected "Event" ics_content=vobject_data, ics_file_name="event.ics" ) From 6a906d9f2b63dcb619063711346032a82f384a04 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 10 Apr 2026 07:56:56 +0200 Subject: [PATCH 09/13] hook/email: extend changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e441527..52c2ea79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * Adjustment: replace logging/trace_on_debug by new log level "trace" * Adjustment: sharing/token: adjust default permissions to "rp" * Fix: sharing/propfind+proppatch: permission check related to properties +* Fix: hook/email: add support for date-only events ## 3.7.0 From c44c414b91841514e018f482100562d5db3bd7ba Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 10 Apr 2026 08:33:57 +0200 Subject: [PATCH 10/13] event item with only 1 attendee --- radicale/tests/static/event1a1.ics | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 radicale/tests/static/event1a1.ics diff --git a/radicale/tests/static/event1a1.ics b/radicale/tests/static/event1a1.ics new file mode 100644 index 00000000..15a43be8 --- /dev/null +++ b/radicale/tests/static/event1a1.ics @@ -0,0 +1,34 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Paris +X-LIC-LOCATION:Europe/Paris +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +TZNAME:CEST +DTSTART:19700329T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +TZNAME:CET +DTSTART:19701025T030000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20130902T150157Z +LAST-MODIFIED:20130902T150158Z +DTSTAMP:20130902T150158Z +UID:event1 +SUMMARY:Event +CATEGORIES:some_category1,another_category2 +ORGANIZER:mailto:unclesam@example.com +ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com +DTSTART;TZID=Europe/Paris:20130901T180000 +DTEND;TZID=Europe/Paris:20130901T190000 +END:VEVENT +END:VCALENDAR From 3158736579864067cac3792a0694fd2b5021db1f Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 10 Apr 2026 08:34:28 +0200 Subject: [PATCH 11/13] hook/email: fix body in case of mass-email is active but only one attendee --- radicale/hook/email/__init__.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/radicale/hook/email/__init__.py b/radicale/hook/email/__init__.py index 11540e8e..0602e416 100644 --- a/radicale/hook/email/__init__.py +++ b/radicale/hook/email/__init__.py @@ -772,10 +772,18 @@ class EmailConfig: """ if self.send_mass_emails: # If mass emails are enabled, we send one email to all attendees - body = template.build_message(event=event, from_email=self.from_email, - mass_email=self.send_mass_emails, attendee=None) - subject = template.build_subject(event=event, from_email=self.from_email, - mass_email=self.send_mass_emails, attendee=None) + if len(attendees) == 1: + # only one attendee + logger.trace("send_mass_emails=True but only 1 attendee, fallback to non-mass") + body = template.build_message(event=event, from_email=self.from_email, + mass_email=False, attendee=attendees[0]) + subject = template.build_subject(event=event, from_email=self.from_email, + mass_email=False, attendee=attendees[0]) + else: + body = template.build_message(event=event, from_email=self.from_email, + mass_email=self.send_mass_emails, attendee=None) + subject = template.build_subject(event=event, from_email=self.from_email, + mass_email=self.send_mass_emails, attendee=None) return self._send_email(subject=subject, body=body, attendees=attendees, ics_attachment=ics_attachment) else: From 5388740ac266d5f4edae5dc52c0307d2e7cbb828 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 10 Apr 2026 08:35:05 +0200 Subject: [PATCH 12/13] hook/email: add additional test cases, extend log for that --- radicale/hook/email/__init__.py | 4 ++- radicale/tests/test_hook_email.py | 56 +++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/radicale/hook/email/__init__.py b/radicale/hook/email/__init__.py index 0602e416..9651e8b4 100644 --- a/radicale/hook/email/__init__.py +++ b/radicale/hook/email/__init__.py @@ -837,7 +837,9 @@ class EmailConfig: return False if self.dryrun is True: - logger.warning("Hook 'email': DRY-RUN _send_email / to_addresses=%r", to_addresses) + logger.notice("Hook 'email': DRY-RUN _send_email / to_addresses=%r", to_addresses) + logger.notice("Hook 'email': DRY-RUN _send_email / subject=%r", subject) + logger.notice("Hook 'email': DRY-RUN _send_email / body=%r", body) return True # Add headers diff --git a/radicale/tests/test_hook_email.py b/radicale/tests/test_hook_email.py index cf636d0f..64ddb24b 100644 --- a/radicale/tests/test_hook_email.py +++ b/radicale/tests/test_hook_email.py @@ -94,7 +94,7 @@ permissions: RrWw""") f"DTEND;VALUE=DATE:{new_date}", event) def test_add_event_with_future_end_date(self, caplog) -> None: - caplog.set_level(logging.WARNING) + caplog.set_level(logging.INFO) """Add an event.""" self.mkcalendar("/calendar.ics/") event = get_file_content("event1.ics") @@ -112,7 +112,7 @@ permissions: RrWw""") # Should have a log saying the notification item was received assert len([log for log in logs if "received notification_item: {'type': 'upsert'," in log]) == 1 # Should NOT have a log saying that no email is sent (email won't actually be sent due to dryrun) - assert len([log for log in logs if "skipping notification for event: event1" in log]) == 0 + assert len([log for log in logs if "New event detected, sending notifications to all attendees: event1" in log]) == 1 def test_add_event_with_past_end_date(self, caplog) -> None: caplog.set_level(logging.WARNING) @@ -210,3 +210,55 @@ permissions: RrWw""") assert len([log for log in logs if "received notification_item: {'type': 'delete'," in log]) == 1 # Should have a log saying that no email is sent due to past end date assert len([log for log in logs if "Event end time is in the past, skipping notification for event: event1" in log]) == 1 + + def test_add_event_with_future_mass1_end_date(self, caplog) -> None: + self.configure({"hook": {"type": "email", + "mass_email": "True", + "dryrun": "True"}}) + + caplog.set_level(logging.INFO) + """Add an event.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1.ics") + event = self._replace_end_date_in_event(event, self._future_date_timestamp()) + 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 + + logs = caplog.messages + # Should have a log saying the notification item was received + assert len([log for log in logs if "received notification_item: {'type': 'upsert'," in log]) == 1 + # Should NOT have a log saying that no email is sent (email won't actually be sent due to dryrun) + assert len([log for log in logs if "New event detected, sending notifications to all attendees: event1" in log]) == 1 + assert len([log for log in logs if "Hello everyone" in log]) == 1 + + def test_add_event_with_future_mass2_end_date(self, caplog) -> None: + self.configure({"hook": {"type": "email", + "mass_email": "True", + "dryrun": "True"}}) + + caplog.set_level(logging.INFO) + """Add an event.""" + self.mkcalendar("/calendar.ics/") + event = get_file_content("event1a1.ics") + event = self._replace_end_date_in_event(event, self._future_date_timestamp()) + 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 + + logs = caplog.messages + # Should have a log saying the notification item was received + assert len([log for log in logs if "received notification_item: {'type': 'upsert'," in log]) == 1 + # Should NOT have a log saying that no email is sent (email won't actually be sent due to dryrun) + assert len([log for log in logs if "New event detected, sending notifications to all attendees: event1" in log]) == 1 + assert len([log for log in logs if "Hello everyone" in log]) == 0 From db8af1c730ab30c09d7a0cce6a92da59f7ce7636 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 10 Apr 2026 08:36:32 +0200 Subject: [PATCH 13/13] hook/email: changelog extension --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52c2ea79..83946c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * Adjustment: sharing/token: adjust default permissions to "rp" * Fix: sharing/propfind+proppatch: permission check related to properties * Fix: hook/email: add support for date-only events +* Feature: hook/email: in case of mass-email was enabled but only one attendee fall-back to non-mass-email ## 3.7.0