소스 검색

Merge pull request #25 from Miksus/dev/logging

ENH: Logging handlers
Mikael Koli 4 년 전
부모
커밋
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.coverage', 
     'sphinx.ext.napoleon',
-    'sphinx_rtd_theme'
+    'sphinx_rtd_theme',
+    'sphinx.ext.extlinks',
 ]
 rst_prolog = """
 .. include:: <s5defs.txt>
@@ -81,4 +82,9 @@ html_static_path = ['_static']
 html_css_files = [
     'css/types.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:
 
    tutorials/index
+   extensions/index
    references
    versions
 

+ 8 - 1
docs/references.rst

@@ -13,4 +13,11 @@ Format Classes
 
 .. 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
 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.
 
 .. note::

+ 1 - 0
redmail/__init__.py

@@ -1,3 +1,4 @@
 from .email import EmailSender, send_email, gmail, outlook
+from .log import EmailHandler, MultiEmailHandler
 from . import _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 typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
 
@@ -38,7 +39,7 @@ class EmailSender:
         User password to authenticate on the server.
     cls_smtp : smtplib.SMTP
         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
         Whether to use `STARTTLS <https://en.wikipedia.org/wiki/Opportunistic_TLS>`_ 
         when connecting to the SMTP server.
@@ -467,3 +468,7 @@ class EmailSender:
             self.templates_html_table = jinja2.Environment(loader=jinja2.FileSystemLoader(html_table))
         if text_table is not None:
             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
 # install_command = pip install --upgrade build
 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