From ce9b2cf5d2b76056e58358a1e1ce3a5f7ce68b20 Mon Sep 17 00:00:00 2001 From: Nate Harris Date: Sun, 29 Jun 2025 00:47:26 -0600 Subject: [PATCH] - Add support for local SMTP - Ignore venv in flake8 --- DOCUMENTATION.md | 16 ++++- config | 8 ++- radicale/config.py | 13 +++- radicale/hook/email/__init__.py | 107 ++++++++++++++++++++++++++++---- setup.cfg | 18 ++++++ 5 files changed, 143 insertions(+), 19 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index eaf36115..b020818f 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -1548,15 +1548,27 @@ Port to connect to SMTP server. Default: +##### smtp_security + +Use encryption on the SMTP connection. none, tls, starttls + +Default: none + +##### smtp_ssl_verify_mode + +The certificate verification mode. Works for tls and starttls. NONE, OPTIONAL or REQUIRED + +Default: REQUIRED + ##### smtp_username -Username to authenticate with SMTP server. +Username to authenticate with SMTP server. Leave empty to disable authentication (e.g. using local mail server). Default: ##### smtp_password -Password to authenticate with SMTP server. +Password to authenticate with SMTP server. Leave empty to disable authentication (e.g. using local mail server). Default: diff --git a/config b/config index 51139056..ab2ea7eb 100644 --- a/config +++ b/config @@ -305,13 +305,15 @@ [hook] # Hook types -# Value: none | rabbitmq +# Value: none | rabbitmq | email #type = none #rabbitmq_endpoint = #rabbitmq_topic = #rabbitmq_queue_type = classic -#smtp_server = -#smtp_port = 587 +#smtp_server = localhost +#smtp_port = 25 +#smtp_security = starttls +#smtp_ssl_verify_mode = REQUIRED #smtp_username = #smtp_password = #from_email = diff --git a/radicale/config.py b/radicale/config.py index ca90b9b9..15405063 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -38,6 +38,7 @@ from typing import (Any, Callable, ClassVar, Iterable, List, Optional, Sequence, Tuple, TypeVar, Union) from radicale import auth, hook, rights, storage, types, web +from radicale.hook import email from radicale.item import check_and_sanitize_props 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) except ValueError: raise ValueError("malformed IP address: %r" % value) + return [ip_address(s) for s in value.split(",")] @@ -445,6 +447,16 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "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", @@ -606,7 +618,6 @@ _Self = TypeVar("_Self", bound="Configuration") class Configuration: - SOURCE_MISSING: ClassVar[types.CONFIG] = {} _schema: types.CONFIG_SCHEMA diff --git a/radicale/hook/email/__init__.py b/radicale/hook/email/__init__.py index 8d3cfd99..28885e50 100644 --- a/radicale/hook/email/__init__.py +++ b/radicale/hook/email/__init__.py @@ -1,16 +1,17 @@ # 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, Tuple +from typing import Any, Dict, List, Optional, Sequence, Tuple import vobject @@ -28,6 +29,14 @@ PLUGIN_CONFIG_SCHEMA = { "value": "", "type": str }, + "smtp_security": { + "value": "none", + "type": str, + }, + "smtp_ssl_verify_mode": { + "value": "REQUIRED", + "type": str, + }, "smtp_username": { "value": "", "type": str @@ -82,6 +91,44 @@ MESSAGE_TEMPLATE_VARIABLES = [ ] +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). @@ -543,6 +590,8 @@ class EmailConfig: def __init__(self, host: str, port: int, + security: str, + ssl_verify_mode: str, username: str, password: str, from_email: str, @@ -551,6 +600,8 @@ class EmailConfig: 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 @@ -567,6 +618,9 @@ class 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. @@ -644,6 +698,23 @@ class EmailConfig: 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, @@ -681,11 +752,25 @@ class EmailConfig: text = message.as_string() try: - server = smtplib.SMTP(host=self.host, port=self.port) - server.ehlo() # Identify self to server - server.starttls() # Start TLS connection - server.ehlo() # Identify again after starting TLS - server.login(user=self.username, password=self.password) + 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) @@ -701,12 +786,6 @@ class EmailConfig: return True - def __repr__(self): - return f'EmailConfig(host={self.host}, port={self.port}, from_email={self.from_email})' - - def __str__(self): - return f'{self.from_email} ({self.host}:{self.port})' - def _read_event(vobject_data: str) -> EmailEvent: """ @@ -729,6 +808,8 @@ class Hook(BaseHook): 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"), diff --git a/setup.cfg b/setup.cfg index ba916303..1ca10f31 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,4 +3,22 @@ # DNE: DOES-NOT-EXIST select = E,F,W,C90,DNE000 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