Browse Source

Merge pull request #25 from Miksus/dev/logging

ENH: Logging handlers
Mikael Koli 4 years ago
parent
commit
50fd6f8f67

+ 3 - 0
ci/file.html

@@ -0,0 +1,3 @@
+<h1>
+    This is an attachment
+</h1>

+ 208 - 0
ci/test_send.py

@@ -0,0 +1,208 @@
+
+import os, time
+import base64, logging
+from pathlib import Path
+from redmail import EmailSender, EmailHandler, MultiEmailHandler
+
+from dotenv import load_dotenv
+load_dotenv()
+
+email = EmailSender(
+    host=os.environ['EMAIL_HOST'],
+    port=int(os.environ['EMAIL_PORT']),
+    user_name=os.environ['EMAIL_USERNAME'],
+    password=os.environ['EMAIL_PASSWORD']
+)
+
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+
+def send():
+    msg = email.send(
+        sender=os.environ['EMAIL_SENDER'],
+        receivers=os.environ['EMAIL_RECEIVERS'].split(","),
+        subject="An example",
+    )
+
+def send_text():
+    msg = email.send(
+        sender=os.environ['EMAIL_SENDER'],
+        receivers=os.environ['EMAIL_RECEIVERS'].split(","),
+        subject="An example",
+        text="Hi, this is an example email.",
+    )
+
+def send_html():
+    msg = email.send(
+        sender=os.environ['EMAIL_SENDER'],
+        receivers=os.environ['EMAIL_RECEIVERS'].split(","),
+        subject="An example",
+        html="<h1>Hi,</h1><p>this is an example email.</p>",
+    )
+
+def send_test_and_html():
+    msg = email.send(
+        sender=os.environ['EMAIL_SENDER'],
+        receivers=os.environ['EMAIL_RECEIVERS'].split(","),
+        subject="An example",
+        text="<h1>Hi,</h1><p>this is an example email.</p>",
+        html="<h1>Hi,</h1><p>this is an example email.</p>",
+    )
+
+
+def send_attachments():
+    msg = email.send(
+        sender=os.environ['EMAIL_SENDER'],
+        receivers=os.environ['EMAIL_RECEIVERS'].split(","),
+        subject="An attachment",
+        attachments={"a_file.html": (Path(__file__).parent / "file.html")}
+    )
+
+def send_attachments_with_text():
+    msg = email.send(
+        sender=os.environ['EMAIL_SENDER'],
+        receivers=os.environ['EMAIL_RECEIVERS'].split(","),
+        subject="An attachment with text body",
+        text="Hi, this contains an attachment.",
+        attachments={"a_file.html": (Path(__file__).parent / "file.html")}
+    )
+
+def send_attachments_with_html():
+    msg = email.send(
+        sender=os.environ['EMAIL_SENDER'],
+        receivers=os.environ['EMAIL_RECEIVERS'].split(","),
+        subject="An attachment with HTML body",
+        html="<h1>Hi, this contains an attachment.</h1>",
+        attachments={"a_file.html": (Path(__file__).parent / "file.html")}
+    )
+
+def send_attachments_with_text_and_html():
+    msg = email.send(
+        sender=os.environ['EMAIL_SENDER'],
+        receivers=os.environ['EMAIL_RECEIVERS'].split(","),
+        subject="An attachment with text and HTML body",
+        text="Hi, this contains an attachment",
+        html="<h1>Hi, this contains an attachment.</h1>",
+        attachments={"a_file.html": (Path(__file__).parent / "file.html")}
+    )
+
+
+def send_images():
+    img_data = '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAIBAQIBAQICAgICAgICAwUDAwMDAwYEBAMFBwYHBwcGBwcICQsJCAgKCAcHCg0KCgsMDAwMBwkODw0MDgsMDAz/2wBDAQICAgMDAwYDAwYMCAcIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wAARCAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD5rooor8DP9oD/2Q=='
+    img_bytes = base64.b64decode(img_data)
+
+    import matplotlib.pyplot as plt
+    fig = plt.figure()
+    plt.plot([1,2,3,2,3])
+
+    email.send(
+        receivers=[os.environ['EMAIL_RECEIVER']],
+        subject="Embedded images",
+        html='''
+            <p>Dict image (JPEG):</p>
+            <br>{{ dict_image }}
+            <p>Plot image:</p>
+            <br>{{ plot_image }}
+            <p>Path image:</p>
+            <br>{{ path_image }}
+        ''',
+        body_images={
+            'dict_image': {
+                "content": img_bytes,
+                'subtype': 'jpg',
+            },
+            "plot_image": fig,
+            "path_image": Path(__file__).parent / "example.png",
+            "path_image_str": str((Path(__file__).parent / "example.png").absolute()),
+        }
+    )
+
+def send_tables():
+    import pandas as pd
+    df_empty = pd.DataFrame()
+    df_simple = pd.DataFrame({"col 1": [1,2,3], "col 2": ["a", "b", "c"]})
+    df_multi_index = pd.DataFrame({"col 1": [1,2,3], "col 2": ["a", "b", "c"]})
+    email.send(
+        receivers=[os.environ['EMAIL_RECEIVER']],
+        subject="Embedded tables",
+        html='''
+            <p>Empty:</p>
+            <br>{{ empty }}
+            <p>Simple:</p>
+            <br>{{ simple }}
+            <p>Multi-index:</p>
+            <br>{{ multi_index }}
+            <p>Multi-index:</p>
+            <br>{{ multi_index_and_columns }}
+        ''',
+        body_tables={
+            'empty': pd.DataFrame(),
+        }
+    )
+
+
+def log_multi():
+    logger.handlers = []
+
+    hdlr = MultiEmailHandler(
+        capacity=4,
+        email=email,
+        subject="Log records: {min_level_name} - {max_level_name}",
+        receivers=os.environ["EMAIL_RECEIVERS"].split(","),
+        html="""
+            {% for record in records %}
+            <h2>A log record</h2>
+            <ul>
+                <li>Logging level: {{ record.levelname }}</li>
+                <li>Message: {{ record.msg }}</li>
+            </ul>
+            {% endfor %}
+        """,
+    )
+    logger.addHandler(hdlr)
+
+    logger.debug("A debug record")
+    logger.info("An info record")
+    logger.warning("A warning record")
+
+    try:
+        raise RuntimeError("Oops")
+    except:
+        logger.exception("An exception record")
+
+    logger.handlers = []
+
+def log_simple():
+    logger.handlers = []
+
+    hdlr = EmailHandler(
+        email=email,
+        subject="A log record: {record.levelname}",
+        receivers=os.environ["EMAIL_RECEIVERS"].split(","),
+        html="""
+            <h2>A log record</h2>
+            <ul>
+                <li>Logging level: {{ record.levelname }}</li>
+                <li>Message: {{ record.msg }}</li>
+            </ul>
+        """,
+    )
+    logger.addHandler(hdlr)
+
+    logger.info("An info record")
+    logger.handlers = []
+
+
+if __name__ == "__main__":
+    fn_bodies = [send, send_text, send_html, send_test_and_html]
+    fn_attachments = [send_attachments, send_attachments_with_text, send_attachments_with_html, send_attachments_with_text_and_html]
+    fn_log = [log_simple, log_multi]
+
+    funcs = {
+        "minimal": fn_bodies[0],
+        "full": fn_bodies + fn_attachments + fn_log,
+        "logging": fn_log,
+    }[os.environ.get("EMAIL_FUNCS", "full")]
+    for func in funcs:
+        time.sleep(1)
+        func()

