Просмотр исходного кода

upd: added attachments and major refactor

Mikael Koli 4 лет назад
Родитель
Сommit
f44e74540d

+ 7 - 7
redmail/email/__init__.py

@@ -1,17 +1,17 @@
 from .sender import EmailSender
 
 gmail = EmailSender(
-    server="smtp.gmail.com",
+    host="smtp.gmail.com",
     port=587,
 )
 
-def send_email(*args, server:str, port:int, user_name:str, password:str, **kwargs):
+def send_email(*args, host:str, port:int, user_name:str, password:str, **kwargs):
     """Send email
 
     Parameters
     ----------
-    server : str
-        Address of the SMTP server
+    host : str
+        Address of the SMTP host
     port : int
         Port of the SMTP server
     user_name : str
@@ -19,12 +19,12 @@ def send_email(*args, server:str, port:int, user_name:str, password:str, **kwarg
     password : str
         Password of the user to send the email with
     **kwargs : dict
-        See redmail.EmailSender.send_email
+        See redmail.EmailSender.send
     """
     sender = EmailSender(
-        server=server, 
+        host=host, 
         port=port, 
         user_name=user_name,
         password=password
     )
-    return sender.send_email(*args, **kwargs)
+    return sender.send(*args, **kwargs)

+ 116 - 0
redmail/email/attachment.py

@@ -0,0 +1,116 @@
+
+from email.message import EmailMessage
+from email.mime.base import MIMEBase
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
+from email.mime.application import MIMEApplication
+import io
+from pathlib import Path, PurePath
+from typing import Union
+
+from .utils import PIL, plt
+import pandas as pd
+
+
+class Attachments:
+
+    def __init__(self, attachments:Union[list, dict], encoding='UTF-8'):
+        self.attachments = attachments
+        self.encoding = encoding
+
+    def attach(self, msg:EmailMessage):
+        for part in self._get_parts():
+            msg.attach(part)
+
+    def _get_parts(self):
+        if isinstance(self.attachments, dict):
+            for name, cont in self.attachments.items():
+                yield self._get_part_named(cont, name=name)
+        elif isinstance(self.attachments, (list, set, tuple)):
+            for cont in self.attachments:
+                yield self._get_part(cont)
+        else:
+            # A single attachment
+            yield self._get_part(self.attachments)
+
+    def _get_part(self, item) -> MIMEBase:
+        cont = self._get_bytes(item)
+        filename = self._get_filename(item)
+        part = MIMEApplication(cont)
+        part.add_header(
+            "Content-Disposition",
+            "attachment", filename=filename
+        )
+        part.add_header('Content-Transfer-Encoding', 'base64')
+        return part
+
+    def _get_part_named(self, item, name) -> MIMEBase:
+        cont = self._get_bytes_named(item, name)
+
+        part = MIMEApplication(cont)
+        part.add_header(
+            "Content-Disposition",
+            "attachment", filename=name
+        )
+        return part
+
+    def _get_bytes(self, item) -> bytes:
+        if isinstance(item, str):
+            # Considered as path
+            if Path(item).is_file():
+                return Path(item).read_bytes()
+            else:
+                raise ValueError(f"Unknown attachment '{item}'. Perhaps a mistyped path?")
+        elif isinstance(item, PurePath):
+            return item.read_bytes()
+        elif isinstance(item, (pd.DataFrame, pd.Series)):
+            return item.to_csv().encode(self.encoding)
+        elif isinstance(item, (bytes, bytearray)):
+            return item
+        else:
+            raise TypeError(f"Unknown attachment {type(item)}")
+
+    def _get_bytes_named(self, item, name:str) -> bytes:
+        if isinstance(item, str):
+            # Considered as raw document
+            return item
+        elif isinstance(item, PurePath):
+            return item.read_bytes()
+        elif isinstance(item, (pd.DataFrame, pd.Series)):
+            buff = io.BytesIO()
+            if name.endswith(".xlsx"):
+                item.to_excel(buff)
+                return buff.getvalue()
+            elif name.endswith(".csv"):
+                return item.to_csv().encode(self.encoding)
+            elif name.endswith(".html"):
+                return item.to_html().encode(self.encoding)
+            elif name.endswith('.txt'):
+                return str(item)
+            else:
+                raise ValueError(f"Unknown dataframe conversion for '{name}'")
+        elif isinstance(item, (bytes, bytearray)):
+            return item
+        elif PIL is not None and isinstance(item, PIL.Image.Image):
+            buf = io.BytesIO()
+            item.save(buf, format='PNG')
+            buf.seek(0)
+            return buf.read()
+        elif plt is not None and isinstance(item, plt.Figure):
+            buf = io.BytesIO()
+            item.savefig(buf, format=Path(name).suffix[1:])
+            buf.seek(0)
+            return buf.read()
+        else:
+            raise TypeError(f"Unknown attachment {type(item)} ({name})")
+
+    def _get_filename(self, item):
+        if isinstance(item, str):
+            # Considered as path
+            if Path(item).is_file():
+                return Path(item).name
+            return item
+        elif isinstance(item, PurePath):
+            return item.name
+        else:
+            raise TypeError(f"Cannot figure out filename for {item}")

+ 20 - 16
redmail/email/body.py

