sender.py 17 KB

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