pax_global_header00006660000000000000000000000064146222331560014516gustar00rootroot0000000000000052 comment=cdaccfa5ab40d6d6f8918fd13fc8e4ede1809568 muttdown-muttdown-0.4.0/000077500000000000000000000000001462223315600152575ustar00rootroot00000000000000muttdown-muttdown-0.4.0/.github/000077500000000000000000000000001462223315600166175ustar00rootroot00000000000000muttdown-muttdown-0.4.0/.github/workflows/000077500000000000000000000000001462223315600206545ustar00rootroot00000000000000muttdown-muttdown-0.4.0/.github/workflows/ci.yml000066400000000000000000000015741462223315600220010ustar00rootroot00000000000000name: "Python Tests" on: pull_request: push: branches: [main, master] jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements-tests.txt -e . - name: Run pytest run: py.test -vs tests/ pre-commit: runs-on: ubuntu-latest steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 - uses: actions/setup-python@61a6322f88396a6271a6ee3565807d608ecaddd1 - uses: pre-commit/action@646c83fcd040023954eafda54b4db0192ce70507 muttdown-muttdown-0.4.0/.gitignore000066400000000000000000000000731462223315600172470ustar00rootroot00000000000000*.pyc env/ build/ dist/ sdist/ *.egg-info/ venv*/ .pytest* muttdown-muttdown-0.4.0/.pre-commit-config.yaml000066400000000000000000000011301462223315600215330ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-added-large-files - id: fix-byte-order-marker - id: check-symlinks - id: check-shebang-scripts-are-executable - id: check-yaml - id: check-json - id: check-toml - id: check-merge-conflict - id: check-ast - repo: https://github.com/psf/black rev: 24.1.1 hooks: - id: black - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: - id: isort - repo: https://github.com/pycqa/flake8 rev: 7.0.0 hooks: - id: flake8 muttdown-muttdown-0.4.0/CHANGES.md000066400000000000000000000017231462223315600166540ustar00rootroot000000000000000.4.0 ===== - Drop support for Python <3.6 - Switch to github actions for CI - Reformat entire project with `black` and add `pre-commit` - Add `assume_markdown` config option 0.3.5 ===== - Fix some unicode handling (including, hopefully, fixing non-ASCII subject lines for real) - Drops support for Python 3.3 and Python 3.4 since we depend on libraries that have dropped support for them - Add support for Python 3.7 and Python 3.8 0.3.4 ===== - Fix regression in headers from 0.3.0 with some multipart/signed messages - Fix regression in passthrough mode from 0.3.3 on Python 2; add better testing 0.3.3 ===== - Fix `-s` / smtp passthrough mode on Python 3 0.3.2 ===== - Fix `smtp_password_command` - Fix tests with newer version of pytest 0.3.1 ===== - Fix an incompatibility with Python 3.5 0.3 === - Add a man page (contribution by @ssgelm) - Split `sendmail` command on whitespace (contribution by @nottwo) - Fix a ton of bugs with MIME tree construction - fix tests muttdown-muttdown-0.4.0/LICENSE.txt000066400000000000000000000013721462223315600171050ustar00rootroot00000000000000Copyright (c) 2015-2024 James Brown Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. muttdown-muttdown-0.4.0/MANIFEST.in000066400000000000000000000000331462223315600170110ustar00rootroot00000000000000include *.txt include *.md muttdown-muttdown-0.4.0/README.md000066400000000000000000000065201462223315600165410ustar00rootroot00000000000000muttdown ======== [![Build Status](https://github.com/Roguelazer/muttdown/actions/workflows/ci.yml/badge.svg)](https://github.com/Roguelazer/muttdown/actions/workflows/ci.yml) `muttdown` is a sendmail-replacement designed for use with the [mutt][] email client which will transparently compile annotated `text/plain` mail into `text/html` using the [Markdown][] standard. It will recursively walk the MIME tree and compile any `text/plain` or `text/markdown` part which begins with the sigil "!m" into Markdown, which it will insert alongside the original in a multipart/alternative container. It's also smart enough not to break `multipart/signed`. For example, the following tree before parsing: - multipart/mixed | -- multipart/signed | ---- text/markdown | ---- application/pgp-signature | -- image/png Will get compiled into - multipart/mixed | -- multipart/alternative | ---- text/html | ---- multipart/signed | ------ text/markdown | ------ application/pgp-signature | -- image/png Configuration ------------- Muttdown's configuration file is written using [YAML][]. Example: smtp_host: smtp.gmail.com smtp_port: 587 smtp_ssl: false smtp_username: foo@bar.com smtp_password: foo css_file: ~/.muttdown.css assume_markdown: false If you prefer not to put your password in plaintext in a configuration file, you can instead specify the `smtp_password_command` parameter to invoke a shell command to lookup your password. The command should output your password, followed by a newline, and no other text. On OS X, the following invocation will extract a generic "Password" entry with the application set to "mutt" and the title set to "foo@bar.com": smtp_password_command: security find-generic-password -w -s mutt -a foo@bar.com NOTE: If `smtp_ssl` is set to False, `muttdown` will do a non-SSL session and then invoke `STARTTLS`. If `smtp_ssl` is set to True, `muttdown` will do an SSL session from the get-go. There is no option to send mail in plaintext. The `css_file` should be regular CSS styling blocks; we use [pynliner][] to inline all CSS rules for maximum client compatibility. Muttdown can also send its mail using the native `sendmail` if you have that set up (instead of doing SMTP itself). To do so, just leave the smtp options in the config file blank, set the `sendmail` option to the fully-qualified path to your `sendmail` binary, and run muttdown with the `-s` flag If `assume_markdown` is true, then all input is assumed to be Markdown by default and the `!m` sigil does nothing. Installation ------------ Install muttdown with `pip install muttdown` or by downloading this package and running `python setup.py install`. You will need the [PyYAML][] and [Python-Markdown][] libraries, as specified in `requirements.txt`. This should work with Python 3.6+. Usage ----- Invoke as muttdown -c /path/to/config -f "from_address" -- "to_address" [more to addresses...] Send a RFC822 formatted mail on stdin. If the config path is not passed, it will assume `~/.muttdown.yaml`. [Markdown]: http://daringfireball.net/projects/markdown/ [YAML]: http://yaml.org [PyYAML]: http://pyyaml.org [Python-Markdown]: https://pypi.python.org/pypi/Markdown [mutt]: http://www.mutt.org [pynliner]: https://github.com/rennat/pynliner muttdown-muttdown-0.4.0/man/000077500000000000000000000000001462223315600160325ustar00rootroot00000000000000muttdown-muttdown-0.4.0/man/muttdown.1000066400000000000000000000067521462223315600200070ustar00rootroot00000000000000.TH muttdown "1" "August 2018" .SH NAME muttdown - Sendmail replacement that compiles markdown into HTML .SH SYNOPSIS .B muttdown [-c \fIconfig_file\fR] [-p] -f \fIfrom_address\fR [-s] \fIto_address\fR ... .br .B muttdown [-h] .SH DESCRIPTION \fBmuttdown\fR is a sendmail-replacement designed for use with the mutt email client which will transparently compile annotated \fItext/plain\fR mail into \fItext/html\fR using the Markdown standard. .P It expects a RFC\-822 formatted mail on STDIN. .P It will recursively walk the MIME tree and compile any \fItext/plain\fR or \fItext/markdown\fR part which begins with the sigil "!m" into Markdown, which it will insert alongside the original in a multipart/alternative container. .P It's also smart enough not to break \fImultipart/signed\fR. .P For example, the following tree before parsing: .IP - multipart/mixed | -- multipart/signed | ---- text/markdown | ---- application/pgp-signature | -- image/png .P Will get compiled into: .IP - multipart/mixed | -- multipart/alternative | ---- text/html | ---- multipart/signed | ------ text/markdown | ------ application/pgp-signature | -- image/png .SH OPTIONS .TP \fB\-c\fR \fI\,CONFIG_FILE\/\fR, \fB\-\-config_file\fR \fI\,CONFIG_FILE\/\fR Path to YAML config file (default \fI~/.muttdown.yaml\fR) .TP \fB\-p\fR, \fB\-\-print\-message\fR Print the translated message to stdout instead of sending it .TP \fB\-f\fR \fI\,from_address\/\fR, \fB\-\-envelope\-from\fR \fI\,from_address\/\fR The \fIfrom\fR address for the email .TP \fB\-s\fR, \fB\-\-sendmail\-passthru\fR Pass mail through to \fBsendmail\fR for delivery .TP \fBto_address\fR The \fIto\fR address where the email is being sent .SH CONFIGURATION Muttdown's configuration file is written using YAML. Example: .IP smtp_host: smtp.gmail.com .br smtp_port: 587 .br smtp_ssl: false .br smtp_username: foo@bar.com .br smtp_password: foo .br css_file: ~/.muttdown.css .br assume_markdown: false .P If you prefer not to put your password in plaintext in a configuration file, you can instead specify the \fBsmtp_password_command\fR parameter to invoke a shell command to lookup your password. The command should output your password, followed by a newline, and no other text. On OS X, the following invocation will extract a generic "Password" entry with the application set to \fImutt\fR and the title set to \fIfoo@bar.com\fR: .IP smtp_password_command: security find-generic-password -w -s mutt -a foo@bar.com .P \fBNOTE:\fR If \fBsmtp_ssl\fR is set to \fIFalse\fR, muttdown will do a non-SSL session and then invoke STARTTLS. If \fBsmtp_ssl\fR is set to \fITrue\fR, \fBmuttdown\fR will do an SSL session from the get-go. There is no option to send mail in plaintext. .P The \fBcss_file\fR should be regular CSS styling blocks; we use \fBpynliner\fR to inline all CSS rules for maximum client compatibility. .P \fBmuttdown\fR can also send its mail using the native \fBsendmail\fR if you have that set up (instead of doing SMTP itself). To do so, just leave the smtp options in the config file blank, set the \fBsendmail\fR option to the fully-qualified path to your \fBsendmail\fR binary, and run \fBmuttdown\fR with the \fB-s\fR flag .P If \fBassume_markdown\fR is true, then all input is assumed to be Markdown by default and the \fB!m\fR sigil does nothing. .SH AUTHORS \fBmuttdown\fR was written by James Brown . .P This man page was adapted from \fBmuttdown\fR's README by Stephen Gelman for the Debian project and may be used by others. muttdown-muttdown-0.4.0/muttdown/000077500000000000000000000000001462223315600171405ustar00rootroot00000000000000muttdown-muttdown-0.4.0/muttdown/__init__.py000066400000000000000000000001721462223315600212510ustar00rootroot00000000000000version_info = (0, 3, 5) __version__ = ".".join(map(str, version_info)) __author__ = "James Brown " muttdown-muttdown-0.4.0/muttdown/__main__.py000066400000000000000000000000751462223315600212340ustar00rootroot00000000000000import sys from muttdown.main import main sys.exit(main()) muttdown-muttdown-0.4.0/muttdown/config.py000066400000000000000000000063341462223315600207650ustar00rootroot00000000000000import copy import os.path from subprocess import check_output import six import yaml # largely copied from my earlier work in fakemtpd def _param_getter_factory(parameter): def f(self): return self._config[parameter] f.__name__ = parameter return f class _ParamsAsProps(type): """Create properties on the classes that apply this for everything in cls._parameters which read out of self._config. Cool fact: you can override any of these properties by just defining your own with the same name. Just like if they were statically defined!""" def __new__(clsarg, name, bases, d): cls = super(_ParamsAsProps, clsarg).__new__(clsarg, name, bases, d) for parameter in cls._parameters.keys(): if parameter not in d: f = _param_getter_factory(parameter) setattr(cls, parameter, property(f)) return cls class ConfigError(Exception): def __init__(self, message): self.message = message def __repr__(self): return "%s(%r)" % (self.__class__.__name__, self.message) def __str__(self): return "%s(%r)" % (self.__class__.__name__, self.message) @six.add_metaclass(_ParamsAsProps) class Config(object): _parameters = { "smtp_host": "127.0.0.1", "smtp_port": 25, "smtp_ssl": True, # if false, do STARTTLS "smtp_username": "", "smtp_password": None, "smtp_password_command": None, "smtp_timeout": 10, "css_file": None, "sendmail": "/usr/sbin/sendmail", "assume_markdown": False, } def __init__(self): self._config = copy.copy(self._parameters) self._css = None def merge_config(self, d): invalid_keys = set(d.keys()) - set(self._config.keys()) if invalid_keys: raise ConfigError( "Unexpected config keys: %s" % ", ".join(sorted(invalid_keys)) ) for key in self._config: if key in d: self._config[key] = d[key] if self._config["smtp_password"] and self._config["smtp_password_command"]: raise ConfigError("Cannot set smtp_password *and* smtp_password_command") if self._config["css_file"]: self._css = None self._config["css_file"] = os.path.expanduser(self._config["css_file"]) if not os.path.exists(self._config["css_file"]): raise ConfigError( "CSS file %s does not exist" % self._config["css_file"] ) def load(self, fobj): d = yaml.safe_load(fobj) self.merge_config(d) @property def css(self): if self._css is None: if self.css_file is not None: with open(os.path.expanduser(self.css_file), "r") as f: self._css = f.read() else: self._css = "" return self._css @property def smtp_password(self): if self._config["smtp_password_command"]: return check_output( self._config["smtp_password_command"], shell=True, universal_newlines=True, ).rstrip("\n") else: return self._config["smtp_password"] muttdown-muttdown-0.4.0/muttdown/debug.py000066400000000000000000000001421462223315600205750ustar00rootroot00000000000000import email.iterators import sys email.iterators._structure(email.message_from_file(sys.stdin)) muttdown-muttdown-0.4.0/muttdown/main.py000066400000000000000000000165771462223315600204560ustar00rootroot00000000000000from __future__ import print_function import argparse import email import email.iterators import os.path import re import smtplib import subprocess import sys from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText import markdown import pynliner import six from . import __version__, config __name__ = "muttdown" def get_charset_from_message_fragment(part): cs = part.get_charset() if cs: return cs.output_charset return None def convert_one(part, config, charset): text = part.get_payload(decode=True) if part.get_charset(): charset = get_charset_from_message_fragment(part) if not isinstance(text, six.text_type): # decode=True only decodes the base64/uuencoded nature, and # will always return bytes; gotta decode it if charset is not None: text = text.decode(charset) else: try: text = text.decode("ascii") except UnicodeError: # this is because of message.py:278 and seems like a hack text = text.decode("raw-unicode-escape") if not config.assume_markdown: if not text.startswith("!m"): return None text = re.sub(r"\s*!m\s*", "", text, re.M) if "\n-- \n" in text: pre_signature, signature = text.split("\n-- \n") md = markdown.markdown( pre_signature, extensions=["extra"], output_format="html5" ) md += '\n

