Просмотр исходного кода

Merge pull request #1808 from nwithan8/email

Add email notification hook
Peter Bieringer 8 месяцев назад
Родитель
Сommit
21c67d0c22
7 измененных файлов с 1136 добавлено и 11 удалено
  1. 127 0
      DOCUMENTATION.md
  2. 9 1
      config
  3. 7 7
      radicale/app/delete.py
  4. 62 2
      radicale/config.py
  5. 7 1
      radicale/hook/__init__.py
  6. 906 0
      radicale/hook/email/__init__.py
  7. 18 0
      setup.cfg

+ 127 - 0
DOCUMENTATION.md

@@ -1506,6 +1506,9 @@ Available types:
 `rabbitmq` _(>= 3.2.0)_
 `rabbitmq` _(>= 3.2.0)_
 : Push the message to the rabbitmq server.
 : Push the message to the rabbitmq server.
 
 
+`email` _(>= 3.5.5)_
+: Send an email notification to event attendees.
+
 Default: `none`
 Default: `none`
 
 
 ##### rabbitmq_endpoint
 ##### rabbitmq_endpoint
@@ -1533,6 +1536,130 @@ RabbitMQ queue type for the topic.
 
 
 Default: classic
 Default: classic
 
 
+##### smtp_server
+
+_(>= 3.5.5)_
+
+Address to connect to SMTP server.
+
+Default:
+
+##### smtp_port
+
+_(>= 3.5.5)_
+
+Port to connect to SMTP server.
+
+Default:
+
+##### smtp_security
+
+_(>= 3.5.5)_
+
+Use encryption on the SMTP connection. none, tls, starttls
+
+Default: none
+
+##### smtp_ssl_verify_mode
+
+_(>= 3.5.5)_
+
+The certificate verification mode. Works for tls and starttls. NONE, OPTIONAL or REQUIRED
+
+Default: REQUIRED
+
+##### smtp_username
+
+_(>= 3.5.5)_
+
+Username to authenticate with SMTP server. Leave empty to disable authentication (e.g. using local mail server).
+
+Default:
+
+##### smtp_password
+
+_(>= 3.5.5)_
+
+Password to authenticate with SMTP server. Leave empty to disable authentication (e.g. using local mail server).
+
+Default:
+
+##### from_email
+
+_(>= 3.5.5)_
+
+Email address to use as sender in email notifications.
+
+Default:
+
+##### mass_email
+
+_(>= 3.5.5)_
+
+When enabled, send one email to all attendee email addresses. When disabled, send one email per attendee email address.
+
+Default: `False`
+
+##### added_template
+
+_(>= 3.5.5)_
+
+Template to use for added/updated event email body.
+
+The following placeholders will be replaced:
+- `$organizer_name`: Name of the organizer, or "Unknown Organizer" if not set in event
+- `$from_email`: Email address the email is sent from
+- `$attendee_name`: Name of the attendee (email recipient), or "everyone" if mass email enabled.
+- `$event_name`: Name/summary of the event, or "No Title" if not set in event
+- `$event_start_time`: Start time of the event in ISO 8601 format
+- `$event_end_time`: End time of the event in ISO 8601 format, or "No End Time" if the event has no end time
+- `$event_location`: Location of the event, or "No Location Specified" if not set in event
+
+Providing any words prefixed with $ not included in the list above will result in an error.
+
+Default: 
+```
+Hello $attendee_name,
+
+You have been added as an attendee to the following calendar event.
+
+    $event_title
+    $event_start_time - $event_end_time
+    $event_location
+
+This is an automated message. Please do not reply.
+```
+
+##### removed_template
+
+_(>= 3.5.5)_
+
+Template to use for deleted event email body.
+
+The following placeholders will be replaced:
+- `$organizer_name`: Name of the organizer, or "Unknown Organizer" if not set in event
+- `$from_email`: Email address the email is sent from
+- `$attendee_name`: Name of the attendee (email recipient), or "everyone" if mass email enabled.
+- `$event_name`: Name/summary of the event, or "No Title" if not set in event
+- `$event_start_time`: Start time of the event in ISO 8601 format
+- `$event_end_time`: End time of the event in ISO 8601 format, or "No End Time" if the event has no end time
+- `$event_location`: Location of the event, or "No Location Specified" if not set in event
+
+Providing any words prefixed with $ not included in the list above will result in an error.
+
+Default: 
+```
+Hello $attendee_name,
+
+You have been removed as an attendee from the following calendar event.
+
+    $event_title
+    $event_start_time - $event_end_time
+    $event_location
+
+This is an automated message. Please do not reply.
+```
+
 #### reporting
 #### reporting
 
 
 ##### max_freebusy_occurrence
 ##### max_freebusy_occurrence

+ 9 - 1
config

@@ -305,11 +305,19 @@
 [hook]
 [hook]
 
 
 # Hook types
 # Hook types
-# Value: none | rabbitmq
+# Value: none | rabbitmq | email
 #type = none
 #type = none
 #rabbitmq_endpoint =
 #rabbitmq_endpoint =
 #rabbitmq_topic =
 #rabbitmq_topic =
 #rabbitmq_queue_type = classic
 #rabbitmq_queue_type = classic
+#smtp_server = localhost
+#smtp_port = 25
+#smtp_security = starttls
+#smtp_ssl_verify_mode = REQUIRED
+#smtp_username =
+#smtp_password =
+#from_email =
+#mass_email = False
 
 
 
 
 [reporting]
 [reporting]

