__init__.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906
  1. # This file is related to Radicale - CalDAV and CardDAV server
  2. # for email notifications
  3. # Copyright © 2025-2025 Nate Harris
  4. import enum
  5. import re
  6. import smtplib
  7. import ssl
  8. from datetime import datetime, timedelta
  9. from email.encoders import encode_base64
  10. from email.mime.base import MIMEBase
  11. from email.mime.multipart import MIMEMultipart
  12. from email.mime.text import MIMEText
  13. from email.utils import formatdate
  14. from typing import Any, Dict, List, Optional, Sequence, Tuple
  15. import vobject
  16. from radicale.hook import (BaseHook, DeleteHookNotificationItem,
  17. HookNotificationItem, HookNotificationItemTypes)
  18. from radicale.log import logger
  19. PLUGIN_CONFIG_SCHEMA = {
  20. "hook": {
  21. "smtp_server": {
  22. "value": "",
  23. "type": str
  24. },
  25. "smtp_port": {
  26. "value": "",
  27. "type": str
  28. },
  29. "smtp_security": {
  30. "value": "none",
  31. "type": str,
  32. },
  33. "smtp_ssl_verify_mode": {
  34. "value": "REQUIRED",
  35. "type": str,
  36. },
  37. "smtp_username": {
  38. "value": "",
  39. "type": str
  40. },
  41. "smtp_password": {
  42. "value": "",
  43. "type": str
  44. },
  45. "from_email": {
  46. "value": "",
  47. "type": str
  48. },
  49. "added_template": {
  50. "value": """Hello $attendee_name,
  51. You have been added as an attendee to the following calendar event.
  52. $event_title
  53. $event_start_time - $event_end_time
  54. $event_location
  55. This is an automated message. Please do not reply.""",
  56. "type": str
  57. },
  58. "removed_template": {
  59. "value": """Hello $attendee_name,
  60. You have been removed as an attendee from the following calendar event.
  61. $event_title
  62. $event_start_time - $event_end_time
  63. $event_location
  64. This is an automated message. Please do not reply.""",
  65. "type": str
  66. },
  67. "mass_email": {
  68. "value": False,
  69. "type": bool,
  70. }
  71. }
  72. }
  73. MESSAGE_TEMPLATE_VARIABLES = [
  74. "organizer_name",
  75. "from_email",
  76. "attendee_name",
  77. "event_title",
  78. "event_start_time",
  79. "event_end_time",
  80. "event_location",
  81. ]
  82. class SMTP_SECURITY_TYPE_ENUM(enum.Enum):
  83. EMPTY = ""
  84. NONE = "none"
  85. STARTTLS = "starttls"
  86. TLS = "tls"
  87. @classmethod
  88. def from_string(cls, value):
  89. """Convert a string to the corresponding enum value."""
  90. for member in cls:
  91. if member.value == value:
  92. return member
  93. raise ValueError(f"Invalid security type: {value}. Allowed values are: {[m.value for m in cls]}")
  94. class SMTP_SSL_VERIFY_MODE_ENUM(enum.Enum):
  95. EMPTY = ""
  96. NONE = "NONE"
  97. OPTIONAL = "OPTIONAL"
  98. REQUIRED = "REQUIRED"
  99. @classmethod
  100. def from_string(cls, value):
  101. """Convert a string to the corresponding enum value."""
  102. for member in cls:
  103. if member.value == value:
  104. return member
  105. raise ValueError(f"Invalid SSL verify mode: {value}. Allowed values are: {[m.value for m in cls]}")
  106. SMTP_SECURITY_TYPES: Sequence[str] = (SMTP_SECURITY_TYPE_ENUM.NONE.value,
  107. SMTP_SECURITY_TYPE_ENUM.STARTTLS.value,
  108. SMTP_SECURITY_TYPE_ENUM.TLS.value)
  109. SMTP_SSL_VERIFY_MODES: Sequence[str] = (SMTP_SSL_VERIFY_MODE_ENUM.NONE.value,
  110. SMTP_SSL_VERIFY_MODE_ENUM.OPTIONAL.value,
  111. SMTP_SSL_VERIFY_MODE_ENUM.REQUIRED.value)
  112. def ics_contents_contains_invited_event(contents: str):
  113. """
  114. Check if the ICS contents contain an event (versus a VTODO or VJOURNAL).
  115. :param contents: The contents of the ICS file.
  116. :return: True if the ICS file contains an event, False otherwise.
  117. """
  118. cal = vobject.readOne(contents)
  119. return cal.vevent is not None
  120. def extract_email(value: str) -> Optional[str]:
  121. """Extract email address from a string."""
  122. if not value:
  123. return None
  124. value = value.strip().lower()
  125. match = re.search(r"mailto:([^;]+)", value)
  126. if match:
  127. return match.group(1)
  128. # Fallback to the whole value if no mailto found
  129. return value if "@" in value else None
  130. class ContentLine:
  131. _key: str
  132. value: Any
  133. _params: Dict[str, Any]
  134. def __init__(self, key: str, value: Any, params: Optional[Dict[str, Any]] = None):
  135. self._key = key
  136. self.value = value
  137. self._params = params or {}
  138. def _get_param(self, name: str) -> List[Optional[Any]]:
  139. """
  140. Get a parameter value by name.
  141. :param name: The name of the parameter to retrieve.
  142. :return: A list of all matching parameter values, or a single-entry (None) list if the parameter does not exist.
  143. """
  144. return self._params.get(name, [None])
  145. class VComponent:
  146. _vobject_item: vobject.base.Component
  147. def __init__(self,
  148. vobject_item: vobject.base.Component,
  149. component_type: str):
  150. """Initialize a VComponent."""
  151. if not isinstance(vobject_item, vobject.base.Component):
  152. raise ValueError("vobject_item must be a vobject.base.Component")
  153. if vobject_item.name != component_type:
  154. raise ValueError("Invalid component type: %r, expected %r" %
  155. (vobject_item.name, component_type))
  156. self._vobject_item = vobject_item
  157. def _get_content_lines(self, name: str) -> List[ContentLine]:
  158. """Get each matching content line."""
  159. name = name.lower().strip()
  160. _content_lines = self._vobject_item.contents.get(name, None)
  161. if not _content_lines:
  162. return [ContentLine("", None)]
  163. if not isinstance(_content_lines, (list, tuple)):
  164. _content_lines = [_content_lines]
  165. return [ContentLine(key=name, value=cl.value, params=cl.params)
  166. for cl in _content_lines if isinstance(cl, vobject.base.ContentLine)] or [ContentLine("", None)]
  167. def _get_sub_vobjects(self, attribute_name: str, _class: type['VComponent']) -> List[Optional['VComponent']]:
  168. """Get sub vobject items of the specified type if they exist."""
  169. sub_vobjects = getattr(self._vobject_item, attribute_name, None)
  170. if not sub_vobjects:
  171. return [None]
  172. if not isinstance(sub_vobjects, (list, tuple)):
  173. sub_vobjects = [sub_vobjects]
  174. return ([_class(vobject_item=so) for so in sub_vobjects if # type: ignore
  175. isinstance(so, vobject.base.Component)]
  176. or [None])
  177. class Attendee(ContentLine):
  178. def __init__(self, content_line: ContentLine):
  179. super().__init__(key=content_line._key, value=content_line.value,
  180. params=content_line._params)
  181. @property
  182. def email(self) -> Optional[str]:
  183. """Return the email address of the attendee."""
  184. return extract_email(self.value)
  185. @property
  186. def role(self) -> Optional[str]:
  187. """Return the role of the attendee."""
  188. return self._get_param("ROLE")[0]
  189. @property
  190. def participation_status(self) -> Optional[str]:
  191. """Return the participation status of the attendee."""
  192. return self._get_param("PARTSTAT")[0]
  193. @property
  194. def name(self) -> Optional[str]:
  195. return self._get_param("CN")[0]
  196. @property
  197. def delegated_from(self) -> Optional[str]:
  198. """Return the email address of the attendee who delegated this attendee."""
  199. delegate = self._get_param("DELEGATED-FROM")[0]
  200. return extract_email(delegate) if delegate else None
  201. class TimeWithTimezone(ContentLine):
  202. def __init__(self, content_line: ContentLine):
  203. """Initialize a time with timezone content line."""
  204. super().__init__(key=content_line._key, value=content_line.value,
  205. params=content_line._params)
  206. @property
  207. def timezone_id(self) -> Optional[str]:
  208. """Return the timezone of the time."""
  209. return self._get_param("TZID")[0]
  210. @property
  211. def time(self) -> Optional[datetime]:
  212. """Return the time value."""
  213. return self.value
  214. def time_string(self, _format: str = "%Y-%m-%d %H:%M:%S") -> Optional[str]:
  215. """Return the time as a formatted string."""
  216. if self.time:
  217. return self.time.strftime(_format)
  218. return None
  219. class Alarm(VComponent):
  220. def __init__(self,
  221. vobject_item: vobject.base.Component):
  222. """Initialize a VALARM item."""
  223. super().__init__(vobject_item, "VALARM")
  224. @property
  225. def action(self) -> Optional[str]:
  226. """Return the action of the alarm."""
  227. return self._get_content_lines("ACTION")[0].value
  228. @property
  229. def description(self) -> Optional[str]:
  230. """Return the description of the alarm."""
  231. return self._get_content_lines("DESCRIPTION")[0].value
  232. @property
  233. def trigger(self) -> Optional[timedelta]:
  234. """Return the trigger of the alarm."""
  235. return self._get_content_lines("TRIGGER")[0].value
  236. @property
  237. def repeat(self) -> Optional[int]:
  238. """Return the repeat interval of the alarm."""
  239. repeat = self._get_content_lines("REPEAT")[0].value
  240. return int(repeat) if repeat is not None else None
  241. @property
  242. def duration(self) -> Optional[str]:
  243. """Return the duration of the alarm."""
  244. return self._get_content_lines("DURATION")[0].value
  245. class SubTimezone(VComponent):
  246. def __init__(self,
  247. vobject_item: vobject.base.Component,
  248. component_type: str):
  249. """Initialize a sub VTIMEZONE item."""
  250. super().__init__(vobject_item, component_type)
  251. @property
  252. def datetime_start(self) -> Optional[datetime]:
  253. """Return the start datetime of the timezone."""
  254. return self._get_content_lines("DTSTART")[0].value
  255. @property
  256. def timezone_name(self) -> Optional[str]:
  257. """Return the timezone name."""
  258. return self._get_content_lines("TZNAME")[0].value
  259. @property
  260. def timezone_offset_from(self) -> Optional[str]:
  261. """Return the timezone offset from."""
  262. return self._get_content_lines("TZOFFSETFROM")[0].value
  263. @property
  264. def timezone_offset_to(self) -> Optional[str]:
  265. """Return the timezone offset to."""
  266. return self._get_content_lines("TZOFFSETTO")[0].value
  267. class StandardTimezone(SubTimezone):
  268. def __init__(self,
  269. vobject_item: vobject.base.Component):
  270. """Initialize a STANDARD item."""
  271. super().__init__(vobject_item, "STANDARD")
  272. class DaylightTimezone(SubTimezone):
  273. def __init__(self,
  274. vobject_item: vobject.base.Component):
  275. """Initialize a DAYLIGHT item."""
  276. super().__init__(vobject_item, "DAYLIGHT")
  277. class Timezone(VComponent):
  278. def __init__(self,
  279. vobject_item: vobject.base.Component):
  280. """Initialize a VTIMEZONE item."""
  281. super().__init__(vobject_item, "VTIMEZONE")
  282. @property
  283. def timezone_id(self) -> Optional[str]:
  284. """Return the timezone ID."""
  285. return self._get_content_lines("TZID")[0].value
  286. @property
  287. def standard(self) -> Optional[StandardTimezone]:
  288. """Return the STANDARD subcomponent if it exists."""
  289. return self._get_sub_vobjects("standard", StandardTimezone)[0] # type: ignore
  290. @property
  291. def daylight(self) -> Optional[DaylightTimezone]:
  292. """Return the DAYLIGHT subcomponent if it exists."""
  293. return self._get_sub_vobjects("daylight", DaylightTimezone)[0] # type: ignore
  294. class Event(VComponent):
  295. def __init__(self,
  296. vobject_item: vobject.base.Component):
  297. """Initialize a VEVENT item."""
  298. super().__init__(vobject_item, "VEVENT")
  299. @property
  300. def datetime_stamp(self) -> Optional[str]:
  301. """Return the last modification datetime of the event."""
  302. return self._get_content_lines("DTSTAMP")[0].value
  303. @property
  304. def datetime_start(self) -> Optional[TimeWithTimezone]:
  305. """Return the start datetime of the event."""
  306. _content_line = self._get_content_lines("DTSTART")[0]
  307. return TimeWithTimezone(_content_line) if _content_line.value else None
  308. @property
  309. def datetime_end(self) -> Optional[TimeWithTimezone]:
  310. """Return the end datetime of the event. Either this or duration will be available, but not both."""
  311. _content_line = self._get_content_lines("DTEND")[0]
  312. return TimeWithTimezone(_content_line) if _content_line.value else None
  313. @property
  314. def duration(self) -> Optional[int]:
  315. """Return the duration of the event. Either this or datetime_end will be available, but not both."""
  316. return self._get_content_lines("DURATION")[0].value
  317. @property
  318. def uid(self) -> Optional[str]:
  319. """Return the UID of the event."""
  320. return self._get_content_lines("UID")[0].value
  321. @property
  322. def status(self) -> Optional[str]:
  323. """Return the status of the event."""
  324. return self._get_content_lines("STATUS")[0].value
  325. @property
  326. def summary(self) -> Optional[str]:
  327. """Return the summary of the event."""
  328. return self._get_content_lines("SUMMARY")[0].value
  329. @property
  330. def location(self) -> Optional[str]:
  331. """Return the location of the event."""
  332. return self._get_content_lines("LOCATION")[0].value
  333. @property
  334. def organizer(self) -> Optional[str]:
  335. """Return the organizer of the event."""
  336. return self._get_content_lines("ORGANIZER")[0].value
  337. @property
  338. def alarms(self) -> List[Alarm]:
  339. """Return a list of VALARM items in the event."""
  340. return self._get_sub_vobjects("valarm", Alarm) # type: ignore # Can be multiple
  341. @property
  342. def attendees(self) -> List[Attendee]:
  343. """Return a list of ATTENDEE items in the event."""
  344. _content_lines = self._get_content_lines("ATTENDEE")
  345. return [Attendee(content_line=attendee) for attendee in _content_lines if attendee.value is not None]
  346. class Calendar(VComponent):
  347. def __init__(self,
  348. vobject_item: vobject.base.Component):
  349. """Initialize a VCALENDAR item."""
  350. super().__init__(vobject_item, "VCALENDAR")
  351. @property
  352. def version(self) -> Optional[str]:
  353. """Return the version of the calendar."""
  354. return self._get_content_lines("VERSION")[0].value
  355. @property
  356. def product_id(self) -> Optional[str]:
  357. """Return the product ID of the calendar."""
  358. return self._get_content_lines("PRODID")[0].value
  359. @property
  360. def event(self) -> Optional[Event]:
  361. """Return the VEVENT item in the calendar."""
  362. return self._get_sub_vobjects("vevent", Event)[0] # type: ignore
  363. # TODO: Add VTODO and VJOURNAL support if needed
  364. @property
  365. def timezone(self) -> Optional[Timezone]:
  366. """Return the VTIMEZONE item in the calendar."""
  367. return self._get_sub_vobjects("vtimezone", Timezone)[0] # type: ignore
  368. class EmailEvent:
  369. def __init__(self,
  370. event: Event,
  371. ics_content: str,
  372. ics_file_name: str):
  373. self.event = event
  374. self.ics_content = ics_content
  375. self.file_name = ics_file_name
  376. class ICSEmailAttachment:
  377. def __init__(self, file_content: str, file_name: str):
  378. self.file_content = file_content
  379. self.file_name = file_name
  380. def prepare_email_part(self) -> MIMEBase:
  381. # Add file as application/octet-stream
  382. # Email client can usually download this automatically as attachment
  383. part = MIMEBase("application", "octet-stream")
  384. part.set_payload(self.file_content)
  385. # Encode file in ASCII characters to send by email
  386. encode_base64(part)
  387. # Add header as key/value pair to attachment part
  388. part.add_header(
  389. "Content-Disposition",
  390. f"attachment; filename= {self.file_name}",
  391. )
  392. return part
  393. class MessageTemplate:
  394. def __init__(self, subject: str, body: str):
  395. self.subject = subject
  396. self.body = body
  397. if not self._validate_template(template=subject):
  398. raise ValueError(
  399. f"Invalid subject template: {subject}. Allowed variables are: {MESSAGE_TEMPLATE_VARIABLES}")
  400. if not self._validate_template(template=body):
  401. raise ValueError(f"Invalid body template: {body}. Allowed variables are: {MESSAGE_TEMPLATE_VARIABLES}")
  402. def __repr__(self):
  403. return f'MessageTemplate(subject={self.subject}, body={self.body})'
  404. def __str__(self):
  405. return f'{self.subject}: {self.body}'
  406. def _validate_template(self, template: str) -> bool:
  407. """
  408. Validate the template to ensure it contains only allowed variables.
  409. :param template: The template string to validate.
  410. :return: True if the template is valid, False otherwise.
  411. """
  412. # Find all variables in the template (starting with $)
  413. variables = re.findall(r'\$(\w+)', template)
  414. # Check if all variables are in the allowed list
  415. for var in variables:
  416. if var not in MESSAGE_TEMPLATE_VARIABLES:
  417. logger.error(
  418. f"Invalid variable '{var}' found in template. Allowed variables are: {MESSAGE_TEMPLATE_VARIABLES}")
  419. return False
  420. return True
  421. def _populate_template(self, template: str, context: dict) -> str:
  422. """
  423. Populate the template with the provided context.
  424. :param template: The template string to populate.
  425. :param context: A dictionary containing the context variables.
  426. :return: The populated template string.
  427. """
  428. for key, value in context.items():
  429. template = template.replace(f"${key}", str(value or ""))
  430. return template
  431. def build_message(self, event: EmailEvent, from_email: str, mass_email: bool,
  432. attendee: Optional[Attendee] = None) -> str:
  433. """
  434. Build the message body using the template.
  435. :param event: The event to include in the message.
  436. :param from_email: The email address of the sender.
  437. :param mass_email: Whether this is a mass email to multiple attendees.
  438. :param attendee: The specific attendee to include in the message, if not a mass email.
  439. :return: The formatted message body.
  440. """
  441. if mass_email:
  442. # If this is a mass email, we do not use individual attendee names
  443. attendee_name = "everyone"
  444. else:
  445. assert attendee is not None, "Attendee must be provided for non-mass emails"
  446. attendee_name = attendee.name if attendee else "Unknown Name" # type: ignore
  447. context = {
  448. "attendee_name": attendee_name,
  449. "from_email": from_email,
  450. "organizer_name": event.event.organizer or "Unknown Organizer",
  451. "event_title": event.event.summary or "No Title",
  452. "event_start_time": event.event.datetime_start.time_string(), # type: ignore
  453. "event_end_time": event.event.datetime_end.time_string() if event.event.datetime_end else "No End Time",
  454. "event_location": event.event.location or "No Location Specified",
  455. }
  456. return self._populate_template(template=self.body, context=context)
  457. def build_subject(self, event: EmailEvent, from_email: str, mass_email: bool,
  458. attendee: Optional[Attendee] = None) -> str:
  459. """
  460. Build the message subject using the template.
  461. :param attendee: The attendee to include in the subject.
  462. :param event: The event to include in the subject.
  463. :param from_email: The email address of the sender.
  464. :param mass_email: Whether this is a mass email to multiple attendees.
  465. :param attendee: The specific attendee to include in the message, if not a mass email.
  466. :return: The formatted message subject.
  467. """
  468. if mass_email:
  469. # If this is a mass email, we do not use individual attendee names
  470. attendee_name = "everyone"
  471. else:
  472. assert attendee is not None, "Attendee must be provided for non-mass emails"
  473. attendee_name = attendee.name if attendee else "Unknown Name" # type: ignore
  474. context = {
  475. "attendee_name": attendee_name,
  476. "from_email": from_email,
  477. "organizer_name": event.event.organizer or "Unknown Organizer",
  478. "event_title": event.event.summary or "No Title",
  479. "event_start_time": event.event.datetime_start.time_string(), # type: ignore
  480. "event_end_time": event.event.datetime_end.time_string() if event.event.datetime_end else "No End Time",
  481. "event_location": event.event.location or "No Location Specified",
  482. }
  483. return self._populate_template(template=self.subject, context=context)
  484. class EmailConfig:
  485. def __init__(self,
  486. host: str,
  487. port: int,
  488. security: str,
  489. ssl_verify_mode: str,
  490. username: str,
  491. password: str,
  492. from_email: str,
  493. send_mass_emails: bool,
  494. added_template: MessageTemplate,
  495. removed_template: MessageTemplate):
  496. self.host = host
  497. self.port = port
  498. self.security = SMTP_SECURITY_TYPE_ENUM.from_string(value=security)
  499. self.ssl_verify_mode = SMTP_SSL_VERIFY_MODE_ENUM.from_string(value=ssl_verify_mode)
  500. self.username = username
  501. self.password = password
  502. self.from_email = from_email
  503. self.send_mass_emails = send_mass_emails
  504. self.added_template = added_template
  505. self.removed_template = removed_template
  506. self.updated_template = added_template # Reuse added template for updated events
  507. self.deleted_template = removed_template # Reuse removed template for deleted events
  508. def __str__(self) -> str:
  509. """
  510. Return a string representation of the EmailConfig.
  511. """
  512. return f"EmailConfig(host={self.host}, port={self.port}, username={self.username}, " \
  513. f"from_email={self.from_email}, send_mass_emails={self.send_mass_emails})"
  514. def __repr__(self):
  515. return self.__str__()
  516. def send_added_email(self, attendees: List[Attendee], event: EmailEvent) -> bool:
  517. """
  518. Send a notification for added attendees.
  519. :param attendees: The attendees to inform.
  520. :param event: The event the attendee is being added to.
  521. :return: True if the email was sent successfully, False otherwise.
  522. """
  523. ics_attachment = ICSEmailAttachment(file_content=event.ics_content, file_name=f"{event.file_name}")
  524. return self._prepare_and_send_email(template=self.added_template, attendees=attendees, event=event,
  525. ics_attachment=ics_attachment)
  526. def send_removed_email(self, attendees: List[Attendee], event: EmailEvent) -> bool:
  527. """
  528. Send a notification for removed attendees.
  529. :param attendees: The attendees to inform.
  530. :param event: The event the attendee is being removed from.
  531. :return: True if the email was sent successfully, False otherwise.
  532. """
  533. return self._prepare_and_send_email(template=self.removed_template, attendees=attendees, event=event,
  534. ics_attachment=None)
  535. def send_updated_email(self, attendees: List[Attendee], event: EmailEvent) -> bool:
  536. """
  537. Send a notification for updated events.
  538. :param attendees: The attendees to inform.
  539. :param event: The event being updated.
  540. :return: True if the email was sent successfully, False otherwise.
  541. """
  542. ics_attachment = ICSEmailAttachment(file_content=event.ics_content, file_name=f"{event.file_name}")
  543. return self._prepare_and_send_email(template=self.updated_template, attendees=attendees, event=event,
  544. ics_attachment=ics_attachment)
  545. def send_deleted_email(self, attendees: List[Attendee], event: EmailEvent) -> bool:
  546. """
  547. Send a notification for deleted events.
  548. :param attendees: The attendees to inform.
  549. :param event: The event being deleted.
  550. :return: True if the email was sent successfully, False otherwise.
  551. """
  552. return self._prepare_and_send_email(template=self.deleted_template, attendees=attendees, event=event,
  553. ics_attachment=None)
  554. def _prepare_and_send_email(self, template: MessageTemplate, attendees: List[Attendee],
  555. event: EmailEvent, ics_attachment: Optional[ICSEmailAttachment] = None) -> bool:
  556. """
  557. Prepare the email message(s) and send them to the attendees.
  558. :param template: The message template to use for the email.
  559. :param attendees: The list of attendees to notify.
  560. :param event: The event to include in the email.
  561. :param ics_attachment: An optional ICS attachment to include in the email.
  562. :return: True if the email(s) were sent successfully, False otherwise.
  563. """
  564. if self.send_mass_emails:
  565. # If mass emails are enabled, we send one email to all attendees
  566. body = template.build_message(event=event, from_email=self.from_email,
  567. mass_email=self.send_mass_emails, attendee=None)
  568. subject = template.build_subject(event=event, from_email=self.from_email,
  569. mass_email=self.send_mass_emails, attendee=None)
  570. return self._send_email(subject=subject, body=body, attendees=attendees, ics_attachment=ics_attachment)
  571. else:
  572. failure_encountered = False
  573. for attendee in attendees:
  574. # For individual emails, we send one email per attendee
  575. body = template.build_message(event=event, from_email=self.from_email,
  576. mass_email=self.send_mass_emails, attendee=attendee)
  577. subject = template.build_subject(event=event, from_email=self.from_email,
  578. mass_email=self.send_mass_emails, attendee=attendee)
  579. if not self._send_email(subject=subject, body=body, attendees=[attendee],
  580. ics_attachment=ics_attachment):
  581. failure_encountered = True
  582. return not failure_encountered # Return True if all emails were sent successfully
  583. def _build_context(self) -> ssl.SSLContext:
  584. """
  585. Build the SSL context based on the configured security and SSL verify mode.
  586. :return: An SSLContext object configured for the SMTP connection.
  587. """
  588. context = ssl.create_default_context()
  589. if self.ssl_verify_mode == SMTP_SSL_VERIFY_MODE_ENUM.REQUIRED:
  590. context.check_hostname = True
  591. context.verify_mode = ssl.CERT_REQUIRED
  592. elif self.ssl_verify_mode == SMTP_SSL_VERIFY_MODE_ENUM.OPTIONAL:
  593. context.check_hostname = True
  594. context.verify_mode = ssl.CERT_OPTIONAL
  595. else:
  596. context.check_hostname = False
  597. context.verify_mode = ssl.CERT_NONE
  598. return context
  599. def _send_email(self,
  600. subject: str,
  601. body: str,
  602. attendees: List[Attendee],
  603. ics_attachment: Optional[ICSEmailAttachment] = None) -> bool:
  604. """
  605. Send the notification using the email service.
  606. :param subject: The subject of the notification.
  607. :param body: The body of the notification.
  608. :param attendees: The attendees to notify.
  609. :param ics_attachment: An optional ICS attachment to include in the email.
  610. :return: True if the email was sent successfully, False otherwise.
  611. """
  612. to_addresses = [attendee.email for attendee in attendees if attendee.email]
  613. if not to_addresses:
  614. logger.warning("No valid email addresses found in attendees. Cannot send email.")
  615. return False
  616. # Add headers
  617. message = MIMEMultipart("mixed")
  618. message["From"] = self.from_email
  619. message["Reply-To"] = self.from_email
  620. message["Subject"] = subject
  621. message["Date"] = formatdate(localtime=True)
  622. # Add body text
  623. message.attach(MIMEText(body, "plain"))
  624. # Add ICS attachment if provided
  625. if ics_attachment:
  626. ical_attachment = ics_attachment.prepare_email_part()
  627. message.attach(ical_attachment)
  628. # Convert message to text
  629. text = message.as_string()
  630. try:
  631. if self.security == SMTP_SECURITY_TYPE_ENUM.EMPTY:
  632. logger.warning("SMTP security type is empty, raising ValueError.")
  633. raise ValueError("SMTP security type cannot be empty. Please specify a valid security type.")
  634. elif self.security == SMTP_SECURITY_TYPE_ENUM.NONE:
  635. server = smtplib.SMTP(host=self.host, port=self.port)
  636. elif self.security == SMTP_SECURITY_TYPE_ENUM.STARTTLS:
  637. context = self._build_context()
  638. server = smtplib.SMTP(host=self.host, port=self.port)
  639. server.ehlo() # Identify self to server
  640. server.starttls(context=context) # Start TLS connection
  641. server.ehlo() # Identify again after starting TLS
  642. elif self.security == SMTP_SECURITY_TYPE_ENUM.TLS:
  643. context = self._build_context()
  644. server = smtplib.SMTP_SSL(host=self.host, port=self.port, context=context)
  645. if self.username and self.password:
  646. logger.debug("Logging in to SMTP server with username: %s", self.username)
  647. server.login(user=self.username, password=self.password)
  648. errors: Dict[str, Tuple[int, bytes]] = server.sendmail(from_addr=self.from_email, to_addrs=to_addresses,
  649. msg=text)
  650. logger.debug("Email sent successfully to %s", to_addresses)
  651. server.quit()
  652. except smtplib.SMTPException as e:
  653. logger.error(f"SMTP error occurred: {e}")
  654. return False
  655. if errors:
  656. for email, (code, error) in errors.items():
  657. logger.error(f"Failed to send email to {email}: {str(error)} (Code: {code})")
  658. return False
  659. return True
  660. def _read_event(vobject_data: str) -> EmailEvent:
  661. """
  662. Read the vobject item from the provided string and create an EmailEvent.
  663. """
  664. v_cal: vobject.base.Component = vobject.readOne(vobject_data)
  665. cal: Calendar = Calendar(vobject_item=v_cal)
  666. event: Event = cal.event # type: ignore
  667. return EmailEvent(
  668. event=event,
  669. ics_content=vobject_data,
  670. ics_file_name="event.ics"
  671. )
  672. class Hook(BaseHook):
  673. def __init__(self, configuration):
  674. super().__init__(configuration)
  675. self.email_config = EmailConfig(
  676. host=self.configuration.get("hook", "smtp_server"),
  677. port=self.configuration.get("hook", "smtp_port"),
  678. security=self.configuration.get("hook", "smtp_security"),
  679. ssl_verify_mode=self.configuration.get("hook", "smtp_ssl_verify_mode"),
  680. username=self.configuration.get("hook", "smtp_username"),
  681. password=self.configuration.get("hook", "smtp_password"),
  682. from_email=self.configuration.get("hook", "from_email"),
  683. send_mass_emails=self.configuration.get("hook", "mass_email"),
  684. added_template=MessageTemplate(
  685. subject="You have been added to an event",
  686. body=self.configuration.get("hook", "added_template")
  687. ),
  688. removed_template=MessageTemplate(
  689. subject="You have been removed from an event",
  690. body=self.configuration.get("hook", "removed_template")
  691. ),
  692. )
  693. logger.info(
  694. "Email hook initialized with configuration: %s",
  695. self.email_config
  696. )
  697. def notify(self, notification_item) -> None:
  698. """
  699. Entrypoint for processing a single notification item.
  700. Overrides default notify method from BaseHook.
  701. Triggered by Radicale when a notifiable event occurs (e.g. item added, updated or deleted)
  702. """
  703. if isinstance(notification_item, HookNotificationItem):
  704. self._process_event_and_notify(notification_item)
  705. def _process_event_and_notify(self, notification_item: HookNotificationItem) -> None:
  706. """
  707. Process the event and send an email notification.
  708. :param notification_item: The single item to process.
  709. :type notification_item: HookNotificationItem
  710. :return: None
  711. """
  712. logger.debug("Received notification item: %s", notification_item)
  713. try:
  714. notification_type = HookNotificationItemTypes(value=notification_item.type)
  715. except ValueError:
  716. logger.warning("Unknown notification item type: %s", notification_item.type)
  717. return
  718. if notification_type == HookNotificationItemTypes.CPATCH:
  719. # Ignore cpatch notifications (PROPPATCH requests for WebDAV metadata updates)
  720. return
  721. elif notification_type == HookNotificationItemTypes.UPSERT:
  722. # Handle upsert notifications (POST request for new item and PUT for updating existing item)
  723. # We don't have access to the original content for a PUT request, just the incoming data
  724. item_str: str = notification_item.content # type: ignore # A serialized vobject.base.Component
  725. if not ics_contents_contains_invited_event(contents=item_str):
  726. # If the ICS file does not contain an event, we do not send any notifications.
  727. logger.debug("No event found in the ICS file, skipping notification.")
  728. return
  729. email_event: EmailEvent = _read_event(vobject_data=item_str) # type: ignore
  730. email_success: bool = self.email_config.send_updated_email( # type: ignore
  731. attendees=email_event.event.attendees,
  732. event=email_event
  733. )
  734. if not email_success:
  735. logger.error("Failed to send some or all email notifications for event: %s", email_event.event.uid)
  736. return
  737. elif notification_type == HookNotificationItemTypes.DELETE:
  738. # Handle delete notifications (DELETE requests)
  739. # Ensure it's a delete notification, as we need the old content
  740. if not isinstance(notification_item, DeleteHookNotificationItem):
  741. return
  742. item_str: str = notification_item.old_content # type: ignore # A serialized vobject.base.Component
  743. if not ics_contents_contains_invited_event(contents=item_str):
  744. # If the ICS file does not contain an event, we do not send any notifications.
  745. logger.debug("No event found in the ICS file, skipping notification.")
  746. return
  747. email_event: EmailEvent = _read_event(vobject_data=item_str) # type: ignore
  748. email_success: bool = self.email_config.send_deleted_email( # type: ignore
  749. attendees=email_event.event.attendees,
  750. event=email_event
  751. )
  752. if not email_success:
  753. logger.error("Failed to send some or all email notifications for event: %s", email_event.event.uid)
  754. return
  755. return