sender.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. from copy import copy
  2. from email.message import EmailMessage
  3. from email.utils import make_msgid, formatdate
  4. from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
  5. import warnings
  6. import jinja2
  7. from redmail.email.attachment import Attachments
  8. from redmail.email.body import HTMLBody, TextBody
  9. from redmail.models import EmailAddress, Error
  10. from .envs import get_span, is_last_group_row
  11. import smtplib
  12. from pathlib import Path
  13. from platform import node
  14. from getpass import getuser
  15. import datetime
  16. import os
  17. if TYPE_CHECKING:
  18. # These are never imported but just for linters
  19. import pandas as pd
  20. from PIL.Image import Image
  21. import matplotlib.pyplot as plt
  22. class EmailSender:
  23. """Red Mail Email Sender
  24. Parameters
  25. ----------
  26. host : str
  27. SMTP host address.
  28. port : int
  29. Port to the SMTP server.
  30. username : str, optional
  31. User name to authenticate on the server.
  32. password : str, optional
  33. User password to authenticate on the server.
  34. cls_smtp : smtplib.SMTP
  35. SMTP class to use for connection. See options
  36. from :stdlib:`Python smtplib docs <smtplib.html>`.
  37. use_starttls : bool
  38. Whether to use `STARTTLS <https://en.wikipedia.org/wiki/Opportunistic_TLS>`_
  39. when connecting to the SMTP server.
  40. user_name : str, optional
  41. Deprecated alias for username. Please use username instead.
  42. domain : str, optional
  43. Portion of the generated IDs after "@" which strengthens the uniqueness
  44. of the generated IDs. Used in the Message-ID header and in the Content-IDs
  45. of the embedded imaged in the HTML body. Usually not needed to be set.
  46. Defaults to the fully qualified domain name.
  47. **kwargs : dict
  48. Additional keyword arguments are passed to initiation in ``cls_smtp``.
  49. These are stored as attribute ``kws_smtp``
  50. Attributes
  51. ----------
  52. sender : str
  53. Address for sending emails if it is not specified
  54. in the send method.
  55. receivers : list of str
  56. Addresses to send emails if not specified
  57. in the send method.
  58. cc : list of str
  59. Carbon copies of emails if not specified
  60. in the send method.
  61. bcc : list of str
  62. Blind carbon copies of emails if not specified
  63. in the send method.
  64. subject : str
  65. Subject of emails if not specified
  66. in the send method.
  67. text : str
  68. Text body of emails if not specified
  69. in the send method.
  70. html : str
  71. HTML body of emails if not specified
  72. in the send method.
  73. text_template : str
  74. Name of the template to use as the text body of emails
  75. if not specified in the send method.
  76. html_template : str
  77. Name of the template to use as the HTML body of emails
  78. if not specified in the send method.
  79. use_jinja : bool
  80. Use Jinja to render text/HTML. If Jinja is disabled,
  81. images cannot be embedded to HTML, templates have no
  82. effect and body_params are not used. Defaults True
  83. templates_html : jinja2.Environment
  84. Jinja environment used for loading HTML templates
  85. if ``html_template`` is specified in send.
  86. templates_text : jinja2.Environment
  87. Jinja environment used for loading text templates
  88. if ``text_template`` is specified in send.
  89. default_html_theme : str
  90. Jinja template from ``templates_html_table``
  91. used for styling tables for HTML body.
  92. default_text_theme : str
  93. Jinja template from ``templates_text_table``
  94. used for styling tables for text body.
  95. templates_html_table : jinja2.Environment
  96. Jinja environment used for loading templates
  97. for table styling for HTML bodies.
  98. templates_text_table : jinja2.Environment
  99. Jinja environment used for loading templates
  100. for table styling for text bodies.
  101. headers : dict
  102. Additional email headers. Will also override
  103. the other generated email headers such as
  104. ``From:``, ``To`` and ``Date:``.
  105. kws_smtp : dict
  106. Keyword arguments passed to ``cls_smtp``
  107. when connecting to the SMTP server.
  108. connection : smtplib.SMTP, None
  109. Connection to the SMTP server. Created and closed
  110. before and after sending each email unless there
  111. is an existing connection.
  112. Examples
  113. --------
  114. .. code-block:: python
  115. email = EmailSender(server="smtp.mymail.com", port=123)
  116. email.send(
  117. subject="Example Email",
  118. sender="me@example.com",
  119. receivers=["you@example.com"],
  120. )
  121. """
  122. default_html_theme = "modest.html"
  123. default_text_theme = "pandas.txt"
  124. templates_html = jinja2.Environment(loader=jinja2.FileSystemLoader(str(Path(__file__).parent / "templates/html")))
  125. templates_html_table = jinja2.Environment(loader=jinja2.FileSystemLoader(str(Path(__file__).parent / "templates/html/table")))
  126. templates_text = jinja2.Environment(loader=jinja2.FileSystemLoader(str(Path(__file__).parent / "templates/text")))
  127. templates_text_table = jinja2.Environment(loader=jinja2.FileSystemLoader(str(Path(__file__).parent / "templates/text/table")))
  128. # Set globals
  129. templates_html_table.globals["get_span"] = get_span
  130. templates_text_table.globals["get_span"] = get_span
  131. templates_html_table.globals["is_last_group_row"] = is_last_group_row
  132. templates_text_table.globals["is_last_group_row"] = is_last_group_row
  133. attachment_encoding = 'UTF-8'
  134. def __init__(self,
  135. host:str,
  136. port:int,
  137. username:str=None,
  138. password:str=None,
  139. cls_smtp:smtplib.SMTP=smtplib.SMTP,
  140. use_starttls:bool=True,
  141. domain:Optional[str]=None,
  142. **kwargs):
  143. if "user_name" in kwargs and username is None:
  144. warnings.warn("Argument user_name was renamed as username. Please use username instead.", FutureWarning)
  145. username = kwargs.pop("user_name")
  146. self.host = host
  147. self.port = port
  148. self.username = username
  149. self.password = password
  150. # Defaults
  151. self.sender = None
  152. self.receivers = None
  153. self.cc = None
  154. self.bcc = None
  155. self.subject = None
  156. self.headers = None
  157. self.text = None
  158. self.html = None
  159. self.html_template = None
  160. self.text_template = None
  161. self.use_jinja = True
  162. self.domain = domain
  163. self.use_starttls = use_starttls
  164. self.cls_smtp = cls_smtp
  165. self.kws_smtp = kwargs
  166. self.connection = None
  167. def send(self,
  168. subject:Optional[str]=None,
  169. sender:Optional[str]=None,
  170. receivers:Union[List[str], str, None]=None,
  171. cc:Union[List[str], str, None]=None,
  172. bcc:Union[List[str], str, None]=None,
  173. headers:Optional[Dict[str, str]]=None,
  174. html:Optional[str]=None,
  175. text:Optional[str]=None,
  176. html_template:Optional[str]=None,
  177. text_template:Optional[str]=None,
  178. body_images:Optional[Dict[str, Union[str, bytes, 'plt.Figure', 'Image']]]=None,
  179. body_tables:Optional[Dict[str, 'pd.DataFrame']]=None,
  180. body_params:Optional[Dict[str, Any]]=None,
  181. attachments:Optional[Dict[str, Union[str, os.PathLike, 'pd.DataFrame', bytes]]]=None) -> EmailMessage:
  182. """Send an email.
  183. Parameters
  184. ----------
  185. subject : str
  186. Subject of the email.
  187. sender : str, optional
  188. Email address the email is sent from.
  189. Note that some email services might not
  190. respect changing sender address
  191. (for example Gmail).
  192. receivers : list, optional
  193. Receivers of the email.
  194. cc : list, optional
  195. Cc or Carbon Copy of the email.
  196. Additional recipients of the email.
  197. bcc : list, optional
  198. Blind Carbon Copy of the email.
  199. Additional recipients of the email that
  200. don't see who else got the email.
  201. headers : dict, optional
  202. Additional email headers. Will also override
  203. the other generated email headers such as
  204. ``From:``, ``To`` and ``Date:``.
  205. html : str, optional
  206. HTML body of the email. This is processed
  207. by Jinja and may contain loops, parametrization
  208. etc. See `Jinja documentation <https://jinja.palletsprojects.com>`_.
  209. text : str, optional
  210. Text body of the email. This is processed
  211. by Jinja and may contain loops, parametrization
  212. etc. See `Jinja documentation <https://jinja.palletsprojects.com>`_.
  213. html_template : str, optional
  214. Name of the HTML template loaded using Jinja environment specified
  215. in ``templates_html`` attribute. Specify either ``html`` or ``html_template``.
  216. text_template : str, optional
  217. Name of the text template loaded using Jinja environment specified
  218. in ``templates_text`` attribute. Specify either ``text`` or ``text_template``.
  219. body_images : dict of bytes, dict of path-like, dict of plt Figure, dict of PIL Image, optional
  220. HTML images to embed with the html. The key should be
  221. as Jinja variables in the html and the values represent
  222. images (path to an image, bytes of an image or image object).
  223. body_tables : dict of Pandas dataframes, optional
  224. HTML tables to embed with the html. The key should be
  225. as Jinja variables in the html and the values are Pandas
  226. DataFrames.
  227. body_params : dict, optional
  228. Extra Jinja parameters passed to the HTML and text bodies.
  229. use_jinja : bool
  230. Use Jinja to render text/HTML. If Jinja is disabled, body content cannot be
  231. embedded, templates have no effect and body parameters do nothing.
  232. attachments : dict, optional
  233. Attachments of the email. If dict value is string, the attachment content
  234. is the string itself. If path, the attachment is the content of the path's file.
  235. If dataframe, the dataframe is turned to bytes or text according to the
  236. file extension in dict key.
  237. Examples
  238. --------
  239. Simple example:
  240. .. code-block:: python
  241. from redmail import EmailSender
  242. email = EmailSender(
  243. host='localhost',
  244. port=0,
  245. username='me@example.com',
  246. password='<PASSWORD>'
  247. )
  248. email.send(
  249. subject="An email",
  250. sender="me@example.com",
  251. receivers=['you@example.com'],
  252. text="Hi, this is an email.",
  253. html="<h1>Hi, </h1><p>this is an email.</p>"
  254. )
  255. See more examples from :ref:`docs <examples>`
  256. Returns
  257. -------
  258. EmailMessage
  259. Email message.
  260. Notes
  261. -----
  262. See also `Jinja documentation <https://jinja.palletsprojects.com>`_
  263. for utilizing Jinja in ``html`` and ``text`` arguments or for using
  264. Jinja templates with ``html_template`` and ``text_template`` arguments.
  265. """
  266. msg = self.get_message(
  267. subject=subject,
  268. sender=sender,
  269. receivers=receivers,
  270. cc=cc,
  271. bcc=bcc,
  272. headers=headers,
  273. html=html,
  274. text=text,
  275. html_template=html_template,
  276. text_template=text_template,
  277. body_images=body_images,
  278. body_tables=body_tables,
  279. body_params=body_params,
  280. attachments=attachments,
  281. )
  282. self.send_message(msg)
  283. return msg
  284. def get_message(self,
  285. subject:Optional[str]=None,
  286. sender:Optional[str]=None,
  287. receivers:Union[List[str], str, None]=None,
  288. cc:Union[List[str], str, None]=None,
  289. bcc:Union[List[str], str, None]=None,
  290. html:Optional[str]=None,
  291. text:Optional[str]=None,
  292. html_template:Optional[str]=None,
  293. text_template:Optional[str]=None,
  294. body_images:Optional[Dict[str, Union[str, bytes, 'plt.Figure', 'Image']]]=None,
  295. body_tables:Optional[Dict[str, 'pd.DataFrame']]=None,
  296. body_params:Optional[Dict[str, Any]]=None,
  297. attachments:Optional[Dict[str, Union[str, os.PathLike, 'pd.DataFrame', bytes]]]=None,
  298. headers:Optional[Dict[str, str]]=None,
  299. use_jinja=None) -> EmailMessage:
  300. """Get the email message"""
  301. subject = subject or self.subject
  302. sender = self.get_sender(sender)
  303. receivers = self.get_receivers(receivers)
  304. cc = self.get_cc(cc)
  305. bcc = self.get_bcc(bcc)
  306. headers = self.get_headers(headers)
  307. html = html or self.html
  308. text = text or self.text
  309. html_template = html_template or self.html_template
  310. text_template = text_template or self.text_template
  311. use_jinja = self.use_jinja if use_jinja is None else use_jinja
  312. if subject is None:
  313. raise ValueError("Email must have a subject")
  314. msg = self._create_body(
  315. subject=subject,
  316. sender=sender,
  317. receivers=receivers,
  318. cc=cc,
  319. bcc=bcc,
  320. headers=headers
  321. )
  322. has_text = text is not None or text_template is not None
  323. has_html = html is not None or html_template is not None
  324. has_attachments = attachments is not None
  325. if has_text:
  326. body = TextBody(
  327. template=self.get_text_template(text_template),
  328. table_template=self.get_text_table_template(),
  329. jinja_env=self.templates_text,
  330. use_jinja=use_jinja
  331. )
  332. body.attach(
  333. msg,
  334. text,
  335. tables=body_tables,
  336. jinja_params=self.get_text_params(extra=body_params, sender=sender),
  337. )
  338. if has_html:
  339. body = HTMLBody(
  340. template=self.get_html_template(html_template),
  341. table_template=self.get_html_table_template(),
  342. jinja_env=self.templates_html,
  343. use_jinja=use_jinja,
  344. domain=self.domain
  345. )
  346. body.attach(
  347. msg,
  348. html=html,
  349. images=body_images,
  350. tables=body_tables,
  351. jinja_params=self.get_html_params(extra=body_params, sender=sender)
  352. )
  353. self._set_content_type(
  354. msg,
  355. has_text=has_text,
  356. has_html=has_html,
  357. has_attachments=has_attachments,
  358. )
  359. if attachments:
  360. att = Attachments(attachments, encoding=self.attachment_encoding)
  361. att.attach(msg)
  362. return msg
  363. def get_receivers(self, receivers:Union[list, str, None]) -> Union[List[str], None]:
  364. """Get receivers of the email"""
  365. return receivers or self.receivers
  366. def get_cc(self, cc:Union[list, str, None]) -> Union[List[str], None]:
  367. """Get carbon copy (cc) of the email"""
  368. return cc or self.cc
  369. def get_bcc(self, bcc:Union[list, str, None]) -> Union[List[str], None]:
  370. """Get blind carbon copy (bcc) of the email"""
  371. return bcc or self.bcc
  372. def get_headers(self, headers:Union[Dict[str, str], None]):
  373. """Get additional headers"""
  374. return headers or self.headers
  375. def get_sender(self, sender:Union[str, None]) -> str:
  376. """Get sender of the email"""
  377. return sender or self.sender or self.username
  378. def create_message_id(self) -> str:
  379. return make_msgid(domain=self.domain)
  380. def _create_body(self, subject, sender, receivers=None, cc=None, bcc=None, headers=None) -> EmailMessage:
  381. msg = EmailMessage()
  382. email_headers = {
  383. "From": sender,
  384. "Subject": subject,
  385. }
  386. # To whoom the email goes
  387. if receivers:
  388. email_headers["To"] = receivers
  389. if cc:
  390. email_headers['Cc'] = cc
  391. if bcc:
  392. email_headers['Bcc'] = bcc
  393. email_headers.update({
  394. # Message-IDs could be produced by the first mail server
  395. # or the program sending the email (as we are doing now).
  396. # Apparently Gmail might require it as of 2022
  397. "Message-ID": self.create_message_id(),
  398. "Date": formatdate(),
  399. })
  400. if headers:
  401. email_headers.update(headers)
  402. for key, val in email_headers.items():
  403. msg[key] = val
  404. return msg
  405. def _set_content_type(self, msg:EmailMessage, has_text, has_html, has_attachments):
  406. # NOTE: we don't convert emails that have only text/plain to multiplart/mixed
  407. # in order to keep the messages minimal (as often desired with simple plain text)
  408. if has_html or has_attachments:
  409. # Change the structure to multipart/mixed if possible.
  410. # This seems to be the most versatile and most unproblematic top level content-type
  411. # as otherwise content may be missing or it may be misrendered.
  412. # See: https://stackoverflow.com/a/23853079/13696660
  413. # See issues: #23, #37
  414. msg.make_mixed()
  415. def send_message(self, msg:EmailMessage):
  416. "Send the created message"
  417. if self.is_alive:
  418. self.connection.send_message(msg)
  419. else:
  420. # The connection was opened for this message
  421. # thus it is also closed with this message
  422. with self:
  423. self.connection.send_message(msg)
  424. def __enter__(self):
  425. self.connect()
  426. def __exit__(self, *args):
  427. self.close()
  428. def connect(self):
  429. "Connect to the SMTP Server"
  430. self.connection = self.get_server()
  431. def close(self):
  432. "Close (quit) the connection"
  433. if self.connection:
  434. self.connection.quit()
  435. self.connection = None
  436. def get_server(self) -> smtplib.SMTP:
  437. "Connect and get the SMTP Server"
  438. user = self.username
  439. password = self.password
  440. server = self.cls_smtp(self.host, self.port, **self.kws_smtp)
  441. if self.use_starttls:
  442. server.starttls()
  443. if user is not None or password is not None:
  444. server.login(user, password)
  445. return server
  446. @property
  447. def is_alive(self):
  448. "bool: Check if there is a connection to the SMTP server"
  449. return self.connection is not None
  450. def get_params(self, sender:str) -> Dict[str, Any]:
  451. "Get Jinja parametes passed to both text and html bodies"
  452. # TODO: Add receivers to params
  453. return {
  454. "node": node(),
  455. "user": getuser(),
  456. "now": datetime.datetime.now(),
  457. "sender": EmailAddress(sender),
  458. }
  459. def get_html_params(self, extra:Optional[dict]=None, **kwargs) -> Dict[str, Any]:
  460. "Get Jinja parameters passed to HTML body"
  461. params = self.get_params(**kwargs)
  462. params.update({
  463. "error": Error(content_type='html-inline')
  464. })
  465. if extra:
  466. params.update(extra)
  467. return params
  468. def get_text_params(self, extra:Optional[dict]=None, **kwargs) -> Dict[str, Any]:
  469. "Get Jinja parameters passed to text body"
  470. params = self.get_params(**kwargs)
  471. params.update({
  472. "error": Error(content_type='text')
  473. })
  474. if extra:
  475. params.update(extra)
  476. return params
  477. def get_html_table_template(self, layout:Optional[str]=None) -> Union[jinja2.Template, None]:
  478. "Get Jinja template for tables in HTML body"
  479. layout = self.default_html_theme if layout is None else layout
  480. if layout is None:
  481. return None
  482. return self.templates_html_table.get_template(layout)
  483. def get_html_template(self, layout:Optional[str]=None) -> Union[jinja2.Template, None]:
  484. "Get pre-made Jinja template for HTML body"
  485. if layout is None:
  486. return None
  487. return self.templates_html.get_template(layout)
  488. def get_text_table_template(self, layout:Optional[str]=None) -> jinja2.Template:
  489. "Get Jinja template for tables in text body"
  490. layout = self.default_text_theme if layout is None else layout
  491. if layout is None:
  492. return None
  493. return self.templates_text_table.get_template(layout)
  494. def get_text_template(self, layout:Optional[str]=None) -> jinja2.Template:
  495. "Get pre-made Jinja template for text body"
  496. if layout is None:
  497. return None
  498. return self.templates_text.get_template(layout)
  499. def set_template_paths(self,
  500. html:Union[str, os.PathLike, None]=None,
  501. text:Union[str, os.PathLike, None]=None,
  502. html_table:Union[str, os.PathLike, None]=None,
  503. text_table:Union[str, os.PathLike, None]=None):
  504. """Create Jinja envs for body templates using given paths
  505. This is a shortcut for manually setting them:
  506. .. code-block:: python
  507. sender.templates_html = jinja2.Environment(loader=jinja2.FileSystemLoader(...))
  508. sender.templates_text = jinja2.Environment(loader=jinja2.FileSystemLoader(...))
  509. sender.templates_html_table = jinja2.Environment(loader=jinja2.FileSystemLoader(...))
  510. sender.templates_text_table = jinja2.Environment(loader=jinja2.FileSystemLoader(...))
  511. """
  512. if html is not None:
  513. self.templates_html = jinja2.Environment(loader=jinja2.FileSystemLoader(html))
  514. if text is not None:
  515. self.templates_text = jinja2.Environment(loader=jinja2.FileSystemLoader(text))
  516. if html_table is not None:
  517. self.templates_html_table = jinja2.Environment(loader=jinja2.FileSystemLoader(html_table))
  518. if text_table is not None:
  519. self.templates_text_table = jinja2.Environment(loader=jinja2.FileSystemLoader(text_table))
  520. def copy(self) -> 'EmailSender':
  521. "Shallow copy EmailSender"
  522. return copy(self)
  523. @property
  524. def user_name(self):
  525. warnings.warn("Attribute user_name was renamed as username. Please use username instead.", FutureWarning)
  526. return self.username
  527. @user_name.setter
  528. def user_name(self, user):
  529. warnings.warn("Attribute user_name was renamed as username. Please use username instead.", FutureWarning)
  530. self.username = user