+ 8 - 2
docs/conf.py

@@ -33,7 +33,8 @@ extensions = [
     'sphinx.ext.autodoc', 
     'sphinx.ext.autodoc', 
     'sphinx.ext.coverage', 
     'sphinx.ext.coverage', 
     'sphinx.ext.napoleon',
     'sphinx.ext.napoleon',
-    'sphinx_rtd_theme'
+    'sphinx_rtd_theme',
+    'sphinx.ext.extlinks',
 ]
 ]
 rst_prolog = """
 rst_prolog = """
 .. include:: <s5defs.txt>
 .. include:: <s5defs.txt>
@@ -81,4 +82,9 @@ html_static_path = ['_static']
 html_css_files = [
 html_css_files = [
     'css/types.css',
     'css/types.css',
     'css/colors.css',
     'css/colors.css',
-]
+]
+
+# Cross references
+extlinks = {
+    "stdlib": ("https://docs.python.org/3/library/%s", None)
+}

+ 13 - 0
docs/extensions/index.rst

@@ -0,0 +1,13 @@
+
+Extensions
+==========
+
+This section covers Red Mail's extensions. Extensions are 
+tools that helps to integrate the email sender to other 
+tasks such as logging.
+
+.. toctree::
+   :maxdepth: 1
+   :caption: Contents:
+
+   logging.rst

+ 218 - 0
docs/extensions/logging.rst

