Browse Source

add: first code commit

Mikael Koli 4 năm trước cách đây
mục cha
commit
c72be04d6b

+ 3 - 0
redmail/__init__.py

@@ -0,0 +1,3 @@
+from .email import EmailSender, send_email, gmail
+from . import _version
+__version__ = _version.get_versions()['version']

+ 644 - 0
redmail/_version.py

@@ -0,0 +1,644 @@
+
+# This file helps to compute a version number in source trees obtained from
+# git-archive tarball (such as those provided by githubs download-from-tag
+# feature). Distribution tarballs (built by setup.py sdist) and build
+# directories (produced by setup.py build) will contain a much shorter file
+# that just contains the computed version number.
+
+# This file is released into the public domain. Generated by
+# versioneer-0.21 (https://github.com/python-versioneer/python-versioneer)
+
+"""Git implementation of _version.py."""
+
+import errno
+import os
+import re
+import subprocess
+import sys
+from typing import Callable, Dict
+
+
+def get_keywords():
+    """Get the keywords needed to look up the version information."""
+    # these strings will be replaced by git during git-archive.
+    # setup.py/versioneer.py will grep for the variable names, so they must
+    # each be defined on a line of their own. _version.py will just call
+    # get_keywords().
+    git_refnames = "$Format:%d$"
+    git_full = "$Format:%H$"
+    git_date = "$Format:%ci$"
+    keywords = {"refnames": git_refnames, "full": git_full, "date": git_date}
+    return keywords
+
+
+class VersioneerConfig:
+    """Container for Versioneer configuration parameters."""
+
+
+def get_config():
+    """Create, populate and return the VersioneerConfig() object."""
+    # these strings are filled in when 'setup.py versioneer' creates
+    # _version.py
+    cfg = VersioneerConfig()
+    cfg.VCS = "git"
+    cfg.style = "pep440"
+    cfg.tag_prefix = "v"
+    cfg.parentdir_prefix = "redmail-"
+    cfg.versionfile_source = "redmail/_version.py"
+    cfg.verbose = False
+    return cfg
+
+
+class NotThisMethod(Exception):
+    """Exception raised if a method is not valid for the current scenario."""
+
+
+LONG_VERSION_PY: Dict[str, str] = {}
+HANDLERS: Dict[str, Dict[str, Callable]] = {}
+
+
+def register_vcs_handler(vcs, method):  # decorator
+    """Create decorator to mark a method as the handler of a VCS."""
+    def decorate(f):
+        """Store f in HANDLERS[vcs][method]."""
+        if vcs not in HANDLERS:
+            HANDLERS[vcs] = {}
+        HANDLERS[vcs][method] = f
+        return f
+    return decorate
+
+
+def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
+                env=None):
+    """Call the given command(s)."""
+    assert isinstance(commands, list)
+    process = None
+    for command in commands:
+        try:
+            dispcmd = str([command] + args)
+            # remember shell=False, so use git.cmd on windows, not just git
+            process = subprocess.Popen([command] + args, cwd=cwd, env=env,
+                                       stdout=subprocess.PIPE,
+                                       stderr=(subprocess.PIPE if hide_stderr
+                                               else None))
+            break
+        except OSError:
+            e = sys.exc_info()[1]
+            if e.errno == errno.ENOENT:
+                continue
+            if verbose:
+                print("unable to run %s" % dispcmd)
+                print(e)
+            return None, None
+    else:
+        if verbose:
+            print("unable to find command, tried %s" % (commands,))
+        return None, None
+    stdout = process.communicate()[0].strip().decode()
+    if process.returncode != 0:
+        if verbose:
+            print("unable to run %s (error)" % dispcmd)
+            print("stdout was %s" % stdout)
+        return None, process.returncode
+    return stdout, process.returncode
+
+
+def versions_from_parentdir(parentdir_prefix, root, verbose):
+    """Try to determine the version from the parent directory name.
+
+    Source tarballs conventionally unpack into a directory that includes both
+    the project name and a version string. We will also support searching up
+    two directory levels for an appropriately named parent directory
+    """
+    rootdirs = []
+
+    for _ in range(3):
+        dirname = os.path.basename(root)
+        if dirname.startswith(parentdir_prefix):
+            return {"version": dirname[len(parentdir_prefix):],
+                    "full-revisionid": None,
+                    "dirty": False, "error": None, "date": None}
+        rootdirs.append(root)
+        root = os.path.dirname(root)  # up a level
+
+    if verbose:
+        print("Tried directories %s but none started with prefix %s" %
+              (str(rootdirs), parentdir_prefix))
+    raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
+
+
+@register_vcs_handler("git", "get_keywords")
+def git_get_keywords(versionfile_abs):
+    """Extract version information from the given file."""
+    # the code embedded in _version.py can just fetch the value of these
+    # keywords. When used from setup.py, we don't want to import _version.py,
+    # so we do it with a regexp instead. This function is not used from
+    # _version.py.
+    keywords = {}
+    try:
+        with open(versionfile_abs, "r") as fobj:
+            for line in fobj:
+                if line.strip().startswith("git_refnames ="):
+                    mo = re.search(r'=\s*"(.*)"', line)
+                    if mo:
+                        keywords["refnames"] = mo.group(1)
+                if line.strip().startswith("git_full ="):
+                    mo = re.search(r'=\s*"(.*)"', line)
+                    if mo:
+                        keywords["full"] = mo.group(1)
+                if line.strip().startswith("git_date ="):
+                    mo = re.search(r'=\s*"(.*)"', line)
+                    if mo:
+                        keywords["date"] = mo.group(1)
+    except OSError:
+        pass
+    return keywords
+
+
+@register_vcs_handler("git", "keywords")
+def git_versions_from_keywords(keywords, tag_prefix, verbose):
+    """Get version information from git keywords."""
+    if "refnames" not in keywords:
+        raise NotThisMethod("Short version file found")
+    date = keywords.get("date")
+    if date is not None:
+        # Use only the last line.  Previous lines may contain GPG signature
+        # information.
+        date = date.splitlines()[-1]
+
+        # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant
+        # datestamp. However we prefer "%ci" (which expands to an "ISO-8601
+        # -like" string, which we must then edit to make compliant), because
+        # it's been around since git-1.5.3, and it's too difficult to
+        # discover which version we're using, or to work around using an
+        # older one.
+        date = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
+    refnames = keywords["refnames"].strip()
+    if refnames.startswith("$Format"):
+        if verbose:
+            print("keywords are unexpanded, not using")
+        raise NotThisMethod("unexpanded keywords, not a git-archive tarball")
+    refs = {r.strip() for r in refnames.strip("()").split(",")}
+    # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
+    # just "foo-1.0". If we see a "tag: " prefix, prefer those.
+    TAG = "tag: "
+    tags = {r[len(TAG):] for r in refs if r.startswith(TAG)}
+    if not tags:
+        # Either we're using git < 1.8.3, or there really are no tags. We use
+        # a heuristic: assume all version tags have a digit. The old git %d
+        # expansion behaves like git log --decorate=short and strips out the
+        # refs/heads/ and refs/tags/ prefixes that would let us distinguish
+        # between branches and tags. By ignoring refnames without digits, we
+        # filter out many common branch names like "release" and
+        # "stabilization", as well as "HEAD" and "master".
+        tags = {r for r in refs if re.search(r'\d', r)}
+        if verbose:
+            print("discarding '%s', no digits" % ",".join(refs - tags))
+    if verbose:
+        print("likely tags: %s" % ",".join(sorted(tags)))
+    for ref in sorted(tags):
+        # sorting will prefer e.g. "2.0" over "2.0rc1"
+        if ref.startswith(tag_prefix):
+            r = ref[len(tag_prefix):]
+            # Filter out refs that exactly match prefix or that don't start
+            # with a number once the prefix is stripped (mostly a concern
+            # when prefix is '')
+            if not re.match(r'\d', r):
+                continue
+            if verbose:
+                print("picking %s" % r)
+            return {"version": r,
+                    "full-revisionid": keywords["full"].strip(),
+                    "dirty": False, "error": None,
+                    "date": date}
+    # no suitable tags, so version is "0+unknown", but full hex is still there
+    if verbose:
+        print("no suitable tags, using unknown + full revision id")
+    return {"version": "0+unknown",
+            "full-revisionid": keywords["full"].strip(),
+            "dirty": False, "error": "no suitable tags", "date": None}
+
+
+@register_vcs_handler("git", "pieces_from_vcs")
+def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command):
+    """Get version from 'git describe' in the root of the source tree.
+
+    This only gets called if the git-archive 'subst' keywords were *not*
+    expanded, and _version.py hasn't already been rewritten with a short
+    version string, meaning we're inside a checked out source tree.
+    """
+    GITS = ["git"]
+    TAG_PREFIX_REGEX = "*"
+    if sys.platform == "win32":
+        GITS = ["git.cmd", "git.exe"]
+        TAG_PREFIX_REGEX = r"\*"
+
+    _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root,
+                   hide_stderr=True)
+    if rc != 0:
+        if verbose:
+            print("Directory %s not under git control" % root)
+        raise NotThisMethod("'git rev-parse --git-dir' returned error")
+
+    # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty]
+    # if there isn't one, this yields HEX[-dirty] (no NUM)
+    describe_out, rc = runner(GITS, ["describe", "--tags", "--dirty",
+                                     "--always", "--long",
+                                     "--match",
+                                     "%s%s" % (tag_prefix, TAG_PREFIX_REGEX)],
+                              cwd=root)
+    # --long was added in git-1.5.5
+    if describe_out is None:
+        raise NotThisMethod("'git describe' failed")
+    describe_out = describe_out.strip()
+    full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root)
+    if full_out is None:
+        raise NotThisMethod("'git rev-parse' failed")
+    full_out = full_out.strip()
+
+    pieces = {}
+    pieces["long"] = full_out
+    pieces["short"] = full_out[:7]  # maybe improved later
+    pieces["error"] = None
+
+    branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"],
+                             cwd=root)
+    # --abbrev-ref was added in git-1.6.3
+    if rc != 0 or branch_name is None:
+        raise NotThisMethod("'git rev-parse --abbrev-ref' returned error")
+    branch_name = branch_name.strip()
+
+    if branch_name == "HEAD":
+        # If we aren't exactly on a branch, pick a branch which represents
+        # the current commit. If all else fails, we are on a branchless
+        # commit.
+        branches, rc = runner(GITS, ["branch", "--contains"], cwd=root)
+        # --contains was added in git-1.5.4
+        if rc != 0 or branches is None:
+            raise NotThisMethod("'git branch --contains' returned error")
+        branches = branches.split("\n")
+
+        # Remove the first line if we're running detached
+        if "(" in branches[0]:
+            branches.pop(0)
+
+        # Strip off the leading "* " from the list of branches.
+        branches = [branch[2:] for branch in branches]
+        if "master" in branches:
+            branch_name = "master"
+        elif not branches:
+            branch_name = None
+        else:
+            # Pick the first branch that is returned. Good or bad.
+            branch_name = branches[0]
+
+    pieces["branch"] = branch_name
+
+    # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
+    # TAG might have hyphens.
+    git_describe = describe_out
+
+    # look for -dirty suffix
+    dirty = git_describe.endswith("-dirty")
+    pieces["dirty"] = dirty
+    if dirty:
+        git_describe = git_describe[:git_describe.rindex("-dirty")]
+
+    # now we have TAG-NUM-gHEX or HEX
+
+    if "-" in git_describe:
+        # TAG-NUM-gHEX
+        mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe)
+        if not mo:
+            # unparsable. Maybe git-describe is misbehaving?
+            pieces["error"] = ("unable to parse git-describe output: '%s'"
+                               % describe_out)
+            return pieces
+
+        # tag
+        full_tag = mo.group(1)
+        if not full_tag.startswith(tag_prefix):
+            if verbose:
+                fmt = "tag '%s' doesn't start with prefix '%s'"
+                print(fmt % (full_tag, tag_prefix))
+            pieces["error"] = ("tag '%s' doesn't start with prefix '%s'"
+                               % (full_tag, tag_prefix))
+            return pieces
+        pieces["closest-tag"] = full_tag[len(tag_prefix):]
+
+        # distance: number of commits since tag
+        pieces["distance"] = int(mo.group(2))
+
+        # commit: short hex revision ID
+        pieces["short"] = mo.group(3)
+
+    else:
+        # HEX: no tags
+        pieces["closest-tag"] = None
+        count_out, rc = runner(GITS, ["rev-list", "HEAD", "--count"], cwd=root)
+        pieces["distance"] = int(count_out)  # total number of commits
+
+    # commit date: see ISO-8601 comment in git_versions_from_keywords()
+    date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip()
+    # Use only the last line.  Previous lines may contain GPG signature
+    # information.
+    date = date.splitlines()[-1]
+    pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
+
+    return pieces
+
+
+def plus_or_dot(pieces):
+    """Return a + if we don't already have one, else return a ."""
+    if "+" in pieces.get("closest-tag", ""):
+        return "."
+    return "+"
+
+
+def render_pep440(pieces):
+    """Build up version string, with post-release "local version identifier".
+
+    Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you
+    get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty
+
+    Exceptions:
+    1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty]
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"] or pieces["dirty"]:
+            rendered += plus_or_dot(pieces)
+            rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
+            if pieces["dirty"]:
+                rendered += ".dirty"
+    else:
+        # exception #1
+        rendered = "0+untagged.%d.g%s" % (pieces["distance"],
+                                          pieces["short"])
+        if pieces["dirty"]:
+            rendered += ".dirty"
+    return rendered
+
+
+def render_pep440_branch(pieces):
+    """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] .
+
+    The ".dev0" means not master branch. Note that .dev0 sorts backwards
+    (a feature branch will appear "older" than the master branch).
+
+    Exceptions:
+    1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty]
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"] or pieces["dirty"]:
+            if pieces["branch"] != "master":
+                rendered += ".dev0"
+            rendered += plus_or_dot(pieces)
+            rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
+            if pieces["dirty"]:
+                rendered += ".dirty"
+    else:
+        # exception #1
+        rendered = "0"
+        if pieces["branch"] != "master":
+            rendered += ".dev0"
+        rendered += "+untagged.%d.g%s" % (pieces["distance"],
+                                          pieces["short"])
+        if pieces["dirty"]:
+            rendered += ".dirty"
+    return rendered
+
+
+def pep440_split_post(ver):
+    """Split pep440 version string at the post-release segment.
+
+    Returns the release segments before the post-release and the
+    post-release version number (or -1 if no post-release segment is present).
+    """
+    vc = str.split(ver, ".post")
+    return vc[0], int(vc[1] or 0) if len(vc) == 2 else None
+
+
+def render_pep440_pre(pieces):
+    """TAG[.postN.devDISTANCE] -- No -dirty.
+
+    Exceptions:
+    1: no tags. 0.post0.devDISTANCE
+    """
+    if pieces["closest-tag"]:
+        if pieces["distance"]:
+            # update the post release segment
+            tag_version, post_version = pep440_split_post(pieces["closest-tag"])
+            rendered = tag_version
+            if post_version is not None:
+                rendered += ".post%d.dev%d" % (post_version+1, pieces["distance"])
+            else:
+                rendered += ".post0.dev%d" % (pieces["distance"])
+        else:
+            # no commits, use the tag as the version
+            rendered = pieces["closest-tag"]
+    else:
+        # exception #1
+        rendered = "0.post0.dev%d" % pieces["distance"]
+    return rendered
+
+
+def render_pep440_post(pieces):
+    """TAG[.postDISTANCE[.dev0]+gHEX] .
+
+    The ".dev0" means dirty. Note that .dev0 sorts backwards
+    (a dirty tree will appear "older" than the corresponding clean one),
+    but you shouldn't be releasing software with -dirty anyways.
+
+    Exceptions:
+    1: no tags. 0.postDISTANCE[.dev0]
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"] or pieces["dirty"]:
+            rendered += ".post%d" % pieces["distance"]
+            if pieces["dirty"]:
+                rendered += ".dev0"
+            rendered += plus_or_dot(pieces)
+            rendered += "g%s" % pieces["short"]
+    else:
+        # exception #1
+        rendered = "0.post%d" % pieces["distance"]
+        if pieces["dirty"]:
+            rendered += ".dev0"
+        rendered += "+g%s" % pieces["short"]
+    return rendered
+
+
+def render_pep440_post_branch(pieces):
+    """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] .
+
+    The ".dev0" means not master branch.
+
+    Exceptions:
+    1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty]
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"] or pieces["dirty"]:
+            rendered += ".post%d" % pieces["distance"]
+            if pieces["branch"] != "master":
+                rendered += ".dev0"
+            rendered += plus_or_dot(pieces)
+            rendered += "g%s" % pieces["short"]
+            if pieces["dirty"]:
+                rendered += ".dirty"
+    else:
+        # exception #1
+        rendered = "0.post%d" % pieces["distance"]
+        if pieces["branch"] != "master":
+            rendered += ".dev0"
+        rendered += "+g%s" % pieces["short"]
+        if pieces["dirty"]:
+            rendered += ".dirty"
+    return rendered
+
+
+def render_pep440_old(pieces):
+    """TAG[.postDISTANCE[.dev0]] .
+
+    The ".dev0" means dirty.
+
+    Exceptions:
+    1: no tags. 0.postDISTANCE[.dev0]
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"] or pieces["dirty"]:
+            rendered += ".post%d" % pieces["distance"]
+            if pieces["dirty"]:
+                rendered += ".dev0"
+    else:
+        # exception #1
+        rendered = "0.post%d" % pieces["distance"]
+        if pieces["dirty"]:
+            rendered += ".dev0"
+    return rendered
+
+
+def render_git_describe(pieces):
+    """TAG[-DISTANCE-gHEX][-dirty].
+
+    Like 'git describe --tags --dirty --always'.
+
+    Exceptions:
+    1: no tags. HEX[-dirty]  (note: no 'g' prefix)
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        if pieces["distance"]:
+            rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
+    else:
+        # exception #1
+        rendered = pieces["short"]
+    if pieces["dirty"]:
+        rendered += "-dirty"
+    return rendered
+
+
+def render_git_describe_long(pieces):
+    """TAG-DISTANCE-gHEX[-dirty].
+
+    Like 'git describe --tags --dirty --always -long'.
+    The distance/hash is unconditional.
+
+    Exceptions:
+    1: no tags. HEX[-dirty]  (note: no 'g' prefix)
+    """
+    if pieces["closest-tag"]:
+        rendered = pieces["closest-tag"]
+        rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
+    else:
+        # exception #1
+        rendered = pieces["short"]
+    if pieces["dirty"]:
+        rendered += "-dirty"
+    return rendered
+
+
+def render(pieces, style):
+    """Render the given version pieces into the requested style."""
+    if pieces["error"]:
+        return {"version": "unknown",
+                "full-revisionid": pieces.get("long"),
+                "dirty": None,
+                "error": pieces["error"],
+                "date": None}
+
+    if not style or style == "default":
+        style = "pep440"  # the default
+
+    if style == "pep440":
+        rendered = render_pep440(pieces)
+    elif style == "pep440-branch":
+        rendered = render_pep440_branch(pieces)
+    elif style == "pep440-pre":
+        rendered = render_pep440_pre(pieces)
+    elif style == "pep440-post":
+        rendered = render_pep440_post(pieces)
+    elif style == "pep440-post-branch":
+        rendered = render_pep440_post_branch(pieces)
+    elif style == "pep440-old":
+        rendered = render_pep440_old(pieces)
+    elif style == "git-describe":
+        rendered = render_git_describe(pieces)
+    elif style == "git-describe-long":
+        rendered = render_git_describe_long(pieces)
+    else:
+        raise ValueError("unknown style '%s'" % style)
+
+    return {"version": rendered, "full-revisionid": pieces["long"],
+            "dirty": pieces["dirty"], "error": None,
+            "date": pieces.get("date")}
+
+
+def get_versions():
+    """Get version information or return default if unable to do so."""
+    # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have
+    # __file__, we can work backwards from there to the root. Some
+    # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which
+    # case we can only use expanded keywords.
+
+    cfg = get_config()
+    verbose = cfg.verbose
+
+    try:
+        return git_versions_from_keywords(get_keywords(), cfg.tag_prefix,
+                                          verbose)
+    except NotThisMethod:
+        pass
+
+    try:
+        root = os.path.realpath(__file__)
+        # versionfile_source is the relative path from the top of the source
+        # tree (where the .git directory might live) to this file. Invert
+        # this to find the root from __file__.
+        for _ in cfg.versionfile_source.split('/'):
+            root = os.path.dirname(root)
+    except NameError:
+        return {"version": "0+unknown", "full-revisionid": None,
+                "dirty": None,
+                "error": "unable to find root of source tree",
+                "date": None}
+
+    try:
+        pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose)
+        return render(pieces, cfg.style)
+    except NotThisMethod:
+        pass
+
+    try:
+        if cfg.parentdir_prefix:
+            return versions_from_parentdir(cfg.parentdir_prefix, root, verbose)
+    except NotThisMethod:
+        pass
+
+    return {"version": "0+unknown", "full-revisionid": None,
+            "dirty": None,
+            "error": "unable to compute version", "date": None}

