Преглед на файлове

Merge pull request #7 from Miksus/dev/recipients

This adds more breakdown on ``EmailSender`` methods so they are easier to subclass and also includes major updates on docstrings.
Mikael Koli преди 4 години
родител
ревизия
614ef18b0a
променени са 5 файла, в които са добавени 279 реда и са изтрити 76 реда
  1. 1 0
      docs/references.rst
  2. 67 0
      docs/tutorials/cookbook.rst
  3. 1 1
      docs/tutorials/example.rst
  4. 165 75
      redmail/email/sender.py
  5. 45 0
      redmail/test/email/test_cookbook.py

+ 1 - 0
docs/references.rst

@@ -5,6 +5,7 @@ Sender
 ------
 
 .. autoclass:: redmail.EmailSender
+    :members:
 
 
 Format Classes

+ 67 - 0
docs/tutorials/cookbook.rst

@@ -143,4 +143,71 @@ the plots and let Red Mail send them to you:
         body_tables={
             "tbl_summary": df_summary
         }
+    )
+
+
+Distribution Lists
+------------------
+
+There might be a situation in which you would like to 
+specify some sets of pre-defined distribution lists
+for which you will send emails to depending on situation. 
+To accomplish this, you can create subclass the :class:`.EmailSender`
+and create cystin distribution list logic:  
+
+.. code-block:: python
+
+    from redmail import EmailSender
+
+    class DistributionSender(EmailSender):
+        "Send email using pre-defined distribution lists"
+
+        def __init__(self, *args, distributions:dict, **kwargs):
+            super().__init__(*args, **kwargs)
+            self.distributions = distributions
+
+        def get_receivers(self, receiver_list):
+            if receiver_list:
+                return self.distributions[receiver_list]
+
+        def get_cc(self, receiver_list):
+            if receiver_list:
+                return self.distributions[receiver_list]
+
+        def get_bcc(self, receiver_list):
+            if receiver_list:
+                return self.distributions[receiver_list]
+
+Then to use it:
+
+.. code-block:: python
+
+    email = DistributionSender(
+        host="localhost", port=0,
+        distributions={
+            "managers": ["boss1@example.com", "boss2@example.com"],
+            "developers": ["dev1@example.com", "dev2@example.com"]
+        }
+    )
+
+    email.send(
+        subject="Important news",
+        receivers="developers",
+        cc="managers",
+        ...
+    )
+
+You can also accomplish this without subclassing to limited extent:
+
+.. code-block:: python
+
+    managers = EmailSender(host="localhost", port=0)
+    managers.receivers = ["boss1@example.com", "boss2@example.com"]
+
+    developers = EmailSender(host="localhost", port=0)
+    developers.receivers = ["dev1@example.com", "dev2@example.com"]
+
+    # Send an email to the developers
+    developers.send(
+        subject="Important news"
     )

+ 1 - 1
docs/tutorials/example.rst

@@ -1,5 +1,5 @@
 
-
+.. _examples:
 
 Examples
 ========

+ 165 - 75
redmail/email/sender.py

@@ -1,6 +1,6 @@
 
 from email.message import EmailMessage
-from typing import Callable, Dict, Optional, Union
+from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
 
 import jinja2
 from redmail.email.attachment import Attachments
@@ -15,9 +15,16 @@ from pathlib import Path
 from platform import node
 from getpass import getuser
 import datetime
+import os
+
+if TYPE_CHECKING:
+    # These are never imported but just for linters
+    import pandas as pd
+    from PIL.Image import Image
+    import matplotlib.pyplot as plt
 
 class EmailSender:
-    """Email sender
+    """Red Mail Email Sender
 
     Parameters
     ----------
@@ -25,25 +32,20 @@ class EmailSender:
         SMTP host address.
     port : int
         Port to the SMTP server.
-    user : str, callable
+    user_name : str, optional
         User name to authenticate on the server.
-    password : str, callable
+    password : str, optional
         User password to authenticate on the server.
 
     Examples
     --------
     .. code-block:: python
 
