1
0

sender.py 19 KB

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