+ 30 - 0
redmail/email/__init__.py

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

+ 86 - 0
redmail/email/address.py

@@ -0,0 +1,86 @@
+
+
+class EmailAddress:
+    """Utility class to represent email
+    address and access the organization/
+    names in it.
+
+    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
+
+# From official specs
+    @property
+    def parts(self):
+        return self.address.split("@")
+
+    @property
+    def local_part(self):
+        return self.parts[0]
+
+    @property
+    def domain(self):
+        return self.parts[1]
+
+# Checks
+    @property
+    def is_personal(self):
+        "Whether the email address seems to belong to a person"
+        return len(self.local_part.split(".")) == 2
+
+# More of typical conventions
+    @property
+    def top_level_domain(self):
+        """Get top level domain (if possible)
+        
+        Ie. john.smith@en.example.com --> com"""
+        domain = self.domain.split(".")
+        return domain[-1] if len(domain) > 1 else None
+
+    @property
+    def second_level_domain(self):
+        """Get second level domain (if possible)
+        
+        Ie. john.smith@en.example.com --> example"""
+        domain = self.domain.split(".")
+        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'"""
+        if self.is_personal:
+            return f'{self.first_name} {self.last_name}'
+        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"""
+        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"""
+        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

+ 223 - 0
redmail/email/body.py