@@ -0,0 +1,218 @@
+
+
+Email Logging
+=============
+
+Red Mail also provides logging handlers which
+extends the :stdlib:`logging library's <logging.html>`
+:stdlib:`handlers <logging.handlers.html>` from the standard library. 
+The logging library also has :stdlib:`SMTPHandler <logging.handlers.html#smtphandler>`
+but its features are somewhat restricted. It does only 
+send a logging message formatted as plain text and it 
+sends only one log record per email. 
+
+Red Mail's email handlers, on the other hand, 
+are capable of formatting the emails in arbitrary ways
+and it also enables to send multiple log records 
+with one email. Red Mail is more feature complete and 
+provides more customizable logging experience.
+
+There are two log handlers provided by Red Mail:
+
+- :ref:`EmailHandler <ext-emailhandler>`: Sends one log record per email
+- :ref:`MultiEmailHandler <ext-multiemailhandler>`: Sends multiple log records with one email
+
+The mechanics are simple and very similar between these two handlers.
+
+.. _ext-emailhandler:
+
+EmailHandler
+------------
+
+To send one log record per email, use :class:`.EmailHandler`:
+
+.. code-block:: python
+
+    import logging
+    from redmail import EmailHandler
+
+    hdlr = EmailHandler(
+        host="localhost",
+        port=0,
+        subject="A log record",
+        sender="no-reply@example.com",
+        receivers=["me@example.com"],
+    )
+    logger = logging.getLogger(__name__)
+    logger.addHandler(hdlr)
+
+    # To use:
+    logger.warning("A warning happened")
+
+.. note::
+
+    You may pass the :class:`.EmailSender` 
+    directly as an argument ``email``, for 
+    example:
+
+    .. code-block:: python
+
+        from redmail import EmailSender
+        hdlr = EmailHandler(
+            email=EmailSender(host="localhost", port=0)
+            subject="A log record",
+            receivers=["me@example.com"],
+        )
+
+    Note that a copy of the :class:`.EmailSender` is created
+    in order to avoid affecting the usage of the instance 
+    elsewhere. Additional arguments (such as subject, sender,
+    receivers, text, html, etc.) are set as attributes to 
+    this copied instance.
+
+You may also template the subject and the bodies:
+
+.. code-block:: python
+
+    import logging
+    from redmail import EmailHandler
+
+    hdlr = EmailHandler(
+        host="localhost",
+        port=0,
+        subject="Log Record: {record.levelname}",
+        receivers=["me@example.com"],
+        text="Logging level: {{ record.levelname }}\nMessage: {{ msg }}",
+        html="<ul><li>Logging level: {{ record.levelname }}</li><li>Message: {{ msg }}</li></ul>",
+    )
+    logger = logging.getLogger(__name__)
+    logger.addHandler(hdlr)
+
+As you may have noted, the subject can contain string formatting.
+The following arguments are passed to the string format:
+
+============== ========================= ==================================
+Argument       Type                      Description
+============== ========================= ==================================
+record         logging.LogRecord         Log records to send
+handler        EmailHandler              EmailHandler itself
+============== ========================= ==================================
+
+In addition, the text and HTML bodies are processed using Jinja and the 
+following parameters are passed:
+
+======== ================= ===================
+Argument Type              Description
+======== ================= ===================
+record   logging.LogRecord Log record
+msg      str               Formatted message
+handler  EmailHandler      EmailHandler itself
+======== ================= ===================
+
+
+.. _ext-multiemailhandler:
+
+MultiEmailHandler
+-----------------
+
+To send multiple log records with one email, use :class:`.MultiEmailHandler`:
+
+.. code-block:: python
+
+    import logging
+    from redmail import MultiEmailHandler
+
+    hdlr = MultiEmailHandler(
+        capacity=2, # Sends email after every second record
+        host="localhost",
+        port=0,
+        subject="log records",
+        sender="no-reply@example.com",
+        receivers=["me@example.com"],
+    )
+    logger = logging.getLogger(__name__)
+    logger.addHandler(hdlr)
+
+    # To use:
+    logger.warning("A warning happened")
+    logger.warning("Another warning happened")
+    # (Now an email should have been sent)
+
+    # You may also manually flush
+    logger.warning("A warning happened")
+    hdlr.flush()
+
+.. note::
+
+    You may pass the :class:`.EmailSender` 
+    directly as an argument ``email``, for 
+    example:
+
+    .. code-block:: python
+
+        from redmail import EmailSender
+        hdlr = MultiEmailHandler(
+            email=EmailSender(host="localhost", port=0)
+            subject="Log records",
+            receivers=["me@example.com"],
+        )
+
+    Note that a copy of the :class:`.EmailSender` is created
+    in order to avoid affecting the usage of the instance 
+    elsewhere. Additional arguments (such as subject, sender,
+    receivers, text, html, etc.) are set as attributes to 
+    this copied instance.
+
+You may also template the subject and the bodies:
+
+.. code-block:: python
+
+    import logging
+    from redmail import EmailHandler
+
+    hdlr = MultiEmailHandler(
+        host="localhost",
+        port=0,
+        subject="Log Records: {min_level_name} - {max_level_name}",
+        receivers=["me@example.com"],
+        text="""Logging level: 
+            {% for record in records %}
+            Level name: {{ record.levelname }}
+            Message: {{ record.msg }}
+            {% endfor %}
+        """,
+        html="""
+            <ul>
+            {% for record in records %}
+                <li>Logging level: {{ record.levelname }}</li>
+                <li>Message: {{ record.msg }}</li>
+            {% endfor %}
+            </ul>
+        """,
+    )
+    logger = logging.getLogger(__name__)
+    logger.addHandler(hdlr)
+
+As you may have noted, the subject can contain string formatting.
+The following arguments are passed to the string format:
+
+============== ========================= ==================================
+Argument       Type                      Description
+============== ========================= ==================================
+records        list of logging.LogRecord Log records to send
+min_level_name str                       Name of the lowest log level name
+max_level_name str                       Name of the highest log level name
+handler        MultiEmailHandler         MultiEmailHandler itself
+============== ========================= ==================================
+
+In addition, the text and HTML bodies are processed using Jinja and the 
+following parameters are passed:
+
+======== ========================= ==========================
+Argument Type                      Description
+======== ========================= ==========================
+records  list of logging.LogRecord List of log records
+msgs     list of str               List of formatted messages
+handler  MultiEmailHandler         MultiEmailHandler itself
+======== ========================= ==========================
+

+ 1 - 0
docs/index.rst

@@ -119,6 +119,7 @@ Some more practical examples:
    :caption: Contents:
    :caption: Contents:
 
 
    tutorials/index
    tutorials/index
+   extensions/index
    references
    references
    versions
    versions
 
 

+ 8 - 1
docs/references.rst

@@ -13,4 +13,11 @@ Format Classes
 
 
 .. autoclass:: redmail.models.EmailAddress
 .. autoclass:: redmail.models.EmailAddress
 
 
-.. autoclass:: redmail.models.Error
+.. autoclass:: redmail.models.Error
+
+Logging Classes
+---------------
+
+.. autoclass:: redmail.EmailHandler
+
+.. autoclass:: redmail.MultiEmailHandler

+ 1 - 1
docs/tutorials/client.rst

@@ -9,7 +9,7 @@ of the connection with your SMTP server. In this discussion we discuss ways to c
 
 
 By default Red Mail uses `STARTTLS <https://en.wikipedia.org/wiki/Opportunistic_TLS>`_ or opportunistic
 By default Red Mail uses `STARTTLS <https://en.wikipedia.org/wiki/Opportunistic_TLS>`_ or opportunistic
 TLS in connecting to the SMTP server. You may also change this if needed by changing the 
 TLS in connecting to the SMTP server. You may also change this if needed by changing the 
-``cls_smtp`` to other SMTP client classes from `smtplib <https://docs.python.org/3/library/smtplib.html>`_
+``cls_smtp`` to other SMTP client classes from ::stdlib:`smtplib <smtplib.html>`
 in standard library.
 in standard library.
 
 
 .. note::
 .. note::

+ 1 - 0
redmail/__init__.py

@@ -1,3 +1,4 @@
 from .email import EmailSender, send_email, gmail, outlook
 from .email import EmailSender, send_email, gmail, outlook
