sender.py 16 KB

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