__init__.py 44 KB

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