Home Assistant Unofficial Reference 2024.12.1
notify.py
Go to the documentation of this file.
1 """Mail (SMTP) notification service."""
2 
3 from __future__ import annotations
4 
5 from email.mime.application import MIMEApplication
6 from email.mime.image import MIMEImage
7 from email.mime.multipart import MIMEMultipart
8 from email.mime.text import MIMEText
9 import email.utils
10 import logging
11 import os
12 from pathlib import Path
13 import smtplib
14 
15 import voluptuous as vol
16 
18  ATTR_DATA,
19  ATTR_TARGET,
20  ATTR_TITLE,
21  ATTR_TITLE_DEFAULT,
22  PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA,
23  BaseNotificationService,
24 )
25 from homeassistant.const import (
26  CONF_PASSWORD,
27  CONF_PORT,
28  CONF_RECIPIENT,
29  CONF_SENDER,
30  CONF_TIMEOUT,
31  CONF_USERNAME,
32  CONF_VERIFY_SSL,
33  Platform,
34 )
35 from homeassistant.core import HomeAssistant
36 from homeassistant.exceptions import ServiceValidationError
38 from homeassistant.helpers.reload import setup_reload_service
39 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
40 import homeassistant.util.dt as dt_util
41 from homeassistant.util.ssl import client_context
42 
43 from .const import (
44  ATTR_HTML,
45  ATTR_IMAGES,
46  CONF_DEBUG,
47  CONF_ENCRYPTION,
48  CONF_SENDER_NAME,
49  CONF_SERVER,
50  DEFAULT_DEBUG,
51  DEFAULT_ENCRYPTION,
52  DEFAULT_HOST,
53  DEFAULT_PORT,
54  DEFAULT_TIMEOUT,
55  DOMAIN,
56  ENCRYPTION_OPTIONS,
57 )
58 
59 PLATFORMS = [Platform.NOTIFY]
60 
61 _LOGGER = logging.getLogger(__name__)
62 
63 PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
64  {
65  vol.Required(CONF_RECIPIENT): vol.All(cv.ensure_list, [vol.Email()]),
66  vol.Required(CONF_SENDER): vol.Email(),
67  vol.Optional(CONF_SERVER, default=DEFAULT_HOST): cv.string,
68  vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
69  vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
70  vol.Optional(CONF_ENCRYPTION, default=DEFAULT_ENCRYPTION): vol.In(
71  ENCRYPTION_OPTIONS
72  ),
73  vol.Optional(CONF_USERNAME): cv.string,
74  vol.Optional(CONF_PASSWORD): cv.string,
75  vol.Optional(CONF_SENDER_NAME): cv.string,
76  vol.Optional(CONF_DEBUG, default=DEFAULT_DEBUG): cv.boolean,
77  vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
78  }
79 )
80 
81 
83  hass: HomeAssistant,
84  config: ConfigType,
85  discovery_info: DiscoveryInfoType | None = None,
86 ) -> MailNotificationService | None:
87  """Get the mail notification service."""
88  setup_reload_service(hass, DOMAIN, PLATFORMS)
89  mail_service = MailNotificationService(
90  config[CONF_SERVER],
91  config[CONF_PORT],
92  config[CONF_TIMEOUT],
93  config[CONF_SENDER],
94  config[CONF_ENCRYPTION],
95  config.get(CONF_USERNAME),
96  config.get(CONF_PASSWORD),
97  config[CONF_RECIPIENT],
98  config.get(CONF_SENDER_NAME),
99  config[CONF_DEBUG],
100  config[CONF_VERIFY_SSL],
101  )
102 
103  if mail_service.connection_is_valid():
104  return mail_service
105 
106  return None
107 
108 
109 class MailNotificationService(BaseNotificationService):
110  """Implement the notification service for E-mail messages."""
111 
112  def __init__(
113  self,
114  server,
115  port,
116  timeout,
117  sender,
118  encryption,
119  username,
120  password,
121  recipients,
122  sender_name,
123  debug,
124  verify_ssl,
125  ):
126  """Initialize the SMTP service."""
127  self._server_server = server
128  self._port_port = port
129  self._timeout_timeout = timeout
130  self._sender_sender = sender
131  self.encryptionencryption = encryption
132  self.usernameusername = username
133  self.passwordpassword = password
134  self.recipientsrecipients = recipients
135  self._sender_name_sender_name = sender_name
136  self.debugdebug = debug
137  self._verify_ssl_verify_ssl = verify_ssl
138  self.triestries = 2
139 
140  def connect(self):
141  """Connect/authenticate to SMTP Server."""
142  ssl_context = client_context() if self._verify_ssl_verify_ssl else None
143  if self.encryptionencryption == "tls":
144  mail = smtplib.SMTP_SSL(
145  self._server_server,
146  self._port_port,
147  timeout=self._timeout_timeout,
148  context=ssl_context,
149  )
150  else:
151  mail = smtplib.SMTP(self._server_server, self._port_port, timeout=self._timeout_timeout)
152  mail.set_debuglevel(self.debugdebug)
153  mail.ehlo_or_helo_if_needed()
154  if self.encryptionencryption == "starttls":
155  mail.starttls(context=ssl_context)
156  mail.ehlo()
157  if self.usernameusername and self.passwordpassword:
158  mail.login(self.usernameusername, self.passwordpassword)
159  return mail
160 
162  """Check for valid config, verify connectivity."""
163  server = None
164  try:
165  server = self.connectconnect()
166  except (smtplib.socket.gaierror, ConnectionRefusedError):
167  _LOGGER.exception(
168  (
169  "SMTP server not found or refused connection (%s:%s). Please check"
170  " the IP address, hostname, and availability of your SMTP server"
171  ),
172  self._server_server,
173  self._port_port,
174  )
175 
176  except smtplib.SMTPAuthenticationError:
177  _LOGGER.exception(
178  "Login not possible. Please check your setting and/or your credentials"
179  )
180  return False
181 
182  finally:
183  if server:
184  server.quit()
185 
186  return True
187 
188  def send_message(self, message="", **kwargs):
189  """Build and send a message to a user.
190 
191  Will send plain text normally, with pictures as attachments if images config is
192  defined, or will build a multipart HTML if html config is defined.
193  """
194  subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
195 
196  if data := kwargs.get(ATTR_DATA):
197  if ATTR_HTML in data:
198  msg = _build_html_msg(
199  self.hass,
200  message,
201  data[ATTR_HTML],
202  images=data.get(ATTR_IMAGES, []),
203  )
204  else:
205  msg = _build_multipart_msg(
206  self.hass, message, images=data.get(ATTR_IMAGES, [])
207  )
208  else:
209  msg = _build_text_msg(message)
210 
211  msg["Subject"] = subject
212 
213  if not (recipients := kwargs.get(ATTR_TARGET)):
214  recipients = self.recipientsrecipients
215  msg["To"] = recipients if isinstance(recipients, str) else ",".join(recipients)
216  if self._sender_name_sender_name:
217  msg["From"] = f"{self._sender_name} <{self._sender}>"
218  else:
219  msg["From"] = self._sender_sender
220  msg["X-Mailer"] = "Home Assistant"
221  msg["Date"] = email.utils.format_datetime(dt_util.now())
222  msg["Message-Id"] = email.utils.make_msgid()
223 
224  return self._send_email_send_email(msg, recipients)
225 
226  def _send_email(self, msg, recipients):
227  """Send the message."""
228  mail = self.connectconnect()
229  for _ in range(self.triestries):
230  try:
231  mail.sendmail(self._sender_sender, recipients, msg.as_string())
232  break
233  except smtplib.SMTPServerDisconnected:
234  _LOGGER.warning(
235  "SMTPServerDisconnected sending mail: retrying connection"
236  )
237  mail.quit()
238  mail = self.connectconnect()
239  except smtplib.SMTPException:
240  _LOGGER.warning("SMTPException sending mail: retrying connection")
241  mail.quit()
242  mail = self.connectconnect()
243  mail.quit()
244 
245 
246 def _build_text_msg(message):
247  """Build plaintext email."""
248  _LOGGER.debug("Building plain text email")
249  return MIMEText(message)
250 
251 
252 def _attach_file(hass, atch_name, content_id=""):
253  """Create a message attachment.
254 
255  If MIMEImage is successful and content_id is passed (HTML), add images in-line.
256  Otherwise add them as attachments.
257  """
258  try:
259  file_path = Path(atch_name).parent
260  if os.path.exists(file_path) and not hass.config.is_allowed_path(
261  str(file_path)
262  ):
263  allow_list = "allowlist_external_dirs"
264  file_name = os.path.basename(atch_name)
265  url = "https://www.home-assistant.io/docs/configuration/basic/"
267  translation_domain=DOMAIN,
268  translation_key="remote_path_not_allowed",
269  translation_placeholders={
270  "allow_list": allow_list,
271  "file_path": file_path,
272  "file_name": file_name,
273  "url": url,
274  },
275  )
276  with open(atch_name, "rb") as attachment_file:
277  file_bytes = attachment_file.read()
278  except FileNotFoundError:
279  _LOGGER.warning("Attachment %s not found. Skipping", atch_name)
280  return None
281 
282  try:
283  attachment = MIMEImage(file_bytes)
284  except TypeError:
285  _LOGGER.warning(
286  "Attachment %s has an unknown MIME type. Falling back to file",
287  atch_name,
288  )
289  attachment = MIMEApplication(file_bytes, Name=os.path.basename(atch_name))
290  attachment["Content-Disposition"] = (
291  f'attachment; filename="{os.path.basename(atch_name)}"'
292  )
293  else:
294  if content_id:
295  attachment.add_header("Content-ID", f"<{content_id}>")
296  else:
297  attachment.add_header(
298  "Content-Disposition",
299  f"attachment; filename={os.path.basename(atch_name)}",
300  )
301 
302  return attachment
303 
304 
305 def _build_multipart_msg(hass, message, images):
306  """Build Multipart message with images as attachments."""
307  _LOGGER.debug("Building multipart email with image attachme_build_html_msgnt(s)")
308  msg = MIMEMultipart()
309  body_txt = MIMEText(message)
310  msg.attach(body_txt)
311 
312  for atch_name in images:
313  attachment = _attach_file(hass, atch_name)
314  if attachment:
315  msg.attach(attachment)
316 
317  return msg
318 
319 
320 def _build_html_msg(hass, text, html, images):
321  """Build Multipart message with in-line images and rich HTML (UTF-8)."""
322  _LOGGER.debug("Building HTML rich email")
323  msg = MIMEMultipart("related")
324  alternative = MIMEMultipart("alternative")
325  alternative.attach(MIMEText(text, _charset="utf-8"))
326  alternative.attach(MIMEText(html, ATTR_HTML, _charset="utf-8"))
327  msg.attach(alternative)
328 
329  for atch_name in images:
330  name = os.path.basename(atch_name)
331  attachment = _attach_file(hass, atch_name, name)
332  if attachment:
333  msg.attach(attachment)
334  return msg
def __init__(self, server, port, timeout, sender, encryption, username, password, recipients, sender_name, debug, verify_ssl)
Definition: notify.py:125
None open(self, **Any kwargs)
Definition: lock.py:86
def _attach_file(hass, atch_name, content_id="")
Definition: notify.py:252
MailNotificationService|None get_service(HomeAssistant hass, ConfigType config, DiscoveryInfoType|None discovery_info=None)
Definition: notify.py:86
def _build_multipart_msg(hass, message, images)
Definition: notify.py:305
def _build_html_msg(hass, text, html, images)
Definition: notify.py:320
None setup_reload_service(HomeAssistant hass, str domain, Iterable[str] platforms)
Definition: reload.py:206
ssl.SSLContext client_context(SSLCipherList ssl_cipher_list=SSLCipherList.PYTHON_DEFAULT)
Definition: ssl.py:137