-        mymail = EmailSender(server="smtp.mymail.com", port=123)
-        mymail.set_credentials(
-            user=lambda: read_yaml("C:/config/email.yaml")["mymail"]["user"],
-            password=lambda: read_yaml("C:/config/email.yaml")["mymail"]["password"]
-        )
-        mymail.send(
-            subject="Important email",
-            html="<h1>Important</h1><img src={{ nice_pic }}>",
-            body_images={'nice_pic': 'path/to/pic.jpg'},
-
+        email = EmailSender(server="smtp.mymail.com", port=123)
+        email.send(
+            subject="Example Email",
+            sender="me@example.com",
+            receivers=["you@example.com"],
         )
     """
     
@@ -77,6 +79,8 @@ class EmailSender:
         # Defaults
         self.sender = None
         self.receivers = None
+        self.cc = None
+        self.bcc = None
         self.subject = None
 
         self.text = None
@@ -84,88 +88,146 @@ class EmailSender:
         self.html_template = None
         self.text_template = None
         
-    def send(self, **kwargs):
-        """Send an email message.
+    def send(self,
+             subject:Optional[str]=None,
+             sender:Optional[str]=None,
+             receivers:Union[List[str], str, None]=None,
+             cc:Union[List[str], str, None]=None,
+             bcc:Union[List[str], str, None]=None,
+             html:Optional[str]=None,
+             text:Optional[str]=None,
+             html_template:Optional[str]=None,
+             text_template:Optional[str]=None,
+             body_images:Optional[Dict[str, Union[str, bytes, 'plt.Figure', 'Image']]]=None, 
+             body_tables:Optional[Dict[str, 'pd.DataFrame']]=None, 
+             body_params:Optional[Dict[str, Any]]=None,
+             attachments:Optional[Dict[str, Union[str, os.PathLike, 'pd.DataFrame', bytes]]]=None) -> EmailMessage:
+        """Send an email.
 
         Parameters
         ----------
         subject : str
             Subject of the email.
+        sender : str, optional
+            Email address the email is sent from.
+            Note that some email services might not 
+            respect changing sender address 
+            (for example Gmail).
         receivers : list, optional
             Receivers of the email.
-        sender : str, optional
-            Sender of the email.
         cc : list, optional
             Cc or Carbon Copy of the email.
-            Extra recipients of the email.
+            Additional recipients of the email.
         bcc : list, optional
             Blind Carbon Copy of the email.
-            Extra recipients of the email that
+            Additional recipients of the email that
             don't see who else got the email.
         html : str, optional
-            HTML body of the email. May contain
-            Jinja templated variables of the 
-            tables, images and other variables.
-        text_body : str, optional
-            Text body of the email.
-        body_images : dict of bytes, path-likes and figures, optional
+            HTML body of the email. This is processed
+            by Jinja and may contain loops, parametrization
+            etc. See `Jinja documentation <https://jinja.palletsprojects.com>`_.
+        text : str, optional
+            Text body of the email. This is processed
+            by Jinja and may contain loops, parametrization
+            etc. See `Jinja documentation <https://jinja.palletsprojects.com>`_.
+        html_template : str, optional
+            Name of the HTML template loaded using Jinja environment specified
+            in ``templates_html`` attribute. Specify either ``html`` or ``html_template``.
+        text_template : str, optional
+            Name of the text template loaded using Jinja environment specified
+            in ``templates_text`` attribute. Specify either ``text`` or ``text_template``.
+        body_images : dict of bytes, dict of path-like, dict of plt Figure, dict of PIL Image, optional
             HTML images to embed with the html. The key should be 
             as Jinja variables in the html and the values represent
             images (path to an image, bytes of an image or image object).
-        body_tables : Dict[str, pd.DataFrame], optional
+        body_tables : dict of Pandas dataframes, optional
             HTML tables to embed with the html. The key should be 
             as Jinja variables in the html and the values are Pandas
             DataFrames.
-        html_params : dict, optional
-            Extra parameters passed to html_table as Jinja parameters.
+        body_params : dict, optional
+            Extra Jinja parameters passed to the HTML and text bodies.
+        attachments : dict, optional
+            Attachments of the email. If dict value is string, the attachment content
+            is the string itself. If path, the attachment is the content of the path's file.
+            If dataframe, the dataframe is turned to bytes or text according to the 
+            file extension in dict key.
 
         Examples
         --------
-            >>> sender = EmailSender(host="myserver", port=1234)
-            >>> sender.send(
-                sender="me@gmail.com",
-                receiver="you@gmail.com",
-                subject="Some news",
-                html='<h1>Hi,</h1> Nice to meet you. Look at this: <img src="{{ my_image }}">',
-                body_images={"my_image": Path("C:/path/to/img.png")}
-            )
-            >>> sender.send(
-                sender="me@gmail.com",
-                receiver="you@gmail.com",
-                subject="Some news",
-                html='<h1>Hi {{ name }},</h1> Nice to meet you. Look at this table: <img src="{{ my_table }}">',
-                body_images={"my_image": Path("C:/path/to/img.png")},
-                html_params={"name": "Jack"},
-            )
+        
+            Simple example:
+
+            .. code-block:: python
+
+                from redmail import EmailSender
+
+                email = EmailSender(
+                    host='localhost', 
+                    port=0, 
+                    user_name='me@example.com', 
+                    password='<PASSWORD>'
+                )
+                email.send(
+                    subject="An email",
+                    sender="me@example.com",
+                    receivers=['you@example.com'],
+                    test="Hi, this is an email.",
+                    html="<h1>Hi, </h1><p>this is an email.</p>"
+                )
+
+            See more examples from :ref:`docs <examples>`
 
         Returns
         -------
         EmailMessage
             Email message.
+
+        Notes
+        -----
+            See also `Jinja documentation <https://jinja.palletsprojects.com>`_
+            for utilizing Jinja in ``html`` and ``text`` arguments or for using 
+            Jinja templates with  ``html_template`` and ``text_template`` arguments.
         """
-        msg = self.get_message(**kwargs)
+        msg = self.get_message(
+            subject=subject,
+            sender=sender,
+            receivers=receivers,
+            cc=cc,
+            bcc=bcc,
+            html=html,
+            text=text,
+            html_template=html_template,
+            text_template=text_template,
+            body_images=body_images,
+            body_tables=body_tables,
+            body_params=body_params,
+            attachments=attachments,
+        )
         self.send_message(msg)
         return msg
         
     def get_message(self, 
-                  subject:str=None,
-                  receivers:list=None,
-                  sender:str=None,
-                  cc:list=None,
-                  bcc:list=None,
-                  html:str=None,
-                  text:str=None,
-                  html_template=None,
-                  text_template=None,
-                  body_images:Dict[str, str]=None, 
-                  body_tables:Dict[str, str]=None, 
-                  body_params:dict=None,
-                  attachments:dict=None) -> EmailMessage:
-        """Get the email message."""
+                  subject:Optional[str]=None,
+                  sender:Optional[str]=None,
+                  receivers:Union[List[str], str, None]=None,
+                  cc:Union[List[str], str, None]=None,
+                  bcc:Union[List[str], str, None]=None,
+                  html:Optional[str]=None,
+                  text:Optional[str]=None,
+                  html_template:Optional[str]=None,
+                  text_template:Optional[str]=None,
+                  body_images:Optional[Dict[str, Union[str, bytes, 'plt.Figure', 'Image']]]=None, 
+                  body_tables:Optional[Dict[str, 'pd.DataFrame']]=None, 
+                  body_params:Optional[Dict[str, Any]]=None,
+                  attachments:Optional[Dict[str, Union[str, os.PathLike, 'pd.DataFrame', bytes]]]=None) -> EmailMessage:
+        """Get the email message"""
 
         subject = subject or self.subject