@@ -0,0 +1,223 @@
+from email.message import EmailMessage
+import mimetypes
+from io import BytesIO
+from pathlib import Path
+from typing import Dict, Union, ByteString
+from pathlib import Path
+
+
+from redmail.utils import is_filelike, is_bytes, is_pathlike
+from redmail.utils import import_from_string
+
+from email.utils import make_msgid
+
+from jinja2.environment import Template, Environment
+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")
+
+class Body:
+
+    def __init__(self, template:Template=None, table_template:Template=None):
+        self.template = template
+        self.table_template = table_template
+
+    def render_body(self, body:str, jinja_params:dict):
+        if body is not None and self.template is not None:
+            raise ValueError("Either body or template must be specified but not both.")
+            
+        if body is not None:
+            template = Environment().from_string(body)
+        else:
+            template = self.template
+        return template.render(**jinja_params)
+
+    def render_table(self, tbl, extra=None):
+        # TODO: Nicer tables. 
+        #   https://stackoverflow.com/a/55356741/13696660
+        #   Email HTML (generally) does not support CSS
+        extra = {} if extra is None else extra
+        df = pd.DataFrame(tbl)
+
+        tbl_html = self.table_template.render({"df": df, **extra})
+        return Markup(tbl_html)
+
+    def render(self, cont:str, tables=None, jinja_params=None):
+        tables = {} if tables is None else tables
+        jinja_params = {} if jinja_params is None else jinja_params
+        tables = {
+            name: self.render_table(tbl)
+            for name, tbl in tables.items()
+        }
+        return self.render_body(cont, jinja_params={**tables, **jinja_params})
+
+
+class TextBody(Body):
+
+    def attach(self, msg:EmailMessage, text:str, **kwargs):
+        text = self.render(text, **kwargs)
+        msg.set_content(text)
+
+
+class HTMLBody(Body):
+
+    def __init__(self, domain:str=None, **kwargs):
+        super().__init__(**kwargs)
+        self.domain = domain
+
+    def attach(self, 
+               msg:EmailMessage, 
+               html:str, 
+               images: Dict[str, Union[Path, str, bytes]]=None, 
+               **kwargs):
+        """Render email HTML
+        
+        Parameters
+        ----------
+            msg : EmailMessage
+                Message of the email.
+            html : str
+                HTML that may contain Jinja syntax.
+            body_images : dict of path-likes, bytes
+                Images to embed to the HTML. The dict keys correspond to variables in the html.
+            body_tables : dict of pd.DataFrame
+                Tables to embed to the HTML
+            jinja_params : dict
+                Extra Jinja parameters for the HTML.
+        """
+        domain = msg["from"].split("@")[-1] if self.domain is None else self.domain
+        html, cids = self.render(
+            html, 
+            images=images,
+            domain=domain,
+            **kwargs
+        )
+        msg.add_alternative(html, subtype='html')
+
+        if images is not None:
+            # https://stackoverflow.com/a/49098251/13696660
+            html_msg = msg.get_payload()[-1]
+            cid_path_mapping = {cids[name]: path for name, path in images.items()}
+            
+            self.attach_imgs(html_msg, cid_path_mapping)
+
+    def render(self, html:str, images:Dict[str, Union[dict, bytes, Path]]=None, tables:Dict[str, pd.DataFrame]=None, jinja_params:dict=None, domain=None):
+        """Render Email HTML body (sets cid for image sources and adds data as other parameters)
+
+        Parameters
+        ----------
+        html : str
+            HTML (template) to be rendered with images,
+            tables etc. May contain...
+        images : list-like, optional
+            A list-like of images to be rendered to the HTML.
+            Values represent the Jinja variables found in the html
+            and the images are rendered on those positions.
+        tables : dict, optional
+            A dict of tables to render to the HTML. The keys
+            should represent variables in ``html`` and values
+            should be Pandas dataframes to be rendered to the HTML.
+        extra : dict, optional
+            Extra items to be passed to the HTML Jinja template.
+        table_theme : str, optional
+            Theme to use for generating the HTML version of the
+            table dataframes. See included files in the 
+            environment pybox.jinja2.envs.inline. The themes
+            are stems of the files in templates/inline/table.
+
+        Returns
+        -------
+        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
+
+        # Define CIDs for images
+        cids = {
+            name: make_msgid(domain=domain)
+            for name in images
+        }
+        cids_html = {
+            name: f'cid:{cid[1:-1]}' # taking "<" and ">" from beginning and end 
+            for name, cid in cids.items()
+        }
+
+        # Tables to HTML
+        jinja_params = {**jinja_params, **cids_html}
+        html = super().render(html, tables=tables, jinja_params=jinja_params)
+        return html, cids
+
+    def attach_imgs(self, msg_body:EmailMessage, imgs:Dict[str, Union[ByteString, str, Dict[str, Union[ByteString, str]]]]):
+        """Attach CID images to Message Body
+        
+        Examples:
+        ---------
+            attach_imgs(..., {"<>"})
+        """
+
+        for cid, img in imgs.items():
+            if is_bytes(img) or isinstance(img, BytesIO):
+                # We just assume the user meant PNG. If not, it should have been specified
+                img_content = img.read() if hasattr(img, "read") else img
+                maintype = "image"
+                subtype  = "png"
+
+            elif isinstance(img, dict):
+                # Expecting dict explanation of bytes
+                # ie. {"maintype": "image", "subtype": "png", "content"}
+                required_keys = ("content", "maintype", "subtype")
+                if any(key not in img for key in required_keys):
+                    missing_keys = tuple(key for key in required_keys if key not in img)
+                    raise KeyError(f"Image {img:!r} missing keys: {missing_keys:!r}")
+                img_content = img["content"]
+                maintype = "image"
+                subtype = "png"
+
+            elif is_filelike(img):
+                path = img
+                maintype, subtype = mimetypes.guess_type(str(path))[0].split('/')
+                
+                with open(path, "rb") as img:
+                    img_content = img.read()
+            elif plt is not None and isinstance(img, plt.Figure):
+                buf = BytesIO()
+                img.savefig(buf, format='png')
+                buf.seek(0)
+                img_content = buf.read()
+                maintype = "image"
+                subtype  = "png"
+            elif PIL is not None and isinstance(img, PIL.Image.Image):
+                buf = BytesIO()
+                img.save(buf, format='PNG')
+                buf.seek(0)
+                img_content = buf.read()
+                maintype = "image"
+                subtype  = "png"
+            else:
+                # Cannot be figured out
+                if isinstance(img, str):
+                    raise ValueError(f"Unknown image string '{img}'. Maybe incorrect path?")
+                raise TypeError(f"Unknown image {repr(img)}")
+
+            msg_body.add_related(
+                img_content,
+                maintype=maintype,
+                subtype=subtype,
+                cid=cid
+            )

+ 65 - 0
redmail/email/envs.py

@@ -0,0 +1,65 @@
+
+from jinja2 import Environment, FileSystemLoader
+from pathlib import Path
+
+def get_span(l:list, loc:int, width=None) -> int:
+    "Get span of each value in index/column"
+    def get_value(item):
+        val = item[:width+1] if width is not None else item
+        return val
+
+    prev = get_value(l[loc-1])
+    curr = get_value(l[loc])
+    if len(l) == 1:
+        # Index/column of size 1
+        span = 1
+    elif prev == curr:
+        # Previous value is the same as current
+        # --> hide (span=0)
+        # The previous should have span>=2
+        span = 0
+    else:
+        span = 1
+        for nxt in l[loc+1:]:
+            if get_value(nxt) != curr:
+                break
+            span += 1
+    return span
+
+def is_last_group_row(n, index:list, level=None):
+    "Check if iteration is the last of the group"
+    curr = index[n]
+    if not isinstance(curr, tuple):
+        return False
+    elif n == (index.shape[0] - 1):
+        # Last of the whole frame
+        return True
+    
+    n += 1
+    next = index[n]
+
+    if level == 0:
+        return True
+    elif level is None:
+        return curr[0] != next[0]
+    
+
+    while curr[:level+1] == next[:level+1]:
+        # fast forward to the span of the level
+        try:
+            next = index[n+1]
+            n += 1
+        except IndexError:
+            # End of the dataframe
+            return True
+    
+    # ie. ("blue", "car"), ("green", "car") --> True
+    # ("blue", "car"), ("blue", "red") --> False
+    return curr[0] != next[0]
+
+inline = Environment(loader=FileSystemLoader(Path(__file__).parent / "templates/inline"))
+inline.globals["get_span"] = get_span
+inline.globals["is_last_group_row"] = is_last_group_row
+
+master = Environment(loader=FileSystemLoader(Path(__file__).parent / "templates"))
+master.globals["get_span"] = get_span

+ 260 - 0
redmail/email/sender.py

@@ -0,0 +1,260 @@
+
+from email.message import EmailMessage
+from typing import Callable, Dict, Union
+
+import jinja2
+
+from redmail.email.body import HTMLBody, TextBody
+from .address import EmailAddress
+from .envs import get_span, is_last_group_row
+
+import smtplib
+
+from pathlib import Path
+from platform import node
+from getpass import getuser
+import datetime
+
+class EmailSender:
+    """Email sender
+
+    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.
+
+    Examples
+    --------
+        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(
+            subject="Important email",
+            html_body="<h1>Important</h1><img src={{ nice_pic }}>",
+            body_images={'nice_pic': 'path/to/pic.jpg'},
+
+        )
+    """
+    
+    default_html_theme = "modest.html"
+    default_text_theme = "pandas.txt"
+
+    templates_html = jinja2.Environment(loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates/html"))
+    templates_html_table = jinja2.Environment(loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates/html/table"))
+
+    templates_text = jinja2.Environment(loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates/text"))
+    templates_text_table = jinja2.Environment(loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates/text/table"))
+
+    # Set globals
+    templates_html_table.globals["get_span"] = get_span
+    templates_text_table.globals["get_span"] = get_span
+    
+    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
+        self.port = port
+
+        self.user_name = user_name
+        self.password = password
+        
+    def send_email(self, **kwargs):
+        """Send an email message.
+
+        Parameters
+        ----------
+        subject : str
+            Subject of the email.
+        receiver : list, optional
+            Receivers of the email.
+        sender : str, optional
+            Sender of the email.
+        cc : list, optional
+            Cc or Carbon Copy of the email.
+            Extra recipients of the email.
+        bcc : list, optional
+            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 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
+            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
+            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="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 }}">',
+                body_images={"my_image": Path("C:/path/to/img.png")}
+            )
+            >>> sender.send_email(
+                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 }}">',
+                body_images={"my_image": Path("C:/path/to/img.png")},
+                html_params={"name": "Jack"},
+            )
+
+        Returns
+        -------
+        EmailMessage
+            Email message.
+        """
+        msg = self.get_message(**kwargs)
+        self.send_message(msg)
+        return msg
+        
+    def get_message(self, 
+                  subject:str,
+                  receiver:list=None,
+                  sender:str=None,
+                  cc:list=None,
+                  bcc:list=None,
+                  html_body:str=None,
+                  text_body: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:
+        """Get the email message."""
+        
+        sender = sender or self.user_name
+        msg = self._create_body(
+            subject=subject, 
+            sender=sender, 
+            receiver=receiver,
+            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:
+            body = TextBody(
+                template=self.get_text_body_template(text_template),
+                table_template=self.get_text_table_template(),
+            )
+            body.attach(
+                msg, 
+                text_body, 
+                tables=body_tables,
+                jinja_params=jinja_params,
+            )
+
+        if html_body is not None or html_template is not None:
+            body = HTMLBody(
+                template=self.get_html_body_template(html_template),
+                table_template=self.get_html_table_template(),
+            )
+            body.attach(
+                msg,
+                html=html_body,
+                images=body_images,
+                tables=body_tables,
+                jinja_params=jinja_params
+            )
+        return msg
+
+    def _create_body(self, subject, sender, receiver=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 cc:
+            msg['cc'] = cc
+        if bcc:
+            msg['bcc'] = bcc
+        return msg
+
+    def send_message(self, msg):
+        "Send the created message"
+        user = self.user_name
+        password = self.password
+        
+        server = smtplib.SMTP(self.server, self.port)
+        server.starttls()
+        server.login(user, password)
+        server.send_message(msg)
+        
+        server.quit()
+    
+    def get_template_params(self, sender:str):
+        "Get Jinja parametes passed to template"
+        # TODO: Add receivers to params
+        return {
+            "node": node(),
+            "user": getuser(),
+            "now": datetime.datetime.now(),
+            "sender": EmailAddress(sender),
+        }
+
+    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:
+        if layout is None:
+            return None
+        return self.templates_html.get_template(layout)
+
+    def get_text_table_template(self, layout=None) -> jinja2.Template:
+        layout = self.default_text_theme if layout is None else layout
+        if layout is None:
+            return None
+        return self.templates_text_table.get_template(layout)
+
+    def get_text_body_template(self, layout=None) -> jinja2.Template:
+        if layout is None:
+            return None
+        return self.templates_text.get_template(layout)
+
+    def set_template_paths(self, html=None, text=None, html_table=None, text_table=None):
+        """Create Jinja envs for body templates using given paths
+        
+        This is a shortcut for manually setting them like:
+        .. clode-block:: python
+
+            sender.templates_html = jinja2.Environment(loader=jinja2.FileSystemLoader(...))
+            sender.templates_text = jinja2.Environment(loader=jinja2.FileSystemLoader(...))
+            ...
+        """
+        if html is not None:
+            self.templates_html = jinja2.Environment(loader=jinja2.FileSystemLoader(html))
+        if text is not None:
+            self.templates_text = jinja2.Environment(loader=jinja2.FileSystemLoader(text))
+        if html_table is not None:
+            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))

+ 85 - 0
redmail/email/templates/html/table/custom.html

@@ -0,0 +1,85 @@
+
+{%- set style_index_value = style_index_value or '' -%}
+
+{%- set n_headers = df.index.names.__len__() + df.columns.__len__() -%}
+{%- set n_indexes = df.index.names|length -%}
+{%- set is_multi_index = df.index.nlevels > 1 -%}
+{%- set is_multi_columns = df.columns.nlevels > 1 -%}
+
+<table style="border-collapse: collapse; {{style_table}}">
+    <thead style="{{ style_thead }}">
+
+        {%- set column_tuples = df.columns.tolist() -%}
+        {#- Fill first with empty values (placeholder for index names below) -#}
+        {%- for n_level in range(df.columns.nlevels) -%}
+            {%- set level = df.columns.get_level_values(n_level) %}
+            <tr>
+                {# Fill first with empty values (placeholder for index names below) #}
+                {%- for _ in range(n_indexes-1) -%}
+                    <th></th>
+                {% endfor -%}
+                
+                {#- Set the actual column values -#}
+                
+                <th style="{{ style_column_name }}">{% if level.name is not none %}{{ level.name }}{% endif %}</th>
+                {% for header in level -%}
+                    {%- set span = get_span(column_tuples, loc=loop.index0, width=n_level if is_multi_columns else none) -%}
+                    {%- set is_last_group_column = is_last_group_row(loop.index0, df.columns, level=n_level) -%}
+
+                    {%- if span > 0 %}
+                        <th style="{{ style_column_value }}
+                                   {{ style_column_value_last_column_group if is_last_group_column else ''}}" colspan="{{ span }}">{{ header }}</th>
+                    {%- endif -%}
+
+                {% endfor %}
+                
+            </tr>
+        {% endfor %}
+        <tr>
+            {# Last header row is the index names #}
+            {%- for index_header in df.index.names -%}
+                <th style="{{ style_index_name }}{{ style_index_name_last if loop.last else ''}}">{% if index_header is not none %}{{ index_header }}{% endif %}</th>
+            {% endfor %}
+
+            {#- Fill rest to empty values -#}
+            {%- for _ in df.columns -%}
+                {%- set is_last_group_column = is_last_group_row(loop.index0, df.columns) -%}
+                <th style="{{ style_index_name }}
+                           {{ style_column_value_last_column_group if is_last_group_column else ''}}"></th>
+            {% endfor %}
+        </tr>
+    </thead>
+
+   <tbody style="{{ style_tbody }}">
+        {%- set index_tuples = df.index.tolist() -%}
+        {% for idx, row in df.iterrows() -%}
+            {%- set idx = [idx] if df.index.nlevels == 1 else idx -%}
+            {%- set row_loop = loop -%}
+            
+            <tr style="{{ loop.cycle(style_row_odd, style_row_even) }}">
+                {% for value in idx -%}
+                    {%- set index_width = loop.index0 if is_multi_index else none -%}
+                    {%- set is_last_group_index = is_last_group_row(row_loop.index0, df.index, level=index_width) -%}
+                    {%- set span = get_span(index_tuples, loc=row_loop.index0, width=index_width) -%}
+
+                    {%- if span > 0 %}
+                        <th style="{{ style_index_value }}
+                                {{ style_index_value_last if loop.last else '' }}
+                                {{ row_loop.cycle(style_row_value_odd, style_row_value_even) }}
+                                {{ style_index_value_last_index_group if is_last_group_index else ''}}" 
+                                rowspan="{{ span }}">{{ value }}</th>
+                    {%- endif -%}
+
+                {% endfor %}
+                {%- for value in row -%}
+                    {%- set is_last_group_index = is_last_group_row(row_loop.index0, df.index) -%}
+                    {%- set is_last_group_column = is_last_group_row(loop.index0, df.columns) -%}
+                    <td style="{{ style_value }}
+                               {{ row_loop.cycle(style_row_value_odd, style_row_value_even) }}
+                               {{ style_value_last_index_group if is_last_group_index else ''}}
+                               {{ style_value_last_column_group if is_last_group_column else ''}}">{{ value }}</td>
+                {% endfor %}
+            </tr>
+        {% endfor %}
+    </tbody>
+</table>

+ 33 - 0
redmail/email/templates/html/table/modest.html

@@ -0,0 +1,33 @@
+
+{#- Names of the axes -#}
+{% set style_column_name="font-weight: bold; padding:0.5em 0.5em; white-space: nowrap; border-right: 1px dashed grey;" %}
+{% set style_index_name="font-weight: bold; padding:0.5em 0.5em; white-space: nowrap;" %}
+{% set style_index_name_last="border-right: 1px dashed grey;" %}
+
+{#- Axes -#}
+{% set style_column_value="font-weight: bold; padding:0.5em 0.5em; white-space: nowrap;" %}
+{% set style_index_value="font-weight: bold; padding:0.5em 0.5em; text-align:right; vertical-align: top; white-space: nowrap;" %}
+{% set style_index_value_last="border-right: 1px solid black; text-align: right;" %}
+
+{#- Actual values -#}
+{% set style_value="padding:0.5em 0.5em; text-align:center;" %}
+
+
+{#- Groups, index & columns -#}
+{% set style_index_value_last_index_group="border-bottom: 1px dashed grey;" %}
+{% set style_column_value_last_column_group="border-right: 1px dashed grey;" %}
+
+{% set style_value_last_index_group="border-bottom: 1px dashed grey;" %}
+{% set style_value_last_column_group="border-right: 1px dashed grey;" %}
+
+{#- Row cycling -#}
+{% set style_row_value_even="background-color: White;" %}
+{% set style_row_value_odd="background-color: #f5f5f5;" %}
+
+
+{#- Other -#}
+{% set style_table="font-family: Arial, Helvetica, sans-serif; font-size: 12px;" %}
+{% set style_thead="border-bottom: 1px solid black;" %}
+
+
+{% extends 'custom.html' %}

+ 17 - 0
redmail/email/templates/html/table/notebook-like.html

@@ -0,0 +1,17 @@
+{% set style_table="font-family: Arial, Helvetica, sans-serif; font-size: 12px;" %}
+
+{% set style_column_name="font-weight: bold; padding:0.5em 0.5em; white-space: nowrap;" %}
+{% set style_index_name="font-weight: bold; padding:0.5em 0.5em; white-space: nowrap;" %}
+
+{% set style_column_value="font-weight: bold; padding:0.5em 0.5em; white-space: nowrap;" %}
+{% set style_index_value="font-weight: bold; padding:0.5em 0.5em; text-align:right; vertical-align: top; white-space: nowrap;" %}
+
+{% set style_index_value_last="border-right: 1px solid black; text-align: right;" %}
+
+{% set style_value="padding:0.5em 0.5em; text-align:center;" %}
+
+{% set style_thead="border-bottom: 1px solid black;" %}
+
+{% set style_row_value_even="background-color: White;" %}
+{% set style_row_value_odd="background-color: #f5f5f5;" %}
+{% extends 'custom.html' %}

+ 1 - 0
redmail/email/templates/text/table/pandas.txt

@@ -0,0 +1 @@
+{{ df }}

+ 0 - 0
redmail/tests/__init__.py


+ 20 - 0
redmail/tests/conftest.py

@@ -0,0 +1,20 @@
+
+import pytest
+from pathlib import Path
+import os, sys
+
+# add helpers to path
+sys.path.append(os.path.join(os.path.dirname(__file__), 'helpers'))
+
+def copy_file_to_tmpdir(tmpdir, source_file, target_path=None):
+    "Utility to copy file from test_files to temporary directory"
+    source_path = Path(os.path.dirname(__file__)) / "test_files" / source_file
+
+    fh = tmpdir.join(Path(target_path).name if target_path is not None else source_path.name)
+    with open(source_path) as f:
+        fh.write(f.read())
+    return fh
+
+@pytest.fixture
+def dummy_png(tmpdir):
+    return copy_file_to_tmpdir(tmpdir, source_file="dummy.png")

+ 0 - 0
redmail/tests/email/__init__.py


+ 139 - 0
redmail/tests/email/test_body.py

@@ -0,0 +1,139 @@
+from redmail import EmailSender
+
+import pytest
+import pandas as pd
+
+from convert import remove_extra_lines
+from getpass import getpass, getuser
+from platform import node
+
+def test_text_message():
+    text = "Hi, nice to meet you."
+
+    sender = EmailSender(server=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receiver="you@gmail.com",
+        subject="Some news",
+        text_body=text,
+    )
+    payload = msg.get_payload()
+    expected_headers = {
+        'from': 'me@gmail.com', 
+        'subject': 'Some news', 
+        'to': 'you@gmail.com', 
+        'MIME-Version': '1.0', 
+        'Content-Type': 'text/plain; charset="utf-8"',
+        'Content-Transfer-Encoding': '7bit',
+    }
+
+    assert "text/plain" == msg.get_content_type()
+    assert text + "\n" == payload
+
+    # Test receivers etc.
+    headers = dict(msg.items())
+    assert expected_headers == headers
+
+def test_html_message():
+    html = "<h3>Hi,</h3><p>Nice to meet you</p>"
+
+    sender = EmailSender(server=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receiver="you@gmail.com",
+        subject="Some news",
+        html_body=html,
+    )
+    payload = msg.get_payload()
+    expected_headers = {
+        'from': 'me@gmail.com', 
+        'subject': 'Some news', 
+        'to': 'you@gmail.com', 
+        #'MIME-Version': '1.0', 
+        'Content-Type': 'multipart/alternative'
+    }
+
+    assert "multipart/alternative" == msg.get_content_type()
+    assert html + "\n" == payload[0].get_content()
+
+    # Test receivers etc.
+    headers = dict(msg.items())
+    assert expected_headers == headers
+
+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)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receiver="you@gmail.com",
+        subject="Some news",
+        html_body=html,
+        text_body=text,
+    )
+    payload = msg.get_payload()
+    expected_headers = {
+        'from': 'me@gmail.com', 
+        'subject': 'Some news', 
+        'to': 'you@gmail.com', 
+        'MIME-Version': '1.0', 
+        'Content-Type': 'multipart/alternative'
+    }
+
+    assert "multipart/alternative" == msg.get_content_type()
+
+    assert "text/plain" == payload[0].get_content_type()
+    assert text + "\n" == payload[0].get_content()
+
+    assert "text/html" == payload[1].get_content_type()
+    assert html + "\n" == payload[1].get_content()
+
+    # Test receivers etc.
+    headers = dict(msg.items())
+    assert expected_headers == headers
+    
+@pytest.mark.parametrize(
+    "html,expected_html,text,expected_text,extra", [
+        pytest.param(
+            "<h3>Hi,</h3> <p>This is {{ user }} from {{ node }}. I'm really {{ sender.full_name }}.</p>",
+            f"<h3>Hi,</h3> <p>This is {getuser()} from {node()}. I'm really Me.</p>\n",
+
+            "Hi, \nThis is {{ user }} from {{ node }}. I'm really {{ sender.full_name }}.",
+            f"Hi, \nThis is {getuser()} from {node()}. I'm really Me.\n",
+
+            None,
+            id="With default extras"
+        ),
+        pytest.param(
+            "<h3>Hi {{ receiver }},</h3> <p>This is {{ user }} from {{ node }}. I'm really {{ sender.full_name }}.</p>", 
+            f"<h3>Hi you,</h3> <p>This is overridden from {node()}. I'm really Me.</p>\n",
+
+            "Hi {{ receiver }}, This is {{ user }} from {{ node }}. I'm really {{ sender.full_name }}.",
+            f"Hi you, This is overridden from {node()}. I'm really Me.\n", 
+
+            {"user": "overridden", "receiver": "you"},
+            id="Custom extra"
+        ),
+    ]
+)
+def test_with_jinja_params(html, text, extra, expected_html, expected_text):
+
+    sender = EmailSender(server=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receiver="you@gmail.com",
+        subject="Some news",
+        text_body=text,
+        html_body=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', "")
+
+    assert expected_html == html
+    assert expected_text == text

+ 178 - 0
redmail/tests/email/test_inline_media.py

@@ -0,0 +1,178 @@
+
+from redmail import EmailSender
+
+import re
+
+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 compare_image_mime(mime_part, mime_part_html, orig_image:bytes):
+    assert 'image/png' == mime_part.get_content_type()
+    image_bytes = mime_part.get_content()
+    assert orig_image == image_bytes
+
+    # Check the HTML mime has the image
+    image_info = dict(mime_part.items())
+    cid_parts = image_info['Content-ID'][1:-1].split(".")
+    cid = "{}.{}.=\n{}.{domain}".format(*cid_parts[:3], domain='.'.join(cid_parts[3:]))
+    cid = image_info['Content-ID'][1:-1]
+
+    mime_part_html_cleaned = mime_part_html.get_payload().replace("=\n", "")
+    assert f'<img src=3D"cid:{cid}">' in mime_part_html_cleaned or f'<img src="cid:{cid}">' in mime_part_html_cleaned
+
+@pytest.mark.parametrize(
+    "get_image_obj", [
+        pytest.param(lambda x: str(x), id="Path (str)"),
+        pytest.param(lambda x: Path(str(x)), id="Path (pathlib)"),
+        pytest.param(lambda x: open(str(x), 'rb').read(), id="Bytes (bytes)"),
+        pytest.param(lambda x: BytesIO(open(str(x), 'rb').read()), id="Bytes (BytesIO)"),
+        pytest.param(lambda x: {"maintype": "image", "subtype": "png", "content": open(str(x), 'rb').read()}, id="Dict specs"),
+    ]
+)
+def test_with_image_file(get_image_obj, dummy_png):
+    with open(str(dummy_png), "rb") as f:
+        dummy_bytes = f.read()
+    image_obj = get_image_obj(dummy_png)
+
+    sender = EmailSender(server=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receiver="you@gmail.com",
+        subject="Some news",
+        html_body='<h1>Hi,</h1> Nice to meet you. Look at this shit: <img src="{{ my_image }}">',
+        body_images={"my_image": image_obj}
+    )
+    
+    assert "multipart/alternative" == msg.get_content_type()
+
+    #mime_text = msg.get_payload()[0]
+    mime_html = msg.get_payload()[0].get_payload()[0]
+    mime_image  = msg.get_payload()[0].get_payload()[1]
+
+    compare_image_mime(mime_image, mime_html, orig_image=dummy_bytes)
+
+    # Test receivers etc.
+    headers = dict(msg.items())
+    assert {
+        'from': 'me@gmail.com', 
+        'subject': 'Some news', 
+        'to': 'you@gmail.com', 
+        #'MIME-Version': '1.0', 
+        'Content-Type': 'multipart/alternative'
+    } == headers
+
+@pytest.mark.parametrize(
+    "get_image_obj", [
+        pytest.param(get_mpl_fig, id="Matplotlib figure"),
+        pytest.param(get_pil_image, id="PIL image"),
+    ]
+)
+def test_with_image_obj(get_image_obj):
+    image_obj, image_bytes = get_image_obj()
+
+    sender = EmailSender(server=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receiver="you@gmail.com",
+        subject="Some news",
+        html_body='<h1>Hi,</h1> Nice to meet you. Look at this shit: <img src="{{ my_image }}">',
+        body_images={"my_image": image_obj}
+    )
+    
+    assert "multipart/alternative" == msg.get_content_type()
+
+    #mime_text = msg.get_payload()[0]
+    mime_html = msg.get_payload()[0].get_payload()[0]
+    mime_image  = msg.get_payload()[0].get_payload()[1]
+
+    compare_image_mime(mime_image, mime_html, orig_image=image_bytes)
+
+    # Test receivers etc.
+    headers = dict(msg.items())
+    assert {
+        'from': 'me@gmail.com', 
+        'subject': 'Some news', 
+        'to': 'you@gmail.com', 
+        #'MIME-Version': '1.0', 
+        'Content-Type': 'multipart/alternative'
+    } == headers
+
+
+
+@pytest.mark.parametrize(
+    "df,", [
+        pytest.param(
+            pd.DataFrame(
+                [[1, 2, 3], [4, 5, 6], [7, 8, 9]],
+                columns=pd.Index(["first", "second", "third"]),
+            ), 
+            id="Simple dataframe"
+        ),
+        pytest.param(
+            pd.DataFrame(
+                [[1], [2], [3]],
+                columns=pd.Index(["first"]),
+            ), 
+            id="Single column datafram"
+        ),
+        pytest.param(
+            pd.DataFrame(
+                [[1, 2, 3], [4, 5, 6], [7, 8, 9]],
+                columns=pd.Index(["first", "second", "third"]),
+                index=pd.Index(["a", "b", "c"], name="category")
+            ),   
+            id="Simple dataframe with index"
+        ),
+        pytest.param(
+            pd.DataFrame(
+                [[1, 2, 3, "a"], [4, 5, 6, "b"], [7, 8, 9, "c"], [10, 11, 12, "d"]],
+                columns=pd.MultiIndex.from_tuples([("parent a", "child a"), ("parent a", "child b"), ("parent b", "child a"), ("parent c", "child a")], names=["lvl 1", "lvl 2"]),
+                index=pd.MultiIndex.from_tuples([("row a", "sub a"), ("row a", "sub b"), ("row b", "sub a"), ("row c", "sub a")], names=["cat 1", "cat 2"]),
+            ), 
+            id="Complex dataframe"
+        ),
+        pytest.param(
+            pd.DataFrame(
+                [[1, 2], [4, 5]],
+                columns=pd.MultiIndex.from_tuples([("col a", "child b", "subchild a"), ("col a", "child b", "subchild a")]),
+                index=pd.MultiIndex.from_tuples([("row a", "child b", "subchild a"), ("row a", "child b", "subchild a")]),
+            ), 
+            id="Multiindex end with spanned"
+        ),
+        pytest.param(
+            pd.DataFrame(
+                [],
+                columns=pd.Index(["first", "second", "third"]),
+            ), 
+            id="Empty datafram"
+        ),
+    ]
+)
+def test_with_html_table_no_error(df, tmpdir):
+
+    sender = EmailSender(server=None, port=1234)
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receiver="you@gmail.com",
+        subject="Some news",
+        html_body='The table {{my_table}}',
+        body_tables={"my_table": df}
+    )
+    
+    assert "multipart/alternative" == msg.get_content_type()
+
+    #mime_text = msg.get_payload()[0]
+    html = remove_extra_lines(msg.get_payload()[0].get_payload()).replace("=20", "").replace('"3D', "")
+    #tmpdir.join("email.html").write(html)
+
+    # TODO: Test the HTML is as required
+
+    assert html

+ 42 - 0
redmail/tests/email/test_template.py

@@ -0,0 +1,42 @@
+from redmail import EmailSender
+
+import pytest
+import pandas as pd
+
+from convert import remove_extra_lines
+from getpass import getpass, getuser
+from platform import node
+
+
+def test_template(tmpdir):
+    
+    html_templates = tmpdir.mkdir("html_tmpl")
+    html_templates.join("example.html").write("""<h1>Hi {{ friend }},</h1><p>have you checked this open source project '{{ project_name }}'?</p><p>- {{ sender.full_name }}</p>""")
+    expected_html = f"<h1>Hi Jack,</h1><p>have you checked this open source project 'RedMail'?</p><p>- Me</p>\n"
+
+    text_templates = tmpdir.mkdir("text_tmpl")
+    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)
+    sender.set_template_paths(
+        html=str(html_templates),
+        text=str(text_templates),
+    )
+    msg = sender.get_message(
+        sender="me@gmail.com",
+        receiver="you@gmail.com",
+        subject="Some news",
+        html_template='example.html',
+        text_template='example.txt',
+        body_params={"friend": "Jack", 'project_name': 'RedMail'}
+    )
+    
+    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', "")
+
+    assert expected_html == html
+    assert expected_text == text

+ 0 - 0
redmail/tests/helpers/__init__.py


+ 6 - 0
redmail/tests/helpers/convert.py

@@ -0,0 +1,6 @@
+
+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)

+ 30 - 0
redmail/tests/helpers/resources.py

@@ -0,0 +1,30 @@
+
+import numpy as np
+from io import BytesIO
+
+import pytest
+
+def get_mpl_fig():
+    pytest.importorskip("matplotlib")
+    import matplotlib.pyplot as plt
+    # Data for plotting
+    t = np.arange(0.0, 2.0, 0.01)
+    s = 1 + np.sin(2 * np.pi * t)
+    fig, ax = plt.subplots()
+    ax.plot(t, s)
+
+    buf = BytesIO()
+    fig.savefig(buf, format='png')
+    buf.seek(0)
+    bytes = buf.read()
+    return fig, bytes
+
+def get_pil_image():
+    pytest.importorskip("PIL")
+    from PIL import Image
+    img = Image.new('RGB', (100, 30), color = (73, 109, 137))
+    buf = BytesIO()
+    img.save(buf, format='PNG')
+    buf.seek(0)
+    bytes = buf.read()
+    return img, bytes

+ 5 - 0
redmail/tests/pytest.ini

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

BIN
redmail/tests/test_files/dummy.png


+ 29 - 0
redmail/utils.py

@@ -0,0 +1,29 @@
+
+import importlib
+import io
+import os
+
+def import_from_string(imp_str, if_missing="raise"):
+    try:
+        return importlib.import_module(imp_str)
+    except ImportError:
+        if if_missing == "ignore":
+            return None
+        raise
+
+
+def is_filelike(value):
+    """Is file-like object or string of file path
+    
+    See: https://stackoverflow.com/a/1661354/13696660"""
+    try:
+        return hasattr(value, "read") or os.path.isfile(value)
+    except TypeError:
+        return False
+
+def is_bytes(value):
+    return isinstance(value, (bytes, bytearray))
+
+def is_pathlike(value):
+    "Check if the value is path-like"
+    return isinstance(value, os.PathLike)