+ 7 - 7
radicale/app/delete.py

@@ -24,7 +24,7 @@ from typing import Optional
 
 
 from radicale import httputils, storage, types, xmlutils
 from radicale import httputils, storage, types, xmlutils
 from radicale.app.base import Access, ApplicationBase
 from radicale.app.base import Access, ApplicationBase
-from radicale.hook import HookNotificationItem, HookNotificationItemTypes
+from radicale.hook import DeleteHookNotificationItem
 from radicale.log import logger
 from radicale.log import logger
 
 
 
 
@@ -82,10 +82,10 @@ class ApplicationPartDelete(ApplicationBase):
                         return httputils.NOT_ALLOWED
                         return httputils.NOT_ALLOWED
                 for i in item.get_all():
                 for i in item.get_all():
                     hook_notification_item_list.append(
                     hook_notification_item_list.append(
-                        HookNotificationItem(
-                            HookNotificationItemTypes.DELETE,
+                        DeleteHookNotificationItem(
                             access.path,
                             access.path,
-                            i.uid
+                            i.uid,
+                            old_content=item.serialize()  # type: ignore
                         )
                         )
                     )
                     )
                 xml_answer = xml_delete(base_prefix, path, item)
                 xml_answer = xml_delete(base_prefix, path, item)
@@ -93,10 +93,10 @@ class ApplicationPartDelete(ApplicationBase):
                 assert item.collection is not None
                 assert item.collection is not None
                 assert item.href is not None
                 assert item.href is not None
                 hook_notification_item_list.append(
                 hook_notification_item_list.append(
-                    HookNotificationItem(
-                        HookNotificationItemTypes.DELETE,
+                    DeleteHookNotificationItem(
                         access.path,
                         access.path,
-                        item.uid
+                        item.uid,
+                        old_content=item.serialize()  # type: ignore
                     )
                     )
                 )
                 )
                 xml_answer = xml_delete(
                 xml_answer = xml_delete(

+ 62 - 2
radicale/config.py

@@ -38,6 +38,7 @@ from typing import (Any, Callable, ClassVar, Iterable, List, Optional,
                     Sequence, Tuple, TypeVar, Union)
                     Sequence, Tuple, TypeVar, Union)
 
 
 from radicale import auth, hook, rights, storage, types, web
 from radicale import auth, hook, rights, storage, types, web
+from radicale.hook import email
 from radicale.item import check_and_sanitize_props
 from radicale.item import check_and_sanitize_props
 
 
 DEFAULT_CONFIG_PATH: str = os.pathsep.join([
 DEFAULT_CONFIG_PATH: str = os.pathsep.join([
@@ -85,6 +86,7 @@ def list_of_ip_address(value: Any) -> List[Tuple[str, int]]:
             return address.strip(string.whitespace + "[]"), int(port)
             return address.strip(string.whitespace + "[]"), int(port)
         except ValueError:
         except ValueError:
             raise ValueError("malformed IP address: %r" % value)
             raise ValueError("malformed IP address: %r" % value)
+
     return [ip_address(s) for s in value.split(",")]
     return [ip_address(s) for s in value.split(",")]
 
 
 
 
@@ -436,7 +438,66 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
         ("rabbitmq_queue_type", {
         ("rabbitmq_queue_type", {
             "value": "",
             "value": "",
             "help": "queue type for topic declaration",
             "help": "queue type for topic declaration",
-            "type": str})])),
+            "type": str}),
+        ("smtp_server", {
+            "value": "",
+            "help": "SMTP server to send emails",
+            "type": str}),
+        ("smtp_port", {
+            "value": "",
+            "help": "SMTP server port",
+            "type": str}),
+        ("smtp_security", {
+            "value": "none",
+            "help": "SMTP security mode: *none*|tls|starttls",
+            "type": str,
+            "internal": email.SMTP_SECURITY_TYPES}),
+        ("smtp_ssl_verify_mode", {
+            "value": "REQUIRED",
+            "help": "The certificate verification mode. Works for tls and starttls: NONE, OPTIONAL, default is REQUIRED",
+            "type": str,
+            "internal": email.SMTP_SSL_VERIFY_MODES}),
+        ("smtp_username", {
+            "value": "",
+            "help": "SMTP server username",
+            "type": str}),
+        ("smtp_password", {
+            "value": "",
+            "help": "SMTP server password",
+            "type": str}),
+        ("from_email", {
+            "value": "",
+            "help": "SMTP server password",
+            "type": str}),
+        ("mass_email", {
+            "value": "False",
+            "help": "Send one email to all attendees, versus one email per attendee",
+            "type": bool}),
+        ("added_template", {
+            "value": """Hello $attendee_name,
+
+You have been added as an attendee to the following calendar event.
+
+    $event_title
+    $event_start_time - $event_end_time
+    $event_location
+
+This is an automated message. Please do not reply.""",
+            "help": "Template for the email sent when an event is added or updated. Select placeholder words prefixed with $ will be replaced",
+            "type": str}),
+        ("removed_template", {
+            "value": """Hello $attendee_name,
+
+You have been removed as an attendee from the following calendar event.
+
+    $event_title
+    $event_start_time - $event_end_time
+    $event_location
+
+This is an automated message. Please do not reply.""",
+            "help": "Template for the email sent when an event is deleted. Select placeholder words prefixed with $ will be replaced",
+            "type": str}),
+    ])),
     ("web", OrderedDict([
     ("web", OrderedDict([
         ("type", {
         ("type", {
             "value": "internal",
             "value": "internal",
@@ -557,7 +618,6 @@ _Self = TypeVar("_Self", bound="Configuration")
 
 
 
 
 class Configuration:
 class Configuration:
-
     SOURCE_MISSING: ClassVar[types.CONFIG] = {}
     SOURCE_MISSING: ClassVar[types.CONFIG] = {}
 
 
     _schema: types.CONFIG_SCHEMA
     _schema: types.CONFIG_SCHEMA

+ 7 - 1
radicale/hook/__init__.py

@@ -5,7 +5,7 @@ from typing import Sequence
 from radicale import pathutils, utils
 from radicale import pathutils, utils
 from radicale.log import logger
 from radicale.log import logger
 
 
-INTERNAL_TYPES: Sequence[str] = ("none", "rabbitmq")
+INTERNAL_TYPES: Sequence[str] = ("none", "rabbitmq", "email")
 
 
 
 
 def load(configuration):
 def load(configuration):
@@ -67,3 +67,9 @@ class HookNotificationItem:
             sort_keys=True,
             sort_keys=True,
             indent=4
             indent=4
         )
         )
+
+
+class DeleteHookNotificationItem(HookNotificationItem):
+    def __init__(self, path, uid, old_content=None):
+        super().__init__(notification_item_type=HookNotificationItemTypes.DELETE, path=path, content=uid)
+        self.old_content = old_content

+ 906 - 0
radicale/hook/email/__init__.py

@@ -0,0 +1,906 @@
+# This file is related to Radicale - CalDAV and CardDAV server
+# for email notifications
+# Copyright © 2025-2025 Nate Harris
+import enum
+import re
+import smtplib
+import ssl
+from datetime import 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
+
+import vobject
+
+from radicale.hook import (BaseHook, DeleteHookNotificationItem,
+                           HookNotificationItem, HookNotificationItemTypes)
+from radicale.log import logger
+
+PLUGIN_CONFIG_SCHEMA = {
+    "hook": {
+        "smtp_server": {
+            "value": "",
+            "type": str
+        },
+        "smtp_port": {
+            "value": "",
+            "type": str
+        },
+        "smtp_security": {
+            "value": "none",
+            "type": str,
+        },
+        "smtp_ssl_verify_mode": {
+            "value": "REQUIRED",
+            "type": str,
+        },
+        "smtp_username": {
+            "value": "",
+            "type": str
+        },
+        "smtp_password": {
+            "value": "",
+            "type": str
+        },
+        "from_email": {
+            "value": "",
+            "type": str
+        },
+        "added_template": {
+            "value": """Hello $attendee_name,
+
+You have been added as an attendee to the following calendar event.
+
+    $event_title
+    $event_start_time - $event_end_time
+    $event_location
+
+This is an automated message. Please do not reply.""",
+            "type": str
+        },
+        "removed_template": {
+            "value": """Hello $attendee_name,
+
+You have been removed as an attendee from the following calendar event.
+
+    $event_title
+    $event_start_time - $event_end_time
+    $event_location
+
+This is an automated message. Please do not reply.""",
+            "type": str
+        },
+        "mass_email": {
+            "value": False,
+            "type": bool,
+        }
+    }
+}
+
+MESSAGE_TEMPLATE_VARIABLES = [
+    "organizer_name",
+    "from_email",
+    "attendee_name",
+    "event_title",
+    "event_start_time",
+    "event_end_time",
+    "event_location",
+]
+
+
+class SMTP_SECURITY_TYPE_ENUM(enum.Enum):
+    EMPTY = ""
+    NONE = "none"
+    STARTTLS = "starttls"
+    TLS = "tls"
+
+    @classmethod
+    def from_string(cls, value):
+        """Convert a string to the corresponding enum value."""
+        for member in cls:
+            if member.value == value:
+                return member
+        raise ValueError(f"Invalid security type: {value}. Allowed values are: {[m.value for m in cls]}")
+
+
+class SMTP_SSL_VERIFY_MODE_ENUM(enum.Enum):
+    EMPTY = ""
+    NONE = "NONE"
+    OPTIONAL = "OPTIONAL"
+    REQUIRED = "REQUIRED"
+
+    @classmethod
+    def from_string(cls, value):
+        """Convert a string to the corresponding enum value."""
+        for member in cls:
+            if member.value == value:
+                return member
+        raise ValueError(f"Invalid SSL verify mode: {value}. Allowed values are: {[m.value for m in cls]}")
+
+
+SMTP_SECURITY_TYPES: Sequence[str] = (SMTP_SECURITY_TYPE_ENUM.NONE.value,
+                                      SMTP_SECURITY_TYPE_ENUM.STARTTLS.value,
+                                      SMTP_SECURITY_TYPE_ENUM.TLS.value)
+SMTP_SSL_VERIFY_MODES: Sequence[str] = (SMTP_SSL_VERIFY_MODE_ENUM.NONE.value,
+                                        SMTP_SSL_VERIFY_MODE_ENUM.OPTIONAL.value,
+                                        SMTP_SSL_VERIFY_MODE_ENUM.REQUIRED.value)
+
+
+def ics_contents_contains_invited_event(contents: str):
+    """
+    Check if the ICS contents contain an event (versus a VTODO or VJOURNAL).
+    :param contents: The contents of the ICS file.
+    :return: True if the ICS file contains an event, False otherwise.
+    """
+    cal = vobject.readOne(contents)
+    return cal.vevent is not None
+
+
+def extract_email(value: str) -> Optional[str]:
+    """Extract email address from a string."""
+    if not value:
+        return None
+    value = value.strip().lower()
+    match = re.search(r"mailto:([^;]+)", value)
+    if match:
+        return match.group(1)
+    # Fallback to the whole value if no mailto found
+    return value if "@" in value else None
+
+
+class ContentLine:
+    _key: str
+    value: Any
+    _params: Dict[str, Any]
+
+    def __init__(self, key: str, value: Any, params: Optional[Dict[str, Any]] = None):
+        self._key = key
+        self.value = value
+        self._params = params or {}
+
+    def _get_param(self, name: str) -> List[Optional[Any]]:
+        """
+        Get a parameter value by name.
+        :param name: The name of the parameter to retrieve.
+        :return: A list of all matching parameter values, or a single-entry (None) list if the parameter does not exist.
+        """
+        return self._params.get(name, [None])
+
+
+class VComponent:
+    _vobject_item: vobject.base.Component
+
+    def __init__(self,
+                 vobject_item: vobject.base.Component,
+                 component_type: str):
+        """Initialize a VComponent."""
+        if not isinstance(vobject_item, vobject.base.Component):
+            raise ValueError("vobject_item must be a vobject.base.Component")
+        if vobject_item.name != component_type:
+            raise ValueError("Invalid component type: %r, expected %r" %
+                             (vobject_item.name, component_type))
+        self._vobject_item = vobject_item
+
+    def _get_content_lines(self, name: str) -> List[ContentLine]:
+        """Get each matching content line."""
+        name = name.lower().strip()
+        _content_lines = self._vobject_item.contents.get(name, None)
+        if not _content_lines:
+            return [ContentLine("", None)]
+        if not isinstance(_content_lines, (list, tuple)):
+            _content_lines = [_content_lines]
+        return [ContentLine(key=name, value=cl.value, params=cl.params)
+                for cl in _content_lines if isinstance(cl, vobject.base.ContentLine)] or [ContentLine("", None)]
+
+    def _get_sub_vobjects(self, attribute_name: str, _class: type['VComponent']) -> List[Optional['VComponent']]:
+        """Get sub vobject items of the specified type if they exist."""
+        sub_vobjects = getattr(self._vobject_item, attribute_name, None)
+        if not sub_vobjects:
+            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
+                 isinstance(so, vobject.base.Component)]
+                or [None])
+
+
+class Attendee(ContentLine):
+    def __init__(self, content_line: ContentLine):
+        super().__init__(key=content_line._key, value=content_line.value,
+                         params=content_line._params)
+
+    @property
+    def email(self) -> Optional[str]:
+        """Return the email address of the attendee."""
+        return extract_email(self.value)
+
+    @property
+    def role(self) -> Optional[str]:
+        """Return the role of the attendee."""
+        return self._get_param("ROLE")[0]
+
+    @property
+    def participation_status(self) -> Optional[str]:
+        """Return the participation status of the attendee."""
+        return self._get_param("PARTSTAT")[0]
+
+    @property
+    def name(self) -> Optional[str]:
+        return self._get_param("CN")[0]
+
+    @property
+    def delegated_from(self) -> Optional[str]:
+        """Return the email address of the attendee who delegated this attendee."""
+        delegate = self._get_param("DELEGATED-FROM")[0]
+        return extract_email(delegate) if delegate else None
+
+
+class TimeWithTimezone(ContentLine):
+    def __init__(self, content_line: ContentLine):
+        """Initialize a time with timezone content line."""
+        super().__init__(key=content_line._key, value=content_line.value,
+                         params=content_line._params)
+
+    @property
+    def timezone_id(self) -> Optional[str]:
+        """Return the timezone of the time."""
+        return self._get_param("TZID")[0]
+
+    @property
+    def time(self) -> Optional[datetime]:
+        """Return the time value."""
+        return self.value
+
+    def time_string(self, _format: str = "%Y-%m-%d %H:%M:%S") -> Optional[str]:
+        """Return the time as a formatted string."""
+        if self.time:
+            return self.time.strftime(_format)
+        return None
+
+
+class Alarm(VComponent):
+    def __init__(self,
+                 vobject_item: vobject.base.Component):
+        """Initialize a VALARM item."""
+        super().__init__(vobject_item, "VALARM")
+
+    @property
+    def action(self) -> Optional[str]:
+        """Return the action of the alarm."""
+        return self._get_content_lines("ACTION")[0].value
+
+    @property
+    def description(self) -> Optional[str]:
+        """Return the description of the alarm."""
+        return self._get_content_lines("DESCRIPTION")[0].value
+
+    @property
+    def trigger(self) -> Optional[timedelta]:
+        """Return the trigger of the alarm."""
+        return self._get_content_lines("TRIGGER")[0].value
+
+    @property
+    def repeat(self) -> Optional[int]:
+        """Return the repeat interval of the alarm."""
+        repeat = self._get_content_lines("REPEAT")[0].value
+        return int(repeat) if repeat is not None else None
+
+    @property
+    def duration(self) -> Optional[str]:
+        """Return the duration of the alarm."""
+        return self._get_content_lines("DURATION")[0].value
+
+
+class SubTimezone(VComponent):
+    def __init__(self,
+                 vobject_item: vobject.base.Component,
+                 component_type: str):
+        """Initialize a sub VTIMEZONE item."""
+        super().__init__(vobject_item, component_type)
+
+    @property
+    def datetime_start(self) -> Optional[datetime]:
+        """Return the start datetime of the timezone."""
+        return self._get_content_lines("DTSTART")[0].value
+
+    @property
+    def timezone_name(self) -> Optional[str]:
+        """Return the timezone name."""
+        return self._get_content_lines("TZNAME")[0].value
+
+    @property
+    def timezone_offset_from(self) -> Optional[str]:
+        """Return the timezone offset from."""
+        return self._get_content_lines("TZOFFSETFROM")[0].value
+
+    @property
+    def timezone_offset_to(self) -> Optional[str]:
+        """Return the timezone offset to."""
+        return self._get_content_lines("TZOFFSETTO")[0].value
+
+
+class StandardTimezone(SubTimezone):
+    def __init__(self,
+                 vobject_item: vobject.base.Component):
+        """Initialize a STANDARD item."""
+        super().__init__(vobject_item, "STANDARD")
+
+
+class DaylightTimezone(SubTimezone):
+    def __init__(self,
+                 vobject_item: vobject.base.Component):
+        """Initialize a DAYLIGHT item."""
+        super().__init__(vobject_item, "DAYLIGHT")
+
+
+class Timezone(VComponent):
+    def __init__(self,
+                 vobject_item: vobject.base.Component):
+        """Initialize a VTIMEZONE item."""
+        super().__init__(vobject_item, "VTIMEZONE")
+
+    @property
+    def timezone_id(self) -> Optional[str]:
+        """Return the timezone ID."""
+        return self._get_content_lines("TZID")[0].value
+
+    @property
+    def standard(self) -> Optional[StandardTimezone]:
+        """Return the STANDARD subcomponent if it exists."""
+        return self._get_sub_vobjects("standard", StandardTimezone)[0]  # type: ignore
+
+    @property
+    def daylight(self) -> Optional[DaylightTimezone]:
+        """Return the DAYLIGHT subcomponent if it exists."""
+        return self._get_sub_vobjects("daylight", DaylightTimezone)[0]  # type: ignore
+
+
+class Event(VComponent):
+    def __init__(self,
+                 vobject_item: vobject.base.Component):
+        """Initialize a VEVENT item."""
+        super().__init__(vobject_item, "VEVENT")
+
+    @property
+    def datetime_stamp(self) -> Optional[str]:
+        """Return the last modification datetime of the event."""
+        return self._get_content_lines("DTSTAMP")[0].value
+
+    @property
+    def datetime_start(self) -> Optional[TimeWithTimezone]:
+        """Return the start datetime of the event."""
+        _content_line = self._get_content_lines("DTSTART")[0]
+        return TimeWithTimezone(_content_line) if _content_line.value else None
+
+    @property
+    def datetime_end(self) -> Optional[TimeWithTimezone]:
+        """Return the end datetime of the event. Either this or duration will be available, but not both."""
+        _content_line = self._get_content_lines("DTEND")[0]
+        return TimeWithTimezone(_content_line) if _content_line.value else None
+
+    @property
+    def duration(self) -> Optional[int]:
+        """Return the duration of the event. Either this or datetime_end will be available, but not both."""
+        return self._get_content_lines("DURATION")[0].value
+
+    @property
+    def uid(self) -> Optional[str]:
+        """Return the UID of the event."""
+        return self._get_content_lines("UID")[0].value
+
+    @property
+    def status(self) -> Optional[str]:
+        """Return the status of the event."""
+        return self._get_content_lines("STATUS")[0].value
+
+    @property
+    def summary(self) -> Optional[str]:
+        """Return the summary of the event."""
+        return self._get_content_lines("SUMMARY")[0].value
+
+    @property
+    def location(self) -> Optional[str]:
+        """Return the location of the event."""
+        return self._get_content_lines("LOCATION")[0].value
+
+    @property
+    def organizer(self) -> Optional[str]:
+        """Return the organizer of the event."""
+        return self._get_content_lines("ORGANIZER")[0].value
+
+    @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
+
+    @property
+    def attendees(self) -> List[Attendee]:
+        """Return a list of ATTENDEE items in the event."""
+        _content_lines = self._get_content_lines("ATTENDEE")
+        return [Attendee(content_line=attendee) for attendee in _content_lines if attendee.value is not None]
+
+
+class Calendar(VComponent):
+    def __init__(self,
+                 vobject_item: vobject.base.Component):
+        """Initialize a VCALENDAR item."""
+        super().__init__(vobject_item, "VCALENDAR")
+
+    @property
+    def version(self) -> Optional[str]:
+        """Return the version of the calendar."""
+        return self._get_content_lines("VERSION")[0].value
+
+    @property
+    def product_id(self) -> Optional[str]:
+        """Return the product ID of the calendar."""
+        return self._get_content_lines("PRODID")[0].value
+
+    @property
+    def event(self) -> Optional[Event]:
+        """Return the VEVENT item in the calendar."""
+        return self._get_sub_vobjects("vevent", Event)[0]  # type: ignore
+
+    # 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
+
+
+class EmailEvent:
+    def __init__(self,
+                 event: Event,
+                 ics_content: str,
+                 ics_file_name: str):
+        self.event = event
+        self.ics_content = ics_content
+        self.file_name = ics_file_name
+
+
+class ICSEmailAttachment:
+    def __init__(self, file_content: str, file_name: str):
+        self.file_content = file_content
+        self.file_name = file_name
+
+    def prepare_email_part(self) -> MIMEBase:
+        # Add file as application/octet-stream
+        # Email client can usually download this automatically as attachment
+        part = MIMEBase("application", "octet-stream")
+        part.set_payload(self.file_content)
+
+        # Encode file in ASCII characters to send by email
+        encode_base64(part)
+
+        # Add header as key/value pair to attachment part
+        part.add_header(
+            "Content-Disposition",
+            f"attachment; filename= {self.file_name}",
+        )
+
+        return part
+
+
+class MessageTemplate:
+    def __init__(self, subject: str, body: str):
+        self.subject = subject
+        self.body = body
+        if not self._validate_template(template=subject):
+            raise ValueError(
+                f"Invalid subject template: {subject}. Allowed variables are: {MESSAGE_TEMPLATE_VARIABLES}")
+        if not self._validate_template(template=body):
+            raise ValueError(f"Invalid body template: {body}. Allowed variables are: {MESSAGE_TEMPLATE_VARIABLES}")
+
+    def __repr__(self):
+        return f'MessageTemplate(subject={self.subject}, body={self.body})'
+
+    def __str__(self):
+        return f'{self.subject}: {self.body}'
+
+    def _validate_template(self, template: str) -> bool:
+        """
+        Validate the template to ensure it contains only allowed variables.
+        :param template: The template string to validate.
+        :return: True if the template is valid, False otherwise.
+        """
+        # Find all variables in the template (starting with $)
+        variables = re.findall(r'\$(\w+)', template)
+        # Check if all variables are in the allowed list
+        for var in variables:
+            if var not in MESSAGE_TEMPLATE_VARIABLES:
+                logger.error(
+                    f"Invalid variable '{var}' found in template. Allowed variables are: {MESSAGE_TEMPLATE_VARIABLES}")
+                return False
+        return True
+
+    def _populate_template(self, template: str, context: dict) -> str:
+        """
+        Populate the template with the provided context.
+        :param template: The template string to populate.
+        :param context: A dictionary containing the context variables.
+        :return: The populated template string.
+        """
+        for key, value in context.items():
+            template = template.replace(f"${key}", str(value or ""))
+        return template
+
+    def build_message(self, event: EmailEvent, from_email: str, mass_email: bool,
+                      attendee: Optional[Attendee] = None) -> str:
+        """
+        Build the message body using the template.
+        :param event: The event to include in the message.
+        :param from_email: The email address of the sender.
+        :param mass_email: Whether this is a mass email to multiple attendees.
+        :param attendee: The specific attendee to include in the message, if not a mass email.
+        :return: The formatted message body.
+        """
+        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
+
+        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_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",
+        }
+        return self._populate_template(template=self.body, context=context)
+
+    def build_subject(self, event: EmailEvent, from_email: str, mass_email: bool,
+                      attendee: Optional[Attendee] = None) -> str:
+        """
+        Build the message subject using the template.
+        :param attendee: The attendee to include in the subject.
+        :param event: The event to include in the subject.
+        :param from_email: The email address of the sender.
+        :param mass_email: Whether this is a mass email to multiple attendees.
+        :param attendee: The specific attendee to include in the message, if not a mass email.
+        :return: The formatted message subject.
+        """
+        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
+
+        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_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",
+        }
+        return self._populate_template(template=self.subject, context=context)
+
+
+class EmailConfig:
+    def __init__(self,
+                 host: str,
+                 port: int,
+                 security: str,
+                 ssl_verify_mode: str,
+                 username: str,
+                 password: str,
+                 from_email: str,
+                 send_mass_emails: bool,
+                 added_template: MessageTemplate,
+                 removed_template: MessageTemplate):
+        self.host = host
+        self.port = port
+        self.security = SMTP_SECURITY_TYPE_ENUM.from_string(value=security)
+        self.ssl_verify_mode = SMTP_SSL_VERIFY_MODE_ENUM.from_string(value=ssl_verify_mode)
+        self.username = username
+        self.password = password
+        self.from_email = from_email
+        self.send_mass_emails = send_mass_emails
+        self.added_template = added_template
+        self.removed_template = removed_template
+        self.updated_template = added_template  # Reuse added template for updated events
+        self.deleted_template = removed_template  # Reuse removed template for deleted events
+
+    def __str__(self) -> str:
+        """
+        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})"
+
+    def __repr__(self):
+        return self.__str__()
+
+    def send_added_email(self, attendees: List[Attendee], event: EmailEvent) -> bool:
+        """
+        Send a notification for added attendees.
+        :param attendees: The attendees to inform.
+        :param event: The event the attendee is being added to.
+        :return: True if the email was sent successfully, False otherwise.
+        """
+        ics_attachment = ICSEmailAttachment(file_content=event.ics_content, file_name=f"{event.file_name}")
+
+        return self._prepare_and_send_email(template=self.added_template, attendees=attendees, event=event,
+                                            ics_attachment=ics_attachment)
+
+    def send_removed_email(self, attendees: List[Attendee], event: EmailEvent) -> bool:
+        """
+        Send a notification for removed attendees.
+        :param attendees: The attendees to inform.
+        :param event: The event the attendee is being removed from.
+        :return: True if the email was sent successfully, False otherwise.
+        """
+        return self._prepare_and_send_email(template=self.removed_template, attendees=attendees, event=event,
+                                            ics_attachment=None)
+
+    def send_updated_email(self, attendees: List[Attendee], event: EmailEvent) -> bool:
+        """
+        Send a notification for updated events.
+        :param attendees: The attendees to inform.
+        :param event: The event being updated.
+        :return: True if the email was sent successfully, False otherwise.
+        """
+        ics_attachment = ICSEmailAttachment(file_content=event.ics_content, file_name=f"{event.file_name}")
+
+        return self._prepare_and_send_email(template=self.updated_template, attendees=attendees, event=event,
+                                            ics_attachment=ics_attachment)
+
+    def send_deleted_email(self, attendees: List[Attendee], event: EmailEvent) -> bool:
+        """
+        Send a notification for deleted events.
+        :param attendees: The attendees to inform.
+        :param event: The event being deleted.
+        :return: True if the email was sent successfully, False otherwise.
+        """
+        return self._prepare_and_send_email(template=self.deleted_template, attendees=attendees, event=event,
+                                            ics_attachment=None)
+
+    def _prepare_and_send_email(self, template: MessageTemplate, attendees: List[Attendee],
+                                event: EmailEvent, ics_attachment: Optional[ICSEmailAttachment] = None) -> bool:
+        """
+        Prepare the email message(s) and send them to the attendees.
+        :param template: The message template to use for the email.
+        :param attendees: The list of attendees to notify.
+        :param event: The event to include in the email.
+        :param ics_attachment: An optional ICS attachment to include in the email.
+        :return: True if the email(s) were sent successfully, False otherwise.
+        """
+        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)
+
+            return self._send_email(subject=subject, body=body, attendees=attendees, ics_attachment=ics_attachment)
+        else:
+            failure_encountered = False
+            for attendee in attendees:
+                # For individual emails, we send one email per attendee
+                body = template.build_message(event=event, from_email=self.from_email,
+                                              mass_email=self.send_mass_emails, attendee=attendee)
+                subject = template.build_subject(event=event, from_email=self.from_email,
+                                                 mass_email=self.send_mass_emails, attendee=attendee)
+
+                if not self._send_email(subject=subject, body=body, attendees=[attendee],
+                                        ics_attachment=ics_attachment):
+                    failure_encountered = True
+
+            return not failure_encountered  # Return True if all emails were sent successfully
+
+    def _build_context(self) -> ssl.SSLContext:
+        """
+        Build the SSL context based on the configured security and SSL verify mode.
+        :return: An SSLContext object configured for the SMTP connection.
+        """
+        context = ssl.create_default_context()
+        if self.ssl_verify_mode == SMTP_SSL_VERIFY_MODE_ENUM.REQUIRED:
+            context.check_hostname = True
+            context.verify_mode = ssl.CERT_REQUIRED
+        elif self.ssl_verify_mode == SMTP_SSL_VERIFY_MODE_ENUM.OPTIONAL:
+            context.check_hostname = True
+            context.verify_mode = ssl.CERT_OPTIONAL
+        else:
+            context.check_hostname = False
+            context.verify_mode = ssl.CERT_NONE
+        return context
+
+    def _send_email(self,
+                    subject: str,
+                    body: str,
+                    attendees: List[Attendee],
+                    ics_attachment: Optional[ICSEmailAttachment] = None) -> bool:
+        """
+        Send the notification using the email service.
+        :param subject: The subject of the notification.
+        :param body: The body of the notification.
+        :param attendees: The attendees to notify.
+        :param ics_attachment: An optional ICS attachment to include in the email.
+        :return: True if the email was sent successfully, False otherwise.
+        """
+        to_addresses = [attendee.email for attendee in attendees if attendee.email]
+        if not to_addresses:
+            logger.warning("No valid email addresses found in attendees. Cannot send email.")
+            return False
+
+        # Add headers
+        message = MIMEMultipart("mixed")
+        message["From"] = self.from_email
+        message["Reply-To"] = self.from_email
+        message["Subject"] = subject
+        message["Date"] = formatdate(localtime=True)
+
+        # Add body text
+        message.attach(MIMEText(body, "plain"))
+
+        # Add ICS attachment if provided
+        if ics_attachment:
+            ical_attachment = ics_attachment.prepare_email_part()
+            message.attach(ical_attachment)
+
+        # Convert message to text
+        text = message.as_string()
+
+        try:
+            if self.security == SMTP_SECURITY_TYPE_ENUM.EMPTY:
+                logger.warning("SMTP security type is empty, raising ValueError.")
+                raise ValueError("SMTP security type cannot be empty. Please specify a valid security type.")
+            elif self.security == SMTP_SECURITY_TYPE_ENUM.NONE:
+                server = smtplib.SMTP(host=self.host, port=self.port)
+            elif self.security == SMTP_SECURITY_TYPE_ENUM.STARTTLS:
+                context = self._build_context()
+                server = smtplib.SMTP(host=self.host, port=self.port)
+                server.ehlo()  # Identify self to server
+                server.starttls(context=context)  # Start TLS connection
+                server.ehlo()  # Identify again after starting TLS
+            elif self.security == SMTP_SECURITY_TYPE_ENUM.TLS:
+                context = self._build_context()
+                server = smtplib.SMTP_SSL(host=self.host, port=self.port, context=context)
+
+            if self.username and self.password:
+                logger.debug("Logging in to SMTP server with username: %s", self.username)
+                server.login(user=self.username, password=self.password)
+
+            errors: Dict[str, Tuple[int, bytes]] = server.sendmail(from_addr=self.from_email, to_addrs=to_addresses,
+                                                                   msg=text)
+            logger.debug("Email sent successfully to %s", to_addresses)
+            server.quit()
+        except smtplib.SMTPException as e:
+            logger.error(f"SMTP error occurred: {e}")
+            return False
+
+        if errors:
+            for email, (code, error) in errors.items():
+                logger.error(f"Failed to send email to {email}: {str(error)} (Code: {code})")
+            return False
+
+        return True
+
+
+def _read_event(vobject_data: str) -> EmailEvent:
+    """
+    Read the vobject item from the provided string and create an EmailEvent.
+    """
+    v_cal: vobject.base.Component = vobject.readOne(vobject_data)
+    cal: Calendar = Calendar(vobject_item=v_cal)
+    event: Event = cal.event  # type: ignore
+
+    return EmailEvent(
+        event=event,
+        ics_content=vobject_data,
+        ics_file_name="event.ics"
+    )
+
+
+class Hook(BaseHook):
+    def __init__(self, configuration):
+        super().__init__(configuration)
+        self.email_config = EmailConfig(
+            host=self.configuration.get("hook", "smtp_server"),
+            port=self.configuration.get("hook", "smtp_port"),
+            security=self.configuration.get("hook", "smtp_security"),
+            ssl_verify_mode=self.configuration.get("hook", "smtp_ssl_verify_mode"),
+            username=self.configuration.get("hook", "smtp_username"),
+            password=self.configuration.get("hook", "smtp_password"),
+            from_email=self.configuration.get("hook", "from_email"),
+            send_mass_emails=self.configuration.get("hook", "mass_email"),
+            added_template=MessageTemplate(
+                subject="You have been added to an event",
+                body=self.configuration.get("hook", "added_template")
+            ),
+            removed_template=MessageTemplate(
+                subject="You have been removed from an event",
+                body=self.configuration.get("hook", "removed_template")
+            ),
+        )
+        logger.info(
+            "Email hook initialized with configuration: %s",
+            self.email_config
+        )
+
+    def notify(self, notification_item) -> None:
+        """
+        Entrypoint for processing a single notification item.
+        Overrides default notify method from BaseHook.
+        Triggered by Radicale when a notifiable event occurs (e.g. item added, updated or deleted)
+        """
+        if isinstance(notification_item, HookNotificationItem):
+            self._process_event_and_notify(notification_item)
+
+    def _process_event_and_notify(self, notification_item: HookNotificationItem) -> None:
+        """
+        Process the event and send an email notification.
+        :param notification_item: The single item to process.
+        :type notification_item: HookNotificationItem
+        :return: None
+        """
+        logger.debug("Received notification item: %s", notification_item)
+        try:
+            notification_type = HookNotificationItemTypes(value=notification_item.type)
+        except ValueError:
+            logger.warning("Unknown notification item type: %s", notification_item.type)
+            return
+
+        if notification_type == HookNotificationItemTypes.CPATCH:
+            # Ignore cpatch notifications (PROPPATCH requests for WebDAV metadata updates)
+            return
+
+        elif notification_type == HookNotificationItemTypes.UPSERT:
+            # Handle upsert notifications (POST request for new item and PUT for updating existing item)
+
+            # We don't have access to the original content for a PUT request, just the incoming data
+
+            item_str: str = notification_item.content  # type: ignore # A serialized vobject.base.Component
+
+            if not ics_contents_contains_invited_event(contents=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=item_str)  # type: ignore
+
+            email_success: bool = self.email_config.send_updated_email(  # type: ignore
+                attendees=email_event.event.attendees,
+                event=email_event
+            )
+            if not email_success:
+                logger.error("Failed to send some or all email notifications for event: %s", email_event.event.uid)
+
+            return
+
+        elif notification_type == HookNotificationItemTypes.DELETE:
+            # Handle delete notifications (DELETE requests)
+
+            # Ensure it's a delete notification, as we need the old content
+            if not isinstance(notification_item, DeleteHookNotificationItem):
+                return
+
+            item_str: str = notification_item.old_content  # type: ignore # A serialized vobject.base.Component
+
+            if not ics_contents_contains_invited_event(contents=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=item_str)  # type: ignore
+
+            email_success: bool = self.email_config.send_deleted_email(  # type: ignore
+                attendees=email_event.event.attendees,
+                event=email_event
+            )
+            if not email_success:
+                logger.error("Failed to send some or all email notifications for event: %s", email_event.event.uid)
+
+            return
+
+        return

+ 18 - 0
setup.cfg

@@ -3,4 +3,22 @@
 # DNE: DOES-NOT-EXIST
 # DNE: DOES-NOT-EXIST
 select = E,F,W,C90,DNE000
 select = E,F,W,C90,DNE000
 ignore = E121,E123,E126,E226,E24,E704,W503,W504,DNE000,E501,E261
 ignore = E121,E123,E126,E226,E24,E704,W503,W504,DNE000,E501,E261
+exclude = .git,
+    __pycache__,
+    build,
+    dist,
+    *.egg,
+    *.egg-info,
+    *.eggs,
+    *.pyc,
+    *.pyo,
+    *.pyd,
+    .tox,
+    venv,
+    venv3,
+    .venv,
+    .venv3,
+    .env,
+    .mypy_cache,
+    .pytest_cache
 extend-exclude = build
 extend-exclude = build