body.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. from email.message import EmailMessage
  2. import mimetypes
  3. from io import BytesIO
  4. from pathlib import Path
  5. from typing import Dict, Union, ByteString
  6. from pathlib import Path
  7. from redmail.utils import is_filelike, is_bytes, is_pathlike
  8. from redmail.utils import import_from_string
  9. from email.utils import make_msgid
  10. from jinja2.environment import Template, Environment
  11. import pandas as pd
  12. from markupsafe import Markup
  13. # We try to import matplotlib and PIL but if fails, they will be None
  14. plt = import_from_string("matplotlib.pyplot", if_missing="ignore")
  15. PIL = import_from_string("PIL", if_missing="ignore")
  16. class Body:
  17. def __init__(self, template:Template=None, table_template:Template=None):
  18. self.template = template
  19. self.table_template = table_template
  20. def render_body(self, body:str, jinja_params:dict):
  21. if body is not None and self.template is not None:
  22. raise ValueError("Either body or template must be specified but not both.")
  23. if body is not None:
  24. template = Environment().from_string(body)
  25. else:
  26. template = self.template
  27. return template.render(**jinja_params)
  28. def render_table(self, tbl, extra=None):
  29. # TODO: Nicer tables.
  30. # https://stackoverflow.com/a/55356741/13696660
  31. # Email HTML (generally) does not support CSS
  32. extra = {} if extra is None else extra
  33. df = pd.DataFrame(tbl)
  34. tbl_html = self.table_template.render({"df": df, **extra})
  35. return Markup(tbl_html)
  36. def render(self, cont:str, tables=None, jinja_params=None):
  37. tables = {} if tables is None else tables
  38. jinja_params = {} if jinja_params is None else jinja_params
  39. tables = {
  40. name: self.render_table(tbl)
  41. for name, tbl in tables.items()
  42. }
  43. return self.render_body(cont, jinja_params={**tables, **jinja_params})
  44. class TextBody(Body):
  45. def attach(self, msg:EmailMessage, text:str, **kwargs):
  46. text = self.render(text, **kwargs)
  47. msg.set_content(text)
  48. class HTMLBody(Body):
  49. def __init__(self, domain:str=None, **kwargs):
  50. super().__init__(**kwargs)
  51. self.domain = domain
  52. def attach(self,
  53. msg:EmailMessage,
  54. html:str,
  55. images: Dict[str, Union[Path, str, bytes]]=None,
  56. **kwargs):
  57. """Render email HTML
  58. Parameters
  59. ----------
  60. msg : EmailMessage
  61. Message of the email.
  62. html : str
  63. HTML that may contain Jinja syntax.
  64. body_images : dict of path-likes, bytes
  65. Images to embed to the HTML. The dict keys correspond to variables in the html.
  66. body_tables : dict of pd.DataFrame
  67. Tables to embed to the HTML
  68. jinja_params : dict
  69. Extra Jinja parameters for the HTML.
  70. """
  71. domain = msg["from"].split("@")[-1] if self.domain is None else self.domain
  72. html, cids = self.render(
  73. html,
  74. images=images,
  75. domain=domain,
  76. **kwargs
  77. )
  78. msg.add_alternative(html, subtype='html')
  79. if images is not None:
  80. # https://stackoverflow.com/a/49098251/13696660
  81. html_msg = msg.get_payload()[-1]
  82. cid_path_mapping = {cids[name]: path for name, path in images.items()}
  83. self.attach_imgs(html_msg, cid_path_mapping)
  84. def render(self, html:str, images:Dict[str, Union[dict, bytes, Path]]=None, tables:Dict[str, pd.DataFrame]=None, jinja_params:dict=None, domain=None):
  85. """Render Email HTML body (sets cid for image sources and adds data as other parameters)
  86. Parameters
  87. ----------
  88. html : str
  89. HTML (template) to be rendered with images,
  90. tables etc. May contain...
  91. images : list-like, optional
  92. A list-like of images to be rendered to the HTML.
  93. Values represent the Jinja variables found in the html
  94. and the images are rendered on those positions.
  95. tables : dict, optional
  96. A dict of tables to render to the HTML. The keys
  97. should represent variables in ``html`` and values
  98. should be Pandas dataframes to be rendered to the HTML.
  99. extra : dict, optional
  100. Extra items to be passed to the HTML Jinja template.
  101. table_theme : str, optional
  102. Theme to use for generating the HTML version of the
  103. table dataframes. See included files in the
  104. environment pybox.jinja2.envs.inline. The themes
  105. are stems of the files in templates/inline/table.
  106. Returns
  107. -------
  108. str, dict
  109. Rendered HTML and Content-IDs to the images.
  110. Example
  111. -------
  112. render_html('''
  113. <html>
  114. <body>
  115. <h1>Date {{ pic_date }}</h1>
  116. <img src={{ cat_picture }}>
  117. </body>
  118. </html>
  119. ''', {'cat_picture': 'path/to/cat_picture.jpg'}, {'pic_date': '2021-01-01'})
  120. """
  121. images = {} if images is None else images
  122. # Define CIDs for images
  123. cids = {
  124. name: make_msgid(domain=domain)
  125. for name in images
  126. }
  127. cids_html = {
  128. name: f'cid:{cid[1:-1]}' # taking "<" and ">" from beginning and end
  129. for name, cid in cids.items()
  130. }
  131. # Tables to HTML
  132. jinja_params = {**jinja_params, **cids_html}
  133. html = super().render(html, tables=tables, jinja_params=jinja_params)
  134. return html, cids
  135. def attach_imgs(self, msg_body:EmailMessage, imgs:Dict[str, Union[ByteString, str, Dict[str, Union[ByteString, str]]]]):
  136. """Attach CID images to Message Body
  137. Examples:
  138. ---------
  139. attach_imgs(..., {"<>"})
  140. """
  141. for cid, img in imgs.items():
  142. if is_bytes(img) or isinstance(img, BytesIO):
  143. # We just assume the user meant PNG. If not, it should have been specified
  144. img_content = img.read() if hasattr(img, "read") else img
  145. maintype = "image"
  146. subtype = "png"
  147. elif isinstance(img, dict):
  148. # Expecting dict explanation of bytes
  149. # ie. {"maintype": "image", "subtype": "png", "content"}
  150. required_keys = ("content", "maintype", "subtype")
  151. if any(key not in img for key in required_keys):
  152. missing_keys = tuple(key for key in required_keys if key not in img)
  153. raise KeyError(f"Image {img:!r} missing keys: {missing_keys:!r}")
  154. img_content = img["content"]
  155. maintype = "image"
  156. subtype = "png"
  157. elif is_filelike(img):
  158. path = img
  159. maintype, subtype = mimetypes.guess_type(str(path))[0].split('/')
  160. with open(path, "rb") as img:
  161. img_content = img.read()
  162. elif plt is not None and isinstance(img, plt.Figure):
  163. buf = BytesIO()
  164. img.savefig(buf, format='png')
  165. buf.seek(0)
  166. img_content = buf.read()
  167. maintype = "image"
  168. subtype = "png"
  169. elif PIL is not None and isinstance(img, PIL.Image.Image):
  170. buf = BytesIO()
  171. img.save(buf, format='PNG')
  172. buf.seek(0)
  173. img_content = buf.read()
  174. maintype = "image"
  175. subtype = "png"
  176. else:
  177. # Cannot be figured out
  178. if isinstance(img, str):
  179. raise ValueError(f"Unknown image string '{img}'. Maybe incorrect path?")
  180. raise TypeError(f"Unknown image {repr(img)}")
  181. msg_body.add_related(
  182. img_content,
  183. maintype=maintype,
  184. subtype=subtype,
  185. cid=cid
  186. )