sender.py 21 KB

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