Files
core/homeassistant/components/smtp/notify.py
T

445 lines
15 KiB
Python

"""Mail (SMTP) notification service."""
import asyncio
from contextlib import suppress
from email.mime.application import MIMEApplication
from email.mime.audio import MIMEAudio
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.nonmultipart import MIMENonMultipart
from email.mime.text import MIMEText
import email.utils
import logging
from smtplib import (
SMTP,
SMTP_SSL,
SMTPAuthenticationError,
SMTPException,
SMTPServerDisconnected,
)
from socket import gaierror
from ssl import SSLContext
from typing import TYPE_CHECKING, Any, override
import voluptuous as vol
from homeassistant.components.notify import (
ATTR_DATA,
ATTR_TARGET,
ATTR_TITLE,
ATTR_TITLE_DEFAULT,
PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA,
BaseNotificationService,
NotifyEntity,
NotifyEntityFeature,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigSubentry
from homeassistant.const import (
CONF_DEBUG,
CONF_PASSWORD,
CONF_PORT,
CONF_RECIPIENT,
CONF_SENDER,
CONF_TIMEOUT,
CONF_USERNAME,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
HomeAssistantError,
ServiceValidationError,
)
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_ATTACHMENTS,
ATTR_CONTENT_ID,
ATTR_FILENAME,
ATTR_HTML,
ATTR_IMAGES,
ATTR_MEDIA_SOURCE,
CONF_ENCRYPTION,
CONF_SENDER_NAME,
CONF_SERVER,
DEFAULT_DEBUG,
DEFAULT_ENCRYPTION,
DEFAULT_HOST,
DEFAULT_PORT,
DEFAULT_TIMEOUT,
DOMAIN,
ENCRYPTION_OPTIONS,
)
from .helpers import (
SmtpClient,
_build_html_msg,
_build_multipart_msg,
_build_text_msg,
_resolve_media,
)
from .issue import async_deprecate_yaml_issue, deprecated_notify_action_call
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()]),
vol.Required(CONF_SENDER): vol.Email(),
vol.Optional(CONF_SERVER, default=DEFAULT_HOST): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
vol.Optional(CONF_ENCRYPTION, default=DEFAULT_ENCRYPTION): vol.In(
ENCRYPTION_OPTIONS
),
vol.Optional(CONF_USERNAME): cv.string,
vol.Optional(CONF_PASSWORD): cv.string,
vol.Optional(CONF_SENDER_NAME): cv.string,
vol.Optional(CONF_DEBUG, default=DEFAULT_DEBUG): cv.boolean,
vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
}
)
async def async_get_service(
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
) -> MailNotificationService | None:
"""Get the mail notification service."""
if config:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
if result.get("type") is FlowResultType.CREATE_ENTRY or (
result.get("type") is FlowResultType.ABORT
and result.get("reason") == "already_configured"
):
async_deprecate_yaml_issue(hass, config)
else:
async_deprecate_yaml_issue(hass, config, import_success=False)
return None
if discovery_info is None:
return None
ssl_context = (
await hass.async_add_executor_job(create_client_context)
if discovery_info[CONF_VERIFY_SSL]
else None
)
mail_service = MailNotificationService(discovery_info, ssl_context)
if await hass.async_add_executor_job(mail_service.connection_is_valid):
return mail_service
return None
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
@override
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)
async def smtp_send_message(
self,
message: str,
title: str | None = None,
**kwargs: Any,
) -> None:
"""Send an email message via smtp.send_message action."""
msg = MIMEMultipart("related")
msg["Subject"] = title or ATTR_TITLE_DEFAULT
alternative_parts = MIMEMultipart("alternative")
alternative_parts.attach(MIMEText(message, _charset="utf-8"))
if ATTR_HTML in kwargs:
alternative_parts.attach(
MIMEText(kwargs[ATTR_HTML], "html", _charset="utf-8")
)
msg.attach(alternative_parts)
attachments = kwargs.get(ATTR_ATTACHMENTS, [])
resolved = await asyncio.gather(
*(
_resolve_media(self.hass, file[ATTR_MEDIA_SOURCE])
for file in attachments
)
)
for file, (content, mime_type, filename) in zip(
attachments, resolved, strict=True
):
main_type, _, subtype = (
mime_type.partition("/")
if mime_type is not None
else (None, None, None)
)
attachment: MIMENonMultipart
attachment = (
MIMEImage(content, _subtype=subtype)
if main_type == "image"
else MIMEAudio(content, _subtype=subtype)
if main_type == "audio"
else MIMEApplication(content)
)
if not (target_filename := file.get(ATTR_FILENAME, filename)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="media_source_missing_filename",
translation_placeholders={
"media_content_id": file[ATTR_MEDIA_SOURCE]["media_content_id"]
},
)
if cid := file.get(ATTR_CONTENT_ID):
attachment.add_header("Content-ID", f"<{cid}>")
attachment.add_header(
"Content-Disposition", "inline", filename=target_filename
)
else:
attachment.add_header(
"Content-Disposition", "attachment", filename=target_filename
)
msg.attach(attachment)
await self.hass.async_add_executor_job(self._send_email, msg)
self._async_record_notification()
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["To"] = email.utils.formataddr(
(self._subentry.title, self._subentry.unique_id)
)
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 ConfigEntryAuthFailed(
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:
with suppress(SMTPException):
client.quit()
class MailNotificationService(SmtpClient, BaseNotificationService):
"""Implement the notification service for E-mail messages."""
def __init__(
self,
config: DiscoveryInfoType,
ssl_context: SSLContext | None,
) -> None:
"""Initialize the SMTP service."""
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,
)
@override
def send_message(self, message: str, **kwargs: Any) -> None:
"""Build and send a message to a user.
Will send plain text normally, with pictures as attachments if images config is
defined, or will build a multipart HTML if html config is defined.
"""
subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
msg: MIMEMultipart | MIMEText
if data := kwargs.get(ATTR_DATA):
if ATTR_HTML in data:
msg = _build_html_msg(
self.hass,
message,
data[ATTR_HTML],
images=data.get(ATTR_IMAGES, []),
)
else:
msg = _build_multipart_msg(
self.hass, message, images=data.get(ATTR_IMAGES, [])
)
else:
msg = _build_text_msg(message)
msg["Subject"] = subject
if targets := kwargs.get(ATTR_TARGET):
recipients: list[str] = targets # ensured by NOTIFY_SERVICE_SCHEMA
else:
recipients = self.recipients
msg["To"] = ",".join(recipients)
if self._sender_name:
msg["From"] = f"{self._sender_name} <{self._sender}>"
else:
msg["From"] = self._sender
msg["X-Mailer"] = "Home Assistant"
msg["Date"] = email.utils.format_datetime(dt_util.now())
msg["Message-Id"] = email.utils.make_msgid()
return self._send_email(msg, recipients)
def _send_email(self, msg: MIMEMultipart | MIMEText, recipients: list[str]) -> None:
"""Send the message."""
deprecated_notify_action_call(self.hass, self._service_name)
mail = self.connect()
for attempt in range(self.tries):
try:
mail.sendmail(self._sender, recipients, msg.as_string())
break
except SMTPServerDisconnected as e:
with suppress(SMTPException):
mail.quit()
if attempt == self.tries - 1:
_LOGGER.debug("Full exception:", exc_info=True)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_mail_connection_error",
) from e
_LOGGER.warning(
"SMTPServerDisconnected sending mail: retrying connection",
exc_info=_LOGGER.isEnabledFor(logging.DEBUG),
)
mail = self.connect()
except SMTPException as e:
with suppress(SMTPException):
mail.quit()
if attempt == self.tries - 1:
_LOGGER.debug("Full exception:", exc_info=True)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_mail_connection_error",
) from e
_LOGGER.warning(
"SMTPException sending mail: retrying connection",
exc_info=_LOGGER.isEnabledFor(logging.DEBUG),
)
mail = self.connect()
with suppress(SMTPException):
mail.quit()