mirror of
https://github.com/home-assistant/core.git
synced 2026-07-07 23:06:34 +01:00
Add notify entities to SMTP integration (#173557)
This commit is contained in:
@@ -1,14 +1,43 @@
|
||||
"""The smtp integration."""
|
||||
|
||||
import logging
|
||||
from smtplib import SMTPAuthenticationError
|
||||
from socket import gaierror
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME, CONF_RECIPIENT, Platform
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_RECIPIENT,
|
||||
CONF_SENDER,
|
||||
CONF_TIMEOUT,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.util.ssl import create_client_context
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import (
|
||||
CONF_ENCRYPTION,
|
||||
CONF_SENDER_NAME,
|
||||
CONF_SERVER,
|
||||
DEFAULT_TIMEOUT,
|
||||
DOMAIN,
|
||||
)
|
||||
from .helpers import SmtpClient
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type SmtpConfigEntry = ConfigEntry[SmtpClient]
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.NOTIFY]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: SmtpConfigEntry) -> bool:
|
||||
"""Set up SMTP from a config entry."""
|
||||
|
||||
hass.async_create_task(
|
||||
@@ -27,17 +56,49 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
{},
|
||||
)
|
||||
)
|
||||
client = SmtpClient(
|
||||
server=entry.data[CONF_SERVER],
|
||||
port=entry.data[CONF_PORT],
|
||||
timeout=entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
|
||||
sender=entry.data[CONF_SENDER],
|
||||
encryption=entry.data[CONF_ENCRYPTION],
|
||||
username=entry.data.get(CONF_USERNAME),
|
||||
password=entry.data.get(CONF_PASSWORD),
|
||||
sender_name=entry.data.get(CONF_SENDER_NAME),
|
||||
verify_ssl=entry.data[CONF_VERIFY_SSL],
|
||||
ssl_context=(
|
||||
await hass.async_add_executor_job(create_client_context)
|
||||
if entry.data[CONF_VERIFY_SSL]
|
||||
else None
|
||||
),
|
||||
)
|
||||
try:
|
||||
await hass.async_add_executor_job(lambda: client.connect().quit())
|
||||
except SMTPAuthenticationError as e:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_error",
|
||||
) from e
|
||||
except (gaierror, ConnectionRefusedError) as e:
|
||||
_LOGGER.debug("Full exception:", exc_info=True)
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
) from e
|
||||
|
||||
entry.runtime_data = client
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
async def _async_update_listener(hass: HomeAssistant, entry: SmtpConfigEntry) -> None:
|
||||
"""Handle update."""
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: SmtpConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return True
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -10,7 +10,6 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_USER,
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryData,
|
||||
@@ -46,6 +45,7 @@ from homeassistant.helpers.selector import (
|
||||
)
|
||||
from homeassistant.util.ssl import create_client_context
|
||||
|
||||
from . import SmtpConfigEntry
|
||||
from .const import (
|
||||
CONF_ENCRYPTION,
|
||||
CONF_SENDER_NAME,
|
||||
@@ -120,14 +120,14 @@ class MailConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
cls, config_entry: SmtpConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this integration."""
|
||||
return {SUBENTRY_TYPE_RECIPIENT: RecipientSubentryFlowHandler}
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler:
|
||||
def async_get_options_flow(config_entry: SmtpConfigEntry) -> OptionsFlowHandler:
|
||||
"""Get the options flow for this handler."""
|
||||
return OptionsFlowHandler()
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Helpers for SMTP integration."""
|
||||
|
||||
from email.mime.application import MIMEApplication
|
||||
from email.mime.image import MIMEImage
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import smtplib
|
||||
import socket
|
||||
import ssl
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
||||
from .const import ATTR_HTML, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SmtpClient:
|
||||
"""Mailer class."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server: str,
|
||||
port: int,
|
||||
timeout: int,
|
||||
sender: str,
|
||||
encryption: str,
|
||||
username: str | None,
|
||||
password: str | None,
|
||||
sender_name: str | None,
|
||||
verify_ssl: bool,
|
||||
ssl_context: ssl.SSLContext | None,
|
||||
) -> None:
|
||||
"""Initialize the SMTP service."""
|
||||
self._server = server
|
||||
self._port = port
|
||||
self._timeout = timeout
|
||||
self._sender = sender
|
||||
self.encryption = encryption
|
||||
self.username = username
|
||||
self.password = password
|
||||
self._sender_name = sender_name
|
||||
self._verify_ssl = verify_ssl
|
||||
self.tries = 2
|
||||
self._ssl_context = ssl_context
|
||||
|
||||
def connect(self) -> smtplib.SMTP_SSL | smtplib.SMTP:
|
||||
"""Connect/authenticate to SMTP Server."""
|
||||
mail: smtplib.SMTP_SSL | smtplib.SMTP
|
||||
if self.encryption == "tls":
|
||||
mail = smtplib.SMTP_SSL(
|
||||
self._server,
|
||||
self._port,
|
||||
timeout=self._timeout,
|
||||
context=self._ssl_context,
|
||||
)
|
||||
else:
|
||||
mail = smtplib.SMTP(self._server, self._port, timeout=self._timeout)
|
||||
mail.ehlo_or_helo_if_needed()
|
||||
if self.encryption == "starttls":
|
||||
mail.starttls(context=self._ssl_context)
|
||||
mail.ehlo()
|
||||
if self.username and self.password:
|
||||
mail.login(self.username, self.password)
|
||||
return mail
|
||||
|
||||
def connection_is_valid(self) -> bool:
|
||||
"""Check for valid config, verify connectivity."""
|
||||
server = None
|
||||
try:
|
||||
server = self.connect()
|
||||
except socket.gaierror, ConnectionRefusedError:
|
||||
_LOGGER.exception(
|
||||
(
|
||||
"SMTP server not found or refused connection (%s:%s). Please check"
|
||||
" the IP address, hostname, and availability of your SMTP server"
|
||||
),
|
||||
self._server,
|
||||
self._port,
|
||||
)
|
||||
|
||||
except smtplib.SMTPAuthenticationError:
|
||||
_LOGGER.exception(
|
||||
"Login not possible. Please check your setting and/or your credentials"
|
||||
)
|
||||
return False
|
||||
|
||||
finally:
|
||||
if server:
|
||||
server.quit()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _build_text_msg(message: str) -> MIMEText:
|
||||
"""Build plaintext email."""
|
||||
_LOGGER.debug("Building plain text email")
|
||||
return MIMEText(message)
|
||||
|
||||
|
||||
def _attach_file(
|
||||
hass: HomeAssistant, atch_name: str, content_id: str | None = None
|
||||
) -> MIMEImage | MIMEApplication | None:
|
||||
"""Create a message attachment.
|
||||
|
||||
If MIMEImage is successful and content_id is passed (HTML), add images in-line.
|
||||
Otherwise add them as attachments.
|
||||
"""
|
||||
try:
|
||||
file_path = Path(atch_name).parent
|
||||
if os.path.exists(file_path) and not hass.config.is_allowed_path(
|
||||
str(file_path)
|
||||
):
|
||||
allow_list = "allowlist_external_dirs"
|
||||
file_name = os.path.basename(atch_name)
|
||||
url = "https://www.home-assistant.io/docs/configuration/basic/"
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_path_not_allowed",
|
||||
translation_placeholders={
|
||||
"allow_list": allow_list,
|
||||
"file_path": str(file_path),
|
||||
"file_name": file_name,
|
||||
"url": url,
|
||||
},
|
||||
)
|
||||
with open(atch_name, "rb") as attachment_file:
|
||||
file_bytes = attachment_file.read()
|
||||
except FileNotFoundError:
|
||||
_LOGGER.warning("Attachment %s not found. Skipping", atch_name)
|
||||
return None
|
||||
|
||||
attachment: MIMEImage | MIMEApplication
|
||||
try:
|
||||
attachment = MIMEImage(file_bytes)
|
||||
except TypeError:
|
||||
_LOGGER.warning(
|
||||
"Attachment %s has an unknown MIME type. Falling back to file",
|
||||
atch_name,
|
||||
)
|
||||
attachment = MIMEApplication(file_bytes, Name=os.path.basename(atch_name))
|
||||
attachment["Content-Disposition"] = (
|
||||
f'attachment; filename="{os.path.basename(atch_name)}"'
|
||||
)
|
||||
else:
|
||||
if content_id:
|
||||
attachment.add_header("Content-ID", f"<{content_id}>")
|
||||
else:
|
||||
attachment.add_header(
|
||||
"Content-Disposition",
|
||||
f"attachment; filename={os.path.basename(atch_name)}",
|
||||
)
|
||||
|
||||
return attachment
|
||||
|
||||
|
||||
def _build_multipart_msg(
|
||||
hass: HomeAssistant, message: str, images: list[str]
|
||||
) -> MIMEMultipart:
|
||||
"""Build Multipart message with images as attachments."""
|
||||
_LOGGER.debug("Building multipart email with image attachment(s)")
|
||||
msg = MIMEMultipart()
|
||||
body_txt = MIMEText(message)
|
||||
msg.attach(body_txt)
|
||||
|
||||
for atch_name in images:
|
||||
attachment = _attach_file(hass, atch_name)
|
||||
if attachment:
|
||||
msg.attach(attachment)
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def _build_html_msg(
|
||||
hass: HomeAssistant, text: str, html: str, images: list[str]
|
||||
) -> MIMEMultipart:
|
||||
"""Build Multipart message with in-line images and rich HTML (UTF-8)."""
|
||||
_LOGGER.debug("Building HTML rich email")
|
||||
msg = MIMEMultipart("related")
|
||||
alternative = MIMEMultipart("alternative")
|
||||
alternative.attach(MIMEText(text, _charset="utf-8"))
|
||||
alternative.attach(MIMEText(html, ATTR_HTML, _charset="utf-8"))
|
||||
msg.attach(alternative)
|
||||
|
||||
for atch_name in images:
|
||||
name = os.path.basename(atch_name)
|
||||
attachment = _attach_file(hass, atch_name, name)
|
||||
if attachment:
|
||||
msg.attach(attachment)
|
||||
return msg
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"notify": {
|
||||
"mailto": {
|
||||
"default": "mdi:email-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
"""Mail (SMTP) notification service."""
|
||||
|
||||
from email.mime.application import MIMEApplication
|
||||
from email.mime.image import MIMEImage
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
import email.utils
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import smtplib
|
||||
import socket
|
||||
import ssl
|
||||
from typing import Any
|
||||
from smtplib import (
|
||||
SMTP,
|
||||
SMTP_SSL,
|
||||
SMTPAuthenticationError,
|
||||
SMTPException,
|
||||
SMTPServerDisconnected,
|
||||
)
|
||||
from socket import gaierror
|
||||
from ssl import SSLContext
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -22,8 +24,10 @@ from homeassistant.components.notify import (
|
||||
ATTR_TITLE_DEFAULT,
|
||||
PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA,
|
||||
BaseNotificationService,
|
||||
NotifyEntity,
|
||||
NotifyEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigSubentry
|
||||
from homeassistant.const import (
|
||||
CONF_DEBUG,
|
||||
CONF_PASSWORD,
|
||||
@@ -37,12 +41,15 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.ssl import create_client_context
|
||||
|
||||
from . import SmtpConfigEntry
|
||||
from .const import (
|
||||
ATTR_HTML,
|
||||
ATTR_IMAGES,
|
||||
@@ -57,12 +64,15 @@ from .const import (
|
||||
DOMAIN,
|
||||
ENCRYPTION_OPTIONS,
|
||||
)
|
||||
from .helpers import SmtpClient, _build_html_msg, _build_multipart_msg, _build_text_msg
|
||||
from .issue import async_deprecate_yaml_issue
|
||||
|
||||
PLATFORMS = [Platform.NOTIFY]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_RECIPIENT): vol.All(cv.ensure_list, [vol.Email()]),
|
||||
@@ -109,20 +119,7 @@ async def async_get_service(
|
||||
if discovery_info[CONF_VERIFY_SSL]
|
||||
else None
|
||||
)
|
||||
mail_service = MailNotificationService(
|
||||
discovery_info[CONF_SERVER],
|
||||
discovery_info[CONF_PORT],
|
||||
discovery_info.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
|
||||
discovery_info[CONF_SENDER],
|
||||
discovery_info[CONF_ENCRYPTION],
|
||||
discovery_info.get(CONF_USERNAME),
|
||||
discovery_info.get(CONF_PASSWORD),
|
||||
discovery_info[CONF_RECIPIENT],
|
||||
discovery_info.get(CONF_SENDER_NAME),
|
||||
DEFAULT_DEBUG,
|
||||
discovery_info[CONF_VERIFY_SSL],
|
||||
ssl_context,
|
||||
)
|
||||
mail_service = MailNotificationService(discovery_info, ssl_context)
|
||||
|
||||
if await hass.async_add_executor_job(mail_service.connection_is_valid):
|
||||
return mail_service
|
||||
@@ -130,86 +127,140 @@ async def async_get_service(
|
||||
return None
|
||||
|
||||
|
||||
class MailNotificationService(BaseNotificationService):
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: SmtpConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the notification entity platform."""
|
||||
client = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
MailNotifyEntity(config_entry, subentry, client)
|
||||
for subentry in config_entry.subentries.values()
|
||||
],
|
||||
)
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
entity_entries = er.async_entries_for_config_entry(
|
||||
entity_registry, config_entry.entry_id
|
||||
)
|
||||
current_recipients = {
|
||||
subentry.unique_id for subentry in config_entry.subentries.values()
|
||||
}
|
||||
for entity in entity_entries:
|
||||
if (
|
||||
entity.unique_id.removeprefix(f"{config_entry.entry_id}_")
|
||||
not in current_recipients
|
||||
):
|
||||
entity_registry.async_remove(entity.entity_id)
|
||||
|
||||
|
||||
class MailNotifyEntity(NotifyEntity):
|
||||
"""Representation of an SMTP notify entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "mailto"
|
||||
_attr_supported_features = NotifyEntityFeature.TITLE
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: SmtpConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
client: SmtpClient,
|
||||
) -> None:
|
||||
"""Initialize the notify entity."""
|
||||
|
||||
self._entry = entry
|
||||
self._subentry = subentry
|
||||
self._client = client
|
||||
|
||||
self._attr_unique_id = f"{entry.entry_id}_{subentry.unique_id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
)
|
||||
self._attr_name = subentry.title
|
||||
|
||||
def send_message(self, message: str, title: str | None = None) -> None:
|
||||
"""Send an email message via notify.send_message action."""
|
||||
|
||||
msg = MIMEText(message)
|
||||
msg["Subject"] = title or ATTR_TITLE_DEFAULT
|
||||
|
||||
self._send_email(msg=msg)
|
||||
|
||||
def _send_email(self, msg: MIMEMultipart | MIMEText) -> None:
|
||||
"""Send the message."""
|
||||
if TYPE_CHECKING:
|
||||
assert self._subentry.unique_id
|
||||
|
||||
msg["From"] = email.utils.formataddr(
|
||||
(self._entry.data.get(CONF_SENDER_NAME), self._entry.data[CONF_SENDER])
|
||||
)
|
||||
msg["X-Mailer"] = "Home Assistant"
|
||||
msg["Date"] = email.utils.format_datetime(dt_util.now())
|
||||
msg["Message-Id"] = email.utils.make_msgid()
|
||||
|
||||
client: SMTP_SSL | SMTP | None = None
|
||||
for attempt in range(self._client.tries):
|
||||
try:
|
||||
client = self._client.connect()
|
||||
except SMTPAuthenticationError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_error",
|
||||
) from e
|
||||
except (gaierror, ConnectionRefusedError, SMTPException) as e:
|
||||
_LOGGER.debug("Full exception:", exc_info=True)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="send_mail_connection_error",
|
||||
) from e
|
||||
|
||||
try:
|
||||
client.sendmail(
|
||||
self._entry.data[CONF_SENDER],
|
||||
self._subentry.unique_id,
|
||||
msg.as_string(),
|
||||
)
|
||||
break
|
||||
except SMTPException as e:
|
||||
_LOGGER.debug(
|
||||
"Error sending mail at attempt %s:", attempt + 1, exc_info=True
|
||||
)
|
||||
if attempt == self._client.tries - 1:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="send_mail_connection_error",
|
||||
) from e
|
||||
finally:
|
||||
client.quit()
|
||||
|
||||
|
||||
class MailNotificationService(SmtpClient, BaseNotificationService):
|
||||
"""Implement the notification service for E-mail messages."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server: str,
|
||||
port: int,
|
||||
timeout: int,
|
||||
sender: str,
|
||||
encryption: str,
|
||||
username: str | None,
|
||||
password: str | None,
|
||||
recipients: list[str],
|
||||
sender_name: str | None,
|
||||
debug: bool,
|
||||
verify_ssl: bool,
|
||||
ssl_context: ssl.SSLContext | None,
|
||||
config: DiscoveryInfoType,
|
||||
ssl_context: SSLContext | None,
|
||||
) -> None:
|
||||
"""Initialize the SMTP service."""
|
||||
self._server = server
|
||||
self._port = port
|
||||
self._timeout = timeout
|
||||
self._sender = sender
|
||||
self.encryption = encryption
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.recipients = recipients
|
||||
self._sender_name = sender_name
|
||||
self.debug = debug
|
||||
self._verify_ssl = verify_ssl
|
||||
self.tries = 2
|
||||
self._ssl_context = ssl_context
|
||||
|
||||
def connect(self) -> smtplib.SMTP_SSL | smtplib.SMTP:
|
||||
"""Connect/authenticate to SMTP Server."""
|
||||
mail: smtplib.SMTP_SSL | smtplib.SMTP
|
||||
if self.encryption == "tls":
|
||||
mail = smtplib.SMTP_SSL(
|
||||
self._server,
|
||||
self._port,
|
||||
timeout=self._timeout,
|
||||
context=self._ssl_context,
|
||||
)
|
||||
else:
|
||||
mail = smtplib.SMTP(self._server, self._port, timeout=self._timeout)
|
||||
mail.set_debuglevel(self.debug)
|
||||
mail.ehlo_or_helo_if_needed()
|
||||
if self.encryption == "starttls":
|
||||
mail.starttls(context=self._ssl_context)
|
||||
mail.ehlo()
|
||||
if self.username and self.password:
|
||||
mail.login(self.username, self.password)
|
||||
return mail
|
||||
|
||||
def connection_is_valid(self) -> bool:
|
||||
"""Check for valid config, verify connectivity."""
|
||||
server = None
|
||||
try:
|
||||
server = self.connect()
|
||||
except socket.gaierror, ConnectionRefusedError:
|
||||
_LOGGER.exception(
|
||||
(
|
||||
"SMTP server not found or refused connection (%s:%s). Please check"
|
||||
" the IP address, hostname, and availability of your SMTP server"
|
||||
),
|
||||
self._server,
|
||||
self._port,
|
||||
)
|
||||
|
||||
except smtplib.SMTPAuthenticationError:
|
||||
_LOGGER.exception(
|
||||
"Login not possible. Please check your setting and/or your credentials"
|
||||
)
|
||||
return False
|
||||
|
||||
finally:
|
||||
if server:
|
||||
server.quit()
|
||||
|
||||
return True
|
||||
self.recipients = config[CONF_RECIPIENT]
|
||||
super().__init__(
|
||||
server=config[CONF_SERVER],
|
||||
port=config[CONF_PORT],
|
||||
timeout=config.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
|
||||
sender=config[CONF_SENDER],
|
||||
encryption=config[CONF_ENCRYPTION],
|
||||
username=config.get(CONF_USERNAME),
|
||||
password=config.get(CONF_PASSWORD),
|
||||
sender_name=config.get(CONF_SENDER_NAME),
|
||||
verify_ssl=config[CONF_VERIFY_SSL],
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
|
||||
def send_message(self, message: str, **kwargs: Any) -> None:
|
||||
"""Build and send a message to a user.
|
||||
@@ -261,112 +312,14 @@ class MailNotificationService(BaseNotificationService):
|
||||
try:
|
||||
mail.sendmail(self._sender, recipients, msg.as_string())
|
||||
break
|
||||
except smtplib.SMTPServerDisconnected:
|
||||
except SMTPServerDisconnected:
|
||||
_LOGGER.warning(
|
||||
"SMTPServerDisconnected sending mail: retrying connection"
|
||||
)
|
||||
mail.quit()
|
||||
mail = self.connect()
|
||||
except smtplib.SMTPException:
|
||||
except SMTPException:
|
||||
_LOGGER.warning("SMTPException sending mail: retrying connection")
|
||||
mail.quit()
|
||||
mail = self.connect()
|
||||
mail.quit()
|
||||
|
||||
|
||||
def _build_text_msg(message: str) -> MIMEText:
|
||||
"""Build plaintext email."""
|
||||
_LOGGER.debug("Building plain text email")
|
||||
return MIMEText(message)
|
||||
|
||||
|
||||
def _attach_file(
|
||||
hass: HomeAssistant, atch_name: str, content_id: str | None = None
|
||||
) -> MIMEImage | MIMEApplication | None:
|
||||
"""Create a message attachment.
|
||||
|
||||
If MIMEImage is successful and content_id is passed (HTML), add images in-line.
|
||||
Otherwise add them as attachments.
|
||||
"""
|
||||
try:
|
||||
file_path = Path(atch_name).parent
|
||||
if os.path.exists(file_path) and not hass.config.is_allowed_path(
|
||||
str(file_path)
|
||||
):
|
||||
allow_list = "allowlist_external_dirs"
|
||||
file_name = os.path.basename(atch_name)
|
||||
url = "https://www.home-assistant.io/docs/configuration/basic/"
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_path_not_allowed",
|
||||
translation_placeholders={
|
||||
"allow_list": allow_list,
|
||||
"file_path": str(file_path),
|
||||
"file_name": file_name,
|
||||
"url": url,
|
||||
},
|
||||
)
|
||||
with open(atch_name, "rb") as attachment_file:
|
||||
file_bytes = attachment_file.read()
|
||||
except FileNotFoundError:
|
||||
_LOGGER.warning("Attachment %s not found. Skipping", atch_name)
|
||||
return None
|
||||
|
||||
attachment: MIMEImage | MIMEApplication
|
||||
try:
|
||||
attachment = MIMEImage(file_bytes)
|
||||
except TypeError:
|
||||
_LOGGER.warning(
|
||||
"Attachment %s has an unknown MIME type. Falling back to file",
|
||||
atch_name,
|
||||
)
|
||||
attachment = MIMEApplication(file_bytes, Name=os.path.basename(atch_name))
|
||||
attachment["Content-Disposition"] = (
|
||||
f'attachment; filename="{os.path.basename(atch_name)}"'
|
||||
)
|
||||
else:
|
||||
if content_id:
|
||||
attachment.add_header("Content-ID", f"<{content_id}>")
|
||||
else:
|
||||
attachment.add_header(
|
||||
"Content-Disposition",
|
||||
f"attachment; filename={os.path.basename(atch_name)}",
|
||||
)
|
||||
|
||||
return attachment
|
||||
|
||||
|
||||
def _build_multipart_msg(
|
||||
hass: HomeAssistant, message: str, images: list[str]
|
||||
) -> MIMEMultipart:
|
||||
"""Build Multipart message with images as attachments."""
|
||||
_LOGGER.debug("Building multipart email with image attachme_build_html_msgnt(s)")
|
||||
msg = MIMEMultipart()
|
||||
body_txt = MIMEText(message)
|
||||
msg.attach(body_txt)
|
||||
|
||||
for atch_name in images:
|
||||
attachment = _attach_file(hass, atch_name)
|
||||
if attachment:
|
||||
msg.attach(attachment)
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def _build_html_msg(
|
||||
hass: HomeAssistant, text: str, html: str, images: list[str]
|
||||
) -> MIMEMultipart:
|
||||
"""Build Multipart message with in-line images and rich HTML (UTF-8)."""
|
||||
_LOGGER.debug("Building HTML rich email")
|
||||
msg = MIMEMultipart("related")
|
||||
alternative = MIMEMultipart("alternative")
|
||||
alternative.attach(MIMEText(text, _charset="utf-8"))
|
||||
alternative.attach(MIMEText(html, ATTR_HTML, _charset="utf-8"))
|
||||
msg.attach(alternative)
|
||||
|
||||
for atch_name in images:
|
||||
name = os.path.basename(atch_name)
|
||||
attachment = _attach_file(hass, atch_name, name)
|
||||
if attachment:
|
||||
msg.attach(attachment)
|
||||
return msg
|
||||
|
||||
@@ -84,8 +84,17 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"authentication_error": {
|
||||
"message": "Failed to authenticate with SMTP server. Please verify your credentials."
|
||||
},
|
||||
"connection_error": {
|
||||
"message": "Failed to connect to SMTP server."
|
||||
},
|
||||
"remote_path_not_allowed": {
|
||||
"message": "Cannot send email with attachment \"{file_name}\" from directory \"{file_path}\" which is not secure to load data from. Only folders added to `{allow_list}` are accessible. See {url} for more information."
|
||||
},
|
||||
"send_mail_connection_error": {
|
||||
"message": "Failed to send email message due to a connection error."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -40,7 +40,7 @@ def mock_smtp() -> Generator[MagicMock]:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.smtp.notify.smtplib.SMTP", autospec=True
|
||||
"homeassistant.components.smtp.helpers.smtplib.SMTP", autospec=True
|
||||
) as mock_client,
|
||||
patch("homeassistant.components.smtp.config_flow.SMTP", new=mock_client),
|
||||
):
|
||||
@@ -48,6 +48,17 @@ def mock_smtp() -> Generator[MagicMock]:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture(name="make_msgid")
|
||||
def mock_make_msgid() -> Generator[None]:
|
||||
"""Mock email.utils.make_msgid."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.smtp.notify.email.utils.make_msgid",
|
||||
return_value="<177777777700.12345.12345678901234567890@mock>",
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="smtp_ssl")
|
||||
def mock_smtp_ssl() -> Generator[MagicMock]:
|
||||
"""Mock SMTP."""
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# serializer version: 1
|
||||
# name: test_notify_platform[notify.home_assistant_recipient-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'notify',
|
||||
'entity_category': None,
|
||||
'entity_id': 'notify.home_assistant_recipient',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Recipient',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Recipient',
|
||||
'platform': 'smtp',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <NotifyEntityFeature: 1>,
|
||||
'translation_key': 'mailto',
|
||||
'unique_id': '123456789_recipient@example.com',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_notify_platform[notify.home_assistant_recipient-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Home Assistant Recipient',
|
||||
'supported_features': <NotifyEntityFeature: 1>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'notify.home_assistant_recipient',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
@@ -224,6 +224,7 @@ async def test_form_recipient_already_configured(
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("smtp")
|
||||
async def test_options_flow(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for the SMTP integration."""
|
||||
|
||||
from smtplib import SMTPAuthenticationError
|
||||
from socket import gaierror
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -48,6 +50,32 @@ async def test_entry_setup_unload(
|
||||
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "state"),
|
||||
[
|
||||
(ConnectionRefusedError, ConfigEntryState.SETUP_RETRY),
|
||||
(gaierror, ConfigEntryState.SETUP_RETRY),
|
||||
(SMTPAuthenticationError(0, ""), ConfigEntryState.SETUP_ERROR),
|
||||
],
|
||||
)
|
||||
async def test_config_entry_not_ready(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
smtp: MagicMock,
|
||||
exception: Exception,
|
||||
state: ConfigEntryState,
|
||||
) -> None:
|
||||
"""Test config entry not ready."""
|
||||
|
||||
smtp.login.side_effect = exception
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is state
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("smtp")
|
||||
async def test_import(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -2,16 +2,49 @@
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
from unittest.mock import patch
|
||||
from smtplib import (
|
||||
SMTPAuthenticationError,
|
||||
SMTPHeloError,
|
||||
SMTPSenderRefused,
|
||||
SMTPServerDisconnected,
|
||||
)
|
||||
from socket import gaierror
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.smtp.const import DOMAIN
|
||||
from homeassistant.components.notify import (
|
||||
ATTR_MESSAGE,
|
||||
DOMAIN as NOTIFY_DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
)
|
||||
from homeassistant.components.smtp.const import (
|
||||
CONF_ENCRYPTION,
|
||||
CONF_SENDER_NAME,
|
||||
CONF_SERVER,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.components.smtp.notify import MailNotificationService
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_RECIPIENT,
|
||||
CONF_SENDER,
|
||||
CONF_TIMEOUT,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.util.ssl import create_client_context
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
class MockSMTP(MailNotificationService):
|
||||
"""Test SMTP object that doesn't need a working server."""
|
||||
@@ -25,18 +58,19 @@ class MockSMTP(MailNotificationService):
|
||||
def message():
|
||||
"""Return MockSMTP object with test data."""
|
||||
return MockSMTP(
|
||||
"localhost",
|
||||
25,
|
||||
5,
|
||||
"test@test.com",
|
||||
1,
|
||||
"testuser",
|
||||
"testpass",
|
||||
["recip1@example.com", "testrecip@test.com"],
|
||||
"Home Assistant",
|
||||
0,
|
||||
True,
|
||||
create_client_context(),
|
||||
config={
|
||||
CONF_SERVER: "localhost",
|
||||
CONF_PORT: 25,
|
||||
CONF_TIMEOUT: 5,
|
||||
CONF_SENDER: "test@test.com",
|
||||
CONF_ENCRYPTION: 1,
|
||||
CONF_USERNAME: "testuser",
|
||||
CONF_PASSWORD: "testpass",
|
||||
CONF_RECIPIENT: ["recip1@example.com", "testrecip@test.com"],
|
||||
CONF_SENDER_NAME: "Home Assistant",
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
ssl_context=create_client_context(),
|
||||
)
|
||||
|
||||
|
||||
@@ -182,3 +216,115 @@ def test_send_target_message(target, hass: HomeAssistant, message) -> None:
|
||||
|
||||
_, recipient = message.send_message(message_data, target=target)
|
||||
assert recipient == expected_recipient
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("smtp")
|
||||
async def test_notify_platform(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test setup of the SMTP notify platform."""
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("make_msgid")
|
||||
@pytest.mark.freeze_time("2026-05-03T03:09:37+00:00")
|
||||
async def test_notify_send_message(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
smtp: MagicMock,
|
||||
) -> None:
|
||||
"""Test sending an email message via notify.send_message action."""
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
state = hass.states.get("notify.home_assistant_recipient")
|
||||
assert state
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
await hass.services.async_call(
|
||||
NOTIFY_DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
{
|
||||
ATTR_ENTITY_ID: "notify.home_assistant_recipient",
|
||||
ATTR_MESSAGE: "Hello World",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
state = hass.states.get("notify.home_assistant_recipient")
|
||||
assert state
|
||||
assert state.state == "2026-05-03T03:09:37+00:00"
|
||||
|
||||
smtp.sendmail.assert_called_once_with(
|
||||
"email@example.com",
|
||||
"recipient@example.com",
|
||||
(
|
||||
'Content-Type: text/plain; charset="us-ascii"\n'
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Transfer-Encoding: 7bit\n"
|
||||
"Subject: Home Assistant\n"
|
||||
"From: Home Assistant <email@example.com>\n"
|
||||
"X-Mailer: Home Assistant\n"
|
||||
"Date: Sat, 02 May 2026 20:09:37 -0700\n"
|
||||
"Message-Id: <177777777700.12345.12345678901234567890@mock>\n\n"
|
||||
"Hello World"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("call_method", "exception", "translation_key"),
|
||||
[
|
||||
("login", SMTPAuthenticationError(0, ""), "authentication_error"),
|
||||
("login", gaierror, "send_mail_connection_error"),
|
||||
("login", ConnectionRefusedError, "send_mail_connection_error"),
|
||||
("login", SMTPHeloError(0, ""), "send_mail_connection_error"),
|
||||
("sendmail", SMTPServerDisconnected, "send_mail_connection_error"),
|
||||
("sendmail", SMTPSenderRefused(0, b"", ""), "send_mail_connection_error"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.freeze_time("2026-05-03T03:09:37+00:00")
|
||||
async def test_notify_send_message_exceptions(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
smtp: MagicMock,
|
||||
call_method: str,
|
||||
exception: Exception,
|
||||
translation_key: str,
|
||||
) -> None:
|
||||
"""Test exceptions via notify.send_message action."""
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
getattr(smtp, call_method).side_effect = exception
|
||||
|
||||
with pytest.raises(HomeAssistantError) as e:
|
||||
await hass.services.async_call(
|
||||
NOTIFY_DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
{
|
||||
ATTR_ENTITY_ID: "notify.home_assistant_recipient",
|
||||
ATTR_MESSAGE: "Hello World",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert e.value.translation_key == translation_key
|
||||
|
||||
Reference in New Issue
Block a user