sender.py 18 KB

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