sender.py 17 KB

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