--
' md += "
".join(signature.split("\n")) md += "

" else: md = markdown.markdown(text, extensions=["extra"]) if config.css: md = "" + md md = pynliner.fromString(md) message = MIMEText(md, "html", _charset="UTF-8") return message def _move_headers(source, dest): for k, v in source.items(): # mutt sometimes sticks in a fake bcc header if k.lower() == "bcc": del source[k] elif not (k.startswith("Content-") or k.startswith("MIME")): dest.add_header(k, v) del source[k] def convert_tree(message, config, indent=0, wrap_alternative=True, charset=None): """Recursively convert a potentially-multipart tree. Returns a tuple of (the converted tree, whether any markdown was found) """ ct = message.get_content_type() cs = message.get_content_subtype() if charset is None: charset = get_charset_from_message_fragment(message) if not message.is_multipart(): # we're on a leaf converted = None disposition = message.get("Content-Disposition", "inline") if disposition == "inline" and ct in ("text/plain", "text/markdown"): converted = convert_one(message, config, charset) if converted is not None: if wrap_alternative: new_tree = MIMEMultipart("alternative") _move_headers(message, new_tree) new_tree.attach(message) new_tree.attach(converted) return new_tree, True else: return converted, True return message, False else: if ct == "multipart/signed": # if this is a multipart/signed message, then let's just # recurse into the non-signature part new_root = MIMEMultipart("alternative") if message.preamble: new_root.preamble = message.preamble _move_headers(message, new_root) converted = None for part in message.get_payload(): if part.get_content_type() != "application/pgp-signature": converted, did_conversion = convert_tree( part, config, indent=indent + 1, wrap_alternative=False, charset=charset, ) if did_conversion: new_root.attach(converted) new_root.attach(message) return new_root, did_conversion else: did_conversion = False new_root = MIMEMultipart(cs, message.get_charset()) if message.preamble: new_root.preamble = message.preamble _move_headers(message, new_root) for part in message.get_payload(): part, did_this_conversion = convert_tree( part, config, indent=indent + 1, charset=charset ) did_conversion |= did_this_conversion new_root.attach(part) return new_root, did_conversion def process_message(mail, config): converted, did_any_markdown = convert_tree(mail, config) if "Bcc" in converted: del converted["Bcc"] return converted def smtp_connection(c): """Create an SMTP connection from a Config object""" if c.smtp_ssl: klass = smtplib.SMTP_SSL else: klass = smtplib.SMTP conn = klass(c.smtp_host, c.smtp_port, timeout=c.smtp_timeout) if not c.smtp_ssl: conn.ehlo() conn.starttls() conn.ehlo() if c.smtp_username: conn.login(c.smtp_username, c.smtp_password) return conn def read_message(): return sys.stdin.read() def main(argv=None): parser = argparse.ArgumentParser(prog="muttdown") parser.add_argument( "-v", "--version", action="version", version="%s %s" % (__name__, __version__) ) parser.add_argument( "-c", "--config_file", default=os.path.expanduser("~/.muttdown.yaml"), type=argparse.FileType("r"), required=False, help="Path to YAML config file (default %(default)s)", ) parser.add_argument( "-p", "--print-message", action="store_true", help="Print the translated message to stdout instead of sending it", ) parser.add_argument("-f", "--envelope-from", required=True) parser.add_argument( "-s", "--sendmail-passthru", action="store_true", help="Pass mail through to sendmail for delivery", ) parser.add_argument("addresses", nargs="+") args = parser.parse_args(argv) c = config.Config() try: c.load(args.config_file) except config.ConfigError as e: sys.stderr.write("Error(s) in configuration %s:\n" % args.config_file.name) sys.stderr.write(" - %s\n" % e.message) sys.stderr.flush() return 1 message = read_message() mail = email.message_from_string(message) rebuilt = process_message(mail, c) rebuilt.set_unixfrom(args.envelope_from) if args.print_message: print(rebuilt.as_string()) elif args.sendmail_passthru: cmd = c.sendmail.split() + ["-f", args.envelope_from] + args.addresses proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, shell=False) msg = rebuilt.as_string() if sys.version_info > (3, 0): msg = msg.encode("utf-8") proc.stdin.write(msg) proc.stdin.close() proc.wait() return proc.returncode else: conn = smtp_connection(c) msg = rebuilt.as_string() if sys.version_info > (3, 0): msg = msg.encode("utf-8") conn.sendmail(args.envelope_from, args.addresses, msg) conn.quit() return 0 if __name__ == "__main__": sys.exit(main()) muttdown-muttdown-0.4.0/requirements-tests.txt000066400000000000000000000000551462223315600217030ustar00rootroot00000000000000pytest==8.* pytest-cov==5.* pytest-mock==3.* muttdown-muttdown-0.4.0/setup.cfg000066400000000000000000000000351462223315600170760ustar00rootroot00000000000000[flake8] max-line-length=120 muttdown-muttdown-0.4.0/setup.py000066400000000000000000000026401462223315600167730ustar00rootroot00000000000000from setuptools import find_packages, setup with open("README.md", "r") as f: long_description = f.read() setup( name="muttdown", version="0.4.0", author="James Brown", author_email="roguelazer@roguelazer.com", url="https://github.com/Roguelazer/muttdown", license="ISC", packages=find_packages(exclude=["tests"]), keywords=["email"], description="Sendmail replacement that compiles markdown into HTML", long_description=long_description, long_description_content_type="text/markdown", install_requires=[ "Markdown>=3.0,<4.0", "PyYAML>=3.0", "pynliner==0.8.0", "six", ], entry_points={ "console_scripts": [ "muttdown = muttdown.main:main", ] }, python_requires=">=3.6", classifiers=[ "Development Status :: 3 - Alpha", "Environment :: Console", "Programming Language :: Python", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Intended Audience :: End Users/Desktop", "Operating System :: OS Independent", "License :: OSI Approved :: ISC License (ISCL)", "Topic :: Communications :: Email", ], ) muttdown-muttdown-0.4.0/tests/000077500000000000000000000000001462223315600164215ustar00rootroot00000000000000muttdown-muttdown-0.4.0/tests/__init__.py000066400000000000000000000000001462223315600205200ustar00rootroot00000000000000muttdown-muttdown-0.4.0/tests/data/000077500000000000000000000000001462223315600173325ustar00rootroot00000000000000muttdown-muttdown-0.4.0/tests/data/cert.pem000066400000000000000000000017001462223315600207700ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIICmDCCAYACCQC7muxZ8ym2UDANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJV UzAgFw0xOTAyMDkwMzA0MDdaGA8yMTE5MDExNjAzMDQwN1owDTELMAkGA1UEBhMC VVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCa7UukN2cZcltM5ajH NVH7YeWGsiJjmobg1QQaf5AgCP1TC2WckN7cbmAp5nR8Ie2y8p4hu0dSziCLap6M txtTJJ87IoTkLvVv2ZIUJBJ00xD3SlEKNDv/532PTnObDUsRzdDRXcKW3PKqprFr kS1UKHgyR/U4pOENdRk5zN2Jkv5A/fWc2nkDwhYXInqW26WyxDJkamInRZF2iW6Y t88QMnHtEtNrf1rom4UvCPTmZqh/9Pm6uhIHi7/aanSSnCasOfKd1wUTuk3wAOzQ SxhVVEXPVHSI0yPQRDKm5O8VSSjRr0WC1ooljJarfrs8sLECTWpQOetlW2YlUjJf udUvAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAJchLG3aIRnjcZU5unEu3icxVayi OiUWqLPTZsNc4vVKn+h8HP3TeTUSWUMHVp7OqmVvbMeLDZJm7JBMklr1RYBgeGi7 HeJzG3q/9O5Ny1CZ3Rok/mxncZlDKKG0Z61aBD8rzkOooQFIa1KAPNdkfBbnfuir Lo83Y8Sy8OMA8yjatMxDt0sjhXJ++F83Tki9EMKEMhAcbY3nC5Df1c25va3O1IPH 5GkDb97lsey5UQ559XmHSBl+w8yZNRtN4k1Vq/aZuKCbyci9ogczU7eIjkawxhQ7 X+O0ZgVqfLqrdCjnLYf7Gz3cEfYhaIfwQc9i6kOTyPZ/oQCz1E7Lf9KOI0c= -----END CERTIFICATE----- muttdown-muttdown-0.4.0/tests/data/key.pem000066400000000000000000000032501462223315600206250ustar00rootroot00000000000000-----BEGIN PRIVATE KEY----- MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCa7UukN2cZcltM 5ajHNVH7YeWGsiJjmobg1QQaf5AgCP1TC2WckN7cbmAp5nR8Ie2y8p4hu0dSziCL ap6MtxtTJJ87IoTkLvVv2ZIUJBJ00xD3SlEKNDv/532PTnObDUsRzdDRXcKW3PKq prFrkS1UKHgyR/U4pOENdRk5zN2Jkv5A/fWc2nkDwhYXInqW26WyxDJkamInRZF2 iW6Yt88QMnHtEtNrf1rom4UvCPTmZqh/9Pm6uhIHi7/aanSSnCasOfKd1wUTuk3w AOzQSxhVVEXPVHSI0yPQRDKm5O8VSSjRr0WC1ooljJarfrs8sLECTWpQOetlW2Yl UjJfudUvAgMBAAECggEAfm6G21XnSmn7vk5xpViLNfYXZQv8aoKR7euI9MMDcFFF wr67RsEnToa47ZjHmQHrRK0ghXCbbSUQhBYXm8hWgUySsaSjBMCZxZSt1Mf3U+Vn pBe++O/Vwyo8WnXwfCmmCLqI3kOA6LMZSlDM23bXoiWAqa/1nCtaCix00KmyZXEN 9x/2OKLorIRn3ewhY+kplhYt/P6MUlPUpPeX1RBMoF31iPFNcSiwwZSZjsK/nUI5 MnZJT4NxcmZfBrP7RUBHZy5WFOlKQ0KO+zlYjXN+R0ZGuCvFAnuDtK37/BHQYc51 gAqcdLCKnjOF00IhQNGnorEKMYvqIIgVIv39bGp2CQKBgQDLBPaE4Zz/CFL/Hqum mlzMk/Oy+cixZJ3LYnLlptnHKTu8PkzoAH0fSRfRYuOBLuYhhhP3iCqnPBAQ0yu+ 48nMothMrbe4OCe+kUiQ/9VUyRLwr1sh0yFcZJBawstcpOX7YTfgJS3qtvQS4rJG 1FDrekBpz3Sgj1us5vya2e/7WwKBgQDDW3BZxuZMGJkeZdKpYrqp2hDrdn/t5lkb TKs9fbEf8SMcQSlYJvoCjmy55NCW04TEFnPSwj/XNhwOPNDxurHMxy3+vfKbchsq 4vnqowpBCdZ1Q7osS9xlzqvLPqET3WXATtcYoYXMjSq5o5tzwcmiSoQnkDnaAnXZ HeyD4aI5vQKBgBLTQfyuYv1vCysm7+nB9IrvyTA2Yzq3xr3+QgMzhowmMajR6hW1 PeTxxSigT9JBxAslwKI6WSIqup6kxjCsNKEqFH5/uUJ2ypCsLhtr7Z8wCfaRfBTV 3AkSNiSEXZEYpU67BBBfwjM6hcVeigNxWpOLQX/OQdVFlc2hmZjOTqdzAoGASRN0 RHDthruQ01kdYzVGQ/EJcTrjgdcvr9GPILJaxmsKSjBpycrSrJAgRa09BZ5bxInt i4IUJWndNsozEqlWhxZeszLUhKc7WGCNQeL5G/kVGspZ4uYBrKeRhbaIxIiF3ljf hxwsk6aeu9BifvuXdDjRlIcTzOQstynFZlPJvjUCgYBi7b/aCGtACAYL0wI/l4VJ iHRghC7/E/wm6Fdu4DT3lUAFYyzV2X30e9Hy9RKGxRB4CHUA4hJ3TIjhCtrg0fA3 +StWD+BqCO8NhiPOQzdCDsElsefMeS70iNg2+vxXn6Vw2QcW0d5G1bz44zH4DVuL mLrBMIq8BZ6qJ8OrylCoMg== -----END PRIVATE KEY----- muttdown-muttdown-0.4.0/tests/test_basic.py000066400000000000000000000270461462223315600211240ustar00rootroot00000000000000# -*- coding: utf-8 -*- import email.message import os import select import shutil import socket import ssl import sys import tempfile import threading import time from email.message import Message from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText import pytest import yaml from muttdown import main from muttdown.config import Config from muttdown.main import convert_tree, process_message @pytest.fixture def basic_config(): return Config() @pytest.fixture def tempdir(): # workaround because pytest's bultin tmpdir fixture is broken on python 3.3 dirname = tempfile.mkdtemp() try: yield dirname finally: shutil.rmtree(dirname) @pytest.fixture def config_with_css(tempdir): with open("%s/test.css" % tempdir, "w") as f: f.write("html, body, p { font-family: serif; }\n") c = Config() c.merge_config({"css_file": "%s/test.css" % tempdir}) return c def test_unmodified_no_match(basic_config): msg = Message() msg["Subject"] = "Test Message" msg["From"] = "from@example.com" msg["To"] = "to@example.com" msg["Bcc"] = "bananas" msg.set_payload("This message has no sigil") converted = process_message(msg, basic_config) assert converted == msg def test_simple_message(basic_config): msg = MIMEMultipart() msg["Subject"] = "Test Message" msg["From"] = "from@example.com" msg["To"] = "to@example.com" msg["Bcc"] = "bananas" msg.preamble = "Outer preamble" msg.attach(MIMEText("!m This is the main message body")) attachment = MIMEText("this is an attachment", "x-misc") attachment.add_header("Content-Disposition", "attachment") msg.attach(attachment) converted, _ = convert_tree(msg, basic_config) assert converted["Subject"] == "Test Message" assert converted["From"] == "from@example.com" assert converted["To"] == "to@example.com" assert converted.get("Bcc", None) is None assert isinstance(converted, MIMEMultipart) assert converted.preamble == "Outer preamble" assert len(converted.get_payload()) == 2 alternatives_part = converted.get_payload()[0] assert isinstance(alternatives_part, MIMEMultipart) assert alternatives_part.get_content_type() == "multipart/alternative" assert len(alternatives_part.get_payload()) == 2 text_part = alternatives_part.get_payload()[0] html_part = alternatives_part.get_payload()[1] assert isinstance(text_part, MIMEText) assert text_part.get_content_type() == "text/plain" assert isinstance(html_part, MIMEText) assert html_part.get_content_type() == "text/html" attachment_part = converted.get_payload()[1] assert isinstance(attachment_part, MIMEText) assert attachment_part["Content-Disposition"] == "attachment" assert attachment_part.get_content_type() == "text/x-misc" def test_with_css(config_with_css): msg = Message() msg["Subject"] = "Test Message" msg["From"] = "from@example.com" msg["To"] = "to@example.com" msg["Bcc"] = "bananas" msg.set_payload("!m\n\nThis is a message") converted, _ = convert_tree(msg, config_with_css) assert isinstance(converted, MIMEMultipart) assert len(converted.get_payload()) == 2 text_part = converted.get_payload()[0] assert text_part.get_payload(decode=True) == b"!m\n\nThis is a message" html_part = converted.get_payload()[1] assert ( html_part.get_payload(decode=True) == b'

This is a message

' ) def test_fenced(basic_config): msg = Message() msg["Subject"] = "Test Message" msg["From"] = "from@example.com" msg["To"] = "to@example.com" msg["Bcc"] = "bananas" msg.preamble = "Outer preamble" msg.set_payload("!m This is the main message body\n\n```\nsome code\n```\n") converted, _ = convert_tree(msg, basic_config) assert isinstance(converted, MIMEMultipart) assert len(converted.get_payload()) == 2 html_part = converted.get_payload()[1] assert ( html_part.get_payload(decode=True) == b"

This is the main message body

\n
some code\n
" ) def test_headers_when_multipart_signed(basic_config): msg = MIMEMultipart("signed") msg["Subject"] = "Test Message" msg["From"] = "from@example.com" msg["To"] = "to@example.com" msg["Bcc"] = "bananas" msg.preamble = "Outer preamble" msg.attach(MIMEText("!m This is the main message body")) msg.attach(MIMEApplication("signature here", "pgp-signature", name="signature.asc")) converted, _ = convert_tree(msg, basic_config) assert converted["Subject"] == "Test Message" assert converted["From"] == "from@example.com" assert converted["To"] == "to@example.com" assert isinstance(converted, MIMEMultipart) assert converted.preamble == "Outer preamble" assert len(converted.get_payload()) == 2 assert converted.get_content_type() == "multipart/alternative" html_part = converted.get_payload()[0] original_signed_part = converted.get_payload()[1] assert isinstance(html_part, MIMEText) assert html_part.get_content_type() == "text/html" assert isinstance(original_signed_part, MIMEMultipart) assert original_signed_part.get_content_type() == "multipart/signed" assert original_signed_part["Subject"] is None text_part = original_signed_part.get_payload()[0] signature_part = original_signed_part.get_payload()[1] assert text_part.get_content_type() == "text/plain" assert signature_part.get_content_type() == "application/pgp-signature" class MockSmtpServer(object): def __init__(self): self._s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._s.bind(("127.0.0.1", 0)) self.address = self._s.getsockname()[0:2] self._t = None self._started = threading.Event() self.messages = [] self.running = False def start(self): self._t = threading.Thread(target=self.run) self._t.start() if self._started.wait(5) is not True: raise ValueError("SMTP Server Thread failed to start!") def run(self): if hasattr(ssl, "create_default_context"): context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) else: context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) context.load_cert_chain( certfile="tests/data/cert.pem", keyfile="tests/data/key.pem" ) self._s.listen(128) self._started.set() self.running = True while self.running: r, _, x = select.select([self._s], [self._s], [self._s], 0.5) if r: start = time.time() conn, addr = self._s.accept() conn = context.wrap_socket(conn, server_side=True) message = b"" conn.sendall(b"220 localhost SMTP Fake\r\n") message += conn.recv(1024) conn.sendall(b"250-localhost\r\n250 DSN\r\n") # MAIL FROM message += conn.recv(1024) conn.sendall(b"250 2.1.0 Ok\r\n") # RCPT TO message += conn.recv(1024) conn.sendall(b"250 2.1.0 Ok\r\n") # DATA message += conn.recv(6) conn.sendall(b"354 End data with .\r\n") while time.time() < start + 5: chunk = conn.recv(4096) if not chunk: break message += chunk if b"\r\n.\r\n" in message: break conn.sendall(b"250 2.1.0 Ok\r\n") message += conn.recv(1024) conn.sendall(b"221 Bye\r\n") conn.close() self.messages.append((addr, message)) def stop(self): if self._t is not None: self.running = False self._t.join() @pytest.fixture def smtp_server(): s = MockSmtpServer() s.start() try: yield s finally: s.stop() def test_main_smtplib(tempdir, smtp_server, mocker): config_path = os.path.join(tempdir, "config.yaml") with open(config_path, "w") as f: yaml.dump( { "smtp_host": smtp_server.address[0], "smtp_port": smtp_server.address[1], "smtp_ssl": True, }, f, ) msg = Message() msg["Subject"] = "Test Message" msg["From"] = "from@example.com" msg["To"] = "to@example.com" msg["Bcc"] = "bananas" msg.set_payload("This message has no sigil") mocker.patch.object(main, "read_message", return_value=msg.as_string()) main.main(["-c", config_path, "-f", "from@example.com", "to@example.com"]) assert len(smtp_server.messages) == 1 attr, transcript = smtp_server.messages[0] assert b"Subject: Test Message" in transcript assert b"no sigil" in transcript def test_main_passthru(tempdir, mocker): output_path = os.path.join(tempdir, "output") sendmail_path = os.path.join(tempdir, "sendmail") with open(sendmail_path, "w") as f: f.write("#!{0}\n".format(sys.executable)) f.write("import sys\n") f.write('output_path = "{0}"\n'.format(output_path)) f.write('open(output_path, "w").write(sys.stdin.read())\n') f.write("sys.exit(0)") os.chmod(sendmail_path, 0o750) config_path = os.path.join(tempdir, "config.yaml") with open(config_path, "w") as f: yaml.dump({"sendmail": sendmail_path}, f) msg = Message() msg["Subject"] = "Test Message" msg["From"] = "from@example.com" msg["To"] = "to@example.com" msg["Bcc"] = "bananas" msg.set_payload("This message has no sigil") mocker.patch.object(main, "read_message", return_value=msg.as_string()) main.main(["-c", config_path, "-f", "from@example.com", "-s", "to@example.com"]) with open(output_path, "rb") as f: transcript = f.read() assert b"Subject: Test Message" in transcript assert b"no sigil" in transcript def test_raw_unicode(basic_config): raw_message = b"Date: Fri, 1 Mar 2019 17:54:06 -0800\nFrom: Test \nTo: Test \nSubject: Re: Fwd: Important: 2019 =?utf-8?Q?Securit?=\n =?utf-8?B?eSBVcGRhdGUg4oCU?=\nReferences: \n \nMIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8\nContent-Disposition: inline\nContent-Transfer-Encoding: 8bit\nUser-Agent: Mutt/1.11.3 (2019-02-01)\n\nThis is a test\n\n\nOn Fri, Mar 01, 2019 at 03:08:35PM -0800, Test Wrote:\n> :)\n> \n> \n> \xc3\x98 Text\n> \n> \xc2\xb7 text\n-- \nend\n" # noqa if sys.version_info > (3, 0): mail = email.message_from_string(raw_message.decode("utf-8")) else: mail = email.message_from_string(raw_message) converted = process_message(mail, basic_config) assert converted["From"] == "Test " assert "Ø" in converted.get_payload() def test_assume_markdown(basic_config): msg = Message() msg["Subject"] = "Test Message" msg["From"] = "from@example.com" msg["To"] = "to@example.com" msg["Bcc"] = "bananas" msg.set_payload("This message has no **sigil**") basic_config.merge_config({"assume_markdown": True}) converted = process_message(msg, basic_config) html_part = converted.get_payload()[1].get_payload(decode=True) assert html_part == b"

This message has no sigil

" muttdown-muttdown-0.4.0/tests/test_config.py000066400000000000000000000015331462223315600213010ustar00rootroot00000000000000import tempfile from muttdown.config import Config def test_smtp_password_literal(): c = Config() c.merge_config({"smtp_password": "foo"}) assert c.smtp_password == "foo" def test_smtp_password_command(): c = Config() c.merge_config({"smtp_password_command": 'sh -c "echo foo"'}) assert c.smtp_password == "foo" def test_css(): c = Config() c.merge_config({"css_file": None}) assert c.css == "" with tempfile.NamedTemporaryFile(delete=True) as css_file: css_file.write(b"html { background-color: black; }\n") css_file.flush() c.merge_config({"css_file": css_file.name}) assert c.css == "html { background-color: black; }\n" def test_assume_markdown(): c = Config() assert not c.assume_markdown c.merge_config({"assume_markdown": True}) assert c.assume_markdown