@@ -17,8 +17,22 @@ import pandas as pd
 from markupsafe import Markup
 
 # We try to import matplotlib and PIL but if fails, they will be None
-plt = import_from_string("matplotlib.pyplot", if_missing="ignore")
-PIL = import_from_string("PIL", if_missing="ignore")
+from .utils import PIL, plt
+
+class BodyImage:
+    "Utility class to represent image on HTML"
+
+    def __init__(self, cid, obj, name=None):
+        self.cid = cid
+        self.obj = obj
+        self.name = name
+
+    def __str__(self):
+        return f'<img src="{self.src}">'
+
+    @property
+    def src(self):
+        return f'cid:{ self.cid }'
 
 class Body:
 
@@ -134,16 +148,6 @@ class HTMLBody(Body):
         str, dict
             Rendered HTML and Content-IDs to the images.
 
-        Example
-        -------
-            render_html('''
-            <html>
-                <body>
-                    <h1>Date {{ pic_date }}</h1>
-                    <img src={{ cat_picture }}>
-                </body>
-            </html>
-            ''', {'cat_picture': 'path/to/cat_picture.jpg'}, {'pic_date': '2021-01-01'})
         """
         
         images = {} if images is None else images
@@ -153,13 +157,13 @@ class HTMLBody(Body):
             name: make_msgid(domain=domain)
             for name in images
         }
-        cids_html = {
-            name: f'cid:{cid[1:-1]}' # taking "<" and ">" from beginning and end 
+        html_images = {
+            name: BodyImage(cid=cid[1:-1], name=name, obj=images[name]) # taking "<" and ">" from beginning and end 
             for name, cid in cids.items()
         }
 
         # Tables to HTML
-        jinja_params = {**jinja_params, **cids_html}
+        jinja_params = {**jinja_params, **html_images}
         html = super().render(html, tables=tables, jinja_params=jinja_params)
         return html, cids
 
@@ -189,7 +193,7 @@ class HTMLBody(Body):
                 maintype = "image"
                 subtype = "png"
 
-            elif is_filelike(img):
+            elif isinstance(img, Path) or (isinstance(img, str) and Path(img).is_file()):
                 path = img
                 maintype, subtype = mimetypes.guess_type(str(path))[0].split('/')
                 

+ 99 - 52
redmail/email/sender.py

@@ -1,11 +1,12 @@
 
 from email.message import EmailMessage
-from typing import Callable, Dict, Union
+from typing import Callable, Dict, Optional, Union
 
 import jinja2
+from redmail.email.attachment import Attachments
 
 from redmail.email.body import HTMLBody, TextBody
-from .address import EmailAddress
+from redmail.models import EmailAddress, Error
 from .envs import get_span, is_last_group_row
 
 import smtplib
@@ -20,25 +21,27 @@ class EmailSender:
 
     Parameters
     ----------
-        server : str
-            SMTP server address.
-        port : int
-            Port to the SMTP server.
-        user : str, callable
-            User name to authenticate on the server.
-        password : str, callable
-            User password to authenticate on the server.
+    host : str
+        SMTP host address.
+    port : int
+        Port to the SMTP server.
+    user : str, callable
+        User name to authenticate on the server.
+    password : str, callable
+        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_email(
+        mymail.send(
             subject="Important email",
-            html_body="<h1>Important</h1><img src={{ nice_pic }}>",
+            html="<h1>Important</h1><img src={{ nice_pic }}>",
             body_images={'nice_pic': 'path/to/pic.jpg'},
 
         )
@@ -60,21 +63,33 @@ class EmailSender:
     templates_html_table.globals["is_last_group_row"] = is_last_group_row
     templates_text_table.globals["is_last_group_row"] = is_last_group_row
 
-    def __init__(self, server:str, port:int, user_name:str=None, password:str=None):
-        self.server = server
+    attachment_encoding = 'UTF-8'
+
+    def __init__(self, host:str, port:int, user_name:str=None, password:str=None):
+        self.host = host
         self.port = port
 
         self.user_name = user_name
         self.password = password
+
+        # Defaults
+        self.sender = None
+        self.receivers = None
+        self.subject = None
+
+        self.text = None
+        self.html = None
+        self.html_template = None
+        self.text_template = None
         
-    def send_email(self, **kwargs):
+    def send(self, **kwargs):
         """Send an email message.
 
         Parameters
         ----------
         subject : str
             Subject of the email.
-        receiver : list, optional
+        receivers : list, optional
             Receivers of the email.
         sender : str, optional
             Sender of the email.
@@ -85,38 +100,38 @@ class EmailSender:
             Blind Carbon Copy of the email.
             Extra recipients of the email that
             don't see who else got the email.
-        html_body : str, optional
+        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 images to embed with the html_body. The key should be 
-            as Jinja variables in the html_body and the values represent
+            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
-            HTML tables to embed with the html_body. The key should be 
-            as Jinja variables in the html_body and the values are Pandas
+            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.
 
         Examples
         --------
