mirror of
https://github.com/home-assistant/core.git
synced 2026-08-15 17:52:59 +01:00
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
445 lines
15 KiB
Python
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()
|