+from .log import EmailHandler, MultiEmailHandler
 from . import _version
 from . import _version
 __version__ = _version.get_versions()['version']
 __version__ = _version.get_versions()['version']

+ 6 - 1
redmail/email/sender.py

@@ -1,4 +1,5 @@
 
 
+from copy import copy
 from email.message import EmailMessage
 from email.message import EmailMessage
 from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
 from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
 
 
@@ -38,7 +39,7 @@ class EmailSender:
         User password to authenticate on the server.
         User password to authenticate on the server.
     cls_smtp : smtplib.SMTP
     cls_smtp : smtplib.SMTP
         SMTP class to use for connection. See options 
         SMTP class to use for connection. See options 
-        from `Python smtplib docs <https://docs.python.org/3/library/smtplib.html>`_.
+        from :stdlib:`Python smtplib docs <smtplib.html>`.
     use_starttls : bool
     use_starttls : bool
         Whether to use `STARTTLS <https://en.wikipedia.org/wiki/Opportunistic_TLS>`_ 
         Whether to use `STARTTLS <https://en.wikipedia.org/wiki/Opportunistic_TLS>`_ 
         when connecting to the SMTP server.
         when connecting to the SMTP server.
@@ -467,3 +468,7 @@ class EmailSender:
             self.templates_html_table = jinja2.Environment(loader=jinja2.FileSystemLoader(html_table))
             self.templates_html_table = jinja2.Environment(loader=jinja2.FileSystemLoader(html_table))
         if text_table is not None:
         if text_table is not None:
             self.templates_text_table = jinja2.Environment(loader=jinja2.FileSystemLoader(text_table))
             self.templates_text_table = jinja2.Environment(loader=jinja2.FileSystemLoader(text_table))
+
+    def copy(self) -> 'EmailSender':
+        "Shallow copy EmailSender"
+        return copy(self)

+ 264 - 0
redmail/log.py

@@ -0,0 +1,264 @@
+
+import logging
+from logging import Handler, LogRecord
+from logging.handlers import SMTPHandler, BufferingHandler
+from textwrap import dedent
+from typing import List, Optional
+
+from redmail.email.sender import EmailSender
+
+class _EmailHandlerMixin:
+
+    def __init__(self, email, kwargs):
+        if email is not None:
+            # Using copy to prevent modifying the sender
+            # if it is used somewhere else
+            email = email.copy()
+            self.email = email
+            self._set_email_kwargs(kwargs)
+        else:
+            self.set_email(**kwargs)
+        self._validate_email()
+
+    def set_email(self, 
+                   host, port,
+                   user_name=None, password=None,
+                   **kwargs):
+        "Create a simple default sender"
+        self.email = EmailSender(
+            host=host, port=port,
+            user_name=user_name, password=password
+        )
+        
+        self._set_email_kwargs(kwargs)
+
+    def get_subject(self, record):
+        "Format subject of the email sender"
+        return self.email.subject.format(
+            record=record, handler=self
+        )
+
+    def _set_email_kwargs(self, kwargs:dict):
+        for attr, value in kwargs.items():
+            if not hasattr(self.email, attr):
+                raise AttributeError(f"EmailSender has no attribute {attr}")
+            setattr(self.email, attr, value)
+
+        # Set default message body if nothing specified
+        has_no_body = (
+            self.email.text is None 
+            and self.email.text_template is None 
+            and self.email.html is None
+            and self.email.html_template is None
+        )
+        if has_no_body:
+            self.email.text = self.default_text
+
+    def _validate_email(self):
+        "Validate the email has all required attributes for logging"
+        req_attrs = ('host', 'port', 'subject', 'receivers')
+        missing = []
+        for attr in req_attrs:
+            if getattr(self.email, attr) is None:
+                missing.append(attr)
+        if missing:
+            cls_name = type(self).__name__
+            raise TypeError(f'{cls_name} email sender missing attributes: {missing}')
+
+class EmailHandler(_EmailHandlerMixin, Handler):
+    """Logging handler for sending a log record as an email
+
+    Parameters
+    ----------
+    level : int
+        Log level of the handler
+    email : EmailSender
+        Sender instance to be used for sending
+        the log records.
+    kwargs : dict
+        Keyword arguments for creating the 
+        sender if ``email`` was not passed.
+
+    Examples
+    --------
+
+        Minimal example:
+
+        .. code-block:: python
+
+            handler = EmailHandler(
+                host="smtp.myhost.com", port=0,
+                sender="no-reply@example.com",
+                receivers=["me@example.com"],
+            )
+
+        Customized example:
+
+        .. code-block:: python
+
+            from redmail import EmailSender
+            email = EmailSender(
+                host="smtp.myhost.com",
+                port=0
+            )
+            email.email = "no-reply@example.com"
+            email.receivers = ["me@example.com"]
+            email.html = '''
+                <h1>Record: {{ record.levelname }}</h1>
+                <pre>{{ record.msg }}</pre>
+                <h2>Info</h2>
+                <ul>
+                    <li>Path: {{ record.pathname }}</li>
+                    <li>Function: {{ record.funcName }}</li>
+                    <li>Line number: {{ record.lineno }}</li>
+                </ul>
+            '''
+            handler = EmailHandler(email=email)
+
+            import logging
+            logger = logging.getLogger()
+            logger.addHandler(handler)
+    """
+    email: EmailSender
+
+    default_text = "{{ msg }}"
+
+    def __init__(self, level:int=logging.NOTSET, email:EmailSender=None, **kwargs):
+        _EmailHandlerMixin.__init__(self, email=email, kwargs=kwargs)
+        Handler.__init__(self, level)
+
+
+    def emit(self, record:logging.LogRecord):
+        "Emit a record (send email)"
+
+        self.email.send(
+            subject=self.get_subject(record),
+            body_params={
+                "record": record,
+                "msg": self.format(record),
+                "handler": self,
+            }
+        )
+
+
+class MultiEmailHandler(_EmailHandlerMixin, BufferingHandler):
+    """Logging handler for sending multiple log records as an email
+
+    Parameters
+    ----------
+    capacity : int
+        Number of 
+    email : EmailSender
+        Sender instance to be used for sending
+        the log records.
+    kwargs : dict
+        Keyword arguments for creating the 
+        sender if ``email`` was not passed.
+
+    Examples
+    --------
+
+        Minimal example:
+
+        .. code-block:: python
+
+            handler = MultiEmailHandler(
+                host="smtp.myhost.com", port=0,
+                sender="no-reply@example.com",
+                receivers=["me@example.com"],
+            )
+
+        Customized example:
+
+        .. code-block:: python
+
+            from redmail import EmailSender
+            email = EmailSender(
+                host="smtp.myhost.com",
+                port=0
+            )
+            email.sender = "no-reply@example.com"
+            email.receivers = ["me@example.com"]
+            email.html = '''
+                <h1>Record: {{ record.levelname }}</h1>
+                <pre>{{ record.msg }}</pre>
+                <h2>Info</h2>
+                <ul>
+                    <li>Path: {{ record.pathname }}</li>
+                    <li>Function: {{ record.funcName }}</li>
+                    <li>Line number: {{ record.lineno }}</li>
+                </ul>
+            '''
+            handler = EmailHandler(sender=email)
+
+            import logging
+            logger = logging.getLogger()
+            logger.addHandler(handler)
+    """
+
+    default_text = dedent("""
+    Log Recods:
+    {% for record in records -%}
+    {{ handler.format(record) }}
+    {% endfor %}""")[1:]
+
+    def __init__(self, capacity:Optional[int]=None, email:EmailSender=None, **kwargs):
+        _EmailHandlerMixin.__init__(self, email=email, kwargs=kwargs)
+        BufferingHandler.__init__(self, capacity)
+
+    def flush(self):
+        "Flush the records (send an email)"
+        self.acquire()
+        try:
+            msgs = []
+            for rec in self.buffer:
+                # This creates msg, exc_text etc. to the LogRecords
+                msgs.append(self.format(rec))
+                # For some reason logging does not create this attr unless having asctime in the format string
+                if self.formatter is None:
+                    rec.asctime = logging.Formatter().formatTime(rec)
+
+            self.email.send(
+                subject=self.get_subject(self.buffer),
+                body_params={
+                    "records": self.buffer,
+                    "msgs": msgs,
+                    "handler": self
+                }
+            )
+            self.buffer = []
+        finally:
+            self.release()
+
+    def shouldFlush(self, record):
+        """Should the handler flush its buffer?
+
+        Returns true if the buffer is up to capacity. This method can be overridden to implement custom flushing strategies.
+        """
+        if self.capacity is None:
+            # Only manual flushing
+            return False
+        else:
+            return super().shouldFlush(record)
+
+    def get_subject(self, records:List[LogRecord]):
+        "Get subject of the email"
+        if records:
+            min_level = min([record.levelno for record in records])
+            max_level = max([record.levelno for record in records])
+            fmt_kwds = {
+                "min_level_name": logging.getLevelName(min_level),
+                "max_level_name": logging.getLevelName(max_level),
+            }
+        else:
+            # No log records, getting something
+            fmt_kwds = {
+                "min_level_name": logging.getLevelName(logging.NOTSET),
+                "max_level_name": logging.getLevelName(logging.NOTSET),
+            }
+
+        return self.email.subject.format(
+            **fmt_kwds,
+            handler=self,
+            records=records
+        )