-        sender = sender or self.sender or self.user_name
-        receivers = receivers or self.receivers
+        sender = self.get_sender(sender)
+
+        receivers = self.get_receivers(receivers)
+        cc = self.get_cc(cc)
+        bcc = self.get_bcc(bcc)
 
         html = html or self.html
         text = text or self.text
@@ -212,6 +274,22 @@ class EmailSender:
             att.attach(msg)
         return msg
 
+    def get_receivers(self, receivers:Union[list, str, None]) -> Union[List[str], None]:
+        """Get receivers of the email"""
+        return receivers or self.receivers
+
+    def get_cc(self, cc:Union[list, str, None]) -> Union[List[str], None]:
+        """Get carbon copy (cc) of the email"""
+        return cc or self.cc
+
+    def get_bcc(self, bcc:Union[list, str, None]) -> Union[List[str], None]:
+        """Get blind carbon copy (bcc) of the email"""
+        return bcc or self.bcc
+
+    def get_sender(self, sender:Union[str, None]) -> str:
+        """Get sender of the email"""
+        return sender or self.sender or self.user_name
+
     def _create_body(self, subject, sender, receivers=None, cc=None, bcc=None) -> EmailMessage:
         msg = EmailMessage()
         msg["from"] = sender
@@ -226,7 +304,7 @@ class EmailSender:
             msg['bcc'] = bcc
         return msg
 
-    def send_message(self, msg):
+    def send_message(self, msg:EmailMessage):
         "Send the created message"
         user = self.user_name
         password = self.password
@@ -239,8 +317,8 @@ class EmailSender:
         
         server.quit()
     
-    def get_params(self, sender:str):
-        "Get Jinja parametes passed to template"
+    def get_params(self, sender:str) -> Dict[str, Any]:
+        "Get Jinja parametes passed to both text and html bodies"
         # TODO: Add receivers to params
         return {
             "node": node(),
@@ -249,7 +327,8 @@ class EmailSender:
             "sender": EmailAddress(sender),
         }
 