-            >>> sender = EmailSender(server="myserver", port=1234)
-            >>> sender.send_email(
+            >>> sender = EmailSender(host="myserver", port=1234)
+            >>> sender.send(
                 sender="me@gmail.com",
                 receiver="you@gmail.com",
                 subject="Some news",
-                html_body='<h1>Hi,</h1> Nice to meet you. Look at this: <img src="{{ my_image }}">',
+                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_email(
+            >>> sender.send(
                 sender="me@gmail.com",
                 receiver="you@gmail.com",
                 subject="Some news",
-                html_body='<h1>Hi {{ name }},</h1> Nice to meet you. Look at this table: <img src="{{ my_table }}">',
+                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"},
             )
@@ -131,65 +146,78 @@ class EmailSender:
         return msg
         
     def get_message(self, 
-                  subject:str,
-                  receiver:list=None,
+                  subject:str=None,
+                  receivers:list=None,
                   sender:str=None,
                   cc:list=None,
                   bcc:list=None,
-                  html_body:str=None,
-                  text_body:str=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) -> EmailMessage:
+                  body_params:dict=None,
+                  attachments:dict=None) -> EmailMessage:
         """Get the email message."""
-        
-        sender = sender or self.user_name
+
+        subject = subject or self.subject
+        sender = sender or self.sender or self.user_name
+        receivers = receivers or self.receivers
+
+        html = html or self.html
+        text = text or self.text
+        html_template = html_template or self.html_template
+        text_template = text_template or self.text_template
+
+        if subject is None:
+            raise ValueError("Email must have a subject")
+
         msg = self._create_body(
             subject=subject, 
             sender=sender, 
-            receiver=receiver,
+            receivers=receivers,
             cc=cc,
             bcc=bcc,
         )
 
-        jinja_params = self.get_template_params(sender=sender)
-        jinja_params.update(body_params if body_params is not None else {})
-        if text_body is not None or text_template is not None:
+        if text is not None or text_template is not None:
             body = TextBody(
-                template=self.get_text_body_template(text_template),
+                template=self.get_text_template(text_template),
                 table_template=self.get_text_table_template(),
             )
             body.attach(
                 msg, 
-                text_body, 
+                text, 
                 tables=body_tables,
-                jinja_params=jinja_params,
+                jinja_params=self.get_text_params(extra=body_params, sender=sender),
             )
 
-        if html_body is not None or html_template is not None:
+        if html is not None or html_template is not None:
             body = HTMLBody(
-                template=self.get_html_body_template(html_template),
+                template=self.get_html_template(html_template),
                 table_template=self.get_html_table_template(),
             )
             body.attach(
                 msg,
-                html=html_body,
+                html=html,
                 images=body_images,
                 tables=body_tables,
-                jinja_params=jinja_params
+                jinja_params=self.get_html_params(extra=body_params, sender=sender)
             )
+        if attachments:
+            att = Attachments(attachments, encoding=self.attachment_encoding)
+            att.attach(msg)
         return msg
 
-    def _create_body(self, subject, sender, receiver=None, cc=None, bcc=None) -> EmailMessage:
+    def _create_body(self, subject, sender, receivers=None, cc=None, bcc=None) -> EmailMessage:
         msg = EmailMessage()
         msg["from"] = sender
         msg["subject"] = subject
         
         # To whoom the email goes
-        if receiver:
-            msg["to"] = receiver
+        if receivers:
+            msg["to"] = receivers
         if cc:
             msg['cc'] = cc
         if bcc:
@@ -201,14 +229,15 @@ class EmailSender:
         user = self.user_name
         password = self.password
         
-        server = smtplib.SMTP(self.server, self.port)
+        server = smtplib.SMTP(self.host, self.port)
         server.starttls()
-        server.login(user, password)
+        if user is not None or password is not None:
+            server.login(user, password)
         server.send_message(msg)
         
         server.quit()
     
-    def get_template_params(self, sender:str):
+    def get_params(self, sender:str):
         "Get Jinja parametes passed to template"
         # TODO: Add receivers to params
         return {
@@ -218,13 +247,31 @@ class EmailSender:
             "sender": EmailAddress(sender),
         }
 
+    def get_html_params(self, extra:Optional[dict]=None, **kwargs):
+        params = self.get_params(**kwargs)
+        params.update({
+            "error": Error(content_type='html-inline')
+        })
+        if extra:
+            params.update(extra)
+        return params
+
+    def get_text_params(self, extra:Optional[dict]=None, **kwargs):
+        params = self.get_params(**kwargs)
+        params.update({
+            "error": Error(content_type='text')
+        })
+        if extra:
+            params.update(extra)
+        return params
+
     def get_html_table_template(self, layout=None) -> jinja2.Template:
         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_body_template(self, layout=None) -> jinja2.Template:
+    def get_html_template(self, layout=None) -> jinja2.Template:
         if layout is None:
             return None
         return self.templates_html.get_template(layout)
@@ -235,7 +282,7 @@ class EmailSender:
             return None
         return self.templates_text_table.get_template(layout)
 
-    def get_text_body_template(self, layout=None) -> jinja2.Template:
+    def get_text_template(self, layout=None) -> jinja2.Template:
         if layout is None:
             return None
         return self.templates_text.get_template(layout)

+ 5 - 0
redmail/email/utils.py

@@ -0,0 +1,5 @@
+
+from redmail.utils import import_from_string
+
+plt = import_from_string("matplotlib.pyplot", if_missing="ignore")
+PIL = import_from_string("PIL", if_missing="ignore")

+ 2 - 0
redmail/models/__init__.py

@@ -0,0 +1,2 @@
+from .address import EmailAddress
+from .system import Error

+ 25 - 26
redmail/email/address.py → redmail/models/address.py

@@ -1,19 +1,23 @@
 
 
 class EmailAddress:
-    """Utility class to represent email
-    address and access the organization/
-    names in it.
+    """Format class for email addresses.
+
+    This class is useful manipulate the 
+    addresses in templates with minimal
+    effort. More about email addresses from 
+    `Wikipedia <https://en.wikipedia.org/wiki/Email_address>`_.
+
+    Parameters
+    ----------
+    address : str
+        Email address as string.
+
 
-    https://en.wikipedia.org/wiki/Email_address
     """
     def __init__(self, address:str):
         self.address = address
 
-
-    def organization(self):
-        return self.address.split("@")
-
     def __str__(self):
         return self.address
 
@@ -28,12 +32,12 @@ class EmailAddress:
 
     @property
     def domain(self):
+        "bool: Domain of the address"
         return self.parts[1]
 
-# Checks
     @property
     def is_personal(self):
-        "Whether the email address seems to belong to a person"
+        "bool: Whether the email address seems to belong to a person"
         return len(self.local_part.split(".")) == 2
 
 # More of typical conventions
@@ -54,33 +58,28 @@ class EmailAddress:
         return domain[-2] if len(domain) > 1 else None
 
     @property
-    def full_name(self):
-        """Get full name of the sender (if possible)
-        
-        Ie. john.smith@en.example.com --> 'john smith'"""
+    def full_name(self) -> str:
+        """str: Full name of the address
+        """
         if self.is_personal:
-            return f'{self.first_name} {self.last_name}'
+            return f'{self.first_name.capitalize()} {self.last_name.capitalize()}'
         else:
             return self.local_part.capitalize()
 
     @property
-    def first_name(self):
-        """Get first name of the sender (if possible)
-        
-        Ie. john.smith@en.example.com --> John"""
+    def first_name(self) -> str:
+        """str: First name of the address (if in typical form)"""
         if self.is_personal:
             return self.local_part.split(".")[0].capitalize()
 
     @property
-    def last_name(self):
-        """Get last name of the sender (if possible)
-        
-        Ie. john.smith@en.example.com --> Smith"""
+    def last_name(self) -> str:
+        """str: Last name of the address (if in typical form)"""
         if self.is_personal:
             return self.local_part.split(".")[1].capitalize()
 
 # Aliases
     @property
-    def organization(self):
-        """This is alias for second level domain."""
-        return self.second_level_domain
+    def organization(self) -> str:
+        """str: Organization"""
+        return self.second_level_domain.capitalize()

+ 102 - 0
redmail/models/system.py

@@ -0,0 +1,102 @@
+
+import traceback
+import sys
+from typing import List, Tuple
+from textwrap import dedent
+import html
+
+class Error:
+    """Format class for errors including the exception 
+    and traceback.
+    
+    Parameters
+    ----------
+        contet_type : str
+            Content type for which the error is meant to be rendered
+            on.
+        exception : Exception
+            Exception object. If not passed, current stack trace is used.
+    """
+
+    def __init__(self, content_type="text", exception:Exception=None):
+        self.content_type = content_type
+        self.exception = exception
+
+    def __str__(self):
+        if self.content_type == "text":
+            return self.as_text()
+        elif self.content_type == "html-inline":
+            return self.as_html_inline()
+        elif self.content_type == "html":
+            return self.as_html()
+
+    def as_text(self):
+        "Format traceback as text"
+        exc_type, exc_text, tb_list = self.exc_format()
+        tb_text = '\n'.join(tb_list)
+        return f"""Traceback (most recent call last):\n{tb_text}\n{exc_type}: {exc_text}"""
+
+    def as_html_inline(self):
+        "Format traceback as HTML"
+        exc_type, exc_text, tb_list = self.exc_format()
+        tb_str = '\n'.join(tb_list)
+        if tb_str.endswith('\n'):
+            tb_str = tb_str[:-1]
+        exc_type, exc_text, tb_str = (html.escape(val) for val in (exc_type, exc_text, tb_str))
+
+        return dedent(
+            f"""
+            <div>
+                <h4>Traceback (most recent call last):</h4>
+                <pre><code>{tb_str}</code></pre>
+                <span style="color: red; font-weight: bold">{exc_text}</span>: <span>{exc_type}</span>
+            </div>"""
+        )
+
+    def as_html(self):
+        "Format traceback as HTML"
+        exc_type, exc_text, tb_list = self.exc_format()
+        tb_str = '\n'.join(tb_list)
+        if tb_str.endswith('\n'):
+            tb_str = tb_str[:-1]
+        exc_type, exc_text, tb_str = (html.escape(val) for val in (exc_type, exc_text, tb_str))
+
+        return dedent(
+            f"""<div class="error">
+                <h4 class="header">Traceback (most recent call last):</h4>
+                <pre class="traceback"><code>{tb_str}</code></pre>
+                <div class="exception">
+                    <span class="exception-type">{exc_type}</span>: <span class="exception-value">{exc_text}</span>
+                </div>
+            </div>"""
+        )
+
+    @property
+    def exception_type(self) -> str:
+        "str: Type of the exception (as string)"
+        type_, _, _ = self.exc_format()
+        return type_
+
+    @property
+    def exception_value(self) -> str:
+        "str: Exception value (as string)"
+        _, value, _ = self.exc_format()
+        return value
+
+    @property
+    def traceback(self) -> List[str]:
+        "list str: Traceback (as list of str)"
+        _, _, tb = self.exc_format()
+        return tb
+
+    def exc_format(self) -> Tuple[str, str, List[str]]:
+        if self.exception is None:
+            exc_type, exc_value, tb = sys.exc_info()
+        else:
+            exc_value = self.exception
+            exc_type = type(self.exception)
+            tb = self.exception.__traceback__
+        tb_list = traceback.format_tb(tb)
+        exc_str = str(exc_value)
+        exc_type_str = exc_type.__name__
+        return exc_type_str, exc_str, tb_list

+ 294 - 0
redmail/tests/email/test_attachments.py

@@ -0,0 +1,294 @@
+
+from redmail import EmailSender
+
+import re
+import base64
+
+from pathlib import Path
+from io import BytesIO
+import pytest
+import pandas as pd
+
+import numpy as np
+
+from resources import get_mpl_fig, get_pil_image
+from convert import remove_extra_lines
+
+def to_encoded(s:str):
+    return str(base64.b64encode(s.encode()), 'ascii')
+
+
+def test_dict_string():
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receivers="you@gmail.com",
+        subject="Some news",
+        attachments={'data.txt': 'Some content'}
+    )
+    payload = msg.get_payload(0)
+    filename = payload.get_filename()
+    data = payload.get_payload()
+    assert filename == 'data.txt'
+    assert to_encoded("Some content") == data.replace('\n', '')
+
+def test_dict_path(tmpdir):
+    file = tmpdir.join("data.txt")
+    file.write("Some content")
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receivers="you@gmail.com",
+        subject="Some news",
+        attachments={'myfile.txt': Path(str(file))}
+    )
+    payload = msg.get_payload(0)
+    filename = payload.get_filename()
+    data = payload.get_payload()
+    assert filename == 'myfile.txt'
+    assert to_encoded("Some content") == data.replace('\n', '')
+
+def test_dict_dataframe_txt():
+    pytest.importorskip("pandas")
+    import pandas as pd
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receivers="you@gmail.com",
+        subject="Some news",
+        attachments={'myfile.txt': pd.DataFrame({'a': [1,2,3], 'b': ['1', '2', '3']})}
+    )
+    expected = str(pd.DataFrame({'a': [1,2,3], 'b': ['1', '2', '3']}))
+
+    payload = msg.get_payload(0)
+    filename = payload.get_filename()
+    data = payload.get_payload()
+    assert filename == 'myfile.txt'
+    assert to_encoded(expected) == data.replace('\n', '')
+
+def test_dict_dataframe_csv():
+    pytest.importorskip("pandas")
+    import pandas as pd
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receivers="you@gmail.com",
+        subject="Some news",
+        attachments={'myfile.csv': pd.DataFrame({'a': [1,2,3], 'b': ['1', '2', '3']})}
+    )
+    expected = ',a,b\r\n0,1,1\r\n1,2,2\r\n2,3,3\r\n'
+
+    payload = msg.get_payload(0)
+    filename = payload.get_filename()
+    data = payload.get_payload()
+    assert filename == 'myfile.csv'
+    assert to_encoded(expected) == data.replace('\n', '')
+
+def test_dict_dataframe_html():
+    pytest.importorskip("pandas")
+    import pandas as pd
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receivers="you@gmail.com",
+        subject="Some news",
+        attachments={'myfile.html': pd.DataFrame({'a': [1,2,3], 'b': ['1', '2', '3']})}
+    )
+    expected = '<table border="1" class="dataframe">\n  <thead>\n    <tr style="text-align: right;">\n      <th></th>\n      <th>a</th>\n      <th>b</th>\n    </tr>\n  </thead>\n  <tbody>\n    <tr>\n      <th>0</th>\n      <td>1</td>\n      <td>1</td>\n    </tr>\n    <tr>\n      <th>1</th>\n      <td>2</td>\n      <td>2</td>\n    </tr>\n    <tr>\n      <th>2</th>\n      <td>3</td>\n      <td>3</td>\n    </tr>\n  </tbody>\n</table>'
+
+    payload = msg.get_payload(0)
+    filename = payload.get_filename()
+    data = payload.get_payload().replace('\n', '')
+
+    assert filename == 'myfile.html'
+    assert to_encoded(expected) == data
+
+def test_dict_dataframe_excel_no_error():
+    pytest.importorskip("pandas")
+    pytest.importorskip("openpyxl")
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receivers="you@gmail.com",
+        subject="Some news",
+        attachments={'myfile.xlsx': pd.DataFrame({'a': [1,2,3], 'b': ['1', '2', '3']})}
+    )
+    payload = msg.get_payload(0)
+    filename = payload.get_filename()
+    data = payload.get_payload()
+    assert filename == 'myfile.html'
+    # Excels are harder to verify
+
+def test_dict_pil_no_error():
+    pil, bytes_img = get_pil_image()
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receivers="you@gmail.com",
+        subject="Some news",
+        attachments={'myimg.png': pil}
+    )
+    payload = msg.get_payload(0)
+    filename = payload.get_filename()
+    data = payload.get_payload()
+    assert filename == 'myimg.png'
+    assert str(base64.b64encode(bytes_img), 'ascii') == data.replace('\n', '')
+
+def test_dict_matplotlib_no_error():
+    fig, bytes_fig = get_mpl_fig()
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receivers="you@gmail.com",
+        subject="Some news",
+        attachments={'myimg.png': fig}
+    )
+    payload = msg.get_payload(0)
+    filename = payload.get_filename()
+    data = payload.get_payload()
+    assert filename == 'myimg.png'
+    assert str(base64.b64encode(bytes_fig), 'ascii') == data.replace('\n', '')
+
+def test_dict_multiple(tmpdir):
+    file1 = tmpdir.join("file_1.txt")
+    file1.write("Some content 1")
+
+    file2 = tmpdir.join("file_2.txt")
+    file2.write("Some content 2")
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receivers="you@gmail.com",
+        subject="Some news",
+        attachments={'data_1.txt': Path(file1), 'data_2.txt': Path(file2)}
+    )
+    expected = [('data_1.txt', 'Some content 1'), ('data_2.txt', 'Some content 2')]
+    for payload, expected in zip(msg.get_payload(), expected):
+        filename = payload.get_filename()
+        data = payload.get_payload()
+        assert filename == expected[0]
+        assert to_encoded(expected[1]) == data.replace('\n', '')
+
+
+
+# List attachments
+# ----------------
+
+def test_list_path(tmpdir):
+    file = tmpdir.join("data.txt")
+    file.write("Some content")
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receivers="you@gmail.com",
+        subject="Some news",
+        attachments=[Path(str(file))]
+    )
+    payload = msg.get_payload(0)
+    filename = payload.get_filename()
+    data = payload.get_payload()
+    assert filename == 'data.txt'
+    assert to_encoded("Some content") == data.replace('\n', '')
+
+def test_list_string_path(tmpdir):
+    file = tmpdir.join("data.txt")
+    file.write("Some content")
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receivers="you@gmail.com",
+        subject="Some news",
+        attachments=[str(file)]
+    )
+    payload = msg.get_payload(0)
+    filename = payload.get_filename()
+    data = payload.get_payload()
+    assert filename == 'data.txt'
+    assert to_encoded("Some content") == data.replace('\n', '')
+
+def test_list_string_error():
+
+    sender = EmailSender(host=None, port=1234)
+    with pytest.raises(ValueError):
+        msg = sender.get_message(
+            sender="me@gmail.com",
+            receivers="you@gmail.com",
+            subject="Some news",
+            attachments=['just something']
+        )
+
+def test_list_multiple(tmpdir):
+    file1 = tmpdir.join("data_1.txt")
+    file1.write("Some content 1")
+
+    file2 = tmpdir.join("data_2.txt")
+    file2.write("Some content 2")
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receivers="you@gmail.com",
+        subject="Some news",
+        attachments=[Path(str(file1)), Path(str(file2))]
+    )
+    expected = [('data_1.txt', 'Some content 1'), ('data_2.txt', 'Some content 2')]
+    for payload, expected in zip(msg.get_payload(), expected):
+        filename = payload.get_filename()
+        data = payload.get_payload()
+        assert filename == expected[0]
+        assert to_encoded(expected[1]) == data.replace('\n', '')
+
+def test_string_path(tmpdir):
+    file = tmpdir.join("data.txt")
+    file.write("Some content")
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receivers="you@gmail.com",
+        subject="Some news",
+        attachments=str(file)
+    )
+    payload = msg.get_payload(0)
+    filename = payload.get_filename()
+    data = payload.get_payload()
+    assert filename == 'data.txt'
+    assert to_encoded("Some content") == data.replace('\n', '')
+
+def test_string_error():
+    sender = EmailSender(host=None, port=1234)
+    with pytest.raises(ValueError):
+        msg = sender.get_message(
+            sender="me@gmail.com",
+            receivers="you@gmail.com",
+            subject="Some news",
+            attachments="just something"
+        )
+
+def test_path(tmpdir):
+    file = tmpdir.join("data.txt")
+    file.write("Some content")
+
+    sender = EmailSender(host=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receivers="you@gmail.com",
+        subject="Some news",
+        attachments=Path(file)
+    )
+    payload = msg.get_payload(0)
+    filename = payload.get_filename()
+    data = payload.get_payload()
+    assert filename == 'data.txt'
+    assert to_encoded("Some content") == data.replace('\n', '')

+ 54 - 17
redmail/tests/email/test_body.py

@@ -7,15 +7,17 @@ from convert import remove_extra_lines
 from getpass import getpass, getuser
 from platform import node
 
+from redmail.tests.helpers.convert import remove_email_extra
+
 def test_text_message():
     text = "Hi, nice to meet you."
 
-    sender = EmailSender(server=None, port=1234)
+    sender = EmailSender(host=None, port=1234)
     msg = sender.get_message(
         sender="me@gmail.com",
-        receiver="you@gmail.com",
+        receivers="you@gmail.com",
         subject="Some news",
-        text_body=text,
+        text=text,
     )
     payload = msg.get_payload()
     expected_headers = {
@@ -37,12 +39,12 @@ def test_text_message():
 def test_html_message():
     html = "<h3>Hi,</h3><p>Nice to meet you</p>"
 
-    sender = EmailSender(server=None, port=1234)
+    sender = EmailSender(host=None, port=1234)
     msg = sender.get_message(
         sender="me@gmail.com",
-        receiver="you@gmail.com",
+        receivers="you@gmail.com",
         subject="Some news",
-        html_body=html,
+        html=html,
     )
     payload = msg.get_payload()
     expected_headers = {
@@ -64,13 +66,13 @@ def test_text_and_html_message():
     html = "<h3>Hi,</h3><p>nice to meet you.</p>"
     text = "Hi, nice to meet you."
 
-    sender = EmailSender(server=None, port=1234)
+    sender = EmailSender(host=None, port=1234)
     msg = sender.get_message(
         sender="me@gmail.com",
-        receiver="you@gmail.com",
+        receivers="you@gmail.com",
         subject="Some news",
-        html_body=html,
-        text_body=text,
+        html=html,
+        text=text,
     )
     payload = msg.get_payload()
     expected_headers = {
@@ -119,21 +121,56 @@ def test_text_and_html_message():
 )
 def test_with_jinja_params(html, text, extra, expected_html, expected_text):
 
-    sender = EmailSender(server=None, port=1234)
+    sender = EmailSender(host=None, port=1234)
     msg = sender.get_message(
         sender="me@gmail.com",
-        receiver="you@gmail.com",
+        receivers="you@gmail.com",
         subject="Some news",
-        text_body=text,
-        html_body=html,
+        text=text,
+        html=html,
         body_params=extra
     )
     
     assert "multipart/alternative" == msg.get_content_type()
 
-    #text = remove_extra_lines(msg.get_payload()[0].get_payload()).replace("=20", "").replace('"3D', "")
-    text = remove_extra_lines(msg.get_payload()[0].get_payload()).replace("=20", "").replace('"3D', "")
-    html = remove_extra_lines(msg.get_payload()[1].get_payload()).replace("=20", "").replace('"3D', "")
+    text = remove_email_extra(msg.get_payload()[0].get_payload())
+    html = remove_email_extra(msg.get_payload()[1].get_payload())
 
     assert expected_html == html
     assert expected_text == text
+
+def test_with_error():
+    sender = EmailSender(host=None, port=1234)
+    try:
+        raise RuntimeError("Deliberate failure")
+    except:
+        msg = sender.get_message(
+            sender="me@gmail.com",
+            receivers="you@gmail.com",
+            subject="Some news",
+            text="Error occurred \n{{ error }}",
+            html="<h1>Error occurred: </h1>{{ error }}",
+        )
+    text = remove_email_extra(msg.get_payload()[0].get_payload())
+    html = remove_email_extra(msg.get_payload()[1].get_payload())
+
+    assert text.startswith('Error occurred\nTraceback (most recent call last):\n  File "')
+    assert text.endswith(', in test_with_error\n    raise RuntimeError("Deliberate failure")\nRuntimeError: Deliberate failure\n')
+
+    assert html.startswith('<h1>Error occurred: </h1>\n        <div>\n            <h4>Traceback (most recent call last):</h4>\n            <pre><code>  File &quot;')
+    assert html.endswith(', in test_with_error\nraise RuntimeError(&quot;Deliberate failure&quot;)</code></pre>\n            <span style=3D"color: red; font-weight: bold">Deliberate failure</span>: <span>RuntimeError</span>\n        </div>\n')
+
+def test_set_defaults():
+    email = EmailSender(host=None, port=1234)
+    email.sender = 'me@gmail.com'
+    email.receivers = ['you@gmail.com', 'they@gmail.com']
+    email.subject = "Some email"
+    msg = email.get_message(text="Hi, an email")
+    assert {
+        'from': 'me@gmail.com', 
+        'to': 'you@gmail.com, they@gmail.com', 
+        'subject': 'Some email', 
+        'Content-Type': 'text/plain; charset="utf-8"', 
+        'Content-Transfer-Encoding': '7bit', 
+        'MIME-Version': '1.0'
+    } == dict(msg.items())

+ 9 - 9
redmail/tests/email/test_inline_media.py

@@ -42,12 +42,12 @@ def test_with_image_file(get_image_obj, dummy_png):
         dummy_bytes = f.read()
     image_obj = get_image_obj(dummy_png)
 
-    sender = EmailSender(server=None, port=1234)
+    sender = EmailSender(host=None, port=1234)
     msg = sender.get_message(
         sender="me@gmail.com",
-        receiver="you@gmail.com",
+        receivers="you@gmail.com",
         subject="Some news",
-        html_body='<h1>Hi,</h1> Nice to meet you. Look at this shit: <img src="{{ my_image }}">',
+        html='<h1>Hi,</h1> Nice to meet you. Look at this: {{ my_image }}',
         body_images={"my_image": image_obj}
     )
     
@@ -78,12 +78,12 @@ def test_with_image_file(get_image_obj, dummy_png):
 def test_with_image_obj(get_image_obj):
     image_obj, image_bytes = get_image_obj()
 
-    sender = EmailSender(server=None, port=1234)
+    sender = EmailSender(host=None, port=1234)
     msg = sender.get_message(
         sender="me@gmail.com",
-        receiver="you@gmail.com",
+        receivers="you@gmail.com",
         subject="Some news",
-        html_body='<h1>Hi,</h1> Nice to meet you. Look at this shit: <img src="{{ my_image }}">',
+        html='<h1>Hi,</h1> Nice to meet you. Look at this: <img src="{{ my_image }}">',
         body_images={"my_image": image_obj}
     )
     
@@ -158,12 +158,12 @@ def test_with_image_obj(get_image_obj):
 )
 def test_with_html_table_no_error(df, tmpdir):
 
-    sender = EmailSender(server=None, port=1234)
+    sender = EmailSender(host=None, port=1234)
     msg = sender.get_message(
         sender="me@gmail.com",
-        receiver="you@gmail.com",
+        receivers="you@gmail.com",
         subject="Some news",
-        html_body='The table {{my_table}}',
+        html='The table {{my_table}}',
         body_tables={"my_table": df}
     )
     

+ 13 - 6
redmail/tests/email/test_template.py

@@ -3,7 +3,7 @@ from redmail import EmailSender
 import pytest
 import pandas as pd
 
-from convert import remove_extra_lines
+from convert import remove_email_extra
 from getpass import getpass, getuser
 from platform import node
 
@@ -18,14 +18,21 @@ def test_template(tmpdir):
     text_templates.join("example.txt").write("""Hi {{ friend }}, \nhave you checked this open source project '{{ project_name }}'? \n- {{ sender.full_name }}""")
     expected_text = f"Hi Jack, \nhave you checked this open source project 'RedMail'? \n- Me\n"
 
-    sender = EmailSender(server=None, port=1234)
+    html_tables = tmpdir.mkdir("html_table_tmpl")
+    html_tables.join("modest.html").write("""{{ df.to_html() }}""")
+    text_tables = tmpdir.mkdir("text_table_tmpl")
+    text_tables.join("pandas.txt").write("""{{ df.to_html() }}""")
+
+    sender = EmailSender(host=None, port=1234)
     sender.set_template_paths(
         html=str(html_templates),
         text=str(text_templates),
+        html_table=str(html_tables),
+        text_table=str(text_tables),
     )
     msg = sender.get_message(
         sender="me@gmail.com",
-        receiver="you@gmail.com",
+        receivers=["you@gmail.com"],
         subject="Some news",
         html_template='example.html',
         text_template='example.txt',
@@ -34,9 +41,9 @@ def test_template(tmpdir):
     
     assert "multipart/alternative" == msg.get_content_type()
 
-    #text = remove_extra_lines(msg.get_payload()[0].get_payload()).replace("=20", "").replace('"3D', "")
-    text = remove_extra_lines(msg.get_payload()[0].get_payload()).replace("=20", "").replace('"3D', "")
-    html = remove_extra_lines(msg.get_payload()[1].get_payload()).replace("=20", "").replace('"3D', "")
+    #text = remove_extra_lines(msg.get_payload()[0].get_payload())
+    text = remove_email_extra(msg.get_payload()[0].get_payload())
+    html = remove_email_extra(msg.get_payload()[1].get_payload())
 
     assert expected_html == html
     assert expected_text == text

+ 5 - 1
redmail/tests/helpers/convert.py

@@ -3,4 +3,8 @@ import re
 
 def remove_extra_lines(s:str):
     # Alternatively: os.linesep.join([line for line in s.splitlines() if line])
-    return re.sub('\n+', '\n', s)
+    return re.sub('\n+', '\n', s)
+
+def remove_email_extra(s:str):
+    s = remove_extra_lines(s)
+    return s.replace("=20", "").replace('"3D', "").replace("=\n", "")

+ 1 - 1
redmail/tests/pytest.ini

@@ -1,5 +1,5 @@
 [pytest]
 log_cli = 1
-log_cli_level = DEBUG
+log_cli_level = INFO
 log_cli_format = %(asctime)s [%(levelname)8s] (%(name)30s) %(message)s (%(filename)s:%(lineno)s)
 norecursedirs = tests/helpers

+ 20 - 0
redmail/tests/test_models.py

@@ -0,0 +1,20 @@
+
+from redmail.models import EmailAddress
+
+import pytest
+
+@pytest.mark.parametrize("addr,expected",
+    [
+        pytest.param('info@company.com', {'first_name': None, 'last_name': None, 'full_name': 'Info', 'organization': 'Company'}, id="Not personal"),
+        pytest.param('first.last@company.com', {'first_name': "First", 'last_name': "Last", 'full_name': 'First Last', 'organization': 'Company'}, id="Personal"),
+        pytest.param('no-reply@en.company.com', {'first_name': None, 'last_name': None, 'full_name': 'No-reply', 'organization': 'Company'}, id="Multi-domain-part"),
+    ]
+)
+def test_address(addr, expected):
+    address = EmailAddress(addr)
+
+    assert expected['first_name'] == address.first_name
+    assert expected['last_name'] == address.last_name
+    assert expected['full_name'] == address.full_name
+    assert expected['organization'] == address.organization
+    assert str(address) == addr