body.py 9.2 KB

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