-    def get_html_params(self, extra:Optional[dict]=None, **kwargs):
+    def get_html_params(self, extra:Optional[dict]=None, **kwargs) -> Dict[str, Any]:
+        "Get Jinja parameters passed to HTML body"
         params = self.get_params(**kwargs)
         params.update({
             "error": Error(content_type='html-inline')
@@ -258,7 +337,8 @@ class EmailSender:
             params.update(extra)
         return params
 
-    def get_text_params(self, extra:Optional[dict]=None, **kwargs):
+    def get_text_params(self, extra:Optional[dict]=None, **kwargs) -> Dict[str, Any]:
+        "Get Jinja parameters passed to text body"
         params = self.get_params(**kwargs)
         params.update({
             "error": Error(content_type='text')
@@ -267,37 +347,47 @@ class EmailSender:
             params.update(extra)
         return params
 
-    def get_html_table_template(self, layout=None) -> jinja2.Template:
+    def get_html_table_template(self, layout:Optional[str]=None) -> Union[jinja2.Template, None]:
+        "Get Jinja template for tables in HTML body"
         layout = self.default_html_theme if layout is None else layout
         if layout is None:
             return None
         return self.templates_html_table.get_template(layout)
 
-    def get_html_template(self, layout=None) -> jinja2.Template:
+    def get_html_template(self, layout:Optional[str]=None) -> Union[jinja2.Template, None]:
+        "Get pre-made Jinja template for HTML body"
         if layout is None:
             return None
         return self.templates_html.get_template(layout)
 
-    def get_text_table_template(self, layout=None) -> jinja2.Template:
+    def get_text_table_template(self, layout:Optional[str]=None) -> jinja2.Template:
+        "Get Jinja template for tables in text body"
         layout = self.default_text_theme if layout is None else layout
         if layout is None:
             return None
         return self.templates_text_table.get_template(layout)
 
-    def get_text_template(self, layout=None) -> jinja2.Template:
+    def get_text_template(self, layout:Optional[str]=None) -> jinja2.Template:
+        "Get pre-made Jinja template for text body"
         if layout is None:
             return None
         return self.templates_text.get_template(layout)
 
-    def set_template_paths(self, html=None, text=None, html_table=None, text_table=None):
+    def set_template_paths(self, 
+                           html:Union[str, os.PathLike, None]=None, 
+                           text:Union[str, os.PathLike, None]=None, 
+                           html_table:Union[str, os.PathLike, None]=None, 
+                           text_table:Union[str, os.PathLike, None]=None):
         """Create Jinja envs for body templates using given paths
         
-        This is a shortcut for manually setting them like:
-        .. clode-block:: python
+        This is a shortcut for manually setting them:
+
+        .. code-block:: python
 
             sender.templates_html = jinja2.Environment(loader=jinja2.FileSystemLoader(...))
             sender.templates_text = jinja2.Environment(loader=jinja2.FileSystemLoader(...))
-            ...
+            sender.templates_html_table = jinja2.Environment(loader=jinja2.FileSystemLoader(...))
+            sender.templates_text_table = jinja2.Environment(loader=jinja2.FileSystemLoader(...))
         """
         if html is not None:
             self.templates_html = jinja2.Environment(loader=jinja2.FileSystemLoader(html))

+ 45 - 0
redmail/test/email/test_cookbook.py

@@ -0,0 +1,45 @@
+
+from typing import Union
+from redmail import EmailSender
+
+def test_distributions():
+    class DistrSender(EmailSender):
+        "Send email using pre-defined distribution lists"
+
+        def __init__(self, *args, distribution:dict, **kwargs):
+            super().__init__(*args, **kwargs)
+            self.distributions = distribution
+
+        def get_receivers(self, receiver_list):
+            if receiver_list:
+                return self.distributions[receiver_list]
+
+        def get_cc(self, receiver_list):
+            if receiver_list:
+                return self.distributions[receiver_list]
+
+        def get_bcc(self, receiver_list):
+            if receiver_list:
+                return self.distributions[receiver_list]
+
+    email = DistrSender(
+        host="localhost", 
+        port=0,
+        distribution={
+            'group1': ["me@example.com", "you@example.com"],
+            'group2': ["he@example.com", "she@example.com"],
+        }
+    )
+
+    msg = email.get_message(
+        sender="me@example.com",
+        receivers="group1",
+        cc="group2",
+        subject="Some email",
+    )
+    assert dict(msg.items()) == {
+        'from': 'me@example.com', 
+        'subject': 'Some email', 
+        'to': 'me@example.com, you@example.com', 
+        'cc': 'he@example.com, she@example.com', 
+    }