+ 0 - 0
redmail/test/log/__init__.py


+ 9 - 0
redmail/test/log/conftest.py

@@ -0,0 +1,9 @@
+
+import logging
+import pytest
+
+@pytest.fixture
+def logger():
+    logger = logging.getLogger("_test")
+    logger.handlers = []
+    return logger

+ 90 - 0
redmail/test/log/test_construct.py

@@ -0,0 +1,90 @@
+
+import pytest
+from redmail import EmailHandler, MultiEmailHandler
+from redmail.email.sender import EmailSender
+
+@pytest.mark.parametrize("cls", [EmailHandler, MultiEmailHandler])
+def test_construct_kwargs_minimal(cls):
+    hdlr = cls(host="localhost", port=0, receivers=["me@example.com"], subject="Some logging")
+    assert hdlr.email.host == 'localhost'
+    assert hdlr.email.port == 0
+    assert hdlr.email.receivers == ["me@example.com"]
+    assert hdlr.email.subject == "Some logging"
+
+
+@pytest.mark.parametrize("cls", [EmailHandler, MultiEmailHandler])
+def test_construct_kwargs(cls):
+    hdlr = cls(host="localhost", port=0, receivers=["me@example.com"], subject="Some logging", text="Error: {{ msg }}", html="<h1>Error: {{ msg }}</h1>")
+    assert hdlr.email.host == 'localhost'
+    assert hdlr.email.port == 0
+    assert hdlr.email.receivers == ["me@example.com"]
+    assert hdlr.email.subject == "Some logging"
+
+    assert hdlr.email.text == "Error: {{ msg }}"
+    assert hdlr.email.html == "<h1>Error: {{ msg }}</h1>"
+
+@pytest.mark.parametrize("cls", [EmailHandler, MultiEmailHandler])
+def test_kwargs_error_missing(cls):
+    # Missing subject
+    with pytest.raises(TypeError):
+        hdlr = cls(host="localhost", port=0, receivers=["me@example.com"])
+
+    # Missing receivers
+    with pytest.raises(TypeError):
+        hdlr = cls(host="localhost", port=0, subject="Some logging")
+
+    # Missing host
+    with pytest.raises(TypeError):
+        hdlr = cls(port=0, receivers=["me@example.com"], subject="Some logging")
+
+    # Missing port
+    with pytest.raises(TypeError):
+        hdlr = cls(host="localhost", receivers=["me@example.com"], subject="Some logging")
+
+@pytest.mark.parametrize("cls", [EmailHandler, MultiEmailHandler])
+def test_kwargs_error_invalid_attr(cls):
+    with pytest.raises(AttributeError):
+        hdlr = cls(host="localhost", port=0, receivers=["me@example.com"], subject="Some logging", not_existing="something")
+
+# Testing with passing EmailSender
+@pytest.mark.parametrize("cls", [EmailHandler, MultiEmailHandler])
+def test_sender_with_kwargs(cls):
+    sender = EmailSender(host="localhost", port=0)
+    hdlr = cls(email=sender, subject="A log", receivers=["me@example.com"])
+    assert hdlr.email is not sender
+    assert hdlr.email.subject == "A log"
+    assert hdlr.email.receivers == ["me@example.com"]
+
+@pytest.mark.parametrize("cls", [EmailHandler, MultiEmailHandler])
+def test_sender(cls):
+    sender = EmailSender(host="localhost", port=0)
+    sender.subject = "A log"
+    sender.receivers = ["me@example.com"]
+
+    hdlr = cls(email=sender)
+    assert hdlr.email is not sender
+    assert hdlr.email.subject == "A log"
+    assert hdlr.email.receivers == ["me@example.com"]
+
+@pytest.mark.parametrize("cls", [EmailHandler, MultiEmailHandler])
+def test_sender_with_kwargs_error_invalid_attr(cls):
+    sender = EmailSender(host="localhost", port=0)
+    with pytest.raises(AttributeError):
+        hdlr = cls(email=sender, not_existing="something")
+
+@pytest.mark.parametrize("cls", [EmailHandler, MultiEmailHandler])
+def test_sender_error_missing(cls):
+    # Missing subject
+    sender = EmailSender(host="localhost", port=0)
+    with pytest.raises(TypeError):
+        hdlr = cls(email=sender, receivers=["me@example.com"])
+
+    # Missing receivers
+    with pytest.raises(TypeError):
+        hdlr = cls(email=sender, subject="Some logging")
+
+@pytest.mark.parametrize("cls", [EmailHandler, MultiEmailHandler])
+def test_sender_invalid_attr(cls):
+    sender = EmailSender(host="localhost", port=0)
+    with pytest.raises(AttributeError):
+        hdlr = cls(email=sender, not_existing="something")

