1
0

sender.py 23 KB

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