sender.py 18 KB

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