+ 158 - 0
redmail/test/log/test_handler.py

@@ -0,0 +1,158 @@
+
+import pytest
+from redmail import EmailSender
+from redmail import EmailHandler
+import logging
+
+def _create_dummy_send(messages:list):
+    def _dummy_send(msg):
+        messages.append(msg)
+    return _dummy_send
+
+def test_default_body():
+    hdlr = EmailHandler(host="localhost", port=0, receivers=["me@example.com"], subject="Some logging")
+    # By default, this should be body if text/html/html_template/text_template not specified
+    assert hdlr.email.text == "{{ msg }}"
+
+
+@pytest.mark.parametrize("kwargs,exp_headers,exp_payload",
+    [
+        pytest.param(
+            {
+                "email": EmailSender(host="localhost", port=0),
+                "subject": "A log record",
+                "sender": "me@example.com",
+                "receivers": ["he@example.com", "she@example.com"],
+            }, 
+            {
+                "from": "me@example.com",
+                "to": "he@example.com, she@example.com",
+                "subject": "A log record",
+                'Content-Transfer-Encoding': '7bit',
+                'Content-Type': 'text/plain; charset="utf-8"',
+                'MIME-Version': '1.0',
+            },
+            'a message\n',
+            id="Minimal",
+        ),
+        pytest.param(
+            {
+                "email": EmailSender(host="localhost", port=0),
+                "subject": "A log record",
+                "sender": "me@example.com",
+                "receivers": ["he@example.com", "she@example.com"],
+                "text": "Log Record: \n{{ msg }}",
+                "fmt": '%(name)s: %(levelname)s: %(message)s'
+            }, 
+            {
+                "from": "me@example.com",
+                "to": "he@example.com, she@example.com",
+                "subject": "A log record",
+                'Content-Transfer-Encoding': '7bit',
+                'Content-Type': 'text/plain; charset="utf-8"',
+                'MIME-Version': '1.0',
+            },
+            'Log Record: \n_test: INFO: a message\n',
+            id="Custom message (msg)",
+        ),
+        pytest.param(
+            {
+                "email": EmailSender(host="localhost", port=0),
+                "subject": "A log record",
+                "sender": "me@example.com",
+                "receivers": ["he@example.com", "she@example.com"],
+                "text": "Log Record: \n{{ record.message }}",
+                "fmt": '%(name)s: %(levelname)s: %(message)s'
+            }, 
+            {
+                "from": "me@example.com",
+                "to": "he@example.com, she@example.com",
+                "subject": "A log record",
+                'Content-Transfer-Encoding': '7bit',
+                'Content-Type': 'text/plain; charset="utf-8"',
+                'MIME-Version': '1.0',
+            },
+            'Log Record: \na message\n',
+            id="Custom message (record)",
+        ),
+        pytest.param(
+            {
+                "email": EmailSender(host="localhost", port=0),
+                "subject": "Log: {record.name} - {record.levelname}",
+                "sender": "me@example.com",
+                "receivers": ["he@example.com", "she@example.com"],
+            }, 
+            {
+                "from": "me@example.com",
+                "to": "he@example.com, she@example.com",
+                "subject": "Log: _test - INFO",
+                'Content-Transfer-Encoding': '7bit',
+                'Content-Type': 'text/plain; charset="utf-8"',
+                'MIME-Version': '1.0',
+            },
+            'a message\n',
+            id="Sender with fomatted subject",
+        ),
+        pytest.param(
+            {
+                "email": EmailSender(host="localhost", port=0),
+                "subject": "A log record",
+                "sender": "me@example.com",
+                "receivers": ["he@example.com", "she@example.com"],
+                "fmt": '%(name)s: %(levelname)s: %(message)s',
+                "html": "<h1>{{ record.levelname }}</h1><p>{{ msg }}</p>"
+            }, 
+            {
+                "from": "me@example.com",
+                "to": "he@example.com, she@example.com",
+                "subject": "A log record",
+                'Content-Type': 'multipart/alternative',
+            },
+            ["<h1>INFO</h1><p>_test: INFO: a message</p>\n"],
+            id="Custom message (HTML, msg)",
+        ),
+        pytest.param(
+            {
+                "email": EmailSender(host="localhost", port=0),
+                "subject": "A log record",
+                "sender": "me@example.com",
+                "receivers": ["he@example.com", "she@example.com"],
+                "fmt": '%(name)s: %(levelname)s: %(message)s',
+                "html": "<h1>{{ record.levelname }}</h1><p>{{ record.message }}</p>"
+            }, 
+            {
+                "from": "me@example.com",
+                "to": "he@example.com, she@example.com",
+                "subject": "A log record",
+                'Content-Type': 'multipart/alternative',
+            },
+            ["<h1>INFO</h1><p>a message</p>\n"],
+            id="Custom message (HTML, record)",
+        ),
+    ]
+)
+def test_emit(logger, kwargs, exp_headers, exp_payload):
+    msgs = []
+    fmt = kwargs.pop("fmt", None)
+    hdlr = EmailHandler(**kwargs)
+    hdlr.formatter = logging.Formatter(fmt)
+    hdlr.email.send_message = _create_dummy_send(msgs)
+    
+    logger.addHandler(hdlr)
+    logger.setLevel(logging.INFO)
+
+    logger.info("a message")
+    
+    assert len(msgs) == 1
+    msg = msgs[0]
+    headers = dict(msg.items())
+    payload = msg.get_payload()
+
+    assert headers == exp_headers
+
+    if isinstance(payload, str):
+        assert payload == exp_payload
+    else:
+        # HTML (and text) of payloads
+        payloads = [pl.get_payload() for pl in payload]
+        assert payloads == exp_payload

+ 227 - 0
redmail/test/log/test_handler_multi.py

@@ -0,0 +1,227 @@
+
+import pytest
+from redmail import EmailSender
+from redmail import MultiEmailHandler
+import logging
+
+def _create_dummy_send(messages:list):
+    def _dummy_send(msg):
+        messages.append(msg)
+    return _dummy_send
+
+def test_default_body():
+    hdlr = MultiEmailHandler(host="localhost", port=0, receivers=["me@example.com"], subject="Some logging")
+    # By default, this should be body if text/html/html_template/text_template not specified
+    assert hdlr.email.text == MultiEmailHandler.default_text
+
+
+@pytest.mark.parametrize("kwargs,exp_headers,exp_payload",
+    [
+        pytest.param(
+            {
+                "email": EmailSender(host="localhost", port=0),
+                "subject": "A log record",
+                "sender": "me@example.com",
+                "receivers": ["he@example.com", "she@example.com"],
+            }, 
+            {
+                "from": "me@example.com",
+                "to": "he@example.com, she@example.com",
+                "subject": "A log record",
+                'Content-Transfer-Encoding': '7bit',
+                'Content-Type': 'text/plain; charset="utf-8"',
+                'MIME-Version': '1.0',
+            },
+            'Log Recods:\na message\n',
+            id="Minimal",
+        ),
+        pytest.param(
+            {
+                "email": EmailSender(host="localhost", port=0),
+                "subject": "A log record",
+                "sender": "me@example.com",
+                "receivers": ["he@example.com", "she@example.com"],
+                "text": "The records: \n{% for msg in msgs %}Log: {{ msg }}{% endfor %}",
+                "fmt": '%(name)s - %(levelname)s - %(message)s'
+            }, 
+            {
+                "from": "me@example.com",
+                "to": "he@example.com, she@example.com",
+                "subject": "A log record",
+                'Content-Transfer-Encoding': '7bit',
+                'Content-Type': 'text/plain; charset="utf-8"',
+                'MIME-Version': '1.0',
+            },
+            'The records: \nLog: _test - INFO - a message\n',
+            id="Custom message (msgs)",
+        ),
+        pytest.param(
+            {
+                "email": EmailSender(host="localhost", port=0),
+                "subject": "A log record",
+                "sender": "me@example.com",
+                "receivers": ["he@example.com", "she@example.com"],
+                "text": "The records: \n{% for rec in records %}Log: {{ rec.levelname }} - {{ rec.message }}{% endfor %}",
+                "fmt": '%(name)s - %(levelname)s - %(message)s'
+            }, 
+            {
+                "from": "me@example.com",
+                "to": "he@example.com, she@example.com",
+                "subject": "A log record",
+                'Content-Transfer-Encoding': '7bit',
+                'Content-Type': 'text/plain; charset="utf-8"',
+                'MIME-Version': '1.0',
+            },
+            'The records: \nLog: INFO - a message\n',
+            id="Custom message (records)",
+        ),
+        pytest.param(
+            {
+                "email": EmailSender(host="localhost", port=0),
+                "subject": "Logs: {min_level_name} - {max_level_name}",
+                "sender": "me@example.com",
+                "receivers": ["he@example.com", "she@example.com"],
+            }, 
+            {
+                "from": "me@example.com",
+                "to": "he@example.com, she@example.com",
+                "subject": "Logs: INFO - INFO",
+                'Content-Transfer-Encoding': '7bit',
+                'Content-Type': 'text/plain; charset="utf-8"',
+                'MIME-Version': '1.0',
+            },
+            'Log Recods:\na message\n',
+            id="Sender with fomatted subject",
+        ),
+        pytest.param(
+            {
+                "email": EmailSender(host="localhost", port=0),
+                "subject": "A log record",
+                "sender": "me@example.com",
+                "receivers": ["he@example.com", "she@example.com"],
+                "fmt": '%(name)s - %(levelname)s - %(message)s',
+                "html": "<h1>The records:</h1><p>{% for msg in msgs %}Log: {{ msg }}{% endfor %}</p>"
+            }, 
+            {
+                "from": "me@example.com",
+                "to": "he@example.com, she@example.com",
+                "subject": "A log record",
+                'Content-Type': 'multipart/alternative',
+            },
+            ["<h1>The records:</h1><p>Log: _test - INFO - a message</p>\n"],
+            id="Custom message (HTML, msgs)",
+        ),
+        pytest.param(
+            {
+                "email": EmailSender(host="localhost", port=0),
+                "subject": "A log record",
+                "sender": "me@example.com",
+                "receivers": ["he@example.com", "she@example.com"],
+                "fmt": '%(name)s: %(levelname)s: %(message)s',
+                "html": "<h1>The records:</h1><p>{% for rec in records %}Log: {{ rec.levelname }} - {{ rec.message }}{% endfor %}</p>"
+            }, 
+            {
+                "from": "me@example.com",
+                "to": "he@example.com, she@example.com",
+                "subject": "A log record",
+                'Content-Type': 'multipart/alternative',
+            },
+            ["<h1>The records:</h1><p>Log: INFO - a message</p>\n"],
+            id="Custom message (HTML, records)",
+        ),
+    ]
+)
+def test_emit(logger, kwargs, exp_headers, exp_payload):
+    msgs = []
+    fmt = kwargs.pop("fmt", None)
+    hdlr = MultiEmailHandler(**kwargs)
+    hdlr.formatter = logging.Formatter(fmt)
+    hdlr.email.send_message = _create_dummy_send(msgs)
+    
+    logger.addHandler(hdlr)
+    logger.setLevel(logging.INFO)
+
+    logger.info("a message")
+    
+    hdlr.flush()
+
+    assert len(msgs) == 1
+    msg = msgs[0]
+    headers = dict(msg.items())
+    payload = msg.get_payload()
+
+    assert headers == exp_headers
+
+    if isinstance(payload, str):
+        assert payload == exp_payload
+    else:
+        # HTML (and text) of payloads
+        payloads = [pl.get_payload() for pl in payload]
+        assert payloads == exp_payload
+
+
+def test_flush_multiple(logger):
+    msgs = []
+    hdlr = MultiEmailHandler(
+        email=EmailSender(host="localhost", port=0),
+        subject="Logs: {min_level_name} - {max_level_name}", 
+        receivers=["he@example.com", "she@example.com"],
+        text="Records: \n{% for rec in records %}{{ rec.levelname }} - {{ rec.message }}\n{% endfor %}"
+    )
+    hdlr.email.send_message = _create_dummy_send(msgs)
+    
+    logger.addHandler(hdlr)
+    logger.setLevel(logging.DEBUG)
+
+    logger.info("an info")
+    logger.debug("a debug")
+    
+    hdlr.flush()
+
+    assert len(msgs) == 1
+    msg = msgs[0]
+    headers = dict(msg.items())
+    payload = msg.get_payload()
+
+    assert headers == {
+        "from": "None",
+        "to": "he@example.com, she@example.com",
+        "subject": "Logs: DEBUG - INFO",
+        'Content-Transfer-Encoding': '7bit',
+        'Content-Type': 'text/plain; charset="utf-8"',
+        'MIME-Version': '1.0',
+    }
+
+    assert payload == "Records: \nINFO - an info\nDEBUG - a debug\n"
+
+def test_flush_none():
+    msgs = []
+    hdlr = MultiEmailHandler(
+        email=EmailSender(host="localhost", port=0),
+        subject="Logs: {min_level_name} - {max_level_name}", 
+        receivers=["he@example.com", "she@example.com"],
+        text="Records: \n{% for rec in records %}{{ rec.levelname }} - {{ rec.message }}\n{% endfor %}"
+    )
+    hdlr.email.send_message = _create_dummy_send(msgs)
+    
+    logger = logging.getLogger("_test")
+    logger.addHandler(hdlr)
+    logger.setLevel(logging.DEBUG)
+    
+    hdlr.flush()
+
+    assert len(msgs) == 1
+    msg = msgs[0]
+    headers = dict(msg.items())
+    payload = msg.get_payload()
+
+    assert headers == {
+        "from": "None",
+        "to": "he@example.com, she@example.com",
+        "subject": "Logs: NOTSET - NOTSET",
+        'Content-Transfer-Encoding': '7bit',
+        'Content-Type': 'text/plain; charset="utf-8"',
+        'MIME-Version': '1.0',
+    }
+
+    assert payload == "Records: \n"

+ 9 - 1
tox.ini

@@ -61,4 +61,12 @@ deps =
     twine
     twine
 # install_command = pip install --upgrade build
 # install_command = pip install --upgrade build
 commands = python setup.py bdist_wheel sdist
 commands = python setup.py bdist_wheel sdist
-           twine upload -r testpypi dist/*
+           twine upload -r testpypi dist/*
+
+[testenv:test-send]
+description = Send actual emails in order to visually test content embedding and actual sending
+deps = 
+    python-dotenv
+    -rrequirements.txt
+# install_command = pip install --upgrade build
+commands = python ci/test_send.py